commit
dbddc177c1
@ -1,4 +1,3 @@
|
||||
NODE_ENV=development
|
||||
# BACKEND_URL="https://example.com"
|
||||
# PATRON_URL="https://patron.example.com"
|
||||
# PROXY_HTTPS_INSECURE=false
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 3.4 KiB |
@ -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."
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"9vMAje101ngtjlMj7w": {
|
||||
"followers_count": 2,
|
||||
"following_count": 3,
|
||||
"statuses_count": 2
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"9vMAje101ngtjlMj7w": {
|
||||
"followers_count": 2,
|
||||
"following_count": 2,
|
||||
"statuses_count": 2
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"9vMAje101ngtjlMj7w": {
|
||||
"followers_count": 2,
|
||||
"following_count": 1,
|
||||
"statuses_count": 2
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
@ -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"
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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't seem to report coverage, but I suspect it's better than both of these combined.<br/><br/>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.",
|
||||
"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"
|
||||
}
|
@ -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
|
||||
}
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
@ -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 });
|
||||
});
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
@ -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 });
|
||||
});
|
||||
};
|
||||
}
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
@ -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) {
|
||||
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));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function importFetchedInstance(instance) {
|
||||
return {
|
||||
type: PATRON_FUNDING_IMPORT,
|
||||
funding,
|
||||
type: PATRON_INSTANCE_FETCH_SUCCESS,
|
||||
instance,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchFundingFail(error) {
|
||||
function fetchInstanceFail(error) {
|
||||
return {
|
||||
type: PATRON_FUNDING_FETCH_FAIL,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const DonorBadge = () => (
|
||||
<span className='badge badge--donor'>Donor</span>
|
||||
);
|
||||
|
||||
export default DonorBadge;
|
@ -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;
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const InvestorBadge = () => (
|
||||
<span className='badge badge--investor'>Investor</span>
|
||||
);
|
||||
|
||||
export default InvestorBadge;
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const ProBadge = () => (
|
||||
<span className='badge badge--pro'>Pro</span>
|
||||
);
|
||||
|
||||
export default ProBadge;
|
@ -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);
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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' ],
|
||||
}));
|
||||
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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
Loading…
Reference in new issue