Merge remote-tracking branch 'soapbox/develop' into familiar-followers

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-develop-3zknud/deployments/28^2
marcin mikołajczak 2 years ago
commit 960dd0ed89

@ -4,4 +4,5 @@
/public/**
/tmp/**
/coverage/**
/custom/**
!.eslintrc.js

@ -8,17 +8,22 @@ cache:
files:
- yarn.lock
paths:
- node_modules
- node_modules/
stages:
- install
- lint
- test
- build
- deploy
before_script:
- env
- yarn
install-dependencies:
stage: install
script:
- yarn install --ignore-scripts
artifacts:
paths:
- node_modules/
lint-js:
stage: lint
@ -87,6 +92,14 @@ docs-deploy:
# - yarn
# - yarn build
review:
stage: deploy
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_COMMIT_REF_SLUG.git.soapbox.pub
script:
- npx -y surge static $CI_COMMIT_REF_SLUG.git.soapbox.pub
pages:
stage: deploy
before_script: []

@ -1,739 +0,0 @@
{
"ancestors": [
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>A</p>",
"created_at": "2020-09-18T20:07:10.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH6kDXA10YqhMKqO",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "A"
},
"conversation_id": 5089485,
"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://gleasonator.com/objects/9995c074-2ff6-4a01-b596-7ef6971ed5d2",
"url": "https://gleasonator.com/notice/9zIH6kDXA10YqhMKqO",
"visibility": "direct"
},
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>B</p>",
"created_at": "2020-09-18T20:07:18.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH7PUdhK3Ircg4hM",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH6kDXA10YqhMKqO",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "B"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/992ca99a-425d-46eb-b094-60412e9fb141",
"url": "https://gleasonator.com/notice/9zIH7PUdhK3Ircg4hM",
"visibility": "direct"
},
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>C</p>",
"created_at": "2020-09-18T20:07:22.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH7mMGgc1RmJwDLM",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH6kDXA10YqhMKqO",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "C"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/a2c25ef5-a40e-4098-b07e-b468989ef749",
"url": "https://gleasonator.com/notice/9zIH7mMGgc1RmJwDLM",
"visibility": "direct"
}
],
"descendants": [
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>E</p>",
"created_at": "2020-09-18T20:07:38.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH9GTCDWEFSRt2um",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH7PUdhK3Ircg4hM",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "E"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/a1e45493-2158-4f11-88ca-ba621429dbe5",
"url": "https://gleasonator.com/notice/9zIH9GTCDWEFSRt2um",
"visibility": "direct"
},
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>F</p>",
"created_at": "2020-09-18T20:07:42.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH9fhaP9atiJoOJc",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH8WYwtnUx4yDzUm",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "F"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/ee661cf9-35d4-4e84-88ff-13b5950f7556",
"url": "https://gleasonator.com/notice/9zIH9fhaP9atiJoOJc",
"visibility": "direct"
}
]
}

@ -1,739 +0,0 @@
{
"ancestors": [
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>A</p>",
"created_at": "2020-09-18T20:07:10.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH6kDXA10YqhMKqO",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "A"
},
"conversation_id": 5089485,
"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://gleasonator.com/objects/9995c074-2ff6-4a01-b596-7ef6971ed5d2",
"url": "https://gleasonator.com/notice/9zIH6kDXA10YqhMKqO",
"visibility": "direct"
}
],
"descendants": [
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>C</p>",
"created_at": "2020-09-18T20:07:22.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH7mMGgc1RmJwDLM",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH6kDXA10YqhMKqO",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "C"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/a2c25ef5-a40e-4098-b07e-b468989ef749",
"url": "https://gleasonator.com/notice/9zIH7mMGgc1RmJwDLM",
"visibility": "direct"
},
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>D</p>",
"created_at": "2020-09-18T20:07:30.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH8WYwtnUx4yDzUm",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH7PUdhK3Ircg4hM",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "D"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/bb423adc-ed86-42d8-942e-84efbe7b1acf",
"url": "https://gleasonator.com/notice/9zIH8WYwtnUx4yDzUm",
"visibility": "direct"
},
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>E</p>",
"created_at": "2020-09-18T20:07:38.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH9GTCDWEFSRt2um",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH7PUdhK3Ircg4hM",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "E"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/a1e45493-2158-4f11-88ca-ba621429dbe5",
"url": "https://gleasonator.com/notice/9zIH9GTCDWEFSRt2um",
"visibility": "direct"
},
{
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"avatar_static": "https://media.gleasonator.com/26f0ca4ef51f7047829fdb65a43cb7d0304413ce0a5d00dd1638458994608718.jpg",
"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": "Pleroma+Soapbox",
"value": "<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"follow_requests_count": 0,
"followers_count": 725,
"following_count": 1211,
"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",
"locked": false,
"note": "Fediverse developer. I come in peace.<br/><br/><a class=\"hashtag\" data-tag=\"vegan\" href=\"https://gleasonator.com/tag/vegan\">#vegan</a> <a class=\"hashtag\" data-tag=\"freeculture\" href=\"https://gleasonator.com/tag/freeculture\">#freeculture</a> <a class=\"hashtag\" data-tag=\"atheist\" href=\"https://gleasonator.com/tag/atheist\">#atheist</a> <a class=\"hashtag\" data-tag=\"antiporn\" href=\"https://gleasonator.com/tag/antiporn\">#antiporn</a> <a class=\"hashtag\" data-tag=\"gendercritical\" href=\"https://gleasonator.com/tag/gendercritical\">#gendercritical</a>.<br/><br/>Boosts ≠ endorsements.",
"pleroma": {
"accepts_chat_messages": true,
"allow_following_move": true,
"ap_id": "https://gleasonator.com/users/alex",
"background_image": null,
"confirmation_pending": false,
"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": true,
"is_moderator": false,
"notification_settings": {
"block_from_strangers": false,
"hide_notification_contents": false
},
"relationship": {},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 95,
"unread_notifications_count": 0
},
"source": {
"fields": [
{
"name": "Website",
"value": "https://alexgleason.me"
},
{
"name": "Pleroma+Soapbox",
"value": "https://soapbox.pub"
},
{
"name": "Email",
"value": "alex@alexgleason.me"
},
{
"name": "Gender identity",
"value": "Soyboy"
}
],
"note": "Fediverse developer. I come in peace.\r\n\r\n#vegan #freeculture #atheist #antiporn #gendercritical.\r\n\r\nBoosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 9157,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "<p>F</p>",
"created_at": "2020-09-18T20:07:42.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 0,
"id": "9zIH9fhaP9atiJoOJc",
"in_reply_to_account_id": "9v5bmRalQvjOy0ECcC",
"in_reply_to_id": "9zIH8WYwtnUx4yDzUm",
"language": null,
"media_attachments": [],
"mentions": [
{
"acct": "alex",
"id": "9v5bmRalQvjOy0ECcC",
"url": "https://gleasonator.com/users/alex",
"username": "alex"
}
],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text/plain": "F"
},
"conversation_id": 5089485,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": "alex",
"local": true,
"parent_visible": true,
"spoiler_text": {
"text/plain": ""
},
"thread_muted": false
},
"poll": null,
"reblog": null,
"reblogged": false,
"reblogs_count": 0,
"replies_count": 0,
"sensitive": false,
"spoiler_text": "",
"tags": [],
"text": null,
"uri": "https://gleasonator.com/objects/ee661cf9-35d4-4e84-88ff-13b5950f7556",
"url": "https://gleasonator.com/notice/9zIH9fhaP9atiJoOJc",
"visibility": "direct"
}
]
}

@ -98,7 +98,7 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
}
};
export function setComposeToStatus(status, rawText, spoilerText, contentType) {
export function setComposeToStatus(status, rawText, spoilerText, contentType, withRedraft) {
return (dispatch, getState) => {
const { instance } = getState();
const { explicitAddressing } = getFeatures(instance);
@ -111,6 +111,7 @@ export function setComposeToStatus(status, rawText, spoilerText, contentType) {
spoilerText,
contentType,
v: parseVersion(instance.version),
withRedraft,
});
};
}

@ -6,6 +6,9 @@ export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL';
export function fetchCustomEmojis() {
return (dispatch, getState) => {
const me = getState().get('me');
if (!me) return;
dispatch(fetchCustomEmojisRequest());
api(getState).get('/api/v1/custom_emojis').then(response => {

@ -1,30 +0,0 @@
import api from '../api';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
dispatch(fetchAccountIdentityProofsRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
.then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
};
export const fetchAccountIdentityProofsRequest = id => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
id,
});
export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
accountId,
identity_proofs,
});
export const fetchAccountIdentityProofsFail = (accountId, error) => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
accountId,
error,
});

@ -149,7 +149,6 @@ export function favourite(status) {
dispatch(favouriteRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(favouriteSuccess(status));
}).catch(function(error) {
dispatch(favouriteFail(status, error));

@ -86,7 +86,7 @@ export function changePassword(oldPassword, newPassword, confirmation) {
export function resetPassword(usernameOrEmail) {
return (dispatch, getState) => {
const state = getState();
const v = parseVersion(state.instance);
const v = parseVersion(state.instance.version);
dispatch({ type: RESET_PASSWORD_REQUEST });

@ -64,8 +64,9 @@ export function fetchSoapboxConfig(host) {
return dispatch(fetchFrontendConfigurations()).then(data => {
if (data.soapbox_fe) {
dispatch(importSoapboxConfig(data.soapbox_fe, host));
return data.soapbox_fe;
} else {
dispatch(fetchSoapboxJson(host));
return dispatch(fetchSoapboxJson(host));
}
});
} else {
@ -79,7 +80,7 @@ export function loadSoapboxConfig() {
return (dispatch, getState) => {
const host = getHost(getState());
return dispatch(rememberSoapboxConfig(host)).finally(() => {
return dispatch(rememberSoapboxConfig(host)).then(() => {
return dispatch(fetchSoapboxConfig(host));
});
};
@ -87,9 +88,10 @@ export function loadSoapboxConfig() {
export function fetchSoapboxJson(host) {
return (dispatch, getState) => {
staticClient.get('/instance/soapbox.json').then(({ data }) => {
return staticClient.get('/instance/soapbox.json').then(({ data }) => {
if (!isObject(data)) throw 'soapbox.json failed';
dispatch(importSoapboxConfig(data, host));
return data;
}).catch(error => {
dispatch(soapboxConfigFail(error, host));
});

@ -98,7 +98,7 @@ export const editStatus = (id) => (dispatch, getState) => {
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text));
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, false));
dispatch(openModal('COMPOSE'));
}).catch(error => {
dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error });
@ -139,7 +139,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
dispatch(deleteFromTimelines(id));
if (withRedraft) {
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type));
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type, withRedraft));
dispatch(openModal('COMPOSE'));
}
}).catch(error => {
@ -179,10 +179,18 @@ export function fetchContext(id) {
};
}
export function fetchNext(next) {
export function fetchNext(statusId, next) {
return async(dispatch, getState) => {
const response = await api(getState).get(next);
dispatch(importFetchedStatuses(response.data));
dispatch({
type: CONTEXT_FETCH_SUCCESS,
id: statusId,
ancestors: [],
descendants: response.data,
});
return { next: getNextLink(response) };
};
}
@ -208,11 +216,19 @@ export function fetchStatusWithContext(id) {
const features = getFeatures(getState().instance);
if (features.paginatedContext) {
await dispatch(fetchStatus(id));
const responses = await Promise.all([
dispatch(fetchAncestors(id)),
dispatch(fetchDescendants(id)),
dispatch(fetchStatus(id)),
]);
dispatch({
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors: responses[0].data,
descendants: responses[1].data,
});
const next = getNextLink(responses[1]);
return { next };
} else {

@ -8,13 +8,13 @@ import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST';
export const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS';
export const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
export const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
export function fetchSuggestionsV1(params = {}) {
return (dispatch, getState) => {
@ -48,23 +48,26 @@ export function fetchSuggestionsV2(params = {}) {
export function fetchSuggestions(params = { limit: 50 }) {
return (dispatch, getState) => {
const state = getState();
const me = state.get('me');
const instance = state.get('instance');
const features = getFeatures(instance);
if (!me) return;
if (features.suggestionsV2) {
dispatch(fetchSuggestionsV2(params))
.then(suggestions => {
const accountIds = suggestions.map(({ account }) => account.id);
dispatch(fetchRelationships(accountIds));
})
.catch(() => {});
.catch(() => { });
} else if (features.suggestions) {
dispatch(fetchSuggestionsV1(params))
.then(accounts => {
const accountIds = accounts.map(({ id }) => id);
dispatch(fetchRelationships(accountIds));
})
.catch(() => {});
.catch(() => { });
} else {
// Do nothing
}

@ -323,6 +323,20 @@ function requestPhoneVerification(phone) {
};
}
/**
* Send the user's phone number to Pepe to re-request confirmation
* @param {string} phone
* @returns {promise}
*/
function reRequestPhoneVerification(phone) {
return (dispatch, getState) => {
dispatch({ type: SET_LOADING });
return api(getState).post('/api/v1/pepe/reverify_sms/request', { phone })
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
}
/**
* Confirm the user's phone number with Pepe
* @param {string} code
@ -345,6 +359,20 @@ function confirmPhoneVerification(code) {
};
}
/**
* Re-Confirm the user's phone number with Pepe
* @param {string} code
* @returns {promise}
*/
function reConfirmPhoneVerification(code) {
return (dispatch, getState) => {
dispatch({ type: SET_LOADING });
return api(getState).post('/api/v1/pepe/reverify_sms/confirm', { code })
.finally(() => dispatch({ type: SET_LOADING, value: false }));
};
}
/**
* Confirm the user's age with Pepe
* @param {date} birthday
@ -404,6 +432,8 @@ export {
requestEmailVerification,
checkEmailVerification,
postEmailVerification,
reConfirmPhoneVerification,
requestPhoneVerification,
reRequestPhoneVerification,
verifyAge,
};

@ -0,0 +1,67 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import { normalizeAccount } from '../../normalizers';
import Account from '../account';
describe('<Account />', () => {
it('renders account name and username', () => {
const account = normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
});
const store = {
accounts: ImmutableMap({
'1': account,
}),
};
render(<Account account={account} />, null, store);
expect(screen.getByTestId('account')).toHaveTextContent('Justin L');
expect(screen.getByTestId('account')).toHaveTextContent(/justin-username/i);
});
describe('verification badge', () => {
it('renders verification badge', () => {
const account = normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: true,
});
const store = {
accounts: ImmutableMap({
'1': account,
}),
};
render(<Account account={account} />, null, store);
expect(screen.getByTestId('verified-badge')).toBeInTheDocument();
});
it('does not render verification badge', () => {
const account = normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
verified: false,
});
const store = {
accounts: ImmutableMap({
'1': account,
}),
};
render(<Account account={account} />, null, store);
expect(screen.queryAllByTestId('verified-badge')).toHaveLength(0);
});
});
});

@ -1,12 +0,0 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import ColumnBackButton from '../column_back_button';
describe('<ColumnBackButton />', () => {
it('renders correctly', () => {
render(<ColumnBackButton />);
expect(screen.getByRole('button')).toHaveTextContent('Back');
});
});

@ -3,7 +3,7 @@ import React from 'react';
import { normalizeAccount } from 'soapbox/normalizers';
import { render, screen } from '../../jest/test-helpers';
import DisplayName from '../display_name';
import DisplayName from '../display-name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {

@ -0,0 +1,44 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import type { List as ImmutableList } from 'immutable';
interface IAttachmentThumbs {
media: ImmutableList<Immutable.Record<any>>
onClick?(): void
sensitive?: boolean
}
const AttachmentThumbs = (props: IAttachmentThumbs) => {
const { media, onClick, sensitive } = props;
const dispatch = useDispatch();
const renderLoading = () => <div className='media-gallery--compact' />;
const onOpenMedia = (media: Immutable.Record<any>, index: number) => dispatch(openModal('MEDIA', { media, index }));
return (
<div className='attachment-thumbs'>
<Bundle fetchComponent={MediaGallery} loading={renderLoading}>
{(Component: any) => (
<Component
media={media}
onOpenMedia={onOpenMedia}
height={50}
compact
sensitive={sensitive}
/>
)}
</Bundle>
{onClick && (
<div className='attachment-thumbs__clickable-region' onClick={onClick} />
)}
</div>
);
};
export default AttachmentThumbs;

@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
export default @connect()
class AttachmentThumbs extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
media: ImmutablePropTypes.list.isRequired,
onClick: PropTypes.func,
sensitive: PropTypes.bool,
};
renderLoading() {
return <div className='media-gallery--compact' />;
}
onOpenMedia = (media, index) => {
this.props.dispatch(openModal('MEDIA', { media, index }));
}
render() {
const { media, onClick, sensitive } = this.props;
return (
<div className='attachment-thumbs'>
<Bundle fetchComponent={MediaGallery} loading={this.renderLoading}>
{Component => (
<Component
media={media}
onOpenMedia={this.onOpenMedia}
height={50}
compact
sensitive={sensitive}
/>
)}
</Bundle>
{onClick && (
<div className='attachment-thumbs__clickable-region' onClick={onClick} />
)}
</div>
);
}
}

@ -14,7 +14,7 @@ import AutosuggestEmoji from './autosuggest_emoji';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
const left = str.slice(0, caretPosition).search(/\S+$/);
const left = str.slice(0, caretPosition).search(/\S+$/);
const right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
@ -69,7 +69,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
@ -123,7 +123,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
break;
case 'Enter':
case 'Tab':
// Select suggestion
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
@ -200,13 +200,13 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccount id={suggestion} />;
key = suggestion;
key = suggestion;
}
return (
@ -257,7 +257,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<Textarea
ref={this.setTextarea}
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', {
className={classNames('transition-[min-height] motion-reduce:transition-none dark:bg-slate-800 px-0 mt-1 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,
})}

@ -1,41 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
export default @withRouter
class ColumnBackButton extends React.PureComponent {
static propTypes = {
to: PropTypes.string,
history: PropTypes.object,
};
handleClick = () => {
const { to } = this.props;
if (window.history?.length === 1) {
this.props.history.push(to ? to : '/');
} else {
this.props.history.goBack();
}
}
handleKeyUp = (e) => {
if (e.key === 'Enter') {
this.handleClick();
}
}
render() {
return (
<button onClick={this.handleClick} onKeyUp={this.handleKeyUp} className='column-back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);
}
}

@ -0,0 +1,51 @@
import * as React from 'react';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import { useSoapboxConfig } from 'soapbox/hooks';
import { getAcct } from '../utils/accounts';
import Icon from './icon';
import RelativeTimestamp from './relative_timestamp';
import VerificationBadge from './verification_badge';
import type { Account } from 'soapbox/types/entities';
interface IDisplayName {
account: Account
withDate?: boolean
}
const DisplayName: React.FC<IDisplayName> = ({ account, children, withDate = false }) => {
const { displayFqn = false } = useSoapboxConfig();
const { created_at: createdAt, verified } = account;
const joinedAt = createdAt ? (
<div className='account__joined-at'>
<Icon src={require('@tabler/icons/icons/clock.svg')} />
<RelativeTimestamp timestamp={createdAt} />
</div>
) : null;
const displayName = (
<span className='display-name__name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
{verified && <VerificationBadge />}
{withDate && joinedAt}
</span>
);
const suffix = (<span className='display-name__account'>@{getAcct(account, displayFqn)}</span>);
return (
<span className='display-name' data-testid='display-name'>
<HoverRefWrapper accountId={account.get('id')} inline>
{displayName}
</HoverRefWrapper>
{suffix}
{children}
</span>
);
};
export default DisplayName;

@ -1,85 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
import { displayFqn } from 'soapbox/utils/state';
import { getAcct } from '../utils/accounts';
import Icon from './icon';
import RelativeTimestamp from './relative_timestamp';
import VerificationBadge from './verification_badge';
const mapStateToProps = state => {
return {
displayFqn: displayFqn(state),
};
};
export default @connect(mapStateToProps)
class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
displayFqn: PropTypes.bool,
others: ImmutablePropTypes.list,
children: PropTypes.node,
withDate: PropTypes.bool,
};
static defaultProps = {
withDate: false,
}
render() {
const { account, displayFqn, others, children, withDate } = this.props;
let displayName, suffix;
const verified = account.get('verified');
const createdAt = account.get('created_at');
const joinedAt = createdAt ? (
<div className='account__joined-at'>
<Icon src={require('@tabler/icons/icons/clock.svg')} />
<RelativeTimestamp timestamp={createdAt} />
</div>
) : null;
if (others?.size > 1) {
displayName = others.take(2).map(a => (
<span className='display-name__name' key={a.get('id')}>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>
{verified && <VerificationBadge />}
{withDate && joinedAt}
</span>
)).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else {
displayName = (
<span className='display-name__name'>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
{verified && <VerificationBadge />}
{withDate && joinedAt}
</span>
);
suffix = <span className='display-name__account'>@{getAcct(account, displayFqn)}</span>;
}
return (
<span className='display-name' data-testid='display-name'>
<HoverRefWrapper accountId={account.get('id')} inline>
{displayName}
</HoverRefWrapper>
{suffix}
{children}
</span>
);
}
}

@ -42,7 +42,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
</Stack>
{hashtag.get('history') && (
<div className='w-[40px]'>
<div className='w-[40px]' data-testid='sparklines'>
<Sparklines
width={40}
height={28}

@ -1,5 +1,5 @@
import React from 'react';
import { Virtuoso, Components } from 'react-virtuoso';
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { useSettings } from 'soapbox/hooks';
@ -25,7 +25,7 @@ const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
return <div ref={ref} className={context?.listClassName} {...rest} />;
});
interface IScrollableList {
interface IScrollableList extends VirtuosoProps<any, any> {
scrollKey?: string,
onLoadMore?: () => void,
isLoading?: boolean,
@ -45,7 +45,7 @@ interface IScrollableList {
}
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
const ScrollableList: React.FC<IScrollableList> = ({
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
prepend = null,
alwaysPrepend,
children,
@ -61,7 +61,9 @@ const ScrollableList: React.FC<IScrollableList> = ({
hasMore,
placeholderComponent: Placeholder,
placeholderCount = 0,
}) => {
initialTopMostItemIndex = 0,
scrollerRef,
}, ref) => {
const settings = useSettings();
const autoloadMore = settings.get('autoloadMore');
@ -126,6 +128,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
/** Render the actual Virtuoso list */
const renderFeed = (): JSX.Element => (
<Virtuoso
ref={ref}
useWindowScroll
className={className}
data={data}
@ -133,6 +136,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
endReached={handleEndReached}
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
itemContent={renderItem}
initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex}
context={{
listClassName: className,
itemClassName,
@ -145,6 +149,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
Item,
Footer: loadMore,
}}
scrollerRef={scrollerRef}
/>
);
@ -162,6 +167,6 @@ const ScrollableList: React.FC<IScrollableList> = ({
{renderBody()}
</PullToRefresh>
);
};
});
export default ScrollableList;

@ -2,20 +2,25 @@ import classNames from 'classnames';
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Icon, Text, Counter } from './ui';
import { Icon, Text } from './ui';
interface ISidebarNavigationLink {
/** Notification count, if any. */
count?: number,
/** URL to an SVG icon. */
icon: string,
text: string | React.ReactElement,
/** Link label. */
text: React.ReactElement,
/** Route to an internal page. */
to?: string,
/** Callback when the link is clicked. */
onClick?: React.EventHandler<React.MouseEvent>,
}
/** Desktop sidebar navigation link. */
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) {
@ -32,29 +37,18 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
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,
'text-gray-900 dark:text-white': isActive,
'flex items-center p-3 text-sm font-semibold space-x-4 rounded-full group hover:bg-primary-200/80 dark:hover:bg-primary-900/60 hover:text-primary-600 dark:hover:text-gray-200': true,
'text-gray-500 dark:text-gray-400': !isActive,
'text-primary-600 dark:text-white': isActive,
})}
>
<span className={classNames({
'relative rounded-lg inline-flex p-3': true,
'bg-primary-50 dark:bg-slate-700': !isActive,
'bg-primary-600': isActive,
})}
>
{withCounter && count > 0 ? (
<span className='absolute -top-2 -right-2'>
<Counter count={count} />
</span>
) : null}
<span className='relative'>
<Icon
src={icon}
count={count}
className={classNames({
'h-5 w-5': true,
'text-primary-700 dark:text-white': !isActive,
'text-white': isActive,
'h-6 w-6 dark:group-hover:text-primary-500': true,
'dark:text-primary-500': isActive,
})}
/>
</span>

@ -149,12 +149,6 @@ const SidebarNavigation = () => {
{account && (
<>
<SidebarNavigationLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/icons/user.svg')}
text={<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />}
/>
<SidebarNavigationLink
to='/notifications'
icon={require('@tabler/icons/icons/bell.svg')}
@ -162,6 +156,14 @@ const SidebarNavigation = () => {
text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />}
/>
{renderMessagesLink()}
<SidebarNavigationLink
to={`/@${account.acct}`}
icon={require('@tabler/icons/icons/user.svg')}
text={<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />}
/>
<SidebarNavigationLink
to='/settings'
icon={require('@tabler/icons/icons/settings.svg')}
@ -170,8 +172,6 @@ const SidebarNavigation = () => {
</>
)}
{account && renderMessagesLink()}
{menu.length > 0 && (
<DropdownMenu items={menu}>
<SidebarNavigationLink

@ -161,7 +161,9 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
<Stack>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
<HStack alignItems='center' justifyContent='between'>
<Text tag='span' size='sm' weight='medium'>Switch accounts</Text>
<Text tag='span' size='sm' weight='medium'>
<FormattedMessage id='profile_dropdown.switch_account' defaultMessage='Switch accounts' />
</Text>
<Icon
src={require('@tabler/icons/icons/chevron-down.svg')}

@ -1,9 +1,15 @@
import classNames from 'classnames';
import React from 'react';
import { useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks';
interface ISiteLogo extends React.ComponentProps<'img'> {
/** Extra class names for the <img> element. */
className?: string,
}
/** Display the most appropriate site logo based on the theme and configuration. */
const SiteLogo: React.FC<React.ComponentProps<'img'>> = (props) => {
const SiteLogo: React.FC<ISiteLogo> = ({ className, ...rest }) => {
const { logo, logoDarkMode } = useSoapboxConfig();
const settings = useSettings();
@ -28,7 +34,11 @@ const SiteLogo: React.FC<React.ComponentProps<'img'>> = (props) => {
return (
// eslint-disable-next-line jsx-a11y/alt-text
<img src={getSrc()} {...props} />
<img
className={classNames('object-contain', className)}
src={getSrc()}
{...rest}
/>
);
};

@ -14,7 +14,7 @@ import Card from '../features/status/components/card';
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import AttachmentThumbs from './attachment_thumbs';
import AttachmentThumbs from './attachment-thumbs';
import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import StatusReplyMentions from './status_reply_mentions';
@ -160,7 +160,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
// Compensate height changes
componentDidUpdate(_prevProps: IStatus, _prevState: IStatusState, snapshot?: ScrollPosition): void {
const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
if (doShowCard && !this.didShowCard) {
this.didShowCard = true;

@ -0,0 +1,17 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Text } from 'soapbox/components/ui';
/** Represents a deleted item. */
const Tombstone: React.FC = () => {
return (
<div className='my-4 p-9 flex items-center justify-center sm:rounded-xl bg-gray-100 border border-solid border-gray-200 dark:bg-slate-900 dark:border-slate-700'>
<Text>
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts is unavailable.' />
</Text>
</div>
);
};
export default Tombstone;

@ -26,7 +26,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 dark:text-primary-400 hover:bg-gray-100 hover:text-primary-700 dark:hover:bg-slate-900/50',
link: 'border-transparent text-primary-600 dark:text-primary-400 hover:bg-primary-100 hover:text-primary-700 dark:hover:bg-slate-900/50',
};
const sizes = {

@ -0,0 +1,16 @@
import React, { forwardRef } from 'react';
interface IFileInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'required' | 'disabled' | 'name' | 'accept'> { }
const FileInput = forwardRef<HTMLInputElement, IFileInput>((props, ref) => {
return (
<input
{...props}
ref={ref}
type='file'
className='block w-full text-sm text-gray-800 dark:text-slate-200 file:cursor-pointer file:mr-2 file:py-1.5 file:px-3 file:rounded-full file:text-xs file:leading-4 file:font-medium file:border-gray-200 file:border file:border-solid file:bg-white file:text-gray-700 hover:file:bg-gray-100 dark:file:border-slate-700 dark:file:bg-slate-800 dark:file:text-slate-200'
/>
);
});
export default FileInput;

@ -6,6 +6,7 @@ export { default as Column } from './column/column';
export { default as Counter } from './counter/counter';
export { default as Emoji } from './emoji/emoji';
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
export { default as FileInput } from './file-input/file-input';
export { default as Form } from './form/form';
export { default as FormActions } from './form-actions/form-actions';
export { default as FormGroup } from './form-group/form-group';

@ -10,13 +10,30 @@ const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
const widths = {
xs: 'max-w-xs',
sm: 'max-w-sm',
md: 'max-w-base',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
};
interface IModal {
/** Callback when the modal is cancelled. */
cancelAction?: () => void,
/** Cancel button text. */
cancelText?: string,
/** URL to an SVG icon for the close button. */
closeIcon?: string,
/** Position of the close button. */
closePosition?: 'left' | 'right',
/** Callback when the modal is confirmed. */
confirmationAction?: () => void,
confirmationAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void,
/** Whether the confirmation button is disabled. */
confirmationDisabled?: boolean,
/** Confirmation button text. */
@ -26,13 +43,15 @@ interface IModal {
/** Callback when the modal is closed. */
onClose?: () => void,
/** Callback when the secondary action is chosen. */
secondaryAction?: () => void,
secondaryAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void,
/** Secondary button text. */
secondaryText?: React.ReactNode,
secondaryDisabled?: boolean,
/** Don't focus the "confirm" button on mount. */
skipFocus?: boolean,
/** Title text for the modal. */
title: string | React.ReactNode,
width?: Widths,
}
/** Displays a modal dialog box. */
@ -40,15 +59,19 @@ const Modal: React.FC<IModal> = ({
cancelAction,
cancelText,
children,
closeIcon = require('@tabler/icons/icons/x.svg'),
closePosition = 'right',
confirmationAction,
confirmationDisabled,
confirmationText,
confirmationTheme,
onClose,
secondaryAction,
secondaryDisabled = false,
secondaryText,
skipFocus = false,
title,
width = 'xl',
}) => {
const intl = useIntl();
const buttonRef = React.useRef<HTMLButtonElement>(null);
@ -60,17 +83,21 @@ const Modal: React.FC<IModal> = ({
}, [skipFocus, buttonRef]);
return (
<div data-testid='modal' className='block w-full max-w-xl p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto'>
<div data-testid='modal' className={classNames('block w-full p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto', widths[width])}>
<div className='sm:flex sm:items-start w-full justify-between'>
<div className='w-full'>
<div className='w-full flex flex-row justify-between items-center'>
<h3 className='text-lg leading-6 font-medium text-gray-900 dark:text-white'>
<div
className={classNames('w-full flex items-center gap-2', {
'flex-row-reverse': closePosition === 'left',
})}
>
<h3 className='flex-grow text-lg leading-6 font-medium text-gray-900 dark:text-white'>
{title}
</h3>
{onClose && (
<IconButton
src={require('@tabler/icons/icons/x.svg')}
src={closeIcon}
title={intl.formatMessage(messages.close)}
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200'
@ -103,6 +130,7 @@ const Modal: React.FC<IModal> = ({
<Button
theme='secondary'
onClick={secondaryAction}
disabled={secondaryDisabled}
>
{secondaryText}
</Button>

@ -24,7 +24,7 @@ const VerificationBadge: React.FC<IVerificationBadge> = ({ className }) => {
const Element = icon.endsWith('.svg') ? Icon : 'img';
return (
<span className='verified-icon'>
<span className='verified-icon' data-testid='verified-badge'>
<Element className={classNames('w-4 text-accent-500', className)} src={icon} alt={intl.formatMessage(messages.verified)} />
</span>
);

@ -18,16 +18,19 @@ import AuthLayout from 'soapbox/features/auth_layout';
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
import PublicLayout from 'soapbox/features/public_layout';
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
import { ModalContainer } from 'soapbox/features/ui/util/async-components';
import WaitlistPage from 'soapbox/features/verification/waitlist_page';
import { createGlobals } from 'soapbox/globals';
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures, useSoapboxConfig, useSettings, useSystemTheme } from 'soapbox/hooks';
import MESSAGES from 'soapbox/locales/messages';
import { useCachedLocationHandler } from 'soapbox/utils/redirect';
import { generateThemeCss } from 'soapbox/utils/theme';
import { checkOnboardingStatus } from '../actions/onboarding';
import { preload } from '../actions/preload';
import ErrorBoundary from '../components/error_boundary';
import UI from '../features/ui';
import BundleContainer from '../features/ui/containers/bundle_container';
import { store } from '../store';
/** Ensure the given locale exists in our codebase */
@ -64,6 +67,7 @@ const loadInitial = () => {
};
const SoapboxMount = () => {
useCachedLocationHandler();
const dispatch = useAppDispatch();
const me = useAppSelector(state => state.me);
@ -94,7 +98,7 @@ const SoapboxMount = () => {
MESSAGES[locale]().then(messages => {
setMessages(messages);
setLocaleLoading(false);
}).catch(() => {});
}).catch(() => { });
}, [locale]);
// Load initial data from the API
@ -170,7 +174,13 @@ const SoapboxMount = () => {
)}
{waitlisted && (
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<>
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
</>
)}
{!me && (singleUserMode

@ -86,7 +86,6 @@ class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record,
meaccount: ImmutablePropTypes.record,
identity_props: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
username: PropTypes.string,
features: PropTypes.object,
@ -608,8 +607,8 @@ class Header extends ImmutablePureComponent {
</a>
</div>
<div className='mt-6 sm:flex-1 sm:min-w-0 sm:flex sm:items-center sm:justify-end sm:space-x-6 sm:pb-1'>
<div className='mt-10 flex justify-stretch flex-row space-y-0 space-x-2'>
<div className='mt-6 flex justify-end w-full sm:pb-1'>
<div className='mt-10 flex flex-row space-y-0 space-x-2'>
{me && (
<Menu>
<MenuButton

@ -13,7 +13,6 @@ class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record,
identity_proofs: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
@ -143,7 +142,7 @@ class Header extends ImmutablePureComponent {
}
render() {
const { account, identity_proofs } = this.props;
const { account } = this.props;
const moved = (account) ? account.get('moved') : false;
return (
@ -152,7 +151,6 @@ class Header extends ImmutablePureComponent {
<InnerHeader
account={account}
identity_proofs={identity_proofs}
onFollow={this.handleFollow}
onBlock={this.handleBlock}
onMention={this.handleMention}

@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
import AvatarOverlay from 'soapbox/components/avatar_overlay';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import type { Account as AccountEntity } from 'soapbox/types/entities';

@ -1,4 +1,3 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
@ -64,7 +63,6 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()),
});
return mapStateToProps;

@ -13,7 +13,6 @@ import { makeGetStatusIds, findAccountByUsername } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';
import { fetchAccount, fetchAccountByUsername } from '../../actions/accounts';
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import { fetchPatronAccount } from '../../actions/patron';
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
import StatusList from '../../components/status_list';
@ -84,11 +83,10 @@ class AccountTimeline extends ImmutablePureComponent {
};
componentDidMount() {
const { params: { username }, accountId, accountApId, withReplies, me, patronEnabled } = this.props;
const { params: { username }, accountId, accountApId, withReplies, patronEnabled } = this.props;
if (accountId && accountId !== -1) {
this.props.dispatch(fetchAccount(accountId));
if (me) this.props.dispatch(fetchAccountIdentityProofs(accountId));
if (!withReplies) {
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
@ -105,11 +103,10 @@ class AccountTimeline extends ImmutablePureComponent {
}
componentDidUpdate(prevProps) {
const { params: { username }, me, accountId, withReplies, accountApId, patronEnabled } = this.props;
const { params: { username }, accountId, withReplies, accountApId, patronEnabled } = this.props;
if (accountId && (accountId !== -1) && (accountId !== prevProps.accountId) || withReplies !== prevProps.withReplies) {
this.props.dispatch(fetchAccount(accountId));
if (me) this.props.dispatch(fetchAccountIdentityProofs(accountId));
if (!withReplies) {
this.props.dispatch(expandAccountFeaturedTimeline(accountId));

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { addToAliases } from 'soapbox/actions/aliases';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button';
import { makeGetAccount } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features';

@ -1,13 +1,14 @@
import React from 'react';
import { Link, Redirect, Route, Switch } from 'react-router-dom';
import { defineMessages, useIntl } from 'react-intl';
import { Link, Redirect, Route, Switch, useHistory } from 'react-router-dom';
import LandingGradient from 'soapbox/components/landing-gradient';
import SiteLogo from 'soapbox/components/site-logo';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { NotificationsContainer } from 'soapbox/features/ui/util/async-components';
import { useAppSelector } from 'soapbox/hooks';
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
import { Card, CardBody } from '../../components/ui';
import { Button, Card, CardBody } from '../../components/ui';
import LoginPage from '../auth_login/components/login_page';
import PasswordReset from '../auth_login/components/password_reset';
import PasswordResetConfirm from '../auth_login/components/password_reset_confirm';
@ -17,22 +18,52 @@ import RegisterInvite from '../register_invite';
import Verification from '../verification';
import EmailPassthru from '../verification/email_passthru';
const messages = defineMessages({
register: { id: 'auth_layout.register', defaultMessage: 'Create an account' },
});
const AuthLayout = () => {
const intl = useIntl();
const history = useHistory();
const siteTitle = useAppSelector(state => state.instance.title);
const soapboxConfig = useSoapboxConfig();
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
const features = useFeatures();
const instance = useAppSelector((state) => state.instance);
const isOpen = features.accountCreation && instance.registrations;
const pepeOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true);
const isLoginPage = history.location.pathname === '/login';
const shouldShowRegisterLink = (isLoginPage && (isOpen || (pepeEnabled && pepeOpen)));
return (
<div className='h-full'>
<LandingGradient />
<main className='relative min-h-full sm:flex sm:items-center sm:justify-center py-12'>
<div className='w-full sm:max-w-lg md:max-w-2xl space-y-8'>
<header className='flex justify-center relative'>
<Link to='/' className='cursor-pointer'>
<SiteLogo alt={siteTitle} className='h-7' />
</Link>
<main className='relative min-h-full sm:flex sm:justify-center'>
<div className='w-full sm:max-w-lg md:max-w-2xl lg:max-w-6xl'>
<header className='flex justify-between relative py-12 px-2'>
<div className='relative z-0 flex-1 px-2 lg:flex lg:items-center lg:justify-center lg:absolute lg:inset-0'>
<Link to='/' className='cursor-pointer'>
<SiteLogo alt={siteTitle} className='h-7' />
</Link>
</div>
{shouldShowRegisterLink && (
<div className='relative z-10 ml-auto flex items-center'>
<Button
theme='link'
icon={require('@tabler/icons/icons/user.svg')}
to='/signup'
>
{intl.formatMessage(messages.register)}
</Button>
</div>
)}
</header>
<div className='flex flex-col justify-center items-center'>
<div className='flex flex-col h-full justify-center items-center'>
<div className='pb-10 sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
<Card variant='rounded' size='xl'>
<CardBody>

@ -7,6 +7,7 @@ import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { closeModal } from 'soapbox/actions/modals';
import { getRedirectUrl } from 'soapbox/utils/redirect';
import { isStandalone } from 'soapbox/utils/state';
import LoginForm from './login_form';
@ -78,7 +79,10 @@ class LoginPage extends ImmutablePureComponent {
if (standalone) return <Redirect to='/login/external' />;
if (shouldRedirect) return <Redirect to='/' />;
if (shouldRedirect) {
const redirectUri = getRedirectUrl();
return <Redirect to={redirectUri} />;
}
if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />;

@ -55,7 +55,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
const AuthTokenList: React.FC = () =>{
const dispatch = useAppDispatch();
const intl = useIntl();
const tokens = useAppSelector(state => state.security.get('tokens'));
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
useEffect(() => {
dispatch(fetchOAuthTokens());

@ -2,7 +2,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import Permalink from 'soapbox/components/permalink';
import { useAppSelector } from 'soapbox/hooks';

@ -77,7 +77,6 @@ class ChatRoom extends ImmutablePureComponent {
return (
<Column label={`@${getAcct(account, displayFqn)}`}>
{/* <div className='chatroom__back'>
<ColumnBackButton />
<Link to={`/@${account.get('acct')}`} className='chatroom__header'>
<Avatar account={account} size={18} />
<div className='chatroom__title'>

@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Icon from 'soapbox/components/icon';
import emojify from 'soapbox/features/emoji/emoji';
import { useAppSelector } from 'soapbox/hooks';

@ -3,7 +3,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
@ -28,7 +28,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
return null;
}
const style = {
const style = {
direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
};
@ -59,7 +59,6 @@ export default class ReplyIndicator extends ImmutablePureComponent {
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>

@ -6,7 +6,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Pullable from 'soapbox/components/pullable';
import ScrollableList from 'soapbox/components/scrollable_list';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
@ -151,24 +150,22 @@ class SearchResults extends ImmutablePureComponent {
{this.renderFilterBar()}
{noResultsMessage || (
<Pullable>
<ScrollableList
key={selectedFilter}
scrollKey={`${selectedFilter}:${value}`}
isLoading={submitted && !loaded}
showLoading={submitted && !loaded && results.isEmpty()}
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
placeholderComponent={placeholderComponent}
placeholderCount={20}
className={classNames({
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
'space-y-4': selectedFilter === 'accounts',
})}
>
{searchResults}
</ScrollableList>
</Pullable>
<ScrollableList
key={selectedFilter}
scrollKey={`${selectedFilter}:${value}`}
isLoading={submitted && !loaded}
showLoading={submitted && !loaded && results.isEmpty()}
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
placeholderComponent={placeholderComponent}
placeholderCount={20}
className={classNames({
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
})}
itemClassName={classNames({ 'pb-4': selectedFilter === 'accounts' })}
>
{searchResults}
</ScrollableList>
)}
</>
);

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { getSettings } from 'soapbox/actions/settings';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Permalink from 'soapbox/components/permalink';
import RelativeTimestamp from 'soapbox/components/relative_timestamp';
import { Text } from 'soapbox/components/ui';

@ -29,7 +29,7 @@ const ProfilePreview: React.FC<IProfilePreview> = ({ account }) => {
<StillImage alt='' className='h-12 w-12 rounded-full' src={account.avatar} />
</div>
{!account.verified && <div className='absolute -top-1.5 -right-1.5'><VerificationBadge /></div>}
{account.verified && <div className='absolute -top-1.5 -right-1.5'><VerificationBadge /></div>}
</div>
<Stack className='truncate'>

@ -9,7 +9,7 @@ import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soap
import { normalizeAccount } from 'soapbox/normalizers';
import resizeImage from 'soapbox/utils/resize_image';
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack, Toggle } from '../../components/ui';
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack, Toggle, FileInput } from '../../components/ui';
import Streamfield, { StreamfieldComponent } from '../../components/ui/streamfield/streamfield';
import ProfilePreview from './components/profile-preview';
@ -179,8 +179,8 @@ const EditProfile: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useOwnAccount();
const features = useFeatures();
const account = useOwnAccount();
const features = useFeatures();
const maxFields = useAppSelector(state => state.instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number);
const [isLoading, setLoading] = useState(false);
@ -378,14 +378,14 @@ const EditProfile: React.FC = () => {
labelText={<FormattedMessage id='edit_profile.fields.header_label' defaultMessage='Choose Background Picture' />}
hintText={<FormattedMessage id='edit_profile.hints.header' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '1920x1080px' }} />}
>
<input type='file' onChange={handleFileChange('header', 1920 * 1080)} className='text-sm' />
<FileInput onChange={handleFileChange('header', 1920 * 1080)} />
</FormGroup>
<FormGroup
labelText={<FormattedMessage id='edit_profile.fields.avatar_label' defaultMessage='Choose Profile Picture' />}
hintText={<FormattedMessage id='edit_profile.hints.avatar' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '400x400px' }} />}
>
<input type='file' onChange={handleFileChange('avatar', 400 * 400)} className='text-sm' />
<FileInput onChange={handleFileChange('avatar', 400 * 400)} />
</FormGroup>
</div>
</div>

@ -1,7 +1,7 @@
import React from 'react';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import Permalink from 'soapbox/components/permalink';
import ActionButton from 'soapbox/features/ui/components/action-button';
import { useAppSelector } from 'soapbox/hooks';

@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux';
import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button';
import Permalink from 'soapbox/components/permalink';
import { useAppSelector } from 'soapbox/hooks';

@ -5,7 +5,7 @@ import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import DisplayName from '../../../components/display-name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import DisplayName from '../../../components/display-name';
import IconButton from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';

@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button';
import Permalink from 'soapbox/components/permalink';

@ -3,6 +3,8 @@ import { HotKeys } from 'react-hotkeys';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { useAppSelector } from 'soapbox/hooks';
import Icon from '../../../components/icon';
import Permalink from '../../../components/permalink';
import { HStack, Text, Emoji } from '../../../components/ui';
@ -50,6 +52,7 @@ const icons: Record<NotificationType, string> = {
move: require('@tabler/icons/icons/briefcase.svg'),
'pleroma:chat_mention': require('@tabler/icons/icons/messages.svg'),
'pleroma:emoji_reaction': require('@tabler/icons/icons/mood-happy.svg'),
user_approved: require('@tabler/icons/icons/user-plus.svg'),
};
const messages: Record<NotificationType, { id: string, defaultMessage: string }> = {
@ -93,16 +96,20 @@ const messages: Record<NotificationType, { id: string, defaultMessage: string }>
id: 'notification.pleroma:emoji_reaction',
defaultMessage: '{name} reacted to your post',
},
user_approved: {
id: 'notification.user_approved',
defaultMessage: 'Welcome to {instance}!',
},
};
const buildMessage = (type: NotificationType, account: Account, targetName?: string): JSX.Element => {
const buildMessage = (type: NotificationType, account: Account, targetName: string, instanceTitle: string): JSX.Element => {
const link = buildLink(account);
return (
<FormattedMessageFixed
id={messages[type].id}
defaultMessage={messages[type].defaultMessage}
values={{ name: link, targetName }}
values={{ name: link, targetName, instance: instanceTitle }}
/>
);
};
@ -128,6 +135,7 @@ const Notification: React.FC<INotificaton> = (props) => {
const history = useHistory();
const intl = useIntl();
const instance = useAppSelector((state) => state.instance);
const type = notification.type;
const { account, status } = notification;
@ -216,6 +224,7 @@ const Notification: React.FC<INotificaton> = (props) => {
switch (type) {
case 'follow':
case 'follow_request':
case 'user_approved':
return account && typeof account === 'object' ? (
<AccountContainer
id={account.id}
@ -239,7 +248,7 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'pleroma:emoji_reaction':
return status && typeof status === 'object' ? (
<StatusContainer
// @ts-ignore
// @ts-ignore
id={status.id}
withDismiss
hidden={hidden}
@ -259,7 +268,7 @@ const Notification: React.FC<INotificaton> = (props) => {
const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName) : null;
const message: React.ReactNode = type && account && typeof account === 'object' ? buildMessage(type, account, targetName, instance.title) : null;
return (
<HotKeys handlers={getHandlers()} data-testid='notification'>

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import { fetchAccount } from 'soapbox/actions/accounts';
import { addToMentions, removeFromMentions } from 'soapbox/actions/compose';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button';
import { makeGetAccount } from 'soapbox/selectors';

@ -4,9 +4,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display_name';
import DisplayName from 'soapbox/components/display-name';
import RelativeTimestamp from 'soapbox/components/relative_timestamp';
import StatusContent from 'soapbox/components/status_content';
import StatusReplyMentions from 'soapbox/components/status_reply_mentions';
@ -74,7 +74,6 @@ class ScheduledStatus extends ImmutablePureComponent {
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>

@ -1,11 +1,11 @@
import * as React from 'react';
import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { fetchMfa } from 'soapbox/actions/mfa';
import List, { ListItem } from 'soapbox/components/list';
import { Button, Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui';
import { Card, CardBody, CardHeader, CardTitle, Column } from 'soapbox/components/ui';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
@ -22,6 +22,7 @@ const messages = defineMessages({
configureMfa: { id: 'settings.configure_mfa', defaultMessage: 'Configure MFA' },
sessions: { id: 'settings.sessions', defaultMessage: 'Active sessions' },
deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' },
other: { id: 'settings.other', defaultMessage: 'Other options' },
});
/** User settings page. */
@ -34,15 +35,16 @@ const Settings = () => {
const features = useAppSelector((state) => getFeatures(state.instance));
const account = useOwnAccount();
const navigateToChangeEmail = React.useCallback(() => history.push('/settings/email'), [history]);
const navigateToChangePassword = React.useCallback(() => history.push('/settings/password'), [history]);
const navigateToMfa = React.useCallback(() => history.push('/settings/mfa'), [history]);
const navigateToSessions = React.useCallback(() => history.push('/settings/tokens'), [history]);
const navigateToEditProfile = React.useCallback(() => history.push('/settings/profile'), [history]);
const navigateToChangeEmail = () => history.push('/settings/email');
const navigateToChangePassword = () => history.push('/settings/password');
const navigateToMfa = () => history.push('/settings/mfa');
const navigateToSessions = () => history.push('/settings/tokens');
const navigateToEditProfile = () => history.push('/settings/profile');
const navigateToDeleteAccount = () => history.push('/settings/account');
const isMfaEnabled = mfa.getIn(['settings', 'totp']);
React.useEffect(() => {
useEffect(() => {
dispatch(fetchMfa());
}, [dispatch]);
@ -92,12 +94,14 @@ const Settings = () => {
<Preferences />
</CardBody>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.other)} />
</CardHeader>
<CardBody>
<div className='mt-4 w-full flex justify-center'>
<Button theme='danger' to='/settings/account'>
{intl.formatMessage(messages.deleteAccount)}
</Button>
</div>
<List>
<ListItem label={intl.formatMessage(messages.deleteAccount)} onClick={navigateToDeleteAccount} />
</List>
</CardBody>
</Card>
</Column>

@ -14,6 +14,7 @@ import {
FormActions,
FormGroup,
Input,
FileInput,
Textarea,
Button,
Toggle,
@ -199,10 +200,8 @@ const SoapboxConfig: React.FC = () => {
labelText={<FormattedMessage id='soapbox_config.fields.logo_label' defaultMessage='Logo' />}
hintText={<FormattedMessage id='soapbox_config.hints.logo' defaultMessage='SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio' />}
>
<input
type='file'
<FileInput
onChange={handleFileChange(['logo'])}
className='text-sm'
accept='image/svg,image/png'
/>
</FormGroup>

@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage, IntlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
@ -143,7 +143,6 @@ class QuotedStatus extends ImmutablePureComponent<IQuotedStatus> {
{status.media_attachments.size > 0 && (
<AttachmentThumbs
compact
media={status.media_attachments}
sensitive={status.sensitive}
/>

@ -11,11 +11,12 @@ interface IThreadStatus {
focusedStatusId: string,
}
/** Status with reply-connector in threads. */
const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
const { id, focusedStatusId } = props;
const replyToId = useAppSelector(state => state.contexts.getIn(['inReplyTos', id]));
const replyCount = useAppSelector(state => state.contexts.getIn(['replies', id], ImmutableOrderedSet()).size);
const replyToId = useAppSelector(state => state.contexts.inReplyTos.get(id));
const replyCount = useAppSelector(state => state.contexts.replies.get(id, ImmutableOrderedSet()).size);
const isLoaded = useAppSelector(state => Boolean(state.statuses.get(id)));
const renderConnector = (): JSX.Element | null => {

@ -19,6 +19,7 @@ import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import ScrollableList from 'soapbox/components/scrollable_list';
import SubNavigation from 'soapbox/components/sub_navigation';
import Tombstone from 'soapbox/components/tombstone';
import { Column, Stack } from 'soapbox/components/ui';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import PendingStatus from 'soapbox/features/ui/components/pending_status';
@ -65,6 +66,7 @@ import ThreadStatus from './components/thread-status';
import type { AxiosError } from 'axios';
import type { History } from 'history';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { RootState } from 'soapbox/store';
@ -97,11 +99,11 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getAncestorsIds = createSelector([
(_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.get('inReplyTos'),
(_: RootState, statusId: string | undefined) => statusId,
(state: RootState) => state.contexts.inReplyTos,
], (statusId, inReplyTos) => {
let ancestorsIds = ImmutableOrderedSet();
let id = statusId;
let ancestorsIds = ImmutableOrderedSet<string>();
let id: string | undefined = statusId;
while (id && !ancestorsIds.includes(id)) {
ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds);
@ -113,13 +115,15 @@ const makeMapStateToProps = () => {
const getDescendantsIds = createSelector([
(_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.get('replies'),
(state: RootState) => state.contexts.replies,
], (statusId, contextReplies) => {
let descendantsIds = ImmutableOrderedSet();
const ids = [statusId];
while (ids.length > 0) {
const id = ids.shift();
const id = ids.shift();
if (!id) break;
const replies = contextReplies.get(id);
if (descendantsIds.includes(id)) {
@ -147,7 +151,7 @@ const makeMapStateToProps = () => {
if (status) {
const statusId = status.id;
ancestorsIds = getAncestorsIds(state, state.contexts.getIn(['inReplyTos', statusId]));
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
descendantsIds = getDescendantsIds(state, statusId);
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds);
descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds);
@ -210,6 +214,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
node: HTMLDivElement | null = null;
status: HTMLDivElement | null = null;
scroller: VirtuosoHandle | null = null;
_scrolledIntoView: boolean = false;
fetchData = async() => {
@ -556,8 +561,8 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
renderTombstone(id: string) {
return (
<div className='tombstone' key={id}>
<p><FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts is unavailable.' /></p>
<div className='pb-4'>
<Tombstone key={id} />
</div>
);
}
@ -615,11 +620,10 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
}
componentDidUpdate(prevProps: IStatus, prevState: IStatusState) {
const { params, status, displayMedia } = this.props;
const { ancestorsIds } = prevProps;
const { params, status, displayMedia, ancestorsIds } = this.props;
const { isLoaded } = this.state;
if (params.statusId !== prevProps.params.statusId) {
this._scrolledIntoView = false;
this.fetchData();
}
@ -627,17 +631,11 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
this.setState({ showMedia: defaultMediaVisibility(status, displayMedia), loadedStatusId: status.id });
}
if (this._scrolledIntoView) {
return;
}
if (prevProps.status && ancestorsIds && ancestorsIds.size > 0 && this.node) {
const element = this.node.querySelector('.detailed-status');
window.requestAnimationFrame(() => {
element?.scrollIntoView(true);
if (params.statusId !== prevProps.params.statusId || status?.id !== prevProps.status?.id || ancestorsIds.size > prevProps.ancestorsIds.size || isLoaded !== prevState.isLoaded) {
this.scroller?.scrollToIndex({
index: this.props.ancestorsIds.size,
offset: -80,
});
this._scrolledIntoView = true;
}
}
@ -654,10 +652,11 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
}
handleLoadMore = () => {
const { status } = this.props;
const { next } = this.state;
if (next) {
this.props.dispatch(fetchNext(next)).then(({ next }) => {
this.props.dispatch(fetchNext(status.id, next)).then(({ next }) => {
this.setState({ next });
}).catch(() => {});
}
@ -671,6 +670,10 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
}));
}
setScrollerRef = (c: VirtuosoHandle) => {
this.scroller = c;
}
render() {
const { me, status, ancestorsIds, descendantsIds, intl } = this.props;
@ -788,10 +791,12 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
<Stack space={2}>
<div ref={this.setRef} className='thread'>
<ScrollableList
ref={this.setScrollerRef}
onRefresh={this.handleRefresh}
hasMore={!!this.state.next}
onLoadMore={this.handleLoadMore}
placeholderComponent={() => <PlaceholderStatus thread />}
initialTopMostItemIndex={ancestorsIds.size}
>
{children}
</ScrollableList>

@ -10,13 +10,19 @@ describe('<TrendsPanel />', () => {
trends: ImmutableMap({
items: fromJS([{
name: 'hashtag 1',
history: [{ accounts: [] }],
history: [{
day: '1652745600',
uses: '294',
accounts: '180',
}],
}]),
}),
};
render(<TrendsPanel limit={1} />, null, store);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i);
expect(screen.getByTestId('sparklines')).toBeInTheDocument();
});
it('renders multiple trends', () => {

@ -30,13 +30,21 @@ const messages = defineMessages({
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
});
interface iActionButton {
interface IActionButton {
/** Target account for the action. */
account: AccountEntity
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
actionType?: 'muting' | 'blocking'
/** Displays shorter text on the "Awaiting approval" button. */
small?: boolean
}
const ActionButton = ({ account, actionType, small }: iActionButton) => {
/**
* Circumstantial action button (usually "Follow") to display on accounts.
* May say "Unblock" or something else, depending on the relationship and
* `actionType` prop.
*/
const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) => {
const dispatch = useDispatch();
const features = useFeatures();
const intl = useIntl();
@ -45,40 +53,41 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
const handleFollow = () => {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(unfollowAccount(account.get('id')));
dispatch(unfollowAccount(account.id));
} else {
dispatch(followAccount(account.get('id')));
dispatch(followAccount(account.id));
}
};
const handleBlock = () => {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
dispatch(unblockAccount(account.id));
} else {
dispatch(blockAccount(account.get('id')));
dispatch(blockAccount(account.id));
}
};
const handleMute = () => {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
dispatch(unmuteAccount(account.id));
} else {
dispatch(muteAccount(account.get('id')));
dispatch(muteAccount(account.id));
}
};
const handleRemoteFollow = () => {
dispatch(openModal('UNAUTHORIZED', {
action: 'FOLLOW',
account: account.get('id'),
ap_id: account.get('url'),
account: account.id,
ap_id: account.url,
}));
};
/** Handles actionType='muting' */
const mutingAction = () => {
const isMuted = account.getIn(['relationship', 'muting']);
const messageKey = isMuted ? messages.unmute : messages.mute;
const text = intl.formatMessage(messageKey, { name: account.get('username') });
const text = intl.formatMessage(messageKey, { name: account.username });
return (
<Button
@ -90,10 +99,11 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
);
};
/** Handles actionType='blocking' */
const blockingAction = () => {
const isBlocked = account.getIn(['relationship', 'blocking']);
const messageKey = isBlocked ? messages.unblock : messages.block;
const text = intl.formatMessage(messageKey, { name: account.get('username') });
const text = intl.formatMessage(messageKey, { name: account.username });
return (
<Button
@ -105,10 +115,9 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
);
};
const empty = <></>;
if (!me) {
// Remote follow
/** Render a remote follow button, depending on features. */
const renderRemoteFollow = () => {
// Remote follow through the API.
if (features.remoteInteractionsAPI) {
return (
<Button
@ -117,18 +126,34 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
text={intl.formatMessage(messages.follow)}
/>
);
// Pleroma's classic remote follow form.
} else if (features.pleromaRemoteFollow) {
return (
<form method='POST' action='/main/ostatus'>
<input type='hidden' name='nickname' value={account.acct} />
<input type='hidden' name='profile' value='' />
<Button text={intl.formatMessage(messages.remote_follow)} type='submit' />
</form>
);
}
return (
<form method='POST' action='/main/ostatus'>
<input type='hidden' name='nickname' value={account.get('acct')} />
<input type='hidden' name='profile' value='' />
<Button text={intl.formatMessage(messages.remote_follow)} type='submit' />
</form>
);
return null;
};
/** Render remote follow if federating, otherwise hide the button. */
const renderLoggedOut = () => {
if (features.federating) {
return renderRemoteFollow();
}
return null;
};
if (!me) {
return renderLoggedOut();
}
if (me !== account.get('id')) {
if (me !== account.id) {
const isFollowing = account.getIn(['relationship', 'following']);
const blockedBy = account.getIn(['relationship', 'blocked_by']) as boolean;
@ -140,9 +165,9 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
}
}
if (!account.get('relationship')) {
if (account.relationship.isEmpty()) {
// Wait until the relationship is loaded
return empty;
return null;
} else if (account.getIn(['relationship', 'requested'])) {
// Awaiting acceptance
return (
@ -176,7 +201,7 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
<Button
theme='danger'
size='sm'
text={intl.formatMessage(messages.unblock, { name: account.get('username') })}
text={intl.formatMessage(messages.unblock, { name: account.username })}
onClick={handleBlock}
/>
);
@ -193,7 +218,7 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
);
}
return empty;
return null;
};
export default ActionButton;

@ -4,7 +4,7 @@ import React, { useEffect } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { fetchHistory } from 'soapbox/actions/history';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { HStack, Modal, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -75,10 +75,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
)}
{version.media_attachments.size > 0 && (
<AttachmentThumbs
compact
media={version.media_attachments}
/>
<AttachmentThumbs media={version.media_attachments} />
)}
<Text align='right' tag='span' theme='muted' size='sm'>

@ -9,6 +9,18 @@ interface IHotkeysModal {
onClose: () => void,
}
const Hotkey: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<kbd className='px-1.5 py-1 bg-primary-50 dark:bg-slate-700 border border-solid border-primary-200 rounded-md dark:border-slate-500 text-xs font-sans'>
{children}
</kbd>
);
const TableCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<td className='pb-3 px-2'>
{children}
</td>
);
const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
const features = useAppSelector((state) => getFeatures(state.instance));
@ -16,142 +28,145 @@ const HotkeysModal: React.FC<IHotkeysModal> = ({ onClose }) => {
<Modal
title={<FormattedMessage id='keyboard_shortcuts.heading' defaultMessage='Keyboard shortcuts' />}
onClose={onClose}
width='4xl'
>
<div className='compose-modal__content'>
<div className='flex flex-col lg:flex-row text-xs'>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
<th className='pb-2 font-bold'><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td>
<TableCell><Hotkey>r</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></TableCell>
</tr>
<tr>
<td><kbd>m</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td>
<TableCell><Hotkey>m</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></TableCell>
</tr>
<tr>
<td><kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></td>
<TableCell><Hotkey>p</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></TableCell>
</tr>
<tr>
<td><kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to like' /></td>
<TableCell><Hotkey>f</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to like' /></TableCell>
</tr>
{features.emojiReacts && (
<tr>
<td><kbd>e</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.react' defaultMessage='to react' /></td>
<TableCell><Hotkey>e</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.react' defaultMessage='to react' /></TableCell>
</tr>
)}
<tr>
<td><kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to repost' /></td>
<TableCell><Hotkey>b</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to repost' /></TableCell>
</tr>
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></td>
<TableCell><Hotkey>enter</Hotkey>, <Hotkey>o</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></TableCell>
</tr>
<tr>
<td><kbd>a</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td>
<TableCell><Hotkey>a</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></TableCell>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
<th className='pb-2 font-bold'><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
{features.spoilers && (
<tr>
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
<TableCell><Hotkey>x</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></TableCell>
</tr>
)}
{features.spoilers && (
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
<TableCell><Hotkey>h</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></TableCell>
</tr>
)}
<tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
<TableCell><Hotkey>up</Hotkey>, <Hotkey>k</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></TableCell>
</tr>
<tr>
<td><kbd>down</kbd>, <kbd>j</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
<TableCell><Hotkey>down</Hotkey>, <Hotkey>j</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></TableCell>
</tr>
<tr>
<td><kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
<TableCell><Hotkey>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></TableCell>
</tr>
<tr>
<td><kbd>alt</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a new post' /></td>
<TableCell><Hotkey>alt</Hotkey> + <Hotkey>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a new post' /></TableCell>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
<TableCell><Hotkey>backspace</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></TableCell>
</tr>
<tr>
<td><kbd>s</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
<TableCell><Hotkey>s</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></TableCell>
</tr>
<tr>
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
<TableCell><Hotkey>esc</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></TableCell>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
<th className='pb-2 font-bold'><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
</tr>
</thead>
<tbody>
<tr>
<td><kbd>g</kbd> + <kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>h</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>n</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>f</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>u</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>p</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned posts list' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>u</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>m</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='to open muted users list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>b</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.blocked' defaultMessage='to open blocked users list' /></TableCell>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>r</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></td>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>m</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.muted' defaultMessage='to open muted users list' /></TableCell>
</tr>
{features.followRequests && (
<tr>
<TableCell><Hotkey>g</Hotkey> + <Hotkey>r</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.requests' defaultMessage='to open follow requests list' /></TableCell>
</tr>
)}
<tr>
<td><kbd>?</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td>
<TableCell><Hotkey>?</Hotkey></TableCell>
<TableCell><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></TableCell>
</tr>
</tbody>
</table>

@ -30,6 +30,7 @@ import {
BirthdaysModal,
AccountNoteModal,
CompareHistoryModal,
VerifySmsModal,
FamiliarFollowersModal,
} from 'soapbox/features/ui/util/async-components';
@ -67,6 +68,7 @@ const MODAL_COMPONENTS = {
'BIRTHDAYS': BirthdaysModal,
'ACCOUNT_NOTE': AccountNoteModal,
'COMPARE_HISTORY': CompareHistoryModal,
'VERIFY_SMS': VerifySmsModal,
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
};

@ -6,7 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { blockAccount } from 'soapbox/actions/accounts';
import { submitReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports';
import { expandAccountTimeline } from 'soapbox/actions/timelines';
import AttachmentThumbs from 'soapbox/components/attachment_thumbs';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import StatusContent from 'soapbox/components/status_content';
import { Modal, ProgressBar, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
@ -61,7 +61,6 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
{status.get('media_attachments').size > 0 && (
<AttachmentThumbs
compact
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>

@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { verifyCredentials } from 'soapbox/actions/auth';
import { closeModal } from 'soapbox/actions/modals';
import snackbar from 'soapbox/actions/snackbar';
import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification';
import { FormGroup, Input, Modal, Stack, Text } from 'soapbox/components/ui';
import { validPhoneNumberRegex } from 'soapbox/features/verification/steps/sms-verification';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { getAccessToken } from 'soapbox/utils/auth';
import { formatPhoneNumber } from 'soapbox/utils/phone';
interface IVerifySmsModal {
onClose: (type: string) => void,
}
enum Statuses {
IDLE = 'IDLE',
READY = 'READY',
REQUESTED = 'REQUESTED',
FAIL = 'FAIL',
SUCCESS = 'SUCCESS',
}
const VerifySmsModal: React.FC<IVerifySmsModal> = ({ onClose }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const accessToken = useAppSelector((state) => getAccessToken(state));
const title = useAppSelector((state) => state.instance.title);
const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean);
const [status, setStatus] = useState<Statuses>(Statuses.IDLE);
const [phone, setPhone] = useState<string>('');
const [verificationCode, setVerificationCode] = useState('');
const [requestedAnother, setAlreadyRequestedAnother] = useState(false);
const isValid = validPhoneNumberRegex.test(phone);
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const formattedPhone = formatPhoneNumber(event.target.value);
setPhone(formattedPhone);
}, []);
const handleSubmit = (event: React.MouseEvent) => {
event.preventDefault();
if (!isValid) {
setStatus(Statuses.IDLE);
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Please enter a valid phone number.',
}),
),
);
return;
}
dispatch(reRequestPhoneVerification(phone)).then(() => {
dispatch(
snackbar.success(
intl.formatMessage({
id: 'sms_verification.success',
defaultMessage: 'A verification code has been sent to your phone number.',
}),
),
);
})
.finally(() => setStatus(Statuses.REQUESTED))
.catch(() => {
dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.fail',
defaultMessage: 'Failed to send SMS message to your phone number.',
}),
),
);
});
};
const resendVerificationCode = (event?: React.MouseEvent<HTMLButtonElement>) => {
setAlreadyRequestedAnother(true);
handleSubmit(event as React.MouseEvent<HTMLButtonElement>);
};
const onConfirmationClick = (event: any) => {
switch (status) {
case Statuses.IDLE:
setStatus(Statuses.READY);
break;
case Statuses.READY:
handleSubmit(event);
break;
case Statuses.REQUESTED:
submitVerification();
break;
default: break;
}
};
const confirmationText = useMemo(() => {
switch (status) {
case Statuses.IDLE:
return intl.formatMessage({
id: 'sms_verification.modal.verify_sms',
defaultMessage: 'Verify SMS',
});
case Statuses.READY:
return intl.formatMessage({
id: 'sms_verification.modal.verify_number',
defaultMessage: 'Verify phone number',
});
case Statuses.REQUESTED:
return intl.formatMessage({
id: 'sms_verification.modal.verify_code',
defaultMessage: 'Verify code',
});
default:
return null;
}
}, [status]);
const renderModalBody = () => {
switch (status) {
case Statuses.IDLE:
return (
<Text theme='muted'>
{intl.formatMessage({
id: 'sms_verification.modal.verify_help_text',
defaultMessage: 'Verify your phone number to start using {instance}.',
}, {
instance: title,
})}
</Text>
);
case Statuses.READY:
return (
<FormGroup labelText='Phone Number'>
<Input
type='text'
value={phone}
onChange={onChange}
required
autoFocus
/>
</FormGroup>
);
case Statuses.REQUESTED:
return (
<>
<Text theme='muted' size='sm' align='center'>
{intl.formatMessage({
id: 'sms_verification.modal.enter_code',
defaultMessage: 'We sent you a 6-digit code via SMS. Enter it below.',
})}
</Text>
<OtpInput
value={verificationCode}
onChange={setVerificationCode}
numInputs={6}
isInputNum
shouldAutoFocus
isDisabled={isLoading}
containerStyle='flex justify-center mt-2 space-x-4'
inputStyle='w-10i border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
/>
</>
);
default:
return null;
}
};
const submitVerification = () => {
// TODO: handle proper validation from Pepe -- expired vs invalid
dispatch(reConfirmPhoneVerification(verificationCode))
.then(() => {
setStatus(Statuses.SUCCESS);
// eslint-disable-next-line promise/catch-or-return
dispatch(verifyCredentials(accessToken))
.then(() => dispatch(closeModal('VERIFY_SMS')));
})
.catch(() => dispatch(
snackbar.error(
intl.formatMessage({
id: 'sms_verification.invalid',
defaultMessage: 'Your SMS token has expired.',
}),
),
));
};
useEffect(() => {
if (verificationCode.length === 6) {
submitVerification();
}
}, [verificationCode]);
return (
<Modal
title={
intl.formatMessage({
id: 'sms_verification.modal.verify_title',
defaultMessage: 'Verify your phone number',
})
}
onClose={() => onClose('VERIFY_SMS')}
cancelAction={status === Statuses.IDLE ? () => onClose('VERIFY_SMS') : undefined}
cancelText='Skip for now'
confirmationAction={onConfirmationClick}
confirmationText={confirmationText}
secondaryAction={status === Statuses.REQUESTED ? resendVerificationCode : undefined}
secondaryText={status === Statuses.REQUESTED ? intl.formatMessage({
id: 'sms_verification.modal.resend_code',
defaultMessage: 'Resend verification code?',
}) : undefined}
secondaryDisabled={requestedAnother}
>
<Stack space={4}>
{renderModalBody()}
</Stack>
</Modal>
);
};
export default VerifySmsModal;

@ -28,7 +28,7 @@ const ProfileStats: React.FC<IProfileStats> = ({ account, onClickHandler }) => {
return (
<HStack alignItems='center' space={3}>
<NavLink to={`/@${account.acct}/followers`} onClick={onClickHandler} title={intl.formatNumber(account.followers_count)}>
<NavLink to={`/@${account.acct}/followers`} onClick={onClickHandler} title={intl.formatNumber(account.followers_count)} className='hover:underline'>
<HStack alignItems='center' space={1}>
<Text theme='primary' weight='bold' size='sm'>
{shortNumberFormat(account.followers_count)}
@ -39,7 +39,7 @@ const ProfileStats: React.FC<IProfileStats> = ({ account, onClickHandler }) => {
</HStack>
</NavLink>
<NavLink to={`/@${account.acct}/following`} onClick={onClickHandler} title={intl.formatNumber(account.following_count)}>
<NavLink to={`/@${account.acct}/following`} onClick={onClickHandler} title={intl.formatNumber(account.following_count)} className='hover:underline'>
<HStack alignItems='center' space={1}>
<Text theme='primary' weight='bold' size='sm'>
{shortNumberFormat(account.following_count)}

@ -29,6 +29,8 @@ const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
<Modal
title={<FormattedMessage id='navigation_bar.in_reply_to' defaultMessage='In reply to' />}
onClose={onClickClose}
closeIcon={require('@tabler/icons/icons/arrow-left.svg')}
closePosition='left'
>
<div className='reply-mentions-modal__accounts'>
{mentions.map(accountId => <Account key={accountId} accountId={accountId} added author={author === accountId} />)}

@ -86,19 +86,6 @@ class UserPanel extends ImmutablePureComponent {
</Stack>
<HStack alignItems='center' space={3}>
{account.get('statuses_count') >= 0 && (
<Link to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<HStack alignItems='center' space={1}>
<Text theme='primary' weight='bold' size='sm'>
{shortNumberFormat(account.get('statuses_count'))}
</Text>
<Text weight='bold' size='sm'>
<FormattedMessage className='user-panel-stats-item__label' id='account.posts' defaultMessage='Posts' />
</Text>
</HStack>
</Link>
)}
{account.get('followers_count') >= 0 && (
<Link to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<HStack alignItems='center' space={1}>

@ -37,6 +37,7 @@ import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
import StatusPage from 'soapbox/pages/status_page';
import { getAccessToken } from 'soapbox/utils/auth';
import { getVapidKey } from 'soapbox/utils/auth';
import { cacheCurrentUrl } from 'soapbox/utils/redirect';
import { isStandalone } from 'soapbox/utils/state';
// import GroupSidebarPanel from '../groups/sidebar_panel';
@ -278,7 +279,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
<WrappedRoute path='/@:username/following' publicRoute={!authenticatedProfile} component={Following} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/media' publicRoute={!authenticatedProfile} component={AccountGallery} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/tagged/:tag' exact component={AccountTimeline} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/favorites' component={FavouritedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/favorites' component={FavouritedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/pins' component={PinnedStatuses} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/posts/:statusId' publicRoute exact page={StatusPage} component={Status} content={children} />
<Redirect from='/@:username/:statusId' to='/@:username/posts/:statusId' />
@ -330,6 +331,7 @@ const UI: React.FC = ({ children }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useDispatch();
const { guestExperience } = useSoapboxConfig();
const [draggingOver, setDraggingOver] = useState<boolean>(false);
const [mobile, setMobile] = useState<boolean>(isMobile(window.innerWidth));
@ -479,7 +481,7 @@ const UI: React.FC = ({ children }) => {
document.addEventListener('drop', handleDrop, false);
document.addEventListener('dragleave', handleDragLeave, false);
if ('serviceWorker' in navigator) {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerPostMessage);
}
@ -608,6 +610,11 @@ const UI: React.FC = ({ children }) => {
// Wait for login to succeed or fail
if (me === null) return null;
if (!me && !guestExperience) {
cacheCurrentUrl(history.location);
return <Redirect to='/login' />;
}
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
const handlers: HotkeyHandlers = {

@ -403,7 +403,7 @@ export function ModalContainer() {
}
export function ProfileHoverCard() {
return import(/* webpackChunkName: "features/ui" */'soapbox/components/profile_hover_card');
return import(/* webpackChunkName: "features/ui" */'soapbox/components/profile-hover-card');
}
export function CryptoDonate() {
@ -502,6 +502,10 @@ export function AuthTokenList() {
return import(/* webpackChunkName: "features/auth_token_list" */'../../auth_token_list');
}
export function VerifySmsModal() {
return import(/* webpackChunkName: "features/ui" */'../components/modals/verify-sms-modal');
}
export function FamiliarFollowersModal() {
return import(/*webpackChunkName: "modals/familiar_followers_modal" */'../components/familiar_followers_modal');
}

@ -86,13 +86,14 @@ const WrappedRoute: React.FC<IWrappedRoute> = ({
</>
);
const renderLoading = () => renderWithLayout(<ColumnLoading />);
const renderLoading = () => renderWithLayout(<ColumnLoading />);
const renderForbidden = () => renderWithLayout(<ColumnForbidden />);
const renderError = (props: any) => renderWithLayout(<BundleColumnError {...props} />);
const renderError = (props: any) => renderWithLayout(<BundleColumnError {...props} />);
const loginRedirect = () => {
const actualUrl = encodeURIComponent(`${history.location.pathname}${history.location.search}`);
return <Redirect to={`/login?redirect_uri=${actualUrl}`} />;
localStorage.setItem('soapbox:redirect_uri', actualUrl);
return <Redirect to='/login' />;
};
const authorized = [

@ -11,6 +11,7 @@ import snackbar from 'soapbox/actions/snackbar';
import { createAccount } from 'soapbox/actions/verification';
import { removeStoredVerification } from 'soapbox/actions/verification';
import { useAppSelector } from 'soapbox/hooks';
import { getRedirectUrl } from 'soapbox/utils/redirect';
import { Button, Form, FormGroup, Input } from '../../components/ui';
@ -81,7 +82,8 @@ const Registration = () => {
}, []);
if (shouldRedirect) {
return <Redirect to='/' />;
const redirectUri = getRedirectUrl();
return <Redirect to={redirectUri} />;
}
return (

@ -49,7 +49,10 @@ const AgeVerification = () => {
snackbar.error(
intl.formatMessage({
id: 'age_verification.fail',
defaultMessage: `You must be ${ageMinimum} years old or older.`,
defaultMessage: 'You must be {ageMinimum, plural, one {# year} other {# years}} old or older.',
values: {
ageMinimum,
},
}),
),
);

@ -1,7 +1,7 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
import OtpInput from 'react-otp-input';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import snackbar from 'soapbox/actions/snackbar';
import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification';
@ -167,4 +167,4 @@ const SmsVerification = () => {
};
export default SmsVerification;
export { SmsVerification as default, validPhoneNumberRegex };

@ -1,13 +1,15 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import LandingGradient from 'soapbox/components/landing-gradient';
import SiteLogo from 'soapbox/components/site-logo';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { NotificationsContainer } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { logOut } from '../../actions/auth';
import { Button, Stack, Text } from '../../components/ui';
@ -15,12 +17,24 @@ import { Button, Stack, Text } from '../../components/ui';
const WaitlistPage = ({ account }) => {
const dispatch = useDispatch();
const intl = useIntl();
const title = useAppSelector((state) => state.instance.title);
const me = useOwnAccount();
const isSmsVerified = me.getIn(['source', 'sms_verified']);
const onClickLogOut = (event) => {
event.preventDefault();
dispatch(logOut(intl));
};
const openVerifySmsModal = () => dispatch(openModal('VERIFY_SMS'));
useEffect(() => {
if (!isSmsVerified) {
openVerifySmsModal();
}
}, []);
return (
<div>
<LandingGradient />
@ -41,19 +55,20 @@ const WaitlistPage = ({ account }) => {
</header>
<div className='-mt-16 flex flex-col justify-center items-center h-full'>
<div className='max-w-2xl'>
<div className='max-w-xl'>
<Stack space={4}>
<img src='/instance/images/waitlist.png' className='mx-auto w-32 h-32' alt='Waitlisted' />
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
@{account.acct} has been created successfully!
</Text>
<Text size='lg' theme='muted' align='center' weight='medium'>
Due to massive demand, we have placed you on our waitlist.
We love you, and you're not just another number to us.
We are working to get you on our platform. Stay tuned!
Welcome back to {title}! You were previously placed on our
waitlist. Please verify your phone number to receive
immediate access to your account!
</Text>
<div className='text-center'>
<Button onClick={openVerifySmsModal} theme='primary'>Verify phone number</Button>
</div>
</Stack>
</Stack>
</div>

@ -18,7 +18,7 @@ const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
let rootState = rootReducer(undefined, {} as Action);
// Apply actions to the state, one at a time
/** Apply actions to the state, one at a time. */
const applyActions = (state: any, actions: any, reducer: any) => {
return actions.reduce((state: any, action: any) => reducer(state, action), state);
};

@ -14,6 +14,7 @@
"account.column_settings.title": "Ustawienia osi czasu",
"account.deactivated": "Dezaktywowany(-a)",
"account.direct": "Wyślij wiadomość bezpośrednią do @{name}",
"account.domain_blocked": "Wyciszono domenę",
"account.edit_profile": "Edytuj profil",
"account.endorse": "Polecaj na profilu",
"account.familiar_followers": "Obserwowany(-a) przez {accounts}",
@ -35,6 +36,7 @@
"account.mention": "Wspomnij",
"account.moved_to": "{name} przeniósł(-osła) się do:",
"account.mute": "Wycisz @{name}",
"account.muted": "Wyciszono",
"account.never_active": "Nigdy",
"account.posts": "Wpisy",
"account.posts_with_replies": "Wpisy i odpowiedzi",
@ -81,7 +83,7 @@
"admin.dashcounters.user_count_label": "użytkownicy łącznie",
"admin.dashwidgets.email_list_header": "Lista adresów e-mail",
"admin.dashwidgets.software_header": "Oprogramowanie",
"admin.latest_accounts_panel.more": "Click to see {count} {count, plural, one {account} other {accounts}}",
"admin.latest_accounts_panel.more": "Naciśnij, aby zobaczyć {count} {count, plural, one {konto} few {konta} many {kont}}",
"admin.latest_accounts_panel.title": "Najnowsze konta",
"admin.moderation_log.empty_message": "Nie wykonałeś(-aś) jeszcze żadnych działań moderacyjnych. Kiedy jakieś zostaną wykonane, ich historia pojawi się tutaj.",
"admin.reports.actions.close": "Zamknij",
@ -124,7 +126,9 @@
"admin_nav.awaiting_approval": "Oczekujące zgłoszenia",
"admin_nav.dashboard": "Panel administracyjny",
"admin_nav.reports": "Zgłoszenia",
"alert.unexpected.body": "Przepraszamy za niedogodność. Jeśli problem nie zniknie, skontaktuj się z naszym wsparciem technicznym. Możesz też spróbować {clearCookies} (zostaniesz wylogowany(-a) z konta).",
"age_verification.header": "Wprowadź datę urodzenia",
"age_verification.fail": "Musisz mieć przynajmniej {ageMinimum, plural, one {# rok} few {# lata} many {# lat} other {# lat}}.",
"alert.unexpected.body": "Przepraszamy za niedogodności. Jeżeli problem nie ustanie, skontaktuj się z naszym wsparciem technicznym. Możesz też spróbować {clearCookies} (zostaniesz wylogowany(-a)).",
"alert.unexpected.browser": "Przeglądarka",
"alert.unexpected.clear_cookies": "wyczyścić pliki cookies i dane przeglądarki",
"alert.unexpected.links.help": "Centrum pomocy",
@ -151,6 +155,7 @@
"app_create.scopes_placeholder": "np. „read write follow”",
"app_create.submit": "Utwórz aplikację",
"app_create.website_label": "Strona",
"auth_layout.register": "Utwórz konto",
"auth.invalid_credentials": "Nieprawidłowa nazwa użytkownika lub hasło",
"auth.logged_out": "Wylogowano.",
"backups.actions.create": "Utwórz kopię zapasową",
@ -158,7 +163,7 @@
"backups.empty_message.action": "Chcesz utworzyć?",
"backups.pending": "Oczekująca",
"beta.also_available": "Dostępne w językach:",
"birthdays_modal.empty": "Nikt kogo znasz nie ma dziś urodzin.",
"birthdays_modal.empty": "Żaden z Twoich znajomych nie ma dziś urodzin.",
"birthday_panel.title": "Urodziny",
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
"bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
@ -367,7 +372,7 @@
"developers.navigation.app_create_label": "Utwórz aplikację",
"developers.navigation.intentional_error_label": "Wywołaj błąd",
"developers.navigation.leave_developers_label": "Opuść programistów",
"developers.navigation.network_error_label": "Network error",
"developers.navigation.network_error_label": "Błąd sieci",
"developers.navigation.settings_store_label": "Settings store",
"developers.navigation.test_timeline_label": "Testowa oś czasu",
"developers.settings_store.hint": "Możesz tu bezpośrednio edytować swoje ustawienia. UWAŻAJ! Edytowanie tej sekcji może uszkodzić Twoje konto, co może zostać naprawione tylko przez API.",
@ -378,6 +383,7 @@
"directory.recently_active": "Ostatnio aktywni",
"donate": "Wesprzyj",
"donate_crypto": "Przekaż kryptowalutę",
"edit_email.header": "Zmień e-mail",
"edit_federation.followers_only": "Ukryj wpisy z wyjątkiem obserwowanych",
"edit_federation.force_nsfw": "Wymuś oznaczanie załączników jako wrażliwe",
"edit_federation.media_removal": "Wycinaj media",
@ -420,14 +426,22 @@
"edit_profile.hints.stranger_notifications": "Wyświetlaj tylko powiadomienia od osób, które obserwujesz",
"edit_profile.save": "Zapisz",
"edit_profile.success": "Zapisano profil!",
"email_confirmation.success": "Twój adres e-mail został potwierdzony!",
"email_passthru.confirmed.body": "Zamknij tę kartę i kontynuuj rejestrację z {bold}, z którego został wysłany ten e-mail.",
"email_passthru.confirmed.heading": "Potwierdzono adres e-mail!",
"email_passthru.fail.expired": "Token z maila wygasł.",
"email_passthru.fail.generic": "Nie udało się potwierdzić adresu e-mail",
"email_passthru.fail.not_found": "Token z maila jest nieprawidłowy.",
"email_passthru.generic_fail.body": "Poproś o nowe potwierdzenie adresu e-mail.",
"email_passthru.generic_fail.heading": "Coś poszło nie tak",
"email_passthru.success": "Twój adres e-mail został zweryfikowany",
"email_passthru.token_expired.body": "Token e-mail wygasł. Poproś o nowe potwierdzenie adresu e-mail z {bold}, z którego został wysłany ten e-mail.",
"email_passthru.token_expired.heading": "Token wygasł",
"email_passthru.token_not_found.body": "Nie odnaleziono tokenu e-mail. Poproś o nowe potwierdzenie adresu e-mail z {bold}, z którego został wysłany ten e-mail.",
"email_passthru.token_not_found.heading": "Nieprawidłowy token",
"email_verification.exists": "Ten adres e-mail już jest zajęty",
"email_verification.fail": "Nie udało się zażądać weryfikacji e-mail",
"email_verification.header": "Wprowadź adres e-mail",
"embed.instructions": "Osadź ten wpis na swojej stronie wklejając poniższy kod.",
"embed.preview": "Tak będzie to wyglądać:",
"emoji_button.activity": "Aktywność",
@ -595,34 +609,34 @@
"introduction.welcome.action": "Let's go!",
"introduction.welcome.headline": "First steps",
"introduction.welcome.text": "Welcome to the fediverse! In a few moments, you'll be able to broadcast messages and talk to your friends across a wide variety of servers. But this server, {domain}, is special—it hosts your profile, so remember its name.",
"keyboard_shortcuts.back": "aby cofnąć się",
"keyboard_shortcuts.blocked": "aby przejść do listy zablokowanych użytkowników",
"keyboard_shortcuts.boost": "aby podbić wpis",
"keyboard_shortcuts.compose": "aby przejść do pola tworzenia wpisu",
"keyboard_shortcuts.down": "aby przejść na dół listy",
"keyboard_shortcuts.enter": "aby otworzyć wpis",
"keyboard_shortcuts.favourite": "aby dodać do ulubionych",
"keyboard_shortcuts.favourites": "aby przejść do listy ulubionych wpisów",
"keyboard_shortcuts.back": "cofnij się",
"keyboard_shortcuts.blocked": "przejdź do listy zablokowanych",
"keyboard_shortcuts.boost": "podbij wpis",
"keyboard_shortcuts.compose": "przejdź do pola tworzenia wpisu",
"keyboard_shortcuts.down": "przejdź na dół listy",
"keyboard_shortcuts.enter": "otwórz wpis",
"keyboard_shortcuts.favourite": "dodaj do ulubionych",
"keyboard_shortcuts.favourites": "przejdź do listy ulubionych wpisów",
"keyboard_shortcuts.heading": "Skróty klawiszowe",
"keyboard_shortcuts.home": "aby otworzyć stronę główną",
"keyboard_shortcuts.home": "otwórz stronę główną",
"keyboard_shortcuts.hotkey": "Klawisz",
"keyboard_shortcuts.legend": "aby wyświetl tę legendę",
"keyboard_shortcuts.mention": "aby wspomnieć o autorze",
"keyboard_shortcuts.muted": "aby przejść do listy wyciszonych użytkowników",
"keyboard_shortcuts.my_profile": "aby otworzyć własny profil",
"keyboard_shortcuts.notifications": "aby otworzyć kolumnę powiadomień",
"keyboard_shortcuts.open_media": "aby otworzyć media",
"keyboard_shortcuts.pinned": "aby przejść do listy przypiętych wpisów",
"keyboard_shortcuts.profile": "aby przejść do profilu autora wpisu",
"keyboard_shortcuts.react": "aby zareagować na wpis",
"keyboard_shortcuts.reply": "aby odpowiedzieć",
"keyboard_shortcuts.requests": "aby przejść do listy próśb o możliwość śledzenia",
"keyboard_shortcuts.search": "aby przejść do pola wyszukiwania",
"keyboard_shortcuts.toggle_hidden": "aby wyświetl lub ukryć wpis spod CW",
"keyboard_shortcuts.toggle_sensitivity": "by pokazać/ukryć multimedia",
"keyboard_shortcuts.toot": "aby utworzyć nowy wpis",
"keyboard_shortcuts.unfocus": "aby opuścić pole wyszukiwania/pisania",
"keyboard_shortcuts.up": "aby przejść na górę listy",
"keyboard_shortcuts.legend": "wyświetl tę legendę",
"keyboard_shortcuts.mention": "wspomnij o autorze",
"keyboard_shortcuts.muted": "przejdź do listy wyciszonych",
"keyboard_shortcuts.my_profile": "otwórz własny profil",
"keyboard_shortcuts.notifications": "otwórz kolumnę powiadomień",
"keyboard_shortcuts.open_media": "otwórz media",
"keyboard_shortcuts.pinned": "przejdź do listy przypiętych wpisów",
"keyboard_shortcuts.profile": "przejdź do profilu autora wpisu",
"keyboard_shortcuts.react": "zareaguj na wpis",
"keyboard_shortcuts.reply": "odpowiedz",
"keyboard_shortcuts.requests": "przejdź do listy próśb o śledzenie",
"keyboard_shortcuts.search": "przejdź do pola wyszukiwania",
"keyboard_shortcuts.toggle_hidden": "wyświetl lub ukryć wpis spod CW",
"keyboard_shortcuts.toggle_sensitivity": "pokaż/ukryj multimedia",
"keyboard_shortcuts.toot": "utwórz nowy wpis",
"keyboard_shortcuts.unfocus": "opuść pole wyszukiwania/pisania",
"keyboard_shortcuts.up": "przejdź na górę listy",
"landing_page_modal.download": "Pobierz",
"landing_page_modal.helpCenter": "Centrum pomocy",
"lightbox.close": "Zamknij",
@ -648,17 +662,21 @@
"login.fields.otp_code_hint": "Wprowadź kod uwierzytelniania dwuetapowego wygenerowany przez aplikację mobilną lub jeden z kodów zapasowych",
"login.fields.otp_code_label": "Kod uwierzytelniania dwuetapowego:",
"login.fields.password_placeholder": "Hasło",
"login.fields.username_label": "Adres e-mail lub nazwa użytkownika",
"login.fields.username_placeholder": "Nazwa użytkownika",
"login.log_in": "Zaloguj się",
"login.otp_log_in": "Login OTP",
"login.otp_log_in.fail": "Nieprawidłowy kod, spróbuj ponownie później.",
"login.reset_password_hint": "Problem z zalogowaniem?",
"login.sign_in": "Zaloguj się",
"login_form.header": "Zaloguj się",
"media_gallery.toggle_visible": "Przełącz widoczność",
"media_panel.empty_message": "Nie znaleziono mediów.",
"media_panel.title": "Media",
"mfa.confirm.success_message": "Potwierdzono MFA",
"mfa.disable.success_message": "Wyłączono MFA",
"mfa.disabled": "Wyłączone",
"mfa.enabled": "Włączone",
"mfa.mfa_disable_enter_password": "Wprowadź obecne hasło, aby wyłączyć uwierzytelnianie dwuetapowe:",
"mfa.mfa_setup.code_hint": "Wprowadź kod z aplikacji do uwierzytelniania dwuskładnikowego.",
"mfa.mfa_setup.code_placeholder": "Kod",
@ -691,6 +709,10 @@
"morefollows.followers_label": "…i {count} więcej {count, plural, one {obserwujący(-a)} few {obserwujących} many {obserwujących} other {obserwujących}} na zdalnych stronach.",
"morefollows.following_label": "…i {count} więcej {count, plural, one {obserwowany(-a)} few {obserwowanych} many {obserwowanych} other {obserwowanych}} na zdalnych stronach.",
"mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
"navbar.login.action": "Zaloguj się",
"navbar.login.forgot_password": "Nie pamiętasz hasła?",
"navbar.login.password.label": "Hasło",
"navbar.login.username.placeholder": "Adres e-mail lub nazwa użytkownika",
"navigation.chats": "Czaty",
"navigation.compose": "Utwórz wpis",
"navigation.dashboard": "Administracja",
@ -775,7 +797,7 @@
"onboarding.display_name.title": "Wybierz wyświetlaną nazwę",
"onboarding.done": "Gotowe",
"onboarding.finished.message": "Cieszymy się, że możemy powitać Cię w naszej społeczności! Naciśnij poniższy przycisk, aby rozpocząć.",
"onboarding.finished.title": "Wprowadzenie",
"onboarding.finished.title": "Wprowadzenie ukończone",
"onboarding.header.subtitle": "Będzie widoczny w górnej części Twojego profilu",
"onboarding.header.title": "Wybierz obraz tła",
"onboarding.next": "Dalej",
@ -786,11 +808,13 @@
"onboarding.suggestions.subtitle": "Oto kilka najbardziej popularnych kont, które mogą Ci się spodobać.",
"onboarding.suggestions.title": "Proponowane konta",
"onboarding.view_feed": "Pokaż strumień",
"password_reset.confirmation_hint": "Nie otrzymałeś(-aś) maila potwierdzającego?",
"password_reset.confirmation": "Sprawdź swoją pocztę e-mail, aby potwierdzić.",
"password_reset.fields.username_placeholder": "Adres e-mail lub nazwa użytkownika",
"password_reset.header": "Resetuj hasło",
"password_reset.reset": "Resetuj hasło",
"patron.donate": "Wesprzyj",
"patron.title": "Cel wsparcia",
"patron.title": "Cel wsparcia",
"pinned_accounts.title": "Polecani przez {name}",
"pinned_statuses.none": "Brak przypięć do pokazania.",
"poll.closed": "Zamknięte",
@ -826,14 +850,18 @@
"privacy.unlisted.short": "Niewidoczny",
"profile_dropdown.add_account": "Dodaj istniejące konto",
"profile_dropdown.logout": "Wyloguj @{acct}",
"profile_dropdown.switch_account": "Przełącz konta",
"profile_dropdown.theme": "Motyw",
"profile_fields_panel.title": "Pola konta",
"profile_fields_panel.title": "Pola profilu",
"public.column_settings.title": "Ustawienia osi czasu Fediwersum",
"reactions.all": "Wszystkie",
"regeneration_indicator.label": "Ładuję…",
"regeneration_indicator.sublabel": "Twoja oś czasu jest przygotowywana!",
"register_invite.lead": "Wypełnij poniższy formularz, aby utworzyć konto.",
"register_invite.title": "Otrzymałeś(-aś) zaproszenie na {siteTitle}!",
"registrations.error": "Nie udało się zarejestrować konta.",
"registrations.success": "Witamy na {siteTitle}!",
"registrations.unprocessable_entity": "Ta nazwa użytkownika jest już zajęta.",
"registration.agreement": "Akceptuję {tos}.",
"registration.captcha.hint": "Naciśnij na obrazek, aby uzyskać nową captchę",
"registration.captcha.placeholder": "Wprowadź kod z obrazka",
@ -845,6 +873,7 @@
"registration.fields.password_placeholder": "Hasło",
"registration.fields.username_hint": "Możesz używać tylko liter, cyfr i podkreślników.",
"registration.fields.username_placeholder": "Nazwa użytkownika",
"registration.header": "Zarejestruj konto",
"registration.newsletter": "Zasubskrybuj newsletter.",
"registration.password_mismatch": "Hasła nie pasują do siebie.",
"registration.reason": "Dlaczego chcesz dołączyć?",
@ -902,6 +931,7 @@
"report.reason.title": "Powód zgłoszenia",
"report.submit": "Wyślij",
"report.target": "Zgłaszanie {target}",
"reset_password.fail": "Token wygasł, spróbuj ponownie.",
"reset_password.header": "Ustaw nowe hasło",
"schedule.post_time": "Data/godzina publikacji",
"schedule.remove": "Usuń zaplanowany wpis",
@ -947,15 +977,22 @@
"settings.configure_mfa": "Konfiguruj uwierzytelnianie wieloskładnikowe",
"settings.delete_account": "Usuń konto",
"settings.edit_profile": "Edytuj profil",
"settings.other": "Inne",
"settings.preferences": "Preferencje",
"settings.profile": "Profil",
"settings.save.success": "Pomyślnie zapisano preferencje!",
"settings.security": "Bezpieczeństwo",
"settings.sessions": "Aktywne sesje",
"settings.settings": "Ustawienia",
"shared.tos": "Zasady użytkowania",
"signup_panel.subtitle": "Zarejestruj się, aby przyłączyć się do dyskusji.",
"signup_panel.title": "Nowi na {site_title}?",
"site_preview.preview": "Podgląd",
"sms_verification.header": "Wprowadź numer telefonu",
"sms_verification.fail": "Nie udało się wysłać kodu weryfikującego na Twój numer telefonu.",
"sms_verification.invalid": "Wprowadź prawidłowy numer telefonu.",
"sms_verification.sent.header": "Kod weryfikujący",
"sms_verification.success": "Kod weryfikujący został wysłany na Twój numer telefonu.",
"snackbar.view": "Wyświetl",
"soapbox_config.authenticated_profile_hint": "Użytkownicy muszą być zalogowani, aby zobaczyć odpowiedzi i media na profilach użytkowników.",
"soapbox_config.authenticated_profile_label": "Profile wymagają uwierzytelniania",
@ -1086,6 +1123,10 @@
"theme_toggle.dark": "Ciemny",
"theme_toggle.light": "Jasny",
"theme_toggle.system": "Systemowy",
"thread_login.login": "Zaloguj się",
"thread_login.message": "Dołącz do {siteTitle}, aby dowiedzieć się więcej.",
"thread_login.signup": "Zarejestruj się",
"thread_login.title": "Kontynuuj dyskusję",
"time_remaining.days": "{number, plural, one {Pozostał # dzień} few {Pozostały # dni} many {Pozostało # dni} other {Pozostało # dni}}",
"time_remaining.hours": "{number, plural, one {Pozostała # godzina} few {Pozostały # godziny} many {Pozostało # godzin} other {Pozostało # godzin}}",
"time_remaining.minutes": "{number, plural, one {Pozostała # minuta} few {Pozostały # minuty} many {Pozostało # minut} other {Pozostało # minut}}",

@ -21,7 +21,8 @@ export type NotificationType =
| 'status'
| 'move'
| 'pleroma:chat_mention'
| 'pleroma:emoji_reaction';
| 'pleroma:emoji_reaction'
| 'user_approved';
// https://docs.joinmastodon.org/entities/notification/
export const NotificationRecord = ImmutableRecord({

@ -115,6 +115,7 @@ export const SoapboxConfigRecord = ImmutableRecord({
singleUserMode: false,
singleUserModeProfile: '',
linkFooterMessage: '',
guestExperience: true,
links: ImmutableMap<string, string>(),
}, 'SoapboxConfig');

@ -36,7 +36,7 @@ const HomePage: React.FC = ({ children }) => {
return (
<>
<Layout.Main className='pt-4 sm:pt-0 divide-y divide-gray-200 dark:divide-slate-700 divide-solid space-y-4 divide-none'>
<Layout.Main className='pt-4 sm:pt-0 dark:divide-slate-700 space-y-4'>
{me && (
<Card variant='rounded' ref={composeBlock}>
<CardBody>

@ -59,6 +59,28 @@ describe('compose reducer', () => {
const result = reducer(undefined, action);
expect(result.getIn(['media_attachments', 0, 'id'])).toEqual('508107650');
});
it('sets the id when editing a post', () => {
const action = {
withRedraft: false,
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
};
const result = reducer(undefined, action);
expect(result.get('id')).toEqual('AHU2RrX0wdcwzCYjFQ');
});
it('does not set the id when redrafting a post', () => {
const action = {
withRedraft: true,
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
};
const result = reducer(undefined, action);
expect(result.get('id')).toEqual(null);
});
});
it('uses \'public\' scope as default', () => {

@ -4,78 +4,111 @@ import {
fromJS,
} from 'immutable';
import context1 from 'soapbox/__fixtures__/context_1.json';
import context2 from 'soapbox/__fixtures__/context_2.json';
import { STATUS_IMPORT } from 'soapbox/actions/importer';
import { CONTEXT_FETCH_SUCCESS } from 'soapbox/actions/statuses';
import { TIMELINE_DELETE } from 'soapbox/actions/timelines';
import { applyActions } from 'soapbox/jest/test-helpers';
import reducer from '../contexts';
import reducer, { ReducerRecord } from '../contexts';
describe('contexts reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({
expect(reducer(undefined, {})).toEqual(ReducerRecord({
inReplyTos: ImmutableMap(),
replies: ImmutableMap(),
}));
});
it('should support rendering a complete tree', () => {
// https://gitlab.com/soapbox-pub/soapbox-fe/-/issues/422
let result;
result = reducer(result, { type: CONTEXT_FETCH_SUCCESS, id: '9zIH8WYwtnUx4yDzUm', ancestors: context1.ancestors, descendants: context1.descendants });
result = reducer(result, { type: CONTEXT_FETCH_SUCCESS, id: '9zIH7PUdhK3Ircg4hM', ancestors: context2.ancestors, descendants: context2.descendants });
expect(result).toEqual(ImmutableMap({
inReplyTos: ImmutableMap({
'9zIH7PUdhK3Ircg4hM': '9zIH6kDXA10YqhMKqO',
'9zIH7mMGgc1RmJwDLM': '9zIH6kDXA10YqhMKqO',
'9zIH9GTCDWEFSRt2um': '9zIH7PUdhK3Ircg4hM',
'9zIH9fhaP9atiJoOJc': '9zIH8WYwtnUx4yDzUm',
'9zIH8WYwtnUx4yDzUm': '9zIH7PUdhK3Ircg4hM',
'9zIH8WYwtnUx4yDzUm-tombstone': '9zIH7mMGgc1RmJwDLM',
}),
replies: ImmutableMap({
'9zIH6kDXA10YqhMKqO': ImmutableOrderedSet([
'9zIH7PUdhK3Ircg4hM',
'9zIH7mMGgc1RmJwDLM',
]),
'9zIH7PUdhK3Ircg4hM': ImmutableOrderedSet([
'9zIH8WYwtnUx4yDzUm',
'9zIH9GTCDWEFSRt2um',
]),
'9zIH8WYwtnUx4yDzUm': ImmutableOrderedSet([
'9zIH9fhaP9atiJoOJc',
]),
'9zIH8WYwtnUx4yDzUm-tombstone': ImmutableOrderedSet([
'9zIH8WYwtnUx4yDzUm',
]),
'9zIH7mMGgc1RmJwDLM': ImmutableOrderedSet([
'9zIH8WYwtnUx4yDzUm-tombstone',
]),
}),
}));
describe(CONTEXT_FETCH_SUCCESS, () => {
it('inserts a tombstone connecting an orphaned descendant', () => {
const status = { id: 'A', in_reply_to_id: null };
const context = {
id: 'A',
ancestors: [],
descendants: [
{ id: 'C', in_reply_to_id: 'B' },
],
};
const actions = [
{ type: STATUS_IMPORT, status },
{ type: CONTEXT_FETCH_SUCCESS, ...context },
];
const result = applyActions(undefined, actions, reducer);
expect(result.inReplyTos.get('C')).toBe('C-tombstone');
expect(result.replies.get('A').toArray()).toEqual(['C-tombstone']);
});
it('inserts a tombstone connecting an orphaned descendant (with null in_reply_to_id)', () => {
const status = { id: 'A', in_reply_to_id: null };
const context = {
id: 'A',
ancestors: [],
descendants: [
{ id: 'C', in_reply_to_id: null },
],
};
const actions = [
{ type: STATUS_IMPORT, status },
{ type: CONTEXT_FETCH_SUCCESS, ...context },
];
const result = applyActions(undefined, actions, reducer);
expect(result.inReplyTos.get('C')).toBe('C-tombstone');
expect(result.replies.get('A').toArray()).toEqual(['C-tombstone']);
});
it('doesn\'t explode when it encounters a loop', () => {
const status = { id: 'A', in_reply_to_id: null };
const context = {
id: 'A',
ancestors: [],
descendants: [
{ id: 'C', in_reply_to_id: 'E' },
{ id: 'D', in_reply_to_id: 'C' },
{ id: 'E', in_reply_to_id: 'D' },
{ id: 'F', in_reply_to_id: 'F' },
],
};
const actions = [
{ type: STATUS_IMPORT, status },
{ type: CONTEXT_FETCH_SUCCESS, ...context },
];
const result = applyActions(undefined, actions, reducer);
// These checks are superficial. We just don't want a stack overflow!
expect(result.inReplyTos.get('C')).toBe('C-tombstone');
expect(result.replies.get('A').toArray()).toEqual(['C-tombstone', 'F-tombstone']);
});
});
describe(TIMELINE_DELETE, () => {
it('deletes the status', () => {
const action = { type: TIMELINE_DELETE, id: 'B' };
const state = fromJS({
inReplyTos: {
const state = ReducerRecord({
inReplyTos: fromJS({
B: 'A',
C: 'B',
},
replies: {
}),
replies: fromJS({
A: ImmutableOrderedSet(['B']),
B: ImmutableOrderedSet(['C']),
},
}),
});
const expected = fromJS({
inReplyTos: {},
replies: {
const expected = ReducerRecord({
inReplyTos: fromJS({}),
replies: fromJS({
A: ImmutableOrderedSet(),
},
}),
});
expect(reducer(state, action)).toEqual(expected);

@ -1,32 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import * as actions from 'soapbox/actions/identity_proofs';
import reducer from '../identity_proofs';
describe('identity_proofs reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap());
});
it('should handle IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST', () => {
const state = ImmutableMap({ isLoading: false });
const action = {
type: actions.IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
};
expect(reducer(state, action).toJS()).toMatchObject({
isLoading: true,
});
});
it('should handle IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL', () => {
const state = ImmutableMap({ isLoading: true });
const action = {
type: actions.IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
};
expect(reducer(state, action).toJS()).toMatchObject({
isLoading: false,
});
});
});

@ -429,7 +429,9 @@ export default function compose(state = initialState, action) {
}));
case COMPOSE_SET_STATUS:
return state.withMutations(map => {
map.set('id', action.status.get('id'));
if (!action.withRedraft) {
map.set('id', action.status.get('id'));
}
map.set('text', action.rawText || unescapeHTML(expandMentions(action.status)));
map.set('to', action.explicitAddressing ? getExplicitMentions(action.status.get('account', 'id'), action.status) : ImmutableOrderedSet());
map.set('in_reply_to', action.status.get('in_reply_to_id'));

@ -1,163 +0,0 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { STATUS_IMPORT, STATUSES_IMPORT } from 'soapbox/actions/importer';
import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import {
STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS,
} from '../actions/statuses';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
const initialState = ImmutableMap({
inReplyTos: ImmutableMap(),
replies: ImmutableMap(),
});
const importStatus = (state, status, idempotencyKey) => {
const { id, in_reply_to_id } = status;
if (!in_reply_to_id) return state;
return state.withMutations(state => {
state.setIn(['inReplyTos', id], in_reply_to_id);
state.updateIn(['replies', in_reply_to_id], ImmutableOrderedSet(), ids => {
return ids.add(id).sort();
});
if (idempotencyKey) {
deletePendingStatus(state, status, idempotencyKey);
}
});
};
const importStatuses = (state, statuses) => {
return state.withMutations(state => {
statuses.forEach(status => importStatus(state, status));
});
};
const isReplyTo = (state, childId, parentId, initialId = null) => {
if (!childId) return false;
// Prevent cycles
if (childId === initialId) return false;
initialId = initialId || childId;
if (childId === parentId) {
return true;
} else {
const nextId = state.getIn(['inReplyTos', childId]);
return isReplyTo(state, nextId, parentId, initialId);
}
};
const insertTombstone = (state, ancestorId, descendantId) => {
// Prevent infinite loop if the API returns a bogus response
if (isReplyTo(state, ancestorId, descendantId)) return state;
const tombstoneId = `${descendantId}-tombstone`;
return state.withMutations(state => {
importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId });
importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId });
});
};
const importBranch = (state, statuses, rootId) => {
return state.withMutations(state => {
statuses.forEach((status, i) => {
const lastId = rootId && i === 0 ? rootId : (statuses[i - 1] || {}).id;
if (status.in_reply_to_id) {
importStatus(state, status);
} else if (lastId) {
insertTombstone(state, lastId, status.id);
}
});
});
};
const normalizeContext = (state, id, ancestors, descendants) => state.withMutations(state => {
importBranch(state, ancestors);
importBranch(state, descendants, id);
if (ancestors.length > 0 && !state.getIn(['inReplyTos', id])) {
insertTombstone(state, ancestors[ancestors.length - 1].id, id);
}
});
const deleteStatus = (state, id) => {
return state.withMutations(state => {
const parentId = state.getIn(['inReplyTos', id]);
const replies = state.getIn(['replies', id], ImmutableOrderedSet());
// Delete from its parent's tree
state.updateIn(['replies', parentId], ImmutableOrderedSet(), ids => ids.delete(id));
// Dereference children
replies.forEach(reply => state.deleteIn(['inReplyTos', reply]));
state.deleteIn(['inReplyTos', id]);
state.deleteIn(['replies', id]);
});
};
const deleteStatuses = (state, ids) => {
return state.withMutations(state => {
ids.forEach(id => deleteStatus(state, id));
});
};
const filterContexts = (state, relationship, statuses) => {
const ownedStatusIds = statuses
.filter(status => status.get('account') === relationship.id)
.map(status => status.get('id'));
return deleteStatuses(state, ownedStatusIds);
};
const importPendingStatus = (state, params, idempotencyKey) => {
const id = `末pending-${idempotencyKey}`;
const { in_reply_to_id } = params;
return importStatus(state, { id, in_reply_to_id });
};
const deletePendingStatus = (state, { in_reply_to_id }, idempotencyKey) => {
const id = `末pending-${idempotencyKey}`;
return state.withMutations(state => {
state.deleteIn(['inReplyTos', id]);
if (in_reply_to_id) {
state.updateIn(['replies', in_reply_to_id], ImmutableOrderedSet(), ids => {
return ids.delete(id).sort();
});
}
});
};
export default function replies(state = initialState, action) {
switch (action.type) {
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return filterContexts(state, action.relationship, action.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
case TIMELINE_DELETE:
return deleteStatuses(state, [action.id]);
case STATUS_CREATE_REQUEST:
return importPendingStatus(state, action.params, action.idempotencyKey);
case STATUS_CREATE_SUCCESS:
return deletePendingStatus(state, action.status, action.idempotencyKey);
case STATUS_IMPORT:
return importStatus(state, action.status, action.idempotencyKey);
case STATUSES_IMPORT:
return importStatuses(state, action.statuses);
default:
return state;
}
}

@ -0,0 +1,221 @@
import {
Map as ImmutableMap,
Record as ImmutableRecord,
OrderedSet as ImmutableOrderedSet,
} from 'immutable';
import { STATUS_IMPORT, STATUSES_IMPORT } from 'soapbox/actions/importer';
import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import {
STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS,
} from '../actions/statuses';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import type { ReducerStatus } from './statuses';
import type { AnyAction } from 'redux';
export const ReducerRecord = ImmutableRecord({
inReplyTos: ImmutableMap<string, string>(),
replies: ImmutableMap<string, ImmutableOrderedSet<string>>(),
});
type State = ReturnType<typeof ReducerRecord>;
/** Minimal status fields needed to process context. */
type ContextStatus = {
id: string,
in_reply_to_id: string | null,
}
/** Import a single status into the reducer, setting replies and replyTos. */
const importStatus = (state: State, status: ContextStatus, idempotencyKey?: string): State => {
const { id, in_reply_to_id: inReplyToId } = status;
if (!inReplyToId) return state;
return state.withMutations(state => {
const replies = state.replies.get(inReplyToId) || ImmutableOrderedSet();
const newReplies = replies.add(id).sort();
state.setIn(['replies', inReplyToId], newReplies);
state.setIn(['inReplyTos', id], inReplyToId);
if (idempotencyKey) {
deletePendingStatus(state, status, idempotencyKey);
}
});
};
/** Import multiple statuses into the state. */
const importStatuses = (state: State, statuses: ContextStatus[]): State => {
return state.withMutations(state => {
statuses.forEach(status => importStatus(state, status));
});
};
/** Insert a fake status ID connecting descendant to ancestor. */
const insertTombstone = (state: State, ancestorId: string, descendantId: string): State => {
const tombstoneId = `${descendantId}-tombstone`;
return state.withMutations(state => {
importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId });
importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId });
});
};
/** Find the highest level status from this statusId. */
const getRootNode = (state: State, statusId: string, initialId = statusId): string => {
const parent = state.inReplyTos.get(statusId);
if (!parent) {
return statusId;
} else if (parent === initialId) {
// Prevent cycles
return parent;
} else {
return getRootNode(state, parent, initialId);
}
};
/** Route fromId to toId by inserting tombstones. */
const connectNodes = (state: State, fromId: string, toId: string): State => {
const fromRoot = getRootNode(state, fromId);
const toRoot = getRootNode(state, toId);
if (fromRoot !== toRoot) {
return insertTombstone(state, toId, fromId);
} else {
return state;
}
};
/** Import a branch of ancestors or descendants, in relation to statusId. */
const importBranch = (state: State, statuses: ContextStatus[], statusId?: string): State => {
return state.withMutations(state => {
statuses.forEach((status, i) => {
const prevId = statusId && i === 0 ? statusId : (statuses[i - 1] || {}).id;
if (status.in_reply_to_id) {
importStatus(state, status);
// On Mastodon, in_reply_to_id can refer to an unavailable status,
// so traverse the tree up and insert a connecting tombstone if needed.
if (statusId) {
connectNodes(state, status.id, statusId);
}
} else if (prevId) {
// On Pleroma, in_reply_to_id will be null if the parent is unavailable,
// so insert the tombstone now.
insertTombstone(state, prevId, status.id);
}
});
});
};
/** Import a status's ancestors and descendants. */
const normalizeContext = (
state: State,
id: string,
ancestors: ContextStatus[],
descendants: ContextStatus[],
) => state.withMutations(state => {
importBranch(state, ancestors);
importBranch(state, descendants, id);
if (ancestors.length > 0 && !state.getIn(['inReplyTos', id])) {
insertTombstone(state, ancestors[ancestors.length - 1].id, id);
}
});
/** Remove a status from the reducer. */
const deleteStatus = (state: State, id: string): State => {
return state.withMutations(state => {
// Delete from its parent's tree
const parentId = state.inReplyTos.get(id);
if (parentId) {
const parentReplies = state.replies.get(parentId) || ImmutableOrderedSet();
const newParentReplies = parentReplies.delete(id);
state.setIn(['replies', parentId], newParentReplies);
}
// Dereference children
const replies = state.replies.get(id) || ImmutableOrderedSet();
replies.forEach(reply => state.deleteIn(['inReplyTos', reply]));
state.deleteIn(['inReplyTos', id]);
state.deleteIn(['replies', id]);
});
};
/** Delete multiple statuses from the reducer. */
const deleteStatuses = (state: State, ids: string[]): State => {
return state.withMutations(state => {
ids.forEach(id => deleteStatus(state, id));
});
};
/** Delete statuses upon blocking or muting a user. */
const filterContexts = (
state: State,
relationship: { id: string },
/** The entire statuses map from the store. */
statuses: ImmutableMap<string, ReducerStatus>,
): State => {
const ownedStatusIds = statuses
.filter(status => status.account === relationship.id)
.map(status => status.id)
.toList()
.toArray();
return deleteStatuses(state, ownedStatusIds);
};
/** Add a fake status ID for a pending status. */
const importPendingStatus = (state: State, params: ContextStatus, idempotencyKey: string): State => {
const id = `末pending-${idempotencyKey}`;
const { in_reply_to_id } = params;
return importStatus(state, { id, in_reply_to_id });
};
/** Delete a pending status from the reducer. */
const deletePendingStatus = (state: State, params: ContextStatus, idempotencyKey: string): State => {
const id = `末pending-${idempotencyKey}`;
const { in_reply_to_id: inReplyToId } = params;
return state.withMutations(state => {
state.deleteIn(['inReplyTos', id]);
if (inReplyToId) {
const replies = state.replies.get(inReplyToId) || ImmutableOrderedSet();
const newReplies = replies.delete(id).sort();
state.setIn(['replies', inReplyToId], newReplies);
}
});
};
/** Contexts reducer. Used for building a nested tree structure for threads. */
export default function replies(state = ReducerRecord(), action: AnyAction) {
switch (action.type) {
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return filterContexts(state, action.relationship, action.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
case TIMELINE_DELETE:
return deleteStatuses(state, [action.id]);
case STATUS_CREATE_REQUEST:
return importPendingStatus(state, action.params, action.idempotencyKey);
case STATUS_CREATE_SUCCESS:
return deletePendingStatus(state, action.status, action.idempotencyKey);
case STATUS_IMPORT:
return importStatus(state, action.status, action.idempotencyKey);
case STATUSES_IMPORT:
return importStatuses(state, action.statuses);
default:
return state;
}
}

@ -1,26 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
} from '../actions/identity_proofs';
const initialState = ImmutableMap();
export default function identityProofsReducer(state = initialState, action) {
switch (action.type) {
case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
return state.set('isLoading', true);
case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
return state.set('isLoading', false);
case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
return state.update(identity_proofs => identity_proofs.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set(action.accountId, fromJS(action.identity_proofs));
}));
default:
return state;
}
}

@ -29,7 +29,6 @@ import group_lists from './group_lists';
import group_relationships from './group_relationships';
import groups from './groups';
import history from './history';
import identity_proofs from './identity_proofs';
import instance from './instance';
import listAdder from './list_adder';
import listEditor from './list_editor';
@ -86,7 +85,6 @@ const reducers = {
search,
notifications,
custom_emojis,
identity_proofs,
lists,
listEditor,
listAdder,

@ -362,6 +362,12 @@ const getInstanceFeatures = (instance: Instance) => {
*/
paginatedContext: v.software === TRUTHSOCIAL,
/**
* Displays a form to follow a user when logged out.
* @see POST /main/ostatus
*/
pleromaRemoteFollow: v.software === PLEROMA,
/**
* Can add polls to statuses.
* @see POST /api/v1/statuses

@ -0,0 +1,36 @@
import { Location } from 'history';
import { useEffect } from 'react';
const LOCAL_STORAGE_REDIRECT_KEY = 'soapbox:redirect-uri';
const cacheCurrentUrl = (location: Location<unknown>) => {
const actualUrl = encodeURIComponent(`${location.pathname}${location.search}`);
localStorage.setItem(LOCAL_STORAGE_REDIRECT_KEY, actualUrl);
return actualUrl;
};
const getRedirectUrl = () => {
let redirectUri = localStorage.getItem(LOCAL_STORAGE_REDIRECT_KEY);
if (redirectUri) {
redirectUri = decodeURIComponent(redirectUri);
}
localStorage.removeItem(LOCAL_STORAGE_REDIRECT_KEY);
return redirectUri || '/';
};
const useCachedLocationHandler = () => {
const removeCachedRedirectUri = () => localStorage.removeItem(LOCAL_STORAGE_REDIRECT_KEY);
useEffect(() => {
window.addEventListener('beforeunload', removeCachedRedirectUri);
return () => {
window.removeEventListener('beforeunload', removeCachedRedirectUri);
};
}, []);
return null;
};
export { cacheCurrentUrl, getRedirectUrl, useCachedLocationHandler };

@ -43,7 +43,6 @@
@import 'components/user-panel';
@import 'components/compose-form';
@import 'components/sidebar-menu';
@import 'components/hotkeys-modal';
@import 'components/emoji-reacts';
@import 'components/status';
@import 'components/reply-indicator';

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

Loading…
Cancel
Save