Merge remote-tracking branch 'origin/develop' into next-embeds
This commit is contained in:
commit
0d0d12489e
|
@ -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'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'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'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'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'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'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,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
@ -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', {
|
return api(getState).post('/api/v1/reports', {
|
||||||
account_id: getState().getIn(['reports', 'new', 'account_id']),
|
account_id: reports.getIn(['new', 'account_id']),
|
||||||
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
|
status_ids: reports.getIn(['new', 'status_ids']),
|
||||||
comment: getState().getIn(['reports', 'new', 'comment']),
|
rule_ids: reports.getIn(['new', 'rule_ids']),
|
||||||
forward: getState().getIn(['reports', 'new', 'forward']),
|
comment: reports.getIn(['new', 'comment']),
|
||||||
}).then(response => {
|
forward: reports.getIn(['new', 'forward']),
|
||||||
dispatch(closeModal());
|
});
|
||||||
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 fetchSoapboxConfig(host) {
|
export function fetchFrontendConfigurations() {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
api(getState).get('/api/pleroma/frontend_configurations').then(response => {
|
return api(getState)
|
||||||
if (response.data.soapbox_fe) {
|
.get('/api/pleroma/frontend_configurations')
|
||||||
dispatch(importSoapboxConfig(response.data.soapbox_fe, host));
|
.then(({ data }) => data);
|
||||||
} else {
|
|
||||||
dispatch(fetchSoapboxJson(host));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
dispatch(fetchSoapboxJson(host));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tries to remember the config from browser storage before fetching it
|
/** Conditionally fetches Soapbox config depending on backend features */
|
||||||
|
export function fetchSoapboxConfig(host) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const features = getFeatures(getState().instance);
|
||||||
|
|
||||||
|
if (features.frontendConfigurations) {
|
||||||
|
return dispatch(fetchFrontendConfigurations()).then(data => {
|
||||||
|
if (data.soapbox_fe) {
|
||||||
|
dispatch(importSoapboxConfig(data.soapbox_fe, host));
|
||||||
|
} else {
|
||||||
|
dispatch(fetchSoapboxJson(host));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return dispatch(fetchSoapboxJson(host));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
|
||||||
|
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(fetchContext(id)),
|
||||||
dispatch(fetchStatus(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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</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 to display on a user's profile. */
|
||||||
|
const Badge: React.FC<IBadge> = ({ title, slug }) => (
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
|
|
||||||
Badge.propTypes = {
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
slug: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
hasError: false,
|
|
||||||
componentStack: undefined,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error, info) {
|
class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: undefined,
|
||||||
|
componentStack: undefined,
|
||||||
|
browser: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
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)."
|
||||||
|
values={{ clearCookies: (
|
||||||
|
<a href='/' onClick={this.clearCookies} className='text-gray-700 hover:underline'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='alert.unexpected.clear_cookies'
|
id='alert.unexpected.clear_cookies'
|
||||||
defaultMessage='clear cookies and browser data'
|
defaultMessage='clear cookies and browser data'
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{' ' }(this will log you out).
|
) }}
|
||||||
|
/>
|
||||||
</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) && (
|
{menu.length > 0 && (
|
||||||
|
<DropdownMenu items={menu}>
|
||||||
<SidebarNavigationLink
|
<SidebarNavigationLink
|
||||||
to='/admin'
|
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
|
||||||
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}
|
count={dashboardCount}
|
||||||
|
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
||||||
/>
|
/>
|
||||||
)} */}
|
</DropdownMenu>
|
||||||
|
|
||||||
{(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 ? (
|
|
||||||
<NavLink to='/timeline/local' className='btn grouped'>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/users.svg')}
|
|
||||||
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/timeline/local' })}
|
|
||||||
/>
|
|
||||||
{instance.title}
|
|
||||||
</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,18 +161,20 @@ 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={switcher ? require('@tabler/icons/icons/chevron-up.svg') : require('@tabler/icons/icons/chevron-down.svg')} className='sidebar-menu-profile__caret'
|
src={require('@tabler/icons/icons/chevron-down.svg')}
|
||||||
|
className={classNames('text-black dark:text-white transition-transform', {
|
||||||
|
'rotate-180': switcher,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</button>
|
</button>
|
||||||
|
@ -169,10 +182,14 @@ const SidebarMenu = () => {
|
||||||
{switcher && (
|
{switcher && (
|
||||||
<div className='border-t border-solid border-gray-200'>
|
<div className='border-t border-solid border-gray-200'>
|
||||||
{otherAccounts.map(account => renderAccount(account))}
|
{otherAccounts.map(account => renderAccount(account))}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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,8 +135,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
const idempotencyKey = statusId.replace(/^末pending-/, '');
|
const idempotencyKey = statusId.replace(/^末pending-/, '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='material-status' key={statusId}>
|
|
||||||
<div className='material-status__status focusable'>
|
|
||||||
<PendingStatus
|
<PendingStatus
|
||||||
key={statusId}
|
key={statusId}
|
||||||
idempotencyKey={idempotencyKey}
|
idempotencyKey={idempotencyKey}
|
||||||
|
@ -145,8 +144,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
group={group}
|
group={group}
|
||||||
withGroupAdmin={withGroupAdmin}
|
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;
|
|
@ -4,12 +4,17 @@ import React from 'react';
|
||||||
import { Emoji, HStack } from 'soapbox/components/ui';
|
import { Emoji, HStack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
interface IEmojiButton {
|
interface IEmojiButton {
|
||||||
|
/** Unicode emoji character. */
|
||||||
emoji: string,
|
emoji: string,
|
||||||
|
/** Event handler when the emoji is clicked. */
|
||||||
onClick: React.EventHandler<React.MouseEvent>,
|
onClick: React.EventHandler<React.MouseEvent>,
|
||||||
|
/** Extra class name on the <button> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
|
/** Tab order of the button. */
|
||||||
tabIndex?: number,
|
tabIndex?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clickable emoji button that scales when hovered. */
|
||||||
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<button className={classNames(className)} onClick={onClick} tabIndex={tabIndex}>
|
<button className={classNames(className)} onClick={onClick} tabIndex={tabIndex}>
|
||||||
|
@ -19,12 +24,17 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IEmojiSelector {
|
interface IEmojiSelector {
|
||||||
emojis: string[],
|
/** List of Unicode emoji characters. */
|
||||||
|
emojis: Iterable<string>,
|
||||||
|
/** Event handler when an emoji is clicked. */
|
||||||
onReact: (emoji: string) => void,
|
onReact: (emoji: string) => void,
|
||||||
|
/** Whether the selector should be visible. */
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
|
/** Whether the selector should be focused. */
|
||||||
focused?: boolean,
|
focused?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Panel with a row of emoji buttons. */
|
||||||
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
||||||
|
|
||||||
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
||||||
|
@ -40,7 +50,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = fa
|
||||||
space={2}
|
space={2}
|
||||||
className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')}
|
className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')}
|
||||||
>
|
>
|
||||||
{emojis.map((emoji, i) => (
|
{Array.from(emojis).map((emoji, i) => (
|
||||||
<EmojiButton
|
<EmojiButton
|
||||||
key={i}
|
key={i}
|
||||||
emoji={emoji}
|
emoji={emoji}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue