Merge remote-tracking branch 'origin/develop' into fix_safari_sidebar_menu_rendering

better-alerts
Alex Gleason 2020-09-17 21:51:38 -05:00
commit dbddc177c1
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
357 zmienionych plików z 23512 dodań i 1419 usunięć

Wyświetl plik

@ -1,4 +1,3 @@
NODE_ENV=development
# BACKEND_URL="https://example.com"
# PATRON_URL="https://patron.example.com"
# PROXY_HTTPS_INSECURE=false

Wyświetl plik

@ -14,6 +14,7 @@ stages:
- lint
- test
- build
- deploy
before_script:
- yarn
@ -39,6 +40,16 @@ build-production:
paths:
- static
docs-deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add curl
script:
- curl -X POST -F"token=$CI_JOB_TOKEN" -F'ref=master' https://gitlab.com/api/v4/projects/15685485/trigger/pipeline
only:
- develop
# Supposed to fail when translations are outdated, instead always passes
#
# i18n:

Wyświetl plik

@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Audio player for audio uploads.
- Integration with Patron recurring donations platform.
## [Unreleased patch]
### Fixed
- Composer: Forcing the scope to default after settings save.
### Removed
- Removed the app name on statuses.
## [1.0.0] - 2020-06-15
### Added
- Emoji reactions.
@ -43,5 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial beta release.
[Unreleased]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v1.0.0...develop
[Unreleased patch]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v1.0.0...stable/1.0.x
[1.0.0]: https://gitlab.com/soapbox-pub/soapbox-fe/-/compare/v0.9.0...v1.0.0
[0.9.0]: https://gitlab.com/soapbox-pub/soapbox-fe/-/tags/v0.9.0

Wyświetl plik

@ -54,7 +54,7 @@ yarn
Finally, run the dev server:
```sh
yarn start
yarn dev
```
**That's it!** :tada:
@ -79,7 +79,7 @@ Try again.
You can also run Soapbox FE locally with a live production server as the backend.
> **Note:** Whether or not this works depends on your production server. It does not seem to work with Cloudflare.
> **Note:** Whether or not this works depends on your production server. It does not seem to work with Cloudflare or VanwaNet.
To do so, just copy the env file:
@ -124,7 +124,7 @@ For https, be sure to also set `PROXY_HTTPS_INSECURE=true`.
Allows using an HTTPS backend if set to `true`.
This is needed if `BACKEND_URL` or `PATRON_URL` are set to an `https://` value.
This is needed if `BACKEND_URL` is set to an `https://` value.
[More info](https://stackoverflow.com/a/48624590/8811886).
**Default:** `false`
@ -140,7 +140,7 @@ NODE_ENV=development
```
#### Local dev server
- `yarn dev` - Exact same as above, aliased to `yarn start` for convenience.
- `yarn dev` - Run the local dev server.
#### Building
- `yarn build` - Compile without a dev server, into `/static` directory.

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.8 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Wyświetl plik

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<title>Soapbox</title>
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<body class="app-body">

Wyświetl plik

@ -0,0 +1,182 @@
{
"9w1HhmenIAKBHJiUs4":{
"header_static":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
"display_name_html":"Alex Gleason",
"bot":false,
"display_name":"Alex Gleason",
"created_at":"2020-06-12T21:47:28.000Z",
"locked":false,
"emojis":[
],
"header":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
"url":"https://gleasonator.com/users/alex",
"note":"Fediverse developer. I come in peace. <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>. Boosts ≠ endorsements.",
"acct":"alex@gleasonator.com",
"avatar_static":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg",
"username":"alex",
"avatar":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg",
"fields":[
{
"name":"Website",
"value":"<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>",
"name_emojified":"Website",
"value_emojified":"<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>",
"value_plain":"https://alexgleason.me"
},
{
"name":"Pleroma+Soapbox",
"value":"<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>",
"name_emojified":"Pleroma+Soapbox",
"value_emojified":"<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>",
"value_plain":"https://soapbox.pub"
},
{
"name":"Email",
"value":"alex@alexgleason.me",
"name_emojified":"Email",
"value_emojified":"alex@alexgleason.me",
"value_plain":"alex@alexgleason.me"
},
{
"name":"Gender identity",
"value":"Soyboy",
"name_emojified":"Gender identity",
"value_emojified":"Soyboy",
"value_plain":"Soyboy"
}
],
"pleroma":{
"hide_follows":false,
"hide_followers_count":false,
"background_image":null,
"confirmation_pending":false,
"is_moderator":false,
"hide_follows_count":false,
"hide_followers":false,
"relationship":{
"showing_reblogs":true,
"followed_by":false,
"subscribing":false,
"blocked_by":false,
"requested":false,
"domain_blocking":false,
"following":false,
"endorsed":false,
"blocking":false,
"muting":false,
"id":"9w1HhmenIAKBHJiUs4",
"muting_notifications":false
},
"tags":[
],
"hide_favorites":true,
"is_admin":false,
"skip_thread_containment":false
},
"source":{
"fields":[
],
"note":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.",
"pleroma":{
"actor_type":"Person",
"discoverable":false
},
"sensitive":false
},
"id":"9w1HhmenIAKBHJiUs4",
"note_emojified":"Fediverse developer. I come in peace. <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>. Boosts ≠ endorsements."
},
"9w1HhmenIAKBHJiUs5":{
"header_static":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
"display_name_html":"Alex Gleason",
"bot":false,
"display_name":"Alex Gleason",
"created_at":"2020-06-12T21:47:28.000Z",
"locked":false,
"emojis":[
],
"header":"https://media.gleasonator.com/accounts/headers/000/000/001/original/9d0e4dbf1c9dbc8f.png",
"url":"https://gleasonator.com/users/alex",
"note":"Fediverse developer. I come in peace. <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>. Boosts ≠ endorsements.",
"acct":"alex@gleasonator.com",
"avatar_static":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg",
"username":"alex",
"avatar":"https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg",
"fields":[
{
"name":"Website",
"value":"<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>",
"name_emojified":"Website",
"value_emojified":"<a href=\"https://alexgleason.me\" rel=\"ugc\">https://alexgleason.me</a>",
"value_plain":"https://alexgleason.me"
},
{
"name":"Pleroma+Soapbox",
"value":"<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>",
"name_emojified":"Pleroma+Soapbox",
"value_emojified":"<a href=\"https://soapbox.pub\" rel=\"ugc\">https://soapbox.pub</a>",
"value_plain":"https://soapbox.pub"
},
{
"name":"Email",
"value":"alex@alexgleason.me",
"name_emojified":"Email",
"value_emojified":"alex@alexgleason.me",
"value_plain":"alex@alexgleason.me"
},
{
"name":"Gender identity",
"value":"Soyboy",
"name_emojified":"Gender identity",
"value_emojified":"Soyboy",
"value_plain":"Soyboy"
}
],
"pleroma":{
"hide_follows":false,
"hide_followers_count":false,
"background_image":null,
"confirmation_pending":false,
"is_moderator":false,
"hide_follows_count":false,
"hide_followers":false,
"relationship":{
"showing_reblogs":true,
"followed_by":false,
"subscribing":false,
"blocked_by":false,
"requested":false,
"domain_blocking":false,
"following":false,
"endorsed":false,
"blocking":false,
"muting":false,
"id":"9w1HhmenIAKBHJiUs5",
"muting_notifications":false
},
"tags":[
],
"hide_favorites":true,
"is_admin":false,
"skip_thread_containment":false
},
"source":{
"fields":[
],
"note":"Fediverse developer. I come in peace. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.",
"pleroma":{
"actor_type":"Person",
"discoverable":false
},
"sensitive":false
},
"id":"9w1HhmenIAKBHJiUs5",
"note_emojified":"Fediverse developer. I come in peace. <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>. Boosts ≠ endorsements."
}
}

Wyświetl plik

@ -0,0 +1,7 @@
{
"9vMAje101ngtjlMj7w": {
"followers_count": 2,
"following_count": 3,
"statuses_count": 2
}
}

Wyświetl plik

@ -0,0 +1,7 @@
{
"9vMAje101ngtjlMj7w": {
"followers_count": 2,
"following_count": 2,
"statuses_count": 2
}
}

Wyświetl plik

@ -0,0 +1,7 @@
{
"9vMAje101ngtjlMj7w": {
"followers_count": 2,
"following_count": 1,
"statuses_count": 2
}
}

Wyświetl plik

@ -0,0 +1,55 @@
{
"configs": [
{
"group": ":pleroma",
"key": ":frontend_configurations",
"value": [
{
"tuple": [
":soapbox_fe",
{
"logo": "blob:http://localhost:3036/0cdfa863-6889-4199-b870-4942cedd364f",
"banner": "blob:http://localhost:3036/a835afed-6078-45bd-92b4-7ffd858c3eca",
"brandColor": "#254f92",
"customCss": [
"/instance/static/custom.css"
],
"promoPanel": {
"items": [
{
"icon": "globe",
"text": "blog",
"url": "https://teci.world/blog"
},
{
"icon": "globe",
"text": "book",
"url": "https://teci.world/book"
}
]
},
"extensions": {
"patron": false
},
"defaultSettings": {
"autoPlayGif": false
},
"navlinks": {
"homeFooter": [
{
"title": "about",
"url": "/instance/about/index.html"
},
{
"title": "tos",
"url": "/instance/about/tos.html"
}
]
}
}
]
}
]
}
]
}

Wyświetl plik

@ -0,0 +1,15 @@
{
"vapid_key": "BHczIFh4Wn3Q_7wDgehaB8Ti3Uu8BoyOgXxkOVuEJRuEqxtd9TAno8K9ycz4myiQ1ruiyVfG6xT1JLeXtpxDzUs",
"token_type": "Bearer",
"client_secret": "cm_8Zip_UYyYq1DPQ-CRFUolrz894MmWYUC0aeVcklM",
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"created_at": 1594764335,
"name": "SoapboxFE_2020-07-14T22:05:17.054Z",
"client_id": "bjiy8AxGKXXesfZcyp_iN-uQVE6Cnl03efWoSdOPh9M",
"expires_in": 600,
"scope": "read write follow push admin",
"refresh_token": "IXoCKCsZi3ZCuCjIkeadvEoHRdqOYHklZmv9jvkJ5VA",
"website": null,
"id": "134",
"access_token": "XSkQFSV1R_IvycQmw_uD5z6hQmNyuhh9PtMQbv8TgG8"
}

Wyświetl plik

@ -0,0 +1,959 @@
{
"default": {
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.block": "Block @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.blocked": "Blocked",
"account.direct": "Direct message @{name}",
"account.domain_blocked": "Domain hidden",
"account.edit_profile": "Edit profile",
"account.endorse": "Feature on profile",
"account.follow": "Follow",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.follows": "Follows",
"account.follows.empty": "This user doesn\"t follow anyone yet.",
"account.follows_you": "Follows you",
"account.hide_reblogs": "Hide reposts from @{name}",
"account.link_verified_on": "Ownership of this link was checked on {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.login": "Log in",
"account.media": "Media",
"account.member_since": "Member since {date}",
"account.mention": "Mention",
"account.message": "Message",
"account.moved_to": "{name} has moved to:",
"account.mute": "Mute @{name}",
"account.mute_notifications": "Mute notifications from @{name}",
"account.muted": "Muted",
"account.posts": "Posts",
"account.posts_with_replies": "Posts and replies",
"account.profile": "Profile",
"account.register": "Sign up",
"account.report": "Report @{name}",
"account.requested": "Awaiting approval. Click to cancel follow request",
"account.share": "Share @{name}\"s profile",
"account.show_reblogs": "Show reposts from @{name}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unhide {domain}",
"account.unendorse": "Don\"t feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_gallery.none": "No media to show.",
"alert.unexpected.message": "An unexpected error occurred.",
"alert.unexpected.title": "Oops!",
"audio.close": "Close audio",
"audio.expand": "Expand audio",
"audio.hide": "Hide audio",
"audio.mute": "Mute",
"audio.pause": "Pause",
"audio.play": "Play",
"audio.unmute": "Unmute",
"boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.community": "Local timeline",
"column.direct": "Direct messages",
"column.domain_blocks": "Hidden domains",
"column.edit_profile": "Edit profile",
"column.filters": "Muted words",
"column.follow_requests": "Follow requests",
"column.groups": "Groups",
"column.home": "Home",
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.notifications": "Notifications",
"column.preferences": "Preferences",
"column.public": "Federated timeline",
"column.security": "Security",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",
"column_header.show_settings": "Show settings",
"column_subheading.settings": "Settings",
"community.column_settings.media_only": "Media Only",
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.hashtag_warning": "This post won\"t be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.placeholder": "What\"s on your mind?",
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.hide": "Mark media as sensitive",
"compose_form.sensitive.marked": "Media is marked as sensitive",
"compose_form.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden",
"compose_form.spoiler_placeholder": "Write your warning here",
"confirmation_modal.cancel": "Cancel",
"confirmations.block.block_and_report": "Block & Report",
"confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Hide entire domain",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"confirmations.mute.confirm": "Mute",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"donate": "Donate",
"edit_profile.fields.avatar_label": "Avatar",
"edit_profile.fields.bio_label": "Bio",
"edit_profile.fields.bot_label": "This is a bot account",
"edit_profile.fields.display_name_label": "Display name",
"edit_profile.fields.header_label": "Header",
"edit_profile.fields.locked_label": "Lock account",
"edit_profile.fields.meta_fields.content_placeholder": "Content",
"edit_profile.fields.meta_fields.label_placeholder": "Label",
"edit_profile.fields.meta_fields_label": "Profile metadata",
"edit_profile.hints.avatar": "PNG, GIF or JPG. At most 2 MB. Will be downscaled to 400x400px",
"edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored",
"edit_profile.hints.header": "PNG, GIF or JPG. At most 2 MB. Will be downscaled to 1500x500px",
"edit_profile.hints.locked": "Requires you to manually approve followers",
"edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile",
"edit_profile.save": "Save",
"embed.instructions": "Embed this post on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.custom": "Custom",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
"emoji_button.label": "Insert emoji",
"emoji_button.nature": "Nature",
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Objects",
"emoji_button.people": "People",
"emoji_button.recent": "Frequently used",
"emoji_button.search": "Search...",
"emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_timeline": "No posts here!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.blocks": "You haven\"t blocked any users yet.",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don\"t have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no hidden domains yet.",
"empty_column.favourited_statuses": "You don\"t have any liked posts yet. When you like one, it will show up here.",
"empty_column.favourites": "No one has liked this post yet. When someone does, they will show up here.",
"empty_column.filters": "You haven\"t created any muted words yet.",
"empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.",
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
"empty_column.home.local_tab": "the {site_title} tab",
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
"empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven\"t muted any users yet.",
"empty_column.notifications": "You don\"t have any notifications yet. Interact with others to start the conversation.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka 'servers'). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don\"t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.",
"fediverse_tab.explanation_box.title": "What is the Fediverse?",
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"getting_started.heading": "Getting started",
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.members.empty": "This group does not has any members.",
"group.removed_accounts.empty": "This group does not has any removed accounts.",
"groups.card.join": "Join",
"groups.card.members": "Members",
"groups.card.roles.admin": "You\"re an admin",
"groups.card.roles.member": "You\"re a member",
"groups.card.view": "View",
"groups.create": "Create group",
"groups.form.coverImage": "Upload new banner image (optional)",
"groups.form.coverImageChange": "Banner image selected",
"groups.form.create": "Create group",
"groups.form.description": "Description",
"groups.form.title": "Title",
"groups.form.update": "Update group",
"groups.removed_accounts": "Removed Accounts",
"groups.tab_admin": "Manage",
"groups.tab_featured": "Featured",
"groups.tab_member": "Member",
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
"home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show reposts",
"home.column_settings.show_replies": "Show replies",
"home_column.lists": "Lists",
"home_column_header.fediverse": "Fediverse",
"home_column_header.home": "Home",
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
"keyboard_shortcuts.back": "to navigate back",
"keyboard_shortcuts.blocked": "to open blocked users list",
"keyboard_shortcuts.boost": "to repost",
"keyboard_shortcuts.column": "to focus a post in one of the columns",
"keyboard_shortcuts.compose": "to focus the compose textarea",
"keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "to move down in the list",
"keyboard_shortcuts.enter": "to open post",
"keyboard_shortcuts.favourite": "to like",
"keyboard_shortcuts.favourites": "to open likes list",
"keyboard_shortcuts.heading": "Keyboard shortcuts",
"keyboard_shortcuts.home": "to open home timeline",
"keyboard_shortcuts.hotkey": "Hotkey",
"keyboard_shortcuts.legend": "to display this legend",
"keyboard_shortcuts.mention": "to mention author",
"keyboard_shortcuts.muted": "to open muted users list",
"keyboard_shortcuts.my_profile": "to open your profile",
"keyboard_shortcuts.notifications": "to open notifications column",
"keyboard_shortcuts.pinned": "to open pinned posts list",
"keyboard_shortcuts.profile": "to open author\"s profile",
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.start": "to open 'get started' column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
"keyboard_shortcuts.toot": "to start a new post",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "to move up in the list",
"lightbox.close": "Close",
"lightbox.next": "Next",
"lightbox.previous": "Previous",
"lightbox.view_context": "View context",
"list.click_to_add": "Click here to add people",
"list_adder.header_title": "Add or Remove from Lists",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.delete": "Delete list",
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.new.create": "Add list",
"lists.new.create_title": "Create",
"lists.new.save_title": "Save Title",
"lists.new.title_placeholder": "New list title",
"lists.search": "Search among people you follow",
"lists.subheading": "Your lists",
"lists.view_all": "View all lists",
"loading_indicator.label": "Loading...",
"login.fields.password_placeholder": "Password",
"login.fields.username_placeholder": "Username",
"login.log_in": "Log in",
"login.reset_password_hint": "Trouble logging in?",
"media_gallery.toggle_visible": "Toggle visibility",
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.",
"morefollows.following_label": "…and {count} more {count, plural, one {follow} other {follows}} on remote sites.",
"mute_modal.hide_notifications": "Hide notifications from this user?",
"navigation_bar.admin_settings": "Admin settings",
"navigation_bar.soapbox_config": "Soapbox config",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.compose": "Compose new post",
"navigation_bar.direct": "Direct messages",
"navigation_bar.discover": "Discover",
"navigation_bar.domain_blocks": "Hidden domains",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.favourites": "Likes",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "About this server",
"navigation_bar.keyboard_shortcuts": "Hotkeys",
"navigation_bar.lists": "Lists",
"navigation_bar.logout": "Logout",
"navigation_bar.messages": "Messages",
"navigation_bar.mutes": "Muted users",
"navigation_bar.personal": "Personal",
"navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.security": "Security",
"notification.emoji_react": "{name} reacted to your post",
"notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} reposted your post",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.favourite": "Likes:",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show": "Show",
"notifications.column_settings.follow": "New followers:",
"notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.poll": "Poll results:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.reblog": "Reposts:",
"notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound",
"notifications.filter.all": "All",
"notifications.filter.boosts": "Reposts",
"notifications.filter.favourites": "Likes",
"notifications.filter.follows": "Follows",
"notifications.filter.mentions": "Mentions",
"notifications.filter.polls": "Poll results",
"notifications.group": "{count} notifications",
"notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}",
"pinned_statuses.none": "No pins to show.",
"poll.closed": "Closed",
"poll.refresh": "Refresh",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"preferences.fields.auto_play_gif_label": "Auto-play animated GIFs",
"preferences.fields.boost_modal_label": "Show confirmation dialog before reposting",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post",
"preferences.fields.demetricator_label": "Use Demetricator",
"preferences.fields.dyslexic_font_label": "Dyslexic mode",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Language",
"preferences.fields.privacy_label": "Post privacy",
"preferences.fields.reduce_motion_label": "Reduce motion in animations",
"preferences.fields.system_font_label": "Use system\"s default font",
"preferences.fields.theme_label": "Theme",
"preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone",
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.privacy_followers_only": "Only show to followers",
"preferences.hints.privacy_public": "Everyone can see",
"preferences.hints.privacy_unlisted": "Everyone can see, but not listed on public timelines",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",
"preferences.options.theme_dark": "Dark",
"preferences.options.theme_light": "Light",
"privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Post to public timelines",
"privacy.public.short": "Public",
"privacy.unlisted.long": "Do not post to public timelines",
"privacy.unlisted.short": "Unlisted",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
"registration.agreement": "I agree to the {tos}.",
"registration.fields.confirm_placeholder": "Password (again)",
"registration.fields.email_placeholder": "E-Mail address",
"registration.fields.password_placeholder": "Password",
"registration.fields.username_placeholder": "Username",
"registration.lead": "With an account on {instance} you\"ll be able to follow people on any server in the fediverse.",
"registration.sign_up": "Sign up",
"registration.tos": "Terms of Service",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"reply_indicator.cancel": "Cancel",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",
"report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
"report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting {target}",
"search.placeholder": "Search",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.full_text": "Simple text returns posts you have written, favorited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "post",
"search_popout.tips.user": "user",
"search_results.accounts": "People",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Posts",
"search_results.top": "Top",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"security.fields.email.label": "Email address",
"security.fields.new_password.label": "New password",
"security.fields.old_password.label": "Current password",
"security.fields.password.label": "Password",
"security.fields.password_confirmation.label": "New password (again)",
"security.headers.tokens": "Sessions",
"security.headers.update_email": "Change Email",
"security.headers.update_password": "Change Password",
"security.submit": "Save changes",
"security.tokens.revoke": "Revoke",
"security.update_email.fail": "Update email failed.",
"security.update_email.success": "Email successfully updated.",
"security.update_password.fail": "Update password failed.",
"security.update_password.success": "Password successfully updated.",
"signup_panel.subtitle": "Sign up now to discuss.",
"signup_panel.title": "New to {site_title}?",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this post in the moderation interface",
"status.block": "Block @{name}",
"status.cancel_reblog_private": "Un-repost",
"status.cannot_reblog": "This post cannot be reposted",
"status.copy": "Copy link to post",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
"status.embed": "Embed",
"status.favourite": "Like",
"status.filtered": "Filtered",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
"status.read_more": "Read more",
"status.reblog": "Repost",
"status.reblog_private": "Repost to original audience",
"status.reblogged_by": "{name} reposted",
"status.reblogs.empty": "No one has reposted this post yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",
"status.remove_account_from_group": "Remove account from group",
"status.remove_post_from_group": "Remove post from group",
"status.reply": "Reply",
"status.replyAll": "Reply to thread",
"status.report": "Report @{name}",
"status.sensitive_warning": "Sensitive content",
"status.share": "Share",
"status.show_less": "Show less",
"status.show_less_all": "Show less for all",
"status.show_more": "Show more",
"status.show_more_all": "Show more for all",
"status.show_thread": "Show thread",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
"suggestions.dismiss": "Dismiss suggestion",
"tabs_bar.apps": "Apps",
"tabs_bar.home": "Home",
"tabs_bar.news": "News",
"tabs_bar.notifications": "Notifications",
"tabs_bar.post": "Post",
"tabs_bar.search": "Search",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.title": "Trends",
"ui.beforeunload": "Your draft will be lost if you leave.",
"unauthorized_modal.footer": "Already have an account? {login}.",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media attachment",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.description": "Describe for the visually impaired",
"upload_form.focus": "Change preview",
"upload_form.undo": "Delete",
"upload_progress.label": "Uploading...",
"video.close": "Close video",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",
"video.hide": "Hide video",
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound",
"who_to_follow.title": "Who To Follow"
},
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.block": "Block @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.blocked": "Blocked",
"account.direct": "Direct message @{name}",
"account.domain_blocked": "Domain hidden",
"account.edit_profile": "Edit profile",
"account.endorse": "Feature on profile",
"account.follow": "Follow",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.follows": "Follows",
"account.follows.empty": "This user doesn\"t follow anyone yet.",
"account.follows_you": "Follows you",
"account.hide_reblogs": "Hide reposts from @{name}",
"account.link_verified_on": "Ownership of this link was checked on {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.login": "Log in",
"account.media": "Media",
"account.member_since": "Member since {date}",
"account.mention": "Mention",
"account.message": "Message",
"account.moved_to": "{name} has moved to:",
"account.mute": "Mute @{name}",
"account.mute_notifications": "Mute notifications from @{name}",
"account.muted": "Muted",
"account.posts": "Posts",
"account.posts_with_replies": "Posts and replies",
"account.profile": "Profile",
"account.register": "Sign up",
"account.report": "Report @{name}",
"account.requested": "Awaiting approval. Click to cancel follow request",
"account.share": "Share @{name}\"s profile",
"account.show_reblogs": "Show reposts from @{name}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unhide {domain}",
"account.unendorse": "Don\"t feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_gallery.none": "No media to show.",
"alert.unexpected.message": "An unexpected error occurred.",
"alert.unexpected.title": "Oops!",
"audio.close": "Close audio",
"audio.expand": "Expand audio",
"audio.hide": "Hide audio",
"audio.mute": "Mute",
"audio.pause": "Pause",
"audio.play": "Play",
"audio.unmute": "Unmute",
"boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.community": "Local timeline",
"column.direct": "Direct messages",
"column.domain_blocks": "Hidden domains",
"column.edit_profile": "Edit profile",
"column.filters": "Muted words",
"column.follow_requests": "Follow requests",
"column.groups": "Groups",
"column.home": "Home",
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.notifications": "Notifications",
"column.preferences": "Preferences",
"column.public": "Federated timeline",
"column.security": "Security",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",
"column_header.show_settings": "Show settings",
"column_subheading.settings": "Settings",
"community.column_settings.media_only": "Media Only",
"compose_form.direct_message_warning": "This post will only be sent to the mentioned users.",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.hashtag_warning": "This post won\"t be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.placeholder": "What\"s on your mind?",
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.poll.type.hint": "Click to toggle poll type. Radio button (default) is single. Checkbox is multiple.",
"compose_form.publish": "Publish",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.hide": "Mark media as sensitive",
"compose_form.sensitive.marked": "Media is marked as sensitive",
"compose_form.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden",
"compose_form.spoiler_placeholder": "Write your warning here",
"confirmation_modal.cancel": "Cancel",
"confirmations.block.block_and_report": "Block & Report",
"confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Hide entire domain",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"confirmations.mute.confirm": "Mute",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"donate": "Donate",
"edit_profile.fields.avatar_label": "Avatar",
"edit_profile.fields.bio_label": "Bio",
"edit_profile.fields.bot_label": "This is a bot account",
"edit_profile.fields.display_name_label": "Display name",
"edit_profile.fields.header_label": "Header",
"edit_profile.fields.locked_label": "Lock account",
"edit_profile.fields.meta_fields.content_placeholder": "Content",
"edit_profile.fields.meta_fields.label_placeholder": "Label",
"edit_profile.fields.meta_fields_label": "Profile metadata",
"edit_profile.hints.avatar": "PNG, GIF or JPG. At most 2 MB. Will be downscaled to 400x400px",
"edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored",
"edit_profile.hints.header": "PNG, GIF or JPG. At most 2 MB. Will be downscaled to 1500x500px",
"edit_profile.hints.locked": "Requires you to manually approve followers",
"edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile",
"edit_profile.save": "Save",
"embed.instructions": "Embed this post on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.custom": "Custom",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
"emoji_button.label": "Insert emoji",
"emoji_button.nature": "Nature",
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Objects",
"emoji_button.people": "People",
"emoji_button.recent": "Frequently used",
"emoji_button.search": "Search...",
"emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_timeline": "No posts here!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.blocks": "You haven\"t blocked any users yet.",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don\"t have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no hidden domains yet.",
"empty_column.favourited_statuses": "You don\"t have any liked posts yet. When you like one, it will show up here.",
"empty_column.favourites": "No one has liked this post yet. When someone does, they will show up here.",
"empty_column.filters": "You haven\"t created any muted words yet.",
"empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.",
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
"empty_column.home.local_tab": "the {site_title} tab",
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
"empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven\"t muted any users yet.",
"empty_column.notifications": "You don\"t have any notifications yet. Interact with others to start the conversation.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"fediverse_tab.explanation_box.explanation": "{site_title} is part of the Fediverse, a social network made up of thousands of independent social media sites (aka 'servers'). The posts you see here are from 3rd-party servers. You have the freedom to engage with them, or to block any server you don\"t like. Pay attention to the full username after the second @ symbol to know which server a post is from. To see only {site_title} posts, visit {local}.",
"fediverse_tab.explanation_box.title": "What is the Fediverse?",
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"getting_started.heading": "Getting started",
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.members.empty": "This group does not has any members.",
"group.removed_accounts.empty": "This group does not has any removed accounts.",
"groups.card.join": "Join",
"groups.card.members": "Members",
"groups.card.roles.admin": "You\"re an admin",
"groups.card.roles.member": "You\"re a member",
"groups.card.view": "View",
"groups.create": "Create group",
"groups.form.coverImage": "Upload new banner image (optional)",
"groups.form.coverImageChange": "Banner image selected",
"groups.form.create": "Create group",
"groups.form.description": "Description",
"groups.form.title": "Title",
"groups.form.update": "Update group",
"groups.removed_accounts": "Removed Accounts",
"groups.tab_admin": "Manage",
"groups.tab_featured": "Featured",
"groups.tab_member": "Member",
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
"home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show reposts",
"home.column_settings.show_replies": "Show replies",
"home_column.lists": "Lists",
"home_column_header.fediverse": "Fediverse",
"home_column_header.home": "Home",
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
"keyboard_shortcuts.back": "to navigate back",
"keyboard_shortcuts.blocked": "to open blocked users list",
"keyboard_shortcuts.boost": "to repost",
"keyboard_shortcuts.column": "to focus a post in one of the columns",
"keyboard_shortcuts.compose": "to focus the compose textarea",
"keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "to move down in the list",
"keyboard_shortcuts.enter": "to open post",
"keyboard_shortcuts.favourite": "to like",
"keyboard_shortcuts.favourites": "to open likes list",
"keyboard_shortcuts.heading": "Keyboard shortcuts",
"keyboard_shortcuts.home": "to open home timeline",
"keyboard_shortcuts.hotkey": "Hotkey",
"keyboard_shortcuts.legend": "to display this legend",
"keyboard_shortcuts.mention": "to mention author",
"keyboard_shortcuts.muted": "to open muted users list",
"keyboard_shortcuts.my_profile": "to open your profile",
"keyboard_shortcuts.notifications": "to open notifications column",
"keyboard_shortcuts.pinned": "to open pinned posts list",
"keyboard_shortcuts.profile": "to open author\"s profile",
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.start": "to open 'get started' column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
"keyboard_shortcuts.toot": "to start a new post",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "to move up in the list",
"lightbox.close": "Close",
"lightbox.next": "Next",
"lightbox.previous": "Previous",
"lightbox.view_context": "View context",
"list.click_to_add": "Click here to add people",
"list_adder.header_title": "Add or Remove from Lists",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.delete": "Delete list",
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.new.create": "Add list",
"lists.new.create_title": "Create",
"lists.new.save_title": "Save Title",
"lists.new.title_placeholder": "New list title",
"lists.search": "Search among people you follow",
"lists.subheading": "Your lists",
"lists.view_all": "View all lists",
"loading_indicator.label": "Loading...",
"login.fields.password_placeholder": "Password",
"login.fields.username_placeholder": "Username",
"login.log_in": "Log in",
"login.reset_password_hint": "Trouble logging in?",
"media_gallery.toggle_visible": "Toggle visibility",
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"morefollows.followers_label": "…and {count} more {count, plural, one {follower} other {followers}} on remote sites.",
"morefollows.following_label": "…and {count} more {count, plural, one {follow} other {follows}} on remote sites.",
"mute_modal.hide_notifications": "Hide notifications from this user?",
"navigation_bar.admin_settings": "Admin settings",
"navigation_bar.soapbox_config": "Soapbox config",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.compose": "Compose new post",
"navigation_bar.direct": "Direct messages",
"navigation_bar.discover": "Discover",
"navigation_bar.domain_blocks": "Hidden domains",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.favourites": "Likes",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "About this server",
"navigation_bar.keyboard_shortcuts": "Hotkeys",
"navigation_bar.lists": "Lists",
"navigation_bar.logout": "Logout",
"navigation_bar.messages": "Messages",
"navigation_bar.mutes": "Muted users",
"navigation_bar.personal": "Personal",
"navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.security": "Security",
"notification.emoji_react": "{name} reacted to your post",
"notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} reposted your post",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.favourite": "Likes:",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show": "Show",
"notifications.column_settings.follow": "New followers:",
"notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.poll": "Poll results:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.reblog": "Reposts:",
"notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound",
"notifications.filter.all": "All",
"notifications.filter.boosts": "Reposts",
"notifications.filter.favourites": "Likes",
"notifications.filter.follows": "Follows",
"notifications.filter.mentions": "Mentions",
"notifications.filter.polls": "Poll results",
"notifications.group": "{count} notifications",
"notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}",
"pinned_statuses.none": "No pins to show.",
"poll.closed": "Closed",
"poll.refresh": "Refresh",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"preferences.fields.auto_play_gif_label": "Auto-play animated GIFs",
"preferences.fields.boost_modal_label": "Show confirmation dialog before reposting",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post",
"preferences.fields.demetricator_label": "Use Demetricator",
"preferences.fields.dyslexic_font_label": "Dyslexic mode",
"preferences.fields.expand_spoilers_label": "Always expand posts marked with content warnings",
"preferences.fields.language_label": "Language",
"preferences.fields.privacy_label": "Post privacy",
"preferences.fields.reduce_motion_label": "Reduce motion in animations",
"preferences.fields.system_font_label": "Use system\"s default font",
"preferences.fields.theme_label": "Theme",
"preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone",
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.privacy_followers_only": "Only show to followers",
"preferences.hints.privacy_public": "Everyone can see",
"preferences.hints.privacy_unlisted": "Everyone can see, but not listed on public timelines",
"preferences.options.privacy_followers_only": "Followers-only",
"preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted",
"preferences.options.theme_dark": "Dark",
"preferences.options.theme_light": "Light",
"privacy.change": "Adjust post privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Post to public timelines",
"privacy.public.short": "Public",
"privacy.unlisted.long": "Do not post to public timelines",
"privacy.unlisted.short": "Unlisted",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
"registration.agreement": "I agree to the {tos}.",
"registration.fields.confirm_placeholder": "Password (again)",
"registration.fields.email_placeholder": "E-Mail address",
"registration.fields.password_placeholder": "Password",
"registration.fields.username_placeholder": "Username",
"registration.lead": "With an account on {instance} you\"ll be able to follow people on any server in the fediverse.",
"registration.sign_up": "Sign up",
"registration.tos": "Terms of Service",
"registration.reason": "Reason for Joining",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"reply_indicator.cancel": "Cancel",
"report.block": "Block {target}",
"report.block_hint": "Do you also want to block this account?",
"report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
"report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting {target}",
"search.placeholder": "Search",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.full_text": "Simple text returns posts you have written, favorited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "post",
"search_popout.tips.user": "user",
"search_results.accounts": "People",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Posts",
"search_results.top": "Top",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"security.fields.email.label": "Email address",
"security.fields.new_password.label": "New password",
"security.fields.old_password.label": "Current password",
"security.fields.password.label": "Password",
"security.fields.password_confirmation.label": "New password (again)",
"security.headers.tokens": "Sessions",
"security.headers.update_email": "Change Email",
"security.headers.update_password": "Change Password",
"security.submit": "Save changes",
"security.tokens.revoke": "Revoke",
"security.update_email.fail": "Update email failed.",
"security.update_email.success": "Email successfully updated.",
"security.update_password.fail": "Update password failed.",
"security.update_password.success": "Password successfully updated.",
"signup_panel.subtitle": "Sign up now to discuss.",
"signup_panel.title": "New to {site_title}?",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this post in the moderation interface",
"status.block": "Block @{name}",
"status.cancel_reblog_private": "Un-repost",
"status.cannot_reblog": "This post cannot be reposted",
"status.copy": "Copy link to post",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
"status.embed": "Embed",
"status.favourite": "Like",
"status.filtered": "Filtered",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
"status.read_more": "Read more",
"status.reblog": "Repost",
"status.reblog_private": "Repost to original audience",
"status.reblogged_by": "{name} reposted",
"status.reblogs.empty": "No one has reposted this post yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",
"status.remove_account_from_group": "Remove account from group",
"status.remove_post_from_group": "Remove post from group",
"status.reply": "Reply",
"status.replyAll": "Reply to thread",
"status.report": "Report @{name}",
"status.sensitive_warning": "Sensitive content",
"status.share": "Share",
"status.show_less": "Show less",
"status.show_less_all": "Show less for all",
"status.show_more": "Show more",
"status.show_more_all": "Show more for all",
"status.show_thread": "Show thread",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
"suggestions.dismiss": "Dismiss suggestion",
"tabs_bar.apps": "Apps",
"tabs_bar.home": "Home",
"tabs_bar.news": "News",
"tabs_bar.notifications": "Notifications",
"tabs_bar.post": "Post",
"tabs_bar.search": "Search",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.title": "Trends",
"ui.beforeunload": "Your draft will be lost if you leave.",
"unauthorized_modal.footer": "Already have an account? {login}.",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media attachment",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.description": "Describe for the visually impaired",
"upload_form.focus": "Change preview",
"upload_form.undo": "Delete",
"upload_progress.label": "Uploading...",
"video.close": "Close video",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",
"video.hide": "Hide video",
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound",
"who_to_follow.title": "Who To Follow"
}

Wyświetl plik

@ -0,0 +1,57 @@
{
"acct": "lain@lain.com",
"avatar": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg",
"avatar_static": "https://lain.com/media/0b7eb9eee68845f94dd1c7bd10d9bae90a2420cf6704de5485179c441eb0e6e0.jpg",
"bot": false,
"created_at": "2020-01-10T17:30:10.000Z",
"display_name": "Avalokiteshvara",
"emojis": [],
"fields": [],
"followers_count": 807,
"following_count": 223,
"header": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png",
"header_static": "https://lain.com/media/fb0768dfa331ad730de32189d2e89b99fe51eebe1782a16cf076d7693394e4f9.png",
"id": "9v5bqYwY2jfmvPNhTM",
"locked": false,
"note": "No more hiding",
"pleroma": {
"background_image": null,
"confirmation_pending": true,
"deactivated": false,
"hide_favorites": true,
"hide_followers": false,
"hide_followers_count": false,
"hide_follows": false,
"hide_follows_count": false,
"is_admin": false,
"is_moderator": false,
"relationship": {
"blocked_by": false,
"blocking": false,
"domain_blocking": false,
"endorsed": false,
"followed_by": true,
"following": true,
"id": "9v5bqYwY2jfmvPNhTM",
"muting": false,
"muting_notifications": false,
"requested": false,
"showing_reblogs": true,
"subscribing": false
},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [],
"note": "No more hiding",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 21107,
"url": "https://lain.com/users/lain",
"username": "lain"
}

Wyświetl plik

@ -0,0 +1,18 @@
{
"notifications": {
"last_read_id": "35098814",
"version": 361,
"updated_at": "2019-11-26T22:37:25.239Z",
"pleroma": {
"unread_count": 3
}
},
"home": {
"last_read_id": "103206604258487607",
"version": 468,
"updated_at": "2019-11-26T22:37:25.235Z",
"pleroma": {
"unread_count": 32
}
}
}

Wyświetl plik

@ -0,0 +1,250 @@
{
"account": {
"acct": "crockwave",
"avatar": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png",
"avatar_static": "https://media.gleasonator.com/d6dcd2779bdb63ef8b8a5f127743f5ad757046943c4b4a8867215c15a72c5e55.png",
"bot": false,
"created_at": "2020-02-26T16:31:25.000Z",
"display_name": "Curtis Rock",
"emojis": [],
"fields": [
{
"name": "Web Site/Book",
"value": "<a href=\"https://teci.world/a-users-guide-to-the-great-awakening\" rel=\"ugc\">https://teci.world/a-users-guide-to-the-great-awakening</a>"
},
{
"name": "Gab",
"value": "<a href=\"https://gab.com/crockwave\" rel=\"ugc\">https://gab.com/crockwave</a>"
},
{
"name": "Twitter",
"value": "<a href=\"https://twitter.com/GAP_Great\" rel=\"ugc\">https://twitter.com/GAP_Great</a>"
},
{
"name": "MeWe",
"value": "<a href=\"https://mewe.com/i/curtisrock\" rel=\"ugc\">https://mewe.com/i/curtisrock</a>"
}
],
"followers_count": 13,
"following_count": 11,
"header": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png",
"header_static": "https://media.gleasonator.com/82720af49afb0daa2a700f4371db9848cd7efb38eaca09d47898e8e7b527e0b4.png",
"id": "9v5c6xSEgAi3Zu1Lv6",
"locked": false,
"note": "soapbox development team test test2",
"pleroma": {
"background_image": null,
"confirmation_pending": false,
"deactivated": false,
"hide_favorites": true,
"hide_followers": false,
"hide_followers_count": false,
"hide_follows": false,
"hide_follows_count": false,
"is_admin": false,
"is_moderator": false,
"relationship": {
"blocked_by": false,
"blocking": false,
"domain_blocking": false,
"endorsed": false,
"followed_by": true,
"following": true,
"id": "9v5c6xSEgAi3Zu1Lv6",
"muting": false,
"muting_notifications": false,
"requested": false,
"showing_reblogs": true,
"subscribing": false
},
"skip_thread_containment": false,
"tags": []
},
"source": {
"fields": [
{
"name": "Web Site/Book",
"value": "https://teci.world/a-users-guide-to-the-great-awakening"
},
{
"name": "Gab",
"value": "https://gab.com/crockwave"
},
{
"name": "Twitter",
"value": "https://twitter.com/GAP_Great"
},
{
"name": "MeWe",
"value": "https://mewe.com/i/curtisrock"
}
],
"note": "soapbox development team test test2",
"pleroma": {
"actor_type": "Person",
"discoverable": false
},
"sensitive": false
},
"statuses_count": 212,
"url": "https://gleasonator.com/users/crockwave",
"username": "crockwave"
},
"created_at": "2020-06-10T02:51:05.000Z",
"id": "10743",
"pleroma": {
"is_seen": true
},
"status": {
"account": {
"acct": "alex",
"avatar": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.jpg",
"avatar_static": "https://media.gleasonator.com/accounts/avatars/000/000/001/original/1a630e4c4c64c948.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": 474,
"following_count": 1083,
"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. <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>. Boosts ≠ endorsements.",
"pleroma": {
"allow_following_move": true,
"background_image": null,
"confirmation_pending": false,
"deactivated": false,
"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": {
"followers": true,
"follows": true,
"non_followers": true,
"non_follows": true,
"privacy_option": false
},
"relationship": {
"blocked_by": false,
"blocking": false,
"domain_blocking": false,
"endorsed": false,
"followed_by": false,
"following": false,
"id": "9v5bmRalQvjOy0ECcC",
"muting": false,
"muting_notifications": false,
"requested": false,
"showing_reblogs": true,
"subscribing": false
},
"skip_thread_containment": false,
"tags": [],
"unread_conversation_count": 25
},
"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. #vegan #freeculture #atheist #antiporn #gendercritical. Boosts ≠ endorsements.",
"pleroma": {
"actor_type": "Person",
"discoverable": false,
"no_rich_text": false,
"show_role": true
},
"privacy": "public",
"sensitive": false
},
"statuses_count": 4857,
"url": "https://gleasonator.com/users/alex",
"username": "alex"
},
"application": {
"name": "Web",
"website": null
},
"bookmarked": false,
"card": null,
"content": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.<br/><br/>Pleroma FE doesn&#39;t seem to report coverage, but I suspect it&#39;s better than both of these combined.<br/><br/>I don&#39;t know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus.",
"created_at": "2020-06-10T01:29:20.000Z",
"emojis": [],
"favourited": false,
"favourites_count": 4,
"id": "9vvNxoo5EFbbnfdXQu",
"in_reply_to_account_id": null,
"in_reply_to_id": null,
"language": null,
"media_attachments": [],
"mentions": [],
"muted": false,
"pinned": false,
"pleroma": {
"content": {
"text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
},
"conversation_id": 1168229,
"direct_conversation_id": null,
"emoji_reactions": [],
"expires_at": null,
"in_reply_to_account_acct": null,
"local": 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": [],
"uri": "https://gleasonator.com/objects/aa294f83-5a6c-4d2b-ba20-2b8bf69a82ba",
"url": "https://gleasonator.com/notice/9vvNxoo5EFbbnfdXQu",
"visibility": "public"
},
"type": "favourite"
}

Wyświetl plik

@ -189,7 +189,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
"text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
},
"conversation_id": 1168229,
"direct_conversation_id": null,
@ -198,7 +198,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -439,7 +439,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
"text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
},
"conversation_id": 1168229,
"direct_conversation_id": null,
@ -448,7 +448,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -668,7 +668,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
"text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
},
"conversation_id": 1168229,
"direct_conversation_id": null,
@ -677,7 +677,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -918,7 +918,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex you dont need to write tests when your community builds a social framework that flags people not using your software as bad people. They have no choice but to eat it up bugs in all"
"text_plain": "@alex you dont need to write tests when your community builds a social framework that flags people not using your software as bad people. They have no choice but to eat it up bugs in all"
},
"conversation_id": 1168229,
"direct_conversation_id": null,
@ -933,7 +933,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -1140,7 +1140,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
"text_plain": "At 10.72% test coverage, Soapbox FE now has 2x more than MastoFE, which only has 4.21%.Pleroma FE doesn't seem to report coverage, but I suspect it's better than both of these combined.I don't know how Mastodon got away with not writing tests for so long, but I feel like there could be an entire release dedicated only to going back and writing missing tests... jesus."
},
"conversation_id": 1168229,
"direct_conversation_id": null,
@ -1149,7 +1149,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -1316,7 +1316,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex The first step to getting people to commit inhumane acts is to convince them that they are incapable of doing so."
"text_plain": "@alex The first step to getting people to commit inhumane acts is to convince them that they are incapable of doing so."
},
"conversation_id": 1117237,
"direct_conversation_id": null,
@ -1325,7 +1325,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -1532,7 +1532,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881"
"text_plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881"
},
"conversation_id": 1149840,
"direct_conversation_id": null,
@ -1541,7 +1541,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -1714,7 +1714,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex @crockwave well, the preliminary state of the issue is not \"no it's not happening\", but rather \" let's find a way to make this possible in the generic case\", so we'll see what can be done. I think a wrapper is definitely possible but also probably expensive to maintain."
"text_plain": "@alex @crockwave well, the preliminary state of the issue is not \"no it's not happening\", but rather \" let's find a way to make this possible in the generic case\", so we'll see what can be done. I think a wrapper is definitely possible but also probably expensive to maintain."
},
"conversation_id": 1149840,
"direct_conversation_id": null,
@ -1723,7 +1723,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -1971,7 +1971,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@crockwave I think we can achieve both those things (funding goal on homepage, Patron badge) by exposing API endpoints from fosspay.I agree a wrapper service could provide many future benefits, but I've done that exact thing before (see: Mastodon Engine: https://gitlab.com/soapbox-pub/mastodon-engine ) and it was experimental, back-breaking, and required a DEEP understanding of the underlying tech. I just don't have the time or energy to dive that deep into Phoenix right now, especially not having a strong grasp of Elixir."
"text_plain": "@crockwave I think we can achieve both those things (funding goal on homepage, Patron badge) by exposing API endpoints from fosspay.I agree a wrapper service could provide many future benefits, but I've done that exact thing before (see: Mastodon Engine: https://gitlab.com/soapbox-pub/mastodon-engine ) and it was experimental, back-breaking, and required a DEEP understanding of the underlying tech. I just don't have the time or energy to dive that deep into Phoenix right now, especially not having a strong grasp of Elixir."
},
"conversation_id": 1149840,
"direct_conversation_id": null,
@ -1980,7 +1980,7 @@
"in_reply_to_account_acct": "crockwave",
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -2188,7 +2188,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.Working on automated tests today."
"text_plain": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.Working on automated tests today."
},
"conversation_id": 1150963,
"direct_conversation_id": null,
@ -2203,7 +2203,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -2438,7 +2438,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex Can you create some automated test templates, then outsource the drudgerous portion of the effort to your support team?"
"text_plain": "@alex Can you create some automated test templates, then outsource the drudgerous portion of the effort to your support team?"
},
"conversation_id": 1150963,
"direct_conversation_id": null,
@ -2447,7 +2447,7 @@
"in_reply_to_account_acct": "alex",
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -2640,7 +2640,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex it's good to be thorough, and is useful if that code ever changes down the line."
"text_plain": "@alex it's good to be thorough, and is useful if that code ever changes down the line."
},
"conversation_id": 1147390,
"direct_conversation_id": null,
@ -2649,7 +2649,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -2834,7 +2834,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex whatever you do: KEEP GOING ALMOST THERE YOU CAN DO IT!"
"text_plain": "@alex whatever you do: KEEP GOING ALMOST THERE YOU CAN DO IT!"
},
"conversation_id": 1150963,
"direct_conversation_id": null,
@ -2843,7 +2843,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -3063,7 +3063,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.Working on automated tests today."
"text_plain": "Been feeling like I'm losing my mind a bit the past few days. I feel like I'm pushing heavy weights up a steep hill and it's never-ending. Can't wait to get to the top so I can sail down.Working on automated tests today."
},
"conversation_id": 1150963,
"direct_conversation_id": null,
@ -3078,7 +3078,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -3271,7 +3271,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex You're close to a finish line 🌈"
"text_plain": "@alex You're close to a finish line 🌈"
},
"conversation_id": 1150963,
"direct_conversation_id": null,
@ -3280,7 +3280,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -3515,7 +3515,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex I think a wrapper service can provide many future benefits, if the complications are manageable. It would allow you to stay well ahead of the features development curve. There is also great value in displaying funding goals status on the home page. The Patron tag is also highly valuable from a social community standpoint."
"text_plain": "@alex I think a wrapper service can provide many future benefits, if the complications are manageable. It would allow you to stay well ahead of the features development curve. There is also great value in displaying funding goals status on the home page. The Patron tag is also highly valuable from a social community standpoint."
},
"conversation_id": 1149840,
"direct_conversation_id": null,
@ -3524,7 +3524,7 @@
"in_reply_to_account_acct": "alex",
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -3765,7 +3765,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881"
"text_plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881"
},
"conversation_id": 1149840,
"direct_conversation_id": null,
@ -3774,7 +3774,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -3995,7 +3995,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@lain Yeah I have no clue what the reasoning was for that one. 😆 It's crazy how \"words\" are such an ongoing issue in FOSS.A seasoned programmer once told me she's had the deepest programming debates about \"naming things.\" It's true that names are important in programming, but at the end of the day it's just code."
"text_plain": "@lain Yeah I have no clue what the reasoning was for that one. 😆 It's crazy how \"words\" are such an ongoing issue in FOSS.A seasoned programmer once told me she's had the deepest programming debates about \"naming things.\" It's true that names are important in programming, but at the end of the day it's just code."
},
"conversation_id": 1122858,
"direct_conversation_id": null,
@ -4004,7 +4004,7 @@
"in_reply_to_account_acct": "lain@lain.com",
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -4191,7 +4191,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "@alex @lain The Peter Principle has a chapter on this.The less significant the decision the more people feel qualified to have an opinion on it."
"text_plain": "@alex @lain The Peter Principle has a chapter on this.The less significant the decision the more people feel qualified to have an opinion on it."
},
"conversation_id": 1122858,
"direct_conversation_id": null,
@ -4200,7 +4200,7 @@
"in_reply_to_account_acct": "alex",
"local": false,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},
@ -4431,7 +4431,7 @@
"pinned": false,
"pleroma": {
"content": {
"text/plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881"
"text_plain": "Pleroma Recurring Donations update: looking into other solutions, possibly working from fosspay instead: https://git.pleroma.social/pleroma/pleroma/-/issues/1853#note_62881"
},
"conversation_id": 1149840,
"direct_conversation_id": null,
@ -4440,7 +4440,7 @@
"in_reply_to_account_acct": null,
"local": true,
"spoiler_text": {
"text/plain": ""
"text_plain": ""
},
"thread_muted": false
},

Wyświetl plik

@ -0,0 +1,14 @@
{
"showing_reblogs": true,
"followed_by": false,
"subscribing": false,
"blocked_by": false,
"requested": false,
"domain_blocking": false,
"following": false,
"endorsed": false,
"blocking": true,
"muting": false,
"id": "9vMAje101ngtjlMj7w",
"muting_notifications": true
}

Wyświetl plik

@ -0,0 +1,40 @@
{
"logo": "blob:http://localhost:3036/0cdfa863-6889-4199-b870-4942cedd364f",
"banner": "blob:http://localhost:3036/a835afed-6078-45bd-92b4-7ffd858c3eca",
"brandColor": "#254f92",
"customCss": [
"/instance/static/custom.css"
],
"promoPanel": {
"items": [
{
"icon": "globe",
"text": "blog",
"url": "https://teci.world/blog"
},
{
"icon": "globe",
"text": "book",
"url": "https://teci.world/book"
}
]
},
"extensions": {
"patron": false
},
"defaultSettings": {
"autoPlayGif": false
},
"navlinks": {
"homeFooter": [
{
"title": "about",
"url": "/instance/about/index.html"
},
{
"title": "tos",
"url": "/instance/about/tos.html"
}
]
}
}

Wyświetl plik

@ -0,0 +1,8 @@
{
"access_token": "UVBP2e17b4pTpb_h8fImIm3F5a66IBVb-JkyZHs4gLE",
"expires_in": 600,
"me": "https://social.teci.world/users/curtis",
"refresh_token": "c2DpbVxYZBJDogNn-VBNFES72yXPNUYQCv0CrXGOplY",
"scope": "read write follow push admin",
"token_type": "Bearer"
}

Wyświetl plik

@ -0,0 +1,35 @@
import api from '../api';
export const ADMIN_CONFIG_UPDATE_REQUEST = 'ADMIN_CONFIG_UPDATE_REQUEST';
export const ADMIN_CONFIG_UPDATE_SUCCESS = 'ADMIN_CONFIG_UPDATE_SUCCESS';
export const ADMIN_CONFIG_UPDATE_FAIL = 'ADMIN_CONFIG_UPDATE_FAIL';
export const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST';
export const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS';
export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL';
export function updateAdminConfig(params) {
return (dispatch, getState) => {
dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST });
return api(getState)
.post('/api/pleroma/admin/config', params)
.then(response => {
dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, config: response.data });
}).catch(error => {
dispatch({ type: ADMIN_CONFIG_UPDATE_FAIL, error });
});
};
}
export function fetchReports(params) {
return (dispatch, getState) => {
dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params });
return api(getState)
.get('/api/pleroma/admin/reports', { params })
.then(({ data }) => {
dispatch({ type: ADMIN_REPORTS_FETCH_SUCCESS, data, params });
}).catch(error => {
dispatch({ type: ADMIN_REPORTS_FETCH_FAIL, error, params });
});
};
}

Wyświetl plik

@ -19,6 +19,10 @@ export const CHANGE_EMAIL_REQUEST = 'CHANGE_EMAIL_REQUEST';
export const CHANGE_EMAIL_SUCCESS = 'CHANGE_EMAIL_SUCCESS';
export const CHANGE_EMAIL_FAIL = 'CHANGE_EMAIL_FAIL';
export const DELETE_ACCOUNT_REQUEST = 'DELETE_ACCOUNT_REQUEST';
export const DELETE_ACCOUNT_SUCCESS = 'DELETE_ACCOUNT_SUCCESS';
export const DELETE_ACCOUNT_FAIL = 'DELETE_ACCOUNT_FAIL';
export const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST';
export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS';
export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL';
@ -108,12 +112,32 @@ export function refreshUserToken() {
};
}
export function otpVerify(code, mfa_token) {
return (dispatch, getState) => {
const app = getState().getIn(['auth', 'app']);
return api(getState, 'app').post('/oauth/mfa/challenge', {
client_id: app.get('client_id'),
client_secret: app.get('client_secret'),
mfa_token: mfa_token,
code: code,
challenge_type: 'totp',
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
}).then(response => {
dispatch(authLoggedIn(response.data));
});
};
}
export function logIn(username, password) {
return (dispatch, getState) => {
return dispatch(createAppAndToken()).then(() => {
return dispatch(createUserToken(username, password));
}).catch(error => {
dispatch(showAlert('Login failed.', 'Invalid username or password.'));
if (error.response.data.error === 'mfa_required') {
throw error;
} else {
dispatch(showAlert('Login failed.', 'Invalid username or password.'));
}
throw error;
});
};
@ -129,15 +153,21 @@ export function logOut() {
export function register(params) {
return (dispatch, getState) => {
const needsConfirmation = getState().getIn(['instance', 'pleroma', 'metadata', 'account_activation_required']);
const needsApproval = getState().getIn(['instance', 'approval_required']);
params.fullname = params.username;
dispatch({ type: AUTH_REGISTER_REQUEST });
return dispatch(createAppAndToken()).then(() => {
return api(getState, 'app').post('/api/v1/accounts', params);
}).then(response => {
dispatch({ type: AUTH_REGISTER_SUCCESS, token: response.data });
dispatch(authLoggedIn(response.data));
return needsConfirmation
? dispatch(showAlert('', 'Check your email for further instructions.'))
: dispatch(fetchMe());
if (needsConfirmation) {
return dispatch(showAlert('', 'Check your email for further instructions.'));
} else if (needsApproval) {
return dispatch(showAlert('', 'Your account has been submitted for approval.'));
} else {
return dispatch(fetchMe());
}
}).catch(error => {
dispatch({ type: AUTH_REGISTER_FAIL, error });
throw error;
@ -183,6 +213,23 @@ export function changeEmail(email, password) {
};
}
export function deleteAccount(password) {
return (dispatch, getState) => {
dispatch({ type: DELETE_ACCOUNT_REQUEST });
return api(getState).post('/api/pleroma/delete_account', {
password,
}).then(response => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: DELETE_ACCOUNT_SUCCESS, response });
dispatch({ type: AUTH_LOGGED_OUT });
dispatch(showAlert('Successfully logged out.', ''));
}).catch(error => {
dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true });
throw error;
});
};
}
export function changePassword(oldPassword, newPassword, confirmation) {
return (dispatch, getState) => {
dispatch({ type: CHANGE_PASSWORD_REQUEST });

Wyświetl plik

@ -0,0 +1,90 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL';
export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST';
export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS';
export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL';
export function fetchBookmarkedStatuses() {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
return;
}
dispatch(fetchBookmarkedStatusesRequest());
api(getState).get('/api/v1/bookmarks').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error));
});
};
};
export function fetchBookmarkedStatusesRequest() {
return {
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
};
};
export function fetchBookmarkedStatusesSuccess(statuses, next) {
return {
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
statuses,
next,
};
};
export function fetchBookmarkedStatusesFail(error) {
return {
type: BOOKMARKED_STATUSES_FETCH_FAIL,
error,
};
};
export function expandBookmarkedStatuses() {
return (dispatch, getState) => {
const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null);
if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
return;
}
dispatch(expandBookmarkedStatusesRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandBookmarkedStatusesFail(error));
});
};
};
export function expandBookmarkedStatusesRequest() {
return {
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
};
};
export function expandBookmarkedStatusesSuccess(statuses, next) {
return {
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
statuses,
next,
};
};
export function expandBookmarkedStatusesFail(error) {
return {
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
error,
};
};

Wyświetl plik

@ -0,0 +1,152 @@
import api from '../api';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { v4 as uuidv4 } from 'uuid';
import { Map as ImmutableMap } from 'immutable';
export const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST';
export const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS';
export const CHATS_FETCH_FAIL = 'CHATS_FETCH_FAIL';
export const CHAT_MESSAGES_FETCH_REQUEST = 'CHAT_MESSAGES_FETCH_REQUEST';
export const CHAT_MESSAGES_FETCH_SUCCESS = 'CHAT_MESSAGES_FETCH_SUCCESS';
export const CHAT_MESSAGES_FETCH_FAIL = 'CHAT_MESSAGES_FETCH_FAIL';
export const CHAT_MESSAGE_SEND_REQUEST = 'CHAT_MESSAGE_SEND_REQUEST';
export const CHAT_MESSAGE_SEND_SUCCESS = 'CHAT_MESSAGE_SEND_SUCCESS';
export const CHAT_MESSAGE_SEND_FAIL = 'CHAT_MESSAGE_SEND_FAIL';
export const CHAT_FETCH_REQUEST = 'CHAT_FETCH_REQUEST';
export const CHAT_FETCH_SUCCESS = 'CHAT_FETCH_SUCCESS';
export const CHAT_FETCH_FAIL = 'CHAT_FETCH_FAIL';
export const CHAT_READ_REQUEST = 'CHAT_READ_REQUEST';
export const CHAT_READ_SUCCESS = 'CHAT_READ_SUCCESS';
export const CHAT_READ_FAIL = 'CHAT_READ_FAIL';
export function fetchChats() {
return (dispatch, getState) => {
dispatch({ type: CHATS_FETCH_REQUEST });
return api(getState).get('/api/v1/pleroma/chats').then(({ data }) => {
dispatch({ type: CHATS_FETCH_SUCCESS, chats: data });
}).catch(error => {
dispatch({ type: CHATS_FETCH_FAIL, error });
});
};
}
export function fetchChatMessages(chatId, maxId = null) {
return (dispatch, getState) => {
dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId, maxId });
return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`, { params: { max_id: maxId } }).then(({ data }) => {
dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, maxId, chatMessages: data });
}).catch(error => {
dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, maxId, error });
});
};
}
export function sendChatMessage(chatId, params) {
return (dispatch, getState) => {
const uuid = `末_${Date.now()}_${uuidv4()}`;
const me = getState().get('me');
dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me });
return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then(({ data }) => {
dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid });
}).catch(error => {
dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid });
});
};
}
export function openChat(chatId) {
return (dispatch, getState) => {
const state = getState();
const panes = getSettings(state).getIn(['chats', 'panes']);
const idx = panes.findIndex(pane => pane.get('chat_id') === chatId);
dispatch(markChatRead(chatId));
if (idx > -1) {
return dispatch(changeSetting(['chats', 'panes', idx, 'state'], 'open'));
} else {
const newPane = ImmutableMap({ chat_id: chatId, state: 'open' });
return dispatch(changeSetting(['chats', 'panes'], panes.push(newPane)));
}
};
}
export function closeChat(chatId) {
return (dispatch, getState) => {
const panes = getSettings(getState()).getIn(['chats', 'panes']);
const idx = panes.findIndex(pane => pane.get('chat_id') === chatId);
if (idx > -1) {
return dispatch(changeSetting(['chats', 'panes'], panes.delete(idx)));
} else {
return false;
}
};
}
export function toggleChat(chatId) {
return (dispatch, getState) => {
const panes = getSettings(getState()).getIn(['chats', 'panes']);
const [idx, pane] = panes.findEntry(pane => pane.get('chat_id') === chatId);
if (idx > -1) {
const state = pane.get('state') === 'minimized' ? 'open' : 'minimized';
if (state === 'open') dispatch(markChatRead(chatId));
return dispatch(changeSetting(['chats', 'panes', idx, 'state'], state));
} else {
return false;
}
};
}
export function toggleMainWindow() {
return (dispatch, getState) => {
const main = getSettings(getState()).getIn(['chats', 'mainWindow']);
const state = main === 'minimized' ? 'open' : 'minimized';
return dispatch(changeSetting(['chats', 'mainWindow'], state));
};
}
export function fetchChat(chatId) {
return (dispatch, getState) => {
dispatch({ type: CHAT_FETCH_REQUEST, chatId });
return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then(({ data }) => {
dispatch({ type: CHAT_FETCH_SUCCESS, chat: data });
}).catch(error => {
dispatch({ type: CHAT_FETCH_FAIL, chatId, error });
});
};
}
export function startChat(accountId) {
return (dispatch, getState) => {
dispatch({ type: CHAT_FETCH_REQUEST, accountId });
return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then(({ data }) => {
dispatch({ type: CHAT_FETCH_SUCCESS, chat: data });
return data;
}).catch(error => {
dispatch({ type: CHAT_FETCH_FAIL, accountId, error });
});
};
}
export function markChatRead(chatId, lastReadId) {
return (dispatch, getState) => {
const chat = getState().getIn(['chats', chatId]);
if (!lastReadId) lastReadId = chat.get('last_message');
if (chat.get('unread') < 1) return;
if (!lastReadId) return;
dispatch({ type: CHAT_READ_REQUEST, chatId, lastReadId });
api(getState).post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }).then(({ data }) => {
dispatch({ type: CHAT_READ_SUCCESS, chat: data, lastReadId });
}).catch(error => {
dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId });
});
};
}

Wyświetl plik

@ -7,12 +7,12 @@ import { useEmoji } from './emojis';
import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer';
import { updateTimeline, dequeueTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { showAlert, showAlertForError } from './alerts';
import { defineMessages } from 'react-intl';
import { openModal, closeModal } from './modal';
import { getSettings } from './settings';
import { getFeatures } from 'soapbox/utils/features';
import { uploadMedia } from './media';
let cancelFetchComposeSuggestionsAccounts;
@ -43,6 +43,7 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
@ -175,6 +176,7 @@ export function submitCompose(routerHistory, group) {
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'privacy']),
content_type: getState().getIn(['compose', 'content_type']),
poll: getState().getIn(['compose', 'poll'], null),
group_id: group ? group.get('id') : null,
}, {
@ -226,11 +228,6 @@ export function uploadCompose(files) {
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
return;
}
dispatch(uploadComposeRequest());
for (const [i, f] of Array.from(files).entries()) {
@ -242,12 +239,14 @@ export function uploadCompose(files) {
// Account for disparity in size of original image and resized data
total += file.size - f.size;
return api(getState).post('/api/v1/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
const onUploadProgress = function({ loaded }) {
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
};
return dispatch(uploadMedia(data, onUploadProgress))
.then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
};
};
@ -495,6 +494,13 @@ export function changeComposeSpoilerness() {
};
};
export function changeComposeContentType(value) {
return {
type: COMPOSE_TYPE_CHANGE,
value,
};
};
export function changeComposeSpoilerText(text) {
return {
type: COMPOSE_SPOILER_TEXT_CHANGE,

Wyświetl plik

@ -61,7 +61,13 @@ export function unblockDomain(domain) {
dispatch(unblockDomainRequest(domain));
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
// Do it both ways for maximum compatibility
const params = {
params: { domain },
data: { domain },
};
api(getState).delete('/api/v1/domain_blocks', params).then(() => {
const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
dispatch(unblockDomainSuccess(domain, accounts));

Wyświetl plik

@ -1,4 +1,5 @@
import api from '../api';
import { showAlert } from 'soapbox/actions/alerts';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
@ -8,6 +9,10 @@ export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST';
export const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS';
export const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL';
export const fetchFilters = () => (dispatch, getState) => {
if (!getState().get('me')) return;
@ -31,13 +36,33 @@ export const fetchFilters = () => (dispatch, getState) => {
}));
};
export function createFilter(params) {
export function createFilter(phrase, expires_at, context, whole_word, irreversible) {
return (dispatch, getState) => {
dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v1/filters', params).then(response => {
return api(getState).post('/api/v1/filters', {
phrase,
context,
irreversible,
whole_word,
expires_at,
}).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
dispatch(showAlert('', 'Filter added'));
}).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error });
});
};
}
export function deleteFilter(id) {
return (dispatch, getState) => {
dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete('/api/v1/filters/'+id).then(response => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
dispatch(showAlert('', 'Filter deleted'));
}).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error });
});
};
}

Wyświetl plik

@ -1,5 +1,9 @@
import { getSettings } from '../settings';
import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer';
import {
normalizeAccount,
normalizeStatus,
normalizePoll,
} from './normalizer';
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
@ -42,6 +46,8 @@ export function importFetchedAccounts(accounts) {
const normalAccounts = [];
function processAccount(account) {
if (!account.id) return;
pushUnique(normalAccounts, normalizeAccount(account));
if (account.moved) {
@ -65,6 +71,8 @@ export function importFetchedStatuses(statuses) {
const polls = [];
function processStatus(status) {
if (!status.account.id) return;
const normalOldStatus = getState().getIn(['statuses', status.id]);
const expandSpoilers = getSettings(getState()).get('expandSpoilers');

Wyświetl plik

@ -80,3 +80,13 @@ export function normalizePoll(poll) {
return normalPoll;
}
export function normalizeChat(chat, normalOldChat) {
const normalChat = { ...chat };
const { account, last_message: lastMessage } = chat;
if (account) normalChat.account = account.id;
if (lastMessage) normalChat.last_message = lastMessage.id;
return normalChat;
}

Wyświetl plik

@ -1,5 +1,6 @@
import api from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { showAlert } from 'soapbox/actions/alerts';
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
@ -33,6 +34,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST';
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
export const UNPIN_FAIL = 'UNPIN_FAIL';
export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST';
export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS';
export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL';
export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export function reblog(status) {
return function(dispatch, getState) {
if (!getState().get('me')) return;
@ -195,6 +204,80 @@ export function unfavouriteFail(status, error) {
};
};
export function bookmark(status) {
return function(dispatch, getState) {
dispatch(bookmarkRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) {
dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status, response.data));
dispatch(showAlert('', 'Bookmark added'));
}).catch(function(error) {
dispatch(bookmarkFail(status, error));
});
};
};
export function unbookmark(status) {
return (dispatch, getState) => {
dispatch(unbookmarkRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(unbookmarkSuccess(status, response.data));
dispatch(showAlert('', 'Bookmark removed'));
}).catch(error => {
dispatch(unbookmarkFail(status, error));
});
};
};
export function bookmarkRequest(status) {
return {
type: BOOKMARK_REQUEST,
status: status,
};
};
export function bookmarkSuccess(status, response) {
return {
type: BOOKMARK_SUCCESS,
status: status,
response: response,
};
};
export function bookmarkFail(status, error) {
return {
type: BOOKMARK_FAIL,
status: status,
error: error,
};
};
export function unbookmarkRequest(status) {
return {
type: UNBOOKMARK_REQUEST,
status: status,
};
};
export function unbookmarkSuccess(status, response) {
return {
type: UNBOOKMARK_SUCCESS,
status: status,
response: response,
};
};
export function unbookmarkFail(status, error) {
return {
type: UNBOOKMARK_FAIL,
status: status,
error: error,
};
};
export function fetchReblogs(id) {
return (dispatch, getState) => {
if (!getState().get('me')) return;

Wyświetl plik

@ -7,6 +7,7 @@ export const ME_FETCH_FAIL = 'ME_FETCH_FAIL';
export const ME_FETCH_SKIP = 'ME_FETCH_SKIP';
export const ME_PATCH_REQUEST = 'ME_PATCH_REQUEST';
export const ME_PATCH_SUCCESS = 'ME_PATCH_SUCCESS';
export const ME_PATCH_FAIL = 'ME_PATCH_FAIL';
const hasToken = getState => getState().hasIn(['auth', 'user', 'access_token']);
@ -35,7 +36,7 @@ export function patchMe(params) {
return api(getState)
.patch('/api/v1/accounts/update_credentials', params)
.then(response => {
dispatch(fetchMeSuccess(response.data));
dispatch(patchMeSuccess(response.data));
}).catch(error => {
dispatch(patchMeFail(error));
});
@ -72,6 +73,16 @@ export function patchMeRequest() {
};
}
export function patchMeSuccess(me) {
return (dispatch, getState) => {
dispatch(importFetchedAccount(me));
dispatch({
type: ME_PATCH_SUCCESS,
me,
});
};
}
export function patchMeFail(error) {
return {
type: ME_PATCH_FAIL,

Wyświetl plik

@ -0,0 +1,11 @@
import api from '../api';
const noOp = () => {};
export function uploadMedia(data, onUploadProgress = noOp) {
return function(dispatch, getState) {
return api(getState).post('/api/v1/media', data, {
onUploadProgress: onUploadProgress,
});
};
}

Wyświetl plik

@ -0,0 +1,180 @@
import api from '../api';
export const TOTP_SETTINGS_FETCH_REQUEST = 'TOTP_SETTINGS_FETCH_REQUEST';
export const TOTP_SETTINGS_FETCH_SUCCESS = 'TOTP_SETTINGS_FETCH_SUCCESS';
export const TOTP_SETTINGS_FETCH_FAIL = 'TOTP_SETTINGS_FETCH_FAIL';
export const BACKUP_CODES_FETCH_REQUEST = 'BACKUP_CODES_FETCH_REQUEST';
export const BACKUP_CODES_FETCH_SUCCESS = 'BACKUP_CODES_FETCH_SUCCESS';
export const BACKUP_CODES_FETCH_FAIL = 'BACKUP_CODES_FETCH_FAIL';
export const TOTP_SETUP_FETCH_REQUEST = 'TOTP_SETUP_FETCH_REQUEST';
export const TOTP_SETUP_FETCH_SUCCESS = 'TOTP_SETUP_FETCH_SUCCESS';
export const TOTP_SETUP_FETCH_FAIL = 'TOTP_SETUP_FETCH_FAIL';
export const CONFIRM_TOTP_REQUEST = 'CONFIRM_TOTP_REQUEST';
export const CONFIRM_TOTP_SUCCESS = 'CONFIRM_TOTP_SUCCESS';
export const CONFIRM_TOTP_FAIL = 'CONFIRM_TOTP_FAIL';
export const DISABLE_TOTP_REQUEST = 'DISABLE_TOTP_REQUEST';
export const DISABLE_TOTP_SUCCESS = 'DISABLE_TOTP_SUCCESS';
export const DISABLE_TOTP_FAIL = 'DISABLE_TOTP_FAIL';
export function fetchUserMfaSettings() {
return (dispatch, getState) => {
dispatch({ type: TOTP_SETTINGS_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa').then(response => {
dispatch({ type: TOTP_SETTINGS_FETCH_SUCCESS, totpEnabled: response.data.totp });
return response;
}).catch(error => {
dispatch({ type: TOTP_SETTINGS_FETCH_FAIL });
});
};
}
export function fetchUserMfaSettingsRequest() {
return {
type: TOTP_SETTINGS_FETCH_REQUEST,
};
};
export function fetchUserMfaSettingsSuccess() {
return {
type: TOTP_SETTINGS_FETCH_SUCCESS,
};
};
export function fetchUserMfaSettingsFail() {
return {
type: TOTP_SETTINGS_FETCH_FAIL,
};
};
export function fetchBackupCodes() {
return (dispatch, getState) => {
dispatch({ type: BACKUP_CODES_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(response => {
dispatch({ type: BACKUP_CODES_FETCH_SUCCESS, backup_codes: response.data });
return response;
}).catch(error => {
dispatch({ type: BACKUP_CODES_FETCH_FAIL });
});
};
}
export function fetchBackupCodesRequest() {
return {
type: BACKUP_CODES_FETCH_REQUEST,
};
};
export function fetchBackupCodesSuccess(backup_codes, response) {
return {
type: BACKUP_CODES_FETCH_SUCCESS,
backup_codes: response.data,
};
};
export function fetchBackupCodesFail(error) {
return {
type: BACKUP_CODES_FETCH_FAIL,
error,
};
};
export function fetchToptSetup() {
return (dispatch, getState) => {
dispatch({ type: TOTP_SETUP_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa/setup/totp').then(response => {
dispatch({ type: TOTP_SETUP_FETCH_SUCCESS, totp_setup: response.data });
return response;
}).catch(error => {
dispatch({ type: TOTP_SETUP_FETCH_FAIL });
});
};
}
export function fetchToptSetupRequest() {
return {
type: TOTP_SETUP_FETCH_REQUEST,
};
};
export function fetchToptSetupSuccess(totp_setup, response) {
return {
type: TOTP_SETUP_FETCH_SUCCESS,
totp_setup: response.data,
};
};
export function fetchToptSetupFail(error) {
return {
type: TOTP_SETUP_FETCH_FAIL,
error,
};
};
export function confirmToptSetup(code, password) {
return (dispatch, getState) => {
dispatch({ type: CONFIRM_TOTP_REQUEST, code });
return api(getState).post('/api/pleroma/accounts/mfa/confirm/totp', {
code,
password,
}).then(response => {
dispatch({ type: CONFIRM_TOTP_SUCCESS });
return response;
}).catch(error => {
dispatch({ type: CONFIRM_TOTP_FAIL });
});
};
}
export function confirmToptRequest() {
return {
type: CONFIRM_TOTP_REQUEST,
};
};
export function confirmToptSuccess(backup_codes, response) {
return {
type: CONFIRM_TOTP_SUCCESS,
};
};
export function confirmToptFail(error) {
return {
type: CONFIRM_TOTP_FAIL,
error,
};
};
export function disableToptSetup(password) {
return (dispatch, getState) => {
dispatch({ type: DISABLE_TOTP_REQUEST });
return api(getState).delete('/api/pleroma/accounts/mfa/totp', { data: { password } }).then(response => {
dispatch({ type: DISABLE_TOTP_SUCCESS });
return response;
}).catch(error => {
dispatch({ type: DISABLE_TOTP_FAIL });
});
};
}
export function disableToptRequest() {
return {
type: DISABLE_TOTP_REQUEST,
};
};
export function disableToptSuccess(backup_codes, response) {
return {
type: DISABLE_TOTP_SUCCESS,
};
};
export function disableToptFail(error) {
return {
type: DISABLE_TOTP_FAIL,
error,
};
};

Wyświetl plik

@ -13,7 +13,6 @@ import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { getFilters, regexFromFilters } from '../selectors';
import { fetchMarkers } from './markers';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -71,6 +70,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
export function updateNotificationsQueue(notification, intlMessages, intlLocale, curPath) {
return (dispatch, getState) => {
if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat
const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]);
const filters = getFilters(getState(), { contextType: 'notifications' });
const playSound = getSettings(getState()).getIn(['notifications', 'sounds', notification.type]);
@ -173,7 +174,6 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
params.since_id = notifications.getIn(['items', 0, 'id']);
}
dispatch(fetchMarkers(['notifications']));
dispatch(expandNotificationsRequest(isLoadingMore));
api(getState).get('/api/v1/notifications', { params }).then(response => {

Wyświetl plik

@ -1,29 +1,62 @@
import api from '../api';
export const PATRON_FUNDING_IMPORT = 'PATRON_FUNDING_IMPORT';
export const PATRON_FUNDING_FETCH_FAIL = 'PATRON_FUNDING_FETCH_FAIL';
export const PATRON_INSTANCE_FETCH_REQUEST = 'PATRON_INSTANCE_FETCH_REQUEST';
export const PATRON_INSTANCE_FETCH_SUCCESS = 'PATRON_INSTANCE_FETCH_SUCCESS';
export const PATRON_INSTANCE_FETCH_FAIL = 'PATRON_INSTANCE_FETCH_FAIL';
export function fetchFunding() {
export const PATRON_ACCOUNT_FETCH_REQUEST = 'PATRON_ACCOUNT_FETCH_REQUEST';
export const PATRON_ACCOUNT_FETCH_SUCCESS = 'PATRON_ACCOUNT_FETCH_SUCCESS';
export const PATRON_ACCOUNT_FETCH_FAIL = 'PATRON_ACCOUNT_FETCH_FAIL';
export function fetchPatronInstance() {
return (dispatch, getState) => {
api(getState).get('/patron/v1/funding').then(response => {
dispatch(importFetchedFunding(response.data));
dispatch({ type: PATRON_INSTANCE_FETCH_REQUEST });
api(getState).get('/api/patron/v1/instance').then(response => {
dispatch(importFetchedInstance(response.data));
}).catch(error => {
dispatch(fetchFundingFail(error));
dispatch(fetchInstanceFail(error));
});
};
};
export function importFetchedFunding(funding) {
return {
type: PATRON_FUNDING_IMPORT,
funding,
export function fetchPatronAccount(apId) {
return (dispatch, getState) => {
apId = encodeURIComponent(apId);
dispatch({ type: PATRON_ACCOUNT_FETCH_REQUEST });
api(getState).get(`/api/patron/v1/accounts/${apId}`).then(response => {
dispatch(importFetchedAccount(response.data));
}).catch(error => {
dispatch(fetchAccountFail(error));
});
};
}
export function fetchFundingFail(error) {
function importFetchedInstance(instance) {
return {
type: PATRON_FUNDING_FETCH_FAIL,
type: PATRON_INSTANCE_FETCH_SUCCESS,
instance,
};
}
function fetchInstanceFail(error) {
return {
type: PATRON_INSTANCE_FETCH_FAIL,
error,
skipAlert: true,
};
};
function importFetchedAccount(account) {
return {
type: PATRON_ACCOUNT_FETCH_SUCCESS,
account,
};
}
function fetchAccountFail(error) {
return {
type: PATRON_ACCOUNT_FETCH_FAIL,
error,
skipAlert: true,
};
}

Wyświetl plik

@ -0,0 +1,25 @@
import { mapValues } from 'lodash';
export const PRELOAD_IMPORT = 'PRELOAD_IMPORT';
// https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1176/diffs
const decodeUTF8Base64 = (data) => {
const rawData = atob(data);
const array = Uint8Array.from(rawData.split('').map((char) => char.charCodeAt(0)));
const text = new TextDecoder().decode(array);
return text;
};
const decodeData = data =>
mapValues(data, base64string =>
JSON.parse(decodeUTF8Base64(base64string)));
export function preload() {
const element = document.getElementById('initial-results');
const data = element ? JSON.parse(element.textContent) : {};
return {
type: PRELOAD_IMPORT,
data: decodeData(data),
};
}

Wyświetl plik

@ -0,0 +1,24 @@
export const PROFILE_HOVER_CARD_OPEN = 'PROFILE_HOVER_CARD_OPEN';
export const PROFILE_HOVER_CARD_UPDATE = 'PROFILE_HOVER_CARD_UPDATE';
export const PROFILE_HOVER_CARD_CLOSE = 'PROFILE_HOVER_CARD_CLOSE';
export function openProfileHoverCard(ref, accountId) {
return {
type: PROFILE_HOVER_CARD_OPEN,
ref,
accountId,
};
}
export function updateProfileHoverCard() {
return {
type: PROFILE_HOVER_CARD_UPDATE,
};
}
export function closeProfileHoverCard(force = false) {
return {
type: PROFILE_HOVER_CARD_CLOSE,
force,
};
}

Wyświetl plik

@ -1,7 +1,7 @@
import { debounce } from 'lodash';
import { showAlertForError } from './alerts';
import { patchMe } from 'soapbox/actions/me';
import { Map as ImmutableMap } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';
@ -22,15 +22,23 @@ const defaultSettings = ImmutableMap({
defaultPrivacy: 'public',
themeMode: 'light',
locale: navigator.language.split(/[-_]/)[0] || 'en',
explanationBox: true,
otpEnabled: false,
systemFont: false,
dyslexicFont: false,
demetricator: false,
chats: ImmutableMap({
panes: ImmutableList(),
mainWindow: 'minimized',
}),
home: ImmutableMap({
shows: ImmutableMap({
reblog: true,
reply: true,
direct: false,
}),
regex: ImmutableMap({
@ -71,6 +79,10 @@ const defaultSettings = ImmutableMap({
}),
community: ImmutableMap({
shows: ImmutableMap({
reblog: false,
reply: true,
}),
other: ImmutableMap({
onlyMedia: false,
}),
@ -80,6 +92,10 @@ const defaultSettings = ImmutableMap({
}),
public: ImmutableMap({
shows: ImmutableMap({
reblog: true,
reply: true,
}),
other: ImmutableMap({
onlyMedia: false,
}),

Wyświetl plik

@ -1,12 +1,48 @@
import api from '../api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
export const SOAPBOX_CONFIG_REQUEST_SUCCESS = 'SOAPBOX_CONFIG_REQUEST_SUCCESS';
export const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL';
export const defaultConfig = ImmutableMap({
logo: '',
banner: '',
brandColor: '', // Empty
customCss: ImmutableList(),
promoPanel: ImmutableMap({
items: ImmutableList(),
}),
extensions: ImmutableMap(),
defaultSettings: ImmutableMap(),
copyright: '♥2020. Copying is an act of love. Please copy and share.',
navlinks: ImmutableMap({
homeFooter: ImmutableList(),
}),
});
export function getSoapboxConfig(state) {
return defaultConfig.mergeDeep(state.get('soapbox'));
}
export function fetchSoapboxConfig() {
return (dispatch, getState) => {
api(getState).get('/instance/soapbox.json').then(response => {
dispatch(importSoapboxConfig(response.data));
api(getState).get('/api/pleroma/frontend_configurations').then(response => {
if (response.data.soapbox_fe) {
dispatch(importSoapboxConfig(response.data.soapbox_fe));
} else {
dispatch(fetchSoapboxJson());
}
}).catch(error => {
dispatch(fetchSoapboxJson());
});
};
}
export function fetchSoapboxJson() {
return (dispatch, getState) => {
api(getState).get('/instance/soapbox.json').then(({ data }) => {
if (!isObject(data)) throw 'soapbox.json failed';
dispatch(importSoapboxConfig(data));
}).catch(error => {
dispatch(soapboxConfigFail(error));
});
@ -14,6 +50,9 @@ export function fetchSoapboxConfig() {
}
export function importSoapboxConfig(soapboxConfig) {
if (!soapboxConfig.brandColor) {
soapboxConfig.brandColor = '#0482d8';
};
return {
type: SOAPBOX_CONFIG_REQUEST_SUCCESS,
soapboxConfig,
@ -21,12 +60,14 @@ export function importSoapboxConfig(soapboxConfig) {
}
export function soapboxConfigFail(error) {
if (!error.response) {
console.error('soapbox.json parsing error: ' + error);
}
return {
type: SOAPBOX_CONFIG_REQUEST_FAIL,
error,
skipAlert: true,
};
}
// https://stackoverflow.com/a/46663081
function isObject(o) {
return o instanceof Object && o.constructor === Object;
}

Wyświetl plik

@ -4,7 +4,7 @@ import {
expandHomeTimeline,
connectTimeline,
disconnectTimeline,
updateTimelineQueue,
processTimelineUpdate,
} from './timelines';
import { updateNotificationsQueue, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
@ -12,10 +12,19 @@ import { fetchFilters } from './filters';
import { getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages';
export const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE';
const validLocale = locale => Object.keys(messages).includes(locale);
const getLocale = state => {
const locale = getSettings(state).get('locale');
return validLocale(locale) ? locale : 'en';
};
export function connectTimelineStream(timelineId, path, pollingRefresh = null, accept = null) {
return connectStream (path, pollingRefresh, (dispatch, getState) => {
const locale = getSettings(getState()).get('locale');
const locale = getLocale(getState());
return {
onConnect() {
@ -29,7 +38,7 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a
onReceive(data) {
switch(data.event) {
case 'update':
dispatch(updateTimelineQueue(timelineId, JSON.parse(data.payload), accept));
dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
@ -45,6 +54,9 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a
case 'filters_changed':
dispatch(fetchFilters());
break;
case 'pleroma:chat_update':
dispatch({ type: STREAMING_CHAT_UPDATE, chat: JSON.parse(data.payload), me: getState().get('me') });
break;
}
},
};

Wyświetl plik

@ -1,6 +1,8 @@
import { importFetchedStatus, importFetchedStatuses } from './importer';
import api, { getLinks } from '../api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { getSettings } from 'soapbox/actions/settings';
import { shouldFilter } from 'soapbox/utils/timelines';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@ -18,6 +20,19 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const MAX_QUEUED_ITEMS = 40;
export function processTimelineUpdate(timeline, status, accept) {
return (dispatch, getState) => {
const columnSettings = getSettings(getState()).get(timeline, ImmutableMap());
const shouldSkipQueue = shouldFilter(fromJS(status), columnSettings);
if (shouldSkipQueue) {
return dispatch(updateTimeline(timeline, status, accept));
} else {
return dispatch(updateTimelineQueue(timeline, status, accept));
}
};
}
export function updateTimeline(timeline, status, accept) {
return dispatch => {
if (typeof accept === 'function' && !accept(status)) {
@ -148,13 +163,23 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
};
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandGroupTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,

Wyświetl plik

@ -3,7 +3,7 @@
import Rails from 'rails-ujs';
export function start() {
require('font-awesome/css/font-awesome.css');
require('fork-awesome/css/fork-awesome.css');
require.context('../images/', true);
try {

Wyświetl plik

@ -2,30 +2,36 @@
exports[`<Avatar /> Autoplay renders an animated avatar 1`] = `
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
className="account__avatar still-image"
style={
Object {
"backgroundImage": "url(/animated/alice.gif)",
"height": "100px",
"width": "100px",
}
}
/>
>
<img
alt=""
onLoad={[Function]}
src="/animated/alice.gif"
/>
</div>
`;
exports[`<Avatar /> Still renders a still avatar 1`] = `
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
className="account__avatar still-image"
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
"height": "100px",
"width": "100px",
}
}
/>
>
<img
alt=""
onLoad={[Function]}
src="/animated/alice.gif"
/>
</div>
`;

Wyświetl plik

@ -5,20 +5,24 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
className="account__avatar-overlay"
>
<div
className="account__avatar-overlay-base"
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
}
}
/>
className="account__avatar-overlay-base still-image"
style={Object {}}
>
<img
alt=""
onLoad={[Function]}
src="/animated/alice.gif"
/>
</div>
<div
className="account__avatar-overlay-overlay"
style={
Object {
"backgroundImage": "url(/static/eve.jpg)",
}
}
/>
className="account__avatar-overlay-overlay still-image"
style={Object {}}
>
<img
alt=""
onLoad={[Function]}
src="/animated/eve.gif"
/>
</div>
</div>
`;

Wyświetl plik

@ -4,16 +4,23 @@ exports[`<DisplayName /> renders display name + account name 1`] = `
<span
className="display-name"
>
<bdi>
<strong
className="display-name__html"
dangerouslySetInnerHTML={
Object {
"__html": "<p>Foo</p>",
<span
className="hover-ref-wrapper"
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<bdi>
<strong
className="display-name__html"
dangerouslySetInnerHTML={
Object {
"__html": "<p>Foo</p>",
}
}
}
/>
</bdi>
/>
</bdi>
</span>
<span
className="display-name__account"
>

Wyświetl plik

@ -1,7 +1,7 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import { Avatar } from '../avatar';
import { createComponent } from 'soapbox/test_helpers';
import Avatar from '../avatar';
describe('<Avatar />', () => {
const account = fromJS({
@ -16,7 +16,7 @@ describe('<Avatar />', () => {
describe('Autoplay', () => {
it('renders an animated avatar', () => {
const component = renderer.create(<Avatar account={account} animate size={size} />);
const component = createComponent(<Avatar account={account} animate size={size} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
@ -25,7 +25,7 @@ describe('<Avatar />', () => {
describe('Still', () => {
it('renders a still avatar', () => {
const component = renderer.create(<Avatar account={account} size={size} />);
const component = createComponent(<Avatar account={account} size={size} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();

Wyświetl plik

@ -1,7 +1,7 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import { AvatarOverlay } from '../avatar_overlay';
import { createComponent } from 'soapbox/test_helpers';
import AvatarOverlay from '../avatar_overlay';
describe('<AvatarOverlay', () => {
const account = fromJS({
@ -21,7 +21,7 @@ describe('<AvatarOverlay', () => {
});
it('renders a overlay avatar', () => {
const component = renderer.create(<AvatarOverlay account={account} friend={friend} />);
const component = createComponent(<AvatarOverlay account={account} friend={friend} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();

Wyświetl plik

@ -1,7 +1,7 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import DisplayName from '../display_name';
import { createComponent } from 'soapbox/test_helpers';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {
@ -10,7 +10,7 @@ describe('<DisplayName />', () => {
acct: 'bar@baz',
display_name_html: '<p>Foo</p>',
});
const component = renderer.create(<DisplayName account={account} />);
const component = createComponent(<DisplayName account={account} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();

Wyświetl plik

@ -153,8 +153,9 @@ export default class AutosuggestInput extends ImmutablePureComponent {
this.input.focus();
}
componentWillReceiveProps(nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
componentDidUpdate(prevProps, prevState) {
const { suggestions } = this.props;
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
this.setState({ suggestionsHidden: false });
}
}

Wyświetl plik

@ -159,8 +159,9 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
this.textarea.focus();
}
componentWillReceiveProps(nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
componentDidUpdate(prevProps, prevState) {
const { suggestions } = this.props;
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
this.setState({ suggestionsHidden: false });
}
}

Wyświetl plik

@ -1,54 +1,25 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getSettings } from 'soapbox/actions/settings';
import classNames from 'classnames';
import StillImage from 'soapbox/components/still_image';
const mapStateToProps = state => ({
animate: getSettings(state).get('autoPlayGif'),
});
export class Avatar extends React.PureComponent {
export default class Avatar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
size: PropTypes.number,
style: PropTypes.object,
inline: PropTypes.bool,
animate: PropTypes.bool,
};
static defaultProps = {
inline: false,
};
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
}
render() {
const { account, size, animate, inline } = this.props;
const { account, size, inline } = this.props;
if (!account) return null;
const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
let className = 'account__avatar';
if (inline) {
className = className + ' account__avatar-inline';
}
// : TODO : remove inline and change all avatars to be sized using css
const style = !size ? {} : {
@ -56,22 +27,14 @@ export class Avatar extends React.PureComponent {
height: `${size}px`,
};
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
}
return (
<div
className={className}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
<StillImage
className={classNames('account__avatar', { 'account__avatar-inline': inline })}
style={style}
src={account.get('avatar')}
alt=''
/>
);
}
}
export default connect(mapStateToProps)(Avatar);

Wyświetl plik

@ -1,24 +1,16 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getSettings } from 'soapbox/actions/settings';
import StillImage from 'soapbox/components/still_image';
const mapStateToProps = state => ({
animate: getSettings(state).get('autoPlayGif'),
});
export default @connect(mapStateToProps)
class AvatarComposite extends React.PureComponent {
export default class AvatarComposite extends React.PureComponent {
static propTypes = {
accounts: ImmutablePropTypes.list.isRequired,
animate: PropTypes.bool,
size: PropTypes.number.isRequired,
};
renderItem(account, size, index) {
const { animate } = this.props;
let width = 50;
let height = 100;
@ -76,12 +68,10 @@ class AvatarComposite extends React.PureComponent {
bottom: bottom,
width: `${width}%`,
height: `${height}%`,
backgroundSize: 'cover',
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (
<div key={account.get('id')} style={style} />
<StillImage key={account.get('id')} src={account.get('avatar')} style={style} />
);
}

Wyświetl plik

@ -1,40 +1,23 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getSettings } from 'soapbox/actions/settings';
import StillImage from 'soapbox/components/still_image';
const mapStateToProps = state => ({
animate: getSettings(state).get('autoPlayGif'),
});
export class AvatarOverlay extends React.PureComponent {
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
};
render() {
const { account, friend, animate } = this.props;
const baseStyle = {
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
const overlayStyle = {
backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
};
const { account, friend } = this.props;
return (
<div className='account__avatar-overlay'>
<div className='account__avatar-overlay-base' style={baseStyle} />
<div className='account__avatar-overlay-overlay' style={overlayStyle} />
<StillImage src={account.get('avatar')} className='account__avatar-overlay-base' />
<StillImage src={friend.get('avatar')} className='account__avatar-overlay-overlay' />
</div>
);
}
}
export default connect(mapStateToProps)(AvatarOverlay);

Wyświetl plik

@ -1,26 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import VerificationBadge from './verification_badge';
import { acctFull } from '../utils/accounts';
import { List as ImmutableList } from 'immutable';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
others: ImmutablePropTypes.list,
children: PropTypes.node,
};
render() {
const { account, others } = this.props;
const { account, others, children } = this.props;
let displayName, suffix;
const verified = account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified');
if (others && others.size > 1) {
displayName = others.take(2).map(a => [
<bdi key={a.get('id')}>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} />
</bdi>,
a.get('is_verified') && <VerificationBadge />,
verified && <VerificationBadge />,
]).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
@ -30,7 +35,7 @@ export default class DisplayName extends React.PureComponent {
displayName = (
<>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
{account.get('is_verified') && <VerificationBadge />}
{verified && <VerificationBadge />}
</>
);
suffix = <span className='display-name__account'>@{acctFull(account)}</span>;
@ -38,8 +43,11 @@ export default class DisplayName extends React.PureComponent {
return (
<span className='display-name'>
{displayName}
<HoverRefWrapper accountId={account.get('id')} inline>
{displayName}
</HoverRefWrapper>
{suffix}
{children}
</span>
);
}

Wyświetl plik

@ -1,7 +0,0 @@
import React from 'react';
const DonorBadge = () => (
<span className='badge badge--donor'>Donor</span>
);
export default DonorBadge;

Wyświetl plik

@ -3,9 +3,16 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Helmet } from'react-helmet';
const getNotifTotals = state => {
const normNotif = state.getIn(['notifications', 'unread']);
const chatNotif = state.get('chats').reduce((acc, curr) => acc + curr.get('unread'), 0);
const notifTotals = normNotif + chatNotif;
return notifTotals;
};
const mapStateToProps = state => ({
siteTitle: state.getIn(['instance', 'title']),
unreadCount: state.getIn(['notifications', 'unread']),
unreadCount: getNotifTotals(state),
});
class SoapboxHelmet extends React.Component {

Wyświetl plik

@ -143,7 +143,7 @@ class ColumnHeader extends React.PureComponent {
</Link>
<Link to='/timeline/local' className={classNames('btn grouped', { 'active': 'local' === activeItem })}>
<Icon id='site-icon' fixedWidth className='column-header__icon' />
<Icon id='users' fixedWidth className='column-header__icon' />
{siteTitle}
</Link>

Wyświetl plik

@ -0,0 +1,64 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import {
openProfileHoverCard,
closeProfileHoverCard,
} from 'soapbox/actions/profile_hover_card';
import { useDispatch } from 'react-redux';
import { debounce } from 'lodash';
import { isMobile } from 'soapbox/is_mobile';
const showProfileHoverCard = debounce((dispatch, ref, accountId) => {
dispatch(openProfileHoverCard(ref, accountId));
}, 1200);
const handleMouseEnter = (dispatch, ref, accountId) => {
return e => {
if (!isMobile(window.innerWidth))
showProfileHoverCard(dispatch, ref, accountId);
};
};
const handleMouseLeave = (dispatch) => {
return e => {
showProfileHoverCard.cancel();
setTimeout(() => dispatch(closeProfileHoverCard()), 300);
};
};
const handleClick = (dispatch) => {
return e => {
showProfileHoverCard.cancel();
dispatch(closeProfileHoverCard(true));
};
};
export const HoverRefWrapper = ({ accountId, children, inline }) => {
const dispatch = useDispatch();
const ref = useRef();
const Elem = inline ? 'span' : 'div';
return (
<Elem
ref={ref}
className='hover-ref-wrapper'
onMouseEnter={handleMouseEnter(dispatch, ref, accountId)}
onMouseLeave={handleMouseLeave(dispatch)}
onClick={handleClick(dispatch)}
>
{children}
</Elem>
);
};
HoverRefWrapper.propTypes = {
accountId: PropTypes.string,
children: PropTypes.node,
inline: PropTypes.bool,
};
HoverRefWrapper.defaultProps = {
inline: false,
};
export default HoverRefWrapper;

Wyświetl plik

@ -12,7 +12,7 @@ export default class Icon extends React.PureComponent {
render() {
const { id, className, fixedWidth, ...other } = this.props;
// Use the font awesome retweet icon, but change its alt
// Use the Fork Awesome retweet icon, but change its alt
// tag. There is a common adblocker rule which hides elements with
// alt='retweet' unless the domain is twitter.com. This should
// change what screenreaders call it as well.

Wyświetl plik

@ -1,7 +0,0 @@
import React from 'react';
const InvestorBadge = () => (
<span className='badge badge--investor'>Investor</span>
);
export default InvestorBadge;

Wyświetl plik

@ -6,11 +6,16 @@ import { is } from 'immutable';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
import { truncateFilename } from 'soapbox/utils/media';
import classNames from 'classnames';
import { decode } from 'blurhash';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
import { Map as ImmutableMap } from 'immutable';
import { getSettings } from 'soapbox/actions/settings';
import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still_image';
const MAX_FILENAME_LENGTH = 45;
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -60,8 +65,7 @@ class Item extends React.PureComponent {
hoverToPlay() {
const { attachment, autoPlayGif } = this.props;
return !autoPlayGif &&
(attachment.get('type') === 'gifv' || attachment.getIn(['pleroma', 'mime_type']) === 'image/gif');
return !autoPlayGif && attachment.get('type') === 'gifv';
}
handleClick = (e) => {
@ -72,7 +76,7 @@ class Item extends React.PureComponent {
e.preventDefault();
} else {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
if (!this.canvas && this.hoverToPlay()) {
if (this.hoverToPlay()) {
e.target.pause();
e.target.currentTime = 0;
}
@ -112,23 +116,12 @@ class Item extends React.PureComponent {
this.canvas = c;
}
setImageRef = i => {
this.image = i;
}
handleImageLoad = () => {
this.setState({ loaded: true });
if (this.hoverToPlay()) {
const image = this.image;
const canvas = this.canvas;
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext('2d').drawImage(image, 0, 0);
}
}
render() {
const { attachment, standalone, displayWidth, visible, dimensions, autoPlayGif } = this.props;
const { attachment, standalone, visible, dimensions, autoPlayGif } = this.props;
let width = 100;
let height = '100%';
@ -153,48 +146,29 @@ class Item extends React.PureComponent {
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
const filename = truncateFilename(attachment.get('remote_url'), MAX_FILENAME_LENGTH);
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
<span className='media-gallery__item__icons'><Icon id='file' /></span>
<span className='media-gallery__filename__label'>{filename}</span>
</a>
</div>
);
} else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
const sizes = hasSize && (displayWidth > 0) ? `${displayWidth * (width / 100)}px` : null;
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<a
className={classNames('media-gallery__item-thumbnail', { 'media-gallery__item-thumbnail--play-on-hover': this.hoverToPlay() })}
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img
src={previewUrl}
srcSet={srcSet}
sizes={sizes}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
ref={this.setImageRef}
/>
{this.hoverToPlay() && <canvas ref={this.setCanvasRef} style={{ objectPosition: `${x}% ${y}%` }} />}
<StillImage src={previewUrl} alt={attachment.get('description')} />
</a>
);
} else if (attachment.get('type') === 'gifv') {
@ -225,10 +199,28 @@ class Item extends React.PureComponent {
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
} else if (attachment.get('type') === 'audio') {
const remoteURL = attachment.get('remote_url');
const originalUrl = attachment.get('url');
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase();
thumbnail = (
<a
className={classNames('media-gallery__item-thumbnail')}
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
alt={attachment.get('description')}
title={attachment.get('description')}
>
<span className='media-gallery__item__icons'><Icon id='volume-up' /></span>
<span className='media-gallery__file-extension__label'>{fileExtension}</span>
</a>
);
}
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<div className={classNames('media-gallery__item', `media-gallery__item--${attachment.get('type')}`, { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
{visible && thumbnail}
</div>
@ -269,12 +261,12 @@ class MediaGallery extends React.PureComponent {
width: this.props.defaultWidth,
};
componentWillReceiveProps(nextProps) {
const { displayMedia } = this.props;
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ visible: nextProps.visible });
componentDidUpdate(prevProps) {
const { media, visible, sensitive } = this.props;
if (!is(media, prevProps.media) && visible === undefined) {
this.setState({ visible: prevProps.displayMedia !== 'hide_all' && !sensitive || prevProps.displayMedia === 'show_all' });
} else if (!is(visible, prevProps.visible) && visible !== undefined) {
this.setState({ visible });
}
}

Wyświetl plik

@ -78,24 +78,20 @@ class ModalRoot extends React.PureComponent {
window.addEventListener('keyup', this.handleKeyUp, false);
}
componentWillReceiveProps(nextProps) {
if (!!nextProps.children && !this.props.children) {
componentDidUpdate(prevProps) {
if (!!this.props.children && !prevProps.children) {
this.activeElement = document.activeElement;
this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
} else if (!nextProps.children) {
} else if (!prevProps.children) {
this.setState({ revealed: false });
}
if (!nextProps.children && !!this.props.children) {
if (!this.props.children && !!prevProps.children) {
this.activeElement.focus();
this.activeElement = null;
}
}
componentDidUpdate(prevProps) {
if (!this.props.children && !!prevProps.children) {
this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
}
if (this.props.children) {
requestAnimationFrame(() => {
this.setState({ revealed: true });

Wyświetl plik

@ -1,7 +0,0 @@
import React from 'react';
const ProBadge = () => (
<span className='badge badge--pro'>Pro</span>
);
export default ProBadge;

Wyświetl plik

@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector, useDispatch } from 'react-redux';
import { makeGetAccount } from 'soapbox/selectors';
import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import UserPanel from 'soapbox/features/ui/components/user_panel';
import ActionButton from 'soapbox/features/ui/components/action_button';
import { isAdmin, isModerator } from 'soapbox/utils/accounts';
import Badge from 'soapbox/components/badge';
import classNames from 'classnames';
import { fetchRelationships } from 'soapbox/actions/accounts';
import { usePopper } from 'react-popper';
import {
closeProfileHoverCard,
updateProfileHoverCard,
} from 'soapbox/actions/profile_hover_card';
const getAccount = makeGetAccount();
const getBadges = (account) => {
let badges = [];
if (isAdmin(account)) badges.push(<Badge key='admin' slug='admin' title='Admin' />);
if (isModerator(account)) badges.push(<Badge key='moderator' slug='moderator' title='Moderator' />);
if (account.getIn(['patron', 'is_patron'])) badges.push(<Badge key='patron' slug='patron' title='Patron' />);
return badges;
};
const handleMouseEnter = (dispatch) => {
return e => {
dispatch(updateProfileHoverCard());
};
};
const handleMouseLeave = (dispatch) => {
return e => {
dispatch(closeProfileHoverCard(true));
};
};
export const ProfileHoverCard = ({ visible }) => {
const dispatch = useDispatch();
const [popperElement, setPopperElement] = useState(null);
const accountId = useSelector(state => state.getIn(['profile_hover_card', 'accountId']));
const account = useSelector(state => accountId && getAccount(state, accountId));
const targetRef = useSelector(state => state.getIn(['profile_hover_card', 'ref', 'current']));
const badges = account ? getBadges(account) : [];
useEffect(() => {
if (accountId) dispatch(fetchRelationships([accountId]));
}, [dispatch, accountId]);
const { styles, attributes } = usePopper(targetRef, popperElement);
if (!account) return null;
const accountBio = { __html: account.get('note_emojified') };
const followedBy = account.getIn(['relationship', 'followed_by']);
return (
<div className={classNames('profile-hover-card', { 'profile-hover-card--visible': visible })} ref={setPopperElement} style={styles.popper} {...attributes.popper} onMouseEnter={handleMouseEnter(dispatch)} onMouseLeave={handleMouseLeave(dispatch)}>
<div className='profile-hover-card__container'>
{followedBy &&
<span className='relationship-tag'>
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
</span>}
<div className='profile-hover-card__action-button'><ActionButton account={account} small /></div>
<UserPanel className='profile-hover-card__user' accountId={account.get('id')} />
{badges.length > 0 &&
<div className='profile-hover-card__badges'>
{badges}
</div>}
{account.getIn(['source', 'note'], '').length > 0 &&
<div className='profile-hover-card__bio' dangerouslySetInnerHTML={accountBio} />}
</div>
</div>
);
};
ProfileHoverCard.propTypes = {
visible: PropTypes.bool,
accountId: PropTypes.string,
account: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};
ProfileHoverCard.defaultProps = {
visible: true,
};
export default injectIntl(ProfileHoverCard);

Wyświetl plik

@ -137,8 +137,8 @@ class RelativeTimestamp extends React.Component {
this.state.now !== nextState.now;
}
componentWillReceiveProps(nextProps) {
if (this.props.timestamp !== nextProps.timestamp) {
componentDidUpdate(prevProps) {
if (this.props.timestamp !== prevProps.timestamp) {
this.setState({ now: Date.now() });
}
}
@ -147,19 +147,19 @@ class RelativeTimestamp extends React.Component {
this._scheduleNextUpdate(this.props, this.state);
}
componentWillUpdate(nextProps, nextState) {
this._scheduleNextUpdate(nextProps, nextState);
componentDidUpdate() {
this._scheduleNextUpdate();
}
componentWillUnmount() {
clearTimeout(this._timer);
}
_scheduleNextUpdate(props, state) {
_scheduleNextUpdate() {
clearTimeout(this._timer);
const { timestamp } = props;
const delta = (new Date(timestamp)).getTime() - state.now;
const { timestamp } = this.props;
const delta = (new Date(timestamp)).getTime() - this.state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;

Wyświetl plik

@ -4,7 +4,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { changeSetting } from 'soapbox/actions/settings';
import { Checkbox } from '../../forms';
import { Checkbox } from 'soapbox/features/forms';
const mapStateToProps = state => ({
settings: state.get('settings'),

Wyświetl plik

@ -15,12 +15,12 @@ import { shortNumberFormat } from '../utils/numbers';
import { isStaff } from '../utils/accounts';
import { makeGetAccount } from '../selectors';
import { logOut } from 'soapbox/actions/auth';
import ThemeToggle from '../features/ui/components/theme_toggle';
const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' },
follows: { id: 'account.follows', defaultMessage: 'Follows' },
profile: { id: 'account.profile', defaultMessage: 'Profile' },
messages: { id: 'navigation_bar.messages', defaultMessage: 'Messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
@ -28,9 +28,11 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
admin_settings: { id: 'navigation_bar.admin_settings', defaultMessage: 'Admin settings' },
soapbox_config: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
apps: { id: 'tabs_bar.apps', defaultMessage: 'Apps' },
news: { id: 'tabs_bar.news', defaultMessage: 'News' },
donate: { id: 'donate', defaultMessage: 'Donate' },
@ -43,7 +45,7 @@ const mapStateToProps = state => {
return {
account: getAccount(state, me),
sidebarOpen: state.get('sidebar').sidebarOpen,
hasPatron: state.getIn(['soapbox', 'extensions', 'patron']),
donateUrl: state.getIn(['patron', 'instance', 'url']),
isStaff: isStaff(state.getIn(['accounts', me])),
};
};
@ -75,7 +77,7 @@ class SidebarMenu extends ImmutablePureComponent {
}
render() {
const { sidebarOpen, onClose, intl, account, onClickLogOut, hasPatron, isStaff } = this.props;
const { sidebarOpen, onClose, intl, account, onClickLogOut, donateUrl, isStaff } = this.props;
if (!account) return null;
const acct = account.get('acct');
@ -119,24 +121,30 @@ class SidebarMenu extends ImmutablePureComponent {
</div>
<div className='sidebar-menu__section sidebar-menu__section--borderless'>
<div className='sidebar-menu-item theme-toggle'>
<ThemeToggle showLabel />
</div>
</div>
<div className='sidebar-menu__section sidebar-menu__section'>
<NavLink className='sidebar-menu-item' to={`/@${acct}`} onClick={onClose}>
<Icon id='user' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.profile)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to={'/messages'} onClick={onClose}>
<Icon id='envelope' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.messages)}</span>
</NavLink>
{hasPatron ?
<NavLink className='sidebar-menu-item' to='/donate' onClick={onClose}>
{donateUrl ?
<a className='sidebar-menu-item' href={donateUrl} onClick={onClose}>
<Icon id='dollar' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.donate)}</span>
</NavLink>
</a>
: ''}
<NavLink className='sidebar-menu-item' to='/lists' onClick={onClose}>
<Icon id='list' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.lists)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/bookmarks' onClick={onClose}>
<Icon id='bookmark' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.bookmarks)}</span>
</NavLink>
</div>
<div className='sidebar-menu__section'>
@ -156,14 +164,18 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='times-circle' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span>
</NavLink>
{/* <NavLink className='sidebar-menu-item' to='/filters' onClick={onClose}>
<NavLink className='sidebar-menu-item' to='/filters' onClick={onClose}>
<Icon id='filter' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.filters)}</span>
</NavLink> */}
{ isStaff && <a className='sidebar-menu-item' href={'/pleroma/admin/'} onClick={onClose}>
</NavLink>
{ isStaff && <a className='sidebar-menu-item' href='/pleroma/admin' target='_blank' onClick={onClose}>
<Icon id='shield' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.admin_settings)}</span>
</a> }
{ isStaff && <NavLink className='sidebar-menu-item' to='/soapbox/config' onClick={onClose}>
<Icon id='cog' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.soapbox_config)}</span>
</NavLink> }
<NavLink className='sidebar-menu-item' to='/settings/preferences' onClick={onClose}>
<Icon id='cog' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.preferences)}</span>

Wyświetl plik

@ -12,12 +12,14 @@ import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
import Icon from 'soapbox/components/icon';
import PollContainer from 'soapbox/containers/poll_container';
import { NavLink } from 'react-router-dom';
import { getDomain } from 'soapbox/utils/accounts';
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
@ -73,10 +75,12 @@ class Status extends ImmutablePureComponent {
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onOpenAudio: PropTypes.func,
onBlock: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onShowHoverProfileCard: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
@ -145,14 +149,16 @@ class Status extends ImmutablePureComponent {
}
componentWillUnmount() {
if (this.node && this.props.getScrollPosition) {
const position = this.props.getScrollPosition();
if (position !== null && this.node.offsetTop < position.top) {
requestAnimationFrame(() => {
this.props.updateScrollBottom(position.height - position.top);
});
}
}
// FIXME: Run this code only when a status is being deleted.
//
// if (this.node && this.props.getScrollPosition) {
// const position = this.props.getScrollPosition();
// if (position !== null && this.node.offsetTop < position.top) {
// requestAnimationFrame(() => {
// this.props.updateScrollBottom(position.height - position.top);
// });
// }
// }
}
handleToggleMediaVisibility = () => {
@ -187,17 +193,25 @@ class Status extends ImmutablePureComponent {
};
renderLoadingMediaGallery() {
return <div className='media_gallery' style={{ height: '110px' }} />;
return <div className='media_gallery' style={{ height: '285px' }} />;
}
renderLoadingVideoPlayer() {
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
return <div className='media-spoiler-video' style={{ height: '285px' }} />;
}
renderLoadingAudioPlayer() {
return <div className='media-spoiler-audio' style={{ height: '285px' }} />;
}
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
}
handleOpenAudio = (media, startTime) => {
this.props.OnOpenAudio(media, startTime);
}
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
@ -256,6 +270,7 @@ class Status extends ImmutablePureComponent {
render() {
let media = null;
let poll = null;
let statusAvatar, prepend, rebloggedByText, reblogContent;
const { intl, hidden, featured, otherAccounts, unread, showThread, group } = this.props;
@ -323,8 +338,9 @@ class Status extends ImmutablePureComponent {
}
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
poll = <PollContainer pollId={status.get('poll')} />;
}
if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
media = (
<AttachmentList
@ -345,7 +361,7 @@ class Status extends ImmutablePureComponent {
alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
width={this.props.cachedMediaWidth}
height={110}
height={285}
inline
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
@ -356,6 +372,24 @@ class Status extends ImmutablePureComponent {
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio' && status.get('media_attachments').size === 1) {
const audio = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={audio.get('url')}
alt={audio.get('description')}
inline
sensitive={status.get('sensitive')}
cacheWidth={this.props.cacheMediaWidth}
visible={this.state.showMedia}
onOpenAudio={this.handleOpenAudio}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
@ -363,7 +397,7 @@ class Status extends ImmutablePureComponent {
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
height={110}
height={285}
onOpenMedia={this.props.onOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
@ -408,6 +442,8 @@ class Status extends ImmutablePureComponent {
};
const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`;
const favicon = status.getIn(['account', 'pleroma', 'favicon']);
const domain = getDomain(status.get('account'));
return (
<HotKeys handlers={handlers}>
@ -421,13 +457,23 @@ class Status extends ImmutablePureComponent {
<RelativeTimestamp timestamp={status.get('created_at')} />
</NavLink>
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
{favicon &&
<div className='status__favicon'>
<img src={favicon} alt='' title={domain} />
</div>}
<DisplayName account={status.get('account')} others={otherAccounts} />
</NavLink>
<div className='status__profile'>
<div className='status__avatar'>
<HoverRefWrapper accountId={status.getIn(['account', 'id'])}>
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])}>
{statusAvatar}
</NavLink>
</HoverRefWrapper>
</div>
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<DisplayName account={status.get('account')} others={otherAccounts} />
</NavLink>
</div>
</div>
{!group && status.get('group') && (
@ -446,6 +492,7 @@ class Status extends ImmutablePureComponent {
/>
{media}
{poll}
{showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
<button className='status__content__read-more-button' onClick={this.handleClick}>

Wyświetl plik

@ -31,6 +31,8 @@ const messages = defineMessages({
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
open: { id: 'status.open', defaultMessage: 'Expand this post' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
@ -55,6 +57,7 @@ class StatusActionBar extends ImmutablePureComponent {
onOpenUnauthorizedModal: PropTypes.func.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onBookmark: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@ -149,6 +152,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
}
handleReblogClick = e => {
const { me } = this.props;
if (me) {
@ -246,6 +253,8 @@ class StatusActionBar extends ImmutablePureComponent {
// menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.unbookmark : messages.bookmark), action: this.handleBookmarkClick });
if (!me) {
return menu;
}

Wyświetl plik

@ -18,6 +18,7 @@ export default class StatusList extends ImmutablePureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
lastStatusId: PropTypes.string,
featuredStatusIds: ImmutablePropTypes.list,
onLoadMore: PropTypes.func,
isLoading: PropTypes.bool,
@ -62,7 +63,8 @@ export default class StatusList extends ImmutablePureComponent {
}
handleLoadOlder = debounce(() => {
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
const loadMoreID = this.props.lastStatusId ? this.props.lastStatusId : this.props.statusIds.last();
this.props.onLoadMore(loadMoreID);
}, 300, { leading: true })
_selectChild(index, align_top) {

Wyświetl plik

@ -0,0 +1,63 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { getSettings } from 'soapbox/actions/settings';
import classNames from 'classnames';
const mapStateToProps = state => ({
autoPlayGif: getSettings(state).get('autoPlayGif'),
});
export default @connect(mapStateToProps)
class StillImage extends React.PureComponent {
static propTypes = {
alt: PropTypes.string,
autoPlayGif: PropTypes.bool.isRequired,
className: PropTypes.node,
src: PropTypes.string.isRequired,
style: PropTypes.object,
};
static defaultProps = {
alt: '',
className: '',
style: {},
}
hoverToPlay() {
const { autoPlayGif, src } = this.props;
return src && !autoPlayGif && (src.endsWith('.gif') || src.startsWith('blob:'));
}
setCanvasRef = c => {
this.canvas = c;
}
setImageRef = i => {
this.img = i;
}
handleImageLoad = () => {
if (this.hoverToPlay()) {
const img = this.img;
const canvas = this.canvas;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
}
}
render() {
const { alt, className, src, style } = this.props;
const hoverToPlay = this.hoverToPlay();
return (
<div className={classNames(className, 'still-image', { 'still-image--play-on-hover': hoverToPlay })} style={style}>
<img src={src} alt={alt} ref={this.setImageRef} onLoad={this.handleImageLoad} />
{hoverToPlay && <canvas ref={this.setCanvasRef} />}
</div>
);
}
}

Wyświetl plik

@ -66,7 +66,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onMuteNotifications(account, notifications) {
dispatch(muteAccount(account.get('id'), notifications));
},

Wyświetl plik

@ -15,23 +15,27 @@ import UI from '../features/ui';
// import Introduction from '../features/introduction';
import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store';
import { IntlProvider } from 'react-intl';
import initialState from '../initial_state';
import { preload } from '../actions/preload';
import { IntlProvider } from 'react-intl';
import ErrorBoundary from '../components/error_boundary';
import { fetchInstance } from 'soapbox/actions/instance';
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
import { fetchMe } from 'soapbox/actions/me';
import PublicLayout from 'soapbox/features/public_layout';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { generateThemeCss } from 'soapbox/utils/theme';
import messages from 'soapbox/locales/messages';
const validLocale = locale => Object.keys(messages).includes(locale);
export const store = configureStore();
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
store.dispatch(preload());
store.dispatch(fetchMe());
store.dispatch(fetchInstance());
store.dispatch(fetchSoapboxConfig());
@ -42,6 +46,7 @@ const mapStateToProps = (state) => {
const account = state.getIn(['accounts', me]);
const showIntroduction = account ? state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION : false;
const settings = getSettings(state);
const soapboxConfig = getSoapboxConfig(state);
const locale = settings.get('locale');
return {
@ -52,9 +57,9 @@ const mapStateToProps = (state) => {
dyslexicFont: settings.get('dyslexicFont'),
demetricator: settings.get('demetricator'),
locale: validLocale(locale) ? locale : 'en',
themeCss: generateThemeCss(state.getIn(['soapbox', 'brandColor'])),
themeCss: generateThemeCss(soapboxConfig.get('brandColor')),
themeMode: settings.get('themeMode'),
customCss: state.getIn(['soapbox', 'customCss']),
customCss: soapboxConfig.get('customCss'),
};
};

Wyświetl plik

@ -12,6 +12,8 @@ import {
favourite,
unreblog,
unfavourite,
bookmark,
unbookmark,
pin,
unpin,
} from '../actions/interactions';
@ -100,6 +102,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onBookmark(status) {
if (status.get('bookmarked')) {
dispatch(unbookmark(status));
} else {
dispatch(bookmark(status));
}
},
onPin(status) {
if (status.get('pinned')) {
dispatch(unpin(status));
@ -146,6 +156,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('VIDEO', { media, time }));
},
onOpenAudio(media, time) {
dispatch(openModal('AUDIO', { media, time }));
},
onBlock(status) {
const account = status.get('account');
dispatch(openModal('CONFIRM', {

Wyświetl plik

@ -20,7 +20,7 @@ class AboutPage extends ImmutablePureComponent {
});
}
componentWillMount() {
componentDidMount() {
this.loadPageHtml();
}

Wyświetl plik

@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon';
import Button from 'soapbox/components/button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { isStaff } from 'soapbox/utils/accounts';
@ -16,13 +17,10 @@ import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import ProfileInfoPanel from '../../ui/components/profile_info_panel';
import { debounce } from 'lodash';
import { getSettings } from 'soapbox/actions/settings';
import StillImage from 'soapbox/components/still_image';
import ActionButton from 'soapbox/features/ui/components/action_button';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
@ -30,6 +28,7 @@ const messages = defineMessages({
direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
@ -54,7 +53,6 @@ const mapStateToProps = state => {
return {
me,
isStaff: isStaff(state.getIn(['accounts', me])),
autoPlayGif: getSettings(state).get('autoPlayGif'),
version: parseVersion(state.getIn(['instance', 'version'])),
};
};
@ -66,11 +64,8 @@ class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
username: PropTypes.string,
autoPlayGif: PropTypes.bool,
isStaff: PropTypes.bool.isRequired,
version: PropTypes.object,
};
@ -91,7 +86,7 @@ class Header extends ImmutablePureComponent {
return !location.pathname.match(/\/(followers|following|favorites|pins)\/?$/);
}
componentWillMount() {
componentDidMount() {
window.addEventListener('resize', this.handleResize, { passive: true });
}
@ -138,7 +133,7 @@ class Header extends ImmutablePureComponent {
}
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
// menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
menu.push(null);
} else if (version.software === 'Pleroma') {
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
@ -173,7 +168,7 @@ class Header extends ImmutablePureComponent {
if (account.get('id') !== me && isStaff) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/pleroma/admin/#/users/${account.get('id')}/` });
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/pleroma/admin/#/users/${account.get('id')}/`, newTab: true });
}
return menu;
@ -201,32 +196,8 @@ class Header extends ImmutablePureComponent {
return info;
};
getActionBtn() {
const { account, intl, me } = this.props;
let actionBtn = null;
if (!account || !me) return actionBtn;
if (me !== account.get('id')) {
if (!account.get('relationship')) { // Wait until the relationship is loaded
//
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
}
} else {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} to='/settings/profile' />;
}
return actionBtn;
};
render() {
const { account, intl, username, me, autoPlayGif } = this.props;
const { account, intl, username, me } = this.props;
const { isSmallScreen } = this.state;
if (!account) {
@ -249,22 +220,20 @@ class Header extends ImmutablePureComponent {
}
const info = this.makeInfo();
const actionBtn = this.getActionBtn();
const menu = this.makeMenu();
const headerImgSrc = autoPlayGif ? account.get('header') : account.get('header_static');
const headerMissing = (headerImgSrc.indexOf('/headers/original/missing.png') > -1);
const headerMissing = (account.get('header').indexOf('/headers/original/missing.png') > -1);
const avatarSize = isSmallScreen ? 90 : 200;
const deactivated = account.getIn(['pleroma', 'deactivated'], false);
return (
<div className={classNames('account__header', { inactive: !!account.get('moved') })}>
<div className={classNames('account__header__image', { 'account__header__image--none': headerMissing })}>
<div className={classNames('account__header', { inactive: !!account.get('moved'), deactivated: deactivated })}>
<div className={classNames('account__header__image', { 'account__header__image--none': headerMissing || deactivated })}>
<div className='account__header__info'>
{info}
</div>
<img src={headerImgSrc} alt='' className='parallax' />
<StillImage src={account.get('header')} alt='' className='parallax' />
</div>
<div className='account__header__bar'>
@ -314,7 +283,7 @@ class Header extends ImmutablePureComponent {
{
isSmallScreen &&
<div className='account-mobile-container'>
<div className={classNames('account-mobile-container', { 'deactivated': deactivated })}>
<ProfileInfoPanel username={username} account={account} />
</div>
}
@ -322,14 +291,11 @@ class Header extends ImmutablePureComponent {
{
me &&
<div className='account__header__extra__buttons'>
{actionBtn}
{account.get('id') !== me &&
<Button className='button button-alternative-2' onClick={this.props.onDirect}>
<FormattedMessage
id='account.message' defaultMessage='Message' values={{
name: account.get('acct'),
}}
/>
<ActionButton account={account} />
{account.get('id') !== me && account.getIn(['pleroma', 'accepts_chat_messages'], false) === true &&
<Button className='button-alternative-2' onClick={this.props.onChat}>
<Icon id='comment' />
<FormattedMessage id='account.message' defaultMessage='Message' />
</Button>
}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />

Wyświetl plik

@ -8,6 +8,7 @@ import classNames from 'classnames';
import { decode } from 'blurhash';
import { isIOS } from 'soapbox/is_mobile';
import { getSettings } from 'soapbox/actions/settings';
import StillImage from 'soapbox/components/still_image';
const mapStateToProps = state => ({
autoPlayGif: getSettings(state).get('autoPlayGif'),
@ -113,12 +114,10 @@ class MediaItem extends ImmutablePureComponent {
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<img
<StillImage
src={attachment.get('preview_url')}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
);
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
@ -147,6 +146,16 @@ class MediaItem extends ImmutablePureComponent {
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
} else if (attachment.get('type') === 'audio') {
const remoteURL = attachment.get('remote_url');
const fileExtensionLastIndex = remoteURL.lastIndexOf('.');
const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase();
thumbnail = (
<div className='media-gallery__item-thumbnail'>
<span className='media-gallery__item__icons'><Icon id='volume-up' /></span>
<span className='media-gallery__file-extension__label'>{fileExtension}</span>
</div>
);
}
if (!visible) {

Wyświetl plik

@ -97,10 +97,11 @@ class AccountGallery extends ImmutablePureComponent {
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.accountId && nextProps.accountId !== -1 && (nextProps.accountId !== this.props.accountId && nextProps.accountId)) {
this.props.dispatch(fetchAccount(nextProps.params.accountId));
this.props.dispatch(expandAccountMediaTimeline(nextProps.accountId));
componentDidUpdate(prevProps) {
const { accountId, params } = this.props;
if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) {
this.props.dispatch(fetchAccount(params.accountId));
this.props.dispatch(expandAccountMediaTimeline(accountId));
}
}

Wyświetl plik

@ -19,7 +19,7 @@ export default class Header extends ImmutablePureComponent {
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
// onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
username: PropTypes.string,
};
@ -72,10 +72,14 @@ export default class Header extends ImmutablePureComponent {
this.props.onUnblockDomain(domain);
}
handleEndorseToggle = () => {
this.props.onEndorseToggle(this.props.account);
handleChat = () => {
this.props.onChat(this.props.account, this.context.router.history);
}
// handleEndorseToggle = () => {
// this.props.onEndorseToggle(this.props.account);
// }
handleAddToList = () => {
this.props.onAddToList(this.props.account);
}
@ -95,6 +99,7 @@ export default class Header extends ImmutablePureComponent {
onBlock={this.handleBlock}
onMention={this.handleMention}
onDirect={this.handleDirect}
onChat={this.handleChat}
onReblogToggle={this.handleReblogToggle}
onReport={this.handleReport}
onMute={this.handleMute}

Wyświetl plik

@ -8,8 +8,8 @@ import {
blockAccount,
unblockAccount,
unmuteAccount,
pinAccount,
unpinAccount,
// pinAccount,
// unpinAccount,
} from '../../../actions/accounts';
import {
mentionCompose,
@ -22,6 +22,8 @@ import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { getSettings } from 'soapbox/actions/settings';
import { startChat, openChat } from 'soapbox/actions/chats';
import { isMobile } from 'soapbox/is_mobile';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@ -95,13 +97,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onEndorseToggle(account) {
if (account.getIn(['relationship', 'endorsed'])) {
dispatch(unpinAccount(account.get('id')));
} else {
dispatch(pinAccount(account.get('id')));
}
},
// onEndorseToggle(account) {
// if (account.getIn(['relationship', 'endorsed'])) {
// dispatch(unpinAccount(account.get('id')));
// } else {
// dispatch(pinAccount(account.get('id')));
// }
// },
onReport(account) {
dispatch(initReport(account));
@ -127,12 +129,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(unblockDomain(domain));
},
onAddToList(account){
onAddToList(account) {
dispatch(openModal('LIST_ADDER', {
accountId: account.get('id'),
}));
},
onChat(account, router) {
// TODO make this faster
dispatch(startChat(account.get('id'))).then(chat => {
if (isMobile(window.innerWidth)) {
router.push(`/chats/${chat.id}`);
} else {
dispatch(openChat(chat.id));
}
}).catch(() => {});
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

Wyświetl plik

@ -13,6 +13,8 @@ import { FormattedMessage } from 'react-intl';
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import MissingIndicator from 'soapbox/components/missing_indicator';
import { NavLink } from 'react-router-dom';
import { fetchPatronAccount } from '../../actions/patron';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
const emptyList = ImmutableList();
@ -20,15 +22,18 @@ const mapStateToProps = (state, { params: { username }, withReplies = false }) =
const me = state.get('me');
const accounts = state.getIn(['accounts']);
const accountFetchError = (state.getIn(['accounts', -1, 'username'], '').toLowerCase() === username.toLowerCase());
const soapboxConfig = getSoapboxConfig(state);
let accountId = -1;
let accountUsername = username;
let accountApId = null;
if (accountFetchError) {
accountId = null;
} else {
let account = accounts.find(acct => username.toLowerCase() === acct.getIn(['acct'], '').toLowerCase());
accountId = account ? account.getIn(['id'], null) : -1;
accountUsername = account ? account.getIn(['acct'], '') : '';
accountApId = account ? account.get('url') : '';
}
const path = withReplies ? `${accountId}:with_replies` : accountId;
@ -40,12 +45,14 @@ const mapStateToProps = (state, { params: { username }, withReplies = false }) =
accountId,
unavailable,
accountUsername,
accountApId,
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
me,
patronEnabled: soapboxConfig.getIn(['extensions', 'patron', 'enabled']),
};
};
@ -64,8 +71,8 @@ class AccountTimeline extends ImmutablePureComponent {
unavailable: PropTypes.bool,
};
componentWillMount() {
const { params: { username }, accountId, withReplies, me } = this.props;
componentDidMount() {
const { params: { username }, accountId, accountApId, withReplies, me, patronEnabled } = this.props;
if (accountId && accountId !== -1) {
this.props.dispatch(fetchAccount(accountId));
@ -75,23 +82,31 @@ class AccountTimeline extends ImmutablePureComponent {
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
}
if (patronEnabled && accountApId) {
this.props.dispatch(fetchPatronAccount(accountApId));
}
this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
} else {
this.props.dispatch(fetchAccountByUsername(username));
}
}
componentWillReceiveProps(nextProps) {
const { me } = nextProps;
if (nextProps.accountId && nextProps.accountId !== -1 && (nextProps.accountId !== this.props.accountId && nextProps.accountId) || nextProps.withReplies !== this.props.withReplies) {
this.props.dispatch(fetchAccount(nextProps.accountId));
if (me) this.props.dispatch(fetchAccountIdentityProofs(nextProps.accountId));
componentDidUpdate(prevProps) {
const { me, accountId, withReplies, accountApId, patronEnabled } = this.props;
if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId) || withReplies !== prevProps.withReplies) {
this.props.dispatch(fetchAccount(accountId));
if (me) this.props.dispatch(fetchAccountIdentityProofs(accountId));
if (!nextProps.withReplies) {
this.props.dispatch(expandAccountFeaturedTimeline(nextProps.accountId));
if (!withReplies) {
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
}
this.props.dispatch(expandAccountTimeline(nextProps.accountId, { withReplies: nextProps.withReplies }));
if (patronEnabled && accountApId) {
this.props.dispatch(fetchPatronAccount(accountApId));
}
this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
}
}

Wyświetl plik

@ -0,0 +1,380 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { throttle } from 'lodash';
import classNames from 'classnames';
import Icon from 'soapbox/components/icon';
import { getSettings } from 'soapbox/actions/settings';
const messages = defineMessages({
play: { id: 'audio.play', defaultMessage: 'Play' },
pause: { id: 'audio.pause', defaultMessage: 'Pause' },
mute: { id: 'audio.mute', defaultMessage: 'Mute' },
unmute: { id: 'audio.unmute', defaultMessage: 'Unmute' },
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
expand: { id: 'audio.expand', defaultMessage: 'Expand audio' },
close: { id: 'audio.close', defaultMessage: 'Close audio' },
});
const formatTime = secondsNum => {
let hours = Math.floor(secondsNum / 3600);
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
let seconds = secondsNum - (hours * 3600) - (minutes * 60);
if (hours < 10) hours = '0' + hours;
if (minutes < 10 && hours >= 1) minutes = '0' + minutes;
if (seconds < 10) seconds = '0' + seconds;
return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
};
export const findElementPosition = el => {
let box;
if (el.getBoundingClientRect && el.parentNode) {
box = el.getBoundingClientRect();
}
if (!box) {
return {
left: 0,
top: 0,
};
}
const docEl = document.documentElement;
const body = document.body;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const scrollLeft = window.pageXOffset || body.scrollLeft;
const left = (box.left + scrollLeft) - clientLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const scrollTop = window.pageYOffset || body.scrollTop;
const top = (box.top + scrollTop) - clientTop;
return {
left: Math.round(left),
top: Math.round(top),
};
};
export const getPointerPosition = (el, event) => {
const position = {};
const box = findElementPosition(el);
const boxW = el.offsetWidth;
const boxH = el.offsetHeight;
const boxY = box.top;
const boxX = box.left;
let pageY = event.pageY;
let pageX = event.pageX;
if (event.changedTouches) {
pageX = event.changedTouches[0].pageX;
pageY = event.changedTouches[0].pageY;
}
position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
return position;
};
const mapStateToProps = state => ({
displayMedia: getSettings(state).get('displayMedia'),
});
export default @connect(mapStateToProps)
@injectIntl
class Audio extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
sensitive: PropTypes.bool,
startTime: PropTypes.number,
detailed: PropTypes.bool,
inline: PropTypes.bool,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
intl: PropTypes.object.isRequired,
link: PropTypes.node,
displayMedia: PropTypes.string,
expandSpoilers: PropTypes.bool,
};
state = {
currentTime: 0,
duration: 0,
volume: 0.5,
paused: true,
dragging: false,
muted: false,
revealed: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'),
};
// hard coded in components.scss
// any way to get ::before values programatically?
volWidth = 50;
volOffset = 85;
volHandleOffset = v => {
const offset = v * this.volWidth + this.volOffset;
return (offset > 125) ? 125 : offset;
}
setPlayerRef = c => {
this.player = c;
if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
this.setState({
containerWidth: c.offsetWidth,
});
}
}
setAudioRef = c => {
this.audio = c;
if (this.audio) {
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
}
}
setSeekRef = c => {
this.seek = c;
}
setVolumeRef = c => {
this.volume = c;
}
handleClickRoot = e => e.stopPropagation();
handlePlay = () => {
this.setState({ paused: false });
}
handlePause = () => {
this.setState({ paused: true });
}
handleTimeUpdate = () => {
this.setState({
currentTime: Math.floor(this.audio.currentTime),
duration: Math.floor(this.audio.duration),
});
}
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
this.handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
}
handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
}
handleMouseVolSlide = throttle(e => {
const rect = this.volume.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
if(!isNaN(x)) {
var slideamt = x;
if(x > 1) {
slideamt = 1;
} else if(x < 0) {
slideamt = 0;
}
this.audio.volume = slideamt;
this.setState({ volume: slideamt });
}
}, 60);
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('mouseup', this.handleMouseUp, true);
document.addEventListener('touchmove', this.handleMouseMove, true);
document.addEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: true });
this.audio.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('mouseup', this.handleMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseMove, true);
document.removeEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: false });
this.audio.play();
}
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = Math.floor(this.audio.duration * x);
if (!isNaN(currentTime)) {
this.audio.currentTime = currentTime;
this.setState({ currentTime });
}
}, 60);
togglePlay = () => {
if (this.state.paused) {
this.audio.play();
} else {
this.audio.pause();
}
}
toggleMute = () => {
this.audio.muted = !this.audio.muted;
this.setState({ muted: this.audio.muted });
}
toggleWarning = () => {
this.setState({ revealed: !this.state.revealed });
}
handleLoadedData = () => {
if (this.props.startTime) {
this.audio.currentTime = this.props.startTime;
this.audio.play();
}
}
handleProgress = () => {
if (this.audio.buffered.length > 0) {
this.setState({ buffer: this.audio.buffered.end(0) / this.audio.duration * 100 });
}
}
handleVolumeChange = () => {
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
}
getPreload = () => {
const { startTime, detailed } = this.props;
const { dragging } = this.state;
if (startTime || dragging) {
return 'auto';
} else if (detailed) {
return 'metadata';
} else {
return 'none';
}
}
render() {
const { src, inline, intl, alt, detailed, sensitive, link } = this.props;
const { currentTime, duration, volume, buffer, dragging, paused, muted, revealed } = this.state;
const progress = (currentTime / duration) * 100;
const volumeWidth = (muted) ? 0 : volume * this.volWidth;
const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
const playerStyle = {};
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
return (
<div
role='menuitem'
className={classNames('audio-player', { detailed: detailed, inline: inline, warning_visible: !revealed })}
style={playerStyle}
ref={this.setPlayerRef}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onClick={this.handleClickRoot}
tabIndex={0}
>
<audio
ref={this.setAudioRef}
src={src}
// preload={this.getPreload()}
role='button'
tabIndex='0'
aria-label={alt}
title={alt}
volume={volume}
onClick={this.togglePlay}
onPlay={this.handlePlay}
onPause={this.handlePause}
onTimeUpdate={this.handleTimeUpdate}
onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
onVolumeChange={this.handleVolumeChange}
/>
<div className={classNames('audio-player__spoiler-warning', { 'spoiler-button--hidden': revealed })}>
<span className='audio-player__spoiler-warning__label'><Icon id='warning' fixedWidth /> {warning}</span>
<button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleWarning}><Icon id='times' fixedWidth /></button>
</div>
<div className={classNames('audio-player__controls')}>
<div className='audio-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='audio-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='audio-player__seek__progress' style={{ width: `${progress}%` }} />
<span
className={classNames('audio-player__seek__handle', { active: dragging })}
tabIndex='0'
style={{ left: `${progress}%` }}
/>
</div>
<div className='audio-player__buttons-bar'>
<div className='audio-player__buttons left'>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className='audio-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='audio-player__volume__current' style={{ width: `${volumeWidth}px` }} />
<span
className={classNames('audio-player__volume__handle')}
tabIndex='0'
style={{ left: `${volumeHandleLoc}px` }}
/>
</div>
<span>
<span className='audio-player__time-current'>{formatTime(currentTime)}</span>
<span className='audio-player__time-sep'>/</span>
<span className='audio-player__time-total'>{formatTime(duration)}</span>
</span>
{link && <span className='audio-player__link'>{link}</span>}
</div>
</div>
</div>
</div>
);
}
}

Wyświetl plik

@ -3,11 +3,9 @@
exports[`<LoginForm /> renders correctly 1`] = `
<form
className="simple_form new_user"
onSubmit={[Function]}
method="post"
>
<fieldset
disabled={false}
>
<fieldset>
<div
className="fields-group"
>

Wyświetl plik

@ -1,8 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginPage /> renders correctly 1`] = `
exports[`<LoginPage /> renders correctly on load 1`] = `
<form
className="simple_form new_user"
method="post"
onSubmit={[Function]}
>
<fieldset
@ -59,4 +60,4 @@ exports[`<LoginPage /> renders correctly 1`] = `
</form>
`;
exports[`<LoginPage /> renders correctly 2`] = `null`;
exports[`<LoginPage /> renders correctly on load 2`] = `null`;

Wyświetl plik

@ -2,9 +2,16 @@ import React from 'react';
import LoginPage from '../login_page';
import { createComponent, mockStore } from 'soapbox/test_helpers';
import { Map as ImmutableMap } from 'immutable';
// import { __stub as stubApi } from 'soapbox/api';
// import { logIn } from 'soapbox/actions/auth';
describe('<LoginPage />', () => {
it('renders correctly', () => {
beforeEach(() => {
const store = mockStore(ImmutableMap({}));
return store;
});
it('renders correctly on load', () => {
expect(createComponent(
<LoginPage />
).toJSON()).toMatchSnapshot();
@ -12,7 +19,38 @@ describe('<LoginPage />', () => {
const store = mockStore(ImmutableMap({ me: '1234' }));
expect(createComponent(
<LoginPage />,
{ store },
{ store }
).toJSON()).toMatchSnapshot();
});
// it('renders the OTP form when logIn returns with mfa_required', () => {
//
// stubApi(mock => {
// mock.onPost('/api/v1/apps').reply(200, {
// data: {
// client_id:'12345', client_secret:'12345', id:'111', name:'SoapboxFE', redirect_uri:'urn:ietf:wg:oauth:2.0:oob', website:null, vapid_key:'12345',
// },
// });
// mock.onPost('/oauth/token').reply(403, {
// error:'mfa_required', mfa_token:'12345', supported_challenge_types:'totp',
// });
// });
//
// const app = new Map();
// app.set('app', { client_id: '12345', client_secret:'12345' });
// const store = mockStore(ImmutableMap({
// auth: { app },
// }));
// const loginPage = createComponent(<LoginPage />, { store });
//
// return loginPage.handleSubmit().then(() => {
// const wrapper = loginPage.toJSON();
// expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({
// type: 'h1',
// props: { className: 'otp-login' },
// children: [ 'OTP Login' ],
// }));
// });
//
// });
});

Wyświetl plik

@ -0,0 +1,29 @@
import React from 'react';
import OtpAuthForm from '../otp_auth_form';
import { createComponent, mockStore } from 'soapbox/test_helpers';
import { Map as ImmutableMap } from 'immutable';
describe('<OtpAuthForm />', () => {
it('renders correctly', () => {
const store = mockStore(ImmutableMap({ mfa_auth_needed: true }));
const wrapper = createComponent(
<OtpAuthForm
mfa_token={'12345'}
/>,
{ store }
).toJSON();
expect(wrapper).toEqual(expect.objectContaining({
type: 'form',
}));
expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({
type: 'h1',
props: { className: 'otp-login' },
children: [ 'OTP Login' ],
}));
});
});

Wyświetl plik

@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { Map as ImmutableMap } from 'immutable';
import { fetchCaptcha } from 'soapbox/actions/auth';
import { TextInput } from 'soapbox/features/forms';
import { FormattedMessage } from 'react-intl';
const noOp = () => {};
@ -15,6 +16,7 @@ class CaptchaField extends React.Component {
onChange: PropTypes.func,
onFetch: PropTypes.func,
onFetchFail: PropTypes.func,
onClick: PropTypes.func,
dispatch: PropTypes.func,
refreshInterval: PropTypes.number,
idempotencyKey: PropTypes.string,
@ -24,6 +26,7 @@ class CaptchaField extends React.Component {
onChange: noOp,
onFetch: noOp,
onFetchFail: noOp,
onClick: noOp,
refreshInterval: 5*60*1000, // 5 minutes, Pleroma default
}
@ -61,7 +64,7 @@ class CaptchaField extends React.Component {
});
}
componentWillMount() {
componentDidMount() {
this.fetchCaptcha();
this.startRefresh(); // Refresh periodically
}
@ -79,10 +82,16 @@ class CaptchaField extends React.Component {
render() {
const { captcha } = this.state;
const { onChange } = this.props;
const { onClick } = this.props;
switch(captcha.get('type')) {
case 'native':
return <NativeCaptchaField captcha={captcha} onChange={onChange} />;
return (
<div>
<p>{<FormattedMessage id='registration.captcha.hint' defaultMessage='Click the image to get a new captcha' />}</p>
<NativeCaptchaField captcha={captcha} onChange={onChange} onClick={onClick} />
</div>
);
case 'none':
default:
return null;
@ -91,9 +100,9 @@ class CaptchaField extends React.Component {
}
export const NativeCaptchaField = ({ captcha, onChange }) => (
<div className='captcha'>
<img alt='captcha' src={captcha.get('url')} />
export const NativeCaptchaField = ({ captcha, onChange, onClick }) => (
<div className='captcha' >
<img alt='captcha' src={captcha.get('url')} onClick={onClick} />
<TextInput
placeholder='Enter the pictured text'
name='captcha_solution'
@ -107,4 +116,5 @@ export const NativeCaptchaField = ({ captcha, onChange }) => (
NativeCaptchaField.propTypes = {
captcha: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func,
onClick: PropTypes.func,
};

Wyświetl plik

@ -3,8 +3,6 @@ import { connect } from 'react-redux';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { logIn } from 'soapbox/actions/auth';
import { fetchMe } from 'soapbox/actions/me';
const messages = defineMessages({
username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' },
@ -15,34 +13,12 @@ export default @connect()
@injectIntl
class LoginForm extends ImmutablePureComponent {
state = {
isLoading: false,
}
getFormData = (form) => {
return Object.fromEntries(
Array.from(form).map(i => [i.name, i.value])
);
}
handleSubmit = (event) => {
const { dispatch } = this.props;
const { username, password } = this.getFormData(event.target);
dispatch(logIn(username, password)).then(() => {
return dispatch(fetchMe());
}).catch(error => {
this.setState({ isLoading: false });
});
this.setState({ isLoading: true });
event.preventDefault();
}
render() {
const { intl } = this.props;
const { intl, isLoading, handleSubmit } = this.props;
return (
<form className='simple_form new_user' onSubmit={this.handleSubmit}>
<fieldset disabled={this.state.isLoading}>
<form className='simple_form new_user' method='post' onSubmit={handleSubmit}>
<fieldset disabled={isLoading}>
<div className='fields-group'>
<div className='input email optional user_email'>
<input aria-label={intl.formatMessage(messages.username)} className='string email optional' placeholder={intl.formatMessage(messages.username)} type='text' name='username' />

Wyświetl plik

@ -3,19 +3,57 @@ import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import LoginForm from './login_form';
import OtpAuthForm from './otp_auth_form';
import { logIn } from 'soapbox/actions/auth';
import { fetchMe } from 'soapbox/actions/me';
const mapStateToProps = state => ({
me: state.get('me'),
isLoading: false,
});
export default @connect(mapStateToProps)
class LoginPage extends ImmutablePureComponent {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
getFormData = (form) => {
return Object.fromEntries(
Array.from(form).map(i => [i.name, i.value])
);
}
state = {
mfa_auth_needed: false,
mfa_token: '',
}
handleSubmit = (event) => {
const { dispatch } = this.props;
const { username, password } = this.getFormData(event.target);
dispatch(logIn(username, password)).then(() => {
return dispatch(fetchMe());
}).catch(error => {
if (error.response.data.error === 'mfa_required') {
this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token });
}
this.setState({ isLoading: false });
});
this.setState({ isLoading: true });
event.preventDefault();
}
render() {
const { me } = this.props;
const { me, isLoading } = this.props;
const { mfa_auth_needed, mfa_token } = this.state;
if (me) return <Redirect to='/' />;
return <LoginForm />;
if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />;
return <LoginForm handleSubmit={this.handleSubmit} isLoading={isLoading} />;
}
}

Wyświetl plik

@ -0,0 +1,92 @@
import React from 'react';
import { connect } from 'react-redux';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { otpVerify } from 'soapbox/actions/auth';
import { fetchMe } from 'soapbox/actions/me';
import { SimpleInput } from 'soapbox/features/forms';
import PropTypes from 'prop-types';
const messages = defineMessages({
otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' },
otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' },
});
export default @connect()
@injectIntl
class OtpAuthForm extends ImmutablePureComponent {
state = {
isLoading: false,
code_error: '',
}
static propTypes = {
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
mfa_token: PropTypes.string,
};
getFormData = (form) => {
return Object.fromEntries(
Array.from(form).map(i => [i.name, i.value])
);
}
handleSubmit = (event) => {
const { dispatch, mfa_token } = this.props;
const { code } = this.getFormData(event.target);
dispatch(otpVerify(code, mfa_token)).then(() => {
this.setState({ code_error: false });
return dispatch(fetchMe());
}).catch(error => {
this.setState({ isLoading: false });
if (error.response.data.error === 'Invalid code') {
this.setState({ code_error: true });
}
});
this.setState({ isLoading: true });
event.preventDefault();
}
render() {
const { intl } = this.props;
const { code_error } = this.state;
return (
<form className='simple_form new_user otp-auth' method='post' onSubmit={this.handleSubmit}>
<fieldset disabled={this.state.isLoading}>
<div className='fields-group'>
<div className='input email optional user_email'>
<h1 className='otp-login'>
<FormattedMessage id='login.otp_log_in' defaultMessage='OTP Login' />
</h1>
</div>
<div className='input code optional otp_code'>
<SimpleInput
label={intl.formatMessage(messages.otpCodeLabel)}
hint={intl.formatMessage(messages.otpCodeHint)}
name='code'
type='text'
autoComplete='off'
onChange={this.onInputChange}
required
/>
</div>
</div>
</fieldset>
{ code_error &&
<div className='error-box'>
<FormattedMessage id='login.otp_log_in.fail' defaultMessage='Invalid code, please try again.' />
</div>
}
<div className='actions'>
<button name='button' type='submit' className='btn button button-primary'>
<FormattedMessage id='login.log_in' defaultMessage='Log in' />
</button>
</div>
</form>
);
}
}

Wyświetl plik

@ -27,12 +27,12 @@ class Blocks extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
accountIds: ImmutablePropTypes.orderedSet,
hasMore: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentWillMount() {
componentDidMount() {
this.props.dispatch(fetchBlocks());
}

Wyświetl plik

@ -0,0 +1,74 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import StatusList from '../../components/status_list';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
import { debounce } from 'lodash';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
});
export default @connect(mapStateToProps)
@injectIntl
class Bookmarks extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentDidMount() {
const { dispatch } = this.props;
dispatch(fetchBookmarkedStatuses());
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkedStatuses());
}, 300, { leading: true })
render() {
const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />;
return (
<Column icon='bookmark' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmarked_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

Wyświetl plik

@ -0,0 +1,94 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Avatar from 'soapbox/components/avatar';
import { acctFull } from 'soapbox/utils/accounts';
import { fetchChat, markChatRead } from 'soapbox/actions/chats';
import ChatBox from './components/chat_box';
import Column from 'soapbox/components/column';
import ColumnBackButton from 'soapbox/components/column_back_button';
import { Map as ImmutableMap } from 'immutable';
import { makeGetChat } from 'soapbox/selectors';
const mapStateToProps = (state, { params }) => {
const getChat = makeGetChat();
const chat = state.getIn(['chats', params.chatId], ImmutableMap()).toJS();
return {
me: state.get('me'),
chat: getChat(state, chat),
};
};
export default @connect(mapStateToProps)
@injectIntl
class ChatRoom extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
chat: ImmutablePropTypes.map,
me: PropTypes.node,
}
handleInputRef = (el) => {
this.inputElem = el;
this.focusInput();
};
focusInput = () => {
if (!this.inputElem) return;
this.inputElem.focus();
}
markRead = () => {
const { dispatch, chat } = this.props;
if (!chat) return;
dispatch(markChatRead(chat.get('id')));
}
componentDidMount() {
const { dispatch, params } = this.props;
dispatch(fetchChat(params.chatId));
this.markRead();
}
componentDidUpdate(prevProps) {
const markReadConditions = [
() => this.props.chat,
() => this.props.chat.get('unread') > 0,
];
if (markReadConditions.every(c => c()))
this.markRead();
}
render() {
const { chat } = this.props;
if (!chat) return null;
const account = chat.get('account');
return (
<Column>
<div className='chatroom__back'>
<ColumnBackButton />
<Link to={`/@${account.get('acct')}`} className='chatroom__header'>
<Avatar account={account} size={18} />
<div className='chatroom__title'>
@{acctFull(account)}
</div>
</Link>
</div>
<ChatBox
chatId={chat.get('id')}
onSetInputRef={this.handleInputRef}
/>
</Column>
);
}
}

Wyświetl plik

@ -0,0 +1,49 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { shortNumberFormat } from 'soapbox/utils/numbers';
import emojify from 'soapbox/features/emoji/emoji';
export default class Chat extends ImmutablePureComponent {
static propTypes = {
chat: ImmutablePropTypes.map.isRequired,
onClick: PropTypes.func,
};
handleClick = () => {
this.props.onClick(this.props.chat);
}
render() {
const { chat } = this.props;
if (!chat) return null;
const account = chat.get('account');
const unreadCount = chat.get('unread');
const content = chat.getIn(['last_message', 'content']);
const parsedContent = content ? emojify(content) : '';
return (
<div className='account'>
<button className='floating-link' onClick={this.handleClick} />
<div className='account__wrapper'>
<div key={account.get('id')} className='account__display-name'>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<DisplayName account={account} />
<span
className='chat__last-message'
dangerouslySetInnerHTML={{ __html: parsedContent }}
/>
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
</div>
</div>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,213 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import {
sendChatMessage,
markChatRead,
} from 'soapbox/actions/chats';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import ChatMessageList from './chat_message_list';
import UploadButton from 'soapbox/features/compose/components/upload_button';
import { uploadMedia } from 'soapbox/actions/media';
import UploadProgress from 'soapbox/features/compose/components/upload_progress';
import { truncateFilename } from 'soapbox/utils/media';
import IconButton from 'soapbox/components/icon_button';
const messages = defineMessages({
placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' },
});
const mapStateToProps = (state, { chatId }) => ({
me: state.get('me'),
chat: state.getIn(['chats', chatId]),
chatMessageIds: state.getIn(['chat_message_lists', chatId], ImmutableOrderedSet()),
});
const fileKeyGen = () => Math.floor((Math.random() * 0x10000));
export default @connect(mapStateToProps)
@injectIntl
class ChatBox extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
chatId: PropTypes.string.isRequired,
chatMessageIds: ImmutablePropTypes.orderedSet,
chat: ImmutablePropTypes.map,
onSetInputRef: PropTypes.func,
me: PropTypes.node,
}
initialState = () => ({
content: '',
attachment: undefined,
isUploading: false,
uploadProgress: 0,
resetFileKey: fileKeyGen(),
})
state = this.initialState()
clearState = () => {
this.setState(this.initialState());
}
getParams = () => {
const { content, attachment } = this.state;
return {
content,
media_id: attachment && attachment.id,
};
}
canSubmit = () => {
const { content, attachment } = this.state;
const conds = [
content.length > 0,
attachment,
];
return conds.some(c => c);
}
sendMessage = () => {
const { dispatch, chatId } = this.props;
const { isUploading } = this.state;
if (this.canSubmit() && !isUploading) {
const params = this.getParams();
dispatch(sendChatMessage(chatId, params));
this.clearState();
}
}
insertLine = () => {
const { content } = this.state;
this.setState({ content: content + '\n' });
}
handleKeyDown = (e) => {
if (e.key === 'Enter' && e.shiftKey) {
this.insertLine();
e.preventDefault();
} else if (e.key === 'Enter') {
this.sendMessage();
e.preventDefault();
}
}
handleContentChange = (e) => {
this.setState({ content: e.target.value });
}
markRead = () => {
const { dispatch, chatId } = this.props;
dispatch(markChatRead(chatId));
}
handleHover = () => {
this.markRead();
}
setInputRef = (el) => {
const { onSetInputRef } = this.props;
this.inputElem = el;
onSetInputRef(el);
};
componentDidUpdate(prevProps) {
const markReadConditions = [
() => this.props.chat !== undefined,
() => document.activeElement === this.inputElem,
() => this.props.chat.get('unread') > 0,
];
if (markReadConditions.every(c => c() === true))
this.markRead();
}
handleRemoveFile = (e) => {
this.setState({ attachment: undefined, resetFileKey: fileKeyGen() });
}
onUploadProgress = (e) => {
const { loaded, total } = e;
this.setState({ uploadProgress: loaded/total });
}
handleFiles = (files) => {
const { dispatch } = this.props;
this.setState({ isUploading: true });
const data = new FormData();
data.append('file', files[0]);
dispatch(uploadMedia(data, this.onUploadProgress)).then(response => {
this.setState({ attachment: response.data, isUploading: false });
}).catch(() => {
this.setState({ isUploading: false });
});
}
renderAttachment = () => {
const { attachment } = this.state;
if (!attachment) return null;
return (
<div className='chat-box__attachment'>
<div className='chat-box__filename'>
{truncateFilename(attachment.preview_url, 20)}
</div>
<div class='chat-box__remove-attachment'>
<IconButton icon='remove' onClick={this.handleRemoveFile} />
</div>
</div>
);
}
renderActionButton = () => {
const { resetFileKey } = this.state;
return this.canSubmit() ? (
<div className='chat-box__send'>
<IconButton icon='send' size={16} onClick={this.sendMessage} />
</div>
) : (
<UploadButton onSelectFile={this.handleFiles} resetFileKey={resetFileKey} />
);
}
render() {
const { chatMessageIds, chatId, intl } = this.props;
const { content, isUploading, uploadProgress } = this.state;
if (!chatMessageIds) return null;
return (
<div className='chat-box' onMouseOver={this.handleHover}>
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} />
{this.renderAttachment()}
<UploadProgress active={isUploading} progress={uploadProgress*100} />
<div className='chat-box__actions simple_form'>
{this.renderActionButton()}
<textarea
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={this.handleKeyDown}
onChange={this.handleContentChange}
value={content}
ref={this.setInputRef}
/>
</div>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,66 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Chat from './chat';
import { makeGetChat } from 'soapbox/selectors';
const chatDateComparator = (chatA, chatB) => {
// Sort most recently updated chats at the top
const a = new Date(chatA.get('updated_at'));
const b = new Date(chatB.get('updated_at'));
if (a === b) return 0;
if (a > b) return -1;
if (a < b) return 1;
return 0;
};
const mapStateToProps = state => {
const getChat = makeGetChat();
const chats = state.get('chats')
.map(chat => getChat(state, chat.toJS()))
.toList()
.sort(chatDateComparator);
return {
chats,
};
};
export default @connect(mapStateToProps)
@injectIntl
class ChatList extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onClickChat: PropTypes.func,
emptyMessage: PropTypes.node,
};
render() {
const { chats, emptyMessage } = this.props;
return (
<div className='chat-list'>
<div className='chat-list__content'>
{chats.count() === 0 &&
<div className='empty-column-indicator'>{emptyMessage}</div>
}
{chats.map(chat => (
<div key={chat.get('id')} className='chat-list-item'>
<Chat
chat={chat}
onClick={this.props.onClickChat}
/>
</div>
))}
</div>
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,225 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { fetchChatMessages } from 'soapbox/actions/chats';
import emojify from 'soapbox/features/emoji/emoji';
import classNames from 'classnames';
import { openModal } from 'soapbox/actions/modal';
import { escape, throttle } from 'lodash';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import Bundle from 'soapbox/features/ui/components/bundle';
const makeEmojiMap = record => record.get('emojis', ImmutableList()).reduce((map, emoji) => {
return map.set(`:${emoji.get('shortcode')}:`, emoji);
}, ImmutableMap());
const mapStateToProps = (state, { chatMessageIds }) => ({
me: state.get('me'),
chatMessages: chatMessageIds.reduce((acc, curr) => {
const chatMessage = state.getIn(['chat_messages', curr]);
return chatMessage ? acc.push(chatMessage) : acc;
}, ImmutableList()),
});
export default @connect(mapStateToProps)
@injectIntl
class ChatMessageList extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
chatId: PropTypes.string,
chatMessages: ImmutablePropTypes.list,
chatMessageIds: ImmutablePropTypes.orderedSet,
me: PropTypes.node,
}
static defaultProps = {
chatMessages: ImmutableList(),
}
state = {
initialLoad: true,
isLoading: false,
}
scrollToBottom = () => {
if (!this.messagesEnd) return;
this.messagesEnd.scrollIntoView(false);
}
setMessageEndRef = (el) => {
this.messagesEnd = el;
};
getFormattedTimestamp = (chatMessage) => {
const { intl } = this.props;
return intl.formatDate(
new Date(chatMessage.get('created_at')), {
hour12: false,
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}
);
};
setBubbleRef = (c) => {
if (!c) return;
const links = c.querySelectorAll('a[rel="ugc"]');
links.forEach(link => {
link.classList.add('chat-link');
link.setAttribute('rel', 'ugc nofollow noopener');
link.setAttribute('target', '_blank');
});
}
isNearBottom = () => {
const elem = this.node;
if (!elem) return false;
const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
return scrollBottom < elem.offsetHeight * 1.5;
}
handleResize = (e) => {
if (this.isNearBottom()) this.scrollToBottom();
}
componentDidMount() {
const { dispatch, chatId } = this.props;
dispatch(fetchChatMessages(chatId));
this.node.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize);
this.scrollToBottom();
}
getSnapshotBeforeUpdate(prevProps, prevState) {
const { scrollHeight, scrollTop } = this.node;
return scrollHeight - scrollTop;
}
restoreScrollPosition = (scrollBottom) => {
this.lastComputedScroll = this.node.scrollHeight - scrollBottom;
this.node.scrollTop = this.lastComputedScroll;
}
componentDidUpdate(prevProps, prevState, scrollBottom) {
const { initialLoad } = this.state;
const oldCount = prevProps.chatMessages.count();
const newCount = this.props.chatMessages.count();
const isNearBottom = this.isNearBottom();
const historyAdded = prevProps.chatMessages.getIn([0, 'id']) !== this.props.chatMessages.getIn([0, 'id']);
// Retain scroll bar position when loading old messages
this.restoreScrollPosition(scrollBottom);
if (oldCount !== newCount) {
if (isNearBottom || initialLoad) this.scrollToBottom();
if (historyAdded) this.setState({ isLoading: false, initialLoad: false });
}
}
componentWillUnmount() {
this.node.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
}
handleLoadMore = () => {
const { dispatch, chatId, chatMessages } = this.props;
const maxId = chatMessages.getIn([0, 'id']);
dispatch(fetchChatMessages(chatId, maxId));
this.setState({ isLoading: true });
}
handleScroll = throttle(() => {
const { lastComputedScroll } = this;
const { isLoading, initialLoad } = this.state;
const { scrollTop, offsetHeight } = this.node;
const computedScroll = lastComputedScroll === scrollTop;
const nearTop = scrollTop < offsetHeight * 2;
if (nearTop && !isLoading && !initialLoad && !computedScroll)
this.handleLoadMore();
}, 150, {
trailing: true,
});
onOpenMedia = (media, index) => {
this.props.dispatch(openModal('MEDIA', { media, index }));
};
maybeRenderMedia = chatMessage => {
const attachment = chatMessage.get('attachment');
if (!attachment) return null;
return (
<div className='chat-message__media'>
<Bundle fetchComponent={MediaGallery}>
{Component => (
<Component
media={ImmutableList([attachment])}
height={120}
onOpenMedia={this.onOpenMedia}
/>
)}
</Bundle>
</div>
);
}
parsePendingContent = content => {
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
}
parseContent = chatMessage => {
const content = chatMessage.get('content') || '';
const pending = chatMessage.get('pending', false);
const formatted = pending ? this.parsePendingContent(content) : content;
const emojiMap = makeEmojiMap(chatMessage);
return emojify(formatted, emojiMap.toJS());
}
setRef = (c) => {
this.node = c;
}
render() {
const { chatMessages, me } = this.props;
return (
<div className='chat-messages' ref={this.setRef}>
{chatMessages.map(chatMessage => (
<div
className={classNames('chat-message', {
'chat-message--me': chatMessage.get('account_id') === me,
'chat-message--pending': chatMessage.get('pending', false) === true,
})}
key={chatMessage.get('id')}
>
<div
title={this.getFormattedTimestamp(chatMessage)}
className='chat-message__bubble'
ref={this.setBubbleRef}
>
{this.maybeRenderMedia(chatMessage)}
<span
className='chat-message__content'
dangerouslySetInnerHTML={{ __html: this.parseContent(chatMessage) }}
/>
</div>
</div>
))}
<div style={{ float: 'left', clear: 'both' }} ref={this.setMessageEndRef} />
</div>
);
}
}

Wyświetl plik

@ -0,0 +1,85 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { getSettings } from 'soapbox/actions/settings';
import ChatList from './chat_list';
import { FormattedMessage } from 'react-intl';
import { makeGetChat } from 'soapbox/selectors';
import { openChat, toggleMainWindow } from 'soapbox/actions/chats';
import ChatWindow from './chat_window';
import { shortNumberFormat } from 'soapbox/utils/numbers';
const addChatsToPanes = (state, panesData) => {
const getChat = makeGetChat();
const newPanes = panesData.get('panes').map(pane => {
const chat = getChat(state, { id: pane.get('chat_id') });
return pane.set('chat', chat);
});
return panesData.set('panes', newPanes);
};
const mapStateToProps = state => {
const panesData = getSettings(state).get('chats');
return {
panesData: addChatsToPanes(state, panesData),
unreadCount: state.get('chats').reduce((acc, curr) => acc + curr.get('unread'), 0),
};
};
export default @connect(mapStateToProps)
@injectIntl
class ChatPanes extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
panesData: ImmutablePropTypes.map,
}
handleClickChat = (chat) => {
this.props.dispatch(openChat(chat.get('id')));
}
handleMainWindowToggle = () => {
this.props.dispatch(toggleMainWindow());
}
render() {
const { panesData, unreadCount } = this.props;
const panes = panesData.get('panes');
const mainWindow = panesData.get('mainWindow');
const mainWindowPane = (
<div className={`pane pane--main pane--${mainWindow}`}>
<div className='pane__header'>
{unreadCount > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(unreadCount)}</i>}
<button className='pane__title' onClick={this.handleMainWindowToggle}>
<FormattedMessage id='chat_panels.main_window.title' defaultMessage='Chats' />
</button>
</div>
<div className='pane__content'>
<ChatList
onClickChat={this.handleClickChat}
emptyMessage={<FormattedMessage id='chat_panels.main_window.empty' defaultMessage="No chats found. To start a chat, visit a user's profile." />}
/>
</div>
</div>
);
return (
<div className='chat-panes'>
{mainWindowPane}
{panes.map((pane, i) =>
<ChatWindow idx={i} pane={pane} key={pane.get('chat_id')} />
)}
</div>
);
}
}

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