Merge remote-tracking branch 'origin/develop' into next-embeds
This commit is contained in:
commit
0d0d12489e
|
@ -25,6 +25,7 @@ module.exports = {
|
|||
'import',
|
||||
'promise',
|
||||
'react-hooks',
|
||||
'@typescript-eslint',
|
||||
],
|
||||
|
||||
parserOptions: {
|
||||
|
@ -104,7 +105,8 @@ module.exports = {
|
|||
'no-undef': 'error',
|
||||
'no-unreachable': 'error',
|
||||
'no-unused-expressions': 'error',
|
||||
'no-unused-vars': [
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
|
@ -141,6 +143,7 @@ module.exports = {
|
|||
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
|
||||
'react/jsx-indent': ['error', 2],
|
||||
// 'react/jsx-no-bind': ['error'],
|
||||
'react/jsx-no-comment-textnodes': 'error',
|
||||
'react/jsx-no-duplicate-props': 'error',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-tag-spacing': 'error',
|
||||
|
@ -149,7 +152,6 @@ module.exports = {
|
|||
'react/jsx-wrap-multilines': 'error',
|
||||
'react/no-multi-comp': 'off',
|
||||
'react/no-string-refs': 'error',
|
||||
'react/prop-types': 'error',
|
||||
'react/self-closing-comp': 'error',
|
||||
|
||||
'jsx-a11y/accessible-emoji': 'warn',
|
||||
|
@ -256,14 +258,12 @@ module.exports = {
|
|||
'promise/catch-or-return': 'error',
|
||||
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
'no-undef': 'off', // https://stackoverflow.com/a/69155899
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
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:
|
||||
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 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
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import loadPolyfills from './soapbox/load_polyfills';
|
||||
|
||||
// @ts-ignore
|
||||
require.context('./images/', true);
|
||||
|
||||
// Load stylesheet
|
File diff suppressed because one or more lines are too long
|
@ -9,6 +9,7 @@
|
|||
<link href="/manifest.json" rel="manifest">
|
||||
<!--server-generated-meta-->
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<%= snippets %>
|
||||
</head>
|
||||
<body class="theme-mode-light no-reduce-motion">
|
||||
<div id="soapbox">
|
||||
|
|
|
@ -52,11 +52,11 @@
|
|||
"audio.play": "Play",
|
||||
"audio.unmute": "Unmute",
|
||||
"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.title": "Network error",
|
||||
"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",
|
||||
"column.blocks": "Blocked users",
|
||||
"column.community": "Local timeline",
|
||||
|
@ -254,7 +254,7 @@
|
|||
"login.fields.username_placeholder": "Username",
|
||||
"login.log_in": "Log 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.sublabel": "This resource could not be found",
|
||||
"morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.",
|
||||
|
@ -530,11 +530,11 @@
|
|||
"audio.play": "Play",
|
||||
"audio.unmute": "Unmute",
|
||||
"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.title": "Network error",
|
||||
"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",
|
||||
"column.blocks": "Blocked users",
|
||||
"column.community": "Local timeline",
|
||||
|
@ -732,7 +732,7 @@
|
|||
"login.fields.username_placeholder": "Username",
|
||||
"login.log_in": "Log 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.sublabel": "This resource could not be found",
|
||||
"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 = [];
|
||||
|
||||
const setupMock = (axios: AxiosInstance) => {
|
||||
const mock = new MockAdapter(axios);
|
||||
const mock = new MockAdapter(axios, { onNoMatch: 'throwException' });
|
||||
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) => {
|
||||
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) {
|
||||
return (dispatch, getState) => {
|
||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||
|
|
|
@ -13,7 +13,9 @@ import { createAccount } from 'soapbox/actions/accounts';
|
|||
import { createApp } from 'soapbox/actions/apps';
|
||||
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
|
||||
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
|
||||
import { startOnboarding } from 'soapbox/actions/onboarding';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import { custom } from 'soapbox/custom';
|
||||
import KVStore from 'soapbox/storage/kv_store';
|
||||
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
|
||||
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_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL';
|
||||
|
||||
const customApp = custom('app');
|
||||
|
||||
export const messages = defineMessages({
|
||||
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
|
||||
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 instance = state.get('instance');
|
||||
|
@ -54,12 +58,23 @@ const getScopes = state => {
|
|||
|
||||
function createAppAndToken() {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(createAuthApp()).then(() => {
|
||||
return dispatch(getAuthApp()).then(() => {
|
||||
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() {
|
||||
return (dispatch, getState) => {
|
||||
const params = {
|
||||
|
@ -117,7 +132,7 @@ export function refreshUserToken() {
|
|||
const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']);
|
||||
const app = getState().getIn(['auth', 'app']);
|
||||
|
||||
if (!refreshToken) return dispatch(noOp());
|
||||
if (!refreshToken) return dispatch(noOp);
|
||||
|
||||
const params = {
|
||||
client_id: app.get('client_id'),
|
||||
|
@ -200,7 +215,7 @@ export function loadCredentials(token, accountUrl) {
|
|||
|
||||
export function logIn(intl, username, password) {
|
||||
return (dispatch, getState) => {
|
||||
return dispatch(createAuthApp()).then(() => {
|
||||
return dispatch(getAuthApp()).then(() => {
|
||||
return dispatch(createUserToken(username, password));
|
||||
}).catch(error => {
|
||||
if (error.response.data.error === 'mfa_required') {
|
||||
|
@ -235,10 +250,12 @@ export function logOut(intl) {
|
|||
const account = getLoggedInAccount(state);
|
||||
const standalone = isStandalone(state);
|
||||
|
||||
if (!account) return dispatch(noOp);
|
||||
|
||||
const params = {
|
||||
client_id: state.getIn(['auth', 'app', 'client_id']),
|
||||
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([
|
||||
|
@ -276,7 +293,10 @@ export function register(params) {
|
|||
|
||||
return dispatch(createAppAndToken())
|
||||
.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() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const instance = state.get('instance');
|
||||
const { instance } = state;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
dispatch({ type: CHATS_FETCH_REQUEST });
|
||||
if (features.chatsV2) {
|
||||
dispatch(fetchChatsV2());
|
||||
return dispatch(fetchChatsV2());
|
||||
} else {
|
||||
dispatch(fetchChatsV1());
|
||||
return dispatch(fetchChatsV1());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ function createExternalApp(instance, baseURL) {
|
|||
|
||||
const params = {
|
||||
client_name: sourceCode.displayName,
|
||||
redirect_uris: `${window.location.origin}/auth/external`,
|
||||
redirect_uris: `${window.location.origin}/login/external`,
|
||||
website: sourceCode.homepage,
|
||||
scopes,
|
||||
};
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
|
||||
export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
|
||||
|
||||
export function setHeight(key, id, height) {
|
||||
return {
|
||||
type: HEIGHT_CACHE_SET,
|
||||
key,
|
||||
id,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearHeight() {
|
||||
return {
|
||||
type: HEIGHT_CACHE_CLEAR,
|
||||
};
|
||||
}
|
|
@ -93,7 +93,7 @@ const isBroken = status => {
|
|||
// https://gitlab.com/soapbox-pub/soapbox/-/issues/28
|
||||
if (status.reblog && !status.reblog.account.id) return true;
|
||||
return false;
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,30 +1,19 @@
|
|||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { get } from 'lodash';
|
||||
|
||||
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 { parseVersion } from 'soapbox/utils/features';
|
||||
|
||||
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 me = state.me;
|
||||
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) => {
|
||||
const accountUrl = getMeUrl(state) || getAuthUserUrl(state);
|
||||
|
||||
|
@ -35,60 +24,49 @@ export const getHost = (state: RootState) => {
|
|||
}
|
||||
};
|
||||
|
||||
export function rememberInstance(host: string) {
|
||||
return (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
dispatch({ type: INSTANCE_REMEMBER_REQUEST, host });
|
||||
return KVStore.getItemOrError(`instance:${host}`).then((instance: Record<string, any>) => {
|
||||
dispatch({ type: INSTANCE_REMEMBER_SUCCESS, host, instance });
|
||||
return instance;
|
||||
}).catch((error: Error) => {
|
||||
dispatch({ type: INSTANCE_REMEMBER_FAIL, host, error, skipAlert: true });
|
||||
});
|
||||
};
|
||||
}
|
||||
export const rememberInstance = createAsyncThunk(
|
||||
'instance/remember',
|
||||
async(host: string) => {
|
||||
return await KVStore.getItemOrError(`instance:${host}`);
|
||||
},
|
||||
);
|
||||
|
||||
// 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 v = parseVersion(get(instance, 'version'));
|
||||
return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']);
|
||||
};
|
||||
|
||||
export function fetchInstance() {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: INSTANCE_FETCH_REQUEST });
|
||||
return api(getState).get('/api/v1/instance').then(({ data: instance }: { data: Record<string, any> }) => {
|
||||
dispatch({ type: INSTANCE_FETCH_SUCCESS, instance });
|
||||
export const fetchInstance = createAsyncThunk<void, void, { state: RootState }>(
|
||||
'instance/fetch',
|
||||
async(_arg, { dispatch, getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { data: instance } = await api(getState).get('/api/v1/instance');
|
||||
if (needsNodeinfo(instance)) {
|
||||
// @ts-ignore: ???
|
||||
dispatch(fetchNodeinfo()); // Pleroma < 2.1 backwards compatibility
|
||||
dispatch(fetchNodeinfo());
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
dispatch({ type: INSTANCE_FETCH_FAIL, error, skipAlert: true });
|
||||
});
|
||||
};
|
||||
}
|
||||
return instance;
|
||||
} catch(e) {
|
||||
return rejectWithValue(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Tries to remember the instance from browser storage before fetching it
|
||||
export function loadInstance() {
|
||||
return (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
/** Tries to remember the instance from browser storage before fetching it */
|
||||
export const loadInstance = createAsyncThunk<void, void, { state: RootState }>(
|
||||
'instance/load',
|
||||
async(_arg, { dispatch, getState }) => {
|
||||
const host = getHost(getState());
|
||||
await Promise.all([
|
||||
dispatch(rememberInstance(host || '')),
|
||||
dispatch(fetchInstance()),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
// @ts-ignore: ???
|
||||
return dispatch(rememberInstance(host)).finally(() => {
|
||||
// @ts-ignore: ???
|
||||
return dispatch(fetchInstance());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
};
|
||||
}
|
||||
export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>(
|
||||
'nodeinfo/fetch',
|
||||
async(_arg, { getState }) => {
|
||||
return await api(getState).get('/nodeinfo/2.1.json');
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import KVStore from 'soapbox/storage/kv_store';
|
||||
import { getAuthUserId, getAuthUserUrl } from 'soapbox/utils/auth';
|
||||
|
||||
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) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(patchMeRequest());
|
||||
|
||||
return api(getState)
|
||||
.patch('/api/v1/accounts/update_credentials', params)
|
||||
.then(response => {
|
||||
persistAuthAccount(response.data, params);
|
||||
dispatch(patchMeSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(patchMeFail(error));
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
export const MODAL_OPEN = 'MODAL_OPEN';
|
||||
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 {
|
||||
type: MODAL_OPEN,
|
||||
modalType: type,
|
||||
|
@ -9,7 +10,8 @@ export function openModal(type, props) {
|
|||
};
|
||||
}
|
||||
|
||||
export function closeModal(type) {
|
||||
/** Close the modal */
|
||||
export function closeModal(type: string) {
|
||||
return {
|
||||
type: MODAL_CLOSE,
|
||||
modalType: type,
|
|
@ -98,7 +98,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale,
|
|||
|
||||
const isOnNotificationsPage = curPath === '/notifications';
|
||||
|
||||
if (notification.type === 'mention') {
|
||||
if (['mention', 'status'].includes(notification.type)) {
|
||||
const regex = regexFromFilters(filters);
|
||||
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
|
||||
filtered = regex && regex.test(searchIndex);
|
||||
|
@ -121,7 +121,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale,
|
|||
}).catch(console.error);
|
||||
}).catch(console.error);
|
||||
}
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
|
@ -170,7 +170,7 @@ export function dequeueNotifications() {
|
|||
const excludeTypesFromSettings = getState => getSettings(getState()).getIn(['notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||
|
||||
const 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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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 { openModal, closeModal } from './modals';
|
||||
import { openModal } from './modals';
|
||||
|
||||
export const REPORT_INIT = 'REPORT_INIT';
|
||||
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_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
|
||||
|
||||
export const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
|
||||
|
||||
export function initReport(account, status) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
|
@ -54,16 +56,15 @@ export function toggleStatusReport(statusId, checked) {
|
|||
export function submitReport() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitReportRequest());
|
||||
const { reports } = getState();
|
||||
|
||||
api(getState).post('/api/v1/reports', {
|
||||
account_id: getState().getIn(['reports', 'new', 'account_id']),
|
||||
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
|
||||
comment: getState().getIn(['reports', 'new', 'comment']),
|
||||
forward: getState().getIn(['reports', 'new', 'forward']),
|
||||
}).then(response => {
|
||||
dispatch(closeModal());
|
||||
dispatch(submitReportSuccess(response.data));
|
||||
}).catch(error => dispatch(submitReportFail(error)));
|
||||
return api(getState).post('/api/v1/reports', {
|
||||
account_id: reports.getIn(['new', 'account_id']),
|
||||
status_ids: reports.getIn(['new', 'status_ids']),
|
||||
rule_ids: reports.getIn(['new', 'rule_ids']),
|
||||
comment: reports.getIn(['new', 'comment']),
|
||||
forward: reports.getIn(['new', 'forward']),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -73,10 +74,9 @@ export function submitReportRequest() {
|
|||
};
|
||||
}
|
||||
|
||||
export function submitReportSuccess(report) {
|
||||
export function submitReportSuccess() {
|
||||
return {
|
||||
type: REPORT_SUBMIT_SUCCESS,
|
||||
report,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -107,3 +107,10 @@ export function changeReportBlock(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 { getLoggedInAccount } from 'soapbox/utils/auth';
|
||||
import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
|
@ -84,12 +85,22 @@ export function changePassword(oldPassword, newPassword, confirmation) {
|
|||
|
||||
export function resetPassword(usernameOrEmail) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const v = parseVersion(state.instance);
|
||||
|
||||
dispatch({ type: RESET_PASSWORD_REQUEST });
|
||||
|
||||
const params =
|
||||
usernameOrEmail.includes('@')
|
||||
? { email: usernameOrEmail }
|
||||
: { username: usernameOrEmail };
|
||||
return api(getState).post('/api/v1/truth/password_reset/request', params).then(() => {
|
||||
: { nickname: usernameOrEmail, username: usernameOrEmail };
|
||||
|
||||
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 });
|
||||
}).catch(error => {
|
||||
dispatch({ type: RESET_PASSWORD_FAIL, error });
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
|
||||
import uuid from '../uuid';
|
||||
|
||||
import { showAlertForError } from './alerts';
|
||||
import snackbar from './snackbar';
|
||||
|
||||
|
@ -85,7 +84,7 @@ export const defaultSettings = ImmutableMap({
|
|||
|
||||
shows: ImmutableMap({
|
||||
follow: true,
|
||||
follow_request: false,
|
||||
follow_request: true,
|
||||
favourite: true,
|
||||
reblog: true,
|
||||
mention: true,
|
||||
|
|
|
@ -47,21 +47,34 @@ export function rememberSoapboxConfig(host) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchSoapboxConfig(host) {
|
||||
export function fetchFrontendConfigurations() {
|
||||
return (dispatch, getState) => {
|
||||
api(getState).get('/api/pleroma/frontend_configurations').then(response => {
|
||||
if (response.data.soapbox_fe) {
|
||||
dispatch(importSoapboxConfig(response.data.soapbox_fe, host));
|
||||
} else {
|
||||
dispatch(fetchSoapboxJson(host));
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch(fetchSoapboxJson(host));
|
||||
});
|
||||
return api(getState)
|
||||
.get('/api/pleroma/frontend_configurations')
|
||||
.then(({ data }) => data);
|
||||
};
|
||||
}
|
||||
|
||||
// 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() {
|
||||
return (dispatch, getState) => {
|
||||
const host = getHost(getState());
|
||||
|
|
|
@ -2,7 +2,7 @@ import { isLoggedIn } from 'soapbox/utils/auth';
|
|||
import { getFeatures, parseVersion } from 'soapbox/utils/features';
|
||||
import { shouldHaveCard } from 'soapbox/utils/status';
|
||||
|
||||
import api from '../api';
|
||||
import api, { getNextLink } from '../api';
|
||||
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
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) {
|
||||
return (dispatch, getState) => {
|
||||
return Promise.all([
|
||||
return async(dispatch, getState) => {
|
||||
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(fetchStatus(id)),
|
||||
]);
|
||||
return { next: undefined };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,10 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
|
|||
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) => {
|
||||
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
|
||||
};
|
||||
|
@ -31,7 +35,7 @@ const getToken = (state: RootState, authType: string) => {
|
|||
const maybeParseJSON = (data: string) => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch(Exception) {
|
||||
} catch (Exception) {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,9 +3,12 @@
|
|||
import 'intl';
|
||||
import 'intl/locale-data/jsonp/en';
|
||||
import 'es6-symbol/implement';
|
||||
// @ts-ignore: No types
|
||||
import includes from 'array-includes';
|
||||
// @ts-ignore: No types
|
||||
import isNaN from 'is-nan';
|
||||
import assign from 'object-assign';
|
||||
// @ts-ignore: No types
|
||||
import values from 'object.values';
|
||||
|
||||
import { decode as decodeBase64 } from './utils/base64';
|
||||
|
@ -30,7 +33,7 @@ if (!HTMLCanvasElement.prototype.toBlob) {
|
|||
const BASE64_MARKER = ';base64,';
|
||||
|
||||
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);
|
||||
let data;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
export default function compareId(id1, id2) {
|
||||
export default function compareId(id1: string, id2: string) {
|
||||
if (id1 === id2) {
|
||||
return 0;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
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 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';
|
||||
|
||||
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 {
|
||||
condition: boolean,
|
||||
wrapper: (children: any) => React.ReactElement<any, any>
|
||||
|
@ -35,6 +54,7 @@ interface IAccount {
|
|||
showProfileHoverCard?: boolean,
|
||||
timestamp?: string | Date,
|
||||
timestampUrl?: string,
|
||||
withDate?: boolean,
|
||||
withRelationship?: boolean,
|
||||
}
|
||||
|
||||
|
@ -51,6 +71,7 @@ const Account = ({
|
|||
showProfileHoverCard = true,
|
||||
timestamp,
|
||||
timestampUrl,
|
||||
withDate = false,
|
||||
withRelationship = true,
|
||||
}: IAccount) => {
|
||||
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} />;
|
||||
}
|
||||
|
||||
|
@ -116,39 +137,41 @@ const Account = ({
|
|||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
{account.display_name}
|
||||
{account.username}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (withDate) timestamp = account.created_at;
|
||||
|
||||
const LinkEl: any = showProfileHoverCard ? Link : 'div';
|
||||
|
||||
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='center' space={3} grow>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||
>
|
||||
<LinkEl
|
||||
to={`/@${account.get('acct')}`}
|
||||
title={account.get('acct')}
|
||||
to={`/@${account.acct}`}
|
||||
title={account.acct}
|
||||
onClick={(event: React.MouseEvent) => event.stopPropagation()}
|
||||
>
|
||||
<Avatar src={account.get('avatar')} size={avatarSize} />
|
||||
<Avatar src={account.avatar} size={avatarSize} />
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
||||
<div className='flex-grow'>
|
||||
<ProfilePopper
|
||||
condition={showProfileHoverCard}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.get('id')} inline>{children}</HoverRefWrapper>}
|
||||
wrapper={(children) => <HoverRefWrapper accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||
>
|
||||
<LinkEl
|
||||
to={`/@${account.get('acct')}`}
|
||||
title={account.get('acct')}
|
||||
to={`/@${account.acct}`}
|
||||
title={account.acct}
|
||||
onClick={(event: React.MouseEvent) => event.stopPropagation()}
|
||||
>
|
||||
<div className='flex items-center space-x-1 flex-grow' style={style}>
|
||||
|
@ -156,10 +179,10 @@ const Account = ({
|
|||
size='sm'
|
||||
weight='semibold'
|
||||
truncate
|
||||
dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }}
|
||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
||||
/>
|
||||
|
||||
{account.get('verified') && <VerificationBadge />}
|
||||
{account.verified && <VerificationBadge />}
|
||||
</div>
|
||||
</LinkEl>
|
||||
</ProfilePopper>
|
||||
|
@ -167,16 +190,20 @@ const Account = ({
|
|||
<HStack alignItems='center' space={1} style={style}>
|
||||
<Text theme='muted' size='sm' truncate>@{username}</Text>
|
||||
|
||||
{account.favicon && (
|
||||
<InstanceFavicon account={account} />
|
||||
)}
|
||||
|
||||
{(timestamp) ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||
|
||||
{timestampUrl ? (
|
||||
<Link to={timestampUrl} className='hover:underline'>
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' />
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' />
|
||||
</Link>
|
||||
) : (
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' />
|
||||
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
@ -206,8 +206,8 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true,
|
||||
'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion,
|
||||
'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 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-gray-700': i === selectedSuggestion,
|
||||
})}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
|
@ -238,7 +238,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
|
||||
return menu.map((item, i) => (
|
||||
<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='#'
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
|
@ -272,7 +272,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
<input
|
||||
type='text'
|
||||
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',
|
||||
})}
|
||||
ref={this.setInput}
|
||||
|
@ -293,7 +293,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
/>
|
||||
|
||||
<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,
|
||||
block: visible,
|
||||
'autosuggest-textarea__suggestions--visible': visible,
|
||||
|
|
|
@ -216,8 +216,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
key={key}
|
||||
data-index={i}
|
||||
className={classNames({
|
||||
'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true,
|
||||
'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion,
|
||||
'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 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-700': i === selectedSuggestion,
|
||||
})}
|
||||
onMouseDown={this.onSuggestionClick}
|
||||
>
|
||||
|
@ -257,7 +257,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
|
||||
<Textarea
|
||||
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,
|
||||
})}
|
||||
id={id}
|
||||
|
@ -284,7 +285,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
<div
|
||||
style={this.setPortalPosition()}
|
||||
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(),
|
||||
block: !suggestionsHidden && !suggestions.isEmpty(),
|
||||
})}
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
const Badge = (props: any) => (
|
||||
<span data-testid='badge' className={'badge badge--' + props.slug}>{props.title}</span>
|
||||
interface IBadge {
|
||||
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;
|
||||
|
|
|
@ -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 { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { IconButton, Counter } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import Motion from 'soapbox/features/ui/util/optional_motion';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
@ -18,12 +18,13 @@ let id = 0;
|
|||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
|
||||
middleClick?: React.EventHandler<React.MouseEvent>,
|
||||
text: string,
|
||||
text: string | JSX.Element,
|
||||
href?: string,
|
||||
to?: string,
|
||||
newTab?: boolean,
|
||||
isLogout?: boolean,
|
||||
icon: string,
|
||||
count?: number,
|
||||
destructive?: boolean,
|
||||
}
|
||||
|
||||
|
@ -174,10 +175,10 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
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 (
|
||||
<li className={classNames('dropdown-menu__item', { destructive })} key={`${text}-${i}`}>
|
||||
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
|
||||
<a
|
||||
href={href || to || '#'}
|
||||
role='button'
|
||||
|
@ -190,8 +191,15 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
target={newTab ? '_blank' : undefined}
|
||||
data-method={isLogout ? 'delete' : undefined}
|
||||
>
|
||||
{icon && <Icon src={icon} />}
|
||||
{text}
|
||||
{icon && <SvgIcon src={icon} className='mr-3 h-5 w-5 flex-none' />}
|
||||
|
||||
<span className='truncate'>{text}</span>
|
||||
|
||||
{count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
|
@ -278,7 +286,7 @@ class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
|
|||
onShiftClick(e);
|
||||
} else if (this.state.id === openDropdownId) {
|
||||
this.handleClose();
|
||||
} else if(onOpen) {
|
||||
} else if (onOpen) {
|
||||
const { top } = e.currentTarget.getBoundingClientRect();
|
||||
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector';
|
||||
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is_mobile';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
|
||||
|
||||
interface IEmojiButtonWrapper {
|
||||
statusId: string,
|
||||
children: JSX.Element,
|
||||
}
|
||||
|
||||
/** Provides emoji reaction functionality to the underlying button component */
|
||||
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
|
||||
const dispatch = useDispatch();
|
||||
const ownAccount = useOwnAccount();
|
||||
const status = useAppSelector(state => state.statuses.get(statusId));
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const [visible, setVisible] = useState(false);
|
||||
// const [focused, setFocused] = useState(false);
|
||||
|
||||
// `useRef` won't trigger a re-render, while `useState` does.
|
||||
// https://popper.js.org/react-popper/v2/
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: 'top-start',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [-10, 0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
if (!isUserTouching()) {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
// Unless the user is touching, delay closing the emoji selector briefly
|
||||
// so the user can move the mouse diagonally to make a selection.
|
||||
if (isUserTouching()) {
|
||||
setVisible(false);
|
||||
} else {
|
||||
timeout.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReact = (emoji: string): void => {
|
||||
if (ownAccount) {
|
||||
dispatch(simpleEmojiReact(status, emoji));
|
||||
} else {
|
||||
dispatch(openModal('UNAUTHORIZED', {
|
||||
action: 'FAVOURITE',
|
||||
ap_id: status.url,
|
||||
}));
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
|
||||
|
||||
if (isUserTouching()) {
|
||||
if (visible) {
|
||||
handleReact(meEmojiReact);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
} else {
|
||||
handleReact(meEmojiReact);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
|
||||
// setFocused(false);
|
||||
// };
|
||||
|
||||
const selector = (
|
||||
<div
|
||||
className={classNames('z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<EmojiSelector
|
||||
emojis={soapboxConfig.allowedEmoji}
|
||||
onReact={handleReact}
|
||||
// focused={focused}
|
||||
// onUnfocus={handleUnfocus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{React.cloneElement(children, {
|
||||
onClick: handleClick,
|
||||
ref: setReferenceElement,
|
||||
})}
|
||||
|
||||
{selector}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiButtonWrapper;
|
|
@ -1,43 +1,59 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
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 SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { captureException } from 'soapbox/monitoring';
|
||||
import KVStore from 'soapbox/storage/kv_store';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
|
||||
import { getSoapboxConfig } from '../actions/soapbox';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
const soapboxConfig = getSoapboxConfig(state);
|
||||
const goHome = () => location.href = '/';
|
||||
|
||||
/** 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 {
|
||||
siteTitle: state.instance.title,
|
||||
helpLink: soapboxConfig.getIn(['links', 'help']),
|
||||
supportLink: soapboxConfig.getIn(['links', 'support']),
|
||||
statusLink: soapboxConfig.getIn(['links', 'status']),
|
||||
logo,
|
||||
links,
|
||||
};
|
||||
};
|
||||
|
||||
@connect(mapStateToProps)
|
||||
class ErrorBoundary extends React.PureComponent {
|
||||
type Props = ReturnType<typeof mapStateToProps>;
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
siteTitle: PropTypes.string,
|
||||
supportLink: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
statusLink: PropTypes.string,
|
||||
};
|
||||
type State = {
|
||||
hasError: boolean,
|
||||
error: any,
|
||||
componentStack: any,
|
||||
browser?: Bowser.Parser.Parser,
|
||||
}
|
||||
|
||||
state = {
|
||||
class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
componentStack: undefined,
|
||||
browser: undefined,
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
textarea: HTMLTextAreaElement | null = null;
|
||||
|
||||
componentDidCatch(error: any, info: any): void {
|
||||
captureException(error);
|
||||
|
||||
this.setState({
|
||||
|
@ -55,11 +71,11 @@ class ErrorBoundary extends React.PureComponent {
|
|||
.catch(() => {});
|
||||
}
|
||||
|
||||
setTextareaRef = c => {
|
||||
setTextareaRef: React.RefCallback<HTMLTextAreaElement> = c => {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
handleCopy = e => {
|
||||
handleCopy: React.MouseEventHandler = () => {
|
||||
if (!this.textarea) return;
|
||||
|
||||
this.textarea.select();
|
||||
|
@ -68,25 +84,31 @@ class ErrorBoundary extends React.PureComponent {
|
|||
document.execCommand('copy');
|
||||
}
|
||||
|
||||
getErrorText = () => {
|
||||
getErrorText = (): string => {
|
||||
const { error, componentStack } = this.state;
|
||||
return error + componentStack;
|
||||
}
|
||||
|
||||
clearCookies = e => {
|
||||
clearCookies: React.MouseEventHandler = (e) => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
KVStore.clear();
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
e.preventDefault();
|
||||
unregisterSw().then(goHome).catch(goHome);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { browser, hasError } = this.state;
|
||||
const { children, siteTitle, helpLink, statusLink, supportLink } = this.props;
|
||||
const { children, siteTitle, logo, links } = this.props;
|
||||
|
||||
if (!hasError) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const isProduction = NODE_ENV === 'production';
|
||||
const isProduction = BuildConfig.NODE_ENV === 'production';
|
||||
|
||||
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'>
|
||||
<div className='flex-shrink-0 flex justify-center'>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
@ -105,14 +131,18 @@ class ErrorBoundary extends React.PureComponent {
|
|||
<FormattedMessage id='alert.unexpected.message' defaultMessage='Something went wrong.' />
|
||||
</h1>
|
||||
<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
|
||||
may also try to <a href='/' onClick={this.clearCookies} className='text-gray-700 hover:underline'>
|
||||
<FormattedMessage
|
||||
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
|
||||
id='alert.unexpected.clear_cookies'
|
||||
defaultMessage='clear cookies and browser data'
|
||||
/>
|
||||
</a>
|
||||
{' ' }(this will log you out).
|
||||
) }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<Text theme='muted'>
|
||||
|
@ -144,7 +174,7 @@ class ErrorBoundary extends React.PureComponent {
|
|||
|
||||
{browser && (
|
||||
<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>
|
||||
</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'>
|
||||
<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'>
|
||||
Status
|
||||
<a href={links.get('status')} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
|
||||
<FormattedMessage id='alert.unexpected.links.status' defaultMessage='Status' />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{helpLink && (
|
||||
{links.get('help') && (
|
||||
<>
|
||||
<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'>
|
||||
Help Center
|
||||
<a href={links.get('help')} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
|
||||
<FormattedMessage id='alert.unexpected.links.help' defaultMessage='Help Center' />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
{supportLink && (
|
||||
{links.get('support') && (
|
||||
<>
|
||||
<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'>
|
||||
Support
|
||||
<a href={links.get('support')} className='text-sm font-medium text-gray-500 hover:text-gray-600'>
|
||||
<FormattedMessage id='alert.unexpected.links.support' defaultMessage='Support' />
|
||||
</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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
|
@ -11,7 +10,13 @@ import { shortNumberFormat } from '../utils/numbers';
|
|||
import Permalink from './permalink';
|
||||
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 brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
|
||||
|
||||
|
@ -41,7 +46,7 @@ const Hashtag = ({ hashtag }) => {
|
|||
<Sparklines
|
||||
width={40}
|
||||
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} />
|
||||
</Sparklines>
|
||||
|
@ -51,8 +56,4 @@ const Hashtag = ({ hashtag }) => {
|
|||
);
|
||||
};
|
||||
|
||||
Hashtag.propTypes = {
|
||||
hashtag: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
export default Hashtag;
|
|
@ -1,5 +1,4 @@
|
|||
import { debounce } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
@ -13,10 +12,16 @@ const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
|
|||
dispatch(openProfileHoverCard(ref, accountId));
|
||||
}, 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 ref = useRef();
|
||||
const Elem = inline ? 'span' : 'div';
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const Elem: keyof JSX.IntrinsicElements = inline ? 'span' : 'div';
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
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 };
|
|
@ -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 Icon from 'soapbox/components/icon';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
|
||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||
count: number,
|
||||
|
@ -14,9 +14,11 @@ const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) =
|
|||
<div className='relative'>
|
||||
<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'>
|
||||
{shortNumberFormat(count)}
|
||||
</i>}
|
||||
{count > 0 && (
|
||||
<i className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
</i>
|
||||
)}
|
||||
</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 PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
|
||||
const List = ({ children }) => (
|
||||
const List: React.FC = ({ children }) => (
|
||||
<div className='space-y-0.5'>{children}</div>
|
||||
);
|
||||
|
||||
List.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
interface IListItem {
|
||||
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 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 };
|
|
@ -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 messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' },
|
||||
});
|
||||
|
||||
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'>
|
||||
{!showResults && (
|
||||
<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,
|
||||
'rounded': poll.multiple,
|
||||
})}
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
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 BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||
import { UserPanel } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
import { showProfileHoverCard } from './hover_ref_wrapper';
|
||||
import { Card, CardBody, Stack, Text } from './ui';
|
||||
|
||||
import type { AppDispatch } from 'soapbox/store';
|
||||
import type { Account } from 'soapbox/types/entities';
|
||||
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const getBadges = (account) => {
|
||||
const getBadges = (account: Account): JSX.Element[] => {
|
||||
const badges = [];
|
||||
|
||||
if (account.admin) {
|
||||
|
@ -36,32 +37,41 @@ const getBadges = (account) => {
|
|||
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
|
||||
}
|
||||
|
||||
if (account.donor) {
|
||||
badges.push(<Badge key='donor' slug='donor' title='Donor' />);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const handleMouseEnter = (dispatch) => {
|
||||
return e => {
|
||||
const handleMouseEnter = (dispatch: AppDispatch): React.MouseEventHandler => {
|
||||
return () => {
|
||||
dispatch(updateProfileHoverCard());
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseLeave = (dispatch) => {
|
||||
return e => {
|
||||
const handleMouseLeave = (dispatch: AppDispatch): React.MouseEventHandler => {
|
||||
return () => {
|
||||
dispatch(closeProfileHoverCard(true));
|
||||
};
|
||||
};
|
||||
|
||||
export const ProfileHoverCard = ({ visible }) => {
|
||||
const dispatch = useDispatch();
|
||||
interface IProfileHoverCard {
|
||||
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 intl = useIntl();
|
||||
|
||||
const [popperElement, setPopperElement] = useState(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const me = useSelector(state => state.get('me'));
|
||||
const accountId = useSelector(state => state.getIn(['profile_hover_card', 'accountId']));
|
||||
const account = useSelector(state => accountId && getAccount(state, accountId));
|
||||
const targetRef = useSelector(state => state.getIn(['profile_hover_card', 'ref', 'current']));
|
||||
const me = useAppSelector(state => state.me);
|
||||
const accountId: string | undefined = useAppSelector(state => state.profile_hover_card.get<string | undefined>('accountId', undefined));
|
||||
const account = useAppSelector(state => accountId && getAccount(state, accountId));
|
||||
const targetRef = useAppSelector(state => state.profile_hover_card.getIn(['ref', 'current']) as Element | null);
|
||||
const badges = account ? getBadges(account) : [];
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -82,8 +92,8 @@ export const ProfileHoverCard = ({ visible }) => {
|
|||
const { styles, attributes } = usePopper(targetRef, popperElement);
|
||||
|
||||
if (!account) return null;
|
||||
const accountBio = { __html: account.get('note_emojified') };
|
||||
const followedBy = me !== account.get('id') && account.getIn(['relationship', 'followed_by']);
|
||||
const accountBio = { __html: account.note_emojified };
|
||||
const followedBy = me !== account.id && account.relationship.get('followed_by') === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -111,7 +121,7 @@ export const ProfileHoverCard = ({ visible }) => {
|
|||
)}
|
||||
</BundleContainer>
|
||||
|
||||
{account.getIn(['source', 'note'], '').length > 0 && (
|
||||
{account.source.get('note', '').length > 0 && (
|
||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
||||
)}
|
||||
</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;
|
|
@ -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 { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon, Text } from './ui';
|
||||
import { Icon, Text, Counter } from './ui';
|
||||
|
||||
interface ISidebarNavigationLink {
|
||||
count?: number,
|
||||
icon: string,
|
||||
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 withCounter = typeof count !== 'undefined';
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
exact
|
||||
to={to}
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={classNames({
|
||||
'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,
|
||||
|
@ -32,8 +44,8 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink
|
|||
})}
|
||||
>
|
||||
{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'>
|
||||
{count}
|
||||
<span className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
@ -50,6 +62,6 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink
|
|||
<Text weight='semibold' theme='inherit'>{text}</Text>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default SidebarNavigationLink;
|
||||
|
|
|
@ -1,33 +1,144 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
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 { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import SidebarNavigationLink from './sidebar-navigation-link';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||
|
||||
const SidebarNavigation = () => {
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
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 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 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 (
|
||||
<div>
|
||||
<div className='flex flex-col space-y-2'>
|
||||
<SidebarNavigationLink
|
||||
to='/'
|
||||
icon={require('icons/feed.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.home' defaultMessage='Feed' />}
|
||||
text={<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />}
|
||||
/>
|
||||
|
||||
{account && (
|
||||
|
@ -42,7 +153,7 @@ const SidebarNavigation = () => {
|
|||
to='/notifications'
|
||||
icon={require('icons/alert.svg')}
|
||||
count={notificationCount}
|
||||
text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Alerts' />}
|
||||
text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />}
|
||||
/>
|
||||
|
||||
<SidebarNavigationLink
|
||||
|
@ -53,69 +164,17 @@ const SidebarNavigation = () => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{account && (
|
||||
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 && renderMessagesLink()}
|
||||
|
||||
{/* {(account && account.staff) && (
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu items={menu}>
|
||||
<SidebarNavigationLink
|
||||
to='/admin'
|
||||
icon={location.pathname.startsWith('/admin') ? require('icons/dashboard-filled.svg') : require('@tabler/icons/icons/dashboard.svg')}
|
||||
text={<FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' />}
|
||||
icon={require('@tabler/icons/icons/dots-circle-horizontal.svg')}
|
||||
count={dashboardCount}
|
||||
text={<FormattedMessage id='tabs_bar.more' defaultMessage='More' />}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
{(account && instance.invites_enabled) && (
|
||||
<SidebarNavigationLink
|
||||
to={`${baseURL}/invites`}
|
||||
icon={require('@tabler/icons/icons/mailbox.svg')}
|
||||
text={<FormattedMessage id='navigation.invites' defaultMessage='Invites' />}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
|
||||
{account && (
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
import { logOut, switchAccount } 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 { Stack } from 'soapbox/components/ui';
|
||||
import ProfileStats from 'soapbox/features/ui/components/profile_stats';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import { closeSidebar } from '../actions/sidebar';
|
||||
import { makeGetAccount, makeGetOtherAccounts } from '../selectors';
|
||||
import { useAppSelector, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
|
||||
import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors';
|
||||
import { getBaseURL } from 'soapbox/utils/accounts';
|
||||
|
||||
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({
|
||||
followers: { id: 'account.followers', defaultMessage: 'Followers' },
|
||||
follows: { id: 'account.follows', defaultMessage: 'Follows' },
|
||||
|
@ -31,9 +33,21 @@ const messages = defineMessages({
|
|||
importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' },
|
||||
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
|
||||
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}>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<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>
|
||||
);
|
||||
|
||||
SidebarLink.propTypes = {
|
||||
to: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
const getOtherAccounts = makeGetOtherAccounts();
|
||||
|
||||
const SidebarMenu = () => {
|
||||
const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const logo = useSelector((state) => getSoapboxConfig(state).get('logo'));
|
||||
const features = useSelector((state) => getFeatures(state.get('instance')));
|
||||
const { logo } = useSoapboxConfig();
|
||||
const features = useFeatures();
|
||||
const getAccount = makeGetAccount();
|
||||
const getOtherAccounts = makeGetOtherAccounts();
|
||||
const me = useSelector((state) => state.get('me'));
|
||||
const account = useSelector((state) => getAccount(state, me));
|
||||
const otherAccounts = useSelector((state) => getOtherAccounts(state));
|
||||
const sidebarOpen = useSelector((state) => state.get('sidebar').sidebarOpen);
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const account = useAppSelector((state) => me ? getAccount(state, me) : null);
|
||||
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
|
||||
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
||||
const settings = useAppSelector((state) => getSettings(state));
|
||||
|
||||
const baseURL = account ? getBaseURL(account) : '';
|
||||
|
||||
const closeButtonRef = React.useRef(null);
|
||||
|
||||
|
@ -76,26 +88,29 @@ const SidebarMenu = () => {
|
|||
onClose();
|
||||
};
|
||||
|
||||
const handleSwitchAccount = (event, account) => {
|
||||
event.preventDefault();
|
||||
switchAccount(account);
|
||||
dispatch(switchAccount(account.get('id')));
|
||||
const handleSwitchAccount = (account: AccountEntity): React.MouseEventHandler => {
|
||||
return (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(switchAccount(account.id));
|
||||
};
|
||||
};
|
||||
|
||||
const onClickLogOut = (event) => {
|
||||
event.preventDefault();
|
||||
const onClickLogOut: React.MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
dispatch(logOut(intl));
|
||||
};
|
||||
|
||||
const handleSwitcherClick = (e) => {
|
||||
const handleSwitcherClick: React.MouseEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
setSwitcher((prevState) => (!prevState));
|
||||
};
|
||||
|
||||
const renderAccount = (account) => (
|
||||
<a href='/' className='block py-2' onClick={(event) => handleSwitchAccount(event, account)} key={account.get('id')}>
|
||||
<Account account={account} showProfileHoverCard={false} />
|
||||
const renderAccount = (account: AccountEntity) => (
|
||||
<a href='#' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
|
||||
<div className='pointer-events-none'>
|
||||
<Account account={account} showProfileHoverCard={false} withRelationship={false} />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
|
@ -103,17 +118,13 @@ const SidebarMenu = () => {
|
|||
dispatch(fetchOwnAccounts());
|
||||
}, []);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const acct = account.get('acct');
|
||||
const classes = classNames('sidebar-menu__root', {
|
||||
'sidebar-menu__root--visible': sidebarOpen,
|
||||
});
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={classNames('sidebar-menu__root', {
|
||||
'sidebar-menu__root--visible': sidebarOpen,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true,
|
||||
|
@ -130,7 +141,7 @@ const SidebarMenu = () => {
|
|||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Link to='/' onClick={onClose}>
|
||||
{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
|
||||
alt='Logo'
|
||||
|
@ -150,18 +161,20 @@ const SidebarMenu = () => {
|
|||
</HStack>
|
||||
|
||||
<Stack space={1}>
|
||||
<Link to={`/@${acct}`} onClick={onClose}>
|
||||
<Link to={`/@${account.acct}`} onClick={onClose}>
|
||||
<Account account={account} showProfileHoverCard={false} />
|
||||
</Link>
|
||||
|
||||
{account.staff && (
|
||||
<Stack>
|
||||
<button type='button' onClick={handleSwitcherClick} className='py-1'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text tag='span' size='sm' weight='medium'>Switch accounts</Text>
|
||||
|
||||
<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>
|
||||
</button>
|
||||
|
@ -169,10 +182,14 @@ const SidebarMenu = () => {
|
|||
{switcher && (
|
||||
<div className='border-t border-solid border-gray-200'>
|
||||
{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>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<ProfileStats
|
||||
|
@ -184,12 +201,68 @@ const SidebarMenu = () => {
|
|||
<hr />
|
||||
|
||||
<SidebarLink
|
||||
to={`/@${acct}`}
|
||||
to={`/@${account.acct}`}
|
||||
icon={require('@tabler/icons/icons/user.svg')}
|
||||
text={intl.formatMessage(messages.profile)}
|
||||
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 />
|
||||
|
||||
<SidebarLink
|
||||
|
@ -261,7 +334,7 @@ const SidebarMenu = () => {
|
|||
<hr />
|
||||
|
||||
<SidebarLink
|
||||
to='/auth/sign_out'
|
||||
to='/logout'
|
||||
icon={require('@tabler/icons/icons/logout.svg')}
|
||||
text={intl.formatMessage(messages.logout)}
|
||||
onClick={onClickLogOut}
|
|
@ -41,9 +41,10 @@ const StatusActionButton = React.forwardRef((props: IStatusActionButton, ref: Re
|
|||
ref={ref}
|
||||
type='button'
|
||||
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',
|
||||
'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-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
|
||||
src={icon}
|
||||
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,
|
||||
},
|
||||
|
|
|
@ -29,7 +29,7 @@ import type {
|
|||
} from 'soapbox/types/entities';
|
||||
|
||||
// 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 => {
|
||||
const { account } = status;
|
||||
|
@ -342,7 +342,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
render() {
|
||||
let media = null;
|
||||
const poll = null;
|
||||
let prepend, rebloggedByText, reblogContent, reblogElement, reblogElementMobile;
|
||||
let prepend, rebloggedByText, reblogElement, reblogElementMobile;
|
||||
|
||||
const { intl, hidden, featured, unread, group } = this.props;
|
||||
|
||||
|
@ -447,7 +447,6 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
|
||||
// @ts-ignore what the FUCK
|
||||
account = status.account;
|
||||
reblogContent = status.contentHtml;
|
||||
status = status.reblog;
|
||||
}
|
||||
|
||||
|
@ -599,7 +598,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
// const domain = getDomain(status.account);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<HotKeys handlers={handlers} data-testid='status'>
|
||||
<div
|
||||
className='status cursor-pointer'
|
||||
tabIndex={this.props.focusable && !this.props.muted ? 0 : undefined}
|
||||
|
@ -646,7 +645,6 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
|
||||
<StatusContent
|
||||
status={status}
|
||||
reblogContent={reblogContent}
|
||||
onClick={this.handleClick}
|
||||
expanded={!status.hidden}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
|
|
|
@ -6,8 +6,7 @@ import { connect } from 'react-redux';
|
|||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
|
||||
import EmojiSelector from 'soapbox/components/emoji_selector';
|
||||
import Hoverable from 'soapbox/components/hoverable';
|
||||
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
|
||||
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
||||
import { isUserTouching } from 'soapbox/is_mobile';
|
||||
|
@ -130,7 +129,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
'emojiSelectorFocused',
|
||||
]
|
||||
|
||||
handleReplyClick = () => {
|
||||
handleReplyClick: React.MouseEventHandler = (e) => {
|
||||
const { me, onReply, onOpenUnauthorizedModal, status } = this.props;
|
||||
|
||||
if (me) {
|
||||
|
@ -138,12 +137,14 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
} else {
|
||||
onOpenUnauthorizedModal('REPLY');
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleShareClick = () => {
|
||||
navigator.share({
|
||||
text: this.props.status.search_index,
|
||||
url: this.props.status.url,
|
||||
url: this.props.status.uri,
|
||||
}).catch((e) => {
|
||||
if (e.name !== 'AbortError') console.error(e);
|
||||
});
|
||||
|
@ -554,7 +555,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
|
@ -633,7 +634,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
/>
|
||||
|
||||
{features.quotePosts && me ? (
|
||||
<DropdownMenuContainer items={reblogMenu} onShiftClick={this.handleReblogClick}>
|
||||
<DropdownMenuContainer
|
||||
items={reblogMenu}
|
||||
disabled={!publicStatus}
|
||||
onShiftClick={this.handleReblogClick}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
) : (
|
||||
|
@ -641,24 +646,16 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
|
|||
)}
|
||||
|
||||
{features.emojiReacts ? (
|
||||
<Hoverable
|
||||
component={(
|
||||
<EmojiSelector
|
||||
onReact={this.handleReact}
|
||||
focused={emojiSelectorFocused}
|
||||
onUnfocus={handleEmojiSelectorUnfocus}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
<StatusActionButton
|
||||
title={meEmojiTitle}
|
||||
icon={require('@tabler/icons/icons/thumb-up.svg')}
|
||||
icon={require('@tabler/icons/icons/heart.svg')}
|
||||
filled
|
||||
color='accent'
|
||||
onClick={this.handleLikeButtonClick}
|
||||
active={Boolean(meEmojiReact)}
|
||||
count={emojiReactCount}
|
||||
/>
|
||||
</Hoverable>
|
||||
</EmojiButtonWrapper>
|
||||
): (
|
||||
<StatusActionButton
|
||||
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 PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
@ -134,8 +135,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
const idempotencyKey = statusId.replace(/^末pending-/, '');
|
||||
|
||||
return (
|
||||
<div className='material-status' key={statusId}>
|
||||
<div className='material-status__status focusable'>
|
||||
<PendingStatus
|
||||
key={statusId}
|
||||
idempotencyKey={idempotencyKey}
|
||||
|
@ -145,8 +144,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
group={group}
|
||||
withGroupAdmin={withGroupAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -226,7 +223,12 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
placeholderComponent={PlaceholderStatus}
|
||||
placeholderCount={20}
|
||||
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}
|
||||
>
|
||||
{this.renderScrollableContent()}
|
||||
|
|
|
@ -31,7 +31,7 @@ const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text,
|
|||
<NavLink to={to} exact={exact} className='thumb-navigation__link'>
|
||||
{count !== undefined ? (
|
||||
<IconWithCounter
|
||||
src={require('@tabler/icons/icons/messages.svg')}
|
||||
src={src}
|
||||
className={classNames({
|
||||
'h-5 w-5': true,
|
||||
'text-gray-600 dark:text-gray-300': !active,
|
||||
|
|
|
@ -9,9 +9,37 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
|||
const account = useOwnAccount();
|
||||
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 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));
|
||||
|
||||
/** 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 (
|
||||
<div className='thumb-navigation'>
|
||||
<ThumbNavigationLink
|
||||
|
@ -38,33 +66,16 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{account && (
|
||||
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 && renderMessagesLink()}
|
||||
|
||||
{/* (account && account.staff && (
|
||||
{(account && account.staff) && (
|
||||
<ThumbNavigationLink
|
||||
src={require('@tabler/icons/icons/dashboard.svg')}
|
||||
text={<FormattedMessage id='navigation.dashboard' defaultMessage='Dashboard' />}
|
||||
to='/admin'
|
||||
to='/soapbox/admin'
|
||||
count={dashboardCount}
|
||||
/>
|
||||
) */}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -103,7 +103,7 @@ class TimelineQueueButtonHeader extends React.PureComponent {
|
|||
|
||||
return (
|
||||
<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')} />
|
||||
|
||||
{(count > 0) && (
|
||||
|
|
|
@ -6,11 +6,15 @@ import StillImage from 'soapbox/components/still_image';
|
|||
const AVATAR_SIZE = 42;
|
||||
|
||||
interface IAvatar {
|
||||
/** URL to the avatar image. */
|
||||
src: string,
|
||||
/** Width and height of the avatar in pixels. */
|
||||
size?: number,
|
||||
/** Extra class names for the div surrounding the avatar image. */
|
||||
className?: string,
|
||||
}
|
||||
|
||||
/** Round profile avatar for accounts. */
|
||||
const Avatar = (props: IAvatar) => {
|
||||
const { src, size = AVATAR_SIZE, className } = props;
|
||||
|
||||
|
|
|
@ -8,20 +8,33 @@ import { useButtonStyles } from './useButtonStyles';
|
|||
import type { ButtonSizes, ButtonThemes } from './useButtonStyles';
|
||||
|
||||
interface IButton {
|
||||
/** Whether this button expands the width of its container. */
|
||||
block?: boolean,
|
||||
/** Elements inside the <button> */
|
||||
children?: React.ReactNode,
|
||||
/** @deprecated unused */
|
||||
classNames?: string,
|
||||
/** Prevent the button from being clicked. */
|
||||
disabled?: boolean,
|
||||
/** URL to an SVG icon to render inside the button. */
|
||||
icon?: string,
|
||||
/** Action when the button is clicked. */
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
||||
/** A predefined button size. */
|
||||
size?: ButtonSizes,
|
||||
/** @deprecated unused */
|
||||
style?: React.CSSProperties,
|
||||
/** Text inside the button. Takes precedence over `children`. */
|
||||
text?: React.ReactNode,
|
||||
/** Makes the button into a navlink, if provided. */
|
||||
to?: string,
|
||||
/** Styles the button visually with a predefined theme. */
|
||||
theme?: ButtonThemes,
|
||||
/** Whether this button should submit a form by default. */
|
||||
type?: 'button' | 'submit',
|
||||
}
|
||||
|
||||
/** Customizable button element with various themes. */
|
||||
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
|
||||
const {
|
||||
block = false,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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 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',
|
||||
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',
|
||||
link: 'border-transparent text-primary-600 hover:bg-gray-100 hover:text-primary-700',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
|
|
|
@ -17,12 +17,18 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
interface ICard {
|
||||
/** The type of card. */
|
||||
variant?: 'rounded',
|
||||
/** Card size preset. */
|
||||
size?: 'md' | 'lg' | 'xl',
|
||||
/** Extra classnames for the <div> element. */
|
||||
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
|
||||
ref={ref}
|
||||
{...filteredProps}
|
||||
|
@ -41,6 +47,7 @@ interface ICardHeader {
|
|||
onBackClick?: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
/** Typically holds a CardTitle. */
|
||||
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
|
||||
|
@ -73,10 +80,12 @@ interface ICardTitle {
|
|||
title: string | React.ReactNode
|
||||
}
|
||||
|
||||
const CardTitle = ({ title }: ICardTitle): JSX.Element => (
|
||||
<Text size='xl' weight='bold' tag='h1' data-testid='card-title'>{title}</Text>
|
||||
/** A card's title. */
|
||||
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 => (
|
||||
<div data-testid='card-body'>{children}</div>
|
||||
);
|
||||
|
|
|
@ -3,21 +3,29 @@ import React from 'react';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||
|
||||
interface IColumn {
|
||||
/** Route the back button goes to. */
|
||||
backHref?: string,
|
||||
/** Column title text. */
|
||||
label?: string,
|
||||
/** Whether this column should have a transparent background. */
|
||||
transparent?: boolean,
|
||||
/** Whether this column should have a title and back button. */
|
||||
withHeader?: boolean,
|
||||
/** Extra class name for top <div> element. */
|
||||
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 { backHref, children, label, transparent = false, withHeader = true, className } = props;
|
||||
|
||||
const history = useHistory();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const handleBackClick = () => {
|
||||
if (backHref) {
|
||||
|
@ -54,7 +62,17 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
|
||||
return (
|
||||
<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()}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
interface ICounter {
|
||||
/** Number this counter should display. */
|
||||
count: number,
|
||||
}
|
||||
|
||||
/** A simple counter for notifications, etc. */
|
||||
const Counter: React.FC<ICounter> = ({ count }) => {
|
||||
return (
|
||||
<span className='block px-1.5 py-0.5 bg-accent-500 text-xs text-white rounded-full ring-2 ring-white dark:ring-slate-800'>
|
||||
{shortNumberFormat(count)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Counter;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue