Merge remote-tracking branch 'origin/develop' into next-embeds

api-accept
Alex Gleason 2 years ago
commit 0d0d12489e
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7

@ -25,6 +25,7 @@ module.exports = {
'import', 'import',
'promise', 'promise',
'react-hooks', 'react-hooks',
'@typescript-eslint',
], ],
parserOptions: { parserOptions: {
@ -104,7 +105,8 @@ module.exports = {
'no-undef': 'error', 'no-undef': 'error',
'no-unreachable': 'error', 'no-unreachable': 'error',
'no-unused-expressions': 'error', 'no-unused-expressions': 'error',
'no-unused-vars': [ 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error', 'error',
{ {
vars: 'all', vars: 'all',
@ -141,6 +143,7 @@ module.exports = {
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
'react/jsx-indent': ['error', 2], 'react/jsx-indent': ['error', 2],
// 'react/jsx-no-bind': ['error'], // 'react/jsx-no-bind': ['error'],
'react/jsx-no-comment-textnodes': 'error',
'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-duplicate-props': 'error',
'react/jsx-no-undef': 'error', 'react/jsx-no-undef': 'error',
'react/jsx-tag-spacing': 'error', 'react/jsx-tag-spacing': 'error',
@ -149,7 +152,6 @@ module.exports = {
'react/jsx-wrap-multilines': 'error', 'react/jsx-wrap-multilines': 'error',
'react/no-multi-comp': 'off', 'react/no-multi-comp': 'off',
'react/no-string-refs': 'error', 'react/no-string-refs': 'error',
'react/prop-types': 'error',
'react/self-closing-comp': 'error', 'react/self-closing-comp': 'error',
'jsx-a11y/accessible-emoji': 'warn', 'jsx-a11y/accessible-emoji': 'warn',
@ -256,14 +258,12 @@ module.exports = {
'promise/catch-or-return': 'error', 'promise/catch-or-return': 'error',
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
}, },
overrides: [ overrides: [
{ {
files: ['**/*.ts', '**/*.tsx'], files: ['**/*.ts', '**/*.tsx'],
rules: { rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899 'no-undef': 'off', // https://stackoverflow.com/a/69155899
'react/prop-types': 'off',
}, },
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
}, },

@ -0,0 +1,5 @@
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: soapbox-pub
liberapay: soapbox
custom: "https://soapbox.pub/donate/"

@ -1,4 +1,4 @@
image: node:14 image: node:16
variables: variables:
NODE_ENV: test NODE_ENV: test

@ -1 +1 @@
nodejs 14.17.6 nodejs 16.14.2

@ -38,7 +38,7 @@ Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Si
It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS. It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS.
It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest).
It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs-develop.pleroma.social/backend/API/differences_in_mastoapi_responses/) in order to function. It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) in order to function.
# Running locally # Running locally

@ -1,5 +1,6 @@
import loadPolyfills from './soapbox/load_polyfills'; import loadPolyfills from './soapbox/load_polyfills';
// @ts-ignore
require.context('./images/', true); require.context('./images/', true);
// Load stylesheet // Load stylesheet

File diff suppressed because one or more lines are too long

@ -9,6 +9,7 @@
<link href="/manifest.json" rel="manifest"> <link href="/manifest.json" rel="manifest">
<!--server-generated-meta--> <!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<%= snippets %>
</head> </head>
<body class="theme-mode-light no-reduce-motion"> <body class="theme-mode-light no-reduce-motion">
<div id="soapbox"> <div id="soapbox">

@ -52,11 +52,11 @@
"audio.play": "Play", "audio.play": "Play",
"audio.unmute": "Unmute", "audio.unmute": "Unmute",
"boost_modal.combo": "You can press {combo} to skip this next time", "boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.body": "Something went wrong while loading this page.",
"bundle_column_error.retry": "Try again", "bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error", "bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close", "bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.retry": "Try again", "bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users", "column.blocks": "Blocked users",
"column.community": "Local timeline", "column.community": "Local timeline",
@ -254,7 +254,7 @@
"login.fields.username_placeholder": "Username", "login.fields.username_placeholder": "Username",
"login.log_in": "Log in", "login.log_in": "Log in",
"login.reset_password_hint": "Trouble logging in?", "login.reset_password_hint": "Trouble logging in?",
"media_gallery.toggle_visible": "Toggle visibility", "media_gallery.toggle_visible": "Hide",
"missing_indicator.label": "Not found", "missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found", "missing_indicator.sublabel": "This resource could not be found",
"morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.", "morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.",
@ -530,11 +530,11 @@
"audio.play": "Play", "audio.play": "Play",
"audio.unmute": "Unmute", "audio.unmute": "Unmute",
"boost_modal.combo": "You can press {combo} to skip this next time", "boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.body": "Something went wrong while loading this page.",
"bundle_column_error.retry": "Try again", "bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error", "bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close", "bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.message": "Something went wrong while loading this page.",
"bundle_modal_error.retry": "Try again", "bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users", "column.blocks": "Blocked users",
"column.community": "Local timeline", "column.community": "Local timeline",
@ -732,7 +732,7 @@
"login.fields.username_placeholder": "Username", "login.fields.username_placeholder": "Username",
"login.log_in": "Log in", "login.log_in": "Log in",
"login.reset_password_hint": "Trouble logging in?", "login.reset_password_hint": "Trouble logging in?",
"media_gallery.toggle_visible": "Toggle visibility", "media_gallery.toggle_visible": "Hide",
"missing_indicator.label": "Not found", "missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found", "missing_indicator.sublabel": "This resource could not be found",
"morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.", "morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.",

@ -0,0 +1,290 @@
{
"account": {
"acct": "Hollahollara@spinster.xyz",
"avatar": "https://gleasonator.com/proxy/LArKQiIrW265rGIJGwdgX7rRsao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvYXZhdGFycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC9lNjA1NjljMjBjNGY3ODNjLnBuZw/e60569c20c4f783c.png",
"avatar_static": "https://gleasonator.com/proxy/LArKQiIrW265rGIJGwdgX7rRsao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvYXZhdGFycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC9lNjA1NjljMjBjNGY3ODNjLnBuZw/e60569c20c4f783c.png",
"bot": false,
"created_at": "2020-05-29T03:15:59.000Z",
"display_name": "Hollahollara",
"emojis": [],
"fields": [],
"followers_count": 0,
"following_count": 0,
"fqn": "Hollahollara@spinster.xyz",
"header": "https://gleasonator.com/proxy/XSANC57uDBL3tM0LBLEer7yMyaA/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC84NTMzMWEzMjJkMTIyN2Q0LnBuZw/85331a322d1227d4.png",
"header_static": "https://gleasonator.com/proxy/XSANC57uDBL3tM0LBLEer7yMyaA/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMTQxLzI5NC9vcmlnaW5hbC84NTMzMWEzMjJkMTIyN2Q0LnBuZw/85331a322d1227d4.png",
"id": "9vWfJdLwuJSyJXqCeG",
"last_status_at": "2022-04-16T20:33:32",
"locked": true,
"note": "Adult human female. Artist. Evil terv. Millennial, killing all the things. Public Universal Friend.<br/><br/><a href=\"http://www.jenniferaldridge.com\">www.jenniferaldridge.com</a><br/><br/><br/>",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://spinster.xyz/users/Hollahollara",
"background_image": null,
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/owo6QgsHm_0ogz5enHyvD68wDUA/aHR0cHM6Ly9zcGluc3Rlci54eXovZmF2aWNvbi5wbmc/favicon.png",
"hide_favorites": true,
"hide_followers": true,
"hide_followers_count": false,
"hide_follows": true,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": false,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 7191,
"url": "https://spinster.xyz/users/Hollahollara",
"username": "Hollahollara"
},
"created_at": "2022-04-14T20:36:52.000Z",
"id": "427825",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"status": {
"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": "<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>"
},
{
"name": "Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
},
{
"name": "Donate (PayPal)",
"value": "<a href=\"https://paypal.me/gleasonator\" rel=\"ugc\">https://paypal.me/gleasonator</a>"
},
{
"name": "$BTC",
"value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
},
{
"name": "$ETH",
"value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
},
{
"name": "$DOGE",
"value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
},
{
"name": "$XMR",
"value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
}
],
"follow_requests_count": 0,
"followers_count": 2602,
"following_count": 1603,
"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-04-16T19:23:50",
"locked": false,
"note": "I create Fediverse software that empowers people online.<br/><br/>I&#39;m vegan btw<br/><br/>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,
"accepts_email_list": true,
"allow_following_move": true,
"also_known_as": [
"https://mitra.social/users/alex"
],
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"birthday": "1993-07-03",
"deactivated": false,
"email": "alex@alexgleason.me",
"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,
"location": "Texas",
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 392,
"unread_notifications_count": 2
},
"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,
"no_rich_text": false,
"show_birthday": true,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 24050,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Soapbox FE",
"website": "https://soapbox.pub/"
},
"bookmarked": false,
"card": null,
"content": "",
"created_at": "2022-04-12T01:31:00.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 11,
"id": "AIMEslRcKrcu02D3HU",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [
{
"blurhash": "etMZzVWq%1%1o#_NayWCofae_Ns:R*kDjYS5a{jYoJj]V@a}WBbGof",
"description": "",
"id": "AIMEqtBeZtvpQvqfIG",
"meta": {
"original": {
"aspect": 0.9726443768996961,
"height": 658,
"width": 640
}
},
"pleroma": {
"mime_type": "image/jpeg"
},
"preview_url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg",
"remote_url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg",
"text_url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg",
"type": "image",
"url": "https://media.gleasonator.com/6c0a1d878b7c9d1d737f415645cf34cdacdf6438c468348f4fa7534a15798023.jpg"
}
],
"mentions": [],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": ""
},
"content_type": null,
"conversation_id": "AIMEslPqRSCzuXNdWC",
"direct_conversation_id": null,
"emoji_reactions": [
{
"count": 4,
"me": false,
"name": "😆"
},
{
"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": null,
"quote_visible": false,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 4,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/7953f9fb-d3d7-4f50-b9d8-27e311ac1f5e",
"url": "https://gleasonator.com/notice/AIMEslRcKrcu02D3HU",
"visibility": "public"
},
"type": "favourite"
}

@ -0,0 +1,61 @@
{
"account": {
"acct": "neko@rdrama.cc",
"avatar": "https://gleasonator.com/proxy/QJ3einzsXdobgWPsyZowxnor1zY/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvODcyNDhjYjctZWYwNC00ZThjLWEwYzEtNTYxNWMyNWM0MTk1L2Jsb2I/blob",
"avatar_static": "https://gleasonator.com/proxy/QJ3einzsXdobgWPsyZowxnor1zY/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvODcyNDhjYjctZWYwNC00ZThjLWEwYzEtNTYxNWMyNWM0MTk1L2Jsb2I/blob",
"bot": false,
"created_at": "2022-04-16T20:23:16.000Z",
"display_name": "Nekobit",
"emojis": [],
"fields": [],
"followers_count": 19,
"following_count": 357,
"fqn": "neko@rdrama.cc",
"header": "https://gleasonator.com/proxy/ojpBSVKfePvLnb7pwqepQspzIko/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvNjBkMTJjOWYtOTNkNi00ODBmLThhMGUtMTE3M2ZkNjg5MzhmL3dhbGxwYXBlcmZsYXJlLmNvbV93YWxscGFwZXItd2ViLmpwZw/wallpaperflare.com_wallpaper-web.jpg",
"header_static": "https://gleasonator.com/proxy/ojpBSVKfePvLnb7pwqepQspzIko/aHR0cHM6Ly9yZHJhbWEuY2MvbWVkaWEvNjBkMTJjOWYtOTNkNi00ODBmLThhMGUtMTE3M2ZkNjg5MzhmL3dhbGxwYXBlcmZsYXJlLmNvbV93YWxscGFwZXItd2ViLmpwZw/wallpaperflare.com_wallpaper-web.jpg",
"id": "AIW9zGESDwdT27vk0W",
"last_status_at": "2022-04-16T21:49:29",
"locked": false,
"note": "New instance, hello!<br/><br/>Please follow if you followed my <a href=\"http://desuposter.club\">desuposter.club</a> alt",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://rdrama.cc/users/neko",
"background_image": null,
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/dbCdmChqVRi0vjYTCpRj5lDLtNM/aHR0cHM6Ly9yZHJhbWEuY2MvZmF2aWNvbi5wbmc/favicon.png",
"hide_favorites": true,
"hide_followers": false,
"hide_followers_count": false,
"hide_follows": false,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": false,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 6,
"url": "https://rdrama.cc/users/neko",
"username": "neko"
},
"created_at": "2022-04-16T20:24:03.000Z",
"id": "429280",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"type": "follow"
}

@ -0,0 +1,61 @@
{
"account": {
"acct": "alex@spinster.xyz",
"avatar": "https://gleasonator.com/images/avi.png",
"avatar_static": "https://gleasonator.com/images/avi.png",
"bot": false,
"created_at": "2020-01-08T03:08:22.000Z",
"display_name": "**MOVED**",
"emojis": [],
"fields": [],
"followers_count": 1005,
"following_count": 724,
"fqn": "alex@spinster.xyz",
"header": "https://gleasonator.com/proxy/yxa7ucolLFAsmBHYJzksSh_zoao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMDAwLzAwMS9vcmlnaW5hbC83ZmE4MWY5ZmZiYWVjZDk3LnBuZw/7fa81f9ffbaecd97.png",
"header_static": "https://gleasonator.com/proxy/yxa7ucolLFAsmBHYJzksSh_zoao/aHR0cHM6Ly9tZWRpYS5zcGluc3Rlci54eXovYWNjb3VudHMvaGVhZGVycy8wMDAvMDAwLzAwMS9vcmlnaW5hbC83ZmE4MWY5ZmZiYWVjZDk3LnBuZw/7fa81f9ffbaecd97.png",
"id": "9v5bmXkCYkqU30gp9s",
"last_status_at": null,
"locked": true,
"note": "Moved to <a href=\"https://spinster.xyz/@alex@gleasonator.com\">https://spinster.xyz/@alex@gleasonator.com</a>",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://spinster.xyz/users/alex",
"background_image": null,
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/owo6QgsHm_0ogz5enHyvD68wDUA/aHR0cHM6Ly9zcGluc3Rlci54eXovZmF2aWNvbi5wbmc/favicon.png",
"hide_favorites": true,
"hide_followers": false,
"hide_followers_count": false,
"hide_follows": false,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": false,
"is_moderator": false,
"is_suggested": false,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 2687,
"url": "https://spinster.xyz/users/alex",
"username": "alex"
},
"created_at": "2020-12-30T02:23:35.000Z",
"id": "87967",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"type": "follow_request"
}

@ -0,0 +1,226 @@
{
"account": {
"acct": "silverpill@mitra.social",
"avatar": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png",
"avatar_static": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png",
"bot": false,
"created_at": "2021-11-11T22:31:51.000Z",
"display_name": "silverpill",
"emojis": [],
"fields": [
{
"name": "Matrix",
"value": "@silverpill:poa.st"
},
{
"name": "Alt",
"value": "@silverpill@poa.st"
},
{
"name": "Code",
"value": "<a href=\"https://codeberg.org/silverpill/\">https://codeberg.org/silverpill/</a>"
},
{
"name": "$XMR",
"value": "884y9LmsWY7PQNsyR7bJy1dvj91tuF5spVabyCnPk4KfQtSuzFbQobTFC7xSemJgVW1FWAwnJbjTZX5zZWbBrfkv62DB62d"
}
],
"followers_count": 0,
"following_count": 0,
"fqn": "silverpill@mitra.social",
"header": "https://gleasonator.com/images/banner.png",
"header_static": "https://gleasonator.com/images/banner.png",
"id": "ADIzJ7q9gExPvDKBCS",
"last_status_at": "2022-04-15T11:27:33",
"locked": false,
"note": "",
"pleroma": {
"accepts_chat_messages": false,
"also_known_as": [],
"ap_id": "https://mitra.social/users/silverpill",
"background_image": null,
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/XSE9_kQbQyYcSFWszWx2GgCbBuY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvZmF2aWNvbi5pY28/favicon.ico",
"hide_favorites": true,
"hide_followers": true,
"hide_followers_count": false,
"hide_follows": true,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": false,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 135,
"url": "https://mitra.social/users/silverpill",
"username": "silverpill"
},
"created_at": "2022-04-15T11:27:33.000Z",
"id": "428172",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"status": {
"account": {
"acct": "silverpill@mitra.social",
"avatar": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png",
"avatar_static": "https://gleasonator.com/proxy/ZbLqy9s8Hxn9I5K23y2mffsL6iY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvbWVkaWEvNmE3ODViZjdkZDA1ZjYxYzM1OTBlODkzNWFhNDkxNTZhNDk5YWMzMGZkMWU0MDJmNzllN2UxNjRhZGIzNmUyYy5wbmc/6a785bf7dd05f61c3590e8935aa49156a499ac30fd1e402f79e7e164adb36e2c.png",
"bot": false,
"created_at": "2021-11-11T22:31:51.000Z",
"display_name": "silverpill",
"emojis": [],
"fields": [
{
"name": "Matrix",
"value": "@silverpill:poa.st"
},
{
"name": "Alt",
"value": "@silverpill@poa.st"
},
{
"name": "Code",
"value": "<a href=\"https://codeberg.org/silverpill/\">https://codeberg.org/silverpill/</a>"
},
{
"name": "$XMR",
"value": "884y9LmsWY7PQNsyR7bJy1dvj91tuF5spVabyCnPk4KfQtSuzFbQobTFC7xSemJgVW1FWAwnJbjTZX5zZWbBrfkv62DB62d"
}
],
"followers_count": 0,
"following_count": 0,
"fqn": "silverpill@mitra.social",
"header": "https://gleasonator.com/images/banner.png",
"header_static": "https://gleasonator.com/images/banner.png",
"id": "ADIzJ7q9gExPvDKBCS",
"last_status_at": "2022-04-15T11:27:33",
"locked": false,
"note": "",
"pleroma": {
"accepts_chat_messages": false,
"also_known_as": [],
"ap_id": "https://mitra.social/users/silverpill",
"background_image": null,
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/XSE9_kQbQyYcSFWszWx2GgCbBuY/aHR0cHM6Ly9taXRyYS5zb2NpYWwvZmF2aWNvbi5pY28/favicon.ico",
"hide_favorites": true,
"hide_followers": true,
"hide_followers_count": false,
"hide_follows": true,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": false,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 135,
"url": "https://mitra.social/users/silverpill",
"username": "silverpill"
},
"application": null,
"bookmarked": true,
"card": {
"author_name": "",
"author_url": "",
"blurhash": null,
"description": "The ActivityPub protocol is a decentralized social networking protocol\n based upon the [ActivityStreams] 2.0 data format.\n It provides a client to server API for creating, updating and deleting\n content, as well as a federated server to server API for delivering\n notifications and content.",
"embed_url": "",
"height": 0,
"html": "",
"image": null,
"provider_name": "www.w3.org",
"provider_url": "https://www.w3.org",
"title": "ActivityPub",
"type": "link",
"url": "https://www.w3.org/TR/activitypub/#retrieving-objects",
"width": 0
},
"content": "<span class=\"h-card\"><a class=\"u-url mention\" href=\"https://gleasonator.com/users/alex\">@alex</a></span> <span class=\"h-card\"><a class=\"u-url mention\" href=\"https://lain.com/users/lain\">@lain</a></span> The second one is suggested by ActivityPub spec: <a href=\"https://www.w3.org/TR/activitypub/#retrieving-objects\">https://www.w3.org/TR/activitypub/#retrieving-objects</a><br/>\nThe first one is likely a legacy of earlier ActivityStreams standards, I&#39;m not sure",
"created_at": "2022-04-15T11:27:28.000Z",
"emojis": [],
"favourited": true,
"favourites_count": 2,
"id": "AITJf9Wpr0msWChNBI",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "AISPFI5nnPaS7J94rI",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
{
"acct": "lain@lain.com",
"id": "9v5bqYwY2jfmvPNhTM",
"url": "https://lain.com/users/lain",
"username": "lain"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex @lain The second one is suggested by ActivityPub spec: https://www.w3.org/TR/activitypub/#retrieving-objects\nThe first one is likely a legacy of earlier ActivityStreams standards, I'm not sure"
},
"content_type": null,
"conversation_id": "AISPFI2bzH2DxPeWsy",
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": false,
"parent_visible": true,
"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://mitra.social/objects/01802cfa-633c-1c2c-e9cf-e6e0ffef0afe",
"url": "https://mitra.social/objects/01802cfa-633c-1c2c-e9cf-e6e0ffef0afe",
"visibility": "public"
},
"type": "mention"
}

@ -0,0 +1,119 @@
{
"account": {
"acct": "alex@fedibird.com",
"avatar": "https://gleasonator.com/images/avi.png",
"avatar_static": "https://gleasonator.com/images/avi.png",
"bot": false,
"created_at": "2022-01-24T21:25:37.000Z",
"display_name": "alex@fedibird.com",
"emojis": [],
"fields": [],
"followers_count": 0,
"following_count": 2,
"fqn": "alex@fedibird.com",
"header": "https://gleasonator.com/images/banner.png",
"header_static": "https://gleasonator.com/images/banner.png",
"id": "AFmHQ18XZ7Lco68MW8",
"last_status_at": "2022-03-16T22:07:53",
"locked": false,
"note": "<p></p>",
"pleroma": {
"accepts_chat_messages": null,
"also_known_as": [],
"ap_id": "https://fedibird.com/users/alex",
"background_image": null,
"birthday": "1993-07-03",
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/HzfsidHss3CuA7aM2zxXN-tAjF8/aHR0cHM6Ly9mZWRpYmlyZC5jb20vZmF2aWNvbi5pY28/favicon.ico",
"hide_favorites": true,
"hide_followers": false,
"hide_followers_count": false,
"hide_follows": false,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": false,
"location": "Texas",
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 5,
"url": "https://fedibird.com/@alex",
"username": "alex"
},
"created_at": "2022-03-17T00:08:48.000Z",
"id": "406814",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"target": {
"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-03-19T22:33:38",
"locked": false,
"note": "hello world 2",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [
"https://gleasonator.com/users/alex",
"https://poa.st/users/alex",
"https://fedibird.com/users/alex"
],
"ap_id": "https://gleasonator.com/users/benis911",
"background_image": null,
"birthday": "2000-01-25",
"deactivated": false,
"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,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "hello world 2",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 174,
"url": "https://gleasonator.com/users/benis911",
"username": "benis911"
},
"type": "move"
}

@ -0,0 +1,73 @@
{
"account": {
"acct": "dave",
"avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"bot": false,
"created_at": "2020-02-01T07:28:46.000Z",
"display_name": "Elden Beedle 🇺🇦 🇫🇷",
"emojis": [],
"fields": [],
"followers_count": 490,
"following_count": 367,
"fqn": "dave@gleasonator.com",
"header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"id": "9v5c0Pkz3MT5KTfam8",
"last_status_at": "2022-04-16T19:57:10",
"locked": false,
"note": "Beedle is back, baby!<br/><br/>Mostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://gleasonator.com/users/dave",
"background_image": null,
"birthday": "1990-01-01",
"deactivated": false,
"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": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": true,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 16758,
"url": "https://gleasonator.com/users/dave",
"username": "dave"
},
"chat_message": {
"account_id": "9v5c0Pkz3MT5KTfam8",
"attachment": null,
"card": null,
"chat_id": "9yX4Q9DiC2te6lvk5g",
"content": "Cool, it works, I&#39;ll keep letting you know when I find broken stuff",
"created_at": "2022-04-16T19:22:54.000Z",
"emojis": [],
"id": "AIW4bHoICoZ9CsRTW4",
"unread": false
},
"created_at": "2022-04-16T19:22:55.000Z",
"id": "429247",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"type": "pleroma:chat_mention"
}

@ -0,0 +1,301 @@
{
"account": {
"acct": "dave",
"avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"bot": false,
"created_at": "2020-02-01T07:28:46.000Z",
"display_name": "Elden Beedle 🇺🇦 🇫🇷",
"emojis": [],
"fields": [],
"followers_count": 490,
"following_count": 367,
"fqn": "dave@gleasonator.com",
"header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"id": "9v5c0Pkz3MT5KTfam8",
"last_status_at": "2022-04-16T19:57:10",
"locked": false,
"note": "Beedle is back, baby!<br/><br/>Mostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://gleasonator.com/users/dave",
"background_image": null,
"birthday": "1990-01-01",
"deactivated": false,
"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": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": true,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 16758,
"url": "https://gleasonator.com/users/dave",
"username": "dave"
},
"created_at": "2022-04-16T16:52:15.000Z",
"emoji": "😮",
"id": "429071",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"status": {
"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": "<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>"
},
{
"name": "Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
},
{
"name": "Donate (PayPal)",
"value": "<a href=\"https://paypal.me/gleasonator\" rel=\"ugc\">https://paypal.me/gleasonator</a>"
},
{
"name": "$BTC",
"value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
},
{
"name": "$ETH",
"value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
},
{
"name": "$DOGE",
"value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
},
{
"name": "$XMR",
"value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
}
],
"follow_requests_count": 0,
"followers_count": 2602,
"following_count": 1603,
"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-04-16T19:23:50",
"locked": false,
"note": "I create Fediverse software that empowers people online.<br/><br/>I&#39;m vegan btw<br/><br/>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,
"accepts_email_list": true,
"allow_following_move": true,
"also_known_as": [
"https://mitra.social/users/alex"
],
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"birthday": "1993-07-03",
"deactivated": false,
"email": "alex@alexgleason.me",
"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,
"location": "Texas",
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 392,
"unread_notifications_count": 0
},
"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,
"no_rich_text": false,
"show_birthday": true,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 24050,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Soapbox FE",
"website": "https://soapbox.pub/"
},
"bookmarked": false,
"card": {
"author_name": "Kaze Emanuar",
"author_url": "https://www.youtube.com/c/KazeEmanuar",
"blurhash": null,
"description": "",
"embed_url": null,
"height": 113,
"html": "<iframe width=\"200\" height=\"113\" src=\"https://www.youtube.com/embed/t_rzYnXEQlE?feature=oembed\" allowfullscreen=\"\"></iframe>",
"image": "https://gleasonator.com/proxy/mI004Vq00johZtAUmMp0fC_XAuM/aHR0cHM6Ly9pLnl0aW1nLmNvbS92aS90X3J6WW5YRVFsRS9ocWRlZmF1bHQuanBn/hqdefault.jpg",
"provider_name": "YouTube",
"provider_url": "https://www.youtube.com/",
"title": "FIXING the ENTIRE SM64 Source Code (INSANE N64 performance)",
"type": "video",
"url": "https://youtu.be/t_rzYnXEQlE",
"width": 200
},
"content": "<p>Bruh. This guy rewrote the reversed engineered Super Mario 64 code for 10x performance. Games need to be open source. <a href=\"https://youtu.be/t_rzYnXEQlE\">https://youtu.be/t_rzYnXEQlE</a></p>",
"created_at": "2022-04-16T16:40:28.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 11,
"id": "AIVq6SrJg5yb8eGVsm",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Bruh. This guy rewrote the reversed engineered Super Mario 64 code for 10x performance. Games need to be open source. https://youtu.be/t_rzYnXEQlE"
},
"content_type": null,
"conversation_id": "AIVq6SqFk37r5LlfE0",
"direct_conversation_id": null,
"emoji_reactions": [
{
"count": 1,
"me": false,
"name": "❤️"
},
{
"count": 2,
"me": false,
"name": "😮"
},
{
"count": 1,
"me": false,
"name": "😆"
},
{
"count": 1,
"me": false,
"name": "👍🏻"
},
{
"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": null,
"quote_visible": false,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 7,
"replies_count": 2,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/160dcbb2-73bc-4cd2-971e-e7f6a38602a0",
"url": "https://gleasonator.com/notice/AIVq6SrJg5yb8eGVsm",
"visibility": "public"
},
"type": "pleroma:emoji_reaction"
}

@ -0,0 +1,202 @@
{
"account": {
"acct": "dave",
"avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"bot": false,
"created_at": "2020-02-01T07:28:46.000Z",
"display_name": "Elden Beedle 🇺🇦 🇫🇷",
"emojis": [],
"fields": [],
"followers_count": 490,
"following_count": 367,
"fqn": "dave@gleasonator.com",
"header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"id": "9v5c0Pkz3MT5KTfam8",
"last_status_at": "2022-04-16T19:57:10",
"locked": false,
"note": "Beedle is back, baby!<br/><br/>Mostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://gleasonator.com/users/dave",
"background_image": null,
"birthday": "1990-01-01",
"deactivated": false,
"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": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": true,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 16758,
"url": "https://gleasonator.com/users/dave",
"username": "dave"
},
"created_at": "2022-04-14T01:12:27.000Z",
"id": "427339",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"status": {
"account": {
"acct": "dave",
"avatar": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"avatar_static": "https://media.gleasonator.com/68c29c30c18f30dd2898f85466bf1670312dda816617e6d31421c7e4c30a8265.png",
"bot": false,
"created_at": "2020-02-01T07:28:46.000Z",
"display_name": "Elden Beedle 🇺🇦 🇫🇷",
"emojis": [],
"fields": [],
"followers_count": 490,
"following_count": 367,
"fqn": "dave@gleasonator.com",
"header": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"header_static": "https://media.gleasonator.com/47e8907c322a0e55d12b211846aa27c6b386e947326fe14bb09c89ef7317901d.jpg",
"id": "9v5c0Pkz3MT5KTfam8",
"last_status_at": "2022-04-16T19:57:10",
"locked": false,
"note": "Beedle is back, baby!<br/><br/>Mostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://gleasonator.com/users/dave",
"background_image": null,
"birthday": "1990-01-01",
"deactivated": false,
"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": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": true,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "Beedle is back, baby!\r\n\r\nMostly just crosspost memes and stuff I find on the internet",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 16758,
"url": "https://gleasonator.com/users/dave",
"username": "dave"
},
"application": {
"name": "Soapbox FE",
"website": "https://soapbox.pub/"
},
"bookmarked": false,
"card": null,
"content": "<p>Focusing on just the look, what do you guys think?</p>",
"created_at": "2022-04-13T01:12:26.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 1,
"id": "AIOHjtGEaqUHoXGVf6",
"in_reply_to_account_id": "9v5c0Pkz3MT5KTfam8",
"in_reply_to_id": "AIOFTLqQrljhdNBNHE",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "dave",
"id": "9v5c0Pkz3MT5KTfam8",
"url": "https://gleasonator.com/users/dave",
"username": "dave"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Focusing on just the look, what do you guys think?"
},
"content_type": null,
"conversation_id": "AIOFTLp0x2bNYyWF4C",
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "dave",
"local": true,
"parent_visible": true,
"pinned_at": null,
"quote": null,
"quote_url": null,
"quote_visible": false,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": {
"emojis": [],
"expired": true,
"expires_at": "2022-04-14T01:12:26.000Z",
"id": "AIOHjtAuucEZY2mGNE",
"multiple": false,
"options": [
{
"title": "Looks good, looking forward to wider deployment",
"votes_count": 10
},
{
"title": "Not a fan, l'll stick to the current UI thanks",
"votes_count": 1
},
{
"title": "Hard to say, need to actually try to decide honestly",
"votes_count": 1
}
],
"own_votes": [
0
],
"voted": true,
"voters_count": 12,
"votes_count": 12
},
"reblog": null,
"reblogged": false,
"reblogs_count": 1,
"replies_count": 1,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/a8465271-a48d-4c39-a0a9-d3eda3ab2735",
"url": "https://gleasonator.com/notice/AIOHjtGEaqUHoXGVf6",
"visibility": "public"
},
"type": "poll"
}

@ -0,0 +1,284 @@
{
"account": {
"acct": "rob@nicecrew.digital",
"avatar": "https://gleasonator.com/proxy/RcEgR4-0InIpw_sCpDWV-XrAbmY/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2M0MTllMTk1Nzg0MmEzMTY5M2MzNDExNTZlMTBhNmQwMTY2ZTM5YzQzM2ExZTczMmVmYWNlYmJkYjAyMDYzZjEucG5n/c419e1957842a31693c341156e10a6d0166e39c433a1e732efacebbdb02063f1.png",
"avatar_static": "https://gleasonator.com/proxy/RcEgR4-0InIpw_sCpDWV-XrAbmY/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2M0MTllMTk1Nzg0MmEzMTY5M2MzNDExNTZlMTBhNmQwMTY2ZTM5YzQzM2ExZTczMmVmYWNlYmJkYjAyMDYzZjEucG5n/c419e1957842a31693c341156e10a6d0166e39c433a1e732efacebbdb02063f1.png",
"bot": false,
"created_at": "2022-03-10T12:30:41.000Z",
"display_name": "Rob Colbert",
"emojis": [],
"fields": [
{
"name": "Shing.tv",
"value": "<a href=\"https://shing.tv\" rel=\"ugc\">https://shing.tv</a>"
},
{
"name": "LibertyLinks",
"value": "<a href=\"https://libertylinks.io\" rel=\"ugc\">https://libertylinks.io</a>"
},
{
"name": "GiveSendGo",
"value": "<a href=\"https://givesendgo.com/dtp\" rel=\"ugc\">https://givesendgo.com/dtp</a>"
}
],
"followers_count": 0,
"following_count": 0,
"fqn": "rob@nicecrew.digital",
"header": "https://gleasonator.com/proxy/t4--aro68-XZlasaR2bYiuiZMcA/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2E5ODYzYWE4YjEzM2QwMzkxNmU1N2MzNDgzMzBhZmE5MTM5MDFlNGZiMDEwYjk1Y2FiZjlmYmZiZTA4N2QxODMucG5n/a9863aa8b133d03916e57c348330afa913901e4fb010b95cabf9fbfbe087d183.png",
"header_static": "https://gleasonator.com/proxy/t4--aro68-XZlasaR2bYiuiZMcA/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL21lZGlhL2E5ODYzYWE4YjEzM2QwMzkxNmU1N2MzNDgzMzBhZmE5MTM5MDFlNGZiMDEwYjk1Y2FiZjlmYmZiZTA4N2QxODMucG5n/a9863aa8b133d03916e57c348330afa913901e4fb010b95cabf9fbfbe087d183.png",
"id": "AHGmnebARD1aa1IiBc",
"last_status_at": "2022-04-16T21:08:35",
"locked": false,
"note": "Creator and CTO of the Digital Telepresence Platform and DTP Technologies, LLC.",
"pleroma": {
"accepts_chat_messages": true,
"also_known_as": [],
"ap_id": "https://nicecrew.digital/users/rob",
"background_image": null,
"deactivated": false,
"favicon": "https://gleasonator.com/proxy/gb2NPo0Kv_svADN1_J9_9iSwlrY/aHR0cHM6Ly9uaWNlY3Jldy5kaWdpdGFsL2Zhdmljb24ucG5n/favicon.png",
"hide_favorites": true,
"hide_followers": true,
"hide_followers_count": false,
"hide_follows": true,
"hide_follows_count": false,
"is_admin": false,
"is_confirmed": true,
"is_moderator": false,
"is_suggested": false,
"location": null,
"relationship": {},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 761,
"url": "https://nicecrew.digital/users/rob",
"username": "rob"
},
"created_at": "2022-04-16T03:43:24.000Z",
"id": "428608",
"pleroma": {
"is_muted": false,
"is_seen": true
},
"status": {
"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": "<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>"
},
{
"name": "Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
},
{
"name": "Donate (PayPal)",
"value": "<a href=\"https://paypal.me/gleasonator\" rel=\"ugc\">https://paypal.me/gleasonator</a>"
},
{
"name": "$BTC",
"value": "bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n"
},
{
"name": "$ETH",
"value": "0xAc9aB5Fc04Dc1cB1789Af75b523Bd23C70B2D717"
},
{
"name": "$DOGE",
"value": "D5zVZs6jrRakaPVGiErkQiHt9sayzm6V5D"
},
{
"name": "$XMR",
"value": "45JDCLrjJ4bgVUSbbs2yjy9m5Mf4VLPW8fG7jw9sq5u69rXZZopQogZNeyYkMBnXpkaip4p4QwaaJNhdTotPa9g44DBCzdK"
}
],
"follow_requests_count": 0,
"followers_count": 2602,
"following_count": 1603,
"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-04-16T19:23:50",
"locked": false,
"note": "I create Fediverse software that empowers people online.<br/><br/>I&#39;m vegan btw<br/><br/>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,
"accepts_email_list": true,
"allow_following_move": true,
"also_known_as": [
"https://mitra.social/users/alex"
],
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"birthday": "1993-07-03",
"deactivated": false,
"email": "alex@alexgleason.me",
"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,
"location": "Texas",
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 392,
"unread_notifications_count": 0
},
"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,
"no_rich_text": false,
"show_birthday": true,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 24050,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Soapbox FE",
"website": "https://soapbox.pub/"
},
"bookmarked": false,
"card": null,
"content": "<p>The <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"9v5boQSsaxVc3AU8u0\" href=\"https://status.fsf.org/fsf\" rel=\"ugc\">@<span>fsf</span></a></span> needs to give out an award to every American who has never downloaded TikTok.</p>",
"created_at": "2022-04-16T03:42:50.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 15,
"id": "AIUihbqUEe5Uvv7P9s",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "fsf@status.fsf.org",
"id": "9v5boQSsaxVc3AU8u0",
"url": "https://status.fsf.org/fsf",
"username": "fsf"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "The @fsf needs to give out an award to every American who has never downloaded TikTok."
},
"content_type": null,
"conversation_id": "AIUihbp4JuxArWSGwq",
"direct_conversation_id": null,
"emoji_reactions": [
{
"count": 2,
"me": false,
"name": "🔥"
}
],
"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": 8,
"replies_count": 4,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/6be95787-fb9c-41cd-96cf-9652b2680863",
"url": "https://gleasonator.com/notice/AIUihbqUEe5Uvv7P9s",
"visibility": "public"
},
"type": "reblog"
}

@ -0,0 +1,17 @@
{
"funding": {
"amount": 3500,
"patrons": 3,
"currency": "usd",
"interval": "monthly"
},
"goals": [
{
"amount": 20000,
"currency": "usd",
"interval": "monthly",
"text": "I'll be able to afford an avocado."
}
],
"url": "https://patron.gleasonator.com"
}

@ -0,0 +1,4 @@
{
"is_patron": true,
"url": "https://gleasonator.com/users/dave"
}

@ -0,0 +1,66 @@
{
"uri": "pixelfed.social",
"title": "pixelfed",
"short_description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms",
"description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms",
"email": "hello@pixelfed.org",
"version": "2.7.2 (compatible; Pixelfed 0.11.2)",
"urls": {
"streaming_api": "wss://pixelfed.social"
},
"stats": {
"user_count": 45061,
"status_count": 301357,
"domain_count": 5028
},
"thumbnail": "https://pixelfed.social/img/pixelfed-icon-color.png",
"languages": [
"en"
],
"registrations": true,
"approval_required": false,
"contact_account": {
"id": "1",
"username": "admin",
"acct": "admin",
"display_name": "Admin",
"discoverable": true,
"locked": false,
"followers_count": 419,
"following_count": 2,
"statuses_count": 6,
"note": "pixelfed.social Admin. Managed by @dansup",
"url": "https://pixelfed.social/admin",
"avatar": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4",
"created_at": "2018-06-01T03:54:08.000000Z",
"avatar_static": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4",
"bot": false,
"emojis": [],
"fields": [],
"header": "https://pixelfed.social/storage/headers/missing.png",
"header_static": "https://pixelfed.social/storage/headers/missing.png",
"last_status_at": null
},
"rules": [
{
"id": "1",
"text": "Sexually explicit or violent media must be marked as sensitive when posting"
},
{
"id": "2",
"text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism"
},
{
"id": "3",
"text": "No incitement of violence or promotion of violent ideologies"
},
{
"id": "4",
"text": "No harassment, dogpiling or doxxing of other users"
},
{
"id": "5",
"text": "No content illegal in United States"
}
]
}

@ -0,0 +1,108 @@
{
"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&#39;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": "<br/><a href=\"https://freespeechextremist.com/media/3e34b808-1c84-4ef3-ba56-67cc86b7911a/0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm?name=0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm\">0f66e92f339705ccc03079b8f647048e15730adf2cc9eaa1071c7c7cf6884b1b.webm</a>",
"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"
}

@ -0,0 +1,14 @@
[
{
"id": "1",
"text": "Illegal activity and behavior",
"subtext": "Content that depicts illegal or criminal acts, threats of violence.",
"rule_type": "content"
},
{
"id": "2",
"text": "Intellectual property infringement",
"subtext": "Impersonating another account or business, infringing on intellectual property rights.",
"rule_type": "content"
}
]

@ -0,0 +1,119 @@
{
"allowedEmoji": [
"👍",
"❤️",
"😆",
"😮",
"😢",
"😡",
"😩"
],
"brandColor": "#990099",
"copyright": "♡2021. Copying is an act of love. Please copy and share.",
"cryptoAddresses": [
{
"address": "bc1qv7lk3algpfg4zpyuhvxfm0uza9ck4parz3y3l5",
"note": "",
"ticker": "btc"
},
{
"address": "0xadc66B63bFee7677CD27CFb81b16a8860f1A1226",
"note": "",
"ticker": "eth"
},
{
"address": "DSf7UmRf7DGGsjh4QYhzQaqtjJMTXZ8k79",
"note": "",
"ticker": "doge"
},
{
"address": "ltc1q642pnkuvw0gpuuvddw6vafvl9hhp3efyl9mnqz",
"note": "",
"ticker": "ltc"
},
{
"address": "t1faHDsoa4bd3pGaLjaU7DiuUtBPzbnEEse",
"note": "",
"ticker": "zec"
},
{
"address": "XchTLkcSMsDoZGESwr4tqtxSU5dideAZVQ",
"note": "",
"ticker": "dash"
},
{
"address": "bitcoincash:qp8f80z27294phmhdk55yf05p3f0tkxl4v9r2aavw5",
"note": "",
"ticker": "bch"
}
],
"cryptoDonatePanel": {
"limit": 1
},
"customCss": [
"/instance/spinster.css"
],
"defaultSettings": {
"autoPlayGif": false,
"themeMode": "light"
},
"extensions": {
"patron": {
"enabled": true
}
},
"logo": "https://spinster.xyz/instance/images/spinster-logo.svg",
"navlinks": {
"homeFooter": [
{
"title": "About",
"url": "/about"
},
{
"title": "Terms of Service",
"url": "/about/tos"
},
{
"title": "Privacy Policy",
"url": "/about/privacy"
},
{
"title": "DMCA",
"url": "/about/dmca"
},
{
"title": "Source Code",
"url": "/about#opensource"
}
]
},
"promoPanel": {
"items": [
{
"icon": "shopping-basket",
"text": "Buy Spinster Merch",
"url": "https://shop.4w.pub/collections/spinster"
},
{
"icon": "eye-slash",
"text": "Privacy Guide",
"url": "https://4w.pub/your-guide-to-spinster-privacy-options/"
},
{
"icon": "question-circle",
"text": "Spinster FAQs",
"url": "https://spinster.xyz/about#faqs"
},
{
"icon": "bug",
"text": "Report a Bug",
"url": "https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/"
},
{
"icon": "fediverse",
"text": "About the Fediverse",
"url": "https://jointhefedi.com/"
}
]
}
}

@ -9,7 +9,7 @@ export const __stub = (func: Function) => mocks.push(func);
export const __clear = (): Function[] => mocks = []; export const __clear = (): Function[] => mocks = [];
const setupMock = (axios: AxiosInstance) => { const setupMock = (axios: AxiosInstance) => {
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios, { onNoMatch: 'throwException' });
mocks.map(func => func(mock)); mocks.map(func => func(mock));
}; };

@ -0,0 +1,101 @@
import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers';
import rootReducer from 'soapbox/reducers';
import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding';
describe('checkOnboarding()', () => {
let mockGetItem: any;
mockWindowProperty('localStorage', {
getItem: (key: string) => mockGetItem(key),
});
beforeEach(() => {
mockGetItem = jest.fn().mockReturnValue(null);
});
it('does nothing if localStorage item is not set', async() => {
mockGetItem = jest.fn().mockReturnValue(null);
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } });
const store = mockStore(state);
await store.dispatch(checkOnboardingStatus());
const actions = store.getActions();
expect(actions).toEqual([]);
expect(mockGetItem.mock.calls.length).toBe(1);
});
it('does nothing if localStorage item is invalid', async() => {
mockGetItem = jest.fn().mockReturnValue('invalid');
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } });
const store = mockStore(state);
await store.dispatch(checkOnboardingStatus());
const actions = store.getActions();
expect(actions).toEqual([]);
expect(mockGetItem.mock.calls.length).toBe(1);
});
it('dispatches the correct action', async() => {
mockGetItem = jest.fn().mockReturnValue('1');
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } });
const store = mockStore(state);
await store.dispatch(checkOnboardingStatus());
const actions = store.getActions();
expect(actions).toEqual([{ type: 'ONBOARDING_START' }]);
expect(mockGetItem.mock.calls.length).toBe(1);
});
});
describe('startOnboarding()', () => {
let mockSetItem: any;
mockWindowProperty('localStorage', {
setItem: (key: string, value: string) => mockSetItem(key, value),
});
beforeEach(() => {
mockSetItem = jest.fn();
});
it('dispatches the correct action', async() => {
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } });
const store = mockStore(state);
await store.dispatch(startOnboarding());
const actions = store.getActions();
expect(actions).toEqual([{ type: 'ONBOARDING_START' }]);
expect(mockSetItem.mock.calls.length).toBe(1);
});
});
describe('endOnboarding()', () => {
let mockRemoveItem: any;
mockWindowProperty('localStorage', {
removeItem: (key: string) => mockRemoveItem(key),
});
beforeEach(() => {
mockRemoveItem = jest.fn();
});
it('dispatches the correct action', async() => {
const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } });
const store = mockStore(state);
await store.dispatch(endOnboarding());
const actions = store.getActions();
expect(actions).toEqual([{ type: 'ONBOARDING_END' }]);
expect(mockRemoveItem.mock.calls.length).toBe(1);
});
});

@ -0,0 +1,26 @@
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { fetchRules, RULES_FETCH_REQUEST, RULES_FETCH_SUCCESS } from '../rules';
describe('fetchRules()', () => {
it('sets the rules', (done) => {
const rules = require('soapbox/__fixtures__/rules.json');
__stub((mock) => {
mock.onGet('/api/v1/instance/rules').reply(200, rules);
});
const store = mockStore(rootState);
store.dispatch(fetchRules()).then((context) => {
const actions = store.getActions();
expect(actions[0].type).toEqual(RULES_FETCH_REQUEST);
expect(actions[1].type).toEqual(RULES_FETCH_SUCCESS);
expect(actions[1].payload[0].id).toEqual('1');
done();
}).catch(console.error);
});
});

@ -1035,7 +1035,7 @@ export function accountLookup(acct, cancelToken) {
}; };
} }
export function fetchBirthdayReminders(day, month) { export function fetchBirthdayReminders(month, day) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;

@ -274,6 +274,18 @@ export function unverifyUser(accountId) {
}; };
} }
export function setDonor(accountId) {
return (dispatch, getState) => {
return dispatch(tagUsers([accountId], ['donor']));
};
}
export function removeDonor(accountId) {
return (dispatch, getState) => {
return dispatch(untagUsers([accountId], ['donor']));
};
}
export function addPermission(accountIds, permissionGroup) { export function addPermission(accountIds, permissionGroup) {
return (dispatch, getState) => { return (dispatch, getState) => {
const nicknames = nicknamesFromIds(getState, accountIds); const nicknames = nicknamesFromIds(getState, accountIds);

@ -13,7 +13,9 @@ import { createAccount } from 'soapbox/actions/accounts';
import { createApp } from 'soapbox/actions/apps'; import { createApp } from 'soapbox/actions/apps';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import { startOnboarding } from 'soapbox/actions/onboarding';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom';
import KVStore from 'soapbox/storage/kv_store'; import KVStore from 'soapbox/storage/kv_store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
@ -39,12 +41,14 @@ export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST';
export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS';
export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL';
const customApp = custom('app');
export const messages = defineMessages({ export const messages = defineMessages({
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
}); });
const noOp = () => () => new Promise(f => f()); const noOp = () => new Promise(f => f());
const getScopes = state => { const getScopes = state => {
const instance = state.get('instance'); const instance = state.get('instance');
@ -54,12 +58,23 @@ const getScopes = state => {
function createAppAndToken() { function createAppAndToken() {
return (dispatch, getState) => { return (dispatch, getState) => {
return dispatch(createAuthApp()).then(() => { return dispatch(getAuthApp()).then(() => {
return dispatch(createAppToken()); return dispatch(createAppToken());
}); });
}; };
} }
/** Create an auth app, or use it from build config */
function getAuthApp() {
return (dispatch, getState) => {
if (customApp?.client_secret) {
return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp }));
} else {
return dispatch(createAuthApp());
}
};
}
function createAuthApp() { function createAuthApp() {
return (dispatch, getState) => { return (dispatch, getState) => {
const params = { const params = {
@ -117,7 +132,7 @@ export function refreshUserToken() {
const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']); const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']);
const app = getState().getIn(['auth', 'app']); const app = getState().getIn(['auth', 'app']);
if (!refreshToken) return dispatch(noOp()); if (!refreshToken) return dispatch(noOp);
const params = { const params = {
client_id: app.get('client_id'), client_id: app.get('client_id'),
@ -200,7 +215,7 @@ export function loadCredentials(token, accountUrl) {
export function logIn(intl, username, password) { export function logIn(intl, username, password) {
return (dispatch, getState) => { return (dispatch, getState) => {
return dispatch(createAuthApp()).then(() => { return dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(username, password)); return dispatch(createUserToken(username, password));
}).catch(error => { }).catch(error => {
if (error.response.data.error === 'mfa_required') { if (error.response.data.error === 'mfa_required') {
@ -235,10 +250,12 @@ export function logOut(intl) {
const account = getLoggedInAccount(state); const account = getLoggedInAccount(state);
const standalone = isStandalone(state); const standalone = isStandalone(state);
if (!account) return dispatch(noOp);
const params = { const params = {
client_id: state.getIn(['auth', 'app', 'client_id']), client_id: state.getIn(['auth', 'app', 'client_id']),
client_secret: state.getIn(['auth', 'app', 'client_secret']), client_secret: state.getIn(['auth', 'app', 'client_secret']),
token: state.getIn(['auth', 'users', account.get('url'), 'access_token']), token: state.getIn(['auth', 'users', account.url, 'access_token']),
}; };
return Promise.all([ return Promise.all([
@ -276,7 +293,10 @@ export function register(params) {
return dispatch(createAppAndToken()) return dispatch(createAppAndToken())
.then(() => dispatch(createAccount(params))) .then(() => dispatch(createAccount(params)))
.then(({ token }) => dispatch(authLoggedIn(token))); .then(({ token }) => {
dispatch(startOnboarding());
return dispatch(authLoggedIn(token));
});
}; };
} }

@ -61,14 +61,14 @@ export function fetchChatsV2() {
export function fetchChats() { export function fetchChats() {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
const instance = state.get('instance'); const { instance } = state;
const features = getFeatures(instance); const features = getFeatures(instance);
dispatch({ type: CHATS_FETCH_REQUEST }); dispatch({ type: CHATS_FETCH_REQUEST });
if (features.chatsV2) { if (features.chatsV2) {
dispatch(fetchChatsV2()); return dispatch(fetchChatsV2());
} else { } else {
dispatch(fetchChatsV1()); return dispatch(fetchChatsV1());
} }
}; };
} }

@ -42,7 +42,7 @@ function createExternalApp(instance, baseURL) {
const params = { const params = {
client_name: sourceCode.displayName, client_name: sourceCode.displayName,
redirect_uris: `${window.location.origin}/auth/external`, redirect_uris: `${window.location.origin}/login/external`,
website: sourceCode.homepage, website: sourceCode.homepage,
scopes, scopes,
}; };

@ -1,17 +0,0 @@
export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
export function setHeight(key, id, height) {
return {
type: HEIGHT_CACHE_SET,
key,
id,
height,
};
}
export function clearHeight() {
return {
type: HEIGHT_CACHE_CLEAR,
};
}

@ -93,7 +93,7 @@ const isBroken = status => {
// https://gitlab.com/soapbox-pub/soapbox/-/issues/28 // https://gitlab.com/soapbox-pub/soapbox/-/issues/28
if (status.reblog && !status.reblog.account.id) return true; if (status.reblog && !status.reblog.account.id) return true;
return false; return false;
} catch(e) { } catch (e) {
return true; return true;
} }
}; };

@ -1,30 +1,19 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get } from 'lodash'; import { get } from 'lodash';
import KVStore from 'soapbox/storage/kv_store'; import KVStore from 'soapbox/storage/kv_store';
import { AppDispatch, RootState } from 'soapbox/store'; import { RootState } from 'soapbox/store';
import { getAuthUserUrl } from 'soapbox/utils/auth'; import { getAuthUserUrl } from 'soapbox/utils/auth';
import { parseVersion } from 'soapbox/utils/features'; import { parseVersion } from 'soapbox/utils/features';
import api from '../api'; import api from '../api';
export const INSTANCE_FETCH_REQUEST = 'INSTANCE_FETCH_REQUEST';
export const INSTANCE_FETCH_SUCCESS = 'INSTANCE_FETCH_SUCCESS';
export const INSTANCE_FETCH_FAIL = 'INSTANCE_FETCH_FAIL';
export const INSTANCE_REMEMBER_REQUEST = 'INSTANCE_REMEMBER_REQUEST';
export const INSTANCE_REMEMBER_SUCCESS = 'INSTANCE_REMEMBER_SUCCESS';
export const INSTANCE_REMEMBER_FAIL = 'INSTANCE_REMEMBER_FAIL';
export const NODEINFO_FETCH_REQUEST = 'NODEINFO_FETCH_REQUEST';
export const NODEINFO_FETCH_SUCCESS = 'NODEINFO_FETCH_SUCCESS';
export const NODEINFO_FETCH_FAIL = 'NODEINFO_FETCH_FAIL';
const getMeUrl = (state: RootState) => { const getMeUrl = (state: RootState) => {
const me = state.me; const me = state.me;
return state.accounts.getIn([me, 'url']); return state.accounts.getIn([me, 'url']);
}; };
// Figure out the appropriate instance to fetch depending on the state /** Figure out the appropriate instance to fetch depending on the state */
export const getHost = (state: RootState) => { export const getHost = (state: RootState) => {
const accountUrl = getMeUrl(state) || getAuthUserUrl(state); const accountUrl = getMeUrl(state) || getAuthUserUrl(state);
@ -35,60 +24,49 @@ export const getHost = (state: RootState) => {
} }
}; };
export function rememberInstance(host: string) { export const rememberInstance = createAsyncThunk(
return (dispatch: AppDispatch, _getState: () => RootState) => { 'instance/remember',
dispatch({ type: INSTANCE_REMEMBER_REQUEST, host }); async(host: string) => {
return KVStore.getItemOrError(`instance:${host}`).then((instance: Record<string, any>) => { return await KVStore.getItemOrError(`instance:${host}`);
dispatch({ type: INSTANCE_REMEMBER_SUCCESS, host, instance }); },
return instance; );
}).catch((error: Error) => {
dispatch({ type: INSTANCE_REMEMBER_FAIL, host, error, skipAlert: true });
});
};
}
// We may need to fetch nodeinfo on Pleroma < 2.1 /** We may need to fetch nodeinfo on Pleroma < 2.1 */
const needsNodeinfo = (instance: Record<string, any>): boolean => { const needsNodeinfo = (instance: Record<string, any>): boolean => {
const v = parseVersion(get(instance, 'version')); const v = parseVersion(get(instance, 'version'));
return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']); return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']);
}; };
export function fetchInstance() { export const fetchInstance = createAsyncThunk<void, void, { state: RootState }>(
return (dispatch: AppDispatch, getState: () => RootState) => { 'instance/fetch',
dispatch({ type: INSTANCE_FETCH_REQUEST }); async(_arg, { dispatch, getState, rejectWithValue }) => {
return api(getState).get('/api/v1/instance').then(({ data: instance }: { data: Record<string, any> }) => { try {
dispatch({ type: INSTANCE_FETCH_SUCCESS, instance }); const { data: instance } = await api(getState).get('/api/v1/instance');
if (needsNodeinfo(instance)) { if (needsNodeinfo(instance)) {
// @ts-ignore: ??? dispatch(fetchNodeinfo());
dispatch(fetchNodeinfo()); // Pleroma < 2.1 backwards compatibility
} }
}).catch(error => { return instance;
console.error(error); } catch(e) {
dispatch({ type: INSTANCE_FETCH_FAIL, error, skipAlert: true }); return rejectWithValue(e);
}); }
}; },
} );
// Tries to remember the instance from browser storage before fetching it /** Tries to remember the instance from browser storage before fetching it */
export function loadInstance() { export const loadInstance = createAsyncThunk<void, void, { state: RootState }>(
return (dispatch: AppDispatch, getState: () => RootState) => { 'instance/load',
async(_arg, { dispatch, getState }) => {
const host = getHost(getState()); const host = getHost(getState());
await Promise.all([
dispatch(rememberInstance(host || '')),
dispatch(fetchInstance()),
]);
},
);
// @ts-ignore: ??? export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>(
return dispatch(rememberInstance(host)).finally(() => { 'nodeinfo/fetch',
// @ts-ignore: ??? async(_arg, { getState }) => {
return dispatch(fetchInstance()); return await api(getState).get('/nodeinfo/2.1.json');
}); },
}; );
}
export function fetchNodeinfo() {
return (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: NODEINFO_FETCH_REQUEST });
return api(getState).get('/nodeinfo/2.1.json').then(({ data: nodeinfo }) => {
return dispatch({ type: NODEINFO_FETCH_SUCCESS, nodeinfo });
}).catch((error: Error) => {
return dispatch({ type: NODEINFO_FETCH_FAIL, error, skipAlert: true });
});
};
}

@ -1,3 +1,4 @@
import KVStore from 'soapbox/storage/kv_store';
import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth'; import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth';
import api from '../api'; import api from '../api';
@ -46,12 +47,26 @@ export function fetchMe() {
}; };
} }
/** Update the auth account in IndexedDB for Mastodon, etc. */
const persistAuthAccount = (account, params) => {
if (account && account.url) {
if (!account.pleroma) account.pleroma = {};
if (!account.pleroma.settings_store) {
account.pleroma.settings_store = params.pleroma_settings_store || {};
}
KVStore.setItem(`authAccount:${account.url}`, account).catch(console.error);
}
};
export function patchMe(params) { export function patchMe(params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(patchMeRequest()); dispatch(patchMeRequest());
return api(getState) return api(getState)
.patch('/api/v1/accounts/update_credentials', params) .patch('/api/v1/accounts/update_credentials', params)
.then(response => { .then(response => {
persistAuthAccount(response.data, params);
dispatch(patchMeSuccess(response.data)); dispatch(patchMeSuccess(response.data));
}).catch(error => { }).catch(error => {
dispatch(patchMeFail(error)); dispatch(patchMeFail(error));

@ -1,7 +1,8 @@
export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE'; export const MODAL_CLOSE = 'MODAL_CLOSE';
export function openModal(type, props) { /** Open a modal of the given type */
export function openModal(type: string, props?: any) {
return { return {
type: MODAL_OPEN, type: MODAL_OPEN,
modalType: type, modalType: type,
@ -9,7 +10,8 @@ export function openModal(type, props) {
}; };
} }
export function closeModal(type) { /** Close the modal */
export function closeModal(type: string) {
return { return {
type: MODAL_CLOSE, type: MODAL_CLOSE,
modalType: type, modalType: type,

@ -98,7 +98,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale,
const isOnNotificationsPage = curPath === '/notifications'; const isOnNotificationsPage = curPath === '/notifications';
if (notification.type === 'mention') { if (['mention', 'status'].includes(notification.type)) {
const regex = regexFromFilters(filters); const regex = regexFromFilters(filters);
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
filtered = regex && regex.test(searchIndex); filtered = regex && regex.test(searchIndex);
@ -121,7 +121,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale,
}).catch(console.error); }).catch(console.error);
}).catch(console.error); }).catch(console.error);
} }
} catch(e) { } catch (e) {
console.warn(e); console.warn(e);
} }
@ -170,7 +170,7 @@ export function dequeueNotifications() {
const excludeTypesFromSettings = getState => getSettings(getState()).getIn(['notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromSettings = getState => getSettings(getState()).getIn(['notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => { const excludeTypesFromFilter = filter => {
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'move', 'pleroma:emoji_reaction']); const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'status', 'poll', 'move', 'pleroma:emoji_reaction']);
return allTypes.filterNot(item => item === filter).toJS(); return allTypes.filterNot(item => item === filter).toJS();
}; };

@ -1,8 +0,0 @@
import { changeSetting, saveSettings } from './settings';
export const INTRODUCTION_VERSION = 20181216044202;
export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings());
};

@ -0,0 +1,40 @@
const ONBOARDING_START = 'ONBOARDING_START';
const ONBOARDING_END = 'ONBOARDING_END';
const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding';
type OnboardingStartAction = {
type: typeof ONBOARDING_START
}
type OnboardingEndAction = {
type: typeof ONBOARDING_END
}
export type OnboardingActions = OnboardingStartAction | OnboardingEndAction
const checkOnboardingStatus = () => (dispatch: React.Dispatch<OnboardingActions>) => {
const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1';
if (needsOnboarding) {
dispatch({ type: ONBOARDING_START });
}
};
const startOnboarding = () => (dispatch: React.Dispatch<OnboardingActions>) => {
localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1');
dispatch({ type: ONBOARDING_START });
};
const endOnboarding = () => (dispatch: React.Dispatch<OnboardingActions>) => {
localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY);
dispatch({ type: ONBOARDING_END });
};
export {
ONBOARDING_END,
ONBOARDING_START,
checkOnboardingStatus,
endOnboarding,
startOnboarding,
};

@ -1,6 +1,6 @@
import api from '../api'; import api from '../api';
import { openModal, closeModal } from './modals'; import { openModal } from './modals';
export const REPORT_INIT = 'REPORT_INIT'; export const REPORT_INIT = 'REPORT_INIT';
export const REPORT_CANCEL = 'REPORT_CANCEL'; export const REPORT_CANCEL = 'REPORT_CANCEL';
@ -14,6 +14,8 @@ export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE';
export const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE'; export const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
export const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
export function initReport(account, status) { export function initReport(account, status) {
return dispatch => { return dispatch => {
dispatch({ dispatch({
@ -54,16 +56,15 @@ export function toggleStatusReport(statusId, checked) {
export function submitReport() { export function submitReport() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(submitReportRequest()); dispatch(submitReportRequest());
const { reports } = getState();
api(getState).post('/api/v1/reports', {
account_id: getState().getIn(['reports', 'new', 'account_id']), return api(getState).post('/api/v1/reports', {
status_ids: getState().getIn(['reports', 'new', 'status_ids']), account_id: reports.getIn(['new', 'account_id']),
comment: getState().getIn(['reports', 'new', 'comment']), status_ids: reports.getIn(['new', 'status_ids']),
forward: getState().getIn(['reports', 'new', 'forward']), rule_ids: reports.getIn(['new', 'rule_ids']),
}).then(response => { comment: reports.getIn(['new', 'comment']),
dispatch(closeModal()); forward: reports.getIn(['new', 'forward']),
dispatch(submitReportSuccess(response.data)); });
}).catch(error => dispatch(submitReportFail(error)));
}; };
} }
@ -73,10 +74,9 @@ export function submitReportRequest() {
}; };
} }
export function submitReportSuccess(report) { export function submitReportSuccess() {
return { return {
type: REPORT_SUBMIT_SUCCESS, type: REPORT_SUBMIT_SUCCESS,
report,
}; };
} }
@ -107,3 +107,10 @@ export function changeReportBlock(block) {
block, block,
}; };
} }
export function changeReportRule(ruleId) {
return {
type: REPORT_RULE_CHANGE,
rule_id: ruleId,
};
}

@ -0,0 +1,31 @@
import api from '../api';
import type { Rule } from 'soapbox/reducers/rules';
const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST';
const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS';
type RulesFetchRequestAction = {
type: typeof RULES_FETCH_REQUEST
}
type RulesFetchRequestSuccessAction = {
type: typeof RULES_FETCH_SUCCESS
payload: Rule[]
}
export type RulesActions = RulesFetchRequestAction | RulesFetchRequestSuccessAction
const fetchRules = () => (dispatch: React.Dispatch<RulesActions>, getState: any) => {
dispatch({ type: RULES_FETCH_REQUEST });
return api(getState)
.get('/api/v1/instance/rules')
.then((response) => dispatch({ type: RULES_FETCH_SUCCESS, payload: response.data }));
};
export {
fetchRules,
RULES_FETCH_REQUEST,
RULES_FETCH_SUCCESS,
};

@ -6,6 +6,7 @@
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { getLoggedInAccount } from 'soapbox/utils/auth'; import { getLoggedInAccount } from 'soapbox/utils/auth';
import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features';
import api from '../api'; import api from '../api';
@ -84,12 +85,22 @@ export function changePassword(oldPassword, newPassword, confirmation) {
export function resetPassword(usernameOrEmail) { export function resetPassword(usernameOrEmail) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState();
const v = parseVersion(state.instance);
dispatch({ type: RESET_PASSWORD_REQUEST }); dispatch({ type: RESET_PASSWORD_REQUEST });
const params = const params =
usernameOrEmail.includes('@') usernameOrEmail.includes('@')
? { email: usernameOrEmail } ? { email: usernameOrEmail }
: { username: usernameOrEmail }; : { nickname: usernameOrEmail, username: usernameOrEmail };
return api(getState).post('/api/v1/truth/password_reset/request', params).then(() => {
const endpoint =
v.software === TRUTHSOCIAL
? '/api/v1/truth/password_reset/request'
: '/auth/password';
return api(getState).post(endpoint, params).then(() => {
dispatch({ type: RESET_PASSWORD_SUCCESS }); dispatch({ type: RESET_PASSWORD_SUCCESS });
}).catch(error => { }).catch(error => {
dispatch({ type: RESET_PASSWORD_FAIL, error }); dispatch({ type: RESET_PASSWORD_FAIL, error });

@ -1,12 +1,11 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { v4 as uuid } from 'uuid';
import { patchMe } from 'soapbox/actions/me'; import { patchMe } from 'soapbox/actions/me';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import uuid from '../uuid';
import { showAlertForError } from './alerts'; import { showAlertForError } from './alerts';
import snackbar from './snackbar'; import snackbar from './snackbar';
@ -85,7 +84,7 @@ export const defaultSettings = ImmutableMap({
shows: ImmutableMap({ shows: ImmutableMap({
follow: true, follow: true,
follow_request: false, follow_request: true,
favourite: true, favourite: true,
reblog: true, reblog: true,
mention: true, mention: true,

@ -47,21 +47,34 @@ export function rememberSoapboxConfig(host) {
}; };
} }
export function fetchFrontendConfigurations() {
return (dispatch, getState) => {
return api(getState)
.get('/api/pleroma/frontend_configurations')
.then(({ data }) => data);
};
}
/** Conditionally fetches Soapbox config depending on backend features */
export function fetchSoapboxConfig(host) { export function fetchSoapboxConfig(host) {
return (dispatch, getState) => { return (dispatch, getState) => {
api(getState).get('/api/pleroma/frontend_configurations').then(response => { const features = getFeatures(getState().instance);
if (response.data.soapbox_fe) {
dispatch(importSoapboxConfig(response.data.soapbox_fe, host)); if (features.frontendConfigurations) {
} else { return dispatch(fetchFrontendConfigurations()).then(data => {
dispatch(fetchSoapboxJson(host)); if (data.soapbox_fe) {
} dispatch(importSoapboxConfig(data.soapbox_fe, host));
}).catch(error => { } else {
dispatch(fetchSoapboxJson(host)); dispatch(fetchSoapboxJson(host));
}); }
});
} else {
return dispatch(fetchSoapboxJson(host));
}
}; };
} }
// Tries to remember the config from browser storage before fetching it /** Tries to remember the config from browser storage before fetching it */
export function loadSoapboxConfig() { export function loadSoapboxConfig() {
return (dispatch, getState) => { return (dispatch, getState) => {
const host = getHost(getState()); const host = getHost(getState());

@ -2,7 +2,7 @@ import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures, parseVersion } from 'soapbox/utils/features'; import { getFeatures, parseVersion } from 'soapbox/utils/features';
import { shouldHaveCard } from 'soapbox/utils/status'; import { shouldHaveCard } from 'soapbox/utils/status';
import api from '../api'; import api, { getNextLink } from '../api';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
import { openModal } from './modals'; import { openModal } from './modals';
@ -167,12 +167,49 @@ export function fetchContext(id) {
}; };
} }
export function fetchNext(next) {
return async(dispatch, getState) => {
const response = await api(getState).get(next);
dispatch(importFetchedStatuses(response.data));
return { next: getNextLink(response) };
};
}
export function fetchAncestors(id) {
return async(dispatch, getState) => {
const response = await api(getState).get(`/api/v1/statuses/${id}/context/ancestors`);
dispatch(importFetchedStatuses(response.data));
return response;
};
}
export function fetchDescendants(id) {
return async(dispatch, getState) => {
const response = await api(getState).get(`/api/v1/statuses/${id}/context/descendants`);
dispatch(importFetchedStatuses(response.data));
return response;
};
}
export function fetchStatusWithContext(id) { export function fetchStatusWithContext(id) {
return (dispatch, getState) => { return async(dispatch, getState) => {
return Promise.all([ const features = getFeatures(getState().instance);
dispatch(fetchContext(id)),
dispatch(fetchStatus(id)), if (features.paginatedContext) {
]); const responses = await Promise.all([
dispatch(fetchAncestors(id)),
dispatch(fetchDescendants(id)),
dispatch(fetchStatus(id)),
]);
const next = getNextLink(responses[1]);
return { next };
} else {
await Promise.all([
dispatch(fetchContext(id)),
dispatch(fetchStatus(id)),
]);
return { next: undefined };
}
}; };
} }

@ -24,6 +24,10 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
return new LinkHeader(response.headers?.link); return new LinkHeader(response.headers?.link);
}; };
export const getNextLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
};
const getToken = (state: RootState, authType: string) => { const getToken = (state: RootState, authType: string) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state); return authType === 'app' ? getAppToken(state) : getAccessToken(state);
}; };
@ -31,7 +35,7 @@ const getToken = (state: RootState, authType: string) => {
const maybeParseJSON = (data: string) => { const maybeParseJSON = (data: string) => {
try { try {
return JSON.parse(data); return JSON.parse(data);
} catch(Exception) { } catch (Exception) {
return data; return data;
} }
}; };

@ -3,9 +3,12 @@
import 'intl'; import 'intl';
import 'intl/locale-data/jsonp/en'; import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement'; import 'es6-symbol/implement';
// @ts-ignore: No types
import includes from 'array-includes'; import includes from 'array-includes';
// @ts-ignore: No types
import isNaN from 'is-nan'; import isNaN from 'is-nan';
import assign from 'object-assign'; import assign from 'object-assign';
// @ts-ignore: No types
import values from 'object.values'; import values from 'object.values';
import { decode as decodeBase64 } from './utils/base64'; import { decode as decodeBase64 } from './utils/base64';
@ -30,7 +33,7 @@ if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,'; const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value(callback, type = 'image/png', quality) { value(callback: any, type = 'image/png', quality: any) {
const dataURL = this.toDataURL(type, quality); const dataURL = this.toDataURL(type, quality);
let data; let data;

@ -1,6 +1,6 @@
'use strict'; 'use strict';
export default function compareId(id1, id2) { export default function compareId(id1: string, id2: string) {
if (id1 === id2) { if (id1 === id2) {
return 0; return 0;
} }

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import VerificationBadge from 'soapbox/components/verification_badge'; import VerificationBadge from 'soapbox/components/verification_badge';
@ -13,6 +13,25 @@ import { Avatar, HStack, IconButton, Text } from './ui';
import type { Account as AccountEntity } from 'soapbox/types/entities'; import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon {
account: AccountEntity,
}
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account }) => {
const history = useHistory();
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
history.push(`/timeline/${account.domain}`);
};
return (
<button className='w-4 h-4 flex-none' onClick={handleClick}>
<img src={account.favicon} alt='' title={account.domain} className='w-full max-h-full' />
</button>
);
};
interface IProfilePopper { interface IProfilePopper {
condition: boolean, condition: boolean,
wrapper: (children: any) => React.ReactElement<any, any> wrapper: (children: any) => React.ReactElement<any, any>
@ -35,6 +54,7 @@ interface IAccount {
showProfileHoverCard?: boolean, showProfileHoverCard?: boolean,
timestamp?: string | Date, timestamp?: string | Date,
timestampUrl?: string, timestampUrl?: string,
withDate?: boolean,
withRelationship?: boolean, withRelationship?: boolean,
} }
@ -51,6 +71,7 @@ const Account = ({
showProfileHoverCard = true, showProfileHoverCard = true,
timestamp, timestamp,
timestampUrl, timestampUrl,
withDate = false,
withRelationship = true, withRelationship = true,
}: IAccount) => { }: IAccount) => {
const overflowRef = React.useRef<HTMLDivElement>(null); const overflowRef = React.useRef<HTMLDivElement>(null);
@ -89,7 +110,7 @@ const Account = ({
); );
} }
if (account.get('id') !== me && account.get('relationship', null) !== null) { if (account.id !== me) {
return <ActionButton account={account} />; return <ActionButton account={account} />;
} }
@ -116,39 +137,41 @@ const Account = ({
if (hidden) { if (hidden) {
return ( return (
<> <>
{account.get('display_name')} {account.display_name}
{account.get('username')} {account.username}
</> </>
); );
} }
if (withDate) timestamp = account.created_at;
const LinkEl: any = showProfileHoverCard ? Link : 'div'; const LinkEl: any = showProfileHoverCard ? Link : 'div';
return ( return (
<div data-testid='account' className='flex-shrink-0 group block w-full overflow-hidden' ref={overflowRef}> <div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'> <HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems='center' space={3} grow> <HStack alignItems='center' space={3} grow>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
> >
<LinkEl <LinkEl
to={`/@${account.get('acct')}`} to={`/@${account.acct}`}
title={account.get('acct')} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<Avatar src={account.get('avatar')} size={avatarSize} /> <Avatar src={account.avatar} size={avatarSize} />
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
<div className='flex-grow'> <div className='flex-grow'>
<ProfilePopper <ProfilePopper
condition={showProfileHoverCard} condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>} wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
> >
<LinkEl <LinkEl
to={`/@${account.get('acct')}`} to={`/@${account.acct}`}
title={account.get('acct')} title={account.acct}
onClick={(event: React.MouseEvent) => event.stopPropagation()} onClick={(event: React.MouseEvent) => event.stopPropagation()}
> >
<div className='flex items-center space-x-1 flex-grow' style={style}> <div className='flex items-center space-x-1 flex-grow' style={style}>
@ -156,10 +179,10 @@ const Account = ({
size='sm' size='sm'
weight='semibold' weight='semibold'
truncate truncate
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/> />
{account.get('verified') && <VerificationBadge />} {account.verified && <VerificationBadge />}
</div> </div>
</LinkEl> </LinkEl>
</ProfilePopper> </ProfilePopper>
@ -167,16 +190,20 @@ const Account = ({
<HStack alignItems='center' space={1} style={style}> <HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text> <Text theme='muted' size='sm' truncate>@{username}</Text>
{account.favicon && (
<InstanceFavicon account={account} />
)}
{(timestamp) ? ( {(timestamp) ? (
<> <>
<Text tag='span' theme='muted' size='sm'>&middot;</Text> <Text tag='span' theme='muted' size='sm'>&middot;</Text>
{timestampUrl ? ( {timestampUrl ? (
<Link to={timestampUrl} className='hover:underline'> <Link to={timestampUrl} className='hover:underline'>
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' /> <RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' />
</Link> </Link>
) : ( ) : (
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' /> <RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' />
)} )}
</> </>
) : null} ) : null}

@ -206,8 +206,8 @@ export default class AutosuggestInput extends ImmutablePureComponent {
key={key} key={key}
data-index={i} data-index={i}
className={classNames({ className={classNames({
'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true, 'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-400 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 group': true,
'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion, 'bg-gray-100 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-gray-700': i === selectedSuggestion,
})} })}
onMouseDown={this.onSuggestionClick} onMouseDown={this.onSuggestionClick}
> >
@ -238,7 +238,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
return menu.map((item, i) => ( return menu.map((item, i) => (
<a <a
className={classNames('flex items-center space-x-2 px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100', { selected: suggestions.size - selectedSuggestion === i })} className={classNames('flex items-center space-x-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-400 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700', { selected: suggestions.size - selectedSuggestion === i })}
href='#' href='#'
role='button' role='button'
tabIndex='0' tabIndex='0'
@ -272,7 +272,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
<input <input
type='text' type='text'
className={classNames({ className={classNames({
'block w-full sm:text-sm focus:ring-indigo-500 focus:border-indigo-500': true, 'block w-full sm:text-sm dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-indigo-500 focus:border-indigo-500': true,
[className]: typeof className !== 'undefined', [className]: typeof className !== 'undefined',
})} })}
ref={this.setInput} ref={this.setInput}
@ -293,7 +293,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
/> />
<div className={classNames({ <div className={classNames({
'absolute top-full w-full z-50 shadow bg-white rounded-lg py-1': true, 'absolute top-full w-full z-50 shadow bg-white dark:bg-slate-800 rounded-lg py-1': true,
hidden: !visible, hidden: !visible,
block: visible, block: visible,
'autosuggest-textarea__suggestions--visible': visible, 'autosuggest-textarea__suggestions--visible': visible,

@ -216,8 +216,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
key={key} key={key}
data-index={i} data-index={i}
className={classNames({ className={classNames({
'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true, 'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-400 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 group': true,
'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion, 'bg-gray-100 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-700': i === selectedSuggestion,
})} })}
onMouseDown={this.onSuggestionClick} onMouseDown={this.onSuggestionClick}
> >
@ -257,7 +257,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<Textarea <Textarea
ref={this.setTextarea} ref={this.setTextarea}
className={classNames('dark:bg-slate-800 px-0 border-0 text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 resize-none w-full focus:shadow-none focus:border-0 focus:ring-0', { className={classNames('transition-[min-height] motion-reduce:transition-none dark:bg-slate-800 px-0 border-0 text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 resize-none w-full focus:shadow-none focus:border-0 focus:ring-0', {
'min-h-[40px]': condensed,
'min-h-[100px]': !condensed, 'min-h-[100px]': !condensed,
})} })}
id={id} id={id}
@ -284,7 +285,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<div <div
style={this.setPortalPosition()} style={this.setPortalPosition()}
className={classNames({ className={classNames({
'fixed z-1000 shadow bg-white rounded-lg py-1 space-y-0': true, 'fixed z-1000 shadow bg-white dark:bg-slate-900 rounded-lg py-1 space-y-0': true,
hidden: suggestionsHidden || suggestions.isEmpty(), hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(), block: !suggestionsHidden && !suggestions.isEmpty(),
})} })}

@ -1,13 +1,26 @@
import PropTypes from 'prop-types'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
const Badge = (props: any) => ( interface IBadge {
<span data-testid='badge' className={'badge badge--' + props.slug}>{props.title}</span> title: string,
); slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque',
}
Badge.propTypes = { /** Badge to display on a user's profile. */
title: PropTypes.string.isRequired, const Badge: React.FC<IBadge> = ({ title, slug }) => (
slug: PropTypes.string.isRequired, <span
}; data-testid='badge'
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white', {
'bg-fuchsia-700': slug === 'patron',
'bg-yellow-500': slug === 'donor',
'bg-black': slug === 'admin',
'bg-cyan-600': slug === 'moderator',
'bg-gray-100 text-gray-800': slug === 'bot',
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
})}
>
{title}
</span>
);
export default Badge; export default Badge;

@ -0,0 +1,47 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchBirthdayReminders } from 'soapbox/actions/accounts';
import { Widget } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
interface IBirthdayPanel {
limit: number
}
const BirthdayPanel = ({ limit }: IBirthdayPanel) => {
const dispatch = useDispatch();
const birthdays: ImmutableOrderedSet<string> = useAppSelector(state => state.user_lists.getIn(['birthday_reminders', state.me], ImmutableOrderedSet()));
const birthdaysToRender = birthdays.slice(0, limit);
React.useEffect(() => {
const date = new Date();
const day = date.getDate();
const month = date.getMonth() + 1;
dispatch(fetchBirthdayReminders(month, day));
}, []);
if (birthdaysToRender.isEmpty()) {
return null;
}
return (
<Widget title={<FormattedMessage id='birthday_panel.title' defaultMessage='Birthdays' />}>
{birthdaysToRender.map(accountId => (
<AccountContainer
key={accountId}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={accountId}
/>
))}
</Widget>
);
};
export default BirthdayPanel;

@ -1,161 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { HotKeys } from 'react-hotkeys';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { fetchBirthdayReminders } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { HStack, Text } from 'soapbox/components/ui';
import { makeGetAccount } from 'soapbox/selectors';
const mapStateToProps = (state, props) => {
const me = state.get('me');
const getAccount = makeGetAccount();
const birthdays = state.getIn(['user_lists', 'birthday_reminders', me]);
if (birthdays?.size > 0) {
return {
birthdays,
account: getAccount(state, birthdays.first()),
};
}
return {
birthdays,
};
};
export default @connect(mapStateToProps)
@injectIntl
class BirthdayReminders extends ImmutablePureComponent {
static propTypes = {
birthdays: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
onMoveDown: PropTypes.func,
};
componentDidMount() {
const { dispatch } = this.props;
const date = new Date();
const day = date.getDate();
const month = date.getMonth() + 1;
dispatch(fetchBirthdayReminders(day, month));
}
getHandlers() {
return {
open: this.handleOpenBirthdaysModal,
moveDown: this.props.onMoveDown,
};
}
handleOpenBirthdaysModal = () => {
const { dispatch } = this.props;
dispatch(openModal('BIRTHDAYS'));
}
renderMessage() {
const { birthdays, account } = this.props;
const link = (
<bdi>
<Link
className='text-gray-800 dark:text-gray-200 font-bold hover:underline'
title={account.get('acct')}
to={`/@${account.get('acct')}`}
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
/>
</bdi>
);
if (birthdays.size === 1) {
return <FormattedMessage id='notification.birthday' defaultMessage='{name} has a birthday today' values={{ name: link }} />;
}
return (
<FormattedMessage
id='notification.birthday_plural'
defaultMessage='{name} and {more} have birthday today'
values={{
name: link,
more: (
<span type='button' role='presentation' onClick={this.handleOpenBirthdaysModal}>
<FormattedMessage
id='notification.birthday.more'
defaultMessage='{count} more {count, plural, one {friend} other {friends}}'
values={{ count: birthdays.size - 1 }}
/>
</span>
),
}}
/>
);
}
renderMessageForScreenReader = () => {
const { intl, birthdays, account } = this.props;
if (birthdays.size === 1) {
return intl.formatMessage({ id: 'notification.birthday', defaultMessage: '{name} has a birthday today' }, { name: account.get('display_name') });
}
return intl.formatMessage(
{
id: 'notification.birthday_plural',
defaultMessage: '{name} and {more} have birthday today',
},
{
name: account.get('display_name'),
more: intl.formatMessage(
{
id: 'notification.birthday.more',
defaultMessage: '{count} more {count, plural, one {friend} other {friends}}',
},
{ count: birthdays.size - 1 },
),
},
);
}
render() {
const { birthdays } = this.props;
if (!birthdays || birthdays.size === 0) return null;
return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-birthday focusable' tabIndex='0' title={this.renderMessageForScreenReader()}>
<div className='p-4 focusable'>
<HStack alignItems='center' space={1.5}>
<Icon
src={require('@tabler/icons/icons/ballon.svg')}
className='text-primary-600'
/>
<Text
theme='muted'
size='sm'
>
{this.renderMessage()}
</Text>
</HStack>
</div>
</div>
</HotKeys>
);
}
}

@ -1,68 +0,0 @@
// @ts-check
import { decode } from 'blurhash';
import PropTypes from 'prop-types';
import React, { useRef, useEffect } from 'react';
/**
* @typedef BlurhashPropsBase
* @property {string?} hash Hash to render
* @property {number} width
* Width of the blurred region in pixels. Defaults to 32
* @property {number} [height]
* Height of the blurred region in pixels. Defaults to width
* @property {boolean} [dummy]
* Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched
*/
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
/**
* Component that is used to render blurred of blurhash string
*
* @param {BlurhashProps} param1 Props of the component
* @returns Canvas which will render blurred region element to embed
*/
function Blurhash({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) {
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
useEffect(() => {
const { current: canvas } = canvasRef;
// resets canvas
canvas.width = canvas.width; // eslint-disable-line no-self-assign
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
// @ts-ignore
ctx.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
}
Blurhash.propTypes = {
hash: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
dummy: PropTypes.bool,
};
export default React.memo(Blurhash);

@ -0,0 +1,59 @@
import { decode } from 'blurhash';
import React, { useRef, useEffect } from 'react';
interface IBlurhash {
/** Hash to render */
hash: string | null | undefined,
/** Width of the blurred region in pixels. Defaults to 32. */
width?: number,
/** Height of the blurred region in pixels. Defaults to width. */
height?: number,
/**
* Whether dummy mode is enabled. If enabled, nothing is rendered
* and canvas left untouched.
*/
dummy?: boolean,
/** className of the canvas element. */
className?: string,
}
/**
* Renders a blurhash in a canvas element.
* @see {@link https://blurha.sh/}
*/
const Blurhash: React.FC<IBlurhash> = ({
hash,
width = 32,
height = width,
dummy = false,
...canvasProps
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const { current: canvas } = canvasRef;
if (!canvas) return;
// resets canvas
canvas.width = canvas.width; // eslint-disable-line no-self-assign
if (dummy || !hash) return;
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, width, height);
if (!ctx) return;
ctx.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}
}, [dummy, hash, width, height]);
return (
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
);
};
export default React.memo(Blurhash);

@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from './icon_button';
const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
export default @injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
domain: PropTypes.string,
onUnblockDomain: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleDomainUnblock = () => {
this.props.onUnblockDomain(this.props.domain);
}
render() {
const { domain, intl } = this.props;
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton active src={require('@tabler/icons/icons/lock-open.svg')} title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} />
</div>
</div>
</div>
);
}
}

@ -0,0 +1,51 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { unblockDomain } from 'soapbox/actions/domain_blocks';
import IconButton from './icon_button';
const messages = defineMessages({
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
});
interface IDomain {
domain: string,
}
const Domain: React.FC<IDomain> = ({ domain }) => {
const dispatch = useDispatch();
const intl = useIntl();
// const onBlockDomain = () => {
// dispatch(openModal('CONFIRM', {
// icon: require('@tabler/icons/icons/ban.svg'),
// heading: <FormattedMessage id='confirmations.domain_block.heading' defaultMessage='Block {domain}' values={{ domain }} />,
// message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
// confirm: intl.formatMessage(messages.blockDomainConfirm),
// onConfirm: () => dispatch(blockDomain(domain)),
// }));
// }
const handleDomainUnblock = () => {
dispatch(unblockDomain(domain));
};
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__buttons'>
<IconButton active src={require('@tabler/icons/icons/lock-open.svg')} title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={handleDomainUnblock} />
</div>
</div>
</div>
);
};
export default Domain;

@ -6,8 +6,8 @@ import { spring } from 'react-motion';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import Icon from 'soapbox/components/icon'; import { IconButton, Counter } from 'soapbox/components/ui';
import { IconButton } from 'soapbox/components/ui'; import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import Motion from 'soapbox/features/ui/util/optional_motion'; import Motion from 'soapbox/features/ui/util/optional_motion';
import type { Status } from 'soapbox/types/entities'; import type { Status } from 'soapbox/types/entities';
@ -18,12 +18,13 @@ let id = 0;
export interface MenuItem { export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>, action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>, middleClick?: React.EventHandler<React.MouseEvent>,
text: string, text: string | JSX.Element,
href?: string, href?: string,
to?: string, to?: string,
newTab?: boolean, newTab?: boolean,
isLogout?: boolean, isLogout?: boolean,
icon: string, icon: string,
count?: number,
destructive?: boolean, destructive?: boolean,
} }
@ -174,10 +175,10 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
} }
const { text, href, to, newTab, isLogout, icon, destructive } = option; const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
return ( return (
<li className={classNames('dropdown-menu__item', { destructive })} key={`${text}-${i}`}> <li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
<a <a
href={href || to || '#'} href={href || to || '#'}
role='button' role='button'
@ -190,8 +191,15 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
target={newTab ? '_blank' : undefined} target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined} data-method={isLogout ? 'delete' : undefined}
> >
{icon && <Icon src={icon} />} {icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
{text}
<span className='truncate'>{text}</span>
{count ? (
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
</span>
) : null}
</a> </a>
</li> </li>
); );
@ -278,7 +286,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
onShiftClick(e); onShiftClick(e);
} else if (this.state.id === openDropdownId) { } else if (this.state.id === openDropdownId) {
this.handleClose(); this.handleClose();
} else if(onOpen) { } else if (onOpen) {
const { top } = e.currentTarget.getBoundingClientRect(); const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top'; const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';

@ -0,0 +1,146 @@
import classNames from 'classnames';
import React, { useState, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
import { useDispatch } from 'react-redux';
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
import { openModal } from 'soapbox/actions/modals';
import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector';
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is_mobile';
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
interface IEmojiButtonWrapper {
statusId: string,
children: JSX.Element,
}
/** Provides emoji reaction functionality to the underlying button component */
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useDispatch();
const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
const soapboxConfig = useSoapboxConfig();
const timeout = useRef<NodeJS.Timeout>();
const [visible, setVisible] = useState(false);
// const [focused, setFocused] = useState(false);
// `useRef` won't trigger a re-render, while `useState` does.
// https://popper.js.org/react-popper/v2/
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, []);
if (!status) return null;
const handleMouseEnter = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
if (!isUserTouching()) {
setVisible(true);
}
};
const handleMouseLeave = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
// Unless the user is touching, delay closing the emoji selector briefly
// so the user can move the mouse diagonally to make a selection.
if (isUserTouching()) {
setVisible(false);
} else {
timeout.current = setTimeout(() => {
setVisible(false);
}, 500);
}
};
const handleReact = (emoji: string): void => {
if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji));
} else {
dispatch(openModal('UNAUTHORIZED', {
action: 'FAVOURITE',
ap_id: status.url,
}));
}
setVisible(false);
};
const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
if (isUserTouching()) {
if (visible) {
handleReact(meEmojiReact);
} else {
setVisible(true);
}
} else {
handleReact(meEmojiReact);
}
e.preventDefault();
e.stopPropagation();
};
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
// setFocused(false);
// };
const selector = (
<div
className={classNames('z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<EmojiSelector
emojis={soapboxConfig.allowedEmoji}
onReact={handleReact}
// focused={focused}
// onUnfocus={handleUnfocus}
/>
</div>
);
return (
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, {
onClick: handleClick,
ref: setReferenceElement,
})}
{selector}
</div>
);
};
export default EmojiButtonWrapper;

@ -1,43 +1,59 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { NODE_ENV } from 'soapbox/build_config'; import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import * as BuildConfig from 'soapbox/build_config';
import { Text, Stack } from 'soapbox/components/ui'; import { Text, Stack } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { captureException } from 'soapbox/monitoring'; import { captureException } from 'soapbox/monitoring';
import KVStore from 'soapbox/storage/kv_store';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { getSoapboxConfig } from '../actions/soapbox'; import type { RootState } from 'soapbox/store';
const mapStateToProps = (state) => { const goHome = () => location.href = '/';
const soapboxConfig = getSoapboxConfig(state);
/** Unregister the ServiceWorker */
// https://stackoverflow.com/a/49771828/8811886
const unregisterSw = async() => {
if (!navigator.serviceWorker) return;
const registrations = await navigator.serviceWorker.getRegistrations();
const unregisterAll = registrations.map(r => r.unregister());
await Promise.all(unregisterAll);
};
const mapStateToProps = (state: RootState) => {
const { links, logo } = getSoapboxConfig(state);
return { return {
siteTitle: state.instance.title, siteTitle: state.instance.title,
helpLink: soapboxConfig.getIn(['links', 'help']), logo,
supportLink: soapboxConfig.getIn(['links', 'support']), links,
statusLink: soapboxConfig.getIn(['links', 'status']),
}; };
}; };
@connect(mapStateToProps) type Props = ReturnType<typeof mapStateToProps>;
class ErrorBoundary extends React.PureComponent {
static propTypes = { type State = {
children: PropTypes.node, hasError: boolean,
siteTitle: PropTypes.string, error: any,
supportLink: PropTypes.string, componentStack: any,
helpLink: PropTypes.string, browser?: Bowser.Parser.Parser,
statusLink: PropTypes.string, }
};
class ErrorBoundary extends React.PureComponent<Props, State> {
state = { state: State = {
hasError: false, hasError: false,
error: undefined,
componentStack: undefined, componentStack: undefined,
browser: undefined,
} }
componentDidCatch(error, info) { textarea: HTMLTextAreaElement | null = null;
componentDidCatch(error: any, info: any): void {
captureException(error); captureException(error);
this.setState({ this.setState({
@ -55,11 +71,11 @@ class ErrorBoundary extends React.PureComponent {
.catch(() => {}); .catch(() => {});
} }
setTextareaRef = c => { setTextareaRef: React.RefCallback<HTMLTextAreaElement> = c => {
this.textarea = c; this.textarea = c;
} }
handleCopy = e => { handleCopy: React.MouseEventHandler = () => {
if (!this.textarea) return; if (!this.textarea) return;
this.textarea.select(); this.textarea.select();
@ -68,25 +84,31 @@ class ErrorBoundary extends React.PureComponent {
document.execCommand('copy'); document.execCommand('copy');
} }
getErrorText = () => { getErrorText = (): string => {
const { error, componentStack } = this.state; const { error, componentStack } = this.state;
return error + componentStack; return error + componentStack;
} }
clearCookies = e => { clearCookies: React.MouseEventHandler = (e) => {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
KVStore.clear();
if ('serviceWorker' in navigator) {
e.preventDefault();
unregisterSw().then(goHome).catch(goHome);
}
} }
render() { render() {
const { browser, hasError } = this.state; const { browser, hasError } = this.state;
const { children, siteTitle, helpLink, statusLink, supportLink } = this.props; const { children, siteTitle, logo, links } = this.props;
if (!hasError) { if (!hasError) {
return children; return children;
} }
const isProduction = NODE_ENV === 'production'; const isProduction = BuildConfig.NODE_ENV === 'production';
const errorText = this.getErrorText(); const errorText = this.getErrorText();
@ -95,7 +117,11 @@ class ErrorBoundary extends React.PureComponent {
<main className='flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'> <main className='flex-grow flex flex-col justify-center max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
<div className='flex-shrink-0 flex justify-center'> <div className='flex-shrink-0 flex justify-center'>
<a href='/' className='inline-flex'> <a href='/' className='inline-flex'>
<img className='h-12 w-12' src='/instance/images/app-icon.png' alt={siteTitle} /> {logo ? (
<img className='h-12 w-12' src={logo} alt={siteTitle} />
) : (
<SvgIcon className='h-12 w-12' src={require('@tabler/icons/icons/home.svg')} alt={siteTitle} />
)}
</a> </a>
</div> </div>
@ -105,14 +131,18 @@ class ErrorBoundary extends React.PureComponent {
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' /> <FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
</h1> </h1>
<p className='text-lg text-gray-500'> <p className='text-lg text-gray-500'>
We're sorry for the interruption. If the problem persists, please reach out to our support team. You <FormattedMessage
may also try to <a href='/' onClick={this.clearCookies} className='text-gray-700 hover:underline'> id='alert.unexpected.body'
<FormattedMessage defaultMessage="We're sorry for the interruption. If the problem persists, please reach out to our support team. You may also try to {clearCookies} (this will log you out)."
id='alert.unexpected.clear_cookies' values={{ clearCookies: (
defaultMessage='clear cookies and browser data' <a href='/' onClick={this.clearCookies} className='text-gray-700 hover:underline'>
/> <FormattedMessage
</a> id='alert.unexpected.clear_cookies'
{' ' }(this will log you out). defaultMessage='clear cookies and browser data'
/>
</a>
) }}
/>
</p> </p>
<Text theme='muted'> <Text theme='muted'>
@ -144,7 +174,7 @@ class ErrorBoundary extends React.PureComponent {
{browser && ( {browser && (
<Stack> <Stack>
<Text weight='semibold'>Browser</Text> <Text weight='semibold'><FormattedMessage id='alert.unexpected.browser' defaultMessage='Browser' /></Text>
<Text theme='muted'>{browser.getBrowserName()} {browser.getBrowserVersion()}</Text> <Text theme='muted'>{browser.getBrowserName()} {browser.getBrowserVersion()}</Text>
</Stack> </Stack>
)} )}
@ -155,28 +185,28 @@ class ErrorBoundary extends React.PureComponent {
<footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'> <footer className='flex-shrink-0 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8'>
<nav className='flex justify-center space-x-4'> <nav className='flex justify-center space-x-4'>
{statusLink && ( {links.get('status') && (
<> <>
<a href={statusLink} className='text-sm font-medium text-gray-500 hover:text-gray-600'> <a href={links.get('status')} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
Status <FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
</a> </a>
</> </>
)} )}
{helpLink && ( {links.get('help') && (
<> <>
<span className='inline-block border-l border-gray-300' aria-hidden='true' /> <span className='inline-block border-l border-gray-300' aria-hidden='true' />
<a href={helpLink} className='text-sm font-medium text-gray-500 hover:text-gray-600'> <a href={links.get('help')} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
Help Center <FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
</a> </a>
</> </>
)} )}
{supportLink && ( {links.get('support') && (
<> <>
<span className='inline-block border-l border-gray-300' aria-hidden='true' /> <span className='inline-block border-l border-gray-300' aria-hidden='true' />
<a href={supportLink} className='text-sm font-medium text-gray-500 hover:text-gray-600'> <a href={links.get('support')} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
Support <FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
</a> </a>
</> </>
)} )}
@ -188,4 +218,4 @@ class ErrorBoundary extends React.PureComponent {
} }
export default ErrorBoundary; export default connect(mapStateToProps)(ErrorBoundary as any);

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { Sparklines, SparklinesCurve } from 'react-sparklines';
@ -11,7 +10,13 @@ import { shortNumberFormat } from '../utils/numbers';
import Permalink from './permalink'; import Permalink from './permalink';
import { HStack, Stack, Text } from './ui'; import { HStack, Stack, Text } from './ui';
const Hashtag = ({ hashtag }) => { import type { Map as ImmutableMap } from 'immutable';
interface IHashtag {
hashtag: ImmutableMap<string, any>,
}
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
const count = Number(hashtag.getIn(['history', 0, 'accounts'])); const count = Number(hashtag.getIn(['history', 0, 'accounts']));
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor')); const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
@ -41,7 +46,7 @@ const Hashtag = ({ hashtag }) => {
<Sparklines <Sparklines
width={40} width={40}
height={28} height={28}
data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()} data={hashtag.get('history').reverse().map((day: ImmutableMap<string, any>) => day.get('uses')).toArray()}
> >
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} /> <SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
</Sparklines> </Sparklines>
@ -51,8 +56,4 @@ const Hashtag = ({ hashtag }) => {
); );
}; };
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
export default Hashtag; export default Hashtag;

@ -1,5 +1,4 @@
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import PropTypes from 'prop-types';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@ -13,10 +12,16 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
dispatch(openProfileHoverCard(ref, accountId)); dispatch(openProfileHoverCard(ref, accountId));
}, 600); }, 600);
export const HoverRefWrapper = ({ accountId, children, inline }) => { interface IHoverRefWrapper {
accountId: string,
inline: boolean,
}
/** Makes a profile hover card appear when the wrapped element is hovered. */
export const HoverRefWrapper: React.FC<IHoverRefWrapper> = ({ accountId, children, inline = false }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const ref = useRef(); const ref = useRef<HTMLDivElement>(null);
const Elem = inline ? 'span' : 'div'; const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (!isMobile(window.innerWidth)) { if (!isMobile(window.innerWidth)) {
@ -47,14 +52,4 @@ export const HoverRefWrapper = ({ accountId, children, inline }) => {
); );
}; };
HoverRefWrapper.propTypes = {
accountId: PropTypes.string,
children: PropTypes.node,
inline: PropTypes.bool,
};
HoverRefWrapper.defaultProps = {
inline: false,
};
export { HoverRefWrapper as default, showProfileHoverCard }; export { HoverRefWrapper as default, showProfileHoverCard };

@ -1,63 +0,0 @@
import classNames from 'classnames';
import React, { useState, useRef } from 'react';
import { usePopper } from 'react-popper';
interface IHoverable {
component: JSX.Element,
}
/** Wrapper to render a given component when hovered */
const Hoverable: React.FC<IHoverable> = ({
component,
children,
}): JSX.Element => {
const [portalActive, setPortalActive] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const popperRef = useRef<HTMLDivElement>(null);
const handleMouseEnter = () => {
setPortalActive(true);
};
const handleMouseLeave = () => {
setPortalActive(false);
};
const { styles, attributes } = usePopper(ref.current, popperRef.current, {
placement: 'top-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
return (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref}
>
{children}
<div
className={classNames('fixed z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !portalActive,
})}
ref={popperRef}
style={styles.popper}
{...attributes.popper}
>
{component}
</div>
</div>
);
};
export default Hoverable;

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { Counter } from 'soapbox/components/ui';
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> { interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
count: number, count: number,
@ -14,9 +14,11 @@ const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) =
<div className='relative'> <div className='relative'>
<Icon id={icon} {...rest} /> <Icon id={icon} {...rest} />
{count > 0 && <i className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'> {count > 0 && (
{shortNumberFormat(count)} <i className='absolute -top-2 -right-2'>
</i>} <Counter count={count} />
</i>
)}
</div> </div>
); );
}; };

@ -1,131 +0,0 @@
import { is } from 'immutable';
import PropTypes from 'prop-types';
import React from 'react';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
export default class IntersectionObserverArticle extends React.Component {
static propTypes = {
intersectionObserverWrapper: PropTypes.object.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
saveHeightKey: PropTypes.string,
cachedHeight: PropTypes.number,
onHeightChange: PropTypes.func,
children: PropTypes.node,
};
state = {
isHidden: false, // set to true in requestIdleCallback to trigger un-render
isIntersecting: true,
}
shouldComponentUpdate(nextProps, nextState) {
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
if (!!isUnrendered !== !!willBeUnrendered) {
// If we're going from rendered to unrendered (or vice versa) then update
return true;
}
// Otherwise, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
}
componentDidMount() {
const { intersectionObserverWrapper, id } = this.props;
intersectionObserverWrapper.observe(
id,
this.node,
this.handleIntersection,
);
this.componentMounted = true;
}
componentWillUnmount() {
const { intersectionObserverWrapper, id } = this.props;
intersectionObserverWrapper.unobserve(id, this.node);
this.componentMounted = false;
}
handleIntersection = (entry) => {
this.entry = entry;
scheduleIdleTask(this.calculateHeight);
this.setState(this.updateStateAfterIntersection);
}
updateStateAfterIntersection = (prevState) => {
if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting: this.entry.isIntersecting,
isHidden: false,
};
}
calculateHeight = () => {
const { onHeightChange, saveHeightKey, id } = this.props;
// save the height of the fully-rendered element (this is expensive
// on Chrome, where we need to fall back to getBoundingClientRect)
this.height = getRectFromEntry(this.entry).height;
if (onHeightChange && saveHeightKey) {
onHeightChange(saveHeightKey, id, this.height);
}
}
hideIfNotIntersecting = () => {
if (!this.componentMounted) {
return;
}
// When the browser gets a chance, test if we're still not intersecting,
// and if so, set our isHidden to true to trigger an unrender. The point of
// this is to save DOM nodes and avoid using up too much memory.
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
}
handleRef = (node) => {
this.node = node;
}
render() {
const { children, id, index, listLength, cachedHeight } = this.props;
const { isIntersecting, isHidden } = this.state;
if (!isIntersecting && (isHidden || cachedHeight)) {
return (
<article
ref={this.handleRef}
aria-posinset={index + 1}
aria-setsize={listLength}
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: true })}
</article>
);
}
return (
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex='0'>
{children && React.cloneElement(children, { hidden: false })}
</article>
);
}
}

@ -1,20 +1,20 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import * as React from 'react'; import * as React from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import Icon from './icon'; import Icon from './icon';
const List: React.FC = ({ children }) => (
const List = ({ children }) => (
<div className='space-y-0.5'>{children}</div> <div className='space-y-0.5'>{children}</div>
); );
List.propTypes = { interface IListItem {
children: PropTypes.node, label: React.ReactNode,
}; hint?: React.ReactNode,
onClick?: () => void,
}
const ListItem = ({ label, hint, children, onClick }) => { const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
const id = uuidv4(); const id = uuidv4();
const domId = `list-group-${id}`; const domId = `list-group-${id}`;
@ -61,11 +61,4 @@ const ListItem = ({ label, hint, children, onClick }) => {
); );
}; };
ListItem.propTypes = {
label: PropTypes.node.isRequired,
hint: PropTypes.node,
children: PropTypes.node,
onClick: PropTypes.func,
};
export { List as default, ListItem }; export { List as default, ListItem };

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from 'soapbox/components/ui';
export default class LoadMore extends React.PureComponent {
static propTypes = {
onClick: PropTypes.func,
disabled: PropTypes.bool,
visible: PropTypes.bool,
}
static defaultProps = {
visible: true,
}
render() {
const { disabled, visible } = this.props;
if (!visible) {
return null;
}
return (
<Button theme='secondary' block disabled={disabled || !visible} onClick={this.props.onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
</Button>
);
}
}

@ -0,0 +1,24 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from 'soapbox/components/ui';
interface ILoadMore {
onClick: () => void,
disabled?: boolean,
visible?: Boolean,
}
const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true }) => {
if (!visible) {
return null;
}
return (
<Button theme='primary' block disabled={disabled || !visible} onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
</Button>
);
};
export default LoadMore;

@ -22,7 +22,7 @@ const ATTACHMENT_LIMIT = 4;
const MAX_FILENAME_LENGTH = 45; const MAX_FILENAME_LENGTH = 45;
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' },
}); });
const mapStateToItemProps = state => ({ const mapStateToItemProps = state => ({

@ -1,68 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
following: {
id: 'morefollows.following_label',
defaultMessage: '…and {count} more {count, plural, one {follow} other {follows}} on remote sites.',
},
followers: {
id: 'morefollows.followers_label',
defaultMessage: '…and {count} more {count, plural, one {follower} other {followers}} on remote sites.',
},
});
const mapStateToProps = state => {
const instance = state.get('instance');
return {
features: getFeatures(instance),
};
};
export default @connect(mapStateToProps)
@injectIntl
class MoreFollows extends React.PureComponent {
static propTypes = {
visible: PropTypes.bool,
count: PropTypes.number,
type: PropTypes.string,
intl: PropTypes.object.isRequired,
features: PropTypes.object.isRequired,
}
static defaultProps = {
visible: true,
}
getMessage = () => {
const { type, count, intl } = this.props;
return intl.formatMessage(messages[type], { count });
}
render() {
const { features } = this.props;
// If the instance isn't federating, there are no remote followers
if (!features.federating) {
return null;
}
return (
<div className='morefollows-indicator'>
<div>
<div className='morefollows-indicator__label' style={{ visibility: this.props.visible ? 'visible' : 'hidden' }}>
{this.getMessage()}
</div>
</div>
</div>
);
}
}

@ -0,0 +1,49 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useAppSelector } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
following: {
id: 'morefollows.following_label',
defaultMessage: '…and {count} more {count, plural, one {follow} other {follows}} on remote sites.',
},
followers: {
id: 'morefollows.followers_label',
defaultMessage: '…and {count} more {count, plural, one {follower} other {followers}} on remote sites.',
},
});
interface IMoreFollows {
visible?: Boolean,
count?: number,
type: 'following' | 'followers',
}
const MoreFollows: React.FC<IMoreFollows> = ({ visible = true, count, type }) => {
const intl = useIntl();
const features = useAppSelector((state) => getFeatures(state.instance));
const getMessage = () => {
return intl.formatMessage(messages[type], { count });
};
// If the instance isn't federating, there are no remote followers
if (!features.federating) {
return null;
}
return (
<div className='morefollows-indicator'>
<div>
<div className='morefollows-indicator__label' style={{ visibility: visible ? 'visible' : 'hidden' }}>
{getMessage()}
</div>
</div>
</div>
);
};
export default MoreFollows;

@ -74,7 +74,7 @@ const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active
<HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'> <HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'>
{!showResults && ( {!showResults && (
<span <span
className={classNames('inline-block w-4 h-4 mr-2.5 border border-solid border-primary-600 rounded-full', { className={classNames('inline-block w-4 h-4 flex-none mr-2.5 border border-solid border-primary-600 rounded-full', {
'bg-primary-600': active, 'bg-primary-600': active,
'rounded': poll.multiple, 'rounded': poll.multiple,
})} })}

@ -1,10 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { usePopper } from 'react-popper'; import { usePopper } from 'react-popper';
import { useSelector, useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { fetchRelationships } from 'soapbox/actions/accounts'; import { fetchRelationships } from 'soapbox/actions/accounts';
@ -16,14 +13,18 @@ import Badge from 'soapbox/components/badge';
import ActionButton from 'soapbox/features/ui/components/action_button'; import ActionButton from 'soapbox/features/ui/components/action_button';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { UserPanel } from 'soapbox/features/ui/util/async-components'; import { UserPanel } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
import { showProfileHoverCard } from './hover_ref_wrapper'; import { showProfileHoverCard } from './hover_ref_wrapper';
import { Card, CardBody, Stack, Text } from './ui'; import { Card, CardBody, Stack, Text } from './ui';
import type { AppDispatch } from 'soapbox/store';
import type { Account } from 'soapbox/types/entities';
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
const getBadges = (account) => { const getBadges = (account: Account): JSX.Element[] => {
const badges = []; const badges = [];
if (account.admin) { if (account.admin) {
@ -36,32 +37,41 @@ const getBadges = (account) => {
badges.push(<Badge key='patron' slug='patron' title='Patron' />); badges.push(<Badge key='patron' slug='patron' title='Patron' />);
} }
if (account.donor) {
badges.push(<Badge key='donor' slug='donor' title='Donor' />);
}
return badges; return badges;
}; };
const handleMouseEnter = (dispatch) => { const handleMouseEnter = (dispatch: AppDispatch): React.MouseEventHandler => {
return e => { return () => {
dispatch(updateProfileHoverCard()); dispatch(updateProfileHoverCard());
}; };
}; };
const handleMouseLeave = (dispatch) => { const handleMouseLeave = (dispatch: AppDispatch): React.MouseEventHandler => {
return e => { return () => {
dispatch(closeProfileHoverCard(true)); dispatch(closeProfileHoverCard(true));
}; };
}; };
export const ProfileHoverCard = ({ visible }) => { interface IProfileHoverCard {
const dispatch = useDispatch(); visible: boolean,
}
/** Popup profile preview that appears when hovering avatars and display names. */
export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }) => {
const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
const intl = useIntl(); const intl = useIntl();
const [popperElement, setPopperElement] = useState(null); const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const me = useSelector(state => state.get('me')); const me = useAppSelector(state => state.me);
const accountId = useSelector(state => state.getIn(['profile_hover_card', 'accountId'])); const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.get<string | undefined>('accountId', undefined));
const account = useSelector(state => accountId && getAccount(state, accountId)); const account = useAppSelector(state => accountId && getAccount(state, accountId));
const targetRef = useSelector(state => state.getIn(['profile_hover_card', 'ref', 'current'])); const targetRef = useAppSelector(state => state.profile_hover_card.getIn(['ref', 'current']) as Element | null);
const badges = account ? getBadges(account) : []; const badges = account ? getBadges(account) : [];
useEffect(() => { useEffect(() => {
@ -82,8 +92,8 @@ export const ProfileHoverCard = ({ visible }) => {
const { styles, attributes } = usePopper(targetRef, popperElement); const { styles, attributes } = usePopper(targetRef, popperElement);
if (!account) return null; if (!account) return null;
const accountBio = { __html: account.get('note_emojified') }; const accountBio = { __html: account.note_emojified };
const followedBy = me !== account.get('id') && account.getIn(['relationship', 'followed_by']); const followedBy = me !== account.id && account.relationship.get('followed_by') === true;
return ( return (
<div <div
@ -111,7 +121,7 @@ export const ProfileHoverCard = ({ visible }) => {
)} )}
</BundleContainer> </BundleContainer>
{account.getIn(['source', 'note'], '').length > 0 && ( {account.source.get('note', '').length > 0 && (
<Text size='sm' dangerouslySetInnerHTML={accountBio} /> <Text size='sm' dangerouslySetInnerHTML={accountBio} />
)} )}
</Stack> </Stack>
@ -130,14 +140,4 @@ export const ProfileHoverCard = ({ visible }) => {
); );
}; };
ProfileHoverCard.propTypes = {
visible: PropTypes.bool,
accountId: PropTypes.string,
account: ImmutablePropTypes.record,
};
ProfileHoverCard.defaultProps = {
visible: true,
};
export default ProfileHoverCard; export default ProfileHoverCard;

@ -1,17 +0,0 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class ProgressBar extends ImmutablePureComponent {
render() {
const { progress } = this.props;
return (
<div className='progress-bar'>
<div className='progress-bar__progress' style={{ width: `${Math.floor(progress*100)}%` }} />
</div>
);
}
}

@ -1,62 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
export default class ProgressCircle extends React.PureComponent {
static propTypes = {
progress: PropTypes.number.isRequired,
radius: PropTypes.number,
stroke: PropTypes.number,
title: PropTypes.string,
};
static defaultProps = {
radius: 12,
stroke: 4,
}
render() {
const { progress, radius, stroke, title } = this.props;
const progressStroke = stroke + 0.5;
const actualRadius = radius + progressStroke;
const circumference = 2 * Math.PI * radius;
const dashoffset = circumference * (1 - Math.min(progress, 1));
return (
<div title={title}>
<svg
width={actualRadius * 2}
height={actualRadius * 2}
viewBox={`0 0 ${actualRadius * 2} ${actualRadius * 2}`}
>
<circle
className='stroke-gray-400'
cx={actualRadius}
cy={actualRadius}
r={radius}
fill='none'
strokeWidth={stroke}
/>
<circle
className={classNames('stroke-primary-800', {
'stroke-danger-600': progress > 1,
})}
style={{
strokeDashoffset: dashoffset,
strokeDasharray: circumference,
}}
cx={actualRadius}
cy={actualRadius}
r={radius}
fill='none'
strokeWidth={progressStroke}
strokeLinecap='round'
/>
</svg>
</div>
);
}
}

@ -0,0 +1,52 @@
import classNames from 'classnames';
import React from 'react';
interface IProgressCircle {
progress: number,
radius?: number,
stroke?: number,
title?: string,
}
const ProgressCircle: React.FC<IProgressCircle> = ({ progress, radius = 12, stroke = 4, title }) => {
const progressStroke = stroke + 0.5;
const actualRadius = radius + progressStroke;
const circumference = 2 * Math.PI * radius;
const dashoffset = circumference * (1 - Math.min(progress, 1));
return (
<div title={title}>
<svg
width={actualRadius * 2}
height={actualRadius * 2}
viewBox={`0 0 ${actualRadius * 2} ${actualRadius * 2}`}
>
<circle
className='stroke-gray-400'
cx={actualRadius}
cy={actualRadius}
r={radius}
fill='none'
strokeWidth={stroke}
/>
<circle
className={classNames('stroke-primary-800', {
'stroke-danger-600': progress > 1,
})}
style={{
strokeDashoffset: dashoffset,
strokeDasharray: circumference,
}}
cx={actualRadius}
cy={actualRadius}
r={radius}
fill='none'
strokeWidth={progressStroke}
strokeLinecap='round'
/>
</svg>
</div>
);
};
export default ProgressCircle;

@ -1,347 +0,0 @@
import classNames from 'classnames';
import { List as ImmutableList } from 'immutable';
import { throttle } from 'lodash';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { getSettings } from 'soapbox/actions/settings';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import LoadMore from './load_more';
import MoreFollows from './more_follows';
import { Spinner, Text } from './ui';
const MOUSE_IDLE_DELAY = 300;
const mapStateToProps = state => {
const settings = getSettings(state);
return {
autoload: settings.get('autoloadMore'),
};
};
export default @connect(mapStateToProps, null, null, { forwardRef: true })
@withRouter
class ScrollableList extends PureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
onLoadMore: PropTypes.func,
isLoading: PropTypes.bool,
showLoading: PropTypes.bool,
hasMore: PropTypes.bool,
diffCount: PropTypes.number,
prepend: PropTypes.node,
alwaysPrepend: PropTypes.bool,
emptyMessage: PropTypes.node,
children: PropTypes.node,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
placeholderComponent: PropTypes.object,
placeholderCount: PropTypes.number,
autoload: PropTypes.bool,
onRefresh: PropTypes.func,
className: PropTypes.string,
location: PropTypes.object,
};
state = {
cachedMediaWidth: 250, // Default media/card width using default theme
};
intersectionObserverWrapper = new IntersectionObserverWrapper();
mouseIdleTimer = null;
mouseMovedRecently = false;
lastScrollWasSynthetic = false;
scrollToTopOnMouseIdle = false;
setScrollTop = newScrollTop => {
if (this.documentElement.scrollTop !== newScrollTop) {
this.lastScrollWasSynthetic = true;
this.documentElement.scrollTop = newScrollTop;
}
};
clearMouseIdleTimer = () => {
if (this.mouseIdleTimer === null) {
return;
}
clearTimeout(this.mouseIdleTimer);
this.mouseIdleTimer = null;
};
handleMouseMove = throttle(() => {
// As long as the mouse keeps moving, clear and restart the idle timer.
this.clearMouseIdleTimer();
this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
if (!this.mouseMovedRecently && this.documentElement.scrollTop === 0) {
// Only set if we just started moving and are scrolled to the top.
this.scrollToTopOnMouseIdle = true;
}
// Save setting this flag for last, so we can do the comparison above.
this.mouseMovedRecently = true;
}, MOUSE_IDLE_DELAY / 2);
handleMouseIdle = () => {
if (this.scrollToTopOnMouseIdle) {
this.setScrollTop(0);
}
this.mouseMovedRecently = false;
this.scrollToTopOnMouseIdle = false;
}
componentDidMount() {
this.window = window;
this.documentElement = document.scrollingElement || document.documentElement;
this.attachScrollListener();
this.attachIntersectionObserver();
// Handle initial scroll position
this.handleScroll();
}
getScrollPosition = () => {
if (this.documentElement && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
return { height: this.documentElement.scrollHeight, top: this.documentElement.scrollTop };
} else {
return undefined;
}
}
updateScrollBottom = (snapshot) => {
const newScrollTop = this.documentElement.scrollHeight - snapshot;
this.setScrollTop(newScrollTop);
}
componentDidUpdate(prevProps, prevState, snapshot) {
// Reset the scroll position when a new child comes in in order not to
// jerk the scrollbar around if you're already scrolled down the page.
if (snapshot !== null) {
this.setScrollTop(this.documentElement.scrollHeight - snapshot);
}
}
attachScrollListener() {
this.window.addEventListener('scroll', this.handleScroll);
this.window.addEventListener('wheel', this.handleWheel);
}
detachScrollListener() {
this.window.removeEventListener('scroll', this.handleScroll);
this.window.removeEventListener('wheel', this.handleWheel);
}
handleScroll = throttle(() => {
const { autoload } = this.props;
if (this.window) {
const { scrollTop, scrollHeight } = this.documentElement;
const { innerHeight } = this.window;
const offset = scrollHeight - scrollTop - innerHeight;
if (autoload && 400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) {
this.props.onLoadMore();
}
if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
} else if (this.props.onScroll) {
this.props.onScroll();
}
if (!this.lastScrollWasSynthetic) {
// If the last scroll wasn't caused by setScrollTop(), assume it was
// intentional and cancel any pending scroll reset on mouse idle
this.scrollToTopOnMouseIdle = false;
}
this.lastScrollWasSynthetic = false;
}
}, 150, {
trailing: true,
});
handleWheel = throttle(() => {
this.scrollToTopOnMouseIdle = false;
}, 150, {
trailing: true,
});
getSnapshotBeforeUpdate(prevProps) {
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
if (someItemInserted && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) {
return this.documentElement.scrollHeight - this.documentElement.scrollTop;
} else {
return null;
}
}
cacheMediaWidth = (width) => {
if (width && this.state.cachedMediaWidth !== width) {
this.setState({ cachedMediaWidth: width });
}
}
componentWillUnmount() {
this.clearMouseIdleTimer();
this.detachScrollListener();
this.detachIntersectionObserver();
}
attachIntersectionObserver() {
this.intersectionObserverWrapper.connect();
}
detachIntersectionObserver() {
this.intersectionObserverWrapper.disconnect();
}
getFirstChildKey(props) {
const { children } = props;
let firstChild = children;
if (children instanceof ImmutableList) {
firstChild = children.get(0);
} else if (Array.isArray(children)) {
firstChild = children[0];
}
return firstChild && firstChild.key;
}
handleLoadMore = e => {
e.preventDefault();
this.props.onLoadMore();
}
getMoreFollows = () => {
const { scrollKey, isLoading, diffCount, hasMore } = this.props;
const isMoreFollows = ['followers', 'following'].some(k => k === scrollKey);
if (!(diffCount && isMoreFollows)) return null;
if (hasMore) return null;
return (
<MoreFollows visible={!isLoading} count={diffCount} type={scrollKey} />
);
}
setRef = c => {
this.node = c;
}
renderLoading = () => {
const { className, prepend, placeholderComponent: Placeholder, placeholderCount } = this.props;
if (Placeholder && placeholderCount > 0) {
return (
<div role='feed' className={className}>
{Array(placeholderCount).fill().map((_, i) => (
<Placeholder key={i} />
))}
</div>
);
}
return (
<div className={classNames('slist slist--flex', className)}>
<div role='feed' className='item-list'>
{prepend}
</div>
<div className='slist__append'>
<Spinner />
</div>
</div>
);
}
renderEmptyMessage = () => {
const { className, prepend, alwaysPrepend, emptyMessage } = this.props;
return (
<div className={classNames('mt-2', className)} ref={this.setRef}>
{alwaysPrepend && prepend}
<div className='bg-primary-50 dark:bg-slate-700 mt-2 rounded-lg text-center p-8'>
<Text>{emptyMessage}</Text>
</div>
</div>
);
}
renderFeed = () => {
const { className, children, scrollKey, isLoading, hasMore, prepend, onLoadMore, onRefresh, placeholderComponent: Placeholder } = this.props;
const childrenCount = React.Children.count(children);
const trackScroll = true; //placeholder
const loadMore = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
const feed = (
<div ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className={className}>
{prepend}
{React.Children.map(children, (child, index) => (
<IntersectionObserverArticleContainer
key={child.key}
id={child.key}
index={index}
listLength={childrenCount}
intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${this.props.location.key}:${scrollKey}` : null}
>
{React.cloneElement(child, {
getScrollPosition: this.getScrollPosition,
updateScrollBottom: this.updateScrollBottom,
cachedMediaWidth: this.state.cachedMediaWidth,
cacheMediaWidth: this.cacheMediaWidth,
})}
</IntersectionObserverArticleContainer>
))}
{(isLoading && Placeholder) && (
<Placeholder />
)}
{this.getMoreFollows()}
{loadMore}
</div>
</div>
);
if (onRefresh) {
return (
<PullToRefresh onRefresh={onRefresh}>
{feed}
</PullToRefresh>
);
} else {
return feed;
}
}
render() {
const { children, showLoading, isLoading, hasMore, emptyMessage } = this.props;
const childrenCount = React.Children.count(children);
if (showLoading) {
return this.renderLoading();
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
return this.renderFeed();
} else {
return this.renderEmptyMessage();
}
}
}

@ -0,0 +1,167 @@
import React from 'react';
import { Virtuoso, Components } from 'react-virtuoso';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { useSettings } from 'soapbox/hooks';
import LoadMore from './load_more';
import { Spinner, Text } from './ui';
type Context = {
itemClassName?: string,
listClassName?: string,
}
// NOTE: It's crucial to space lists with **padding** instead of margin!
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
const Item: Components<Context>['Item'] = ({ context, ...rest }) => (
<div className={context?.itemClassName} {...rest} />
);
// Ensure the className winds up here
const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} className={context?.listClassName} {...rest} />;
});
interface IScrollableList {
scrollKey?: string,
onLoadMore?: () => void,
isLoading?: boolean,
showLoading?: boolean,
hasMore?: boolean,
prepend?: React.ReactElement,
alwaysPrepend?: boolean,
emptyMessage?: React.ReactNode,
children: Iterable<React.ReactNode>,
onScrollToTop?: () => void,
onScroll?: () => void,
placeholderComponent?: React.ComponentType,
placeholderCount?: number,
onRefresh?: () => Promise<any>,
className?: string,
itemClassName?: string,
}
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
const ScrollableList: React.FC<IScrollableList> = ({
prepend = null,
alwaysPrepend,
children,
isLoading,
emptyMessage,
showLoading,
onRefresh,
onScroll,
onScrollToTop,
onLoadMore,
className,
itemClassName,
hasMore,
placeholderComponent: Placeholder,
placeholderCount = 0,
}) => {
const settings = useSettings();
const autoloadMore = settings.get('autoloadMore');
/** Normalized children */
const elements = Array.from(children || []);
const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;
// NOTE: We are doing some trickery to load a feed of placeholders
// Virtuoso's `EmptyPlaceholder` unfortunately doesn't work for our use-case
const data = showPlaceholder ? Array(placeholderCount).fill('') : elements;
const isEmpty = data.length === 0; // Yes, if it has placeholders it isn't "empty"
// Add a placeholder at the bottom for loading
// (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
if (hasMore && (autoloadMore || isLoading) && Placeholder) {
data.push(<Placeholder />);
} else if (hasMore && (autoloadMore || isLoading)) {
data.push(<Spinner />);
}
/* Render an empty state instead of the scrollable list */
const renderEmpty = (): JSX.Element => {
return (
<div className='mt-2'>
{alwaysPrepend && prepend}
<div className='bg-primary-50 dark:bg-slate-700 mt-2 rounded-lg text-center p-8'>
{isLoading ? (
<Spinner />
) : (
<Text>{emptyMessage}</Text>
)}
</div>
</div>
);
};
/** Render a single item */
const renderItem = (_i: number, element: JSX.Element): JSX.Element => {
if (showPlaceholder) {
return <Placeholder />;
} else {
return element;
}
};
const handleEndReached = () => {
if (autoloadMore && hasMore && onLoadMore) {
onLoadMore();
}
};
const loadMore = () => {
if (autoloadMore || !hasMore || !onLoadMore) {
return null;
} else {
return <LoadMore visible={!isLoading} onClick={onLoadMore} />;
}
};
/** Render the actual Virtuoso list */
const renderFeed = (): JSX.Element => (
<Virtuoso
useWindowScroll
className={className}
data={data}
startReached={onScrollToTop}
endReached={handleEndReached}
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
itemContent={renderItem}
context={{
listClassName: className,
itemClassName,
}}
components={{
Header: () => prepend,
ScrollSeekPlaceholder: Placeholder as any,
EmptyPlaceholder: () => renderEmpty(),
List,
Item,
Footer: loadMore,
}}
/>
);
/** Conditionally render inner elements */
const renderBody = (): JSX.Element => {
if (isEmpty) {
return renderEmpty();
} else {
return renderFeed();
}
};
return (
<PullToRefresh onRefresh={onRefresh}>
{renderBody()}
</PullToRefresh>
);
};
export default ScrollableList;

@ -1,65 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
import { FormPropTypes, InputContainer, LabelInputContainer } from 'soapbox/features/forms';
const messages = defineMessages({
showPassword: { id: 'forms.show_password', defaultMessage: 'Show password' },
hidePassword: { id: 'forms.hide_password', defaultMessage: 'Hide password' },
});
export default @injectIntl
class ShowablePassword extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
label: FormPropTypes.label,
className: PropTypes.string,
hint: PropTypes.node,
error: PropTypes.bool,
}
state = {
revealed: false,
}
toggleReveal = () => {
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
} else {
this.setState({ revealed: !this.state.revealed });
}
}
render() {
const { intl, hint, error, label, className, ...props } = this.props;
const { revealed } = this.state;
const revealButton = (
<IconButton
src={revealed ? require('@tabler/icons/icons/eye-off.svg') : require('@tabler/icons/icons/eye.svg')}
onClick={this.toggleReveal}
title={intl.formatMessage(revealed ? messages.hidePassword : messages.showPassword)}
/>
);
return (
<InputContainer {...this.props} extraClass={classNames('showable-password', className)}>
{label ? (
<LabelInputContainer label={label}>
<input {...props} type={revealed ? 'text' : 'password'} />
{revealButton}
</LabelInputContainer>
) : (<>
<input {...props} type={revealed ? 'text' : 'password'} />
{revealButton}
</>)}
</InputContainer>
);
}
}

@ -0,0 +1,58 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import IconButton from 'soapbox/components/icon_button';
import { InputContainer, LabelInputContainer } from 'soapbox/features/forms';
const messages = defineMessages({
showPassword: { id: 'forms.show_password', defaultMessage: 'Show password' },
hidePassword: { id: 'forms.hide_password', defaultMessage: 'Hide password' },
});
interface IShowablePassword {
label?: React.ReactNode,
className?: string,
hint?: React.ReactNode,
error?: boolean,
onToggleVisibility?: () => void,
}
const ShowablePassword: React.FC<IShowablePassword> = (props) => {
const intl = useIntl();
const [revealed, setRevealed] = useState(false);
const { hint, error, label, className, ...rest } = props;
const toggleReveal = () => {
if (props.onToggleVisibility) {
props.onToggleVisibility();
} else {
setRevealed(!revealed);
}
};
const revealButton = (
<IconButton
src={revealed ? require('@tabler/icons/icons/eye-off.svg') : require('@tabler/icons/icons/eye.svg')}
onClick={toggleReveal}
title={intl.formatMessage(revealed ? messages.hidePassword : messages.showPassword)}
/>
);
return (
<InputContainer {...props} extraClass={classNames('showable-password', className)}>
{label ? (
<LabelInputContainer label={label}>
<input {...rest} type={revealed ? 'text' : 'password'} />
{revealButton}
</LabelInputContainer>
) : (<>
<input {...rest} type={revealed ? 'text' : 'password'} />
{revealButton}
</>)}
</InputContainer>
);
};
export default ShowablePassword;

@ -2,23 +2,35 @@ import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { Icon, Text } from './ui'; import { Icon, Text, Counter } from './ui';
interface ISidebarNavigationLink { interface ISidebarNavigationLink {
count?: number, count?: number,
icon: string, icon: string,
text: string | React.ReactElement, text: string | React.ReactElement,
to: string, to?: string,
onClick?: React.EventHandler<React.MouseEvent>,
} }
const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink) => { const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
const { icon, text, to = '', count, onClick } = props;
const isActive = location.pathname === to; const isActive = location.pathname === to;
const withCounter = typeof count !== 'undefined'; const withCounter = typeof count !== 'undefined';
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
if (onClick) {
onClick(e);
e.preventDefault();
e.stopPropagation();
}
};
return ( return (
<NavLink <NavLink
exact exact
to={to} to={to}
ref={ref}
onClick={handleClick}
className={classNames({ className={classNames({
'flex items-center py-2 text-sm font-semibold space-x-4': true, 'flex items-center py-2 text-sm font-semibold space-x-4': true,
'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200': !isActive, 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200': !isActive,
@ -32,8 +44,8 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink
})} })}
> >
{withCounter && count > 0 ? ( {withCounter && count > 0 ? (
<span className='absolute -top-2 -right-2 block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white'> <span className='absolute -top-2 -right-2'>
{count} <Counter count={count} />
</span> </span>
) : null} ) : null}
@ -50,6 +62,6 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink
<Text weight='semibold' theme='inherit'>{text}</Text> <Text weight='semibold' theme='inherit'>{text}</Text>
</NavLink> </NavLink>
); );
}; });
export default SidebarNavigationLink; export default SidebarNavigationLink;

@ -1,33 +1,144 @@
import { Map as ImmutableMap } from 'immutable'; import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
import ComposeButton from 'soapbox/features/ui/components/compose-button'; import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { getBaseURL } from 'soapbox/utils/accounts'; import { getBaseURL } from 'soapbox/utils/accounts';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import SidebarNavigationLink from './sidebar-navigation-link'; import SidebarNavigationLink from './sidebar-navigation-link';
import type { Menu } from 'soapbox/components/dropdown_menu';
const SidebarNavigation = () => { const SidebarNavigation = () => {
const me = useAppSelector((state) => state.me);
const instance = useAppSelector((state) => state.instance); const instance = useAppSelector((state) => state.instance);
const settings = useAppSelector((state) => getSettings(state)); const settings = useAppSelector((state) => getSettings(state));
const account = useAppSelector((state) => state.accounts.get(me)); const account = useOwnAccount();
const notificationCount = useAppSelector((state) => state.notifications.get('unread')); const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0)); const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count());
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const baseURL = getBaseURL(ImmutableMap(account)); const baseURL = account ? getBaseURL(account) : '';
const features = getFeatures(instance); const features = getFeatures(instance);
const makeMenu = (): Menu => {
const menu: Menu = [];
if (account) {
if (account.locked || followRequestsCount > 0) {
menu.push({
to: '/follow_requests',
text: <FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' />,
icon: require('@tabler/icons/icons/user-plus.svg'),
// TODO: let menu items have a counter
// count: followRequestsCount,
});
}
if (features.bookmarks) {
menu.push({
to: '/bookmarks',
text: <FormattedMessage id='column.bookmarks' defaultMessage='Bookmarks' />,
icon: require('@tabler/icons/icons/bookmark.svg'),
});
}
if (features.lists) {
menu.push({
to: '/lists',
text: <FormattedMessage id='column.lists' defaultMessage='Lists' />,
icon: require('@tabler/icons/icons/list.svg'),
});
}
if (instance.invites_enabled) {
menu.push({
to: `${baseURL}/invites`,
icon: require('@tabler/icons/icons/mailbox.svg'),
text: <FormattedMessage id='navigation.invites' defaultMessage='Invites' />,
});
}
if (settings.get('isDeveloper')) {
menu.push({
to: '/developers',
icon: require('@tabler/icons/icons/code.svg'),
text: <FormattedMessage id='navigation.developers' defaultMessage='Developers' />,
});
}
if (account.staff) {
menu.push({
to: '/soapbox/admin',
icon: require('@tabler/icons/icons/dashboard.svg'),
text: <FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />,
count: dashboardCount,
});
}
if (features.publicTimeline) {
menu.push(null);
}
}
if (features.publicTimeline) {
menu.push({
to: '/timeline/local',
icon: features.federating ? require('@tabler/icons/icons/users.svg') : require('@tabler/icons/icons/world.svg'),
text: features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />,
});
}
if (features.publicTimeline && features.federating) {
menu.push({
to: '/timeline/fediverse',
icon: require('icons/fediverse.svg'),
text: <FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />,
});
}
return menu;
};
const menu = makeMenu();
/** Conditionally render the supported messages link */
const renderMessagesLink = (): React.ReactNode => {
if (features.chats) {
return (
<SidebarNavigationLink
to='/chats'
icon={require('@tabler/icons/icons/messages.svg')}
count={chatsCount}
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
/>
);
}
if (features.directTimeline || features.conversations) {
return (
<SidebarNavigationLink
to='/messages'
icon={require('icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
);
}
return null;
};
return ( return (
<div> <div>
<div className='flex flex-col space-y-2'> <div className='flex flex-col space-y-2'>
<SidebarNavigationLink <SidebarNavigationLink
to='/' to='/'
icon={require('icons/feed.svg')} icon={require('icons/feed.svg')}
text={<FormattedMessage id='tabs_bar.home' defaultMessage='Feed' />} text={<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />}
/> />
{account && ( {account && (
@ -42,7 +153,7 @@ const SidebarNavigation = () => {
to='/notifications' to='/notifications'
icon={require('icons/alert.svg')} icon={require('icons/alert.svg')}
count={notificationCount} count={notificationCount}
text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Alerts' />} text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />}
/> />
<SidebarNavigationLink <SidebarNavigationLink
@ -53,69 +164,17 @@ const SidebarNavigation = () => {
</> </>
)} )}
{account && ( {account && renderMessagesLink()}
features.chats ? (
<SidebarNavigationLink
to='/chats'
icon={require('@tabler/icons/icons/messages.svg')}
count={chatsCount}
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
/>
) : (
<SidebarNavigationLink
to='/messages'
icon={require('icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
)
)}
{/* {(account && account.staff) && (
<SidebarNavigationLink
to='/admin'
icon={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
text={<FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />}
count={dashboardCount}
/>
)} */}
{(account && instance.invites_enabled) && (
<SidebarNavigationLink
to={`${baseURL}/invites`}
icon={require('@tabler/icons/icons/mailbox.svg')}
text={<FormattedMessage id='navigation.invites' defaultMessage='Invites' />}
/>
)}
{(settings.get('isDeveloper')) && (
<SidebarNavigationLink
to='/developers'
icon={require('@tabler/icons/icons/code.svg')}
text={<FormattedMessage id='navigation.developers' defaultMessage='Developers' />}
/>
)}
{/* {features.federating ? ( {menu.length > 0 && (
<NavLink to='/timeline/local' className='btn grouped'> <DropdownMenu items={menu}>
<Icon <SidebarNavigationLink
src={require('@tabler/icons/icons/users.svg')} icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/timeline/local' })} count={dashboardCount}
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
/> />
{instance.title} </DropdownMenu>
</NavLink>
) : (
<NavLink to='/timeline/local' className='btn grouped'>
<Icon src={require('@tabler/icons/icons/world.svg')} className='primary-navigation__icon' />
<FormattedMessage id='tabs_bar.all' defaultMessage='All' />
</NavLink>
)} )}
{features.federating && (
<NavLink to='/timeline/fediverse' className='btn grouped'>
<Icon src={require('icons/fediverse.svg')} className='column-header__icon' />
<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />
</NavLink>
)} */}
</div> </div>
{account && ( {account && (

@ -1,23 +1,25 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { logOut, switchAccount } from 'soapbox/actions/auth'; import { logOut, switchAccount } from 'soapbox/actions/auth';
import { fetchOwnAccounts } from 'soapbox/actions/auth'; import { fetchOwnAccounts } from 'soapbox/actions/auth';
import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { getSettings } from 'soapbox/actions/settings';
import { closeSidebar } from 'soapbox/actions/sidebar';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile_stats'; import ProfileStats from 'soapbox/features/ui/components/profile_stats';
import { getFeatures } from 'soapbox/utils/features'; import { useAppSelector, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
import { closeSidebar } from '../actions/sidebar'; import { getBaseURL } from 'soapbox/utils/accounts';
import { makeGetAccount, makeGetOtherAccounts } from '../selectors';
import { HStack, Icon, IconButton, Text } from './ui'; import { HStack, Icon, IconButton, Text } from './ui';
import type { List as ImmutableList } from 'immutable';
import type { Account as AccountEntity } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' }, followers: { id: 'account.followers', defaultMessage: 'Followers' },
follows: { id: 'account.follows', defaultMessage: 'Follows' }, follows: { id: 'account.follows', defaultMessage: 'Follows' },
@ -31,9 +33,21 @@ const messages = defineMessages({
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' }, importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' }, accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' },
developers: { id: 'navigation.developers', defaultMessage: 'Developers' },
addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
}); });
const SidebarLink = ({ to, icon, text, onClick }) => ( interface ISidebarLink {
to: string,
icon: string,
text: string | JSX.Element,
onClick: React.EventHandler<React.MouseEvent>,
}
const SidebarLink: React.FC<ISidebarLink> = ({ to, icon, text, onClick }) => (
<NavLink className='group py-1 rounded-md' to={to} onClick={onClick}> <NavLink className='group py-1 rounded-md' to={to} onClick={onClick}>
<HStack space={2} alignItems='center'> <HStack space={2} alignItems='center'>
<div className='bg-primary-50 dark:bg-slate-700 relative rounded inline-flex p-2'> <div className='bg-primary-50 dark:bg-slate-700 relative rounded inline-flex p-2'>
@ -45,25 +59,23 @@ const SidebarLink = ({ to, icon, text, onClick }) => (
</NavLink> </NavLink>
); );
SidebarLink.propTypes = { const getOtherAccounts = makeGetOtherAccounts();
to: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const SidebarMenu = () => { const SidebarMenu: React.FC = (): JSX.Element | null => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const logo = useSelector((state) => getSoapboxConfig(state).get('logo')); const { logo } = useSoapboxConfig();
const features = useSelector((state) => getFeatures(state.get('instance'))); const features = useFeatures();
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
const getOtherAccounts = makeGetOtherAccounts(); const instance = useAppSelector((state) => state.instance);
const me = useSelector((state) => state.get('me')); const me = useAppSelector((state) => state.me);
const account = useSelector((state) => getAccount(state, me)); const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const otherAccounts = useSelector((state) => getOtherAccounts(state)); const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
const sidebarOpen = useSelector((state) => state.get('sidebar').sidebarOpen); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state));
const baseURL = account ? getBaseURL(account) : '';
const closeButtonRef = React.useRef(null); const closeButtonRef = React.useRef(null);
@ -76,26 +88,29 @@ const SidebarMenu = () => {
onClose(); onClose();
}; };
const handleSwitchAccount = (event, account) => { const handleSwitchAccount = (account: AccountEntity): React.MouseEventHandler => {
event.preventDefault(); return (e) => {
switchAccount(account); e.preventDefault();
dispatch(switchAccount(account.get('id'))); dispatch(switchAccount(account.id));
};
}; };
const onClickLogOut = (event) => { const onClickLogOut: React.MouseEventHandler = (e) => {
event.preventDefault(); e.preventDefault();
dispatch(logOut(intl)); dispatch(logOut(intl));
}; };
const handleSwitcherClick = (e) => { const handleSwitcherClick: React.MouseEventHandler = (e) => {
e.preventDefault(); e.preventDefault();
setSwitcher((prevState) => (!prevState)); setSwitcher((prevState) => (!prevState));
}; };
const renderAccount = (account) => ( const renderAccount = (account: AccountEntity) => (
<a href='/' className='block py-2' onClick={(event) => handleSwitchAccount(event, account)} key={account.get('id')}> <a href='#' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
<Account account={account} showProfileHoverCard={false} /> <div className='pointer-events-none'>
<Account account={account} showProfileHoverCard={false} withRelationship={false} />
</div>
</a> </a>
); );
@ -103,17 +118,13 @@ const SidebarMenu = () => {
dispatch(fetchOwnAccounts()); dispatch(fetchOwnAccounts());
}, []); }, []);
if (!account) { if (!account) return null;
return null;
}
const acct = account.get('acct');
const classes = classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
});
return ( return (
<div className={classes}> <div className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
>
<div <div
className={classNames({ className={classNames({
'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true, 'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true,
@ -130,7 +141,7 @@ const SidebarMenu = () => {
<HStack alignItems='center' justifyContent='between'> <HStack alignItems='center' justifyContent='between'>
<Link to='/' onClick={onClose}> <Link to='/' onClick={onClose}>
{logo ? ( {logo ? (
<img alt='Logo' src={logo} className='h-5 w-auto min-w-[140px] cursor-pointer' /> <img alt='Logo' src={logo} className='h-5 w-auto cursor-pointer' />
): ( ): (
<Icon <Icon
alt='Logo' alt='Logo'
@ -150,29 +161,35 @@ const SidebarMenu = () => {
</HStack> </HStack>
<Stack space={1}> <Stack space={1}>
<Link to={`/@${acct}`} onClick={onClose}> <Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} /> <Account account={account} showProfileHoverCard={false} />
</Link> </Link>
{account.staff && ( <Stack>
<Stack> <button type='button' onClick={handleSwitcherClick} className='py-1'>
<button type='button' onClick={handleSwitcherClick} className='py-1'> <HStack alignItems='center' justifyContent='between'>
<HStack alignItems='center' justifyContent='between'> <Text tag='span' size='sm' weight='medium'>Switch accounts</Text>
<Text tag='span' size='sm' weight='medium'>Switch accounts</Text>
<Icon
<Icon src={require('@tabler/icons/icons/chevron-down.svg')}
src={switcher ? require('@tabler/icons/icons/chevron-up.svg') : require('@tabler/icons/icons/chevron-down.svg')} className='sidebar-menu-profile__caret' className={classNames('text-black dark:text-white transition-transform', {
/> 'rotate-180': switcher,
</HStack> })}
</button> />
</HStack>
{switcher && ( </button>
<div className='border-t border-solid border-gray-200'>
{otherAccounts.map(account => renderAccount(account))} {switcher && (
</div> <div className='border-t border-solid border-gray-200'>
)} {otherAccounts.map(account => renderAccount(account))}
</Stack>
)} <NavLink className='flex py-2 space-x-1' to='/login' onClick={handleClose}>
<Icon className='dark:text-white' src={require('@tabler/icons/icons/plus.svg')} />
<Text>{intl.formatMessage(messages.addAccount)}</Text>
</NavLink>
</div>
)}
</Stack>
</Stack> </Stack>
<ProfileStats <ProfileStats
@ -184,12 +201,68 @@ const SidebarMenu = () => {
<hr /> <hr />
<SidebarLink <SidebarLink
to={`/@${acct}`} to={`/@${account.acct}`}
icon={require('@tabler/icons/icons/user.svg')} icon={require('@tabler/icons/icons/user.svg')}
text={intl.formatMessage(messages.profile)} text={intl.formatMessage(messages.profile)}
onClick={onClose} onClick={onClose}
/> />
{features.bookmarks && (
<SidebarLink
to='/bookmarks'
icon={require('@tabler/icons/icons/bookmark.svg')}
text={intl.formatMessage(messages.bookmarks)}
onClick={onClose}
/>
)}
{features.lists && (
<SidebarLink
to='/lists'
icon={require('@tabler/icons/icons/list.svg')}
text={intl.formatMessage(messages.lists)}
onClick={onClose}
/>
)}
{instance.invites_enabled && (
<SidebarLink
to={`${baseURL}/invites`}
icon={require('@tabler/icons/icons/mailbox.svg')}
text={intl.formatMessage(messages.invites)}
onClick={onClose}
/>
)}
{settings.get('isDeveloper') && (
<SidebarLink
to='/developers'
icon={require('@tabler/icons/icons/code.svg')}
text={intl.formatMessage(messages.developers)}
onClick={onClose}
/>
)}
{features.publicTimeline && <>
<hr className='dark:border-slate-700' />
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/icons/users.svg') : require('@tabler/icons/icons/world.svg')}
text={features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
onClick={onClose}
/>
{features.federating && (
<SidebarLink
to='/timeline/fediverse'
icon={require('icons/fediverse.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>
)}
</>}
<hr /> <hr />
<SidebarLink <SidebarLink
@ -261,7 +334,7 @@ const SidebarMenu = () => {
<hr /> <hr />
<SidebarLink <SidebarLink
to='/auth/sign_out' to='/logout'
icon={require('@tabler/icons/icons/logout.svg')} icon={require('@tabler/icons/icons/logout.svg')}
text={intl.formatMessage(messages.logout)} text={intl.formatMessage(messages.logout)}
onClick={onClickLogOut} onClick={onClickLogOut}

@ -41,9 +41,10 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re
ref={ref} ref={ref}
type='button' type='button'
className={classNames( className={classNames(
'group flex items-center p-1 space-x-0.5 rounded-full', 'flex items-center p-1 space-x-0.5 rounded-full',
'text-gray-400 hover:text-gray-600 dark:hover:text-white', 'text-gray-400 hover:text-gray-600 dark:hover:text-white',
'bg-white dark:bg-transparent', 'bg-white dark:bg-transparent',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:ring-offset-0',
{ {
'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && color === COLORS.accent, 'text-accent-300 hover:text-accent-300 dark:hover:text-accent-300': active && color === COLORS.accent,
'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && color === COLORS.success, 'text-success-600 hover:text-success-600 dark:hover:text-success-600': active && color === COLORS.success,
@ -55,8 +56,6 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re
<Icon <Icon
src={icon} src={icon}
className={classNames( className={classNames(
'rounded-full',
'group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 dark:ring-offset-0 group-focus:ring-primary-500',
{ {
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent, 'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
}, },

@ -29,7 +29,7 @@ import type {
} from 'soapbox/types/entities'; } from 'soapbox/types/entities';
// Defined in components/scrollable_list // Defined in components/scrollable_list
type ScrollPosition = { height: number, top: number }; export type ScrollPosition = { height: number, top: number };
export const textForScreenReader = (intl: IntlShape, status: StatusEntity, rebloggedByText?: string): string => { export const textForScreenReader = (intl: IntlShape, status: StatusEntity, rebloggedByText?: string): string => {
const { account } = status; const { account } = status;
@ -342,7 +342,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
render() { render() {
let media = null; let media = null;
const poll = null; const poll = null;
let prepend, rebloggedByText, reblogContent, reblogElement, reblogElementMobile; let prepend, rebloggedByText, reblogElement, reblogElementMobile;
const { intl, hidden, featured, unread, group } = this.props; const { intl, hidden, featured, unread, group } = this.props;
@ -447,7 +447,6 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
// @ts-ignore what the FUCK // @ts-ignore what the FUCK
account = status.account; account = status.account;
reblogContent = status.contentHtml;
status = status.reblog; status = status.reblog;
} }
@ -599,7 +598,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
// const domain = getDomain(status.account); // const domain = getDomain(status.account);
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers} data-testid='status'>
<div <div
className='status cursor-pointer' className='status cursor-pointer'
tabIndex={this.props.focusable && !this.props.muted ? 0 : undefined} tabIndex={this.props.focusable && !this.props.muted ? 0 : undefined}
@ -646,7 +645,6 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
<StatusContent <StatusContent
status={status} status={status}
reblogContent={reblogContent}
onClick={this.handleClick} onClick={this.handleClick}
expanded={!status.hidden} expanded={!status.hidden}
onExpandedToggle={this.handleExpandedToggle} onExpandedToggle={this.handleExpandedToggle}

@ -6,8 +6,7 @@ import { connect } from 'react-redux';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
import EmojiSelector from 'soapbox/components/emoji_selector'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import Hoverable from 'soapbox/components/hoverable';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import { isUserTouching } from 'soapbox/is_mobile'; import { isUserTouching } from 'soapbox/is_mobile';
@ -130,7 +129,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
'emojiSelectorFocused', 'emojiSelectorFocused',
] ]
handleReplyClick = () => { handleReplyClick: React.MouseEventHandler = (e) => {
const { me, onReply, onOpenUnauthorizedModal, status } = this.props; const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
if (me) { if (me) {
@ -138,12 +137,14 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
} else { } else {
onOpenUnauthorizedModal('REPLY'); onOpenUnauthorizedModal('REPLY');
} }
e.stopPropagation();
} }
handleShareClick = () => { handleShareClick = () => {
navigator.share({ navigator.share({
text: this.props.status.search_index, text: this.props.status.search_index,
url: this.props.status.url, url: this.props.status.uri,
}).catch((e) => { }).catch((e) => {
if (e.name !== 'AbortError') console.error(e); if (e.name !== 'AbortError') console.error(e);
}); });
@ -554,7 +555,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
} }
render() { render() {
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props; const { status, intl, allowedEmoji, features, me } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.visibility); const publicStatus = ['public', 'unlisted'].includes(status.visibility);
@ -633,7 +634,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
/> />
{features.quotePosts && me ? ( {features.quotePosts && me ? (
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}> <DropdownMenuContainer
items={reblogMenu}
disabled={!publicStatus}
onShiftClick={this.handleReblogClick}
>
{reblogButton} {reblogButton}
</DropdownMenuContainer> </DropdownMenuContainer>
) : ( ) : (
@ -641,24 +646,16 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
)} )}
{features.emojiReacts ? ( {features.emojiReacts ? (
<Hoverable <EmojiButtonWrapper statusId={status.id}>
component={(
<EmojiSelector
onReact={this.handleReact}
focused={emojiSelectorFocused}
onUnfocus={handleEmojiSelectorUnfocus}
/>
)}
>
<StatusActionButton <StatusActionButton
title={meEmojiTitle} title={meEmojiTitle}
icon={require('@tabler/icons/icons/thumb-up.svg')} icon={require('@tabler/icons/icons/heart.svg')}
filled
color='accent' color='accent'
onClick={this.handleLikeButtonClick}
active={Boolean(meEmojiReact)} active={Boolean(meEmojiReact)}
count={emojiReactCount} count={emojiReactCount}
/> />
</Hoverable> </EmojiButtonWrapper>
): ( ): (
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.favourite)} title={intl.formatMessage(messages.favourite)}

@ -1,297 +0,0 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import Icon from 'soapbox/components/icon';
import Poll from 'soapbox/components/poll';
import { addGreentext } from 'soapbox/utils/greentext';
import { onlyEmoji } from 'soapbox/utils/rich_content';
import { isRtl } from '../rtl';
import Permalink from './permalink';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const BIG_EMOJI_LIMIT = 10;
const mapStateToProps = state => ({
greentext: getSoapboxConfig(state).get('greentext'),
});
export default @connect(mapStateToProps)
@withRouter
class StatusContent extends React.PureComponent {
static propTypes = {
status: ImmutablePropTypes.record.isRequired,
reblogContent: PropTypes.string,
expanded: PropTypes.bool,
onExpandedToggle: PropTypes.func,
onClick: PropTypes.func,
collapsable: PropTypes.bool,
greentext: PropTypes.bool,
history: PropTypes.object,
};
state = {
hidden: true,
collapsed: null, // `collapsed: null` indicates that an element doesn't need collapsing, while `true` or `false` indicates that it does (and is/isn't).
};
_updateStatusLinks() {
const node = this.node;
if (!node) {
return;
}
const links = node.querySelectorAll('a');
for (let i = 0; i < links.length; ++i) {
const link = links[i];
if (link.classList.contains('status-link')) {
continue;
}
link.classList.add('status-link');
link.setAttribute('rel', 'nofollow noopener');
link.setAttribute('target', '_blank');
const mention = this.props.status.get('mentions').find(item => link.href === `${item.get('url')}`);
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.setAttribute('title', link.href);
}
}
}
setCollapse() {
const node = this.node;
if (!node) {
return;
}
if (
this.props.collapsable
&& this.props.onClick
&& this.state.collapsed === null
&& this.props.status.get('spoiler_text').length === 0
) {
if (node.clientHeight > MAX_HEIGHT){
this.setState({ collapsed: true });
}
}
}
setOnlyEmoji = () => {
if (!this.node) return;
const only = onlyEmoji(this.node, BIG_EMOJI_LIMIT, true);
if (only !== this.state.onlyEmoji) {
this.setState({ onlyEmoji: only });
}
}
refresh = () => {
this.setCollapse();
this._updateStatusLinks();
this.setOnlyEmoji();
}
componentDidMount() {
this.refresh();
}
componentDidUpdate() {
this.refresh();
}
onMentionClick = (mention, e) => {
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history.push(`/@${mention.get('acct')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history.push(`/tags/${hashtag}`);
}
}
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
handleMouseUp = (e) => {
if (!this.startXY) {
return;
}
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
this.props.onClick();
}
this.startXY = null;
}
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.onExpandedToggle) {
// The parent manages the state
this.props.onExpandedToggle();
} else {
this.setState({ hidden: !this.state.hidden });
}
}
handleCollapsedClick = (e) => {
e.preventDefault();
this.setState({ collapsed: !this.state.collapsed });
}
setRef = (c) => {
this.node = c;
}
parseHtml = html => {
const { greentext } = this.props;
if (greentext) return addGreentext(html);
return html;
}
getHtmlContent = () => {
const { status } = this.props;
const html = status.get('contentHtml');
return this.parseHtml(html);
}
render() {
const { status } = this.props;
const { onlyEmoji } = this.state;
if (status.get('content').length === 0) {
return null;
}
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const content = { __html: this.getHtmlContent() };
const spoilerContent = { __html: status.get('spoilerHtml') };
const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.props.history,
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
'status__content--collapsed': this.state.collapsed === true,
'status__content--big': onlyEmoji,
});
if (isRtl(status.get('search_index'))) {
directionStyle.direction = 'rtl';
}
const readMoreButton = (
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
</button>
);
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink to={`/@${item.get('acct')}`} href={`/@${item.get('acct')}`} key={item.get('id')} className='mention'>
@<span>{item.get('username')}</span>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
if (hidden) {
mentionsPlaceholder = <div>{mentionLinks}</div>;
}
return (
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} />
{' '}
<button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button>
</p>
{mentionsPlaceholder}
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
{!hidden && !!status.get('poll') && <Poll id={status.get('poll')} status={status.get('url')} />}
</div>
);
} else if (this.props.onClick) {
const output = [
<div
ref={this.setRef}
tabIndex='0'
key='content'
className={classNames}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
/>,
];
if (this.state.collapsed) {
output.push(readMoreButton);
}
if (status.get('poll')) {
output.push(<Poll id={status.get('poll')} key='poll' status={status.get('url')} />);
}
return output;
} else {
const output = [
<div
ref={this.setRef}
tabIndex='0'
key='content'
className={classnames('status__content', {
'status__content--big': onlyEmoji,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
/>,
];
if (status.get('poll')) {
output.push(<Poll id={status.get('poll')} key='poll' status={status.get('url')} />);
}
return output;
}
}
}

@ -0,0 +1,304 @@
import classNames from 'classnames';
import React, { useState, useRef, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import Poll from 'soapbox/components/poll';
import { useSoapboxConfig } from 'soapbox/hooks';
import { addGreentext } from 'soapbox/utils/greentext';
import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content';
import { isRtl } from '../rtl';
import type { Status, Mention } from 'soapbox/types/entities';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const BIG_EMOJI_LIMIT = 10;
type Point = [
x: number,
y: number,
]
interface IReadMoreButton {
onClick: React.MouseEventHandler,
}
/** Button to expand a truncated status (due to too much content) */
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
<button className='status__content__read-more-button' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon id='angle-right' fixedWidth />
</button>
);
interface ISpoilerButton {
onClick: React.MouseEventHandler,
hidden: boolean,
tabIndex?: number,
}
/** Button to expand status text behind a content warning */
const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex }) => (
<button
tabIndex={tabIndex}
className={classNames(
'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]',
'text-black dark:text-white',
'font-bold text-[11px] uppercase',
'bg-primary-100 dark:bg-primary-900',
'hover:bg-primary-300 dark:hover:bg-primary-600',
'focus:bg-primary-200 dark:focus:bg-primary-600',
'hover:no-underline',
'duration-100',
)}
onClick={onClick}
>
{hidden ? (
<FormattedMessage id='status.show_more' defaultMessage='Show more' />
) : (
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
)}
</button>
);
interface IStatusContent {
status: Status,
expanded?: boolean,
onExpandedToggle?: () => void,
onClick?: () => void,
collapsable?: boolean,
}
/** Renders the text content of a status */
const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onExpandedToggle, onClick, collapsable = false }) => {
const history = useHistory();
const [hidden, setHidden] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const [onlyEmoji, setOnlyEmoji] = useState(false);
const startXY = useRef<Point>();
const node = useRef<HTMLDivElement>(null);
const { greentext } = useSoapboxConfig();
const onMentionClick = (mention: Mention, e: MouseEvent) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
history.push(`/@${mention.acct}`);
}
};
const onHashtagClick = (hashtag: string, e: MouseEvent) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
history.push(`/tags/${hashtag}`);
}
};
/** For regular links, just stop propogation */
const onLinkClick = (e: MouseEvent) => {
e.stopPropagation();
};
const updateStatusLinks = () => {
if (!node.current) return;
const links = node.current.querySelectorAll('a');
links.forEach(link => {
// Skip already processed
if (link.classList.contains('status-link')) return;
// Add attributes
link.classList.add('status-link');
link.setAttribute('rel', 'nofollow noopener');
link.setAttribute('target', '_blank');
const mention = status.mentions.find(mention => link.href === `${mention.url}`);
// Add event listeners on mentions and hashtags
if (mention) {
link.addEventListener('click', onMentionClick.bind(link, mention), false);
link.setAttribute('title', mention.acct);
} else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) {
link.addEventListener('click', onHashtagClick.bind(link, link.text), false);
} else {
link.setAttribute('title', link.href);
link.addEventListener('click', onLinkClick.bind(link), false);
}
});
};
const maybeSetCollapsed = (): void => {
if (!node.current) return;
if (collapsable && onClick && !collapsed && status.spoiler_text.length === 0) {
if (node.current.clientHeight > MAX_HEIGHT) {
setCollapsed(true);
}
}
};
const maybeSetOnlyEmoji = (): void => {
if (!node.current) return;
const only = isOnlyEmoji(node.current, BIG_EMOJI_LIMIT, true);
if (only !== onlyEmoji) {
setOnlyEmoji(only);
}
};
const refresh = (): void => {
maybeSetCollapsed();
maybeSetOnlyEmoji();
updateStatusLinks();
};
useEffect(() => {
refresh();
});
const handleMouseDown: React.EventHandler<React.MouseEvent> = (e) => {
startXY.current = [e.clientX, e.clientY];
};
const handleMouseUp: React.EventHandler<React.MouseEvent> = (e) => {
if (!startXY.current) return;
const target = e.target as HTMLElement;
const parentNode = target.parentNode as HTMLElement;
const [ startX, startY ] = startXY.current;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && onClick) {
onClick();
}
startXY.current = undefined;
};
const handleSpoilerClick: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
e.stopPropagation();
if (onExpandedToggle) {
// The parent manages the state
onExpandedToggle();
} else {
setHidden(!hidden);
}
};
const getHtmlContent = (): string => {
const { contentHtml: html } = status;
if (greentext) return addGreentext(html);
return html;
};
if (status.content.length === 0) {
return null;
}
const isHidden = onExpandedToggle ? !expanded : hidden;
const content = { __html: getHtmlContent() };
const spoilerContent = { __html: status.spoilerHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames('status__content', {
'status__content--with-action': onClick,
'status__content--with-spoiler': status.spoiler_text.length > 0,
'status__content--collapsed': collapsed,
'status__content--big': onlyEmoji,
});
if (isRtl(status.search_index)) {
directionStyle.direction = 'rtl';
}
if (status.spoiler_text.length > 0) {
return (
<div className={className} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
<p style={{ marginBottom: isHidden && status.mentions.isEmpty() ? 0 : undefined }}>
<span dangerouslySetInnerHTML={spoilerContent} lang={status.language || undefined} />
<SpoilerButton
tabIndex={0}
onClick={handleSpoilerClick}
hidden={isHidden}
/>
</p>
<div
tabIndex={!isHidden ? 0 : undefined}
className={classNames('status__content__text', {
'status__content__text--visible': !isHidden,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>
{!isHidden && status.poll && typeof status.poll === 'string' && (
<Poll id={status.poll} status={status.url} />
)}
</div>
);
} else if (onClick) {
const output = [
<div
ref={node}
tabIndex={0}
key='content'
className={className}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>,
];
if (collapsed) {
output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
}
if (status.poll && typeof status.poll === 'string') {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <>{output}</>;
} else {
const output = [
<div
ref={node}
tabIndex={0}
key='content'
className={classNames('status__content', {
'status__content--big': onlyEmoji,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>,
];
if (status.poll && typeof status.poll === 'string') {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <>{output}</>;
}
};
export default StatusContent;

@ -1,3 +1,4 @@
import classNames from 'classnames';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
@ -134,19 +135,15 @@ export default class StatusList extends ImmutablePureComponent {
const idempotencyKey = statusId.replace(/^末pending-/, ''); const idempotencyKey = statusId.replace(/^末pending-/, '');
return ( return (
<div className='material-status' key={statusId}> <PendingStatus
<div className='material-status__status focusable'> key={statusId}
<PendingStatus idempotencyKey={idempotencyKey}
key={statusId} onMoveUp={this.handleMoveUp}
idempotencyKey={idempotencyKey} onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp} contextType={timelineId}
onMoveDown={this.handleMoveDown} group={group}
contextType={timelineId} withGroupAdmin={withGroupAdmin}
group={group} />
withGroupAdmin={withGroupAdmin}
/>
</div>
</div>
); );
} }
@ -226,7 +223,12 @@ export default class StatusList extends ImmutablePureComponent {
placeholderComponent={PlaceholderStatus} placeholderComponent={PlaceholderStatus}
placeholderCount={20} placeholderCount={20}
ref={this.setRef} ref={this.setRef}
className={divideType === 'border' ? 'divide-y divide-solid divide-gray-200 dark:divide-gray-800' : 'sm:space-y-3 divide-y divide-solid divide-gray-200 dark:divide-gray-800 sm:divide-none'} className={classNames('divide-y divide-solid divide-gray-200 dark:divide-slate-700', {
'sm:divide-none': divideType !== 'border',
})}
itemClassName={classNames({
'sm:pb-3': divideType !== 'border',
})}
{...other} {...other}
> >
{this.renderScrollableContent()} {this.renderScrollableContent()}

@ -31,7 +31,7 @@ const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text,
<NavLink to={to} exact={exact} className='thumb-navigation__link'> <NavLink to={to} exact={exact} className='thumb-navigation__link'>
{count !== undefined ? ( {count !== undefined ? (
<IconWithCounter <IconWithCounter
src={require('@tabler/icons/icons/messages.svg')} src={src}
className={classNames({ className={classNames({
'h-5 w-5': true, 'h-5 w-5': true,
'text-gray-600 dark:text-gray-300': !active, 'text-gray-600 dark:text-gray-300': !active,

@ -9,9 +9,37 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
const account = useOwnAccount(); const account = useOwnAccount();
const notificationCount = useAppSelector((state) => state.notifications.unread); const notificationCount = useAppSelector((state) => state.notifications.unread);
const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: number, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0)); const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: number, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0));
// const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const features = getFeatures(useAppSelector((state) => state.instance)); const features = getFeatures(useAppSelector((state) => state.instance));
/** Conditionally render the supported messages link */
const renderMessagesLink = (): React.ReactNode => {
if (features.chats) {
return (
<ThumbNavigationLink
src={require('@tabler/icons/icons/messages.svg')}
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
to='/chats'
exact
count={chatsCount}
/>
);
}
if (features.directTimeline || features.conversations) {
return (
<ThumbNavigationLink
src={require('@tabler/icons/icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
to='/messages'
paths={['/messages', '/conversations']}
/>
);
}
return null;
};
return ( return (
<div className='thumb-navigation'> <div className='thumb-navigation'>
<ThumbNavigationLink <ThumbNavigationLink
@ -38,33 +66,16 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
/> />
)} )}
{account && ( {account && renderMessagesLink()}
features.chats ? (
<ThumbNavigationLink
src={require('@tabler/icons/icons/messages.svg')}
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
to='/chats'
exact
count={chatsCount}
/>
) : (
<ThumbNavigationLink
src={require('@tabler/icons/icons/mail.svg')}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
to='/messages'
paths={['/messages', '/conversations']}
/>
)
)}
{/* (account && account.staff && ( {(account && account.staff) && (
<ThumbNavigationLink <ThumbNavigationLink
src={require('@tabler/icons/icons/dashboard.svg')} src={require('@tabler/icons/icons/dashboard.svg')}
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />} text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
to='/admin' to='/soapbox/admin'
count={dashboardCount} count={dashboardCount}
/> />
) */} )}
</div> </div>
); );
}; };

@ -103,7 +103,7 @@ class TimelineQueueButtonHeader extends React.PureComponent {
return ( return (
<div className={classes}> <div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer' onClick={this.handleClick}> <a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={this.handleClick}>
<Icon src={require('@tabler/icons/icons/arrow-bar-to-up.svg')} /> <Icon src={require('@tabler/icons/icons/arrow-bar-to-up.svg')} />
{(count > 0) && ( {(count > 0) && (

@ -6,11 +6,15 @@ import StillImage from 'soapbox/components/still_image';
const AVATAR_SIZE = 42; const AVATAR_SIZE = 42;
interface IAvatar { interface IAvatar {
/** URL to the avatar image. */
src: string, src: string,
/** Width and height of the avatar in pixels. */
size?: number, size?: number,
/** Extra class names for the div surrounding the avatar image. */
className?: string, className?: string,
} }
/** Round profile avatar for accounts. */
const Avatar = (props: IAvatar) => { const Avatar = (props: IAvatar) => {
const { src, size = AVATAR_SIZE, className } = props; const { src, size = AVATAR_SIZE, className } = props;

@ -8,20 +8,33 @@ import { useButtonStyles } from './useButtonStyles';
import type { ButtonSizes, ButtonThemes } from './useButtonStyles'; import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
interface IButton { interface IButton {
/** Whether this button expands the width of its container. */
block?: boolean, block?: boolean,
/** Elements inside the <button> */
children?: React.ReactNode, children?: React.ReactNode,
/** @deprecated unused */
classNames?: string, classNames?: string,
/** Prevent the button from being clicked. */
disabled?: boolean, disabled?: boolean,
/** URL to an SVG icon to render inside the button. */
icon?: string, icon?: string,
/** Action when the button is clicked. */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void, onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
/** A predefined button size. */
size?: ButtonSizes, size?: ButtonSizes,
/** @deprecated unused */
style?: React.CSSProperties, style?: React.CSSProperties,
/** Text inside the button. Takes precedence over `children`. */
text?: React.ReactNode, text?: React.ReactNode,
/** Makes the button into a navlink, if provided. */
to?: string, to?: string,
/** Styles the button visually with a predefined theme. */
theme?: ButtonThemes, theme?: ButtonThemes,
/** Whether this button should submit a form by default. */
type?: 'button' | 'submit', type?: 'button' | 'submit',
} }
/** Customizable button element with various themes. */
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => { const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
const { const {
block = false, block = false,

@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' | 'link'
type ButtonSizes = 'sm' | 'md' | 'lg' type ButtonSizes = 'sm' | 'md' | 'lg'
type IButtonStyles = { type IButtonStyles = {
@ -25,6 +25,7 @@ const useButtonStyles = ({
accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2', accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2',
danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2', danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
link: 'border-transparent text-primary-600 hover:bg-gray-100 hover:text-primary-700',
}; };
const sizes = { const sizes = {

@ -17,12 +17,18 @@ const messages = defineMessages({
}); });
interface ICard { interface ICard {
/** The type of card. */
variant?: 'rounded', variant?: 'rounded',
/** Card size preset. */
size?: 'md' | 'lg' | 'xl', size?: 'md' | 'lg' | 'xl',
/** Extra classnames for the <div> element. */
className?: string, className?: string,
/** Elements inside the card. */
children: React.ReactNode,
} }
const Card: React.FC<ICard> = React.forwardRef(({ children, variant, size = 'md', className, ...filteredProps }, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => ( /** An opaque backdrop to hold a collection of related elements. */
const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant, size = 'md', className, ...filteredProps }, ref): JSX.Element => (
<div <div
ref={ref} ref={ref}
{...filteredProps} {...filteredProps}
@ -41,6 +47,7 @@ interface ICardHeader {
onBackClick?: (event: React.MouseEvent) => void onBackClick?: (event: React.MouseEvent) => void
} }
/** Typically holds a CardTitle. */
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => { const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
const intl = useIntl(); const intl = useIntl();
@ -73,10 +80,12 @@ interface ICardTitle {
title: string | React.ReactNode title: string | React.ReactNode
} }
const CardTitle = ({ title }: ICardTitle): JSX.Element => ( /** A card's title. */
<Text size='xl' weight='bold' tag='h1' data-testid='card-title'>{title}</Text> const CardTitle: React.FC<ICardTitle> = ({ title }): JSX.Element => (
<Text size='xl' weight='bold' tag='h1' data-testid='card-title' truncate>{title}</Text>
); );
/** A card's body. */
const CardBody: React.FC = ({ children }): JSX.Element => ( const CardBody: React.FC = ({ children }): JSX.Element => (
<div data-testid='card-body'>{children}</div> <div data-testid='card-body'>{children}</div>
); );

@ -3,21 +3,29 @@ import React from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import Helmet from 'soapbox/components/helmet'; import Helmet from 'soapbox/components/helmet';
import { useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
interface IColumn { interface IColumn {
/** Route the back button goes to. */
backHref?: string, backHref?: string,
/** Column title text. */
label?: string, label?: string,
/** Whether this column should have a transparent background. */
transparent?: boolean, transparent?: boolean,
/** Whether this column should have a title and back button. */
withHeader?: boolean, withHeader?: boolean,
/** Extra class name for top <div> element. */
className?: string, className?: string,
} }
/** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => { const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
const { backHref, children, label, transparent = false, withHeader = true, className } = props; const { backHref, children, label, transparent = false, withHeader = true, className } = props;
const history = useHistory(); const history = useHistory();
const soapboxConfig = useSoapboxConfig();
const handleBackClick = () => { const handleBackClick = () => {
if (backHref) { if (backHref) {
@ -54,7 +62,17 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
return ( return (
<div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}> <div role='region' className='relative' ref={ref} aria-label={label} column-type={transparent ? 'transparent' : 'filled'}>
<Helmet><title>{label}</title></Helmet> <Helmet>
<title>{label}</title>
{soapboxConfig.appleAppId && (
<meta
data-react-helmet='true'
name='apple-itunes-app'
content={`app-id=${soapboxConfig.appleAppId}, app-argument=${location.href}`}
/>
)}
</Helmet>
{renderChildren()} {renderChildren()}
</div> </div>

@ -0,0 +1,19 @@
import React from 'react';
import { shortNumberFormat } from 'soapbox/utils/numbers';
interface ICounter {
/** Number this counter should display. */
count: number,
}
/** A simple counter for notifications, etc. */
const Counter: React.FC<ICounter> = ({ count }) => {
return (
<span className='block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white dark:ring-slate-800'>
{shortNumberFormat(count)}
</span>
);
};
export default Counter;

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

Loading…
Cancel
Save