aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMaksim Pechnikov <parallel588@gmail.com>2020-09-07 09:47:17 +0300
committerMaksim Pechnikov <parallel588@gmail.com>2020-09-07 09:47:17 +0300
commitfa2b680855c790ba8ed8d7cc0dbf2a3a2e1dbaf6 (patch)
treeb2868a1c0d2fce025134af4167c824fc8ee49068 /src
parent12519a54b55140a3e5f76e67ac53914654c2a8b0 (diff)
parenta73b09c73202117ffa3fecf7a9185981d6696912 (diff)
Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop
Diffstat (limited to 'src')
-rw-r--r--src/App.js8
-rw-r--r--src/App.scss55
-rw-r--r--src/App.vue6
-rw-r--r--src/_variables.scss1
-rw-r--r--src/boot/after_store.js14
-rw-r--r--src/boot/routes.js17
-rw-r--r--src/components/account_actions/account_actions.js12
-rw-r--r--src/components/account_actions/account_actions.vue7
-rw-r--r--src/components/attachment/attachment.js32
-rw-r--r--src/components/attachment/attachment.vue39
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.js17
-rw-r--r--src/components/bookmark_timeline/bookmark_timeline.vue9
-rw-r--r--src/components/chat/chat.js333
-rw-r--r--src/components/chat/chat.scss162
-rw-r--r--src/components/chat/chat.vue100
-rw-r--r--src/components/chat/chat_layout_utils.js26
-rw-r--r--src/components/chat_list/chat_list.js37
-rw-r--r--src/components/chat_list/chat_list.vue64
-rw-r--r--src/components/chat_list_item/chat_list_item.js67
-rw-r--r--src/components/chat_list_item/chat_list_item.scss94
-rw-r--r--src/components/chat_list_item/chat_list_item.vue52
-rw-r--r--src/components/chat_message/chat_message.js96
-rw-r--r--src/components/chat_message/chat_message.scss164
-rw-r--r--src/components/chat_message/chat_message.vue99
-rw-r--r--src/components/chat_message_date/chat_message_date.vue24
-rw-r--r--src/components/chat_new/chat_new.js73
-rw-r--r--src/components/chat_new/chat_new.scss29
-rw-r--r--src/components/chat_new/chat_new.vue46
-rw-r--r--src/components/chat_panel/chat_panel.vue84
-rw-r--r--src/components/chat_title/chat_title.js26
-rw-r--r--src/components/chat_title/chat_title.vue67
-rw-r--r--src/components/checkbox/checkbox.vue2
-rw-r--r--src/components/conversation/conversation.vue33
-rw-r--r--src/components/emoji_input/emoji_input.js61
-rw-r--r--src/components/emoji_input/emoji_input.vue5
-rw-r--r--src/components/emoji_reactions/emoji_reactions.js4
-rw-r--r--src/components/emoji_reactions/emoji_reactions.vue65
-rw-r--r--src/components/extra_buttons/extra_buttons.js10
-rw-r--r--src/components/extra_buttons/extra_buttons.vue16
-rw-r--r--src/components/features_panel/features_panel.js1
-rw-r--r--src/components/features_panel/features_panel.vue3
-rw-r--r--src/components/gallery/gallery.vue4
-rw-r--r--src/components/global_notice_list/global_notice_list.js15
-rw-r--r--src/components/global_notice_list/global_notice_list.vue77
-rw-r--r--src/components/media_modal/media_modal.vue10
-rw-r--r--src/components/media_upload/media_upload.js3
-rw-r--r--src/components/media_upload/media_upload.vue8
-rw-r--r--src/components/mobile_nav/mobile_nav.js9
-rw-r--r--src/components/mobile_nav/mobile_nav.vue5
-rw-r--r--src/components/mobile_post_status_button/mobile_post_status_button.js7
-rw-r--r--src/components/nav_panel/nav_panel.js29
-rw-r--r--src/components/nav_panel/nav_panel.vue31
-rw-r--r--src/components/notification/notification.js6
-rw-r--r--src/components/notification/notification.scss52
-rw-r--r--src/components/notification/notification.vue3
-rw-r--r--src/components/notifications/notifications.js19
-rw-r--r--src/components/notifications/notifications.scss22
-rw-r--r--src/components/password_reset/password_reset.js5
-rw-r--r--src/components/poll/poll.vue3
-rw-r--r--src/components/poll/poll_form.js1
-rw-r--r--src/components/popover/popover.js6
-rw-r--r--src/components/popover/popover.vue5
-rw-r--r--src/components/post_status_form/post_status_form.js262
-rw-r--r--src/components/post_status_form/post_status_form.vue238
-rw-r--r--src/components/react_button/react_button.js5
-rw-r--r--src/components/settings_modal/settings_modal.scss11
-rw-r--r--src/components/settings_modal/settings_modal_content.js28
-rw-r--r--src/components/settings_modal/settings_modal_content.vue9
-rw-r--r--src/components/settings_modal/tabs/filtering_tab.js3
-rw-r--r--src/components/settings_modal/tabs/general_tab.vue10
-rw-r--r--src/components/settings_modal/tabs/notifications_tab.vue34
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js61
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss39
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue51
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.js6
-rw-r--r--src/components/settings_modal/tabs/theme_tab/theme_tab.vue67
-rw-r--r--src/components/side_drawer/side_drawer.js13
-rw-r--r--src/components/side_drawer/side_drawer.vue52
-rw-r--r--src/components/staff_panel/staff_panel.js4
-rw-r--r--src/components/status/status.js63
-rw-r--r--src/components/status/status.scss414
-rw-r--r--src/components/status/status.vue531
-rw-r--r--src/components/status_content/status_content.js28
-rw-r--r--src/components/status_content/status_content.vue159
-rw-r--r--src/components/status_popover/status_popover.js4
-rw-r--r--src/components/status_popover/status_popover.vue8
-rw-r--r--src/components/still-image/still-image.js3
-rw-r--r--src/components/still-image/still-image.vue52
-rw-r--r--src/components/tab_switcher/tab_switcher.js34
-rw-r--r--src/components/timeline/timeline.js25
-rw-r--r--src/components/timeline/timeline.vue24
-rw-r--r--src/components/timeline_menu/timeline_menu.js63
-rw-r--r--src/components/timeline_menu/timeline_menu.vue180
-rw-r--r--src/components/user_avatar/user_avatar.js16
-rw-r--r--src/components/user_avatar/user_avatar.vue8
-rw-r--r--src/components/user_card/user_card.vue13
-rw-r--r--src/components/user_list_popover/user_list_popover.js18
-rw-r--r--src/components/user_list_popover/user_list_popover.vue71
-rw-r--r--src/components/user_panel/user_panel.vue4
-rw-r--r--src/components/user_profile/user_profile.vue7
-rw-r--r--src/components/user_reporting_modal/user_reporting_modal.vue3
-rw-r--r--src/components/video_attachment/video_attachment.vue2
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js17
-rw-r--r--src/hocs/with_load_more/with_load_more.scss4
-rw-r--r--src/i18n/ar.json2
-rw-r--r--src/i18n/ca.json10
-rw-r--r--src/i18n/cs.json7
-rw-r--r--src/i18n/de.json27
-rw-r--r--src/i18n/en.json82
-rw-r--r--src/i18n/eo.json550
-rw-r--r--src/i18n/es.json8
-rw-r--r--src/i18n/et.json7
-rw-r--r--src/i18n/eu.json36
-rw-r--r--src/i18n/fi.json86
-rw-r--r--src/i18n/fr.json13
-rw-r--r--src/i18n/ga.json8
-rw-r--r--src/i18n/he.json20
-rw-r--r--src/i18n/hu.json2
-rw-r--r--src/i18n/it.json294
-rw-r--r--src/i18n/ja_easy.json82
-rw-r--r--src/i18n/ja_pedantic.json8
-rw-r--r--src/i18n/ko.json2
-rw-r--r--src/i18n/nb.json7
-rw-r--r--src/i18n/nl.json8
-rw-r--r--src/i18n/oc.json9
-rw-r--r--src/i18n/pl.json80
-rw-r--r--src/i18n/pt.json2
-rw-r--r--src/i18n/ro.json2
-rw-r--r--src/i18n/ru.json16
-rw-r--r--src/i18n/te.json2
-rw-r--r--src/i18n/zh.json24
-rw-r--r--src/main.js20
-rw-r--r--src/modules/api.js29
-rw-r--r--src/modules/chats.js234
-rw-r--r--src/modules/config.js5
-rw-r--r--src/modules/instance.js3
-rw-r--r--src/modules/interface.js56
-rw-r--r--src/modules/media_viewer.js2
-rw-r--r--src/modules/statuses.js103
-rw-r--r--src/modules/users.js11
-rw-r--r--src/services/api/api.service.js202
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js4
-rw-r--r--src/services/chat_service/chat_service.js151
-rw-r--r--src/services/chat_utils/chat_utils.js19
-rw-r--r--src/services/completion/completion.js50
-rw-r--r--src/services/desktop_notification_utils/desktop_notification_utils.js9
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js53
-rw-r--r--src/services/follow_request_fetcher/follow_request_fetcher.service.js1
-rw-r--r--src/services/notification_utils/notification_utils.js40
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js12
-rw-r--r--src/services/status_poster/status_poster.service.js30
-rw-r--r--src/services/style_setter/style_setter.js3
-rw-r--r--src/services/theme_data/pleromafe.js93
-rw-r--r--src/services/theme_data/theme_data.service.js9
-rw-r--r--src/services/timeline_fetcher/timeline_fetcher.service.js23
-rw-r--r--src/services/window_utils/window_utils.js5
156 files changed, 5933 insertions, 1652 deletions
diff --git a/src/App.js b/src/App.js
index 040138c9..ded772fa 100644
--- a/src/App.js
+++ b/src/App.js
@@ -13,7 +13,8 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
-import { windowWidth } from './services/window_utils/window_utils'
+import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
+import { windowWidth, windowHeight } from './services/window_utils/window_utils'
export default {
name: 'app',
@@ -32,7 +33,8 @@ export default {
MobileNav,
SettingsModal,
UserReportingModal,
- PostStatusModal
+ PostStatusModal,
+ GlobalNoticeList
},
data: () => ({
mobileActivePanel: 'timeline',
@@ -125,10 +127,12 @@ export default {
},
updateMobileState () {
const mobileLayout = windowWidth() <= 800
+ const layoutHeight = windowHeight()
const changed = mobileLayout !== this.isMobileLayout
if (changed) {
this.$store.dispatch('setMobileLayout', mobileLayout)
}
+ this.$store.dispatch('setLayoutHeight', layoutHeight)
}
}
}
diff --git a/src/App.scss b/src/App.scss
index f2972eda..e2e2d079 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -47,6 +47,7 @@ html {
}
body {
+ overscroll-behavior-y: none;
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
margin: 0;
@@ -319,7 +320,7 @@ option {
i[class*=icon-] {
color: $fallback--icon;
- color: var(--icon, $fallback--icon)
+ color: var(--icon, $fallback--icon);
}
.btn-block {
@@ -858,6 +859,10 @@ nav {
display: block;
margin-right: 0.8em;
}
+
+ .main {
+ margin-bottom: 7em;
+ }
}
.select-multiple {
@@ -924,3 +929,51 @@ nav {
background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg);
}
+
+.unread-chat-count {
+ font-size: 0.9em;
+ font-weight: bolder;
+ font-style: normal;
+ position: absolute;
+ right: 0.6rem;
+ padding: 0 0.3em;
+ min-width: 1.3rem;
+ min-height: 1.3rem;
+ max-height: 1.3rem;
+ line-height: 1.3rem;
+}
+
+.chat-layout {
+ // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
+ overflow: hidden;
+ height: 100%;
+
+ // Ensures the fixed position of the mobile browser bars on scroll up / down events.
+ // Prevents the mobile browser bars from overlapping or hiding the message posting form.
+ @media all and (max-width: 800px) {
+ body {
+ height: 100%;
+ }
+
+ #app {
+ height: 100%;
+ overflow: hidden;
+ min-height: auto;
+ }
+
+ #app_bg_wrapper {
+ overflow: hidden;
+ }
+
+ .main {
+ overflow: hidden;
+ height: 100%;
+ }
+
+ #content {
+ padding-top: 0;
+ height: 100%;
+ overflow: visible;
+ }
+ }
+}
diff --git a/src/App.vue b/src/App.vue
index 7b9ad3dc..0276c6a6 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -77,6 +77,7 @@
</div>
</div>
</nav>
+ <div class="app-bg-wrapper app-container-wrapper" />
<div
id="content"
class="container underlay"
@@ -112,9 +113,7 @@
{{ $t("login.hint") }}
</router-link>
</div>
- <transition name="fade">
- <router-view />
- </transition>
+ <router-view />
</div>
<media-modal />
</div>
@@ -128,6 +127,7 @@
<PostStatusModal />
<SettingsModal />
<portal-target name="modal" />
+ <GlobalNoticeList />
</div>
</template>
diff --git a/src/_variables.scss b/src/_variables.scss
index 30dc3e42..9004d551 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 10px;
+$fallback--chatMessageRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 1796eb1b..00ca74a2 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -20,15 +20,23 @@ const parsedInitialResults = () => {
return staticInitialResults
}
+const decodeUTF8Base64 = (data) => {
+ const rawData = atob(data)
+ const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
+ const text = new TextDecoder().decode(array)
+ return text
+}
+
const preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
- const requestData = atob(data[request])
+ const decoded = decodeUTF8Base64(data[request])
+ const requestData = JSON.parse(decoded)
return {
ok: true,
- json: () => JSON.parse(requestData),
+ json: () => requestData,
text: () => requestData
}
}
@@ -215,7 +223,6 @@ const getAppSecret = async ({ store }) => {
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop())
- nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
@@ -231,6 +238,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
+ store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
diff --git a/src/boot/routes.js b/src/boot/routes.js
index d98a3b50..b5d3c631 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -2,9 +2,12 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
+import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
+import ChatList from 'components/chat_list/chat_list.vue'
+import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
@@ -27,7 +30,7 @@ export default (store) => {
}
}
- return [
+ let routes = [
{ name: 'root',
path: '/',
redirect: _to => {
@@ -40,6 +43,7 @@ export default (store) => {
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
+ { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
@@ -60,11 +64,20 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
- { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
+ { name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
]
+
+ if (store.state.instance.pleromaChatMessagesAvailable) {
+ routes = routes.concat([
+ { name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
+ { name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
+ ])
+ }
+
+ return routes
}
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 0826c275..6d345bc7 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,3 +1,4 @@
+import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
@@ -27,7 +28,18 @@ const AccountActions = {
},
reportUser () {
this.$store.dispatch('openUserReportingModal', this.user.id)
+ },
+ openChat () {
+ this.$router.push({
+ name: 'chat',
+ params: { recipient_id: this.user.id }
+ })
}
+ },
+ computed: {
+ ...mapState({
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ })
}
}
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 029e7096..987e94b7 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -50,6 +50,13 @@
>
{{ $t('user_card.report') }}
</button>
+ <button
+ v-if="pleromaChatMessagesAvailable"
+ class="btn btn-default btn-block dropdown-item"
+ @click="openChat"
+ >
+ {{ $t('user_card.message') }}
+ </button>
</div>
</div>
<div
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index b832e10f..cb31020d 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -8,7 +8,6 @@ const Attachment = {
props: [
'attachment',
'nsfw',
- 'statusId',
'size',
'allowPlay',
'setMedia',
@@ -30,9 +29,21 @@ const Attachment = {
VideoAttachment
},
computed: {
- usePlaceHolder () {
+ usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown'
},
+ placeholderName () {
+ if (this.attachment.description === '' || !this.attachment.description) {
+ return this.type.toUpperCase()
+ }
+ return this.attachment.description
+ },
+ placeholderIconClass () {
+ if (this.type === 'image') return 'icon-picture'
+ if (this.type === 'video') return 'icon-video'
+ if (this.type === 'audio') return 'icon-music'
+ return 'icon-doc'
+ },
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
@@ -49,7 +60,15 @@ const Attachment = {
return this.size === 'small'
},
fullwidth () {
- return this.type === 'html' || this.type === 'audio'
+ if (this.size === 'hide') return false
+ return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
+ },
+ useModal () {
+ const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
+ : this.mergedConfig.playVideosInModal
+ ? ['image', 'video']
+ : ['image']
+ return modalTypes.includes(this.type)
},
...mapGetters(['mergedConfig'])
},
@@ -60,12 +79,7 @@ const Attachment = {
}
},
openModal (event) {
- const modalTypes = this.mergedConfig.playVideosInModal
- ? ['image', 'video']
- : ['image']
- if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
- this.usePlaceHolder
- ) {
+ if (this.useModal) {
event.stopPropagation()
event.preventDefault()
this.setMedia()
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index a7e217c1..63e0ceba 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,6 +1,7 @@
<template>
<div
- v-if="usePlaceHolder"
+ v-if="usePlaceholder"
+ :class="{ 'fullwidth': fullwidth }"
@click="openModal"
>
<a
@@ -8,8 +9,11 @@
class="placeholder"
target="_blank"
:href="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
>
- [{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}]
+ <span :class="placeholderIconClass" />
+ <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a>
</div>
<div
@@ -22,6 +26,8 @@
v-if="hidden"
class="image-attachment"
:href="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
@click.prevent="toggleHidden"
>
<img
@@ -51,14 +57,15 @@
:class="{'hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
- :title="attachment.description"
@click="openModal"
>
<StillImage
+ class="image"
:referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad"
+ :alt="attachment.description"
/>
</a>
@@ -83,6 +90,8 @@
<audio
v-if="type === 'audio'"
:src="attachment.url"
+ :alt="attachment.description"
+ :title="attachment.description"
controls
/>
@@ -116,22 +125,19 @@
display: flex;
flex-wrap: wrap;
- .attachment.media-upload-container {
- flex: 0 0 auto;
- max-height: 200px;
+ .non-gallery {
max-width: 100%;
- display: flex;
- align-items: center;
- video {
- max-width: 100%;
- }
}
.placeholder {
- margin-right: 8px;
- margin-bottom: 4px;
+ display: inline-block;
+ padding: 0.3em 1em 0.3em 0;
color: $fallback--link;
color: var(--postLink, $fallback--link);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ max-width: 100%;
}
.nsfw-placeholder {
@@ -276,8 +282,11 @@
}
.image-attachment {
- width: 100%;
- height: 100%;
+ &,
+ & .image {
+ width: 100%;
+ height: 100%;
+ }
&.hidden {
display: none;
diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js
new file mode 100644
index 00000000..64b69e5d
--- /dev/null
+++ b/src/components/bookmark_timeline/bookmark_timeline.js
@@ -0,0 +1,17 @@
+import Timeline from '../timeline/timeline.vue'
+
+const Bookmarks = {
+ computed: {
+ timeline () {
+ return this.$store.state.statuses.timelines.bookmarks
+ }
+ },
+ components: {
+ Timeline
+ },
+ destroyed () {
+ this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
+ }
+}
+
+export default Bookmarks
diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue
new file mode 100644
index 00000000..8da6884b
--- /dev/null
+++ b/src/components/bookmark_timeline/bookmark_timeline.vue
@@ -0,0 +1,9 @@
+<template>
+ <Timeline
+ :title="$t('nav.bookmarks')"
+ :timeline="timeline"
+ :timeline-name="'bookmarks'"
+ />
+</template>
+
+<script src="./bookmark_timeline.js"></script>
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
new file mode 100644
index 00000000..9c4e5b05
--- /dev/null
+++ b/src/components/chat/chat.js
@@ -0,0 +1,333 @@
+import _ from 'lodash'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
+import ChatMessage from '../chat_message/chat_message.vue'
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+import chatService from '../../services/chat_service/chat_service.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
+
+const BOTTOMED_OUT_OFFSET = 10
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+const SAFE_RESIZE_TIME_OFFSET = 100
+
+const Chat = {
+ components: {
+ ChatMessage,
+ ChatTitle,
+ PostStatusForm
+ },
+ data () {
+ return {
+ jumpToBottomButtonVisible: false,
+ hoveredMessageChainId: undefined,
+ lastScrollPosition: {},
+ scrollableContainerHeight: '100%',
+ errorLoadingChat: false
+ }
+ },
+ created () {
+ this.startFetching()
+ window.addEventListener('resize', this.handleLayoutChange)
+ },
+ mounted () {
+ window.addEventListener('scroll', this.handleScroll)
+ if (typeof document.hidden !== 'undefined') {
+ document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.handleResize()
+ })
+ this.setChatLayout()
+ },
+ destroyed () {
+ window.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('resize', this.handleLayoutChange)
+ this.unsetChatLayout()
+ if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
+ this.$store.dispatch('clearCurrentChat')
+ },
+ computed: {
+ recipient () {
+ return this.currentChat && this.currentChat.account
+ },
+ recipientId () {
+ return this.$route.params.recipient_id
+ },
+ formPlaceholder () {
+ if (this.recipient) {
+ return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
+ } else {
+ return ''
+ }
+ },
+ chatViewItems () {
+ return chatService.getView(this.currentChatMessageService)
+ },
+ newMessageCount () {
+ return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
+ },
+ streamingEnabled () {
+ return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+ },
+ ...mapGetters([
+ 'currentChat',
+ 'currentChatMessageService',
+ 'findOpenedChatByRecipientId',
+ 'mergedConfig'
+ ]),
+ ...mapState({
+ backendInteractor: state => state.api.backendInteractor,
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
+ mobileLayout: state => state.interface.mobileLayout,
+ layoutHeight: state => state.interface.layoutHeight,
+ currentUser: state => state.users.currentUser
+ })
+ },
+ watch: {
+ chatViewItems () {
+ // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
+ // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
+ const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
+ this.$nextTick(() => {
+ if (bottomedOutBeforeUpdate) {
+ this.scrollDown({ forceRead: !document.hidden })
+ }
+ })
+ },
+ '$route': function () {
+ this.startFetching()
+ },
+ layoutHeight () {
+ this.handleResize({ expand: true })
+ },
+ mastoUserSocketStatus (newValue) {
+ if (newValue === WSConnectionStatus.JOINED) {
+ this.fetchChat({ isFirstFetch: true })
+ }
+ }
+ },
+ methods: {
+ // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
+ onMessageHover ({ isHovered, messageChainId }) {
+ this.hoveredMessageChainId = isHovered ? messageChainId : undefined
+ },
+ onFilesDropped () {
+ this.$nextTick(() => {
+ this.handleResize()
+ this.updateScrollableContainerHeight()
+ })
+ },
+ handleVisibilityChange () {
+ this.$nextTick(() => {
+ if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
+ this.scrollDown({ forceRead: true })
+ }
+ })
+ },
+ setChatLayout () {
+ // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
+ // This layout prevents empty spaces from being visible at the bottom
+ // of the chat on iOS Safari (`safe-area-inset`) when
+ // - the on-screen keyboard appears and the user starts typing
+ // - the user selects the text inside the input area
+ // - the user selects and deletes the text that is multiple lines long
+ // TODO: unify the chat layout with the global layout.
+ let html = document.querySelector('html')
+ if (html) {
+ html.classList.add('chat-layout')
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ })
+ },
+ unsetChatLayout () {
+ let html = document.querySelector('html')
+ if (html) {
+ html.classList.remove('chat-layout')
+ }
+ },
+ handleLayoutChange () {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.scrollDown()
+ })
+ },
+ // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
+ updateScrollableContainerHeight () {
+ const header = this.$refs.header
+ const footer = this.$refs.footer
+ const inner = this.mobileLayout ? window.document.body : this.$refs.inner
+ this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
+ },
+ // Preserves the scroll position when OSK appears or the posting form changes its height.
+ handleResize (opts = {}) {
+ const { expand = false, delayed = false } = opts
+
+ if (delayed) {
+ setTimeout(() => {
+ this.handleResize({ ...opts, delayed: false })
+ }, SAFE_RESIZE_TIME_OFFSET)
+ return
+ }
+
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+
+ const { offsetHeight = undefined } = this.lastScrollPosition
+ this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
+
+ const diff = this.lastScrollPosition.offsetHeight - offsetHeight
+ if (diff < 0 || (!this.bottomedOut() && expand)) {
+ this.$nextTick(() => {
+ this.updateScrollableContainerHeight()
+ this.$refs.scrollable.scrollTo({
+ top: this.$refs.scrollable.scrollTop - diff,
+ left: 0
+ })
+ })
+ }
+ })
+ },
+ scrollDown (options = {}) {
+ const { behavior = 'auto', forceRead = false } = options
+ const scrollable = this.$refs.scrollable
+ if (!scrollable) { return }
+ this.$nextTick(() => {
+ scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
+ })
+ if (forceRead || this.newMessageCount > 0) {
+ this.readChat()
+ }
+ },
+ readChat () {
+ if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
+ if (document.hidden) { return }
+ const lastReadId = this.currentChatMessageService.lastMessage.id
+ this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
+ },
+ bottomedOut (offset) {
+ return isBottomedOut(this.$refs.scrollable, offset)
+ },
+ reachedTop () {
+ const scrollable = this.$refs.scrollable
+ return scrollable && scrollable.scrollTop <= 0
+ },
+ handleScroll: _.throttle(function () {
+ if (!this.currentChat) { return }
+
+ if (this.reachedTop()) {
+ this.fetchChat({ maxId: this.currentChatMessageService.minId })
+ } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
+ this.jumpToBottomButtonVisible = false
+ if (this.newMessageCount > 0) {
+ this.readChat()
+ }
+ } else {
+ this.jumpToBottomButtonVisible = true
+ }
+ }, 100),
+ handleScrollUp (positionBeforeLoading) {
+ const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
+ this.$refs.scrollable.scrollTo({
+ top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
+ left: 0
+ })
+ },
+ fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
+ const chatMessageService = this.currentChatMessageService
+ if (!chatMessageService) { return }
+ if (fetchLatest && this.streamingEnabled) { return }
+
+ const chatId = chatMessageService.chatId
+ const fetchOlderMessages = !!maxId
+ const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id
+
+ this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
+ .then((messages) => {
+ // Clear the current chat in case we're recovering from a ws connection loss.
+ if (isFirstFetch) {
+ chatService.clear(chatMessageService)
+ }
+
+ const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
+ this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
+ this.$nextTick(() => {
+ if (fetchOlderMessages) {
+ this.handleScrollUp(positionBeforeUpdate)
+ }
+
+ if (isFirstFetch) {
+ this.updateScrollableContainerHeight()
+ }
+ })
+ })
+ })
+ },
+ async startFetching () {
+ let chat = this.findOpenedChatByRecipientId(this.recipientId)
+ if (!chat) {
+ try {
+ chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
+ } catch (e) {
+ console.error('Error creating or getting a chat', e)
+ this.errorLoadingChat = true
+ }
+ }
+ if (chat) {
+ this.$nextTick(() => {
+ this.scrollDown({ forceRead: true })
+ })
+ this.$store.dispatch('addOpenedChat', { chat })
+ this.doStartFetching()
+ }
+ },
+ doStartFetching () {
+ this.$store.dispatch('startFetchingCurrentChat', {
+ fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
+ })
+ this.fetchChat({ isFirstFetch: true })
+ },
+ sendMessage ({ status, media }) {
+ const params = {
+ id: this.currentChat.id,
+ content: status
+ }
+
+ if (media[0]) {
+ params.mediaId = media[0].id
+ }
+
+ return this.backendInteractor.sendChatMessage(params)
+ .then(data => {
+ this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
+ this.$nextTick(() => {
+ this.handleResize()
+ // When the posting form size changes because of a media attachment, we need an extra resize
+ // to account for the potential delay in the DOM update.
+ setTimeout(() => {
+ this.updateScrollableContainerHeight()
+ }, SAFE_RESIZE_TIME_OFFSET)
+ this.scrollDown({ forceRead: true })
+ })
+ })
+
+ return data
+ })
+ .catch(error => {
+ console.error('Error sending message', error)
+ return {
+ error: this.$t('chats.error_sending_message')
+ }
+ })
+ },
+ goBack () {
+ this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
+ }
+ }
+}
+
+export default Chat
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
new file mode 100644
index 00000000..012a1b1d
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -0,0 +1,162 @@
+.chat-view {
+ display: flex;
+ height: calc(100vh - 60px);
+ width: 100%;
+
+ .chat-title {
+ // prevents chat header jumping on when the user avatar loads
+ height: 28px;
+ }
+
+ .chat-view-inner {
+ height: auto;
+ width: 100%;
+ overflow: visible;
+ display: flex;
+ margin: 0.5em 0.5em 0 0.5em;
+ }
+
+ .chat-view-body {
+ background-color: var(--chatBg, $fallback--bg);
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: visible;
+ min-height: 100%;
+ margin: 0 0 0 0;
+ border-radius: 10px 10px 0 0;
+ border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
+
+ &::after {
+ border-radius: 0;
+ }
+ }
+
+ .scrollable-message-list {
+ padding: 0 0.8em;
+ height: 100%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: 0;
+ }
+
+ .chat-view-heading {
+ align-items: center;
+ justify-content: space-between;
+ top: 50px;
+ display: flex;
+ z-index: 2;
+ position: sticky;
+ overflow: hidden;
+ }
+
+ .go-back-button {
+ cursor: pointer;
+ margin-right: 1.4em;
+
+ i {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .jump-to-bottom-button {
+ width: 2.5em;
+ height: 2.5em;
+ border-radius: 100%;
+ position: absolute;
+ right: 1.3em;
+ top: -3.2em;
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+ transition: 0.35s all;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ opacity: 0;
+ visibility: hidden;
+ cursor: pointer;
+
+ &.visible {
+ opacity: 1;
+ visibility: visible;
+ }
+
+ i {
+ font-size: 1em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ .unread-message-count {
+ font-size: 0.8em;
+ left: 50%;
+ transform: translate(-50%, 0);
+ border-radius: 100%;
+ margin-top: -1rem;
+ padding: 0;
+ }
+
+ .chat-loading-error {
+ width: 100%;
+ display: flex;
+ align-items: flex-end;
+ height: 100%;
+
+ .error {
+ width: 100%;
+ }
+ }
+ }
+
+ @media all and (max-width: 800px) {
+ height: 100%;
+ overflow: hidden;
+
+ .chat-view-inner {
+ overflow: hidden;
+ height: 100%;
+ margin-top: 0;
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ .chat-view-body {
+ display: flex;
+ min-height: auto;
+ overflow: hidden;
+ height: 100%;
+ margin: 0;
+ border-radius: 0;
+ }
+
+ .chat-view-heading {
+ position: static;
+ z-index: 9999;
+ top: 0;
+ margin-top: 0;
+ border-radius: 0;
+ }
+
+ .scrollable-message-list {
+ display: unset;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: auto;
+ }
+ }
+}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
new file mode 100644
index 00000000..2e4538c8
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,100 @@
+<template>
+ <div class="chat-view">
+ <div class="chat-view-inner">
+ <div
+ id="nav"
+ ref="inner"
+ class="panel-default panel chat-view-body"
+ >
+ <div
+ ref="header"
+ class="panel-heading chat-view-heading mobile-hidden"
+ >
+ <a
+ class="go-back-button"
+ @click="goBack"
+ >
+ <i class="button-icon icon-left-open" />
+ </a>
+ <div class="title text-center">
+ <ChatTitle
+ :user="recipient"
+ :with-avatar="true"
+ />
+ </div>
+ </div>
+ <template>
+ <div
+ ref="scrollable"
+ class="scrollable-message-list"
+ :style="{ height: scrollableContainerHeight }"
+ @scroll="handleScroll"
+ >
+ <template v-if="!errorLoadingChat">
+ <ChatMessage
+ v-for="chatViewItem in chatViewItems"
+ :key="chatViewItem.id"
+ :author="recipient"
+ :chat-view-item="chatViewItem"
+ :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
+ @hover="onMessageHover"
+ />
+ </template>
+ <div
+ v-else
+ class="chat-loading-error"
+ >
+ <div class="alert error">
+ {{ $t('chats.error_loading_chat') }}
+ </div>
+ </div>
+ </div>
+ <div
+ ref="footer"
+ class="panel-body footer"
+ >
+ <div
+ class="jump-to-bottom-button"
+ :class="{ 'visible': jumpToBottomButtonVisible }"
+ @click="scrollDown({ behavior: 'smooth' })"
+ >
+ <i class="icon-down-open">
+ <div
+ v-if="newMessageCount"
+ class="badge badge-notification unread-chat-count unread-message-count"
+ >
+ {{ newMessageCount }}
+ </div>
+ </i>
+ </div>
+ <PostStatusForm
+ :disable-subject="true"
+ :disable-scope-selector="true"
+ :disable-notice="true"
+ :disable-lock-warning="true"
+ :disable-polls="true"
+ :disable-sensitivity-checkbox="true"
+ :disable-submit="errorLoadingChat || !currentChat"
+ :disable-preview="true"
+ :post-handler="sendMessage"
+ :submit-on-enter="!mobileLayout"
+ :preserve-focus="!mobileLayout"
+ :auto-focus="!mobileLayout"
+ :placeholder="formPlaceholder"
+ :file-limit="1"
+ max-height="160"
+ emoji-picker-placement="top"
+ @resize="handleResize"
+ />
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat.scss';
+</style>
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 00000000..609dc0c9
--- /dev/null
+++ b/src/components/chat/chat_layout_utils.js
@@ -0,0 +1,26 @@
+// Captures a scroll position
+export const getScrollPosition = (el) => {
+ return {
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ offsetHeight: el.offsetHeight
+ }
+}
+
+// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
+// Takes two scroll positions, before and after the update.
+export const getNewTopPosition = (previousPosition, newPosition) => {
+ return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
+}
+
+export const isBottomedOut = (el, offset = 0) => {
+ if (!el) { return }
+ const scrollHeight = el.scrollTop + offset
+ const totalHeight = el.scrollHeight - el.offsetHeight
+ return totalHeight <= scrollHeight
+}
+
+// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
+export const scrollableContainerHeight = (inner, header, footer) => {
+ return inner.offsetHeight - header.clientHeight - footer.clientHeight
+}
diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js
new file mode 100644
index 00000000..95708d1d
--- /dev/null
+++ b/src/components/chat_list/chat_list.js
@@ -0,0 +1,37 @@
+import { mapState, mapGetters } from 'vuex'
+import ChatListItem from '../chat_list_item/chat_list_item.vue'
+import ChatNew from '../chat_new/chat_new.vue'
+import List from '../list/list.vue'
+
+const ChatList = {
+ components: {
+ ChatListItem,
+ List,
+ ChatNew
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ ...mapGetters(['sortedChatList'])
+ },
+ data () {
+ return {
+ isNew: false
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchChats', { latest: true })
+ },
+ methods: {
+ cancelNewChat () {
+ this.isNew = false
+ this.$store.dispatch('fetchChats', { latest: true })
+ },
+ newChat () {
+ this.isNew = true
+ }
+ }
+}
+
+export default ChatList
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
new file mode 100644
index 00000000..17e2f795
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,64 @@
+<template>
+ <div v-if="isNew">
+ <ChatNew @cancel="cancelNewChat" />
+ </div>
+ <div
+ v-else
+ class="chat-list panel panel-default"
+ >
+ <div class="panel-heading">
+ <span class="title">
+ {{ $t("chats.chats") }}
+ </span>
+ <button @click="newChat">
+ {{ $t("chats.new") }}
+ </button>
+ </div>
+ <div class="panel-body">
+ <div
+ v-if="sortedChatList.length > 0"
+ class="timeline"
+ >
+ <List :items="sortedChatList">
+ <template
+ slot="item"
+ slot-scope="{item}"
+ >
+ <ChatListItem
+ :key="item.id"
+ :compact="false"
+ :chat="item"
+ />
+ </template>
+ </List>
+ </div>
+ <div
+ v-else
+ class="emtpy-chat-list-alert"
+ >
+ <span>{{ $t('chats.empty_chat_list_placeholder') }}</span>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-list {
+ min-height: 25em;
+ margin-bottom: 0;
+}
+
+.emtpy-chat-list-alert {
+ padding: 3em;
+ font-size: 1.2em;
+ display: flex;
+ justify-content: center;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
+}
+
+</style>
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
new file mode 100644
index 00000000..bee1ad53
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -0,0 +1,67 @@
+import { mapState } from 'vuex'
+import StatusContent from '../status_content/status_content.vue'
+import fileType from 'src/services/file_type/file_type.service'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import AvatarList from '../avatar_list/avatar_list.vue'
+import Timeago from '../timeago/timeago.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+
+const ChatListItem = {
+ name: 'ChatListItem',
+ props: [
+ 'chat'
+ ],
+ components: {
+ UserAvatar,
+ AvatarList,
+ Timeago,
+ ChatTitle,
+ StatusContent
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ attachmentInfo () {
+ if (this.chat.lastMessage.attachments.length === 0) { return }
+
+ const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
+ if (types.includes('video')) {
+ return this.$t('file_type.video')
+ } else if (types.includes('audio')) {
+ return this.$t('file_type.audio')
+ } else if (types.includes('image')) {
+ return this.$t('file_type.image')
+ } else {
+ return this.$t('file_type.file')
+ }
+ },
+ messageForStatusContent () {
+ const message = this.chat.lastMessage
+ const isYou = message && message.account_id === this.currentUser.id
+ const content = message ? (this.attachmentInfo || message.content) : ''
+ const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
+ return {
+ summary: '',
+ statusnet_html: messagePreview,
+ text: messagePreview,
+ attachments: []
+ }
+ }
+ },
+ methods: {
+ openChat (_e) {
+ if (this.chat.id) {
+ this.$router.push({
+ name: 'chat',
+ params: {
+ username: this.currentUser.screen_name,
+ recipient_id: this.chat.account.id
+ }
+ })
+ }
+ }
+ }
+}
+
+export default ChatListItem
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
new file mode 100644
index 00000000..9e97b28e
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -0,0 +1,94 @@
+.chat-list-item {
+ display: flex;
+ flex-direction: row;
+ padding: 0.75em;
+ height: 5em;
+ overflow: hidden;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ :focus {
+ outline: none;
+ }
+
+ &:hover {
+ background-color: var(--selectedPost, $fallback--lightBg);
+ box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
+ }
+
+ .chat-list-item-left {
+ margin-right: 1em;
+ }
+
+ .chat-list-item-center {
+ width: 100%;
+ box-sizing: border-box;
+ overflow: hidden;
+ word-wrap: break-word;
+ }
+
+ .heading {
+ width: 100%;
+ display: inline-flex;
+ justify-content: space-between;
+ line-height: 1em;
+ }
+
+ .heading-right {
+ white-space: nowrap;
+ }
+
+ .name-and-account-name {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ flex-shrink: 1;
+ line-height: 1.4em;
+ }
+
+ .chat-preview {
+ display: inline-flex;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0.35em 0;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
+ width: 100%;
+ }
+
+ a {
+ color: var(--faintLink, $fallback--link);
+ text-decoration: none;
+ pointer-events: none;
+ }
+
+ &:hover .animated.avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+
+ .Avatar {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .StatusContent {
+ img.emoji {
+ width: 1.4em;
+ height: 1.4em;
+ }
+ }
+
+ .time-wrapper {
+ line-height: 1.4em;
+ }
+
+ .single-line {
+ padding-right: 1em;
+ }
+}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
new file mode 100644
index 00000000..1f8ecdf6
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,52 @@
+<template>
+ <div
+ class="chat-list-item"
+ @click.capture.prevent="openChat"
+ >
+ <div class="chat-list-item-left">
+ <UserAvatar
+ :user="chat.account"
+ height="48px"
+ width="48px"
+ />
+ </div>
+ <div class="chat-list-item-center">
+ <div class="heading">
+ <span
+ v-if="chat.account"
+ class="name-and-account-name"
+ >
+ <ChatTitle
+ :user="chat.account"
+ />
+ </span>
+ <span class="heading-right" />
+ </div>
+ <div class="chat-preview">
+ <StatusContent
+ :status="messageForStatusContent"
+ :single-line="true"
+ />
+ <div
+ v-if="chat.unread > 0"
+ class="badge badge-notification unread-chat-count"
+ >
+ {{ chat.unread }}
+ </div>
+ </div>
+ </div>
+ <div class="time-wrapper">
+ <Timeago
+ :time="chat.updated_at"
+ :auto-update="60"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./chat_list_item.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat_list_item.scss';
+</style>
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
new file mode 100644
index 00000000..be4a7c89
--- /dev/null
+++ b/src/components/chat_message/chat_message.js
@@ -0,0 +1,96 @@
+import { mapState, mapGetters } from 'vuex'
+import Popover from '../popover/popover.vue'
+import Attachment from '../attachment/attachment.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import Gallery from '../gallery/gallery.vue'
+import LinkPreview from '../link-preview/link-preview.vue'
+import StatusContent from '../status_content/status_content.vue'
+import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const ChatMessage = {
+ name: 'ChatMessage',
+ props: [
+ 'author',
+ 'edited',
+ 'noHeading',
+ 'chatViewItem',
+ 'hoveredMessageChain'
+ ],
+ components: {
+ Popover,
+ Attachment,
+ StatusContent,
+ UserAvatar,
+ Gallery,
+ LinkPreview,
+ ChatMessageDate
+ },
+ computed: {
+ // Returns HH:MM (hours and minutes) in local time.
+ createdAt () {
+ const time = this.chatViewItem.data.created_at
+ return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
+ },
+ isCurrentUser () {
+ return this.message.account_id === this.currentUser.id
+ },
+ message () {
+ return this.chatViewItem.data
+ },
+ userProfileLink () {
+ return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
+ },
+ isMessage () {
+ return this.chatViewItem.type === 'message'
+ },
+ messageForStatusContent () {
+ return {
+ summary: '',
+ statusnet_html: this.message.content,
+ text: this.message.content,
+ attachments: this.message.attachments
+ }
+ },
+ hasAttachment () {
+ return this.message.attachments.length > 0
+ },
+ ...mapState({
+ betterShadow: state => state.interface.browserSupport.cssFilter,
+ currentUser: state => state.users.currentUser,
+ restrictedNicknames: state => state.instance.restrictedNicknames
+ }),
+ popoverMarginStyle () {
+ if (this.isCurrentUser) {
+ return {}
+ } else {
+ return { left: 50 }
+ }
+ },
+ ...mapGetters(['mergedConfig', 'findUser'])
+ },
+ data () {
+ return {
+ hovered: false,
+ menuOpened: false
+ }
+ },
+ methods: {
+ onHover (bool) {
+ this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
+ },
+ async deleteMessage () {
+ const confirmed = window.confirm(this.$t('chats.delete_confirm'))
+ if (confirmed) {
+ await this.$store.dispatch('deleteChatMessage', {
+ messageId: this.chatViewItem.data.id,
+ chatId: this.chatViewItem.data.chat_id
+ })
+ }
+ this.hovered = false
+ this.menuOpened = false
+ }
+ }
+}
+
+export default ChatMessage
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
new file mode 100644
index 00000000..7d4ff60c
--- /dev/null
+++ b/src/components/chat_message/chat_message.scss
@@ -0,0 +1,164 @@
+@import '../../_variables.scss';
+
+.chat-message-wrapper {
+ &.hovered-message-chain {
+ .animated.Avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+ }
+
+ .chat-message-menu {
+ transition: opacity 0.1s;
+ opacity: 0;
+ position: absolute;
+ top: -0.8em;
+
+ button {
+ padding-top: 0.2em;
+ padding-bottom: 0.2em;
+ }
+ }
+
+ .icon-ellipsis {
+ cursor: pointer;
+
+ &:hover, .extra-button-popover.open & {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ border-radius: $fallback--chatMessageRadius;
+ border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+ }
+
+ .popover {
+ width: 12em;
+ }
+
+ .chat-message {
+ display: flex;
+ padding-bottom: 0.5em;
+ }
+
+ .avatar-wrapper {
+ margin-right: 0.72em;
+ width: 32px;
+ }
+
+ .link-preview, .attachments {
+ margin-bottom: 1em;
+ }
+
+ .chat-message-inner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ max-width: 80%;
+ min-width: 10em;
+ width: 100%;
+
+ &.with-media {
+ width: 100%;
+
+ .gallery-row {
+ overflow: hidden;
+ }
+
+ .status {
+ width: 100%;
+ }
+ }
+ }
+
+ .status {
+ border-radius: $fallback--chatMessageRadius;
+ border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+ display: flex;
+ padding: 0.75em;
+ }
+
+ .created-at {
+ position: relative;
+ float: right;
+ font-size: 0.8em;
+ margin: -1em 0 -0.5em 0;
+ font-style: italic;
+ opacity: 0.8;
+ }
+
+ .without-attachment {
+ .status-content {
+ &::after {
+ margin-right: 5.4em;
+ content: " ";
+ display: inline-block;
+ }
+ }
+ }
+
+ .incoming {
+ a {
+ color: var(--chatMessageIncomingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageIncomingText, $fallback--text);
+ background-color: var(--chatMessageIncomingBg, $fallback--bg);
+ border: 1px solid var(--chatMessageIncomingBorder, --border);
+ }
+
+ .created-at {
+ a {
+ color: var(--chatMessageIncomingText, $fallback--text);
+ }
+ }
+
+ .chat-message-menu {
+ left: 0.4rem;
+ }
+ }
+
+ .outgoing {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-content: end;
+ justify-content: flex-end;
+
+ a {
+ color: var(--chatMessageOutgoingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageOutgoingText, $fallback--text);
+ background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
+ border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
+ }
+
+ .chat-message-inner {
+ align-items: flex-end;
+ }
+
+ .chat-message-menu {
+ right: 0.4rem;
+ }
+ }
+
+ .visible {
+ opacity: 1;
+ }
+}
+
+.chat-message-date-separator {
+ text-align: center;
+ margin: 1.4em 0;
+ font-size: 0.9em;
+ user-select: none;
+ color: $fallback--text;
+ color: var(--faintedText, $fallback--text);
+}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
new file mode 100644
index 00000000..e923d694
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,99 @@
+<template>
+ <div
+ v-if="isMessage"
+ class="chat-message-wrapper"
+ :class="{ 'hovered-message-chain': hoveredMessageChain }"
+ @mouseover="onHover(true)"
+ @mouseleave="onHover(false)"
+ >
+ <div
+ class="chat-message"
+ :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
+ >
+ <div
+ v-if="!isCurrentUser"
+ class="avatar-wrapper"
+ >
+ <router-link
+ v-if="chatViewItem.isHead"
+ :to="userProfileLink"
+ >
+ <UserAvatar
+ :compact="true"
+ :better-shadow="betterShadow"
+ :user="author"
+ />
+ </router-link>
+ </div>
+ <div class="chat-message-inner">
+ <div
+ class="status-body"
+ :style="{ 'min-width': message.attachment ? '80%' : '' }"
+ >
+ <div
+ class="media status"
+ :class="{ 'without-attachment': !hasAttachment }"
+ style="position: relative"
+ @mouseenter="hovered = true"
+ @mouseleave="hovered = false"
+ >
+ <div
+ class="chat-message-menu"
+ :class="{ 'visible': hovered || menuOpened }"
+ >
+ <Popover
+ trigger="click"
+ placement="top"
+ :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
+ :bound-to="{ x: 'container' }"
+ :margin="popoverMarginStyle"
+ @show="menuOpened = true"
+ @close="menuOpened = false"
+ >
+ <div slot="content">
+ <div class="dropdown-menu">
+ <button
+ class="dropdown-item dropdown-item-icon"
+ @click="deleteMessage"
+ >
+ <i class="icon-cancel" /> {{ $t("chats.delete") }}
+ </button>
+ </div>
+ </div>
+ <button
+ slot="trigger"
+ :title="$t('chats.more')"
+ >
+ <i class="icon-ellipsis" />
+ </button>
+ </Popover>
+ </div>
+ <StatusContent
+ :status="messageForStatusContent"
+ :full-content="true"
+ >
+ <span
+ slot="footer"
+ class="created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </StatusContent>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div
+ v-else
+ class="chat-message-date-separator"
+ >
+ <ChatMessageDate :date="chatViewItem.date" />
+ </div>
+</template>
+
+<script src="./chat_message.js" ></script>
+<style lang="scss">
+@import './chat_message.scss';
+
+</style>
diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue
new file mode 100644
index 00000000..79c346b6
--- /dev/null
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -0,0 +1,24 @@
+<template>
+ <time>
+ {{ displayDate }}
+ </time>
+</template>
+
+<script>
+export default {
+ name: 'Timeago',
+ props: ['date'],
+ computed: {
+ displayDate () {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ if (this.date.getTime() === today.getTime()) {
+ return this.$t('display_date.today')
+ } else {
+ return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
+ }
+ }
+ }
+}
+</script>
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 00000000..d023efc0
--- /dev/null
+++ b/src/components/chat_new/chat_new.js
@@ -0,0 +1,73 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+
+const chatNew = {
+ components: {
+ BasicUserCard,
+ UserAvatar
+ },
+ data () {
+ return {
+ suggestions: [],
+ userIds: [],
+ loading: false,
+ query: ''
+ }
+ },
+ async created () {
+ const { chats } = await this.backendInteractor.chats()
+ chats.forEach(chat => this.suggestions.push(chat.account))
+ },
+ computed: {
+ users () {
+ return this.userIds.map(userId => this.findUser(userId))
+ },
+ availableUsers () {
+ if (this.query.length !== 0) {
+ return this.users
+ } else {
+ return this.suggestions
+ }
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ backendInteractor: state => state.api.backendInteractor
+ }),
+ ...mapGetters(['findUser'])
+ },
+ methods: {
+ goBack () {
+ this.$emit('cancel')
+ },
+ goToChat (user) {
+ this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
+ },
+ onInput () {
+ this.search(this.query)
+ },
+ addUser (user) {
+ this.selectedUserIds.push(user.id)
+ this.query = ''
+ },
+ removeUser (userId) {
+ this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
+ },
+ search (query) {
+ if (!query) {
+ this.loading = false
+ return
+ }
+
+ this.loading = true
+ this.userIds = []
+ this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
+ .then(data => {
+ this.loading = false
+ this.userIds = data.accounts.map(a => a.id)
+ })
+ }
+ }
+}
+
+export default chatNew
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
new file mode 100644
index 00000000..11305444
--- /dev/null
+++ b/src/components/chat_new/chat_new.scss
@@ -0,0 +1,29 @@
+.chat-new {
+ .input-wrap {
+ display: flex;
+ margin: 0.7em 0.5em 0.7em 0.5em;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ .icon-search {
+ font-size: 1.5em;
+ float: right;
+ margin-right: 0.3em;
+ }
+
+ .member-list {
+ padding-bottom: 0.7rem;
+ }
+
+ .basic-user-card:hover {
+ cursor: pointer;
+ background-color: var(--selectedPost, $fallback--lightBg);
+ }
+
+ .go-back-button {
+ cursor: pointer;
+ }
+}
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
new file mode 100644
index 00000000..3333dbf9
--- /dev/null
+++ b/src/components/chat_new/chat_new.vue
@@ -0,0 +1,46 @@
+<template>
+ <div
+ id="nav"
+ class="panel-default panel chat-new"
+ >
+ <div
+ ref="header"
+ class="panel-heading"
+ >
+ <a
+ class="go-back-button"
+ @click="goBack"
+ >
+ <i class="button-icon icon-left-open" />
+ </a>
+ </div>
+ <div class="input-wrap">
+ <div class="input-search">
+ <i class="button-icon icon-search" />
+ </div>
+ <input
+ ref="search"
+ v-model="query"
+ placeholder="Search people"
+ @input="onInput"
+ >
+ </div>
+ <div class="member-list">
+ <div
+ v-for="user in availableUsers"
+ :key="user.id"
+ class="member"
+ >
+ <div @click.capture.prevent="goToChat(user)">
+ <BasicUserCard :user="user" />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./chat_new.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat_new.scss';
+</style>
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue
index 3677722f..ca529b5a 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -10,7 +10,7 @@
@click.stop.prevent="togglePanel"
>
<div class="title">
- <span>{{ $t('chat.title') }}</span>
+ <span>{{ $t('shoutbox.title') }}</span>
<i
v-if="floating"
class="icon-cancel"
@@ -64,7 +64,7 @@
>
<div class="title">
<i class="icon-comment-empty" />
- {{ $t('chat.title') }}
+ {{ $t('shoutbox.title') }}
</div>
</div>
</div>
@@ -84,54 +84,56 @@
max-width: 25em;
}
-.chat-heading {
- cursor: pointer;
- .icon-comment-empty {
- color: $fallback--text;
- color: var(--text, $fallback--text);
+.chat-panel {
+ .chat-heading {
+ cursor: pointer;
+ .icon-comment-empty {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
}
-}
-
-.chat-window {
- overflow-y: auto;
- overflow-x: hidden;
- max-height: 20em;
-}
-.chat-window-container {
- height: 100%;
-}
+ .chat-window {
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: 20em;
+ }
-.chat-message {
- display: flex;
- padding: 0.2em 0.5em
-}
+ .chat-window-container {
+ height: 100%;
+ }
-.chat-avatar {
- img {
- height: 24px;
- width: 24px;
- border-radius: $fallback--avatarRadius;
- border-radius: var(--avatarRadius, $fallback--avatarRadius);
- margin-right: 0.5em;
- margin-top: 0.25em;
+ .chat-message {
+ display: flex;
+ padding: 0.2em 0.5em
}
-}
-.chat-input {
- display: flex;
- textarea {
- flex: 1;
- margin: 0.6em;
- min-height: 3.5em;
- resize: none;
+ .chat-avatar {
+ img {
+ height: 24px;
+ width: 24px;
+ border-radius: $fallback--avatarRadius;
+ border-radius: var(--avatarRadius, $fallback--avatarRadius);
+ margin-right: 0.5em;
+ margin-top: 0.25em;
+ }
}
-}
-.chat-panel {
- .title {
+ .chat-input {
display: flex;
- justify-content: space-between;
+ textarea {
+ flex: 1;
+ margin: 0.6em;
+ min-height: 3.5em;
+ resize: none;
+ }
+ }
+
+ .chat-panel {
+ .title {
+ display: flex;
+ justify-content: space-between;
+ }
}
}
</style>
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 00000000..e424bb1f
--- /dev/null
+++ b/src/components/chat_title/chat_title.js
@@ -0,0 +1,26 @@
+import Vue from 'vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+
+export default Vue.component('chat-title', {
+ name: 'ChatTitle',
+ components: {
+ UserAvatar
+ },
+ props: [
+ 'user', 'withAvatar'
+ ],
+ computed: {
+ title () {
+ return this.user ? this.user.screen_name : ''
+ },
+ htmlTitle () {
+ return this.user ? this.user.name_html : ''
+ }
+ },
+ methods: {
+ getUserProfileLink (user) {
+ return generateProfileLink(user.id, user.screen_name)
+ }
+ }
+})
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
new file mode 100644
index 00000000..b16ed39d
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,67 @@
+<template>
+ <!-- eslint-disable vue/no-v-html -->
+ <div
+ class="chat-title"
+ :title="title"
+ >
+ <router-link
+ v-if="withAvatar && user"
+ :to="getUserProfileLink(user)"
+ >
+ <UserAvatar
+ :user="user"
+ width="23px"
+ height="23px"
+ />
+ </router-link>
+ <span
+ class="username"
+ v-html="htmlTitle"
+ />
+ </div>
+ <!-- eslint-enable vue/no-v-html -->
+</template>
+
+<script src="./chat_title.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-title {
+ display: flex;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ align-items: center;
+
+ .username {
+ max-width: 100%;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline;
+ word-wrap: break-word;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .emoji {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain
+ }
+ }
+
+ .Avatar {
+ width: 23px;
+ height: 23px;
+ margin-right: 0.5em;
+
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+
+ &.animated::before {
+ display: none;
+ }
+ }
+}
+</style>
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue
index 03375b2f..d28c2cfd 100644
--- a/src/components/checkbox/checkbox.vue
+++ b/src/components/checkbox/checkbox.vue
@@ -52,7 +52,7 @@ export default {
right: 0;
top: 0;
display: block;
- content: '✔';
+ content: '✓';
transition: color 200ms;
width: 1.1em;
height: 1.1em;
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 2e48240a..997a4d10 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,7 +1,7 @@
<template>
<div
- class="timeline panel-default"
- :class="[isExpanded ? 'panel' : 'panel-disabled']"
+ class="Conversation"
+ :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
@@ -28,7 +28,7 @@
:replies="getReplies(status.id)"
:in-profile="inProfile"
:profile-user-id="profileUserId"
- class="status-fadein panel-body"
+ class="conversation-status status-fadein panel-body"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
@@ -40,14 +40,27 @@
<style lang="scss">
@import '../../_variables.scss';
-.timeline {
- .panel-disabled {
- .status-el {
- border-left: none;
- border-bottom-width: 1px;
- border-bottom-style: solid;
+.Conversation {
+ .conversation-status {
+ border-left: none;
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ border-bottom-color: var(--border, $fallback--border);
+ border-radius: 0;
+ }
+
+ &.-expanded {
+ .conversation-status {
+ border-color: $fallback--border;
border-color: var(--border, $fallback--border);
- border-radius: 0;
+ border-left: 4px solid $fallback--cRed;
+ border-left: 4px solid var(--cRed, $fallback--cRed);
+ }
+
+ .conversation-status:last-child {
+ border-bottom: none;
+ border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+ border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
}
}
}
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 7974a66d..f0123447 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -79,6 +79,20 @@ const EmojiInput = {
required: false,
type: Boolean,
default: false
+ },
+ placement: {
+ /**
+ * Forces the panel to take a specific position relative to the input element.
+ * The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
+ */
+ required: false,
+ type: String, // 'auto', 'top', 'bottom'
+ default: 'auto'
+ },
+ newlineOnCtrlEnter: {
+ required: false,
+ type: Boolean,
+ default: false
}
},
data () {
@@ -162,6 +176,11 @@ const EmojiInput = {
input.elm.removeEventListener('input', this.onInput)
}
},
+ watch: {
+ showSuggestions: function (newValue) {
+ this.$emit('shown', newValue)
+ }
+ },
methods: {
triggerShowPicker () {
this.showPicker = true
@@ -190,7 +209,7 @@ const EmojiInput = {
this.$emit('input', newValue)
this.caret = 0
},
- insert ({ insertion, keepOpen }) {
+ insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || ''
@@ -209,8 +228,8 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
- const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
- const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
+ const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
+ const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [
before,
@@ -367,6 +386,18 @@ const EmojiInput = {
},
onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e
+ if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
+ this.insert({ insertion: '\n', surroundingSpace: false })
+ // Ensure only one new line is added on macos
+ e.stopPropagation()
+ e.preventDefault()
+
+ // Scroll the input element to the position of the cursor
+ this.$nextTick(() => {
+ this.input.elm.blur()
+ this.input.elm.focus()
+ })
+ }
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
@@ -425,15 +456,29 @@ const EmojiInput = {
this.caret = selectionStart
},
resize () {
- const { panel, picker } = this.$refs
+ const panel = this.$refs.panel
if (!panel) return
+ const picker = this.$refs.picker.$el
+ const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input.elm
const offsetBottom = offsetTop + offsetHeight
- panel.style.top = offsetBottom + 'px'
- if (!picker) return
- picker.$el.style.top = offsetBottom + 'px'
- picker.$el.style.bottom = 'auto'
+ this.setPlacement(panelBody, panel, offsetBottom)
+ this.setPlacement(picker, picker, offsetBottom)
+ },
+ setPlacement (container, target, offsetBottom) {
+ if (!container || !target) return
+
+ target.style.top = offsetBottom + 'px'
+ target.style.bottom = 'auto'
+
+ if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
+ target.style.top = 'auto'
+ target.style.bottom = this.input.elm.offsetHeight + 'px'
+ }
+ },
+ overflowsBottom (el) {
+ return el.getBoundingClientRect().bottom > window.innerHeight
}
}
}
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index e9ac09c3..b9a74572 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -29,7 +29,10 @@
class="autocomplete-panel"
:class="{ hide: !showSuggestions }"
>
- <div class="autocomplete-panel-body">
+ <div
+ ref="panel-body"
+ class="autocomplete-panel-body"
+ >
<div
v-for="(suggestion, index) in suggestions"
:key="index"
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index ae7f53be..bb11b840 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,5 +1,5 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
-import Popover from '../popover/popover.vue'
+import UserListPopover from '../user_list_popover/user_list_popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12
@@ -7,7 +7,7 @@ const EmojiReactions = {
name: 'EmojiReactions',
components: {
UserAvatar,
- Popover
+ UserListPopover
},
props: ['status'],
data: () => ({
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index bac4c605..2f14b5b2 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,44 +1,11 @@
<template>
<div class="emoji-reactions">
- <Popover
+ <UserListPopover
v-for="(reaction) in emojiReactions"
:key="reaction.name"
- trigger="hover"
- placement="top"
- :offset="{ y: 5 }"
+ :users="accountsForEmoji[reaction.name]"
>
- <div
- slot="content"
- class="reacted-users"
- >
- <div v-if="accountsForEmoji[reaction.name].length">
- <div
- v-for="(account) in accountsForEmoji[reaction.name]"
- :key="account.id"
- class="reacted-user"
- >
- <UserAvatar
- :user="account"
- class="avatar-small"
- :compact="true"
- />
- <div class="reacted-user-names">
- <!-- eslint-disable vue/no-v-html -->
- <span
- class="reacted-user-name"
- v-html="account.name_html"
- />
- <!-- eslint-enable vue/no-v-html -->
- <span class="reacted-user-screen-name">{{ account.screen_name }}</span>
- </div>
- </div>
- </div>
- <div v-else>
- <i class="icon-spin4 animate-spin" />
- </div>
- </div>
<button
- slot="trigger"
class="emoji-reaction btn btn-default"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@@ -47,7 +14,7 @@
<span class="reaction-emoji">{{ reaction.name }}</span>
<span>{{ reaction.count }}</span>
</button>
- </Popover>
+ </UserListPopover>
<a
v-if="tooManyReactions"
class="emoji-reaction-expand faint"
@@ -69,32 +36,6 @@
flex-wrap: wrap;
}
-.reacted-users {
- padding: 0.5em;
-}
-
-.reacted-user {
- padding: 0.25em;
- display: flex;
- flex-direction: row;
-
- .reacted-user-names {
- display: flex;
- flex-direction: column;
- margin-left: 0.5em;
- min-width: 5em;
-
- img {
- width: 1em;
- height: 1em;
- }
- }
-
- .reacted-user-screen-name {
- font-size: 9px;
- }
-}
-
.emoji-reaction {
padding: 0 0.5em;
margin-right: 0.5em;
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index e4b19d01..5e0c36bb 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
+ },
+ bookmarkStatus () {
+ this.$store.dispatch('bookmark', { id: this.status.id })
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
+ },
+ unbookmarkStatus () {
+ this.$store.dispatch('unbookmark', { id: this.status.id })
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
}
},
computed: {
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index 68db6fd8..7a4e8642 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -41,6 +41,22 @@
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button>
<button
+ v-if="!status.bookmarked"
+ class="dropdown-item dropdown-item-icon"
+ @click.prevent="bookmarkStatus"
+ @click="close"
+ >
+ <i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span>
+ </button>
+ <button
+ v-if="status.bookmarked"
+ class="dropdown-item dropdown-item-icon"
+ @click.prevent="unbookmarkStatus"
+ @click="close"
+ >
+ <i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span>
+ </button>
+ <button
v-if="canDelete"
class="dropdown-item dropdown-item-icon"
@click.prevent="deleteStatus"
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index 5f80a079..620a85ea 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -1,6 +1,7 @@
const FeaturesPanel = {
computed: {
chat: function () { return this.$store.state.instance.chatAvailable },
+ pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 3e5939a6..608b11c8 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -11,6 +11,9 @@
<li v-if="chat">
{{ $t('features_panel.chat') }}
</li>
+ <li v-if="pleromaChatMessages">
+ {{ $t('features_panel.pleroma_chat_messages') }}
+ </li>
<li v-if="gopher">
{{ $t('features_panel.gopher') }}
</li>
diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue
index 1ffa7b3c..ca91c9c1 100644
--- a/src/components/gallery/gallery.vue
+++ b/src/components/gallery/gallery.vue
@@ -50,9 +50,7 @@
align-content: stretch;
}
- // FIXME: specificity problem with this and .attachments.attachment
- // we shouldn't have the need for .image here
- .attachment.image {
+ .gallery-row-inner .attachment {
margin: 0 0.5em 0 0;
flex-grow: 1;
height: 100%;
diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js
new file mode 100644
index 00000000..3af29c23
--- /dev/null
+++ b/src/components/global_notice_list/global_notice_list.js
@@ -0,0 +1,15 @@
+
+const GlobalNoticeList = {
+ computed: {
+ notices () {
+ return this.$store.state.interface.globalNotices
+ }
+ },
+ methods: {
+ closeNotice (notice) {
+ this.$store.dispatch('removeGlobalNotice', notice)
+ }
+ }
+}
+
+export default GlobalNoticeList
diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
new file mode 100644
index 00000000..0e4285cc
--- /dev/null
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -0,0 +1,77 @@
+<template>
+ <div class="global-notice-list">
+ <div
+ v-for="(notice, index) in notices"
+ :key="index"
+ class="alert global-notice"
+ :class="{ ['global-' + notice.level]: true }"
+ >
+ <div class="notice-message">
+ {{ $t(notice.messageKey, notice.messageArgs) }}
+ </div>
+ <i
+ class="button-icon icon-cancel"
+ @click="closeNotice(notice)"
+ />
+ </div>
+ </div>
+</template>
+
+<script src="./global_notice_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.global-notice-list {
+ position: fixed;
+ top: 50px;
+ width: 100%;
+ pointer-events: none;
+ z-index: 1001;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .global-notice {
+ pointer-events: auto;
+ text-align: center;
+ width: 40em;
+ max-width: calc(100% - 3em);
+ display: flex;
+ padding-left: 1.5em;
+ line-height: 2em;
+ .notice-message {
+ flex: 1 1 100%;
+ }
+ i {
+ flex: 0 0;
+ width: 1.5em;
+ cursor: pointer;
+ }
+ }
+
+ .global-error {
+ background-color: var(--alertPopupError, $fallback--cRed);
+ color: var(--alertPopupErrorText, $fallback--text);
+ i {
+ color: var(--alertPopupErrorText, $fallback--text);
+ }
+ }
+
+ .global-warning {
+ background-color: var(--alertPopupWarning, $fallback--cOrange);
+ color: var(--alertPopupWarningText, $fallback--text);
+ i {
+ color: var(--alertPopupWarningText, $fallback--text);
+ }
+ }
+
+ .global-info {
+ background-color: var(--alertPopupNeutral, $fallback--fg);
+ color: var(--alertPopupNeutralText, $fallback--text);
+ i {
+ color: var(--alertPopupNeutralText, $fallback--text);
+ }
+ }
+}
+</style>
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 80d2a8b9..46931667 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -8,6 +8,8 @@
v-if="type === 'image'"
class="modal-image"
:src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
@touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove"
@click="hide"
@@ -18,6 +20,14 @@
:attachment="currentMedia"
:controls="true"
/>
+ <audio
+ v-if="type === 'audio'"
+ class="modal-image"
+ :src="currentMedia.url"
+ :alt="currentMedia.description"
+ :title="currentMedia.description"
+ controls
+ />
<button
v-if="canNavigate"
:title="$t('media_modal.previous')"
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index fbb2d03d..7b8a76cc 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -61,7 +61,8 @@ const mediaUpload = {
}
},
props: [
- 'dropFiles'
+ 'dropFiles',
+ 'disabled'
],
watch: {
'dropFiles': function (fileInfos) {
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index 5e31730b..c8865d77 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -1,5 +1,8 @@
<template>
- <div class="media-upload">
+ <div
+ class="media-upload"
+ :class="{ disabled: disabled }"
+ >
<label
class="label"
:title="$t('tool_tip.media_upload')"
@@ -14,6 +17,7 @@
/>
<input
v-if="uploadReady"
+ :disabled="disabled"
type="file"
style="position: fixed; top: -100em"
multiple="true"
@@ -26,6 +30,8 @@
<script src="./media_upload.js" ></script>
<style lang="scss">
+@import '../../_variables.scss';
+
.media-upload {
.label {
display: inline-block;
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index c1166a0c..b2b5d264 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
+import { mapGetters } from 'vuex'
const MobileNav = {
components: {
@@ -30,7 +31,11 @@ const MobileNav = {
return this.unseenNotifications.length
},
hideSitename () { return this.$store.state.instance.hideSitename },
- sitename () { return this.$store.state.instance.name }
+ sitename () { return this.$store.state.instance.name },
+ isChat () {
+ return this.$route.name === 'chat'
+ },
+ ...mapGetters(['unreadChatCount'])
},
methods: {
toggleMobileSidebar () {
@@ -64,7 +69,7 @@ const MobileNav = {
this.$refs.notifications.markAsSeen()
},
onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
- if (this.$store.getters.mergedConfig.autoLoad && scrollTop + clientHeight >= scrollHeight) {
+ if (scrollTop + clientHeight >= scrollHeight) {
this.$refs.notifications.fetchOlderNotifications()
}
}
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index 51f1d636..abd95f09 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -3,6 +3,7 @@
<nav
id="nav"
class="nav-bar container"
+ :class="{ 'mobile-hidden': isChat }"
>
<div
class="mobile-inner-nav"
@@ -15,6 +16,10 @@
@click.stop.prevent="toggleMobileSidebar()"
>
<i class="button-icon icon-menu" />
+ <div
+ v-if="unreadChatCount"
+ class="alert-dot"
+ />
</a>
<router-link
v-if="!hideSitename"
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 0ad12bb1..6348277b 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -1,5 +1,10 @@
import { debounce } from 'lodash'
+const HIDDEN_FOR_PAGES = new Set([
+ 'chats',
+ 'chat'
+])
+
const MobilePostStatusButton = {
data () {
return {
@@ -27,6 +32,8 @@ const MobilePostStatusButton = {
return !!this.$store.state.users.currentUser
},
isHidden () {
+ if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
+
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
autohideFloatingPostButton () {
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 8f7edb7f..623dfaec 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,4 +1,5 @@
-import { mapState } from 'vuex'
+import { timelineNames } from '../timeline_menu/timeline_menu.js'
+import { mapState, mapGetters } from 'vuex'
const NavPanel = {
created () {
@@ -6,13 +7,25 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests')
}
},
- computed: mapState({
- currentUser: state => state.users.currentUser,
- chat: state => state.chat.channel,
- followRequestCount: state => state.api.followRequests.length,
- privateMode: state => state.instance.private,
- federating: state => state.instance.federating
- })
+ computed: {
+ onTimelineRoute () {
+ return !!timelineNames()[this.$route.name]
+ },
+ timelinesRoute () {
+ if (this.$store.state.interface.lastTimeline) {
+ return this.$store.state.interface.lastTimeline
+ }
+ return this.currentUser ? 'friends' : 'public-timeline'
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ followRequestCount: state => state.api.followRequests.length,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating,
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ }),
+ ...mapGetters(['unreadChatCount'])
+ }
}
export default NavPanel
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 8cd04dc7..f8459fd1 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -2,9 +2,12 @@
<div class="nav-panel">
<div class="panel panel-default">
<ul>
- <li v-if="currentUser">
- <router-link :to="{ name: 'friends' }">
- <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
+ <li v-if="currentUser || !privateMode">
+ <router-link
+ :to="{ name: timelinesRoute }"
+ :class="onTimelineRoute && 'router-link-active'"
+ >
+ <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
</router-link>
</li>
<li v-if="currentUser">
@@ -12,9 +15,15 @@
<i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link>
</li>
- <li v-if="currentUser">
- <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
- <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
+ <li v-if="currentUser && pleromaChatMessagesAvailable">
+ <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
+ <div
+ v-if="unreadChatCount"
+ class="badge badge-notification unread-chat-count"
+ >
+ {{ unreadChatCount }}
+ </div>
+ <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked">
@@ -28,16 +37,6 @@
</span>
</router-link>
</li>
- <li v-if="currentUser || !privateMode">
- <router-link :to="{ name: 'public-timeline' }">
- <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li v-if="federating && (currentUser || !privateMode)">
- <router-link :to="{ name: 'public-external-timeline' }">
- <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
- </router-link>
- </li>
<li>
<router-link :to="{ name: 'about' }">
<i class="button-icon icon-info-circled" /> {{ $t("nav.about") }}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 5aa40e98..bb906b50 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,4 +1,5 @@
import StatusContent from '../status_content/status_content.vue'
+import { mapState } from 'vuex'
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
@@ -81,7 +82,10 @@ const Notification = {
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
- }
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ })
}
}
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
new file mode 100644
index 00000000..d0e63d81
--- /dev/null
+++ b/src/components/notification/notification.scss
@@ -0,0 +1,52 @@
+// TODO Copypaste from Status, should unify it somehow
+.Notification {
+ &.-muted {
+ padding: 0.25em 0.6em;
+ height: 1.2em;
+ line-height: 1.2em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: nowrap;
+
+ & .status-username,
+ & .mute-thread,
+ & .mute-words {
+ word-wrap: normal;
+ word-break: normal;
+ white-space: nowrap;
+ }
+
+ & .status-username,
+ & .mute-words {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .status-username {
+ font-weight: normal;
+ flex: 0 1 auto;
+ margin-right: 0.2em;
+ font-size: smaller;
+ }
+
+ .mute-thread {
+ flex: 0 0 auto;
+ }
+
+ .mute-words {
+ flex: 1 0 5em;
+ margin-left: 0.2em;
+
+ &::before {
+ content: ' ';
+ }
+ }
+
+ .unmute {
+ flex: 0 0 auto;
+ margin-left: auto;
+ display: block;
+ }
+ }
+}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 044ac871..7fac3840 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -7,7 +7,7 @@
<div v-else>
<div
v-if="needMute && !unmuted"
- class="container muted"
+ class="Notification container -muted"
>
<small>
<router-link :to="userProfileLink">
@@ -168,3 +168,4 @@
</template>
<script src="./notification.js"></script>
+<style src="./notification.scss" lang="scss"></style>
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index 26ffbab6..d951e2a8 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,3 +1,4 @@
+import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
@@ -27,6 +28,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
}
},
+ created () {
+ const store = this.$store
+ const credentials = store.state.users.currentUser.credentials
+ notificationsFetcher.fetchAndUpdate({ store, credentials })
+ },
computed: {
mainClass () {
return this.minimalMode ? '' : 'panel panel-default'
@@ -46,23 +52,22 @@ const Notifications = {
unseenCount () {
return this.unseenNotifications.length
},
+ unseenCountTitle () {
+ return this.unseenCount + (this.unreadChatCount)
+ },
loading () {
return this.$store.state.statuses.notifications.loading
},
notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
- }
+ },
+ ...mapGetters(['unreadChatCount'])
},
components: {
Notification
},
- created () {
- const { dispatch } = this.$store
-
- dispatch('fetchAndUpdateNotifications')
- },
watch: {
- unseenCount (count) {
+ unseenCountTitle (count) {
if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`)
} else {
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 20797cf9..c6b2a5b5 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -39,7 +39,7 @@
word-wrap: break-word;
word-break: break-word;
- &:hover .animated.avatar {
+ &:hover .animated.Avatar {
canvas {
display: none;
}
@@ -60,16 +60,8 @@
height: 32px;
}
- .status-body {
- color: $fallback--faint;
- color: var(--faint, $fallback--faint);
- a {
- color: var(--faintLink);
- }
- .status-content a {
- color: var(--postFaintLink);
- }
- }
+ --link: var(--faintLink);
+ --text: var(--faint);
}
.follow-request-accept {
@@ -106,7 +98,8 @@
}
}
- .status-el {
+ /* TODO cleanup this */
+ .Status {
flex: 1;
}
@@ -118,6 +111,11 @@
flex: 1;
padding-left: 0.8em;
min-width: 0;
+
+ .timeago {
+ min-width: 3em;
+ text-align: right;
+ }
}
.emoji-reaction-emoji {
diff --git a/src/components/password_reset/password_reset.js b/src/components/password_reset/password_reset.js
index 62e74e30..5d21d720 100644
--- a/src/components/password_reset/password_reset.js
+++ b/src/components/password_reset/password_reset.js
@@ -47,11 +47,6 @@ const passwordReset = {
if (status === 204) {
this.success = true
this.error = null
- } else if (status === 404 || status === 400) {
- this.error = this.$t('password_reset.not_found')
- this.$nextTick(() => {
- this.$refs.email.focus()
- })
} else if (status === 429) {
this.throttled = true
this.error = this.$t('password_reset.too_many_requests')
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index adbb0555..1858f3e1 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,7 +17,7 @@
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
- <span v-html="option.title_html"></span>
+ <span v-html="option.title_html" />
</div>
<div
class="result-fill"
@@ -96,6 +96,7 @@
align-items: center;
padding: 0.1em 0.25em;
z-index: 1;
+ word-break: break-word;
}
.result-percentage {
width: 3.5em;
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
index c0c1ccf7..df93f038 100644
--- a/src/components/poll/poll_form.js
+++ b/src/components/poll/poll_form.js
@@ -75,6 +75,7 @@ export default {
deleteOption (index, event) {
if (this.options.length > 2) {
this.options.splice(index, 1)
+ this.updatePollToParent()
}
},
convertExpiryToUnit (unit, amount) {
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index a40a9195..695f73b9 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -18,7 +18,9 @@ const Popover = {
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
- // Additional styles you may want for the popover container
+ // Replaces the classes you may want for the popover container.
+ // Use 'popover-default' in addition to get the default popover
+ // styles with your custom class.
popoverClass: String
},
data () {
@@ -106,7 +108,7 @@ const Popover = {
// single translate or translate3d resulted in blurry text.
this.styles = {
opacity: 1,
- transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)`
+ transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
}
},
showPopover () {
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
index a271cb1b..5c99c509 100644
--- a/src/components/popover/popover.vue
+++ b/src/components/popover/popover.vue
@@ -14,7 +14,7 @@
ref="content"
:style="styles"
class="popover"
- :class="popoverClass"
+ :class="popoverClass || 'popover-default'"
>
<slot
name="content"
@@ -34,6 +34,9 @@
z-index: 8;
position: absolute;
min-width: 0;
+}
+
+.popover-default {
transition: opacity 0.3s;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 9027566f..ad149506 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue'
+import Attachment from '../attachment/attachment.vue'
+import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-import { reject, map, uniqBy } from 'lodash'
+import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js'
-import { mapGetters } from 'vuex'
+import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue'
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@@ -25,27 +27,54 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
+// Converts a string with px to a number like '2px' -> 2
+const pxStringToNumber = (str) => {
+ return Number(str.substring(0, str.length - 2))
+}
+
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
- 'subject'
+ 'subject',
+ 'disableSubject',
+ 'disableScopeSelector',
+ 'disableNotice',
+ 'disableLockWarning',
+ 'disablePolls',
+ 'disableSensitivityCheckbox',
+ 'disableSubmit',
+ 'disablePreview',
+ 'placeholder',
+ 'maxHeight',
+ 'postHandler',
+ 'preserveFocus',
+ 'autoFocus',
+ 'fileLimit',
+ 'submitOnEnter',
+ 'emojiPickerPlacement'
],
components: {
MediaUpload,
EmojiInput,
PollForm,
ScopeSelector,
- Checkbox
+ Checkbox,
+ Attachment,
+ StatusContent
},
mounted () {
+ this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
- const textLength = this.$refs.textarea.value.length
- this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) {
+ const textLength = this.$refs.textarea.value.length
+ this.$refs.textarea.setSelectionRange(textLength, textLength)
+ }
+
+ if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
@@ -68,7 +97,7 @@ const PostStatusForm = {
return {
dropFiles: [],
- submitDisabled: false,
+ uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
@@ -78,13 +107,18 @@ const PostStatusForm = {
nsfw: false,
files: [],
poll: {},
+ mediaDescriptions: {},
visibility: scope,
contentType
},
caret: 0,
pollFormVisible: false,
showDropIcon: 'hide',
- dropStopTimeout: null
+ dropStopTimeout: null,
+ preview: null,
+ previewLoading: false,
+ emojiInputShown: false,
+ idempotencyKey: ''
}
},
computed: {
@@ -153,28 +187,81 @@ const PostStatusForm = {
},
pollsAvailable () {
return this.$store.state.instance.pollsAvailable &&
- this.$store.state.instance.pollLimits.max_options >= 2
+ this.$store.state.instance.pollLimits.max_options >= 2 &&
+ this.disablePolls !== true
},
hideScopeNotice () {
- return this.$store.getters.mergedConfig.hideScopeNotice
+ return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
},
pollContentError () {
return this.pollFormVisible &&
this.newStatus.poll &&
this.newStatus.poll.error
},
- ...mapGetters(['mergedConfig'])
+ showPreview () {
+ return !this.disablePreview && (!!this.preview || this.previewLoading)
+ },
+ emptyStatus () {
+ return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
+ },
+ uploadFileLimitReached () {
+ return this.newStatus.files.length >= this.fileLimit
+ },
+ ...mapGetters(['mergedConfig']),
+ ...mapState({
+ mobileLayout: state => state.interface.mobileLayout
+ })
+ },
+ watch: {
+ 'newStatus': {
+ deep: true,
+ handler () {
+ this.statusChanged()
+ }
+ }
},
methods: {
- postStatus (newStatus) {
+ statusChanged () {
+ this.autoPreview()
+ this.updateIdempotencyKey()
+ },
+ clearStatus () {
+ const newStatus = this.newStatus
+ this.newStatus = {
+ status: '',
+ spoilerText: '',
+ files: [],
+ visibility: newStatus.visibility,
+ contentType: newStatus.contentType,
+ poll: {},
+ mediaDescriptions: {}
+ }
+ this.pollFormVisible = false
+ this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
+ this.clearPollForm()
+ if (this.preserveFocus) {
+ this.$nextTick(() => {
+ this.$refs.textarea.focus()
+ })
+ }
+ let el = this.$el.querySelector('textarea')
+ el.style.height = 'auto'
+ el.style.height = undefined
+ this.error = null
+ if (this.preview) this.previewStatus()
+ },
+ async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return }
- if (this.submitDisabled) { return }
+ if (this.disableSubmit) { return }
+ if (this.emojiInputShown) { return }
+ if (this.submitOnEnter) {
+ event.stopPropagation()
+ event.preventDefault()
+ }
- if (this.newStatus.status === '') {
- if (this.newStatus.files.length === 0) {
- this.error = 'Cannot post an empty status with no files'
- return
- }
+ if (this.emptyStatus) {
+ this.error = this.$t('post_status.empty_status_error')
+ return
}
const poll = this.pollFormVisible ? this.newStatus.poll : {}
@@ -184,7 +271,16 @@ const PostStatusForm = {
}
this.posting = true
- statusPoster.postStatus({
+
+ try {
+ await this.setAllMediaDescriptions()
+ } catch (e) {
+ this.error = this.$t('post_status.media_description_error')
+ this.posting = false
+ return
+ }
+
+ const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
@@ -193,52 +289,98 @@ const PostStatusForm = {
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
- poll
- }).then((data) => {
+ poll,
+ idempotencyKey: this.idempotencyKey
+ }
+
+ const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
+
+ postHandler(postingOptions).then((data) => {
if (!data.error) {
- this.newStatus = {
- status: '',
- spoilerText: '',
- files: [],
- visibility: newStatus.visibility,
- contentType: newStatus.contentType,
- poll: {}
- }
- this.pollFormVisible = false
- this.$refs.mediaUpload.clearFile()
- this.clearPollForm()
- this.$emit('posted')
- let el = this.$el.querySelector('textarea')
- el.style.height = 'auto'
- el.style.height = undefined
- this.error = null
+ this.clearStatus()
+ this.$emit('posted', data)
} else {
this.error = data.error
}
this.posting = false
})
},
+ previewStatus () {
+ if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
+ this.preview = { error: this.$t('post_status.preview_empty') }
+ this.previewLoading = false
+ return
+ }
+ const newStatus = this.newStatus
+ this.previewLoading = true
+ statusPoster.postStatus({
+ status: newStatus.status,
+ spoilerText: newStatus.spoilerText || null,
+ visibility: newStatus.visibility,
+ sensitive: newStatus.nsfw,
+ media: [],
+ store: this.$store,
+ inReplyToStatusId: this.replyTo,
+ contentType: newStatus.contentType,
+ poll: {},
+ preview: true
+ }).then((data) => {
+ // Don't apply preview if not loading, because it means
+ // user has closed the preview manually.
+ if (!this.previewLoading) return
+ if (!data.error) {
+ this.preview = data
+ } else {
+ this.preview = { error: data.error }
+ }
+ }).catch((error) => {
+ this.preview = { error }
+ }).finally(() => {
+ this.previewLoading = false
+ })
+ },
+ debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
+ autoPreview () {
+ if (!this.preview) return
+ this.previewLoading = true
+ this.debouncePreviewStatus()
+ },
+ closePreview () {
+ this.preview = null
+ this.previewLoading = false
+ },
+ togglePreview () {
+ if (this.showPreview) {
+ this.closePreview()
+ } else {
+ this.previewStatus()
+ }
+ },
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
+ this.$emit('resize', { delayed: true })
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
+ this.$emit('resize')
},
uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
},
- disableSubmit () {
- this.submitDisabled = true
+ startedUploadingFiles () {
+ this.uploadingFiles = true
},
- enableSubmit () {
- this.submitDisabled = false
+ finishedUploadingFiles () {
+ this.$emit('resize')
+ this.uploadingFiles = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
+ this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
@@ -266,7 +408,7 @@ const PostStatusForm = {
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag (e) {
- e.dataTransfer.dropEffect = 'copy'
+ e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
@@ -284,6 +426,7 @@ const PostStatusForm = {
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
+ this.$emit('resize')
this.$refs['emoji-input'].resize()
return
}
@@ -295,7 +438,7 @@ const PostStatusForm = {
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
- const bottomBottomPadding = Number(bottomBottomPaddingStr.substring(0, bottomBottomPaddingStr.length - 2))
+ const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
@@ -304,10 +447,12 @@ const PostStatusForm = {
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
- const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2))
- const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2))
+ const topPadding = pxStringToNumber(topPaddingStr)
+ const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
+ const oldHeight = pxStringToNumber(target.style.height)
+
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
@@ -336,8 +481,15 @@ const PostStatusForm = {
// BEGIN content size update
target.style.height = 'auto'
- const newHeight = target.scrollHeight - vertPadding
+ const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
+ let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
+ // This is a bit of a hack to combat target.scrollHeight being different on every other input
+ // on some browsers for whatever reason. Don't change the height if difference is 1px or less.
+ if (Math.abs(newHeight - oldHeight) <= 1) {
+ newHeight = oldHeight
+ }
target.style.height = `${newHeight}px`
+ this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
@@ -388,6 +540,24 @@ const PostStatusForm = {
},
dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
+ },
+ setMediaDescription (id) {
+ const description = this.newStatus.mediaDescriptions[id]
+ if (!description || description.trim() === '') return
+ return statusPoster.setMediaDescription({ store: this.$store, id, description })
+ },
+ setAllMediaDescriptions () {
+ const ids = this.newStatus.files.map(file => file.id)
+ return Promise.all(ids.map(id => this.setMediaDescription(id)))
+ },
+ handleEmojiInputShow (value) {
+ this.emojiInputShown = value
+ },
+ updateIdempotencyKey () {
+ this.idempotencyKey = Date.now().toString()
+ },
+ openProfileTab () {
+ this.$store.dispatch('openSettingsModalTab', 'profile')
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index e3d8d087..d67d9ae9 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -5,26 +5,30 @@
>
<form
autocomplete="off"
- @submit.prevent="postStatus(newStatus)"
+ @submit.prevent
@dragover.prevent="fileDrag"
>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
- class="drop-indicator icon-upload"
+ class="drop-indicator"
+ :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
/>
<div class="form-group">
<i18n
- v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
+ v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
path="post_status.account_not_locked_warning"
tag="p"
class="visibility-notice"
>
- <router-link :to="{ name: 'user-settings' }">
+ <a
+ href="#"
+ @click="openProfileTab"
+ >
{{ $t('post_status.account_not_locked_warning_link') }}
- </router-link>
+ </a>
</i18n>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
@@ -69,15 +73,52 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
+ <div
+ v-if="!disablePreview"
+ class="preview-heading faint"
+ >
+ <a
+ class="preview-toggle faint"
+ @click.stop.prevent="togglePreview"
+ >
+ {{ $t('post_status.preview') }}
+ <i :class="showPreview ? 'icon-left-open' : 'icon-right-open'" />
+ </a>
+ <i
+ v-show="previewLoading"
+ class="icon-spin3 animate-spin"
+ />
+ </div>
+ <div
+ v-if="showPreview"
+ class="preview-container"
+ >
+ <div
+ v-if="!preview"
+ class="preview-status"
+ >
+ {{ $t('general.loading') }}
+ </div>
+ <div
+ v-else-if="preview.error"
+ class="preview-status preview-error"
+ >
+ {{ preview.error }}
+ </div>
+ <StatusContent
+ v-else
+ :status="preview"
+ class="preview-status"
+ />
+ </div>
<EmojiInput
- v-if="newStatus.spoilerText || alwaysShowSubject"
+ v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="form-control"
>
<input
-
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
@@ -89,23 +130,29 @@
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
+ :placement="emojiPickerPlacement"
class="form-control main-input"
enable-emoji-picker
hide-emoji-button
+ :newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
+ @shown="handleEmojiInputShow"
>
<textarea
ref="textarea"
v-model="newStatus.status"
- :placeholder="$t('post_status.default')"
+ :placeholder="placeholder || $t('post_status.default')"
rows="1"
+ cols="1"
:disabled="posting"
class="form-post-body"
- @keydown.meta.enter="postStatus(newStatus)"
- @keydown.ctrl.enter="postStatus(newStatus)"
+ :class="{ 'scrollable-form': !!maxHeight }"
+ @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
+ @keydown.meta.enter="postStatus($event, newStatus)"
+ @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
@@ -118,7 +165,10 @@
{{ charactersLeft }}
</p>
</EmojiInput>
- <div class="visibility-tray">
+ <div
+ v-if="!disableScopeSelector"
+ class="visibility-tray"
+ >
<scope-selector
:show-all="showAllScopes"
:user-default="userDefaultScope"
@@ -176,10 +226,11 @@
ref="mediaUpload"
class="media-upload-icon"
:drop-files="dropFiles"
- @uploading="disableSubmit"
+ :disabled="uploadFileLimitReached"
+ @uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
- @all-uploaded="enableSubmit"
+ @all-uploaded="finishedUploadingFiles"
/>
<div
class="emoji-icon"
@@ -216,11 +267,13 @@
>
{{ $t('general.submit') }}
</button>
+ <!-- touchstart is used to keep the OSK at the same position after a message send -->
<button
v-else
- :disabled="submitDisabled"
- type="submit"
+ :disabled="uploadingFiles || disableSubmit"
class="btn btn-default"
+ @touchstart.stop.prevent="postStatus($event, newStatus)"
+ @click.stop.prevent="postStatus($event, newStatus)"
>
{{ $t('general.submit') }}
</button>
@@ -245,31 +298,22 @@
class="fa button-icon icon-cancel"
@click="removeMediaFile(file)"
/>
- <div class="media-upload-container attachment">
- <img
- v-if="type(file) === 'image'"
- class="thumbnail media-upload"
- :src="file.url"
- >
- <video
- v-if="type(file) === 'video'"
- :src="file.url"
- controls
- />
- <audio
- v-if="type(file) === 'audio'"
- :src="file.url"
- controls
- />
- <a
- v-if="type(file) === 'unknown'"
- :href="file.url"
- >{{ file.url }}</a>
- </div>
+ <attachment
+ :attachment="file"
+ :set-media="() => $store.dispatch('setMedia', newStatus.files)"
+ size="small"
+ allow-play="false"
+ />
+ <input
+ v-model="newStatus.mediaDescriptions[file.id]"
+ type="text"
+ :placeholder="$t('post_status.media_description')"
+ @keydown.enter.prevent=""
+ >
</div>
</div>
<div
- v-if="newStatus.files.length > 0"
+ v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
@@ -303,14 +347,8 @@
}
.post-status-form {
- .visibility-tray {
- display: flex;
- justify-content: space-between;
- padding-top: 5px;
- }
-}
+ position: relative;
-.post-status-form {
.form-bottom {
display: flex;
justify-content: space-between;
@@ -336,6 +374,51 @@
max-width: 10em;
}
+ .preview-heading {
+ padding-left: 0.5em;
+ display: flex;
+ width: 100%;
+
+ .icon-spin3 {
+ margin-left: auto;
+ }
+ }
+
+ .preview-toggle {
+ display: flex;
+ cursor: pointer;
+ user-select: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ i {
+ margin-left: 0.2em;
+ font-size: 0.8em;
+ transform: rotate(90deg);
+ }
+ }
+
+ .preview-container {
+ margin-bottom: 1em;
+ }
+
+ .preview-error {
+ font-style: italic;
+ color: $fallback--faint;
+ color: var(--faint, $fallback--faint);
+ }
+
+ .preview-status {
+ border: 1px solid $fallback--border;
+ border: 1px solid var(--border, $fallback--border);
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ padding: 0.5em;
+ margin: 0;
+ line-height: 1.4em;
+ }
+
.text-format {
.only-format {
color: $fallback--faint;
@@ -343,6 +426,12 @@
}
}
+ .visibility-tray {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 5px;
+ }
+
.media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px;
flex: 1;
@@ -354,6 +443,19 @@
color: var(--lightText, $fallback--lightText);
}
}
+
+ &.disabled {
+ i {
+ cursor: not-allowed;
+ color: $fallback--icon;
+ color: var(--btnDisabledText, $fallback--icon);
+
+ &:hover {
+ color: $fallback--icon;
+ color: var(--btnDisabledText, $fallback--icon);
+ }
+ }
+ }
}
// Order is not necessary but a good indicator
@@ -381,11 +483,9 @@
}
.media-upload-wrapper {
- flex: 0 0 auto;
- max-width: 100%;
- min-width: 50px;
margin-right: .2em;
margin-bottom: .5em;
+ width: 18em;
.icon-cancel {
display: inline-block;
@@ -399,6 +499,20 @@
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
+
+ img, video {
+ object-fit: contain;
+ max-height: 10em;
+ }
+
+ .video {
+ max-height: 10em;
+ }
+
+ input {
+ flex: 1;
+ width: 100%;
+ }
}
.status-input-wrapper {
@@ -408,28 +522,13 @@
flex-direction: column;
}
- .attachments {
+ .media-upload-wrapper .attachments {
padding: 0 0.5em;
.attachment {
margin: 0;
+ padding: 0;
position: relative;
- flex: 0 0 auto;
- border: 1px solid $fallback--border;
- border: 1px solid var(--border, $fallback--border);
- text-align: center;
-
- audio {
- min-width: 300px;
- flex: 1 0 auto;
- }
-
- a {
- display: block;
- text-align: left;
- line-height: 1.2;
- padding: .5em;
- }
}
i {
@@ -482,6 +581,10 @@
padding-bottom: 1.75em;
min-height: 1px;
box-sizing: content-box;
+
+ &.scrollable-form {
+ overflow-y: auto;
+ }
}
.main-input {
@@ -544,4 +647,11 @@
border: 2px dashed var(--text, $fallback--text);
}
}
+
+// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
+img.media-upload, .media-upload-container > video {
+ line-height: 0;
+ max-height: 200px;
+ max-width: 100%;
+}
</style>
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index f0931446..abcf0455 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -28,7 +28,10 @@ const ReactButton = {
},
emojis () {
if (this.filterWord !== '') {
- return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord))
+ const filterWordLowercase = this.filterWord.toLowerCase()
+ return this.$store.state.instance.emoji.filter(emoji =>
+ emoji.displayText.toLowerCase().includes(filterWordLowercase)
+ )
}
return this.$store.state.instance.emoji || []
},
diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss
index 833ff89a..90446b36 100644
--- a/src/components/settings_modal/settings_modal.scss
+++ b/src/components/settings_modal/settings_modal.scss
@@ -13,6 +13,13 @@
* - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
*/
transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
+
+ @media all and (max-width: 800px) {
+ /* For mobile, the modal takes 100% of the available screen.
+ This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
+ */
+ transform: translateY(calc(100% - 50px));
+ }
}
}
@@ -27,10 +34,10 @@
@media all and (max-width: 800px) {
max-width: 100vw;
- height: 100vh;
+ height: 100%;
}
- .panel-body {
+ >.panel-body {
height: 100%;
overflow-y: hidden;
diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js
index 48101a90..ef1a5ffa 100644
--- a/src/components/settings_modal/settings_modal_content.js
+++ b/src/components/settings_modal/settings_modal_content.js
@@ -27,6 +27,34 @@ const SettingsModalContent = {
computed: {
isLoggedIn () {
return !!this.$store.state.users.currentUser
+ },
+ open () {
+ return this.$store.state.interface.settingsModalState !== 'hidden'
+ }
+ },
+ methods: {
+ onOpen () {
+ const targetTab = this.$store.state.interface.settingsModalTargetTab
+ // We're being told to open in specific tab
+ if (targetTab) {
+ const tabIndex = this.$refs.tabSwitcher.$slots.default.findIndex(elm => {
+ return elm.data && elm.data.attrs['data-tab-name'] === targetTab
+ })
+ if (tabIndex >= 0) {
+ this.$refs.tabSwitcher.setTab(tabIndex)
+ }
+ }
+ // Clear the state of target tab, so that next time settings is opened
+ // it doesn't force it.
+ this.$store.dispatch('clearSettingsModalTargetTab')
+ }
+ },
+ mounted () {
+ this.onOpen()
+ },
+ watch: {
+ open: function (value) {
+ if (value) this.onOpen()
}
}
}
diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue
index 2156844f..bc30a0ff 100644
--- a/src/components/settings_modal/settings_modal_content.vue
+++ b/src/components/settings_modal/settings_modal_content.vue
@@ -8,6 +8,7 @@
<div
:label="$t('settings.general')"
icon="wrench"
+ data-tab-name="general"
>
<GeneralTab />
</div>
@@ -15,6 +16,7 @@
v-if="isLoggedIn"
:label="$t('settings.profile_tab')"
icon="user"
+ data-tab-name="profile"
>
<ProfileTab />
</div>
@@ -22,18 +24,21 @@
v-if="isLoggedIn"
:label="$t('settings.security_tab')"
icon="lock"
+ data-tab-name="security"
>
<SecurityTab />
</div>
<div
:label="$t('settings.filtering')"
icon="filter"
+ data-tab-name="filtering"
>
<FilteringTab />
</div>
<div
:label="$t('settings.theme')"
icon="brush"
+ data-tab-name="theme"
>
<ThemeTab />
</div>
@@ -41,6 +46,7 @@
v-if="isLoggedIn"
:label="$t('settings.notifications')"
icon="bell-ringing-o"
+ data-tab-name="notifications"
>
<NotificationsTab />
</div>
@@ -48,6 +54,7 @@
v-if="isLoggedIn"
:label="$t('settings.data_import_export_tab')"
icon="download"
+ data-tab-name="dataImportExport"
>
<DataImportExportTab />
</div>
@@ -56,12 +63,14 @@
:label="$t('settings.mutes_and_blocks')"
:fullHeight="true"
icon="eye-off"
+ data-tab-name="mutesAndBlocks"
>
<MutesAndBlocksTab />
</div>
<div
:label="$t('settings.version.title')"
icon="info-circled"
+ data-tab-name="version"
>
<VersionTab />
</div>
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 224a7f47..3b2df556 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -37,6 +37,9 @@ const FilteringTab = {
})
},
deep: true
+ },
+ replyVisibility () {
+ this.$store.dispatch('queueFlushAll')
}
}
}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index f89c0480..7f06d0bd 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -54,16 +54,6 @@
</Checkbox>
</li>
<li>
- <Checkbox v-model="autoLoad">
- {{ $t('settings.autoload') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="hoverPreview">
- {{ $t('settings.reply_link_preview') }}
- </Checkbox>
- </li>
- <li>
<Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }}
</Checkbox>
diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue
index b7a3cb37..86eed3f5 100644
--- a/src/components/settings_modal/tabs/notifications_tab.vue
+++ b/src/components/settings_modal/tabs/notifications_tab.vue
@@ -2,38 +2,18 @@
<div :label="$t('settings.notifications')">
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2>
- <div class="select-multiple">
- <span class="label">{{ $t('settings.notification_setting') }}</span>
- <ul class="option-list">
- <li>
- <Checkbox v-model="notificationSettings.follows">
- {{ $t('settings.notification_setting_follows') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationSettings.followers">
- {{ $t('settings.notification_setting_followers') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationSettings.non_follows">
- {{ $t('settings.notification_setting_non_follows') }}
- </Checkbox>
- </li>
- <li>
- <Checkbox v-model="notificationSettings.non_followers">
- {{ $t('settings.notification_setting_non_followers') }}
- </Checkbox>
- </li>
- </ul>
- </div>
+ <p>
+ <Checkbox v-model="notificationSettings.block_from_strangers">
+ {{ $t('settings.notification_setting_block_from_strangers') }}
+ </Checkbox>
+ </p>
</div>
<div class="setting-item">
<h2>{{ $t('settings.notification_setting_privacy') }}</h2>
<p>
- <Checkbox v-model="notificationSettings.privacy_option">
- {{ $t('settings.notification_setting_privacy_option') }}
+ <Checkbox v-model="notificationSettings.hide_notification_contents">
+ {{ $t('settings.notification_setting_hide_notification_contents') }}
</Checkbox>
</p>
</div>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index e6db802d..bd6bef6a 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -77,6 +77,33 @@ const ProfileTab = {
},
maxFields () {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
+ },
+ defaultAvatar () {
+ return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
+ },
+ defaultBanner () {
+ return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
+ },
+ isDefaultAvatar () {
+ const baseAvatar = this.$store.state.instance.defaultAvatar
+ return !(this.$store.state.users.currentUser.profile_image_url) ||
+ this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
+ },
+ isDefaultBanner () {
+ const baseBanner = this.$store.state.instance.defaultBanner
+ return !(this.$store.state.users.currentUser.cover_photo) ||
+ this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
+ },
+ isDefaultBackground () {
+ return !(this.$store.state.users.currentUser.background_image)
+ },
+ avatarImgSrc () {
+ const src = this.$store.state.users.currentUser.profile_image_url_original
+ return (!src) ? this.defaultAvatar : src
+ },
+ bannerImgSrc () {
+ const src = this.$store.state.users.currentUser.cover_photo
+ return (!src) ? this.defaultBanner : src
}
},
methods: {
@@ -150,11 +177,29 @@ const ProfileTab = {
}
reader.readAsDataURL(file)
},
+ resetAvatar () {
+ const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
+ if (confirmed) {
+ this.submitAvatar(undefined, '')
+ }
+ },
+ resetBanner () {
+ const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
+ if (confirmed) {
+ this.submitBanner('')
+ }
+ },
+ resetBackground () {
+ const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
+ if (confirmed) {
+ this.submitBackground('')
+ }
+ },
submitAvatar (cropper, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar) {
- that.$store.state.api.backendInteractor.updateAvatar({ avatar })
+ that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
@@ -172,11 +217,11 @@ const ProfileTab = {
}
})
},
- submitBanner () {
- if (!this.bannerPreview) { return }
+ submitBanner (banner) {
+ if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
- this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner })
+ this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
@@ -187,11 +232,11 @@ const ProfileTab = {
})
.then(() => { this.bannerUploading = false })
},
- submitBg () {
- if (!this.backgroundPreview) { return }
- let background = this.background
+ submitBackground (background) {
+ if (!this.backgroundPreview && background !== '') { return }
+
this.backgroundUploading = true
- this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
+ this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
if (!data.error) {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
index b3dcf42c..e14cf054 100644
--- a/src/components/settings_modal/tabs/profile_tab.scss
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -13,8 +13,14 @@
height: auto;
}
- .banner {
+ .banner-background-preview {
max-width: 100%;
+ width: 300px;
+ position: relative;
+
+ img {
+ width: 100%;
+ }
}
.uploading {
@@ -26,18 +32,40 @@
width: 100%;
}
- .bg {
- max-width: 100%;
+ .current-avatar-container {
+ position: relative;
+ width: 150px;
+ height: 150px;
}
.current-avatar {
display: block;
- width: 150px;
- height: 150px;
+ width: 100%;
+ height: 100%;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
}
+ .reset-button {
+ position: absolute;
+ top: 0.2em;
+ right: 0.2em;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ background-color: rgba(0, 0, 0, 0.6);
+ opacity: 0.7;
+ color: white;
+ width: 1.5em;
+ height: 1.5em;
+ text-align: center;
+ line-height: 1.5em;
+ font-size: 1.5em;
+ cursor: pointer;
+ &:hover {
+ opacity: 1;
+ }
+ }
+
.oauth-tokens {
width: 100%;
@@ -86,6 +114,7 @@
&>.emoji-input {
flex: 1 1 auto;
margin: 0 .2em .5em;
+ min-width: 0;
}
&>.icon-container {
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index 0f9210a6..cf88c4e4 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -161,11 +161,19 @@
<p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }}
</p>
- <p>{{ $t('settings.current_avatar') }}</p>
- <img
- :src="user.profile_image_url_original"
- class="current-avatar"
- >
+ <div class="current-avatar-container">
+ <img
+ :src="user.profile_image_url_original"
+ class="current-avatar"
+ >
+ <i
+ v-if="!isDefaultAvatar && pickAvatarBtnVisible"
+ :title="$t('settings.reset_avatar')"
+ class="reset-button icon-cancel"
+ type="button"
+ @click="resetAvatar"
+ />
+ </div>
<p>{{ $t('settings.set_new_avatar') }}</p>
<button
v-show="pickAvatarBtnVisible"
@@ -184,15 +192,20 @@
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2>
- <p>{{ $t('settings.current_profile_banner') }}</p>
- <img
- :src="user.cover_photo"
- class="banner"
- >
+ <div class="banner-background-preview">
+ <img :src="user.cover_photo">
+ <i
+ v-if="!isDefaultBanner"
+ :title="$t('settings.reset_profile_banner')"
+ class="reset-button icon-cancel"
+ type="button"
+ @click="resetBanner"
+ />
+ </div>
<p>{{ $t('settings.set_new_profile_banner') }}</p>
<img
v-if="bannerPreview"
- class="banner"
+ class="banner-background-preview"
:src="bannerPreview"
>
<div>
@@ -208,7 +221,7 @@
<button
v-else-if="bannerPreview"
class="btn btn-default"
- @click="submitBanner"
+ @click="submitBanner(banner)"
>
{{ $t('general.submit') }}
</button>
@@ -225,10 +238,20 @@
</div>
<div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2>
+ <div class="banner-background-preview">
+ <img :src="user.background_image">
+ <i
+ v-if="!isDefaultBackground"
+ :title="$t('settings.reset_profile_background')"
+ class="reset-button icon-cancel"
+ type="button"
+ @click="resetBackground"
+ />
+ </div>
<p>{{ $t('settings.set_new_profile_background') }}</p>
<img
v-if="backgroundPreview"
- class="bg"
+ class="banner-background-preview"
:src="backgroundPreview"
>
<div>
@@ -244,7 +267,7 @@
<button
v-else-if="backgroundPreview"
class="btn btn-default"
- @click="submitBg"
+ @click="submitBackground(background)"
>
{{ $t('general.submit') }}
</button>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 9d61b0c4..e3c5e80a 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -99,7 +99,8 @@ export default {
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
- tooltipRadiusLocal: ''
+ tooltipRadiusLocal: '',
+ chatMessageRadiusLocal: ''
}
},
created () {
@@ -214,7 +215,8 @@ export default {
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
- attachment: this.attachmentRadiusLocal
+ attachment: this.attachmentRadiusLocal,
+ chatMessage: this.chatMessageRadiusLocal
}
},
preview () {
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index d14f854c..d57894de 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -735,6 +735,65 @@
/>
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div>
+ <div class="color-item">
+ <h4>{{ $t('chats.chats') }}</h4>
+ <ColorInput
+ v-model="chatBgColorLocal"
+ name="chatBgColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.background')"
+ />
+ <h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
+ <ColorInput
+ v-model="chatMessageIncomingBgColorLocal"
+ name="chatMessageIncomingBgColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="chatMessageIncomingTextColorLocal"
+ name="chatMessageIncomingTextColor"
+ :fallback="previewTheme.colors.text || 1"
+ :label="$t('settings.text')"
+ />
+ <ColorInput
+ v-model="chatMessageIncomingLinkColorLocal"
+ name="chatMessageIncomingLinkColor"
+ :fallback="previewTheme.colors.link || 1"
+ :label="$t('settings.links')"
+ />
+ <ColorInput
+ v-model="chatMessageIncomingBorderColorLocal"
+ name="chatMessageIncomingBorderLinkColor"
+ :fallback="previewTheme.colors.fg || 1"
+ :label="$t('settings.style.advanced_colors.chat.border')"
+ />
+ <h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
+ <ColorInput
+ v-model="chatMessageOutgoingBgColorLocal"
+ name="chatMessageOutgoingBgColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.background')"
+ />
+ <ColorInput
+ v-model="chatMessageOutgoingTextColorLocal"
+ name="chatMessageOutgoingTextColor"
+ :fallback="previewTheme.colors.text || 1"
+ :label="$t('settings.text')"
+ />
+ <ColorInput
+ v-model="chatMessageOutgoingLinkColorLocal"
+ name="chatMessageOutgoingLinkColor"
+ :fallback="previewTheme.colors.link || 1"
+ :label="$t('settings.links')"
+ />
+ <ColorInput
+ v-model="chatMessageOutgoingBorderColorLocal"
+ name="chatMessageOutgoingBorderLinkColor"
+ :fallback="previewTheme.colors.bg || 1"
+ :label="$t('settings.style.advanced_colors.chat.border')"
+ />
+ </div>
</div>
<div
@@ -814,6 +873,14 @@
max="50"
hard-min="0"
/>
+ <RangeInput
+ v-model="chatMessageRadiusLocal"
+ name="chatMessageRadius"
+ :label="$t('settings.chatMessageRadius')"
+ :fallback="previewTheme.radii.chatMessage || 2"
+ max="50"
+ hard-min="0"
+ />
</div>
<div
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index d1f044f6..281052e5 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,3 +1,4 @@
+import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
@@ -47,7 +48,17 @@ const SideDrawer = {
},
federating () {
return this.$store.state.instance.federating
- }
+ },
+ timelinesRoute () {
+ if (this.$store.state.interface.lastTimeline) {
+ return this.$store.state.interface.lastTimeline
+ }
+ return this.currentUser ? 'friends' : 'public-timeline'
+ },
+ ...mapState({
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ }),
+ ...mapGetters(['unreadChatCount'])
},
methods: {
toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index f253742d..0587ee02 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -40,33 +40,39 @@
</router-link>
</li>
<li
- v-if="currentUser"
+ v-if="currentUser || !privateMode"
@click="toggleDrawer"
>
- <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
- <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
+ <router-link :to="{ name: timelinesRoute }">
+ <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
</router-link>
</li>
<li
- v-if="currentUser"
+ v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer"
>
- <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
- <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
+ <router-link
+ :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
+ style="position: relative"
+ >
+ <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
+ <span
+ v-if="unreadChatCount"
+ class="badge badge-notification unread-chat-count"
+ >
+ {{ unreadChatCount }}
+ </span>
</router-link>
</li>
</ul>
- <ul>
- <li
- v-if="currentUser"
- @click="toggleDrawer"
- >
- <router-link :to="{ name: 'friends' }">
- <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
+ <ul v-if="currentUser">
+ <li @click="toggleDrawer">
+ <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
+ <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link>
</li>
<li
- v-if="currentUser && currentUser.locked"
+ v-if="currentUser.locked"
@click="toggleDrawer"
>
<router-link to="/friend-requests">
@@ -80,23 +86,7 @@
</router-link>
</li>
<li
- v-if="currentUser || !privateMode"
- @click="toggleDrawer"
- >
- <router-link to="/main/public">
- <i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
- </router-link>
- </li>
- <li
- v-if="federating && (currentUser || !privateMode)"
- @click="toggleDrawer"
- >
- <router-link to="/main/all">
- <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
- </router-link>
- </li>
- <li
- v-if="currentUser && chat"
+ v-if="chat"
@click="toggleDrawer"
>
<router-link :to="{ name: 'chat' }">
diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js
index 4f98fff6..8665648a 100644
--- a/src/components/staff_panel/staff_panel.js
+++ b/src/components/staff_panel/staff_panel.js
@@ -2,6 +2,10 @@ import map from 'lodash/map'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = {
+ created () {
+ const nicknames = this.$store.state.instance.staffAccounts
+ nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname))
+ },
components: {
BasicUserCard
},
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 73382521..d263da68 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -9,6 +9,7 @@ import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue'
+import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
@@ -18,6 +19,21 @@ import { mapGetters, mapState } from 'vuex'
const Status = {
name: 'Status',
+ components: {
+ FavoriteButton,
+ ReactButton,
+ RetweetButton,
+ ExtraButtons,
+ PostStatusForm,
+ UserCard,
+ UserAvatar,
+ AvatarList,
+ Timeago,
+ StatusPopover,
+ UserListPopover,
+ EmojiReactions,
+ StatusContent
+ },
props: [
'statusoid',
'expandable',
@@ -141,7 +157,7 @@ const Status = {
return this.mergedConfig.hideFilteredStatuses
},
hideStatus () {
- return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
+ return this.deleted || (this.muted && this.hideFilteredStatuses)
},
isFocused () {
// retweet or root of an expanded conversation
@@ -164,37 +180,6 @@ const Status = {
return user && user.screen_name
}
},
- hideReply () {
- if (this.mergedConfig.replyVisibility === 'all') {
- return false
- }
- if (this.inConversation || !this.isReply) {
- return false
- }
- if (this.status.user.id === this.currentUser.id) {
- return false
- }
- if (this.status.type === 'retweet') {
- return false
- }
- const checkFollowing = this.mergedConfig.replyVisibility === 'following'
- for (var i = 0; i < this.status.attentions.length; ++i) {
- if (this.status.user.id === this.status.attentions[i].id) {
- continue
- }
- // There's zero guarantee of this working. If we happen to have that user and their
- // relationship in store then it will work, but there's kinda little chance of having
- // them for people you're not following.
- const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
- if (checkFollowing && relationship && relationship.following) {
- return false
- }
- if (this.status.attentions[i].id === this.currentUser.id) {
- return false
- }
- }
- return this.status.attentions.length > 0
- },
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
@@ -228,20 +213,6 @@ const Status = {
currentUser: state => state.users.currentUser
})
},
- components: {
- FavoriteButton,
- ReactButton,
- RetweetButton,
- ExtraButtons,
- PostStatusForm,
- UserCard,
- UserAvatar,
- AvatarList,
- Timeago,
- StatusPopover,
- EmojiReactions,
- StatusContent
- },
methods: {
visibilityIcon (visibility) {
switch (visibility) {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
new file mode 100644
index 00000000..8d292d3f
--- /dev/null
+++ b/src/components/status/status.scss
@@ -0,0 +1,414 @@
+
+@import '../../_variables.scss';
+
+$status-margin: 0.75em;
+
+.Status {
+ min-width: 0;
+
+ &:hover {
+ --still-image-img: visible;
+ --still-image-canvas: hidden;
+ }
+
+ &.-focused {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedPost, $fallback--lightBg);
+ color: $fallback--text;
+ color: var(--selectedPostText, $fallback--text);
+
+ --lightText: var(--selectedPostLightText, $fallback--light);
+ --faint: var(--selectedPostFaintText, $fallback--faint);
+ --faintLink: var(--selectedPostFaintLink, $fallback--faint);
+ --postLink: var(--selectedPostPostLink, $fallback--faint);
+ --postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
+ --icon: var(--selectedPostIcon, $fallback--icon);
+ }
+
+ .status-container {
+ display: flex;
+ padding: $status-margin;
+
+ &.-repeat {
+ padding-top: 0;
+ }
+ }
+
+ .pin {
+ padding: $status-margin $status-margin 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ .left-side {
+ margin-right: $status-margin;
+ }
+
+ .right-side {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .usercard {
+ margin-bottom: $status-margin;
+ }
+
+ .status-username {
+ white-space: nowrap;
+ font-size: 14px;
+ overflow: hidden;
+ max-width: 85%;
+ font-weight: bold;
+ flex-shrink: 1;
+ margin-right: 0.4em;
+ text-overflow: ellipsis;
+
+ .emoji {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+ }
+
+ .status-favicon {
+ height: 18px;
+ width: 18px;
+ margin-right: 0.4em;
+ }
+
+ .status-heading {
+ margin-bottom: 0.5em;
+ }
+
+ .heading-name-row {
+ display: flex;
+ justify-content: space-between;
+ line-height: 18px;
+
+ a {
+ display: inline-block;
+ word-break: break-all;
+ }
+ }
+
+ .account-name {
+ min-width: 1.6em;
+ margin-right: 0.4em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1 1 0;
+ }
+
+ .heading-left {
+ display: flex;
+ min-width: 0;
+ }
+
+ .heading-right {
+ display: flex;
+ flex-shrink: 0;
+ }
+
+ .timeago {
+ margin-right: 0.2em;
+ }
+
+ .heading-reply-row {
+ position: relative;
+ align-content: baseline;
+ font-size: 12px;
+ line-height: 18px;
+ max-width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+ }
+
+ .reply-to-and-accountname {
+ display: flex;
+ height: 18px;
+ margin-right: 0.5em;
+ max-width: 100%;
+
+ .reply-to-link {
+ white-space: nowrap;
+ word-break: break-word;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ }
+
+ .icon-reply {
+ // mirror the icon
+ transform: scaleX(-1);
+ }
+ }
+
+ & .reply-to-popover,
+ & .reply-to-no-popover {
+ min-width: 0;
+ margin-right: 0.4em;
+ flex-shrink: 0;
+ }
+
+ .reply-to-popover {
+ .reply-to:hover::before {
+ content: '';
+ display: block;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ border-bottom: 1px solid var(--faint);
+ pointer-events: none;
+ }
+
+ .faint-link:hover {
+ // override default
+ text-decoration: none;
+ }
+
+ &.-strikethrough {
+ .reply-to::after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 50%;
+ width: 100%;
+ border-bottom: 1px solid var(--faint);
+ pointer-events: none;
+ }
+ }
+ }
+
+ .reply-to {
+ display: flex;
+ position: relative;
+ }
+
+ .reply-to-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-left: 0.2em;
+ }
+
+ .replies-separator {
+ margin-left: 0.4em;
+ }
+
+ .replies {
+ line-height: 18px;
+ font-size: 12px;
+ display: flex;
+ flex-wrap: wrap;
+
+ & > * {
+ margin-right: 0.4em;
+ }
+ }
+
+ .reply-link {
+ height: 17px;
+ }
+
+ .repeat-info {
+ padding: 0.4em $status-margin;
+ line-height: 22px;
+
+ .right-side {
+ display: flex;
+ align-content: center;
+ flex-wrap: wrap;
+ }
+
+ i {
+ padding: 0 0.2em;
+ }
+ }
+
+ .repeater-avatar {
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ margin-left: 28px;
+ width: 20px;
+ height: 20px;
+ }
+
+ .repeater-name {
+ text-overflow: ellipsis;
+ margin-right: 0;
+
+ .emoji {
+ width: 14px;
+ height: 14px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+ }
+
+ .status-fadein {
+ animation-duration: 0.4s;
+ animation-name: fadein;
+ }
+
+ @keyframes fadein {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+ }
+
+ .status-actions {
+ position: relative;
+ width: 100%;
+ display: flex;
+ margin-top: $status-margin;
+
+ > * {
+ max-width: 4em;
+ flex: 1;
+ }
+ }
+
+ .button-reply {
+ &:not(.-disabled) {
+ cursor: pointer;
+ }
+
+ &:not(.-disabled):hover,
+ &.-active {
+ color: $fallback--cBlue;
+ color: var(--cBlue, $fallback--cBlue);
+ }
+ }
+
+ .muted {
+ padding: 0.25em 0.6em;
+ height: 1.2em;
+ line-height: 1.2em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: flex;
+ flex-wrap: nowrap;
+
+ & .status-username,
+ & .mute-thread,
+ & .mute-words {
+ word-wrap: normal;
+ word-break: normal;
+ white-space: nowrap;
+ }
+
+ & .status-username,
+ & .mute-words {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .status-username {
+ font-weight: normal;
+ flex: 0 1 auto;
+ margin-right: 0.2em;
+ font-size: smaller;
+ }
+
+ .mute-thread {
+ flex: 0 0 auto;
+ }
+
+ .mute-words {
+ flex: 1 0 5em;
+ margin-left: 0.2em;
+
+ &::before {
+ content: ' ';
+ }
+ }
+
+ .unmute {
+ flex: 0 0 auto;
+ margin-left: auto;
+ display: block;
+ }
+ }
+
+ .reply-form {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .reply-body {
+ flex: 1;
+ }
+
+ .favs-repeated-users {
+ margin-top: $status-margin;
+ }
+
+ .stats {
+ width: 100%;
+ display: flex;
+ line-height: 1em;
+ }
+
+ .avatar-row {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ display: flex;
+ align-items: center;
+
+ &::before {
+ content: '';
+ position: absolute;
+ height: 100%;
+ width: 1px;
+ left: 0;
+ background-color: var(--faint, $fallback--faint);
+ }
+ }
+
+ .stat-count {
+ margin-right: $status-margin;
+ user-select: none;
+
+ .stat-title {
+ color: var(--faint, $fallback--faint);
+ font-size: 12px;
+ text-transform: uppercase;
+ position: relative;
+ }
+
+ .stat-number {
+ font-weight: bolder;
+ font-size: 16px;
+ line-height: 1em;
+ }
+
+ &:hover .stat-title {
+ text-decoration: underline;
+ }
+ }
+
+ @media all and (max-width: 800px) {
+ .repeater-avatar {
+ margin-left: 20px;
+ }
+
+ .avatar:not(.repeater-avatar) {
+ width: 40px;
+ height: 40px;
+
+ // TODO define those other way somehow?
+ // stylelint-disable rscss/class-format
+ &.avatar-compact {
+ width: 32px;
+ height: 32px;
+ }
+ }
+ }
+}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 7ec29b28..282ad37d 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -2,8 +2,8 @@
<!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
- class="status-el"
- :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"
+ class="Status"
+ :class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
>
<div
v-if="error"
@@ -16,8 +16,8 @@
/>
</div>
<template v-if="muted && !isPreview">
- <div class="media status container muted">
- <small class="username">
+ <div class="status-csontainer muted">
+ <small class="status-username">
<i
v-if="muted && retweet"
class="button-icon icon-retweet"
@@ -54,7 +54,7 @@
<template v-else>
<div
v-if="showPinned"
- class="status-pin"
+ class="pin"
>
<i class="fa icon-pin faint" />
<span class="faint">{{ $t('status.pinned') }}</span>
@@ -63,16 +63,19 @@
v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]"
- class="media container retweet-info"
+ class="status-container repeat-info"
>
<UserAvatar
v-if="retweet"
- class="media-left"
+ class="left-side repeater-avatar"
:better-shadow="betterShadow"
:user="statusoid.user"
/>
- <div class="media-body faint">
- <span class="user-name">
+ <div class="right-side faint">
+ <span
+ class="status-username repeater-name"
+ :title="retweeter"
+ >
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
@@ -92,14 +95,14 @@
</div>
<div
- :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]"
+ :class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]"
- class="media status"
+ class="status-container"
:data-tags="tags"
>
<div
v-if="!noHeading"
- class="media-left"
+ class="left-side"
>
<router-link
:to="userProfileLink"
@@ -112,37 +115,45 @@
/>
</router-link>
</div>
- <div class="status-body">
+ <div class="right-side">
<UserCard
v-if="userExpanded"
:user-id="status.user.id"
:rounded="true"
:bordered="true"
- class="status-usercard"
+ class="usercard"
/>
<div
v-if="!noHeading"
- class="media-heading"
+ class="status-heading"
>
<div class="heading-name-row">
- <div class="name-and-account-name">
+ <div class="heading-left">
<h4
v-if="status.user.name_html"
- class="user-name"
+ class="status-username"
+ :title="status.user.name"
v-html="status.user.name_html"
/>
<h4
v-else
- class="user-name"
+ class="status-username"
+ :title="status.user.name"
>
{{ status.user.name }}
</h4>
<router-link
class="account-name"
+ :title="status.user.screen_name"
:to="userProfileLink"
>
{{ status.user.screen_name }}
</router-link>
+ <img
+ v-if="!!(status.user && status.user.favicon)"
+ class="status-favicon"
+ :src="status.user.favicon"
+ >
</div>
<span class="heading-right">
@@ -197,9 +208,10 @@
>
<StatusPopover
v-if="!isPreview"
- :status-id="status.in_reply_to_status_id"
+ :status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
style="min-width: 0"
+ :class="{ '-strikethrough': !status.parent_visible }"
>
<a
class="reply-to"
@@ -207,17 +219,25 @@
:aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
- <i class="button-icon icon-reply" />
- <span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span>
+ <i class="button-icon reply-button icon-reply" />
+ <span
+ class="faint-link reply-to-text"
+ >
+ {{ $t('status.reply_to') }}
+ </span>
</a>
</StatusPopover>
<span
v-else
- class="reply-to"
+ class="reply-to-no-popover"
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
- <router-link :to="replyProfileLink">
+ <router-link
+ class="reply-to-link"
+ :title="replyToName"
+ :to="replyProfileLink"
+ >
{{ replyToName }}
</router-link>
<span
@@ -260,24 +280,30 @@
class="favs-repeated-users"
>
<div class="stats">
- <div
+ <UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
- class="stat-count"
+ :users="statusFromGlobalRepository.rebloggedBy"
>
- <a class="stat-title">{{ $t('status.repeats') }}</a>
- <div class="stat-number">
- {{ statusFromGlobalRepository.rebloggedBy.length }}
+ <div class="stat-count">
+ <a class="stat-title">{{ $t('status.repeats') }}</a>
+ <div class="stat-number">
+ {{ statusFromGlobalRepository.rebloggedBy.length }}
+ </div>
</div>
- </div>
- <div
+ </UserListPopover>
+ <UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
- class="stat-count"
+ :users="statusFromGlobalRepository.favoritedBy"
>
- <a class="stat-title">{{ $t('status.favorites') }}</a>
- <div class="stat-number">
- {{ statusFromGlobalRepository.favoritedBy.length }}
+ <div
+ class="stat-count"
+ >
+ <a class="stat-title">{{ $t('status.favorites') }}</a>
+ <div class="stat-number">
+ {{ statusFromGlobalRepository.favoritedBy.length }}
+ </div>
</div>
- </div>
+ </UserListPopover>
<div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" />
</div>
@@ -292,19 +318,19 @@
<div
v-if="!noHeading && !isPreview"
- class="status-actions media-body"
+ class="status-actions"
>
<div>
<i
v-if="loggedIn"
- class="button-icon icon-reply"
+ class="button-icon button-reply icon-reply"
:title="$t('tool_tip.reply')"
- :class="{'button-icon-active': replying}"
+ :class="{'-active': replying}"
@click.prevent="toggleReplying"
/>
<i
v-else
- class="button-icon button-icon-disabled icon-reply"
+ class="button-icon button-reply -disabled icon-reply"
:title="$t('tool_tip.reply')"
/>
<span v-if="status.replies_count > 0">{{ status.replies_count }}</span>
@@ -332,7 +358,7 @@
</div>
<div
v-if="replying"
- class="container"
+ class="status-container reply-form"
>
<PostStatusForm
class="reply-body"
@@ -350,427 +376,4 @@
</template>
<script src="./status.js" ></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-$status-margin: 0.75em;
-
-.status-body {
- flex: 1;
- min-width: 0;
-}
-
-.status-pin {
- padding: $status-margin $status-margin 0;
- display: flex;
- align-items: center;
- justify-content: flex-end;
-}
-
-.media-left {
- margin-right: $status-margin;
-}
-
-.status-el {
- overflow-wrap: break-word;
- word-wrap: break-word;
- word-break: break-word;
- border-left-width: 0px;
- min-width: 0;
- border-color: $fallback--border;
- border-color: var(--border, $fallback--border);
-
- border-left: 4px $fallback--cRed;
- border-left: 4px var(--cRed, $fallback--cRed);
-
- &_focused {
- background-color: $fallback--lightBg;
- background-color: var(--selectedPost, $fallback--lightBg);
- color: $fallback--text;
- color: var(--selectedPostText, $fallback--text);
- --lightText: var(--selectedPostLightText, $fallback--light);
- --faint: var(--selectedPostFaintText, $fallback--faint);
- --faintLink: var(--selectedPostFaintLink, $fallback--faint);
- --postLink: var(--selectedPostPostLink, $fallback--faint);
- --postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
- --icon: var(--selectedPostIcon, $fallback--icon);
- }
-
- .timeline & {
- border-bottom-width: 1px;
- border-bottom-style: solid;
- }
-
- .media-body {
- flex: 1;
- padding: 0;
- }
-
- .status-usercard {
- margin-bottom: $status-margin;
- }
-
- .user-name {
- white-space: nowrap;
- font-size: 14px;
- overflow: hidden;
- flex-shrink: 0;
- max-width: 85%;
- font-weight: bold;
-
- img.emoji {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
- }
-
- .media-heading {
- padding: 0;
- vertical-align: bottom;
- flex-basis: 100%;
- margin-bottom: 0.5em;
-
- small {
- font-weight: lighter;
- }
-
- .heading-name-row {
- padding: 0;
- display: flex;
- justify-content: space-between;
- line-height: 18px;
-
- a {
- display: inline-block;
- word-break: break-all;
- }
-
- .name-and-account-name {
- display: flex;
- min-width: 0;
- }
-
- .user-name {
- flex-shrink: 1;
- margin-right: 0.4em;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .account-name {
- min-width: 1.6em;
- margin-right: 0.4em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1 1 0;
- }
- }
-
- .heading-right {
- display: flex;
- flex-shrink: 0;
- }
-
- .timeago {
- margin-right: 0.2em;
- }
-
- .heading-reply-row {
- position: relative;
- align-content: baseline;
- font-size: 12px;
- line-height: 18px;
- max-width: 100%;
- display: flex;
- flex-wrap: wrap;
- align-items: stretch;
-
- > .reply-to-and-accountname > a {
- overflow: hidden;
- max-width: 100%;
- text-overflow: ellipsis;
- white-space: nowrap;
- word-break: break-all;
- }
- }
-
- .reply-to-and-accountname {
- display: flex;
- height: 18px;
- margin-right: 0.5em;
- max-width: 100%;
- .icon-reply {
- transform: scaleX(-1);
- }
- }
-
- .reply-info {
- display: flex;
- }
-
- .reply-to-popover {
- min-width: 0;
- }
-
- .reply-to {
- display: flex;
- }
-
- .reply-to-text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- margin: 0 0.4em 0 0.2em;
- }
-
- .replies-separator {
- margin-left: 0.4em;
- }
-
- .replies {
- line-height: 18px;
- font-size: 12px;
- display: flex;
- flex-wrap: wrap;
- & > * {
- margin-right: 0.4em;
- }
- }
-
- .reply-link {
- height: 17px;
- }
- }
-
- .retweet-info {
- padding: 0.4em $status-margin;
- margin: 0;
-
- .avatar.still-image {
- border-radius: $fallback--avatarAltRadius;
- border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
- margin-left: 28px;
- width: 20px;
- height: 20px;
- }
-
- .media-body {
- font-size: 1em;
- line-height: 22px;
-
- display: flex;
- align-content: center;
- flex-wrap: wrap;
-
- .user-name {
- font-weight: bold;
- overflow: hidden;
- text-overflow: ellipsis;
-
- img {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- object-fit: contain
- }
- }
-
- i {
- padding: 0 0.2em;
- }
-
- a {
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
- }
-}
-
-.status-fadein {
- animation-duration: 0.4s;
- animation-name: fadein;
-}
-
-@keyframes fadein {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-.status-conversation {
- border-left-style: solid;
-}
-
-.status-actions {
- position: relative;
- width: 100%;
- display: flex;
- margin-top: $status-margin;
-
- > * {
- max-width: 4em;
- flex: 1;
- }
-}
-
-.button-icon.icon-reply {
- &:not(.button-icon-disabled):hover,
- &.button-icon-active {
- color: $fallback--cBlue;
- color: var(--cBlue, $fallback--cBlue);
- }
-}
-
-.button-icon.icon-reply {
- &:not(.button-icon-disabled) {
- cursor: pointer;
- }
-}
-
-.status:hover .animated.avatar {
- canvas {
- display: none;
- }
- img {
- visibility: visible;
- }
-}
-
-.status {
- display: flex;
- padding: $status-margin;
- &.is-retweet {
- padding-top: 0;
- }
-}
-
-.status-conversation:last-child {
- border-bottom: none;
-}
-
-.muted {
- padding: .25em .6em;
- height: 1.2em;
- line-height: 1.2em;
- text-overflow: ellipsis;
- overflow: hidden;
- display: flex;
- flex-wrap: nowrap;
-
- .username, .mute-thread, .mute-words {
- word-wrap: normal;
- word-break: normal;
- white-space: nowrap;
- }
-
- .username, .mute-words {
- text-overflow: ellipsis;
- overflow: hidden;
- }
-
- .username {
- flex: 0 1 auto;
- margin-right: .2em;
- }
-
- .mute-thread {
- flex: 0 0 auto;
- }
-
- .mute-words {
- flex: 1 0 5em;
- margin-left: .2em;
- &::before {
- content: ' '
- }
- }
-
- .unmute {
- flex: 0 0 auto;
- margin-left: auto;
- display: block;
- margin-left: auto;
- }
-}
-
-.reply-body {
- flex: 1;
-}
-
-.favs-repeated-users {
- margin-top: $status-margin;
-
- .stats {
- width: 100%;
- display: flex;
- line-height: 1em;
-
- .stat-count {
- margin-right: $status-margin;
-
- .stat-title {
- color: var(--faint, $fallback--faint);
- font-size: 12px;
- text-transform: uppercase;
- position: relative;
- }
-
- .stat-number {
- font-weight: bolder;
- font-size: 16px;
- line-height: 1em;
- }
- }
-
- .avatar-row {
- flex: 1;
- overflow: hidden;
- position: relative;
- display: flex;
- align-items: center;
-
- &::before {
- content: '';
- position: absolute;
- height: 100%;
- width: 1px;
- left: 0;
- background-color: var(--faint, $fallback--faint);
- }
- }
- }
-}
-
-@media all and (max-width: 800px) {
- .status-el {
- .retweet-info {
- .avatar.still-image {
- margin-left: 20px;
- }
- }
- }
- .status {
- max-width: 100%;
- }
-
- .status .avatar.still-image {
- width: 40px;
- height: 40px;
-
- &.avatar-compact {
- width: 32px;
- height: 32px;
- }
- }
-}
-
-</style>
+<style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index c0a71e8f..df095de3 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -14,11 +14,12 @@ const StatusContent = {
'status',
'focused',
'noHeading',
- 'fullContent'
+ 'fullContent',
+ 'singleLine'
],
data () {
return {
- showingTall: this.inConversation && this.focused,
+ showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
@@ -44,14 +45,14 @@ const StatusContent = {
return lengthScore > 20
},
longSubject () {
- return this.status.summary.length > 900
+ return this.status.summary.length > 240
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
- return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
+ return !!this.status.summary && this.localCollapseSubjectDefault
},
mightHideBecauseTall () {
- return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
+ return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
@@ -99,15 +100,8 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
- hasImageAttachments () {
- return this.status.attachments.some(
- file => fileType.fileType(file.mimetype) === 'image'
- )
- },
- hasVideoAttachments () {
- return this.status.attachments.some(
- file => fileType.fileType(file.mimetype) === 'video'
- )
+ attachmentTypes () {
+ return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
@@ -142,12 +136,6 @@ const StatusContent = {
return html
}
},
- contentHtml () {
- if (!this.status.summary_html) {
- return this.postBodyHtml
- }
- return this.status.summary_html + '<br />' + this.postBodyHtml
- },
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index efc2485e..76fe3278 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,47 +1,34 @@
<template>
<!-- eslint-disable vue/no-v-html -->
- <div class="status-body">
+ <div class="StatusContent">
<slot name="header" />
<div
- v-if="longSubject"
- class="status-content-wrapper"
- :class="{ 'tall-status': !showingLongSubject }"
+ v-if="status.summary_html"
+ class="summary-wrapper"
+ :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
>
- <a
- v-if="!showingLongSubject"
- class="tall-status-hider"
- :class="{ 'tall-status-hider_focused': focused }"
- href="#"
- @click.prevent="showingLongSubject=true"
- >
- {{ $t("general.show_more") }}
- <span
- v-if="hasImageAttachments"
- class="icon-picture"
- />
- <span
- v-if="hasVideoAttachments"
- class="icon-video"
- />
- <span
- v-if="status.card"
- class="icon-link"
- />
- </a>
<div
- class="status-content media-body"
+ class="media-body summary"
@click.prevent="linkClicked"
- v-html="contentHtml"
+ v-html="status.summary_html"
/>
<a
- v-if="showingLongSubject"
+ v-if="longSubject && showingLongSubject"
href="#"
- class="status-unhider"
+ class="tall-subject-hider"
@click.prevent="showingLongSubject=false"
- >{{ $t("general.show_less") }}</a>
+ >{{ $t("status.hide_full_subject") }}</a>
+ <a
+ v-else-if="longSubject"
+ class="tall-subject-hider"
+ :class="{ 'tall-subject-hider_focused': focused }"
+ href="#"
+ @click.prevent="showingLongSubject=true"
+ >
+ {{ $t("status.show_full_subject") }}
+ </a>
</div>
<div
- v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
@@ -51,34 +38,59 @@
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
- >{{ $t("general.show_more") }}</a>
+ >
+ {{ $t("general.show_more") }}
+ </a>
<div
v-if="!hideSubjectStatus"
+ :class="{ 'single-line': singleLine }"
class="status-content media-body"
@click.prevent="linkClicked"
- v-html="contentHtml"
- />
- <div
- v-else
- class="status-content media-body"
- @click.prevent="linkClicked"
- v-html="status.summary_html"
+ v-html="postBodyHtml"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
- >{{ $t("general.show_more") }}</a>
+ >
+ {{ $t("status.show_content") }}
+ <span
+ v-if="attachmentTypes.includes('image')"
+ class="icon-picture"
+ />
+ <span
+ v-if="attachmentTypes.includes('video')"
+ class="icon-video"
+ />
+ <span
+ v-if="attachmentTypes.includes('audio')"
+ class="icon-music"
+ />
+ <span
+ v-if="attachmentTypes.includes('unknown')"
+ class="icon-doc"
+ />
+ <span
+ v-if="status.poll && status.poll.options"
+ class="icon-chart-bar"
+ />
+ <span
+ v-if="status.card"
+ class="icon-link"
+ />
+ </a>
<a
- v-if="showingMore"
+ v-if="showingMore && !fullContent"
href="#"
class="status-unhider"
@click.prevent="toggleShowMore"
- >{{ $t("general.show_less") }}</a>
+ >
+ {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
+ </a>
</div>
- <div v-if="status.poll && status.poll.options">
+ <div v-if="status.poll && status.poll.options && !hideSubjectStatus">
<poll :base-poll="status.poll" />
</div>
@@ -125,10 +137,16 @@
$status-margin: 0.75em;
-.status-body {
+.StatusContent {
flex: 1;
min-width: 0;
+ .status-content-wrapper {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ }
+
.tall-status {
position: relative;
height: 220px;
@@ -136,7 +154,7 @@ $status-margin: 0.75em;
overflow-y: hidden;
z-index: 1;
.status-content {
- height: 100%;
+ min-height: 0;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
@@ -176,10 +194,45 @@ $status-margin: 0.75em;
}
}
+ .summary-wrapper {
+ margin-bottom: 0.5em;
+ border-style: solid;
+ border-width: 0 0 1px 0;
+ border-color: var(--border, $fallback--border);
+ flex-grow: 0;
+ }
+
+ .summary {
+ font-style: italic;
+ padding-bottom: 0.5em;
+ }
+
+ .tall-subject {
+ position: relative;
+ .summary {
+ max-height: 2em;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .tall-subject-hider {
+ display: inline-block;
+ word-break: break-all;
+ // position: absolute;
+ width: 100%;
+ text-align: center;
+ padding-bottom: 0.5em;
+ }
+
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
blockquote {
margin: 0.2em 0 0.2em 2em;
@@ -221,6 +274,13 @@ $status-margin: 0.75em;
h4 {
margin: 1.1em 0;
}
+
+ &.single-line {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ height: 1.4em;
+ }
}
}
@@ -228,13 +288,4 @@ $status-margin: 0.75em;
color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen);
}
-
-.timeline :not(.panel-disabled) > {
- .status-el:last-child {
- border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
- border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
- border-bottom: none;
- }
-}
-
</style>
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index 159132a9..51e7680c 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -22,6 +22,10 @@ const StatusPopover = {
methods: {
enter () {
if (!this.status) {
+ if (!this.statusId) {
+ this.error = true
+ return
+ }
this.$store.dispatch('fetchStatus', this.statusId)
.then(data => (this.error = false))
.catch(e => (this.error = true))
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index f5948207..162eb210 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,7 +1,7 @@
<template>
<Popover
trigger="hover"
- popover-class="status-popover"
+ popover-class="popover-default status-popover"
:bound-to="{ x: 'container' }"
@show="enter"
>
@@ -38,7 +38,8 @@
<style lang="scss">
@import '../../_variables.scss';
-.status-popover {
+/* popover styles load on-demand, so we need to override */
+.status-popover.popover {
font-size: 1rem;
min-width: 15em;
max-width: 95%;
@@ -52,7 +53,8 @@
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
- .status-el.status-el {
+ /* TODO cleanup this */
+ .Status.Status {
border: none;
}
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index e48fef47..ab40bbd7 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -4,7 +4,8 @@ const StillImage = {
'referrerpolicy',
'mimetype',
'imageLoadError',
- 'imageLoadHandler'
+ 'imageLoadHandler',
+ 'alt'
],
data () {
return {
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index f2ddeb7b..ad82210d 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -11,6 +11,8 @@
<img
ref="src"
:key="src"
+ :alt="alt"
+ :title="alt"
:src="src"
:referrerpolicy="referrerpolicy"
@load="onLoad"
@@ -28,13 +30,19 @@
position: relative;
line-height: 0;
overflow: hidden;
- width: 100%;
- height: 100%;
display: flex;
align-items: center;
- &:hover canvas {
- display: none;
+ canvas {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ visibility: var(--still-image-canvas, visible);
}
img {
@@ -44,15 +52,6 @@
}
&.animated {
- &:hover::before,
- img {
- visibility: hidden;
- }
-
- &:hover img {
- visibility: visible
- }
-
&::before {
content: 'gif';
position: absolute;
@@ -60,25 +59,28 @@
font-size: 10px;
top: 5px;
left: 5px;
- background: rgba(127,127,127,.5);
- color: #FFF;
+ background: rgba(127, 127, 127, 0.5);
+ color: #fff;
display: block;
padding: 2px 4px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2;
+ visibility: var(--still-image-label-visibility, visible);
}
- }
- canvas {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- width: 100%;
- height: 100%;
- object-fit: contain;
+ &:hover canvas {
+ display: none;
+ }
+
+ &:hover::before,
+ img {
+ visibility: var(--still-image-img, hidden);
+ }
+
+ &:hover img {
+ visibility: visible;
+ }
}
}
</style>
diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js
index 7891cb78..9c1da354 100644
--- a/src/components/tab_switcher/tab_switcher.js
+++ b/src/components/tab_switcher/tab_switcher.js
@@ -1,4 +1,5 @@
import Vue from 'vue'
+import { mapState } from 'vuex'
import './tab_switcher.scss'
@@ -44,7 +45,13 @@ export default Vue.component('tab-switcher', {
} else {
return this.active
}
- }
+ },
+ settingsModalVisible () {
+ return this.settingsModalState === 'visible'
+ },
+ ...mapState({
+ settingsModalState: state => state.interface.settingsModalState
+ })
},
beforeUpdate () {
const currentSlot = this.$slots.default[this.active]
@@ -53,16 +60,19 @@ export default Vue.component('tab-switcher', {
}
},
methods: {
- activateTab (index) {
+ clickTab (index) {
return (e) => {
e.preventDefault()
- if (typeof this.onSwitch === 'function') {
- this.onSwitch.call(null, this.$slots.default[index].key)
- }
- this.active = index
- if (this.scrollableTabs) {
- this.$refs.contents.scrollTop = 0
- }
+ this.setTab(index)
+ }
+ },
+ setTab (index) {
+ if (typeof this.onSwitch === 'function') {
+ this.onSwitch.call(null, this.$slots.default[index].key)
+ }
+ this.active = index
+ if (this.scrollableTabs) {
+ this.$refs.contents.scrollTop = 0
}
}
},
@@ -81,7 +91,7 @@ export default Vue.component('tab-switcher', {
<div class={classesWrapper.join(' ')}>
<button
disabled={slot.data.attrs.disabled}
- onClick={this.activateTab(index)}
+ onClick={this.clickTab(index)}
class={classesTab.join(' ')}>
<img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/>
{slot.data.attrs.label ? '' : slot.data.attrs.label}
@@ -93,7 +103,7 @@ export default Vue.component('tab-switcher', {
<div class={classesWrapper.join(' ')}>
<button
disabled={slot.data.attrs.disabled}
- onClick={this.activateTab(index)}
+ onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
>
@@ -134,7 +144,7 @@ export default Vue.component('tab-switcher', {
<div class="tabs">
{tabs}
</div>
- <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}>
+ <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}>
{contents}
</div>
</div>
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 9a53acd6..5a7f7a78 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,6 +1,7 @@
import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import Conversation from '../conversation/conversation.vue'
+import TimelineMenu from '../timeline_menu/timeline_menu.vue'
import { throttle, keyBy } from 'lodash'
export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => {
@@ -35,6 +36,11 @@ const Timeline = {
bottomedOut: false
}
},
+ components: {
+ Status,
+ Conversation,
+ TimelineMenu
+ },
computed: {
timelineError () {
return this.$store.state.statuses.error
@@ -45,11 +51,15 @@ const Timeline = {
newStatusCount () {
return this.timeline.newStatusCount
},
- newStatusCountStr () {
+ showLoadButton () {
+ if (this.timelineError || this.errorData) return false
+ return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
+ },
+ loadButtonString () {
if (this.timeline.flushMarker !== 0) {
- return ''
+ return this.$t('timeline.reload')
} else {
- return ` (${this.newStatusCount})`
+ return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
classes () {
@@ -70,10 +80,6 @@ const Timeline = {
return keyBy(this.pinnedStatusIds)
}
},
- components: {
- Status,
- Conversation
- },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
@@ -112,8 +118,6 @@ const Timeline = {
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses () {
- if (this.newStatusCount === 0) return
-
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
@@ -135,7 +139,7 @@ const Timeline = {
showImmediately: true,
userId: this.userId,
tag: this.tag
- }).then(statuses => {
+ }).then(({ statuses }) => {
store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) {
this.bottomedOut = true
@@ -146,7 +150,6 @@ const Timeline = {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -(bodyBRect.y))
if (this.timeline.loading === false &&
- this.$store.getters.mergedConfig.autoLoad &&
this.$el.offsetHeight > 0 &&
(window.innerHeight + window.pageYOffset) >= (height - 750)) {
this.fetchOlderStatuses()
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 9777bd0c..2ff933e9 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,9 +1,7 @@
<template>
- <div :class="classes.root">
+ <div :class="[classes.root, 'timeline']">
<div :class="classes.header">
- <div class="title">
- {{ title }}
- </div>
+ <TimelineMenu v-if="!embedded" />
<div
v-if="timelineError"
class="loadmore-error alert error"
@@ -19,14 +17,14 @@
{{ errorData.statusText }}
</div>
<button
- v-if="timeline.newStatusCount > 0 && !timelineError && !errorData"
+ v-else-if="showLoadButton"
class="loadmore-button"
@click.prevent="showNewStatuses"
>
- {{ $t('timeline.show_new') }}{{ newStatusCountStr }}
+ {{ loadButtonString }}
</button>
<div
- v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData"
+ v-else
class="loadmore-text faint"
@click.prevent
>
@@ -106,4 +104,16 @@
opacity: 1;
}
}
+
+.timeline-heading {
+ max-width: 100%;
+ flex-wrap: nowrap;
+ .loadmore-button {
+ flex-shrink: 0;
+ }
+ .loadmore-text {
+ flex-shrink: 0;
+ line-height: 1em;
+ }
+}
</style>
diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js
new file mode 100644
index 00000000..2be75b06
--- /dev/null
+++ b/src/components/timeline_menu/timeline_menu.js
@@ -0,0 +1,63 @@
+import Popover from '../popover/popover.vue'
+import { mapState } from 'vuex'
+
+// Route -> i18n key mapping, exported andnot in the computed
+// because nav panel benefits from the same information.
+export const timelineNames = () => {
+ return {
+ 'friends': 'nav.timeline',
+ 'bookmarks': 'nav.bookmarks',
+ 'dms': 'nav.dms',
+ 'public-timeline': 'nav.public_tl',
+ 'public-external-timeline': 'nav.twkn',
+ 'tag-timeline': 'tag'
+ }
+}
+
+const TimelineMenu = {
+ components: {
+ Popover
+ },
+ data () {
+ return {
+ isOpen: false
+ }
+ },
+ created () {
+ if (this.currentUser && this.currentUser.locked) {
+ this.$store.dispatch('startFetchingFollowRequests')
+ }
+ if (timelineNames()[this.$route.name]) {
+ this.$store.dispatch('setLastTimeline', this.$route.name)
+ }
+ },
+ methods: {
+ openMenu () {
+ // $nextTick is too fast, animation won't play back but
+ // instead starts in fully open position. Low values
+ // like 1-5 work on fast machines but not on mobile, 25
+ // seems like a good compromise that plays without significant
+ // added lag.
+ setTimeout(() => {
+ this.isOpen = true
+ }, 25)
+ },
+ timelineName () {
+ const route = this.$route.name
+ if (route === 'tag-timeline') {
+ return '#' + this.$route.params.tag
+ }
+ const i18nkey = timelineNames()[this.$route.name]
+ return i18nkey ? this.$t(i18nkey) : route
+ }
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ })
+ }
+}
+
+export default TimelineMenu
diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue
new file mode 100644
index 00000000..be512d60
--- /dev/null
+++ b/src/components/timeline_menu/timeline_menu.vue
@@ -0,0 +1,180 @@
+<template>
+ <Popover
+ trigger="click"
+ class="timeline-menu"
+ :class="{ 'open': isOpen }"
+ :margin="{ left: -15, right: -200 }"
+ :bound-to="{ x: 'container' }"
+ popover-class="timeline-menu-popover-wrap"
+ @show="openMenu"
+ @close="() => isOpen = false"
+ >
+ <div
+ slot="content"
+ class="timeline-menu-popover panel panel-default"
+ >
+ <ul>
+ <li v-if="currentUser">
+ <router-link :to="{ name: 'friends' }">
+ <i class="button-icon icon-home-2" />{{ $t("nav.timeline") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser">
+ <router-link :to="{ name: 'bookmarks'}">
+ <i class="button-icon icon-bookmark" />{{ $t("nav.bookmarks") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser">
+ <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
+ <i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }}
+ </router-link>
+ </li>
+ <li v-if="currentUser || !privateMode">
+ <router-link :to="{ name: 'public-timeline' }">
+ <i class="button-icon icon-users" />{{ $t("nav.public_tl") }}
+ </router-link>
+ </li>
+ <li v-if="federating && (currentUser || !privateMode)">
+ <router-link :to="{ name: 'public-external-timeline' }">
+ <i class="button-icon icon-globe" />{{ $t("nav.twkn") }}
+ </router-link>
+ </li>
+ </ul>
+ </div>
+ <div
+ slot="trigger"
+ class="title timeline-menu-title"
+ >
+ <span>{{ timelineName() }}</span>
+ <i class="icon-down-open" />
+ </div>
+ </Popover>
+</template>
+
+<script src="./timeline_menu.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.timeline-menu {
+ flex-shrink: 1;
+ margin-right: auto;
+ min-width: 0;
+ width: 24rem;
+ .timeline-menu-popover-wrap {
+ overflow: hidden;
+ // Match panel heading padding to line up menu with bottom of heading
+ margin-top: 0.6rem;
+ padding: 0 15px 15px 15px;
+ }
+ .timeline-menu-popover {
+ width: 24rem;
+ max-width: 100vw;
+ margin: 0;
+ font-size: 1rem;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ transform: translateY(-100%);
+ transition: transform 100ms;
+ }
+ .panel::after {
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ }
+ &.open .timeline-menu-popover {
+ transform: translateY(0);
+ }
+
+ .timeline-menu-title {
+ margin: 0;
+ cursor: pointer;
+ display: flex;
+ user-select: none;
+ width: 100%;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ i {
+ margin-left: 0.6em;
+ flex-shrink: 0;
+ font-size: 1rem;
+ transition: transform 100ms;
+ }
+ }
+
+ &.open .timeline-menu-title i {
+ color: $fallback--text;
+ color: var(--panelText, $fallback--text);
+ transform: rotate(180deg);
+ }
+
+ .panel {
+ box-shadow: var(--popoverShadow);
+ }
+
+ ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ li {
+ border-bottom: 1px solid;
+ border-color: $fallback--border;
+ border-color: var(--border, $fallback--border);
+ padding: 0;
+
+ &:last-child a {
+ border-bottom-right-radius: $fallback--panelRadius;
+ border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
+ border-bottom-left-radius: $fallback--panelRadius;
+ border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius);
+ }
+
+ &:last-child {
+ border: none;
+ }
+
+ i {
+ margin: 0 0.5em;
+ }
+ }
+
+ a {
+ display: block;
+ padding: 0.6em 0;
+
+ &:hover {
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--link;
+ color: var(--selectedMenuText, $fallback--link);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+ --icon: var(--selectedMenuIcon, $fallback--icon);
+ }
+
+ &.router-link-active {
+ font-weight: bolder;
+ background-color: $fallback--lightBg;
+ background-color: var(--selectedMenu, $fallback--lightBg);
+ color: $fallback--text;
+ color: var(--selectedMenuText, $fallback--text);
+ --faint: var(--selectedMenuFaintText, $fallback--faint);
+ --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+ --lightText: var(--selectedMenuLightText, $fallback--lightText);
+ --icon: var(--selectedMenuIcon, $fallback--icon);
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
+
+</style>
diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js
index 4adf8211..94653004 100644
--- a/src/components/user_avatar/user_avatar.js
+++ b/src/components/user_avatar/user_avatar.js
@@ -8,26 +8,20 @@ const UserAvatar = {
],
data () {
return {
- showPlaceholder: false
+ showPlaceholder: false,
+ defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}`
}
},
components: {
StillImage
},
- computed: {
- imgSrc () {
- return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original
- }
- },
methods: {
+ imgSrc (src) {
+ return (!src || this.showPlaceholder) ? this.defaultAvatar : src
+ },
imageLoadError () {
this.showPlaceholder = true
}
- },
- watch: {
- src () {
- this.showPlaceholder = false
- }
}
}
diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue
index 9ffb28d8..e4e4127c 100644
--- a/src/components/user_avatar/user_avatar.vue
+++ b/src/components/user_avatar/user_avatar.vue
@@ -1,9 +1,9 @@
<template>
<StillImage
- class="avatar"
+ class="Avatar"
:alt="user.screen_name"
:title="user.screen_name"
- :src="imgSrc"
+ :src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
/>
@@ -13,7 +13,9 @@
<style lang="scss">
@import '../../_variables.scss';
-.avatar.still-image {
+.Avatar {
+ --still-image-label-visibility: hidden;
+
width: 48px;
height: 48px;
box-shadow: var(--avatarStatusShadow);
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 9529d7f6..041bb80f 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -66,6 +66,7 @@
<div class="bottom-line">
<router-link
class="user-screen-name"
+ :title="user.screen_name"
:to="userProfileLink(user)"
>
@{{ user.screen_name }}
@@ -353,7 +354,7 @@
align-items: flex-start;
max-height: 56px;
- .avatar {
+ .Avatar {
flex: 1 0 100%;
width: 56px;
height: 56px;
@@ -363,13 +364,9 @@
}
}
- &:hover .animated.avatar {
- canvas {
- display: none;
- }
- img {
- visibility: visible;
- }
+ &:hover .Avatar {
+ --still-image-img: visible;
+ --still-image-canvas: hidden;
}
&-avatar-link {
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
new file mode 100644
index 00000000..b60f2c4c
--- /dev/null
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -0,0 +1,18 @@
+
+const UserListPopover = {
+ name: 'UserListPopover',
+ props: [
+ 'users'
+ ],
+ components: {
+ Popover: () => import('../popover/popover.vue'),
+ UserAvatar: () => import('../user_avatar/user_avatar.vue')
+ },
+ computed: {
+ usersCapped () {
+ return this.users.slice(0, 16)
+ }
+ }
+}
+
+export default UserListPopover
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
new file mode 100644
index 00000000..185c73ca
--- /dev/null
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -0,0 +1,71 @@
+<template>
+ <Popover
+ trigger="hover"
+ placement="top"
+ :offset="{ y: 5 }"
+ >
+ <template slot="trigger">
+ <slot />
+ </template>
+ <div
+ slot="content"
+ class="user-list-popover"
+ >
+ <div v-if="users.length">
+ <div
+ v-for="(user) in usersCapped"
+ :key="user.id"
+ class="user-list-row"
+ >
+ <UserAvatar
+ :user="user"
+ class="avatar-small"
+ :compact="true"
+ />
+ <div class="user-list-names">
+ <!-- eslint-disable vue/no-v-html -->
+ <span v-html="user.name_html" />
+ <!-- eslint-enable vue/no-v-html -->
+ <span class="user-list-screen-name">{{ user.screen_name }}</span>
+ </div>
+ </div>
+ </div>
+ <div v-else>
+ <i class="icon-spin4 animate-spin" />
+ </div>
+ </div>
+ </Popover>
+</template>
+
+<script src="./user_list_popover.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.user-list-popover {
+ padding: 0.5em;
+
+ .user-list-row {
+ padding: 0.25em;
+ display: flex;
+ flex-direction: row;
+
+ .user-list-names {
+ display: flex;
+ flex-direction: column;
+ margin-left: 0.5em;
+ min-width: 5em;
+
+ img {
+ width: 1em;
+ height: 1em;
+ }
+ }
+
+ .user-list-screen-name {
+ font-size: 9px;
+ }
+ }
+}
+
+</style>
diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue
index 1db4f648..5685916a 100644
--- a/src/components/user_panel/user_panel.vue
+++ b/src/components/user_panel/user_panel.vue
@@ -10,9 +10,7 @@
:hide-bio="true"
rounded="top"
/>
- <div class="panel-footer">
- <PostStatusForm />
- </div>
+ <PostStatusForm />
</div>
<auth-form
v-else
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 361a3b5c..c7c67c0a 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -20,13 +20,14 @@
:key="index"
class="user-profile-field"
>
- <!-- eslint-disable vue/no-v-html -->
<dt
:title="user.fields_text[index].name"
class="user-profile-field-name"
@click.prevent="linkClicked"
- v-html="field.name"
- />
+ >
+ {{ field.name }}
+ </dt>
+ <!-- eslint-disable vue/no-v-html -->
<dd
:title="user.fields_text[index].value"
class="user-profile-field-value"
diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue
index 6ee53461..2a8d8d48 100644
--- a/src/components/user_reporting_modal/user_reporting_modal.vue
+++ b/src/components/user_reporting_modal/user_reporting_modal.vue
@@ -146,7 +146,8 @@
display: flex;
justify-content: space-between;
- > .status-el {
+ /* TODO cleanup this */
+ > .Status {
flex: 1;
}
diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue
index 97ddf1cd..1ffed4e0 100644
--- a/src/components/video_attachment/video_attachment.vue
+++ b/src/components/video_attachment/video_attachment.vue
@@ -4,6 +4,8 @@
:src="attachment.url"
:loop="loopVideo"
:controls="controls"
+ :alt="attachment.description"
+ :title="attachment.description"
playsinline
@loadeddata="onVideoDataLoad"
/>
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js
index dcb56106..818e8bd5 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.js
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.js
@@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) {
panel.usersToFollow.forEach((toFollow, index) => {
let user = shuffled[index]
- let img = user.avatar || '/images/avi.png'
+ let img = user.avatar || this.$store.state.instance.defaultAvatar
let name = user.acct
toFollow.img = img
@@ -38,13 +38,7 @@ function getWhoToFollow (panel) {
const WhoToFollowPanel = {
data: () => ({
- usersToFollow: new Array(3).fill().map(x => (
- {
- img: '/images/avi.png',
- name: '',
- id: 0
- }
- ))
+ usersToFollow: []
}),
computed: {
user: function () {
@@ -68,6 +62,13 @@ const WhoToFollowPanel = {
},
mounted:
function () {
+ this.usersToFollow = new Array(3).fill().map(x => (
+ {
+ img: this.$store.state.instance.defaultAvatar,
+ name: '',
+ id: 0
+ }
+ ))
if (this.suggestionsEnabled) {
getWhoToFollow(this)
}
diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss
index 4cefe2be..1a26eb8d 100644
--- a/src/hocs/with_load_more/with_load_more.scss
+++ b/src/hocs/with_load_more/with_load_more.scss
@@ -12,5 +12,9 @@
.error {
font-size: 14px;
}
+
+ a {
+ cursor: pointer;
+ }
}
}
diff --git a/src/i18n/ar.json b/src/i18n/ar.json
index 8bba2b97..a475d291 100644
--- a/src/i18n/ar.json
+++ b/src/i18n/ar.json
@@ -73,7 +73,6 @@
"settings": {
"attachmentRadius": "المُرفَقات",
"attachments": "المُرفَقات",
- "autoload": "",
"avatar": "الصورة الرمزية",
"avatarAltRadius": "الصور الرمزية (الإشعارات)",
"avatarRadius": "الصور الرمزية",
@@ -147,7 +146,6 @@
"profile_tab": "الملف الشخصي",
"radii_help": "",
"replies_in_timeline": "الردود على الخيط الزمني",
- "reply_link_preview": "",
"reply_visibility_all": "عرض كافة الردود",
"reply_visibility_following": "",
"reply_visibility_self": "",
diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 42d7745c..f2bcdd06 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -73,7 +73,6 @@
"settings": {
"attachmentRadius": "Adjunts",
"attachments": "Adjunts",
- "autoload": "Recarrega automàticament en arribar a sota de tot.",
"avatar": "Avatar",
"avatarAltRadius": "Avatars en les notificacions",
"avatarRadius": "Avatars",
@@ -96,8 +95,8 @@
"default_vis": "Abast per defecte de les entrades",
"delete_account": "Esborra el compte",
"delete_account_description": "Esborra permanentment el teu compte i tots els missatges",
- "delete_account_error": "No s'ha pogut esborrar el compte. Si continua el problema, contacta amb l'administració del node",
- "delete_account_instructions": "Confirma que vols esborrar el compte escrivint la teva contrasenya aquí sota",
+ "delete_account_error": "No s'ha pogut esborrar el compte. Si continua el problema, contacta amb l'administració del node.",
+ "delete_account_instructions": "Confirma que vols esborrar el compte escrivint la teva contrasenya aquí sota.",
"export_theme": "Desa el tema",
"filtering": "Filtres",
"filtering_explanation": "Es silenciaran totes les entrades que continguin aquestes paraules. Separa-les per línies",
@@ -119,7 +118,7 @@
"invalid_theme_imported": "No s'ha entès l'arxiu carregat perquè no és un tema vàlid de Pleroma. No s'ha fet cap canvi als temes actuals.",
"limited_availability": "No està disponible en aquest navegador",
"links": "Enllaços",
- "lock_account_description": "Restringeix el teu compte només a seguidores aprovades.",
+ "lock_account_description": "Restringeix el teu compte només a seguidores aprovades",
"loop_video": "Reprodueix els vídeos en bucle",
"loop_video_silent_only": "Reprodueix en bucles només els vídeos sense so (com els \"GIF\" de Mastodon)",
"name": "Nom",
@@ -145,7 +144,6 @@
"profile_tab": "Perfil",
"radii_help": "Configura l'arrodoniment de les vores (en píxels)",
"replies_in_timeline": "Replies in timeline",
- "reply_link_preview": "Mostra el missatge citat en passar el ratolí per sobre de l'enllaç de resposta",
"reply_visibility_all": "Mostra totes les respostes",
"reply_visibility_following": "Mostra només les respostes a entrades meves o d'usuàries que jo segueixo",
"reply_visibility_self": "Mostra només les respostes a entrades meves",
@@ -160,7 +158,7 @@
"streaming": "Carrega automàticament entrades noves quan estigui a dalt de tot",
"text": "Text",
"theme": "Tema",
- "theme_help": "Personalitza els colors del tema. Escriu-los en format RGB hexadecimal (#rrggbb)",
+ "theme_help": "Personalitza els colors del tema. Escriu-los en format RGB hexadecimal (#rrggbb).",
"tooltipRadius": "Missatges sobreposats",
"user_settings": "Configuració personal",
"values": {
diff --git a/src/i18n/cs.json b/src/i18n/cs.json
index 42e75567..d9aed34a 100644
--- a/src/i18n/cs.json
+++ b/src/i18n/cs.json
@@ -112,7 +112,6 @@
"app_name": "Název aplikace",
"attachmentRadius": "Přílohy",
"attachments": "Přílohy",
- "autoload": "Povolit automatické načítání při rolování dolů",
"avatar": "Avatar",
"avatarAltRadius": "Avatary (oznámení)",
"avatarRadius": "Avatary",
@@ -206,7 +205,6 @@
"profile_tab": "Profil",
"radii_help": "Nastavit zakulacení rohů rozhraní (v pixelech)",
"replies_in_timeline": "Odpovědi v časové ose",
- "reply_link_preview": "Povolit náhledy odkazu pro odpověď při přejetí myši",
"reply_visibility_all": "Zobrazit všechny odpovědi",
"reply_visibility_following": "Zobrazit pouze odpovědi směřované na mě nebo uživatele, které sleduji",
"reply_visibility_self": "Zobrazit pouze odpovědi směřované na mě",
@@ -258,7 +256,7 @@
"contrast": {
"hint": "Poměr kontrastu je {ratio}, {level} {context}",
"level": {
- "aa": "splňuje směrnici úrovně AA (minimální)",
+ "aa": "splňuje směrnici úrovně AA (minimální)",
"aaa": "splňuje směrnici úrovně AAA (doporučováno)",
"bad": "nesplňuje žádné směrnice přístupnosti"
},
@@ -400,7 +398,6 @@
"reply_to": "Odpověď uživateli",
"replies_list": "Odpovědi:"
},
-
"user_card": {
"approve": "Schválit",
"block": "Blokovat",
@@ -446,7 +443,7 @@
"favorite": "Oblíbit",
"user_settings": "Uživatelské nastavení"
},
- "upload":{
+ "upload": {
"error": {
"base": "Nahrávání selhalo.",
"file_too_big": "Soubor je příliš velký [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
diff --git a/src/i18n/de.json b/src/i18n/de.json
index a44e58cb..6fe6ab2c 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -58,7 +58,7 @@
"dms": "Direktnachrichten",
"public_tl": "Öffentliche Zeitleiste",
"timeline": "Zeitleiste",
- "twkn": "Das gesamte bekannte Netzwerk",
+ "twkn": "Bekannte Netzwerk",
"user_search": "Benutzersuche",
"search": "Suche",
"preferences": "Voreinstellungen",
@@ -66,7 +66,7 @@
"who_to_follow": "Wem folgen"
},
"notifications": {
- "broken_favorite": "Unbekannte Nachricht, suche danach...",
+ "broken_favorite": "Unbekannte Nachricht, suche danach…",
"favorited_you": "favorisierte deine Nachricht",
"followed_you": "folgt dir",
"load_older": "Ältere Benachrichtigungen laden",
@@ -115,7 +115,7 @@
"registration": "Registrierung",
"token": "Einladungsschlüssel",
"captcha": "CAPTCHA",
- "new_captcha": "Zum Erstellen eines neuen Captcha auf das Bild klicken.",
+ "new_captcha": "Zum Erstellen eines neuen Captcha auf das Bild klicken",
"validations": {
"username_required": "darf nicht leer sein",
"fullname_required": "darf nicht leer sein",
@@ -131,7 +131,6 @@
"settings": {
"attachmentRadius": "Anhänge",
"attachments": "Anhänge",
- "autoload": "Aktiviere automatisches Laden von älteren Beiträgen beim scrollen",
"avatar": "Avatar",
"avatarAltRadius": "Avatare (Benachrichtigungen)",
"avatarRadius": "Avatare",
@@ -162,7 +161,7 @@
"pad_emoji": "Emojis mit Leerzeichen umrahmen",
"export_theme": "Farbschema speichern",
"filtering": "Filtern",
- "filtering_explanation": "Alle Beiträge, welche diese Wörter enthalten, werden ausgeblendet. Ein Wort pro Zeile.",
+ "filtering_explanation": "Alle Beiträge, welche diese Wörter enthalten, werden ausgeblendet. Ein Wort pro Zeile",
"follow_export": "Follower exportieren",
"follow_export_button": "Exportiere deine Follows in eine csv-Datei",
"follow_export_processing": "In Bearbeitung. Die Liste steht gleich zum herunterladen bereit.",
@@ -225,7 +224,6 @@
"profile_tab": "Profil",
"radii_help": "Kantenrundung (in Pixel) der Oberfläche anpassen",
"replies_in_timeline": "Antworten in der Zeitleiste",
- "reply_link_preview": "Antwortlink-Vorschau beim Überfahren mit der Maus aktivieren",
"reply_visibility_all": "Alle Antworten zeigen",
"reply_visibility_following": "Zeige nur Antworten an mich oder an Benutzer, denen ich folge",
"reply_visibility_self": "Nur Antworten an mich anzeigen",
@@ -249,7 +247,7 @@
"streaming": "Aktiviere automatisches Laden (Streaming) von neuen Beiträgen",
"text": "Text",
"theme": "Farbschema",
- "theme_help": "Benutze HTML-Farbcodes (#rrggbb) um dein Farbschema anzupassen",
+ "theme_help": "Benutze HTML-Farbcodes (#rrggbb) um dein Farbschema anzupassen.",
"theme_help_v2_1": "Du kannst auch die Farben und die Deckkraft bestimmter Komponenten überschreiben, indem du das Kontrollkästchen umschaltest. Verwende die Schaltfläche \"Alle löschen\", um alle Überschreibungen zurückzusetzen.",
"theme_help_v2_2": "Unter einigen Einträgen befinden sich Symbole für Hintergrund-/Textkontrastindikatoren, für detaillierte Informationen fahre mit der Maus darüber. Bitte beachte, dass bei der Verwendung von Transparenz Kontrastindikatoren den schlechtest möglichen Fall darstellen.",
"tooltipRadius": "Tooltips/Warnungen",
@@ -323,7 +321,7 @@
"always_drop_shadow": "Achtung, dieser Schatten verwendet immer {0}, wenn der Browser dies unterstützt.",
"drop_shadow_syntax": "{0} unterstützt Parameter {1} und Schlüsselwort {2} nicht.",
"avatar_inset": "Bitte beachte, dass die Kombination von eingesetzten und nicht eingesetzten Schatten auf Avataren zu unerwarteten Ergebnissen bei transparenten Avataren führen kann.",
- "spread_zero": "Schatten mit einer Streuung > 0 erscheinen so, als ob sie auf Null gesetzt wären.",
+ "spread_zero": "Schatten mit einer Streuung > 0 erscheinen so, als ob sie auf Null gesetzt wären",
"inset_classic": "Eingesetzte Schatten werden mit {0} verwendet"
},
"components": {
@@ -377,7 +375,7 @@
"warning_of_generate_new_codes": "Wenn du neue Wiederherstellungs-Codes generierst, werden die alten Codes nicht mehr funktionieren.",
"generate_new_recovery_codes": "Generiere neue Wiederherstellungs-Codes",
"title": "Zwei-Faktor Authentifizierung",
- "waiting_a_recovery_codes": "Erhalte Wiederherstellungscodes...",
+ "waiting_a_recovery_codes": "Erhalte Wiederherstellungscodes…",
"authentication_methods": "Authentifizierungsmethoden",
"scan": {
"title": "Scan",
@@ -401,8 +399,6 @@
"changed_email": "Email Adresse erfolgreich geändert!",
"change_email_error": "Es trat ein Problem auf beim Versuch, deine Email Adresse zu ändern.",
"change_email": "Ändere Email",
- "notification_setting_non_followers": "Nutzer, die dir nicht folgen",
- "notification_setting_followers": "Nutzer, die dir folgen",
"import_blocks_from_a_csv_file": "Importiere Blocks von einer CSV Datei",
"accent": "Akzent"
},
@@ -411,7 +407,7 @@
"conversation": "Unterhaltung",
"error_fetching": "Fehler beim Laden",
"load_older": "Lade ältere Beiträge",
- "no_retweet_hint": "Der Beitrag ist als nur-für-Follower oder als Direktnachricht markiert und kann nicht wiederholt werden.",
+ "no_retweet_hint": "Der Beitrag ist als nur-für-Follower oder als Direktnachricht markiert und kann nicht wiederholt werden",
"repeated": "wiederholte",
"show_new": "Zeige Neuere",
"up_to_date": "Aktuell"
@@ -482,10 +478,9 @@
"placeholder": "Dein Benutzername oder die zugehörige E-Mail-Adresse",
"check_email": "Im E-Mail-Posteingang des angebenen Kontos müsste sich jetzt (oder zumindest in Kürze) die E-Mail mit dem Link zum Passwortzurücksetzen befinden.",
"return_home": "Zurück zur Heimseite",
- "not_found": "Benutzername/E-Mail-Adresse nicht gefunden. Vertippt?",
"too_many_requests": "Kurze Pause. Zu viele Versuche. Bitte, später nochmal probieren.",
"password_reset_disabled": "Passwortzurücksetzen deaktiviert. Bitte Administrator kontaktieren.",
- "password_reset_required": "Passwortzurücksetzen erforderlich",
+ "password_reset_required": "Passwortzurücksetzen erforderlich.",
"password_reset_required_but_mailer_is_disabled": "Passwortzurücksetzen wäre erforderlich, ist aber deaktiviert. Bitte Administrator kontaktieren."
},
"about": {
@@ -520,9 +515,9 @@
},
"domain_mute_card": {
"mute": "Stummschalten",
- "mute_progress": "Wird stummgeschaltet..",
+ "mute_progress": "Wird stummgeschaltet…",
"unmute": "Stummschaltung aufheben",
- "unmute_progress": "Stummschaltung wird aufgehoben.."
+ "unmute_progress": "Stummschaltung wird aufgehoben…"
},
"exporter": {
"export": "Exportieren",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 2840904f..8540f551 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -29,8 +29,8 @@
},
"staff": "Staff"
},
- "chat": {
- "title": "Chat"
+ "shoutbox": {
+ "title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
@@ -44,6 +44,7 @@
},
"features_panel": {
"chat": "Chat",
+ "pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
@@ -119,11 +120,14 @@
"dms": "Direct Messages",
"public_tl": "Public Timeline",
"timeline": "Timeline",
- "twkn": "The Whole Known Network",
+ "twkn": "Known Network",
+ "bookmarks": "Bookmarks",
"user_search": "User Search",
"search": "Search",
"who_to_follow": "Who to follow",
- "preferences": "Preferences"
+ "preferences": "Preferences",
+ "timelines": "Timelines",
+ "chats": "Chats"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
@@ -163,6 +167,9 @@
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji"
},
+ "errors": {
+ "storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
+ },
"interactions": {
"favs_repeats": "Repeats and Favorites",
"follows": "New follows",
@@ -174,6 +181,7 @@
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
+ "media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
@@ -185,6 +193,10 @@
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"posting": "Posting",
+ "preview": "Preview",
+ "preview_empty": "Empty",
+ "empty_status_error": "Can't post an empty status with no files",
+ "media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
@@ -254,7 +266,6 @@
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
- "autoload": "Enable automatic loading when scrolled to the bottom",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (Notifications)",
"avatarRadius": "Avatars",
@@ -278,12 +289,11 @@
"change_password": "Change Password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
+ "chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"confirm_new_password": "Confirm new password",
- "current_avatar": "Your current avatar",
"current_password": "Current password",
- "current_profile_banner": "Your current profile banner",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data Import / Export",
"default_vis": "Default visibility scope",
@@ -375,7 +385,6 @@
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
- "reply_link_preview": "Enable reply-link preview on mouse hover",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
@@ -390,6 +399,12 @@
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner",
+ "reset_avatar": "Reset avatar",
+ "reset_profile_background": "Reset profile background",
+ "reset_profile_banner": "Reset profile banner",
+ "reset_avatar_confirm": "Do you really want to reset the avatar?",
+ "reset_banner_confirm": "Do you really want to reset the banner?",
+ "reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
@@ -419,13 +434,9 @@
"greentext": "Meme arrows",
"notifications": "Notifications",
"notification_setting_filters": "Filters",
- "notification_setting": "Receive notifications from:",
- "notification_setting_follows": "Users you follow",
- "notification_setting_non_follows": "Users you do not follow",
- "notification_setting_followers": "Users who follow you",
- "notification_setting_non_followers": "Users who do not follow you",
+ "notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
- "notification_setting_privacy_option": "Hide the sender and contents of push notifications",
+ "notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
@@ -505,7 +516,12 @@
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
- "tabs": "Tabs"
+ "tabs": "Tabs",
+ "chat": {
+ "incoming": "Incoming",
+ "outgoing": "Outgoing",
+ "border": "Border"
+ }
},
"radii": {
"_tab_label": "Roundness"
@@ -617,6 +633,7 @@
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
+ "reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses"
@@ -628,6 +645,8 @@
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
+ "bookmark": "Bookmark",
+ "unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to",
"replies_list": "Replies:",
@@ -636,7 +655,11 @@
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"thread_muted": "Thread muted",
- "thread_muted_and_words": ", has words:"
+ "thread_muted_and_words": ", has words:",
+ "show_full_subject": "Show full subject",
+ "hide_full_subject": "Hide full subject",
+ "show_content": "Show content",
+ "hide_content": "Hide content"
},
"user_card": {
"approve": "Approve",
@@ -657,6 +680,7 @@
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
+ "message": "Message",
"mute": "Mute",
"muted": "Muted",
"per_day": "per day",
@@ -719,7 +743,8 @@
"add_reaction": "Add Reaction",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
- "reject_follow_request": "Reject follow request"
+ "reject_follow_request": "Reject follow request",
+ "bookmark": "Bookmark"
},
"upload": {
"error": {
@@ -749,10 +774,31 @@
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
- "not_found": "We couldn't find that email or username.",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
+ },
+ "chats": {
+ "you": "You:",
+ "message_user": "Message {nickname}",
+ "delete": "Delete",
+ "chats": "Chats",
+ "new": "New Chat",
+ "empty_message_error": "Cannot post empty message",
+ "more": "More",
+ "delete_confirm": "Do you really want to delete this message?",
+ "error_loading_chat": "Something went wrong when loading the chat.",
+ "error_sending_message": "Something went wrong when sending the message.",
+ "empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
+ },
+ "file_type": {
+ "audio": "Audio",
+ "video": "Video",
+ "image": "Image",
+ "file": "File"
+ },
+ "display_date": {
+ "today": "Today"
}
}
diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 6c5b3a74..e73ac2f8 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -5,14 +5,15 @@
"features_panel": {
"chat": "Babilejo",
"gopher": "Gopher",
- "media_proxy": "Aŭdvidaĵa prokurilo",
+ "media_proxy": "Vidaŭdaĵa prokurilo",
"scope_options": "Agordoj de amplekso",
- "text_limit": "Teksta limo",
+ "text_limit": "Limo de teksto",
"title": "Funkcioj",
- "who_to_follow": "Kiun aboni"
+ "who_to_follow": "Kiun aboni",
+ "pleroma_chat_messages": "Babilejo de Pleroma"
},
"finder": {
- "error_fetching_user": "Eraro alportante uzanton",
+ "error_fetching_user": "Eraris alporto de uzanto",
"find_user": "Trovi uzanton"
},
"general": {
@@ -20,12 +21,25 @@
"submit": "Sendi",
"more": "Pli",
"generic_error": "Eraro okazis",
- "optional": "Malnepra"
+ "optional": "malnepra",
+ "close": "Fermi",
+ "verify": "Kontroli",
+ "confirm": "Konfirmi",
+ "enable": "Ŝalti",
+ "disable": "Malŝalti",
+ "cancel": "Nuligi",
+ "dismiss": "Forlasi",
+ "show_less": "Montri malplion",
+ "show_more": "Montri plion",
+ "retry": "Reprovi",
+ "error_retry": "Bonvolu reprovi",
+ "loading": "Enlegante…"
},
"image_cropper": {
"crop_picture": "Tondi bildon",
"save": "Konservi",
- "cancel": "Nuligi"
+ "cancel": "Nuligi",
+ "save_without_cropping": "Konservi sen tondado"
},
"login": {
"login": "Saluti",
@@ -34,8 +48,16 @@
"password": "Pasvorto",
"placeholder": "ekz. lain",
"register": "Registriĝi",
- "username": "Salutnomo",
- "hint": "Salutu por partopreni la diskutadon"
+ "username": "Uzantonomo",
+ "hint": "Salutu por partopreni la diskutadon",
+ "heading": {
+ "recovery": "Rehavo de duobla aŭtentikigo",
+ "totp": "Duobla aŭtentikigo"
+ },
+ "recovery_code": "Rehava kodo",
+ "enter_two_factor_code": "Enigu kodon de duobla aŭtentikigo",
+ "enter_recovery_code": "Enigu rehavan kodon",
+ "authentication_code": "Aŭtentikiga kodo"
},
"media_modal": {
"previous": "Antaŭa",
@@ -45,7 +67,7 @@
"about": "Pri",
"back": "Reen",
"chat": "Loka babilejo",
- "friend_requests": "Abonaj petoj",
+ "friend_requests": "Petoj pri abono",
"mentions": "Mencioj",
"dms": "Rektaj mesaĝoj",
"public_tl": "Publika tempolinio",
@@ -53,7 +75,12 @@
"twkn": "La tuta konata reto",
"user_search": "Serĉi uzantojn",
"who_to_follow": "Kiun aboni",
- "preferences": "Agordoj"
+ "preferences": "Agordoj",
+ "chats": "Babiloj",
+ "search": "Serĉi",
+ "interactions": "Interagoj",
+ "administration": "Administrado",
+ "bookmarks": "Legosignoj"
},
"notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝin…",
@@ -63,15 +90,21 @@
"notifications": "Sciigoj",
"read": "Legite!",
"repeated_you": "ripetis vian staton",
- "no_more_notifications": "Neniuj pliaj sciigoj"
+ "no_more_notifications": "Neniuj pliaj sciigoj",
+ "reacted_with": "reagis per {0}",
+ "migrated_to": "migris al",
+ "follow_request": "volas vin aboni"
},
"post_status": {
"new_status": "Afiŝi novan staton",
- "account_not_locked_warning": "Via konto ne estas {0}. Iu ajn povas vin aboni por vidi viajn afiŝoj nur por abonantoj.",
+ "account_not_locked_warning": "Via konto ne estas {0}. Iu ajn povas vin aboni por vidi eĉ viajn afiŝoj nur por abonantoj.",
"account_not_locked_warning_link": "ŝlosita",
- "attachments_sensitive": "Marki kunsendaĵojn kiel konsternajn",
+ "attachments_sensitive": "Marki kunsendaĵojn konsternaj",
"content_type": {
- "text/plain": "Plata teksto"
+ "text/plain": "Plata teksto",
+ "text/bbcode": "BBCode",
+ "text/markdown": "Markdown",
+ "text/html": "HTML"
},
"content_warning": "Temo (malnepra)",
"default": "Ĵus alvenis al la Universala Kongreso!",
@@ -82,7 +115,19 @@
"private": "Nur abonantoj – Afiŝi nur al abonantoj",
"public": "Publika – Afiŝi al publikaj tempolinioj",
"unlisted": "Nelistigita – Ne afiŝi al publikaj tempolinioj"
- }
+ },
+ "scope_notice": {
+ "unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto",
+ "private": "Ĉi tiu afiŝo estos videbla nur al viaj abonantoj",
+ "public": "Ĉi tiu afiŝo estos videbla al ĉiuj"
+ },
+ "media_description_error": "Malsukcesis afiŝo de vidaŭdaĵoj; reprovu",
+ "empty_status_error": "Ne povas afiŝi malplenan staton sen dosieroj",
+ "preview_empty": "Malplena",
+ "preview": "Antaŭrigardo",
+ "direct_warning_to_first_only": "Ĉi tiu afiŝo estas nur videbla al uzantoj menciitaj je la komenco de la mesaĝo.",
+ "direct_warning_to_all": "Ĉi tiu afiŝo estos videbla al ĉiuj menciitaj uzantoj.",
+ "media_description": "Priskribo de vidaŭdaĵo"
},
"registration": {
"bio": "Priskribo",
@@ -92,10 +137,10 @@
"registration": "Registriĝo",
"token": "Invita ĵetono",
"captcha": "TESTO DE HOMECO",
- "new_captcha": "Alklaku la bildon por akiri novan teston",
+ "new_captcha": "Klaku la bildon por akiri novan teston",
"username_placeholder": "ekz. lain",
"fullname_placeholder": "ekz. Lain Iwakura",
- "bio_placeholder": "ekz.\nSaluton, mi estas Lain\nMi estas animea knabino vivante en Japanujo. Eble vi konas min de la retejo « Wired ».",
+ "bio_placeholder": "ekz.\nSaluton, mi estas Lain.\nMi estas animea knabino vivanta en Japanujo. Eble vi konas min pro la retejo « Wired ».",
"validations": {
"username_required": "ne povas resti malplena",
"fullname_required": "ne povas resti malplena",
@@ -109,53 +154,52 @@
"app_name": "Nomo de aplikaĵo",
"attachmentRadius": "Kunsendaĵoj",
"attachments": "Kunsendaĵoj",
- "autoload": "Ŝalti memfaran enlegadon ĉe subo de paĝo",
"avatar": "Profilbildo",
"avatarAltRadius": "Profilbildoj (sciigoj)",
"avatarRadius": "Profilbildoj",
"background": "Fono",
"bio": "Priskribo",
- "blocks_tab": "Baroj",
+ "blocks_tab": "Blokitoj",
"btnRadius": "Butonoj",
- "cBlue": "Blua (Respondo, abono)",
- "cGreen": "Verda (Kunhavigo)",
- "cOrange": "Oranĝa (Ŝato)",
- "cRed": "Ruĝa (Nuligo)",
+ "cBlue": "Blua (respondi, aboni)",
+ "cGreen": "Verda (kunhavigi)",
+ "cOrange": "Oranĝa (ŝati)",
+ "cRed": "Ruĝa (nuligi)",
"change_password": "Ŝanĝi pasvorton",
- "change_password_error": "Okazis eraro dum ŝanĝo de via pasvorto.",
+ "change_password_error": "Eraris ŝanĝo de via pasvorto.",
"changed_password": "Pasvorto sukcese ŝanĝiĝis!",
"collapse_subject": "Maletendi afiŝojn kun temoj",
- "composing": "Verkante",
+ "composing": "Verkado",
"confirm_new_password": "Konfirmu novan pasvorton",
"current_avatar": "Via nuna profilbildo",
"current_password": "Nuna pasvorto",
"current_profile_banner": "Via nuna profila rubando",
- "data_import_export_tab": "Enporto / Elporto de datenoj",
- "default_vis": "Implicita videbleca amplekso",
+ "data_import_export_tab": "Enporto / Elporto de datumoj",
+ "default_vis": "Implicita amplekso de vidibleco",
"delete_account": "Forigi konton",
- "delete_account_description": "Por ĉiam forigi vian konton kaj ĉiujn viajn mesaĝojn",
- "delete_account_error": "Okazis eraro dum forigo de via kanto. Se tio daŭre okazados, bonvolu kontakti la administranton de via nodo.",
+ "delete_account_description": "Por ĉiam forigi viajn datumojn kaj malaktivigi vian konton.",
+ "delete_account_error": "Eraris forigo de via kanto. Se tio daŭre ripetiĝos, bonvolu kontakti la administranton de via nodo.",
"delete_account_instructions": "Entajpu sube vian pasvorton por konfirmi forigon de konto.",
- "avatar_size_instruction": "La rekomendata malpleja grando de profilbildoj estas 150×150 bilderoj.",
+ "avatar_size_instruction": "La rekomendata minimuma grando de profilbildoj estas 150×150 bilderoj.",
"export_theme": "Konservi antaŭagordon",
"filtering": "Filtrado",
- "filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linio",
- "follow_export": "Abona elporto",
+ "filtering_explanation": "Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos; skribu po unu linie",
+ "follow_export": "Elporto de abonoj",
"follow_export_button": "Elporti viajn abonojn al CSV-dosiero",
"follow_export_processing": "Traktante; baldaŭ vi ricevos peton elŝuti la dosieron",
- "follow_import": "Abona enporto",
+ "follow_import": "Enporto de abonoj",
"follow_import_error": "Eraro enportante abonojn",
- "follows_imported": "Abonoj enportiĝis! Traktado daŭros iom.",
+ "follows_imported": "Abonoj enportiĝis! Traktado daŭros iom da tempo.",
"foreground": "Malfono",
"general": "Ĝenerala",
"hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj",
"hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio",
- "max_thumbnails": "Plej multa nombro da bildetoj po afiŝo",
- "hide_isp": "Kaŝi nodo-propran breton",
+ "max_thumbnails": "Maksimuma nombro da bildetoj en afiŝo",
+ "hide_isp": "Kaŝi breton propran al nodo",
"preload_images": "Antaŭ-enlegi bildojn",
"use_one_click_nsfw": "Malfermi konsternajn kunsendaĵojn per nur unu klako",
- "hide_post_stats": "Kaŝi statistikon de afiŝoj (ekz. nombron da ŝatoj)",
- "hide_user_stats": "Kaŝi statistikon de uzantoj (ekz. nombron da abonantoj)",
+ "hide_post_stats": "Kaŝi statistikon de afiŝoj (ekz. nombron de ŝatoj)",
+ "hide_user_stats": "Kaŝi statistikon de uzantoj (ekz. nombron de abonantoj)",
"hide_filtered_statuses": "Kaŝi filtritajn statojn",
"import_followers_from_a_csv_file": "Enporti abonojn el CSV-dosiero",
"import_theme": "Enlegi antaŭagordojn",
@@ -170,9 +214,9 @@
"links": "Ligiloj",
"lock_account_description": "Limigi vian konton al nur abonantoj aprobitaj",
"loop_video": "Ripetadi filmojn",
- "loop_video_silent_only": "Ripetadi nur filmojn sen sono (ekz. la \"GIF-ojn\" de Mastodon)",
+ "loop_video_silent_only": "Ripetadi nur filmojn sen sono (ekz. la «GIF-ojn» de Mastodon)",
"mutes_tab": "Silentigoj",
- "play_videos_in_modal": "Ludi filmojn rekte en la aŭdvidaĵa spektilo",
+ "play_videos_in_modal": "Ludi filmojn en ŝpruca kadro",
"use_contain_fit": "Ne tondi la kunsendaĵon en bildetoj",
"name": "Nomo",
"name_bio": "Nomo kaj priskribo",
@@ -183,51 +227,50 @@
"notification_visibility_mentions": "Mencioj",
"notification_visibility_repeats": "Ripetoj",
"no_rich_text_description": "Forigi riĉtekstajn formojn de ĉiuj afiŝoj",
- "no_blocks": "Neniuj baroj",
+ "no_blocks": "Neniuj blokitoj",
"no_mutes": "Neniuj silentigoj",
"hide_follows_description": "Ne montri kiun mi sekvas",
"hide_followers_description": "Ne montri kiu min sekvas",
"show_admin_badge": "Montri la insignon de administranto en mia profilo",
- "show_moderator_badge": "Montri la insignon de kontrolanto en mia profilo",
- "nsfw_clickthrough": "Ŝalti traklakan kaŝon de konsternaj kunsendaĵoj",
+ "show_moderator_badge": "Montri la insignon de reguligisto en mia profilo",
+ "nsfw_clickthrough": "Ŝalti traklakan kaŝadon de konsternaj kunsendaĵoj",
"oauth_tokens": "Ĵetonoj de OAuth",
"token": "Ĵetono",
- "refresh_token": "Ĵetono de novigo",
+ "refresh_token": "Ĵetono de aktualigo",
"valid_until": "Valida ĝis",
"revoke_token": "Senvalidigi",
"panelRadius": "Bretoj",
"pause_on_unfocused": "Paŭzigi elsendfluon kiam langeto ne estas fokusata",
"presets": "Antaŭagordoj",
- "profile_background": "Profila fono",
- "profile_banner": "Profila rubando",
+ "profile_background": "Fono de profilo",
+ "profile_banner": "Rubando de profilo",
"profile_tab": "Profilo",
"radii_help": "Agordi fasadan rondigon de randoj (bildere)",
"replies_in_timeline": "Respondoj en tempolinio",
- "reply_link_preview": "Ŝalti respond-ligilan antaŭvidon dum musa ŝvebo",
"reply_visibility_all": "Montri ĉiujn respondojn",
"reply_visibility_following": "Montri nur respondojn por mi aŭ miaj abonatoj",
"reply_visibility_self": "Montri nur respondojn por mi",
- "saving_err": "Eraro dum konservo de agordoj",
+ "saving_err": "Eraris konservado de agordoj",
"saving_ok": "Agordoj konserviĝis",
"security_tab": "Sekureco",
"scope_copy": "Kopii amplekson por respondo (rektaj mesaĝoj ĉiam kopiiĝas)",
"set_new_avatar": "Agordi novan profilbildon",
- "set_new_profile_background": "Agordi novan profilan fonon",
- "set_new_profile_banner": "Agordi novan profilan rubandon",
+ "set_new_profile_background": "Agordi novan fonon de profilo",
+ "set_new_profile_banner": "Agordi novan rubandon de profilo",
"settings": "Agordoj",
- "subject_input_always_show": "Ĉiam montri teman kampon",
- "subject_line_behavior": "Kopii temon por respondo",
- "subject_line_email": "Kiel retpoŝto: \"re: temo\"",
+ "subject_input_always_show": "Ĉiam montri kampon de temo",
+ "subject_line_behavior": "Kopii temon dum respondado",
+ "subject_line_email": "Kiel retpoŝto: «re: temo»",
"subject_line_mastodon": "Kiel Mastodon: kopii senŝanĝe",
"subject_line_noop": "Ne kopii",
"post_status_content_type": "Afiŝi specon de la enhavo de la stato",
- "stop_gifs": "Movi GIF-bildojn dum musa ŝvebo",
- "streaming": "Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo",
+ "stop_gifs": "Movi GIF-bildojn dum ŝvebo de muso",
+ "streaming": "Ŝalti memagan fluigon de novaj afiŝoj kiam vi vidas la supron de la paĝo",
"text": "Teksto",
"theme": "Haŭto",
"theme_help": "Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran haŭton.",
- "theme_help_v2_1": "Vi ankaŭ povas superagordi la kolorojn kaj travideblecon de kelkaj eroj per marko de la markbutono; uzu la butonon \"Vakigi ĉion\" por forigi ĉîujn superagordojn.",
- "theme_help_v2_2": "Bildsimboloj sub kelkaj eroj estas indikiloj de kontrasto inter fono kaj teksto; muse ŝvebu por detalaj informoj. Bonvolu memori, ke la indikilo montras la plej malbonan okazeblon dum sia uzo.",
+ "theme_help_v2_1": "Vi ankaŭ povas superagordi la kolorojn kaj travideblecon de kelkaj eroj per marko de la markbutono; uzu la butonon «Vakigi ĉion» por forigi ĉîujn superagordojn.",
+ "theme_help_v2_2": "Bildsimboloj sub kelkaj eroj estas indikiloj de kontrasto inter fono kaj teksto; ŝvebigu muson por detalaj informoj. Bonvolu memori, ke la indikilo montras la plej malbonan okazeblon dum sia uzo.",
"tooltipRadius": "Ŝpruchelpiloj/avertoj",
"upload_a_photo": "Alŝuti foton",
"user_settings": "Agordoj de uzanto",
@@ -236,7 +279,7 @@
"true": "jes"
},
"notifications": "Sciigoj",
- "enable_web_push_notifications": "Ŝalti retajn puŝajn sciigojn",
+ "enable_web_push_notifications": "Ŝalti retajn pasivajn sciigojn",
"style": {
"switcher": {
"keep_color": "Konservi kolorojn",
@@ -244,10 +287,22 @@
"keep_opacity": "Konservi maltravideblecon",
"keep_roundness": "Konservi rondecon",
"keep_fonts": "Konservi tiparojn",
- "save_load_hint": "Elektebloj de \"konservi\" konservas la nuntempajn agordojn dum elektado aŭ enlegado de haŭtoj. Ĝi ankaŭ konservas tiujn agordojn dum elportado de haŭto. Kun ĉiuj markbutonoj nemarkitaj, elporto de la haŭto ĉion konservos.",
+ "save_load_hint": "Elektebloj de «konservi» konservas la nuntempajn agordojn dum elektado aŭ enlegado de haŭtoj. Ĝi ankaŭ konservas tiujn agordojn dum elportado de haŭto. Kun ĉiuj markbutonoj nemarkitaj, elporto de la haŭto ĉion konservos.",
"reset": "Restarigi",
"clear_all": "Vakigi ĉion",
- "clear_opacity": "Vakigi maltravideblecon"
+ "clear_opacity": "Vakigi maltravideblecon",
+ "help": {
+ "fe_downgraded": "Versio de PleromaFE reen iris.",
+ "fe_upgraded": "La motoro de haŭtoj de PleromaFE ĝisdatiĝis post ĝisdatigo de la versio.",
+ "older_version_imported": "La enportita dosiero estis farita per pli malnova versio de PleromaFE.",
+ "future_version_imported": "La enportita dosiero estis farita per pli nova versio de PleromaFE.",
+ "v2_imported": "La dosiero, kiun vi enportis, estis farita por malnova versio de PleromaFE. Ni provas maksimumigi interkonformecon, sed tamen eble montriĝos misoj.",
+ "upgraded_from_v2": "PleromaFE estis ĝisdatigita; la haŭto eble aspektos malsame ol kiel vi ĝin memoras."
+ },
+ "use_source": "Nova versio",
+ "use_snapshot": "Malnova versio",
+ "keep_as_is": "Teni senŝanĝa",
+ "load_theme": "Enlegi haŭton"
},
"common": {
"color": "Koloro",
@@ -255,7 +310,7 @@
"contrast": {
"hint": "Proporcio de kontrasto estas {ratio}, ĝi {level} {context}",
"level": {
- "aa": "plenumas la gvidilon je nivelo AA (malpleja)",
+ "aa": "plenumas la gvidilon je nivelo AA (minimuma)",
"aaa": "plenumas la gvidilon je nivela AAA (rekomendita)",
"bad": "plenumas neniujn faciluzajn gvidilojn"
},
@@ -268,21 +323,39 @@
"common_colors": {
"_tab_label": "Komunaj",
"main": "Komunaj koloroj",
- "foreground_hint": "Vidu langeton \"Specialaj\" por pli detalaj agordoj",
+ "foreground_hint": "Vidu langeton «Specialaj» por pli detalaj agordoj",
"rgbo": "Bildsimboloj, emfazoj, insignoj"
},
"advanced_colors": {
"_tab_label": "Specialaj",
- "alert": "Averta fono",
+ "alert": "Fono de averto",
"alert_error": "Eraro",
- "badge": "Insigna fono",
+ "badge": "Fono de insigno",
"badge_notification": "Sciigo",
"panel_header": "Kapo de breto",
"top_bar": "Supra breto",
"borders": "Limoj",
"buttons": "Butonoj",
"inputs": "Enigaj kampoj",
- "faint_text": "Malvigla teksto"
+ "faint_text": "Malvigla teksto",
+ "chat": {
+ "border": "Limo",
+ "outgoing": "Eliraj",
+ "incoming": "Envenaj"
+ },
+ "tabs": "Langetoj",
+ "disabled": "Malŝaltita",
+ "selectedMenu": "Elektita menuero",
+ "selectedPost": "Elektita afiŝo",
+ "pressed": "Premita",
+ "highlight": "Emfazitaj eroj",
+ "icons": "Bildsimboloj",
+ "poll": "Grafo de enketo",
+ "underlay": "Subtavolo",
+ "popover": "Ŝpruchelpiloj, menuoj",
+ "post": "Afiŝoj/Priskriboj de uzantoj",
+ "alert_neutral": "Neŭtrala",
+ "alert_warning": "Averto"
},
"radii": {
"_tab_label": "Rondeco"
@@ -297,7 +370,7 @@
"inset": "Internigo",
"hint": "Por ombroj vi ankaŭ povas uzi --variable kiel koloran valoron, por uzi variantojn de CSS3. Bonvolu rimarki, ke tiuokaze agordoj de maltravidebleco ne funkcios.",
"filter_hint": {
- "always_drop_shadow": "Averto: ĉi tiu ombro ĉiam uzas {0} kiam la foliumilo ĝin subtenas.",
+ "always_drop_shadow": "Averto: ĉi tiu ombro ĉiam uzas {0} kiam la foliumilo tion subtenas.",
"drop_shadow_syntax": "{0} ne subtenas parametron {1} kaj ŝlosilvorton {2}.",
"avatar_inset": "Bonvolu rimarki, ke agordi ambaŭ internajn kaj eksterajn ombrojn por profilbildoj povas redoni neatenditajn rezultojn ĉe profilbildoj travideblaj.",
"spread_zero": "Ombroj kun vastigo > 0 aperos kvazaŭ ĝi estus fakte nulo",
@@ -313,13 +386,13 @@
"button": "Butono",
"buttonHover": "Butono (je ŝvebo)",
"buttonPressed": "Butono (premita)",
- "buttonPressedHover": "Butono (premita je ŝvebo)",
+ "buttonPressedHover": "Butono (premita kaj je ŝvebo)",
"input": "Eniga kampo"
}
},
"fonts": {
"_tab_label": "Tiparoj",
- "help": "Elektu tiparon uzotan por eroj de la fasado. Por \"propra\" vi devas enigi la precizan nomon de tiparo tiel, kiel ĝi aperas en la sistemo",
+ "help": "Elektu tiparon uzotan por eroj de la fasado. Por «propra» vi devas enigi la precizan nomon de tiparo tiel, kiel ĝi aperas en la sistemo.",
"components": {
"interface": "Fasado",
"input": "Enigaj kampoj",
@@ -345,66 +418,175 @@
"checkbox": "Mi legetis la kondiĉojn de uzado",
"link": "bela eta ligil’"
}
- }
+ },
+ "discoverable": "Permesi trovon de ĉi tiu konto en serĉrezultoj kaj aliaj servoj",
+ "mutes_and_blocks": "Silentigitoj kaj blokitoj",
+ "chatMessageRadius": "Babileja mesaĝo",
+ "changed_email": "Retpoŝtadreso sukcese ŝanĝiĝis!",
+ "change_email_error": "Eraris ŝanĝo de via retpoŝtadreso.",
+ "change_email": "Ŝanĝi retpoŝtadreson",
+ "bot": "Ĉi tio estas robota konto",
+ "blocks_imported": "Blokitoj enportiĝis! Traktado daŭros iom da tempo.",
+ "block_import_error": "Eraris enporto de blokitoj",
+ "block_export": "Elporto de blokitoj",
+ "block_import": "Enporto de blokitoj",
+ "block_export_button": "Elporti viajn blokitojn al CSV-dosiero",
+ "allow_following_move": "Permesi memagan abonadon kiam abonata konto migras",
+ "mfa": {
+ "verify": {
+ "desc": "Por ŝalti duoblan aŭtentikigon, enigu la kodon el via aplikaĵo por duobla aŭtentikigo:"
+ },
+ "scan": {
+ "secret_code": "Ŝlosilo",
+ "desc": "Uzante vian aplikaĵon por duobla aŭtentikigo, skanu ĉi tiun rapidrespondan kodon aŭ enigu tekstan ŝlosilon:",
+ "title": "Skani"
+ },
+ "authentication_methods": "Metodoj de aŭtentikigo",
+ "recovery_codes_warning": "Notu la kodojn aŭ konservu ilin en sekura loko – alie vi ne revidos ilin. Se vi perdos aliron al via aplikaĵo por duobla aŭtentikigo kaj al la rehavaj kodoj, vi ne povos aliri vian konton.",
+ "waiting_a_recovery_codes": "Ricevante savkopiajn kodojn…",
+ "recovery_codes": "Rehavaj kodoj.",
+ "warning_of_generate_new_codes": "Kiam vi estigos novajn rehavajn kodojn, viaj malnovaj ne plu funkcios.",
+ "generate_new_recovery_codes": "Estigi novajn rehavajn kodojn",
+ "title": "Duobla aŭtentikigo",
+ "otp": "OTP"
+ },
+ "enter_current_password_to_confirm": "Enigu vian pasvorton por konfirmi vian identecon",
+ "security": "Sekureco",
+ "fun": "Amuzo",
+ "type_domains_to_mute": "Serĉu silentigotajn retnomojn",
+ "useStreamingApiWarning": "(Nerekomendate, eksperimente, povas preterpasi afiŝojn)",
+ "useStreamingApi": "Ricevi afiŝojn kaj sciigojn realtempe",
+ "user_mutes": "Uzantoj",
+ "reset_background_confirm": "Ĉu vi certe volas restarigi la fonon?",
+ "reset_banner_confirm": "Ĉu vi certe volas restarigi la rubandon?",
+ "reset_avatar_confirm": "Ĉu vi certe volas restarigi la profilbildon?",
+ "reset_profile_banner": "Restarigi rubandon de profilo",
+ "reset_profile_background": "Restarigi fonon de profilo",
+ "reset_avatar": "Restarigi profilbildon",
+ "minimal_scopes_mode": "Minimumigi elekteblojn pri amplekso de afiŝoj",
+ "search_user_to_block": "Serĉu, kiun vi volas bloki",
+ "search_user_to_mute": "Serĉu, kiun vi volas silentigi",
+ "autohide_floating_post_button": "Memage kaŝi la butonon por Nova afiŝo (poŝtelefone)",
+ "hide_followers_count_description": "Ne montri nombron de abonantoj",
+ "hide_follows_count_description": "Ne montri nombron de abonoj",
+ "notification_visibility_emoji_reactions": "Reagoj",
+ "notification_visibility_moves": "Migroj",
+ "new_email": "Nova retpoŝtadreso",
+ "profile_fields": {
+ "value": "Enhavo",
+ "name": "Etikedo",
+ "add_field": "Aldoni kampon",
+ "label": "Pridatumoj de profilo"
+ },
+ "import_blocks_from_a_csv_file": "Enporti blokitojn el CSV-dosiero",
+ "hide_muted_posts": "Kaŝi afiŝojn de silentigitaj uzantoj",
+ "emoji_reactions_on_timeline": "Montri bildosignajn reagojn en la tempolinio",
+ "pad_emoji": "Meti spacetojn ĉirkaŭ bildosigno post ties elekto",
+ "domain_mutes": "Retnomoj",
+ "notification_blocks": "Blokinte uzanton vi malabonos ĝin kaj haltigos ĉiujn sciigojn.",
+ "notification_mutes": "Por ne plu ricevi sciigojn de certa uzanto, silentigu.",
+ "notification_setting_hide_notification_contents": "Kaŝi la sendinton kaj la enhavojn de pasivaj sciigoj",
+ "notification_setting_privacy": "Privateco",
+ "notification_setting_block_from_strangers": "Bloki sciigojn de uzantoj, kiujn vi ne abonas",
+ "notification_setting_filters": "Filtriloj",
+ "greentext": "Memecitaĵoj",
+ "version": {
+ "frontend_version": "Versio de fasado",
+ "backend_version": "Versio de internaĵo",
+ "title": "Versio"
+ },
+ "accent": "Emfazo"
},
"timeline": {
"collapse": "Maletendi",
"conversation": "Interparolo",
- "error_fetching": "Eraro dum ĝisdatigo",
+ "error_fetching": "Eraris ĝisdatigo",
"load_older": "Montri pli malnovajn statojn",
"no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti",
"repeated": "ripetita",
"show_new": "Montri novajn",
"up_to_date": "Ĝisdata",
"no_more_statuses": "Neniuj pliaj statoj",
- "no_statuses": "Neniuj statoj"
+ "no_statuses": "Neniuj statoj",
+ "reload": "Enlegi ree"
},
"user_card": {
"approve": "Aprobi",
- "block": "Bari",
- "blocked": "Barita!",
+ "block": "Bloki",
+ "blocked": "Blokita!",
"deny": "Rifuzi",
"favorites": "Ŝatataj",
"follow": "Aboni",
"follow_sent": "Peto sendiĝis!",
- "follow_progress": "Petanta…",
- "follow_again": "Ĉu sendi peton denove?",
+ "follow_progress": "Petante…",
+ "follow_again": "Ĉu sendi peton ree?",
"follow_unfollow": "Malaboni",
"followees": "Abonatoj",
"followers": "Abonantoj",
- "following": "Abonanta!",
+ "following": "Abonata!",
"follows_you": "Abonas vin!",
"its_you": "Tio estas vi!",
- "media": "Aŭdvidaĵoj",
+ "media": "Vidaŭdaĵoj",
"mute": "Silentigi",
- "muted": "Silentigitaj",
+ "muted": "Silentigita",
"per_day": "tage",
"remote_follow": "Fore aboni",
"statuses": "Statoj",
- "unblock": "Malbari",
- "unblock_progress": "Malbaranta…",
- "block_progress": "Baranta…",
+ "unblock": "Malbloki",
+ "unblock_progress": "Malblokante…",
+ "block_progress": "Blokante…",
"unmute": "Malsilentigi",
- "unmute_progress": "Malsilentiganta…",
- "mute_progress": "Silentiganta…"
+ "unmute_progress": "Malsilentigante…",
+ "mute_progress": "Silentigante…",
+ "report": "Raporti",
+ "message": "Mesaĝo",
+ "mention": "Mencio",
+ "hidden": "Kaŝita",
+ "admin_menu": {
+ "delete_user_confirmation": "Ĉu vi tute certas? Ĉi tiu ago ne estas malfarebla.",
+ "delete_user": "Forigi uzanton",
+ "quarantine": "Malpermesi federadon de afiŝoj de uzanto",
+ "disable_any_subscription": "Malpermesi ĉian abonadon al uzanto",
+ "disable_remote_subscription": "Malpermesi abonadon al uzanto el foraj nodoj",
+ "sandbox": "Devigi afiŝojn esti nur por abonantoj",
+ "force_unlisted": "Devigi afiŝojn nelistiĝi",
+ "strip_media": "Forigi vidaŭdaĵojn de afiŝoj",
+ "force_nsfw": "Marki ĉiujn afiŝojn konsternaj",
+ "delete_account": "Forigi konton",
+ "deactivate_account": "Malaktivigi konton",
+ "activate_account": "Aktivigi konton",
+ "revoke_moderator": "Malnomumi reguligiston",
+ "grant_moderator": "Nomumi reguligiston",
+ "revoke_admin": "Malnomumi administranton",
+ "grant_admin": "Nomumi administranton",
+ "moderation": "Reguligado"
+ },
+ "show_repeats": "Montri ripetojn",
+ "hide_repeats": "Kaŝi ripetojn",
+ "unsubscribe": "Ne ricevi sciigojn",
+ "subscribe": "Ricevi sciigojn"
},
"user_profile": {
- "timeline_title": "Uzanta tempolinio",
+ "timeline_title": "Historio de uzanto",
"profile_does_not_exist": "Pardonu, ĉi tiu profilo ne ekzistas.",
- "profile_loading_error": "Pardonu, eraro okazis dum enlegado de ĉi tiu profilo."
+ "profile_loading_error": "Pardonu, eraris enlego de ĉi tiu profilo."
},
"who_to_follow": {
"more": "Pli",
"who_to_follow": "Kiun aboni"
},
"tool_tip": {
- "media_upload": "Alŝuti aŭdvidaĵon",
+ "media_upload": "Alŝuti vidaŭdaĵon",
"repeat": "Ripeti",
"reply": "Respondi",
"favorite": "Ŝati",
- "user_settings": "Agordoj de uzanto"
+ "user_settings": "Agordoj de uzanto",
+ "bookmark": "Legosigno",
+ "reject_follow_request": "Rifuzi abonpeton",
+ "accept_follow_request": "Akcepti abonpeton",
+ "add_reaction": "Aldoni reagon"
},
- "upload":{
+ "upload": {
"error": {
"base": "Alŝuto malsukcesis.",
"file_too_big": "Dosiero estas tro granda [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
@@ -417,5 +599,197 @@
"GiB": "GiB",
"TiB": "TiB"
}
+ },
+ "emoji": {
+ "search_emoji": "Serĉi bildosignon",
+ "keep_open": "Teni elektilon malfermita",
+ "emoji": "Bildsignoj",
+ "stickers": "Glumarkoj",
+ "add_emoji": "Enigi bildosignon",
+ "load_all": "Enlegante ĉiujn {emojiAmount} bildosignojn",
+ "load_all_hint": "Enlegis la {saneAmount} unuajn bildosignojn; enlego de ĉiuj povus kaŭzi problemojn pri efikeco.",
+ "unicode": "Unikoda bildosigno",
+ "custom": "Propra bildosigno"
+ },
+ "polls": {
+ "not_enough_options": "Tro malmultaj unikaj elektebloj en la enketo",
+ "expired": "Enketo finiĝis antaŭ {0}",
+ "expires_in": "Enketo finiĝas je {0}",
+ "expiry": "Aĝo de enketo",
+ "multiple_choices": "Pluraj elektoj",
+ "single_choice": "Unu elekto",
+ "type": "Speco de enketo",
+ "vote": "Voĉi",
+ "votes": "voĉoj",
+ "option": "Elekteblo",
+ "add_option": "Aldoni elekteblon",
+ "add_poll": "Aldoni enketon"
+ },
+ "importer": {
+ "error": "Eraris enporto de ĉi tiu dosiero.",
+ "success": "Enportita sukcese.",
+ "submit": "Sendi"
+ },
+ "exporter": {
+ "processing": "Traktante; baldaŭ vi ricevos peton elŝuti vian dosieron",
+ "export": "Elporti"
+ },
+ "domain_mute_card": {
+ "unmute_progress": "Malsilentigante…",
+ "unmute": "Malsilentigi",
+ "mute_progress": "Silentigante…",
+ "mute": "Silentigi"
+ },
+ "about": {
+ "staff": "Skipo",
+ "mrf": {
+ "simple": {
+ "media_nsfw_desc": "Ĉi tiu nodo devigas vidaŭdaĵojn esti markitaj kiel konsternaj en afiŝoj el la jenaj nodoj:",
+ "media_nsfw": "Devige marki vidaŭdaĵojn konsternaj",
+ "media_removal_desc": "Ĉi tiu nodo forigas vidaŭdaĵojn de afiŝoj el la jenaj nodoj:",
+ "media_removal": "Forigo de vidaŭdaĵoj",
+ "ftl_removal": "Forigo de la historio de «La tuta konata reto»",
+ "quarantine_desc": "Ĉi tiu nodo sendos nur publikajn afiŝojn al la jenaj nodoj:",
+ "quarantine": "Kvaranteno",
+ "reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:",
+ "reject": "Rifuzi",
+ "accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:",
+ "accept": "Akcepti",
+ "simple_policies": "Specialaj politikoj de la nodo"
+ },
+ "mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)",
+ "keyword": {
+ "is_replaced_by": "→",
+ "replace": "Anstataŭigi",
+ "reject": "Rifuzi",
+ "ftl_removal": "Forigo de la historio de «La tuta konata reto»",
+ "keyword_policies": "Politiko pri ŝlosilvortoj"
+ },
+ "federation": "Federado",
+ "mrf_policies_desc": "Politikoj de Mesaĝa ŝanĝilaro (MRF) efikas sur federa konduto de la nodo. La sekvaj politikoj estas ŝaltitaj:"
+ }
+ },
+ "selectable_list": {
+ "select_all": "Elekti ĉion"
+ },
+ "remote_user_resolver": {
+ "error": "Netrovinte.",
+ "searching_for": "Serĉante",
+ "remote_user_resolver": "Trovilo de foraj uzantoj"
+ },
+ "interactions": {
+ "load_older": "Enlegi pli malnovajn interagojn",
+ "moves": "Migrado de uzantoj",
+ "follows": "Novaj abonoj",
+ "favs_repeats": "Ripetoj kaj ŝatoj"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma ne povis aliri deponejon de la foliumilo. Via saluto kaj viaj lokaj agordoj ne estos konservitaj, kaj vi eble renkontos neatenditajn problemojn. Provu permesi kuketojn."
+ },
+ "status": {
+ "hide_content": "Kaŝi enhavon",
+ "show_content": "Montri enhavon",
+ "hide_full_subject": "Kaŝi plenan temon",
+ "show_full_subject": "Montri plenan temon",
+ "thread_muted_and_words": ", enhavas vortojn:",
+ "thread_muted": "Fadeno silentigita",
+ "copy_link": "Kopii ligilon al stato",
+ "status_unavailable": "Stato ne estas disponebla",
+ "unmute_conversation": "Malsilentigi interparolon",
+ "mute_conversation": "Silentigi interparolon",
+ "replies_list": "Respondoj:",
+ "reply_to": "Responde al",
+ "delete_confirm": "Ĉu vi certe volas forigi ĉi tiun staton?",
+ "unbookmark": "Senlegosigni",
+ "bookmark": "Legosigni",
+ "pinned": "Fiksita",
+ "unpin": "Malfiksi de profilo",
+ "pin": "Fiksi al profilo",
+ "delete": "Forigi staton",
+ "repeats": "Ripetoj",
+ "favorites": "Ŝatataj"
+ },
+ "time": {
+ "years_short": "{0}j",
+ "year_short": "{0}j",
+ "years": "{0} jaroj",
+ "year": "{0} jaro",
+ "weeks_short": "{0}s",
+ "week_short": "{0}s",
+ "weeks": "{0} semajnoj",
+ "week": "{0} semajno",
+ "seconds_short": "{0}s",
+ "second_short": "{0}s",
+ "seconds": "{0} sekundoj",
+ "second": "{0} sekundo",
+ "now_short": "nun",
+ "now": "ĵus",
+ "months_short": "{0}m",
+ "month_short": "{0}m",
+ "months": "{0} monatoj",
+ "month": "{0} monato",
+ "minutes_short": "{0}m",
+ "minute_short": "{0}m",
+ "minutes": "{0} minutoj",
+ "minute": "{0} minuto",
+ "in_past": "antaŭ {0}",
+ "in_future": "post {0}",
+ "hours_short": "{0}h",
+ "hour_short": "{0}h",
+ "hours": "{0} horoj",
+ "hour": "{0} horo",
+ "days_short": "{0}t",
+ "day_short": "{0}t",
+ "days": "{0} tagoj",
+ "day": "{0} tago"
+ },
+ "search": {
+ "people": "Personoj",
+ "no_results": "Neniuj rezultoj",
+ "people_talking": "{count} personoj parolas",
+ "person_talking": "{count} persono parolas",
+ "hashtags": "Kradvortoj"
+ },
+ "display_date": {
+ "today": "Hodiaŭ"
+ },
+ "file_type": {
+ "file": "Dosiero",
+ "image": "Bildo",
+ "video": "Filmo",
+ "audio": "Sono"
+ },
+ "chats": {
+ "empty_chat_list_placeholder": "Vi ankoraŭ havas neniun babilon. Komencu novan babilon!",
+ "error_sending_message": "Io misokazis dum sendado de la mesaĝo.",
+ "error_loading_chat": "Io misokazis dum enlego de la babilo.",
+ "delete_confirm": "Ĉu vi certe volas forigi ĉi tiun mesaĝon?",
+ "more": "Pli",
+ "empty_message_error": "Ne povas sendi malplenan mesaĝon",
+ "new": "Nova babilo",
+ "chats": "Babiloj",
+ "delete": "Forigi",
+ "you": "Vi:"
+ },
+ "password_reset": {
+ "password_reset_required_but_mailer_is_disabled": "Vi devas restarigi vian pasvorton, sed restarigado de pasvortoj estas malŝaltita. Bonvolu kontakti la administranton de via nodo.",
+ "password_reset_required": "Vi devas restarigi vian pasvorton por saluti.",
+ "password_reset_disabled": "Restarigado de pasvortoj estas malŝaltita. Bonvolu kontakti la administranton de via nodo.",
+ "too_many_requests": "Vi atingis la limon de provoj, reprovu pli poste.",
+ "return_home": "Reiri al la hejmpaĝo",
+ "check_email": "Kontrolu vian retpoŝton pro ligilo por restarigi vian pasvorton.",
+ "placeholder": "Via retpoŝtadreso aŭ uzantonomo",
+ "instruction": "Enigu vian retpoŝtadreson aŭ uzantonomon. Ni sendos al vi ligilon por restarigi vian pasvorton.",
+ "password_reset": "Restarigi pasvorton",
+ "forgot_password": "Ĉu vi forgesis pasvorton?"
+ },
+ "user_reporting": {
+ "generic_error": "Eraris traktado de via peto.",
+ "submit": "Sendi",
+ "forward_to": "Plusendi al {0}",
+ "forward_description": "La konto venas de alia servilo. Ĉu kopio de la raporto sendiĝu ankaŭ tien?",
+ "additional_comments": "Aldonaj komentoj",
+ "add_comment_description": "Ĉi tiu raporto sendiĝos al reguligistoj de via nodo. Vi povas komprenigi kial vi raportas ĉi tiun konton sube:",
+ "title": "Raportante {0}"
}
}
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 931d4c64..50bdcfb4 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -203,7 +203,6 @@
},
"attachmentRadius": "Adjuntos",
"attachments": "Adjuntos",
- "autoload": "Habilitar carga automática al llegar al final de la página",
"avatar": "Avatar",
"avatarAltRadius": "Avatares (Notificaciones)",
"avatarRadius": "Avatares",
@@ -307,7 +306,6 @@
"profile_tab": "Perfil",
"radii_help": "Establezca el redondeo de las esquinas de la interfaz (en píxeles)",
"replies_in_timeline": "Réplicas en la línea temporal",
- "reply_link_preview": "Activar la previsualización del enlace de responder al pasar el ratón por encima",
"reply_visibility_all": "Mostrar todas las réplicas",
"reply_visibility_following": "Solo mostrar réplicas para mí o usuarios a los que sigo",
"reply_visibility_self": "Solo mostrar réplicas para mí",
@@ -344,11 +342,6 @@
"true": "sí"
},
"notifications": "Notificaciones",
- "notification_setting": "Recibir notificaciones de:",
- "notification_setting_follows": "Usuarios que sigues",
- "notification_setting_non_follows": "Usuarios que no sigues",
- "notification_setting_followers": "Usuarios que te siguen",
- "notification_setting_non_followers": "Usuarios que no te siguen",
"notification_mutes": "Para dejar de recibir notificaciones de un usuario específico, siléncialo.",
"notification_blocks": "El bloqueo de un usuario detiene todas las notificaciones y también las cancela.",
"enable_web_push_notifications": "Habilitar las notificiaciones en el navegador",
@@ -631,7 +624,6 @@
"placeholder": "Su correo electrónico o nombre de usuario",
"check_email": "Revise su correo electrónico para obtener un enlace para restablecer su contraseña.",
"return_home": "Volver a la página de inicio",
- "not_found": "No pudimos encontrar ese correo electrónico o nombre de usuario.",
"too_many_requests": "Has alcanzado el límite de intentos, vuelve a intentarlo más tarde.",
"password_reset_disabled": "El restablecimiento de contraseñas está deshabilitado. Póngase en contacto con el administrador de su instancia."
}
diff --git a/src/i18n/et.json b/src/i18n/et.json
index b5ae4275..97e835da 100644
--- a/src/i18n/et.json
+++ b/src/i18n/et.json
@@ -116,7 +116,6 @@
},
"settings": {
"attachments": "Manused",
- "autoload": "Luba ajajoone automaatne uuendamine kui ajajoon on põhja keritud",
"avatar": "Profiilipilt",
"bio": "Bio",
"current_avatar": "Sinu praegune profiilipilt",
@@ -130,7 +129,6 @@
"nsfw_clickthrough": "Peida tööks-mittesobivad(NSFW) manuste hiireklõpsu taha",
"profile_background": "Profiilitaust",
"profile_banner": "Profiilibänner",
- "reply_link_preview": "Luba algpostituse kuvamine vastustes",
"set_new_avatar": "Vali uus profiilipilt",
"set_new_profile_background": "Vali uus profiilitaust",
"set_new_profile_banner": "Vali uus profiilibänner",
@@ -304,14 +302,9 @@
"enable_web_push_notifications": "Luba veebipõhised push-teated",
"notification_blocks": "Kasutaja blokeerimisel ei tule neilt enam teateid ning nendele teilt ka mitte.",
"notification_setting_privacy_option": "Peida saatja ning sisu push-teadetelt",
- "notification_setting": "Saa teateid nendelt:",
"notifications": "Teated",
"notification_mutes": "Kui soovid mõnelt kasutajalt mitte teateid saada, kasuta vaigistust.",
"notification_setting_privacy": "Privaatsus",
- "notification_setting_non_followers": "Kasutajatelt, kes sind ei jälgi",
- "notification_setting_followers": "Kasutajatelt, kes jälgivad sind",
- "notification_setting_non_follows": "Kasutajatelt, keda sa ei jälgi",
- "notification_setting_follows": "Kasutajatelt, keda jälgid",
"notification_setting_filters": "Filtrid",
"greentext": "Meemi nooled",
"fun": "Naljad",
diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index 1c75bf75..ad3e02a0 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -84,7 +84,7 @@
"preferences": "Hobespenak"
},
"notifications": {
- "broken_favorite": "Egoera ezezaguna, bilatzen...",
+ "broken_favorite": "Egoera ezezaguna, bilatzen…",
"favorited_you": "zure mezua gogoko du",
"followed_you": "Zu jarraitzen zaitu",
"load_older": "Kargatu jakinarazpen zaharragoak",
@@ -128,7 +128,7 @@
"new_status": "Mezu berri bat idatzi",
"account_not_locked_warning": "Zure kontua ez dago {0}. Edozeinek jarraitzen hastearekin, zure mezuak irakur ditzake.",
"account_not_locked_warning_link": "Blokeatuta",
- "attachments_sensitive": "Nabarmendu eranskinak hunkigarri gisa ",
+ "attachments_sensitive": "Nabarmendu eranskinak hunkigarri gisa",
"content_type": {
"text/plain": "Testu arrunta",
"text/html": "HTML",
@@ -187,9 +187,9 @@
"confirm_and_enable": "Baieztatu eta gaitu OTP",
"title": "Bi-faktore autentifikazioa",
"generate_new_recovery_codes": "Sortu berreskuratze kode berriak",
- "warning_of_generate_new_codes": "Berreskuratze kode berriak sortzean, zure berreskuratze kode zaharrak ez dute balioko",
+ "warning_of_generate_new_codes": "Berreskuratze kode berriak sortzean, zure berreskuratze kode zaharrak ez dute balioko.",
"recovery_codes": "Berreskuratze kodea",
- "waiting_a_recovery_codes": "Babes-kopia kodeak jasotzen...",
+ "waiting_a_recovery_codes": "Babes-kopia kodeak jasotzen…",
"recovery_codes_warning": "Idatzi edo gorde kodeak leku seguruan - bestela ez dituzu berriro ikusiko. Zure 2FA aplikaziorako sarbidea eta berreskuratze kodeak galduz gero, zure kontutik blokeatuta egongo zara.",
"authentication_methods": "Autentifikazio metodoa",
"scan": {
@@ -198,12 +198,11 @@
"secret_code": "Giltza"
},
"verify": {
- "desc": "Bi-faktore autentifikazioa gaitzeko, sar ezazu bi-faktore kodea zure app-tik"
+ "desc": "Bi-faktore autentifikazioa gaitzeko, sar ezazu bi-faktore kodea zure app-tik:"
}
},
"attachmentRadius": "Eranskinak",
"attachments": "Eranskinak",
- "autoload": "Gaitu karga automatikoa beheraino mugitzean",
"avatar": "Avatarra",
"avatarAltRadius": "Avatarra (Aipamenak)",
"avatarRadius": "Avatarrak",
@@ -221,7 +220,7 @@
"cOrange": "Laranja (Gogokoa)",
"cRed": "Gorria (ezeztatu)",
"change_password": "Pasahitza aldatu",
- "change_password_error": "Arazao bat egon da zure pasahitza aldatzean",
+ "change_password_error": "Arazao bat egon da zure pasahitza aldatzean.",
"changed_password": "Pasahitza ondo aldatu da!",
"collapse_subject": "Bildu gaia daukaten mezuak",
"composing": "Idazten",
@@ -248,7 +247,7 @@
"follows_imported": "Jarraitzaileak inportatuta! Prozesatzeak denbora pixka bat iraungo du.",
"foreground": "Aurreko planoa",
"general": "Orokorra",
- "hide_attachments_in_convo": "Ezkutatu eranskinak elkarrizketatan ",
+ "hide_attachments_in_convo": "Ezkutatu eranskinak elkarrizketatan",
"hide_attachments_in_tl": "Ezkutatu eranskinak donbora-lerroan",
"hide_muted_posts": "Ezkutatu mutututako erabiltzaileen mezuak",
"max_thumbnails": "Mezu bakoitzeko argazki-miniatura kopuru maximoa",
@@ -307,7 +306,6 @@
"profile_tab": "Profila",
"radii_help": "Konfiguratu interfazearen ertzen biribiltzea (pixeletan)",
"replies_in_timeline": "Denbora-lerroko erantzunak",
- "reply_link_preview": "Gaitu erantzun-estekaren aurrebista arratoiarekin",
"reply_visibility_all": "Erakutsi erantzun guztiak",
"reply_visibility_following": "Erakutsi bakarrik niri zuzendutako edo nik jarraitutako erabiltzaileen erantzunak",
"reply_visibility_self": "Erakutsi bakarrik niri zuzendutako erantzunak",
@@ -344,11 +342,6 @@
"true": "bai"
},
"notifications": "Jakinarazpenak",
- "notification_setting": "Jaso pertsona honen jakinarazpenak:",
- "notification_setting_follows": "Jarraitutako erabiltzaileak",
- "notification_setting_non_follows": "Jarraitzen ez dituzun erabiltzaileak",
- "notification_setting_followers": "Zu jarraitzen zaituzten erabiltzaileak",
- "notification_setting_non_followers": "Zu jarraitzen ez zaituzten erabiltzaileak",
"notification_mutes": "Erabiltzaile jakin baten jakinarazpenak jasotzeari uzteko, isilarazi ezazu.",
"notification_blocks": "Erabiltzaile bat blokeatzeak jakinarazpen guztiak gelditzen ditu eta harpidetza ezeztatu.",
"enable_web_push_notifications": "Gaitu web jakinarazpenak",
@@ -434,7 +427,7 @@
},
"fonts": {
"_tab_label": "Letra-tipoak",
- "help": "Aukeratu letra-tipoak erabiltzailearen interfazean erabiltzeko. \"Pertsonalizatua\" letra-tipoan, sisteman agertzen den izen berdinarekin idatzi behar duzu.",
+ "help": "Aukeratu letra-tipoak erabiltzailearen interfazean erabiltzeko. \"Pertsonalizatua\" letra-tipoan, sisteman agertzen den izen berdinarekin idatzi behar duzu.",
"components": {
"interface": "Interfazea",
"input": "Sarrera eremuak",
@@ -534,7 +527,7 @@
"favorites": "Gogokoak",
"follow": "Jarraitu",
"follow_sent": "Eskaera bidalita!",
- "follow_progress": "Eskatzen...",
+ "follow_progress": "Eskatzen…",
"follow_again": "Eskaera berriro bidali?",
"follow_unfollow": "Jarraitzeari utzi",
"followees": "Jarraitzen",
@@ -553,11 +546,11 @@
"subscribe": "Harpidetu",
"unsubscribe": "Harpidetza ezeztatu",
"unblock": "Blokeoa kendu",
- "unblock_progress": "Blokeoa ezeztatzen...",
- "block_progress": "Blokeatzen...",
+ "unblock_progress": "Blokeoa ezeztatzen…",
+ "block_progress": "Blokeatzen…",
"unmute": "Isiltasuna kendu",
- "unmute_progress": "Isiltasuna kentzen...",
- "mute_progress": "Isiltzen...",
+ "unmute_progress": "Isiltasuna kentzen…",
+ "mute_progress": "Isiltzen…",
"hide_repeats": "Ezkutatu errepikapenak",
"show_repeats": "Erakutsi errpekiapenak",
"admin_menu": {
@@ -633,10 +626,9 @@
"placeholder": "Zure e-posta edo erabiltzaile izena",
"check_email": "Begiratu zure posta elektronikoa pasahitza berrezarri ahal izateko.",
"return_home": "Itzuli hasierara",
- "not_found": "Ezin izan dugu helbide elektroniko edo erabiltzaile hori aurkitu.",
"too_many_requests": "Saiakera gehiegi burutu ditzu, saiatu berriro geroxeago.",
"password_reset_disabled": "Pasahitza berrezartzea debekatuta dago. Mesedez, jarri harremanetan instantzia administratzailearekin.",
"password_reset_required": "Pasahitza berrezarri behar duzu saioa hasteko.",
"password_reset_required_but_mailer_is_disabled": "Pasahitza berrezarri behar duzu, baina pasahitza berrezartzeko aukera desgaituta dago. Mesedez, jarri harremanetan instantziaren administratzailearekin."
}
-} \ No newline at end of file
+}
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index 99a1b53a..3832dcaa 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -28,7 +28,12 @@
"disable": "Poista käytöstä",
"confirm": "Hyväksy",
"verify": "Varmenna",
- "enable": "Ota käyttöön"
+ "enable": "Ota käyttöön",
+ "loading": "Ladataan…",
+ "error_retry": "Yritä uudelleen",
+ "retry": "Yritä uudelleen",
+ "close": "Sulje",
+ "peek": "Kurkkaa"
},
"login": {
"login": "Kirjaudu sisään",
@@ -58,15 +63,16 @@
"dms": "Yksityisviestit",
"public_tl": "Julkinen Aikajana",
"timeline": "Aikajana",
- "twkn": "Koko Tunnettu Verkosto",
+ "twkn": "Tunnettu Verkosto",
"user_search": "Käyttäjähaku",
"who_to_follow": "Seurausehdotukset",
"preferences": "Asetukset",
"administration": "Ylläpito",
- "search": "Haku"
+ "search": "Haku",
+ "bookmarks": "Kirjanmerkit"
},
"notifications": {
- "broken_favorite": "Viestiä ei löydetty...",
+ "broken_favorite": "Viestiä ei löydetty…",
"favorited_you": "tykkäsi viestistäsi",
"followed_you": "seuraa sinua",
"load_older": "Lataa vanhempia ilmoituksia",
@@ -101,7 +107,7 @@
},
"post_status": {
"new_status": "Uusi viesti",
- "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi",
+ "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi.",
"account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": {
@@ -126,7 +132,12 @@
"public": "Tämä viesti näkyy kaikille",
"private": "Tämä viesti näkyy vain sinun seuraajillesi",
"unlisted": "Tämä viesti ei näy Julkisella Aikajanalla tai Koko Tunnettu Verkosto -aikajanalla"
- }
+ },
+ "preview": "Esikatselu",
+ "preview_empty": "Tyhjä",
+ "empty_status_error": "Tyhjää viestiä ilman tiedostoja ei voi lähettää",
+ "media_description": "Tiedoston kuvaus",
+ "media_description_error": "Tiedostojen päivitys epäonnistui, yritä uudelleen"
},
"registration": {
"bio": "Kuvaus",
@@ -152,7 +163,6 @@
"settings": {
"attachmentRadius": "Liitteet",
"attachments": "Liitteet",
- "autoload": "Lataa vanhempia viestejä automaattisesti ruudun pohjalla",
"avatar": "Profiilikuva",
"avatarAltRadius": "Profiilikuvat (ilmoitukset)",
"avatarRadius": "Profiilikuvat",
@@ -175,7 +185,7 @@
"data_import_export_tab": "Tietojen tuonti / vienti",
"default_vis": "Oletusnäkyvyysrajaus",
"delete_account": "Poista tili",
- "delete_account_description": "Poista tilisi ja viestisi pysyvästi.",
+ "delete_account_description": "Poista tietosi ja lukitse tili pysyvästi.",
"delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.",
"delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.",
"emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla",
@@ -239,7 +249,6 @@
"profile_tab": "Profiili",
"radii_help": "Aseta reunojen pyöristys (pikseleinä)",
"replies_in_timeline": "Keskustelut aikajanalla",
- "reply_link_preview": "Keskusteluiden vastauslinkkien esikatselu",
"reply_visibility_all": "Näytä kaikki vastaukset",
"reply_visibility_following": "Näytä vain vastaukset minulle tai seuraamilleni käyttäjille",
"reply_visibility_self": "Näytä vain vastaukset minulle",
@@ -273,7 +282,6 @@
"show_moderator_badge": "Näytä Moderaattori-merkki profiilissani",
"useStreamingApi": "Vastaanota viestiejä ja ilmoituksia reaaliajassa",
"notification_setting_filters": "Suodattimet",
- "notification_setting": "Vastaanota ilmoituksia seuraavista:",
"notification_setting_privacy_option": "Piilota lähettäjä ja sisältö sovelluksen ulkopuolisista ilmoituksista",
"enable_web_push_notifications": "Ota käyttöön sovelluksen ulkopuoliset ilmoitukset",
"app_name": "Sovelluksen nimi",
@@ -288,7 +296,7 @@
"authentication_methods": "Todennus",
"warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.",
"recovery_codes": "Palautuskoodit.",
- "waiting_a_recovery_codes": "Odotetaan palautuskoodeja...",
+ "waiting_a_recovery_codes": "Odotetaan palautuskoodeja…",
"recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.",
"scan": {
"title": "Skannaa",
@@ -329,7 +337,7 @@
"post_status_content_type": "Uuden viestin sisällön muoto",
"user_mutes": "Käyttäjät",
"useStreamingApiWarning": "(Kokeellinen)",
- "type_domains_to_mute": "Syötä mykistettäviä sivustoja",
+ "type_domains_to_mute": "Etsi mykistettäviä sivustoja",
"upload_a_photo": "Lataa kuva",
"fun": "Hupi",
"greentext": "Meeminuolet",
@@ -479,10 +487,6 @@
"pad_emoji": "Välistä emojit välilyönneillä lisätessäsi niitä valitsimesta",
"mutes_tab": "Mykistykset",
"new_email": "Uusi sähköpostiosoite",
- "notification_setting_follows": "Käyttäjät joita seuraat",
- "notification_setting_non_follows": "Käyttäjät joita et seuraa",
- "notification_setting_followers": "Käyttäjät jotka seuraavat sinua",
- "notification_setting_non_followers": "Käyttäjät jotka eivät seuraa sinua",
"notification_setting_privacy": "Yksityisyys",
"notification_mutes": "Jos et halua ilmoituksia joltain käyttäjältä, käytä mykistystä.",
"notification_blocks": "Estäminen pysäyttää kaikki ilmoitukset käyttäjältä ja poistaa seurauksen.",
@@ -490,7 +494,21 @@
"title": "Versio",
"backend_version": "Palvelimen versio",
"frontend_version": "Käyttöliittymän versio"
- }
+ },
+ "reset_profile_background": "Nollaa taustakuva",
+ "reset_background_confirm": "Haluatko todella nollata taustakuvan?",
+ "mutes_and_blocks": "Mykistykset ja Estot",
+ "bot": "Tämä on bottitili",
+ "profile_fields": {
+ "label": "Profiilin metatiedot",
+ "add_field": "Lisää kenttä",
+ "name": "Nimi",
+ "value": "Sisältö"
+ },
+ "reset_avatar": "Nollaa profiilikuva",
+ "reset_profile_banner": "Nollaa profiilin tausta",
+ "reset_avatar_confirm": "Haluatko todella nollata profiilikuvan?",
+ "reset_banner_confirm": "Haluatko todella nollata profiilin taustan?"
},
"time": {
"day": "{0} päivä",
@@ -536,7 +554,8 @@
"show_new": "Näytä uudet",
"up_to_date": "Ajantasalla",
"no_more_statuses": "Ei enempää viestejä",
- "no_statuses": "Ei viestejä"
+ "no_statuses": "Ei viestejä",
+ "reload": "Päivitä"
},
"status": {
"favorites": "Tykkäykset",
@@ -551,7 +570,15 @@
"mute_conversation": "Mykistä keskustelu",
"unmute_conversation": "Poista mykistys",
"status_unavailable": "Viesti ei saatavissa",
- "copy_link": "Kopioi linkki"
+ "copy_link": "Kopioi linkki",
+ "bookmark": "Lisää kirjanmerkkeihin",
+ "unbookmark": "Poista kirjanmerkeistä",
+ "thread_muted": "Keskustelu mykistetty",
+ "thread_muted_and_words": ", sisältää sanat:",
+ "show_full_subject": "Näytä koko otsikko",
+ "hide_full_subject": "Piilota koko otsikko",
+ "show_content": "Näytä sisältö",
+ "hide_content": "Piilota sisältö"
},
"user_card": {
"approve": "Hyväksy",
@@ -561,7 +588,7 @@
"follow": "Seuraa",
"follow_sent": "Pyyntö lähetetty!",
"follow_progress": "Pyydetään…",
- "follow_again": "Lähetä pyyntö uudestaan",
+ "follow_again": "Lähetä pyyntö uudestaan?",
"follow_unfollow": "Älä seuraa",
"followees": "Seuraa",
"followers": "Seuraajat",
@@ -575,7 +602,7 @@
"statuses": "Viestit",
"hidden": "Piilotettu",
"media": "Media",
- "block_progress": "Estetään...",
+ "block_progress": "Estetään…",
"admin_menu": {
"grant_admin": "Anna Ylläpitöoikeudet",
"force_nsfw": "Merkitse kaikki viestit NSFW:nä",
@@ -601,10 +628,10 @@
"subscribe": "Tilaa",
"unsubscribe": "Poista tilaus",
"unblock": "Poista esto",
- "unblock_progress": "Postetaan estoa...",
+ "unblock_progress": "Poistetaan estoa…",
"unmute": "Poista mykistys",
- "unmute_progress": "Poistetaan mykistystä...",
- "mute_progress": "Mykistetään...",
+ "unmute_progress": "Poistetaan mykistystä…",
+ "mute_progress": "Mykistetään…",
"hide_repeats": "Piilota toistot",
"show_repeats": "Näytä toistot"
},
@@ -625,7 +652,8 @@
"user_settings": "Käyttäjäasetukset",
"add_reaction": "Lisää Reaktio",
"accept_follow_request": "Hyväksy seurauspyyntö",
- "reject_follow_request": "Hylkää seurauspyyntö"
+ "reject_follow_request": "Hylkää seurauspyyntö",
+ "bookmark": "Kirjanmerkki"
},
"upload": {
"error": {
@@ -674,8 +702,8 @@
"domain_mute_card": {
"mute": "Mykistä",
"unmute": "Poista mykistys",
- "mute_progress": "Mykistetään...",
- "unmute_progress": "Poistetaan mykistyst..."
+ "mute_progress": "Mykistetään…",
+ "unmute_progress": "Poistetaan mykistystä…"
},
"exporter": {
"export": "Vie",
@@ -724,7 +752,6 @@
"password_reset": "Salasanan nollaus",
"placeholder": "Sähköpostiosoite tai käyttäjänimi",
"return_home": "Palaa etusivulle",
- "not_found": "Sähköpostiosoitetta tai käyttäjänimeä ei löytynyt.",
"too_many_requests": "Olet käyttänyt kaikki yritykset, yritä uudelleen myöhemmin.",
"password_reset_required": "Sinun täytyy vaihtaa salasana kirjautuaksesi."
},
@@ -743,5 +770,8 @@
"people_talking": "{0} käyttäjää puhuvat",
"person_talking": "{0} käyttäjä puhuu",
"no_results": "Ei tuloksia"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma ei voinut käyttää selaimen muistia. Kirjautumisesi ja paikalliset asetukset eivät tallennu ja saatat kohdata odottamattomia ongelmia. Yritä sallia evästeet."
}
}
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 31b69a0f..3b7eefaf 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -182,7 +182,6 @@
},
"attachmentRadius": "Pièces jointes",
"attachments": "Pièces jointes",
- "autoload": "Charger la suite automatiquement une fois le bas de la page atteint",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (Notifications)",
"avatarRadius": "Avatars",
@@ -282,7 +281,6 @@
"profile_tab": "Profil",
"radii_help": "Vous pouvez ici choisir le niveau d'arrondi des angles de l'interface (en pixels)",
"replies_in_timeline": "Réponses au journal",
- "reply_link_preview": "Afficher un aperçu lors du survol de liens vers une réponse",
"reply_visibility_all": "Montrer toutes les réponses",
"reply_visibility_following": "Afficher uniquement les réponses adressées à moi ou aux personnes que je suis",
"reply_visibility_self": "Afficher uniquement les réponses adressées à moi",
@@ -319,11 +317,6 @@
"true": "oui"
},
"notifications": "Notifications",
- "notification_setting": "Reçevoir les notifications de :",
- "notification_setting_follows": "Utilisateurs que vous suivez",
- "notification_setting_non_follows": "Utilisateurs que vous ne suivez pas",
- "notification_setting_followers": "Utilisateurs qui vous suivent",
- "notification_setting_non_followers": "Utilisateurs qui ne vous suivent pas",
"notification_mutes": "Pour stopper la récéption de notifications d'un utilisateur particulier, utilisez un masquage.",
"notification_blocks": "Bloquer un utilisateur stoppe toute notification et se désabonne de lui.",
"enable_web_push_notifications": "Activer les notifications de push web",
@@ -350,7 +343,8 @@
"migration_snapshot_ok": "Pour être sûr un instantanée du thème à été chargé. Vos pouvez essayer de charger ses données.",
"fe_downgraded": "Retour en arrière de la version de PleromaFE.",
"fe_upgraded": "Le moteur de thème PleromaFE à été mis à jour après un changement de version.",
- "snapshot_missing": "Aucun instantané du thème à été trouvé dans le fichier, il peut y avoir un rendu différent à la vision originelle."
+ "snapshot_missing": "Aucun instantané du thème à été trouvé dans le fichier, il peut y avoir un rendu différent à la vision originelle.",
+ "snapshot_present": "Un instantané du thème à été chargé, toutes les valeurs sont dont écrasées. Vous pouvez autrement charger le thème complètement."
},
"keep_as_is": "Garder tel-quel",
"use_source": "Nouvelle version"
@@ -626,7 +620,7 @@
"reject": "Rejeté",
"replace": "Remplacer",
"keyword_policies": "Politiques par mot-clés",
- "ftl_removal": "Suppression du flux \"Ensemble du réseau connu\"",
+ "ftl_removal": "Suppression du flux fédéré",
"is_replaced_by": "→"
},
"simple": {
@@ -736,7 +730,6 @@
"instruction": "Entrer votre address de courriel ou votre nom utilisateur. Nous enverrons un lien pour changer votre mot de passe.",
"placeholder": "Votre email ou nom d'utilisateur",
"return_home": "Retourner à la page d'accueil",
- "not_found": "Email ou nom d'utilisateur inconnu.",
"too_many_requests": "Vos avez atteint la limite d'essais, essayez plus tard.",
"password_reset_required": "Vous devez changer votre mot de passe pour vous authentifier."
}
diff --git a/src/i18n/ga.json b/src/i18n/ga.json
index 7a10ba40..3ec5d86b 100644
--- a/src/i18n/ga.json
+++ b/src/i18n/ga.json
@@ -36,7 +36,7 @@
"twkn": "An Líonra Iomlán"
},
"notifications": {
- "broken_favorite": "Post anaithnid. Cuardach dó...",
+ "broken_favorite": "Post anaithnid. Cuardach dó…",
"favorited_you": "toghadh le do phost",
"followed_you": "lean tú",
"load_older": "Luchtaigh fógraí aosta",
@@ -73,7 +73,6 @@
"settings": {
"attachmentRadius": "Ceangaltáin",
"attachments": "Ceangaltáin",
- "autoload": "Cumasaigh luchtú uathoibríoch nuair a scrollaítear go bun",
"avatar": "Phictúir phrófíle",
"avatarAltRadius": "Phictúirí phrófíle (Fograí)",
"avatarRadius": "Phictúirí phrófíle",
@@ -85,7 +84,7 @@
"cOrange": "Oráiste (Cosúil)",
"cRed": "Dearg (Cealaigh)",
"change_password": "Athraigh do pasfhocal",
- "change_password_error": "Bhí fadhb ann ag athrú do pasfhocail",
+ "change_password_error": "Bhí fadhb ann ag athrú do pasfhocail.",
"changed_password": "Athraigh an pasfhocal go rathúil!",
"collapse_subject": "Poist a chosc le teidil",
"confirm_new_password": "Deimhnigh do pasfhocal nua",
@@ -147,7 +146,6 @@
"profile_tab": "Próifíl",
"radii_help": "Cruinniú imeall comhéadan a chumrú (i bpicteilíní)",
"replies_in_timeline": "Freagraí sa amlíne",
- "reply_link_preview": "Cumasaigh réamhamharc nasc freagartha ar chlár na luiche",
"reply_visibility_all": "Taispeáin gach freagra",
"reply_visibility_following": "Taispeáin freagraí amháin atá dírithe ar mise nó ar úsáideoirí atá mé ag leanúint",
"reply_visibility_self": "Taispeáin freagraí amháin atá dírithe ar mise",
@@ -162,7 +160,7 @@
"streaming": "Cumasaigh post nua a shruthú uathoibríoch nuair a scrollaítear go barr an leathanaigh",
"text": "Téacs",
"theme": "Téama",
- "theme_help": "Úsáid cód daith hex (#rrggbb) chun do schéim a saincheapadh",
+ "theme_help": "Úsáid cód daith hex (#rrggbb) chun do schéim a saincheapadh.",
"tooltipRadius": "Bileoga eolais",
"user_settings": "Socruithe úsáideora",
"values": {
diff --git a/src/i18n/he.json b/src/i18n/he.json
index 1c034960..7f2bf58f 100644
--- a/src/i18n/he.json
+++ b/src/i18n/he.json
@@ -70,9 +70,9 @@
"preferences": "העדפות"
},
"notifications": {
- "broken_favorite": "סטאטוס לא ידוע, מחפש...",
+ "broken_favorite": "סטאטוס לא ידוע, מחפש…",
"favorited_you": "אהב את הסטטוס שלך",
- "followed_you": "עקב אחריך!",
+ "followed_you": "עקב אחריך",
"load_older": "טען התראות ישנות",
"notifications": "התראות",
"read": "קרא!",
@@ -140,7 +140,6 @@
"app_name": "שם האפליקציה",
"attachmentRadius": "צירופים",
"attachments": "צירופים",
- "autoload": "החל טעינה אוטומטית בגלילה לתחתית הדף",
"avatar": "תמונת פרופיל",
"avatarAltRadius": "תמונות פרופיל (התראות)",
"avatarRadius": "תמונות פרופיל",
@@ -179,7 +178,7 @@
"follow_export": "יצוא עקיבות",
"follow_export_button": "ייצא את הנעקבים שלך לקובץ csv",
"follow_import": "יבוא עקיבות",
- "follow_import_error": "שגיאה בייבוא נעקבים.",
+ "follow_import_error": "שגיאה בייבוא נעקבים",
"follows_imported": "נעקבים יובאו! ייקח זמן מה לעבד אותם.",
"foreground": "חזית",
"general": "כללי",
@@ -240,7 +239,6 @@
"profile_tab": "פרופיל",
"radii_help": "קבע מראש עיגול פינות לממשק (בפיקסלים)",
"replies_in_timeline": "תגובות בציר הזמן",
- "reply_link_preview": "החל תצוגה מקדימה של לינק-תגובה בעת ריחוף עם העכבר",
"reply_visibility_all": "הראה את כל התגובות",
"reply_visibility_following": "הראה תגובות שמופנות אליי או לעקובים שלי בלבד",
"reply_visibility_self": "הראה תגובות שמופנות אליי בלבד",
@@ -313,7 +311,7 @@
"favorites": "מועדפים",
"follow": "עקוב",
"follow_sent": "בקשה נשלחה!",
- "follow_progress": "מבקש...",
+ "follow_progress": "מבקש…",
"follow_again": "שלח בקשה שוב?",
"follow_unfollow": "בטל עקיבה",
"followees": "נעקבים",
@@ -329,11 +327,11 @@
"report": "דווח",
"statuses": "סטטוסים",
"unblock": "הסר חסימה",
- "unblock_progress": "מסיר חסימה...",
- "block_progress": "חוסם...",
+ "unblock_progress": "מסיר חסימה…",
+ "block_progress": "חוסם…",
"unmute": "הסר השתקה",
- "unmute_progress": "מסיר השתקה...",
- "mute_progress": "משתיק...",
+ "unmute_progress": "מסיר השתקה…",
+ "mute_progress": "משתיק…",
"admin_menu": {
"moderation": "ניהול (צוות)",
"grant_admin": "הפוך למנהל",
@@ -379,7 +377,7 @@
"favorite": "מועדף",
"user_settings": "הגדרות משתמש"
},
- "upload":{
+ "upload": {
"error": {
"base": "העלאה נכשלה.",
"file_too_big": "קובץ גדול מדי [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
diff --git a/src/i18n/hu.json b/src/i18n/hu.json
index e98fdc44..41355800 100644
--- a/src/i18n/hu.json
+++ b/src/i18n/hu.json
@@ -38,7 +38,6 @@
},
"settings": {
"attachments": "Csatolmányok",
- "autoload": "Autoatikus betöltés engedélyezése lap aljára görgetéskor",
"avatar": "Avatár",
"bio": "Bio",
"current_avatar": "Jelenlegi avatár",
@@ -52,7 +51,6 @@
"nsfw_clickthrough": "NSFW átkattintási tartalom elrejtésének engedélyezése",
"profile_background": "Profil háttérkép",
"profile_banner": "Profil Banner",
- "reply_link_preview": "Válasz-link előzetes mutatása egér rátételkor",
"set_new_avatar": "Új avatár",
"set_new_profile_background": "Új profil háttér beállítása",
"set_new_profile_banner": "Új profil banner",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 7311f0b6..474e7fde 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -34,7 +34,10 @@
"user_search": "Ricerca utenti",
"search": "Ricerca",
"who_to_follow": "Chi seguire",
- "preferences": "Preferenze"
+ "preferences": "Preferenze",
+ "bookmarks": "Segnalibri",
+ "chats": "Conversazioni",
+ "timelines": "Sequenze"
},
"notifications": {
"followed_you": "ti segue",
@@ -51,7 +54,6 @@
},
"settings": {
"attachments": "Allegati",
- "autoload": "Abilita caricamento automatico quando raggiungi il fondo pagina",
"avatar": "Icona utente",
"bio": "Introduzione",
"current_avatar": "La tua icona attuale",
@@ -65,7 +67,6 @@
"nsfw_clickthrough": "Fai click per visualizzare gli allegati offuscati",
"profile_background": "Sfondo della tua pagina",
"profile_banner": "Stendardo del tuo profilo",
- "reply_link_preview": "Visualizza le risposte al passaggio del cursore",
"set_new_avatar": "Scegli una nuova icona",
"set_new_profile_background": "Scegli un nuovo sfondo per la tua pagina",
"set_new_profile_banner": "Scegli un nuovo stendardo per il tuo profilo",
@@ -84,7 +85,7 @@
"change_password": "Cambia password",
"change_password_error": "C'è stato un problema durante il cambiamento della password.",
"changed_password": "Password cambiata correttamente!",
- "collapse_subject": "Ripiega messaggi con Oggetto",
+ "collapse_subject": "Ripiega messaggi con oggetto",
"confirm_new_password": "Conferma la nuova password",
"current_password": "La tua password attuale",
"data_import_export_tab": "Importa o esporta dati",
@@ -256,7 +257,12 @@
"panel_header": "Titolo pannello",
"badge_notification": "Notifica",
"popover": "Suggerimenti, menù, sbalzi",
- "toggled": "Scambiato"
+ "toggled": "Scambiato",
+ "chat": {
+ "border": "Bordo",
+ "outgoing": "Inviati",
+ "incoming": "Ricevuti"
+ }
},
"common_colors": {
"rgbo": "Icone, accenti, medaglie",
@@ -271,10 +277,59 @@
"shadow_id": "Ombra numero {value}",
"override": "Sostituisci",
"component": "Componente",
- "_tab_label": "Luci ed ombre"
+ "_tab_label": "Luci ed ombre",
+ "components": {
+ "avatarStatus": "Icona utente (vista messaggio)",
+ "avatar": "Icona utente (vista profilo)",
+ "topBar": "Barra superiore",
+ "panelHeader": "Intestazione pannello",
+ "panel": "Pannello",
+ "input": "Campo d'immissione",
+ "buttonPressedHover": "Pulsante (puntato e premuto)",
+ "buttonPressed": "Pulsante (premuto)",
+ "buttonHover": "Pulsante (puntato)",
+ "button": "Pulsante",
+ "popup": "Sbalzi e suggerimenti"
+ },
+ "filter_hint": {
+ "inset_classic": "Le ombre incluse usano {0}",
+ "spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre",
+ "avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.",
+ "drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.",
+ "always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta."
+ },
+ "hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore."
},
"radii": {
"_tab_label": "Raggio"
+ },
+ "fonts": {
+ "_tab_label": "Font",
+ "custom": "Personalizzato",
+ "weight": "Peso (grassettatura)",
+ "size": "Dimensione (in pixel)",
+ "family": "Nome font",
+ "components": {
+ "postCode": "Font a spaziatura fissa incluso in un messaggio",
+ "post": "Testo del messaggio",
+ "input": "Campi d'immissione",
+ "interface": "Interfaccia"
+ },
+ "help": "Seleziona il font da usare per gli elementi dell'interfaccia. Se scegli \"personalizzato\" devi inserire il suo nome di sistema."
+ },
+ "preview": {
+ "link": "un bel collegamentino",
+ "checkbox": "Ho dato uno sguardo a termini e condizioni",
+ "header_faint": "Tutto bene",
+ "fine_print": "Leggi il nostro {0} per imparare un bel niente!",
+ "faint_link": "utilissimo manuale",
+ "input": "Sono appena atterrato a Fiumicino.",
+ "mono": "contenuto",
+ "text": "Altro {0} e {1}",
+ "content": "Contenuto",
+ "button": "Pulsante",
+ "error": "Errore d'esempio",
+ "header": "Anteprima"
}
},
"enable_web_push_notifications": "Abilita notifiche web push",
@@ -282,11 +337,6 @@
"notification_mutes": "Per non ricevere notifiche da uno specifico utente, zittiscilo.",
"notification_setting_privacy_option": "Nascondi mittente e contenuti delle notifiche push",
"notification_setting_privacy": "Privacy",
- "notification_setting_followers": "Utenti che ti seguono",
- "notification_setting_non_followers": "Utenti che non ti seguono",
- "notification_setting_non_follows": "Utenti che non segui",
- "notification_setting_follows": "Utenti che segui",
- "notification_setting": "Ricevi notifiche da:",
"notification_setting_filters": "Filtri",
"notifications": "Notifiche",
"greentext": "Frecce da meme",
@@ -336,7 +386,28 @@
"emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze",
"pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore",
"notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.",
- "mutes_and_blocks": "Zittiti e bloccati"
+ "mutes_and_blocks": "Zittiti e bloccati",
+ "profile_fields": {
+ "value": "Contenuto",
+ "name": "Etichetta",
+ "add_field": "Aggiungi campo",
+ "label": "Metadati profilo"
+ },
+ "bot": "Questo profilo è di un robot",
+ "version": {
+ "frontend_version": "Versione interfaccia",
+ "backend_version": "Versione backend",
+ "title": "Versione"
+ },
+ "reset_avatar": "Azzera icona",
+ "reset_profile_background": "Azzera sfondo profilo",
+ "reset_profile_banner": "Azzera stendardo profilo",
+ "reset_avatar_confirm": "Vuoi veramente azzerare l'icona?",
+ "reset_banner_confirm": "Vuoi veramente azzerare lo stendardo?",
+ "reset_background_confirm": "Vuoi veramente azzerare lo sfondo?",
+ "chatMessageRadius": "Messaggi istantanei",
+ "notification_setting_hide_notification_contents": "Nascondi mittente e contenuti delle notifiche push",
+ "notification_setting_block_from_strangers": "Blocca notifiche da utenti che non segui"
},
"timeline": {
"error_fetching": "Errore nell'aggiornamento",
@@ -346,7 +417,10 @@
"collapse": "Riduci",
"conversation": "Conversazione",
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso",
- "repeated": "condiviso"
+ "repeated": "condiviso",
+ "no_statuses": "Nessun messaggio",
+ "no_more_statuses": "Fine dei messaggi",
+ "reload": "Ricarica"
},
"user_card": {
"follow": "Segui",
@@ -362,7 +436,47 @@
"block": "Blocca",
"blocked": "Bloccato!",
"deny": "Nega",
- "remote_follow": "Segui da remoto"
+ "remote_follow": "Segui da remoto",
+ "admin_menu": {
+ "delete_user_confirmation": "Ne sei completamente sicuro? Quest'azione non può essere annullata.",
+ "delete_user": "Elimina utente",
+ "quarantine": "I messaggi non arriveranno alle altre stanze",
+ "disable_any_subscription": "Rendi utente non seguibile",
+ "disable_remote_subscription": "Blocca i tentativi di seguirlo da altre stanze",
+ "sandbox": "Rendi tutti i messaggi solo per seguaci",
+ "force_unlisted": "Rendi tutti i messaggi invisibili",
+ "strip_media": "Rimuovi ogni allegato ai messaggi",
+ "force_nsfw": "Oscura tutti i messaggi",
+ "delete_account": "Elimina profilo",
+ "deactivate_account": "Disattiva profilo",
+ "activate_account": "Attiva profilo",
+ "revoke_moderator": "Divesti Moderatore",
+ "grant_moderator": "Crea Moderatore",
+ "revoke_admin": "Divesti Amministratore",
+ "grant_admin": "Crea Amministratore",
+ "moderation": "Moderazione"
+ },
+ "show_repeats": "Mostra condivisioni",
+ "hide_repeats": "Nascondi condivisioni",
+ "mute_progress": "Zittisco…",
+ "unmute_progress": "Riabilito…",
+ "unmute": "Riabilita",
+ "block_progress": "Blocco…",
+ "unblock_progress": "Sblocco…",
+ "unblock": "Sblocca",
+ "unsubscribe": "Disdici",
+ "subscribe": "Abbònati",
+ "report": "Segnala",
+ "mention": "Menzioni",
+ "media": "Media",
+ "its_you": "Sei tu!",
+ "hidden": "Nascosto",
+ "follow_unfollow": "Disconosci",
+ "follow_again": "Reinvio richiesta?",
+ "follow_progress": "Richiedo…",
+ "follow_sent": "Richiesta inviata!",
+ "favorites": "Preferiti",
+ "message": "Contatta"
},
"chat": {
"title": "Chat"
@@ -374,7 +488,8 @@
"scope_options": "Opzioni visibilità",
"text_limit": "Lunghezza massima",
"title": "Caratteristiche",
- "who_to_follow": "Chi seguire"
+ "who_to_follow": "Chi seguire",
+ "pleroma_chat_messages": "Chiacchiere"
},
"finder": {
"error_fetching_user": "Errore nel recupero dell'utente",
@@ -425,7 +540,12 @@
},
"direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.",
"direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.",
- "new_status": "Nuovo messaggio"
+ "new_status": "Nuovo messaggio",
+ "empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati",
+ "preview_empty": "Vuoto",
+ "preview": "Anteprima",
+ "media_description_error": "Allegati non caricati, riprova",
+ "media_description": "Descrizione allegati"
},
"registration": {
"bio": "Introduzione",
@@ -449,7 +569,9 @@
"captcha": "CAPTCHA"
},
"user_profile": {
- "timeline_title": "Sequenza dell'Utente"
+ "timeline_title": "Sequenza dell'Utente",
+ "profile_loading_error": "Spiacente, c'è stato un errore nel caricamento del profilo.",
+ "profile_does_not_exist": "Spiacente, questo profilo non esiste."
},
"who_to_follow": {
"more": "Altro",
@@ -548,5 +670,143 @@
"error": "Non trovato.",
"searching_for": "Cerco",
"remote_user_resolver": "Cerca utenti remoti"
+ },
+ "errors": {
+ "storage_unavailable": "Pleroma non ha potuto accedere ai dati del tuo browser. Le tue credenziali o le tue impostazioni locali non potranno essere salvate e potresti incontrare strani errori. Prova ad abilitare i cookie."
+ },
+ "status": {
+ "pinned": "Intestato",
+ "unpin": "De-intesta",
+ "pin": "Intesta al profilo",
+ "delete": "Elimina messaggio",
+ "repeats": "Condivisi",
+ "favorites": "Preferiti",
+ "hide_content": "Nascondi contenuti",
+ "show_content": "Mostra contenuti",
+ "hide_full_subject": "Nascondi intero oggetto",
+ "show_full_subject": "Mostra intero oggetto",
+ "thread_muted_and_words": ", contiene:",
+ "thread_muted": "Discussione zittita",
+ "copy_link": "Copia collegamento",
+ "status_unavailable": "Messaggio non disponibile",
+ "unmute_conversation": "Riabilita conversazione",
+ "mute_conversation": "Zittisci conversazione",
+ "replies_list": "Risposte:",
+ "reply_to": "Rispondi a",
+ "delete_confirm": "Vuoi veramente eliminare questo messaggio?",
+ "unbookmark": "Rimuovi segnalibro",
+ "bookmark": "Aggiungi segnalibro"
+ },
+ "time": {
+ "years_short": "{0}a",
+ "year_short": "{0}a",
+ "years": "{0} anni",
+ "year": "{0} anno",
+ "weeks_short": "{0}set",
+ "week_short": "{0}set",
+ "seconds_short": "{0}sec",
+ "second_short": "{0}sec",
+ "weeks": "{0} settimane",
+ "week": "{0} settimana",
+ "seconds": "{0} secondi",
+ "second": "{0} secondo",
+ "now_short": "ora",
+ "now": "adesso",
+ "months_short": "{0}me",
+ "month_short": "{0}me",
+ "months": "{0} mesi",
+ "month": "{0} mese",
+ "minutes_short": "{0}min",
+ "minute_short": "{0}min",
+ "minutes": "{0} minuti",
+ "minute": "{0} minuto",
+ "in_past": "{0} fa",
+ "in_future": "fra {0}",
+ "hours_short": "{0}h",
+ "days_short": "{0}g",
+ "hour_short": "{0}h",
+ "hours": "{0} ore",
+ "hour": "{0} ora",
+ "day_short": "{0}g",
+ "days": "{0} giorni",
+ "day": "{0} giorno"
+ },
+ "user_reporting": {
+ "title": "Segnalo {0}",
+ "additional_comments": "Osservazioni accessorie",
+ "generic_error": "C'è stato un errore nell'elaborazione della tua richiesta.",
+ "submit": "Invia",
+ "forward_to": "Inoltra a {0}",
+ "forward_description": "Il profilo appartiene ad un'altra stanza. Inviare la segnalazione anche a quella?",
+ "add_comment_description": "La segnalazione sarà inviata ai moderatori della tua stanza. Puoi motivarla qui sotto:"
+ },
+ "password_reset": {
+ "password_reset_required_but_mailer_is_disabled": "Devi reimpostare la tua password, ma non puoi farlo. Contatta il tuo amministratore.",
+ "password_reset_required": "Devi reimpostare la tua password per poter continuare.",
+ "password_reset_disabled": "Non puoi azzerare la tua password. Contatta il tuo amministratore.",
+ "too_many_requests": "Hai raggiunto il numero massimo di tentativi, riprova più tardi.",
+ "return_home": "Torna alla pagina principale",
+ "check_email": "Controlla la tua posta elettronica.",
+ "placeholder": "La tua email o nome utente",
+ "instruction": "Inserisci il tuo indirizzo email o il tuo nome utente. Ti invieremo un collegamento per reimpostare la tua password.",
+ "password_reset": "Azzera password",
+ "forgot_password": "Password dimenticata?"
+ },
+ "search": {
+ "no_results": "Nessun risultato",
+ "people_talking": "{count} partecipanti",
+ "person_talking": "{count} partecipante",
+ "hashtags": "Etichette",
+ "people": "Utenti"
+ },
+ "upload": {
+ "file_size_units": {
+ "TiB": "TiB",
+ "GiB": "GiB",
+ "MiB": "MiB",
+ "KiB": "KiB",
+ "B": "B"
+ },
+ "error": {
+ "default": "Riprova in seguito",
+ "file_too_big": "File troppo pesante [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+ "base": "Caricamento fallito."
+ }
+ },
+ "tool_tip": {
+ "bookmark": "Aggiungi segnalibro",
+ "reject_follow_request": "Rifiuta seguace",
+ "accept_follow_request": "Accetta seguace",
+ "user_settings": "Impostazioni utente",
+ "add_reaction": "Reagisci",
+ "favorite": "Gradisci",
+ "reply": "Rispondi",
+ "repeat": "Ripeti",
+ "media_upload": "Carica allegati"
+ },
+ "display_date": {
+ "today": "Oggi"
+ },
+ "file_type": {
+ "file": "File",
+ "image": "Immagine",
+ "video": "Video",
+ "audio": "Audio"
+ },
+ "chats": {
+ "empty_chat_list_placeholder": "Non hai conversazioni. Contatta qualcuno!",
+ "error_sending_message": "Errore. Il messaggio non è stato inviato.",
+ "error_loading_chat": "Errore. La conversazione non è stata caricata.",
+ "delete_confirm": "Vuoi veramente eliminare questo messaggio?",
+ "more": "Altro",
+ "empty_message_error": "Non puoi inviare messaggi vuoti",
+ "new": "Nuova conversazione",
+ "chats": "Conversazioni",
+ "delete": "Elimina",
+ "message_user": "Contatta {nickname}",
+ "you": "Tu:"
+ },
+ "shoutbox": {
+ "title": "Graffiti"
}
}
diff --git a/src/i18n/ja_easy.json b/src/i18n/ja_easy.json
index 978e43b3..991f3762 100644
--- a/src/i18n/ja_easy.json
+++ b/src/i18n/ja_easy.json
@@ -27,7 +27,7 @@
},
"exporter": {
"export": "エクスポート",
- "processing": "おまちください。しばらくすると、あなたのファイルをダウンロードするように、メッセージがでます。"
+ "processing": "おまちください。しばらくすると、あなたのファイルをダウンロードするように、メッセージがでます"
},
"features_panel": {
"chat": "チャット",
@@ -39,7 +39,7 @@
"who_to_follow": "おすすめユーザー"
},
"finder": {
- "error_fetching_user": "ユーザーけんさくがエラーになりました。",
+ "error_fetching_user": "ユーザーけんさくがエラーになりました",
"find_user": "ユーザーをさがす"
},
"general": {
@@ -80,9 +80,9 @@
"enter_recovery_code": "リカバリーコードをいれてください",
"enter_two_factor_code": "2-ファクターコードをいれてください",
"recovery_code": "リカバリーコード",
- "heading" : {
- "totp" : "2-ファクターにんしょう",
- "recovery" : "2-ファクターリカバリー"
+ "heading": {
+ "totp": "2-ファクターにんしょう",
+ "recovery": "2-ファクターリカバリー"
}
},
"media_modal": {
@@ -107,7 +107,7 @@
"preferences": "せってい"
},
"notifications": {
- "broken_favorite": "ステータスがみつかりません。さがしています...",
+ "broken_favorite": "ステータスがみつかりません。さがしています…",
"favorited_you": "あなたのステータスがおきにいりされました",
"followed_you": "フォローされました",
"load_older": "ふるいつうちをみる",
@@ -172,10 +172,10 @@
"unlisted": "このとうこうは、パブリックタイムラインと、つながっているすべてのネットワークでは、みることができません"
},
"scope": {
- "direct": "ダイレクト: メンションされたユーザーのみにとどきます。",
- "private": "フォロワーげんてい: フォロワーのみにとどきます。",
- "public": "パブリック: パブリックタイムラインにとどきます。",
- "unlisted": "アンリステッド: パブリックタイムラインにとどきません。"
+ "direct": "ダイレクト: メンションされたユーザーのみにとどきます",
+ "private": "フォロワーげんてい: フォロワーのみにとどきます",
+ "public": "パブリック: パブリックタイムラインにとどきます",
+ "unlisted": "アンリステッド: パブリックタイムラインにとどきません"
}
},
"registration": {
@@ -212,17 +212,17 @@
"security": "セキュリティ",
"enter_current_password_to_confirm": "あなたのアイデンティティをたしかめるため、あなたのいまのパスワードをかいてください",
"mfa": {
- "otp" : "OTP",
- "setup_otp" : "OTPをつくる",
- "wait_pre_setup_otp" : "OTPをよういしています",
- "confirm_and_enable" : "OTPをたしかめて、ゆうこうにする",
+ "otp": "OTP",
+ "setup_otp": "OTPをつくる",
+ "wait_pre_setup_otp": "OTPをよういしています",
+ "confirm_and_enable": "OTPをたしかめて、ゆうこうにする",
"title": "2-ファクターにんしょう",
- "generate_new_recovery_codes" : "あたらしいリカバリーコードをつくる",
- "warning_of_generate_new_codes" : "あたらしいリカバリーコードをつくったら、ふるいコードはつかえなくなります。",
- "recovery_codes" : "リカバリーコード。",
- "waiting_a_recovery_codes": "バックアップコードをうけとっています...",
- "recovery_codes_warning" : "コードをかきうつすか、ひとにみられないところにセーブしてください。そうでなければ、あなたはこのコードをふたたびみることはできません。もしあなたが、2FAアプリのアクセスをうしなって、なおかつ、リカバリーコードもおもいだせないならば、あなたはあなたのアカウントから、しめだされます。",
- "authentication_methods" : "にんしょうメソッド",
+ "generate_new_recovery_codes": "あたらしいリカバリーコードをつくる",
+ "warning_of_generate_new_codes": "あたらしいリカバリーコードをつくったら、ふるいコードはつかえなくなります。",
+ "recovery_codes": "リカバリーコード。",
+ "waiting_a_recovery_codes": "バックアップコードをうけとっています…",
+ "recovery_codes_warning": "コードをかきうつすか、ひとにみられないところにセーブしてください。そうでなければ、あなたはこのコードをふたたびみることはできません。もしあなたが、2FAアプリのアクセスをうしなって、なおかつ、リカバリーコードもおもいだせないならば、あなたはあなたのアカウントから、しめだされます。",
+ "authentication_methods": "にんしょうメソッド",
"scan": {
"title": "スキャン",
"desc": "あなたの2-ファクターアプリをつかって、このQRコードをスキャンするか、テキストキーをうちこんでください:",
@@ -234,7 +234,6 @@
},
"attachmentRadius": "ファイル",
"attachments": "ファイル",
- "autoload": "したにスクロールしたとき、じどうてきによみこむ。",
"avatar": "アバター",
"avatarAltRadius": "つうちのアバター",
"avatarRadius": "アバター",
@@ -274,12 +273,12 @@
"pad_emoji": "えもじをピッカーでえらんだとき、えもじのまわりにスペースをいれる",
"export_theme": "セーブ",
"filtering": "フィルタリング",
- "filtering_explanation": "これらのことばをふくむすべてのものがミュートされます。1ぎょうに1つのことばをかいてください。",
+ "filtering_explanation": "これらのことばをふくむすべてのものがミュートされます。1ぎょうに1つのことばをかいてください",
"follow_export": "フォローのエクスポート",
"follow_export_button": "エクスポート",
"follow_export_processing": "おまちください。まもなくファイルをダウンロードできます。",
"follow_import": "フォローインポート",
- "follow_import_error": "フォローのインポートがエラーになりました。",
+ "follow_import_error": "フォローのインポートがエラーになりました",
"follows_imported": "フォローがインポートされました! すこしじかんがかかるかもしれません。",
"foreground": "フォアグラウンド",
"general": "ぜんぱん",
@@ -341,9 +340,8 @@
"profile_background": "プロフィールのバックグラウンド",
"profile_banner": "プロフィールバナー",
"profile_tab": "プロフィール",
- "radii_help": "インターフェースのまるさをせっていする。",
+ "radii_help": "インターフェースのまるさをせっていする",
"replies_in_timeline": "タイムラインのリプライ",
- "reply_link_preview": "カーソルをかさねたとき、リプライのプレビューをみる",
"reply_visibility_all": "すべてのリプライをみる",
"reply_visibility_following": "わたしにあてられたリプライと、フォローしているひとからのリプライをみる",
"reply_visibility_self": "わたしにあてられたリプライをみる",
@@ -369,7 +367,7 @@
"streaming": "うえまでスクロールしたとき、じどうてきにストリーミングする",
"text": "もじ",
"theme": "テーマ",
- "theme_help": "カラーテーマをカスタマイズできます",
+ "theme_help": "カラーテーマをカスタマイズできます。",
"theme_help_v2_1": "チェックボックスをONにすると、コンポーネントごとに、いろと、とうめいどを、オーバーライドできます。「すべてクリア」ボタンをおすと、すべてのオーバーライドを、やめます。",
"theme_help_v2_2": "バックグラウンドとテキストのコントラストをあらわすアイコンがあります。マウスをホバーすると、くわしいせつめいがでます。とうめいないろをつかっているときは、もっともわるいばあいのコントラストがしめされます。",
"upload_a_photo": "がぞうをアップロード",
@@ -382,11 +380,6 @@
"fun": "おたのしみ",
"greentext": "ミームやじるし",
"notifications": "つうち",
- "notification_setting": "つうちをうけとる:",
- "notification_setting_follows": "あなたがフォローしているひとから",
- "notification_setting_non_follows": "あなたがフォローしていないひとから",
- "notification_setting_followers": "あなたをフォローしているひとから",
- "notification_setting_non_followers": "あなたをフォローしていないひとから",
"notification_mutes": "あるユーザーからのつうちをとめるには、ミュートしてください。",
"notification_blocks": "ブロックしているユーザーからのつうちは、すべてとまります。",
"enable_web_push_notifications": "ウェブプッシュつうちをゆるす",
@@ -409,8 +402,8 @@
"hint": "コントラストは {ratio} です。{level}。({context})",
"level": {
"aa": "AAレベルガイドライン (ミニマル) をみたします",
- "aaa": "AAAレベルガイドライン (レコメンデッド) をみたします。",
- "bad": "ガイドラインをみたしません。"
+ "aaa": "AAAレベルガイドライン (レコメンデッド) をみたします",
+ "bad": "ガイドラインをみたしません"
},
"context": {
"18pt": "おおきい (18ポイントいじょう) テキスト",
@@ -454,8 +447,8 @@
"always_drop_shadow": "ブラウザーがサポートしていれば、つねに {0} がつかわれます。",
"drop_shadow_syntax": "{0} は、{1} パラメーターと {2} キーワードをサポートしていません。",
"avatar_inset": "うちがわのかげと、そとがわのかげを、いっしょにつかうと、とうめいなアバターが、へんなみためになります。",
- "spread_zero": "ひろがりが 0 よりもおおきなかげは、0 とおなじです。",
- "inset_classic": "うちがわのかげは {0} をつかいます。"
+ "spread_zero": "ひろがりが 0 よりもおおきなかげは、0 とおなじです",
+ "inset_classic": "うちがわのかげは {0} をつかいます"
},
"components": {
"panel": "パネル",
@@ -490,7 +483,7 @@
"content": "ほんぶん",
"error": "エラーのれい",
"button": "ボタン",
- "text": "これは{0}と{1}のれいです。",
+ "text": "これは{0}と{1}のれいです",
"mono": "monospace",
"input": "はねだくうこうに、つきました。",
"faint_link": "とてもたすけになるマニュアル",
@@ -593,11 +586,11 @@
"subscribe": "サブスクライブ",
"unsubscribe": "サブスクライブをやめる",
"unblock": "ブロックをやめる",
- "unblock_progress": "ブロックをとりけしています...",
- "block_progress": "ブロックしています...",
+ "unblock_progress": "ブロックをとりけしています…",
+ "block_progress": "ブロックしています…",
"unmute": "ミュートをやめる",
- "unmute_progress": "ミュートをとりけしています...",
- "mute_progress": "ミュートしています...",
+ "unmute_progress": "ミュートをとりけしています…",
+ "mute_progress": "ミュートしています…",
"hide_repeats": "リピートをかくす",
"show_repeats": "リピートをみる",
"admin_menu": {
@@ -645,11 +638,11 @@
"favorite": "おきにいり",
"user_settings": "ユーザーせってい"
},
- "upload":{
+ "upload": {
"error": {
- "base": "アップロードにしっぱいしました。",
- "file_too_big": "ファイルがおおきすぎます [{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]",
- "default": "しばらくしてから、ためしてください"
+ "base": "アップロードにしっぱいしました。",
+ "file_too_big": "ファイルがおおきすぎます [{filesize} {filesizeunit} / {allowedsize} {allowedsizeunit}]",
+ "default": "しばらくしてから、ためしてください"
},
"file_size_units": {
"B": "B",
@@ -673,7 +666,6 @@
"placeholder": "あなたのメールアドレスかユーザーめい",
"check_email": "パスワードをリセットするためのリンクがかかれたメールが、とどいているかどうか、みてください。",
"return_home": "ホームページにもどる",
- "not_found": "そのメールアドレスまたはユーザーめいを、みつけることができませんでした。",
"too_many_requests": "パスワードリセットを、ためすことが、おおすぎます。しばらくしてから、ためしてください。",
"password_reset_disabled": "このインスタンスでは、パスワードリセットは、できません。インスタンスのアドミニストレーターに、おといあわせください。",
"password_reset_required": "ログインするには、パスワードをリセットしてください。",
diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json
index 2ca7dca8..e2de1066 100644
--- a/src/i18n/ja_pedantic.json
+++ b/src/i18n/ja_pedantic.json
@@ -203,7 +203,6 @@
},
"attachmentRadius": "ファイル",
"attachments": "ファイル",
- "autoload": "下にスクロールしたとき、自動的に読み込む。",
"avatar": "アバター",
"avatarAltRadius": "通知のアバター",
"avatarRadius": "アバター",
@@ -308,7 +307,6 @@
"profile_tab": "プロフィール",
"radii_help": "インターフェースの丸さを設定する。",
"replies_in_timeline": "タイムラインのリプライ",
- "reply_link_preview": "カーソルを重ねたとき、リプライのプレビューを見る",
"reply_visibility_all": "すべてのリプライを見る",
"reply_visibility_following": "私に宛てられたリプライと、フォローしている人からのリプライを見る",
"reply_visibility_self": "私に宛てられたリプライを見る",
@@ -345,11 +343,6 @@
"true": "はい"
},
"notifications": "通知",
- "notification_setting": "通知を受け取る:",
- "notification_setting_follows": "あなたがフォローしているユーザーから",
- "notification_setting_non_follows": "あなたがフォローしていないユーザーから",
- "notification_setting_followers": "あなたをフォローしているユーザーから",
- "notification_setting_non_followers": "あなたをフォローしていないユーザーから",
"notification_mutes": "特定のユーザーからの通知を止めるには、ミュートしてください。",
"notification_blocks": "ブロックしているユーザーからの通知は、すべて止まります。",
"enable_web_push_notifications": "ウェブプッシュ通知を許可する",
@@ -632,7 +625,6 @@
"placeholder": "メールアドレスまたはユーザー名",
"check_email": "パスワードをリセットするためのリンクが記載されたメールが届いているか確認してください。",
"return_home": "ホームページに戻る",
- "not_found": "メールアドレスまたはユーザー名が見つかりませんでした。",
"too_many_requests": "試行回数の制限に達しました。しばらく時間を置いてから再試行してください。",
"password_reset_disabled": "このインスタンスではパスワードリセットは無効になっています。インスタンスの管理者に連絡してください。"
}
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 402a354c..0968949b 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -90,7 +90,6 @@
"settings": {
"attachmentRadius": "첨부물",
"attachments": "첨부물",
- "autoload": "최하단에 도착하면 자동으로 로드 활성화",
"avatar": "아바타",
"avatarAltRadius": "아바타 (알림)",
"avatarRadius": "아바타",
@@ -172,7 +171,6 @@
"profile_tab": "프로필",
"radii_help": "인터페이스 모서리 둥글기 (픽셀 단위)",
"replies_in_timeline": "답글을 타임라인에",
- "reply_link_preview": "마우스를 올려서 답글 링크 미리보기 활성화",
"reply_visibility_all": "모든 답글 보기",
"reply_visibility_following": "나에게 직접 오는 답글이나 내가 팔로우 중인 사람에게서 오는 답글만 표시",
"reply_visibility_self": "나에게 직접 전송 된 답글만 보이기",
diff --git a/src/i18n/nb.json b/src/i18n/nb.json
index 248b05bc..b9669a35 100644
--- a/src/i18n/nb.json
+++ b/src/i18n/nb.json
@@ -193,7 +193,6 @@
},
"attachmentRadius": "Vedlegg",
"attachments": "Vedlegg",
- "autoload": "Automatisk lasting når du blar ned til bunnen",
"avatar": "Profilbilde",
"avatarAltRadius": "Profilbilde (Varslinger)",
"avatarRadius": "Profilbilde",
@@ -293,7 +292,6 @@
"profile_tab": "Profil",
"radii_help": "Bestem hvor runde hjørnene i brukergrensesnittet skal være (i piksler)",
"replies_in_timeline": "Svar på tidslinje",
- "reply_link_preview": "Vis en forhåndsvisning når du holder musen over svar til en status",
"reply_visibility_all": "Vis alle svar",
"reply_visibility_following": "Vis bare svar som er til meg eller folk jeg følger",
"reply_visibility_self": "Vis bare svar som er til meg",
@@ -330,11 +328,6 @@
"true": "ja"
},
"notifications": "Varsler",
- "notification_setting": "Motta varsler i fra:",
- "notification_setting_follows": "Brukere du følger",
- "notification_setting_non_follows": "Brukere du ikke følger",
- "notification_setting_followers": "Brukere som følger deg",
- "notification_setting_non_followers": "Brukere som ikke følger deg",
"notification_mutes": "For å stoppe å motta varsler i fra en spesifikk bruker, kan du dempe dem.",
"notification_blocks": "Hvis du blokkerer en bruker vil det stoppe alle varsler og i tilleg få dem til å slutte å følge deg",
"enable_web_push_notifications": "Skru på pushnotifikasjoner i nettlesere",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index bf270f87..a01e57a0 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -136,7 +136,6 @@
"settings": {
"attachmentRadius": "Bijlages",
"attachments": "Bijlages",
- "autoload": "Automatisch laden inschakelen wanneer tot de bodem gescrold wordt",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (Meldingen)",
"avatarRadius": "Avatars",
@@ -217,7 +216,6 @@
"profile_tab": "Profiel",
"radii_help": "Stel afronding van hoeken in de interface in (in pixels)",
"replies_in_timeline": "Antwoorden in tijdlijn",
- "reply_link_preview": "Antwoord-link weergave inschakelen bij aanwijzen met muisaanwijzer",
"reply_visibility_all": "Alle antwoorden tonen",
"reply_visibility_following": "Enkel antwoorden tonen die aan mij of gevolgde gebruikers gericht zijn",
"reply_visibility_self": "Enkel antwoorden tonen die aan mij gericht zijn",
@@ -390,9 +388,6 @@
"link": "een leuke kleine link"
}
},
- "notification_setting_follows": "Gebruikers die je volgt",
- "notification_setting_non_follows": "Gebruikers die je niet volgt",
- "notification_setting_followers": "Gebruikers die je volgen",
"notification_setting_privacy": "Privacy",
"notification_setting_privacy_option": "Verberg de afzender en inhoud van push meldingen",
"notification_mutes": "Om niet langer meldingen te ontvangen van een specifieke gebruiker, kun je deze negeren.",
@@ -460,7 +455,6 @@
"upload_a_photo": "Upload een foto",
"fun": "Plezier",
"greentext": "Meme pijlen",
- "notification_setting": "Ontvang meldingen van:",
"block_export_button": "Exporteer je geblokkeerde gebruikers naar een csv bestand",
"block_import_error": "Fout bij importeren blokkades",
"discoverable": "Sta toe dat dit account ontdekt kan worden in zoekresultaten en andere diensten",
@@ -469,7 +463,6 @@
"hide_follows_description": "Niet tonen wie ik volg",
"show_moderator_badge": "Moderators badge tonen in mijn profiel",
"notification_setting_filters": "Filters",
- "notification_setting_non_followers": "Gebruikers die je niet volgen",
"notification_blocks": "Door een gebruiker te blokkeren, ontvang je geen meldingen meer van de gebruiker en wordt je abonnement op de gebruiker opgeheven.",
"version": {
"frontend_version": "Frontend Versie",
@@ -684,7 +677,6 @@
"password_reset_required": "Je dient je wachtwoord opnieuw in te stellen om in te kunnen loggen.",
"password_reset_disabled": "Wachtwoord reset is uitgeschakeld. Neem contact op met de beheerder van deze instantie.",
"too_many_requests": "Je hebt het maximaal aantal pogingen bereikt, probeer het later opnieuw.",
- "not_found": "We kunnen die email of gebruikersnaam niet vinden.",
"return_home": "Terugkeren naar de home pagina",
"check_email": "Controleer je email inbox voor een link om je wachtwoord opnieuw in te stellen.",
"placeholder": "Je email of gebruikersnaam",
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
index 680ad6dd..24001d4a 100644
--- a/src/i18n/oc.json
+++ b/src/i18n/oc.json
@@ -152,7 +152,6 @@
"app_name": "Nom de l’aplicacion",
"attachmentRadius": "Pèças juntas",
"attachments": "Pèças juntas",
- "autoload": "Activar lo cargament automatic un còp arribat al cap de la pagina",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (Notificacions)",
"avatarRadius": "Avatars",
@@ -252,7 +251,6 @@
"profile_tab": "Perfil",
"radii_help": "Configurar los caires arredondits de l’interfàcia (en pixèls)",
"replies_in_timeline": "Responsas del flux",
- "reply_link_preview": "Activar l’apercebut en passar la mirga",
"reply_visibility_all": "Mostrar totas las responsas",
"reply_visibility_following": "Mostrar pas que las responsas que me son destinada a ieu o un utilizaire que seguissi",
"reply_visibility_self": "Mostrar pas que las responsas que me son destinadas",
@@ -288,11 +286,6 @@
"true": "òc"
},
"notifications": "Notificacions",
- "notification_setting": "Recebre las notificacions de :",
- "notification_setting_follows": "Utilizaires que seguissètz",
- "notification_setting_non_follows": "Utilizaires que seguissètz pas",
- "notification_setting_followers": "Utilizaires que vos seguisson",
- "notification_setting_non_followers": "Utilizaires que vos seguisson pas",
"notification_mutes": "Per recebre pas mai d’un utilizaire en particular, botatz-lo en silenci.",
"notification_blocks": "Blocar un utilizaire arrèsta totas las notificacions tan coma quitar de los seguir.",
"enable_web_push_notifications": "Activar las notificacions web push",
@@ -550,4 +543,4 @@
"people_talking": "{count} personas ne parlan",
"no_results": "Cap de resultats"
}
-} \ No newline at end of file
+}
diff --git a/src/i18n/pl.json b/src/i18n/pl.json
index 61e09318..05a7edf7 100644
--- a/src/i18n/pl.json
+++ b/src/i18n/pl.json
@@ -34,9 +34,9 @@
},
"domain_mute_card": {
"mute": "Wycisz",
- "mute_progress": "Wyciszam...",
+ "mute_progress": "Wyciszam…",
"unmute": "Odcisz",
- "unmute_progress": "Odciszam..."
+ "unmute_progress": "Odciszam…"
},
"exporter": {
"export": "Eksportuj",
@@ -68,7 +68,10 @@
"disable": "Wyłącz",
"enable": "Włącz",
"confirm": "Potwierdź",
- "verify": "Zweryfikuj"
+ "verify": "Zweryfikuj",
+ "close": "Zamknij",
+ "loading": "Ładowanie…",
+ "retry": "Spróbuj ponownie"
},
"image_cropper": {
"crop_picture": "Przytnij obrazek",
@@ -118,7 +121,8 @@
"user_search": "Wyszukiwanie użytkowników",
"search": "Wyszukiwanie",
"who_to_follow": "Sugestie obserwacji",
- "preferences": "Preferencje"
+ "preferences": "Preferencje",
+ "bookmarks": "Zakładki"
},
"notifications": {
"broken_favorite": "Nieznany status, szukam go…",
@@ -190,7 +194,10 @@
"private": "Tylko dla obserwujących – Umieść dla osób, które cię obserwują",
"public": "Publiczny – Umieść na publicznych osiach czasu",
"unlisted": "Niewidoczny – Nie umieszczaj na publicznych osiach czasu"
- }
+ },
+ "preview_empty": "Pusty",
+ "preview": "Podgląd",
+ "empty_status_error": "Nie można wysłać pustego wpisu bez plików"
},
"registration": {
"bio": "Bio",
@@ -234,7 +241,7 @@
"generate_new_recovery_codes": "Wygeneruj nowe kody zapasowe",
"warning_of_generate_new_codes": "Po tym gdy wygenerujesz nowe kody zapasowe, stare przestaną działać.",
"recovery_codes": "Kody zapasowe.",
- "waiting_a_recovery_codes": "Otrzymuję kody zapasowe...",
+ "waiting_a_recovery_codes": "Otrzymuję kody zapasowe…",
"recovery_codes_warning": "Spisz kody na kartce papieru, albo zapisz je w bezpiecznym miejscu - inaczej nie zobaczysz ich już nigdy. Jeśli stracisz dostęp do twojej aplikacji 2FA i kodów zapasowych, nie będziesz miał(-a) dostępu do swojego konta.",
"authentication_methods": "Metody weryfikacji",
"scan": {
@@ -249,7 +256,6 @@
"allow_following_move": "Zezwalaj na automatyczną obserwację gdy obserwowane konto migruje",
"attachmentRadius": "Załączniki",
"attachments": "Załączniki",
- "autoload": "Włącz automatyczne ładowanie po przewinięciu do końca strony",
"avatar": "Awatar",
"avatarAltRadius": "Awatary (powiadomienia)",
"avatarRadius": "Awatary",
@@ -340,8 +346,8 @@
"notification_visibility_moves": "Użytkownik migruje",
"notification_visibility_emoji_reactions": "Reakcje",
"no_rich_text_description": "Usuwaj formatowanie ze wszystkich postów",
- "no_blocks": "Bez blokad",
- "no_mutes": "Bez wyciszeń",
+ "no_blocks": "Brak blokad",
+ "no_mutes": "Brak wyciszeń",
"hide_follows_description": "Nie pokazuj kogo obserwuję",
"hide_followers_description": "Nie pokazuj kto mnie obserwuje",
"hide_follows_count_description": "Nie pokazuj licznika obserwowanych",
@@ -362,7 +368,6 @@
"profile_tab": "Profil",
"radii_help": "Ustaw zaokrąglenie krawędzi interfejsu (w pikselach)",
"replies_in_timeline": "Odpowiedzi na osi czasu",
- "reply_link_preview": "Włącz dymek z podglądem postu po najechaniu na znak odpowiedzi",
"reply_visibility_all": "Pokazuj wszystkie odpowiedzi",
"reply_visibility_following": "Pokazuj tylko odpowiedzi skierowane do mnie i osób które obserwuję",
"reply_visibility_self": "Pokazuj tylko odpowiedzi skierowane do mnie",
@@ -405,11 +410,6 @@
"fun": "Zabawa",
"greentext": "Memiczne strzałki",
"notifications": "Powiadomienia",
- "notification_setting": "Otrzymuj powiadomienia od:",
- "notification_setting_follows": "Ludzi których obserwujesz",
- "notification_setting_non_follows": "Ludzi których nie obserwujesz",
- "notification_setting_followers": "Ludzi którzy obserwują ciebie",
- "notification_setting_non_followers": "Ludzi którzy nie obserwują ciebie",
"notification_mutes": "By przestać otrzymywać powiadomienia od jednego użytkownika, wycisz go.",
"notification_blocks": "Blokowanie uzytkownika zatrzymuje wszystkie powiadomienia i odsubskrybowuje go.",
"enable_web_push_notifications": "Włącz powiadomienia push",
@@ -489,7 +489,11 @@
"selectedMenu": "Wybrany element menu",
"disabled": "Wyłączone",
"toggled": "Przełączone",
- "tabs": "Karty"
+ "tabs": "Karty",
+ "chat": {
+ "outgoing": "Wiadomości wychodzące",
+ "incoming": "Wiadomości przychodzące"
+ }
},
"radii": {
"_tab_label": "Zaokrąglenie"
@@ -560,7 +564,16 @@
},
"notification_setting_privacy": "Prywatność",
"notification_setting_filters": "Filtry",
- "notification_setting_privacy_option": "Ukryj nadawcę i zawartość powiadomień push"
+ "notification_setting_privacy_option": "Ukryj nadawcę i zawartość powiadomień push",
+ "reset_avatar": "Zresetuj awatar",
+ "profile_fields": {
+ "value": "Zawartość",
+ "label": "Metadane profilu",
+ "name": "Nazwa",
+ "add_field": "Dodaj pole"
+ },
+ "bot": "To konto jest prowadzone przez bota",
+ "notification_setting_hide_notification_contents": "Ukryj nadawcę i zawartość powiadomień push"
},
"time": {
"day": "{0} dzień",
@@ -606,7 +619,8 @@
"show_new": "Pokaż nowe",
"up_to_date": "Na bieżąco",
"no_more_statuses": "Brak kolejnych statusów",
- "no_statuses": "Brak statusów"
+ "no_statuses": "Brak statusów",
+ "reload": "Odśwież"
},
"status": {
"favorites": "Ulubione",
@@ -621,7 +635,11 @@
"mute_conversation": "Wycisz konwersację",
"unmute_conversation": "Odcisz konwersację",
"status_unavailable": "Status niedostępny",
- "copy_link": "Kopiuj link do statusu"
+ "copy_link": "Kopiuj link do statusu",
+ "unbookmark": "Usuń z zakładek",
+ "bookmark": "Dodaj do zakładek",
+ "hide_content": "Ukryj zawartość",
+ "show_content": "Pokaż zawartość"
},
"user_card": {
"approve": "Przyjmij",
@@ -676,7 +694,8 @@
"quarantine": "Zakaż federowania postów od tego użytkownika",
"delete_user": "Usuń użytkownika",
"delete_user_confirmation": "Czy jesteś absolutnie pewny(-a)? Ta operacja nie może być cofnięta."
- }
+ },
+ "message": "Napisz"
},
"user_profile": {
"timeline_title": "Oś czasu użytkownika",
@@ -734,10 +753,29 @@
"placeholder": "Twój email lub nazwa użytkownika",
"check_email": "Sprawdź pocztę, aby uzyskać link do zresetowania hasła.",
"return_home": "Wróć do strony głównej",
- "not_found": "Nie mogliśmy znaleźć tego emaila lub nazwy użytkownika.",
"too_many_requests": "Przekroczyłeś(-aś) limit prób, spróbuj ponownie później.",
"password_reset_disabled": "Resetowanie hasła jest wyłączone. Proszę skontaktuj się z administratorem tej instancji.",
"password_reset_required": "Musisz zresetować hasło, by się zalogować.",
"password_reset_required_but_mailer_is_disabled": "Musisz zresetować hasło, ale resetowanie hasła jest wyłączone. Proszę skontaktuj się z administratorem tej instancji."
+ },
+ "file_type": {
+ "file": "Plik",
+ "image": "Zdjęcie",
+ "video": "Wideo",
+ "audio": "Audio"
+ },
+ "chats": {
+ "more": "Więcej",
+ "delete": "Usuń",
+ "you": "Ty:",
+ "delete_confirm": "Czy na pewno chcesz usunąć tą wiadomość?",
+ "message_user": "Napisz do {nickname}",
+ "error_sending_message": "Coś poszło nie tak podczas wysyłania wiadomości.",
+ "error_loading_chat": "Coś poszło nie tak podczas ładowania czatu.",
+ "empty_message_error": "Nie można wysłać pustej wiadomości",
+ "new": "Nowy czat"
+ },
+ "display_date": {
+ "today": "Dzisiaj"
}
}
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 41a34483..1b8694d9 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -109,7 +109,6 @@
"app_name": "Nome do aplicativo",
"attachmentRadius": "Anexos",
"attachments": "Anexos",
- "autoload": "Habilitar carregamento automático quando a rolagem chegar ao fim.",
"avatar": "Avatar",
"avatarAltRadius": "Avatares (Notificações)",
"avatarRadius": "Avatares",
@@ -203,7 +202,6 @@
"profile_tab": "Perfil",
"radii_help": "Arredondar arestas da interface (em pixel)",
"replies_in_timeline": "Respostas na linha do tempo",
- "reply_link_preview": "Habilitar a pré-visualização de de respostas ao passar o mouse.",
"reply_visibility_all": "Mostrar todas as respostas",
"reply_visibility_following": "Só mostrar respostas direcionadas a mim ou a usuários que sigo",
"reply_visibility_self": "Só mostrar respostas direcionadas a mim",
diff --git a/src/i18n/ro.json b/src/i18n/ro.json
index 3cee264f..d800a8d4 100644
--- a/src/i18n/ro.json
+++ b/src/i18n/ro.json
@@ -38,7 +38,6 @@
},
"settings": {
"attachments": "Atașamente",
- "autoload": "Permite încărcarea automată când scrolat la capăt",
"avatar": "Avatar",
"bio": "Bio",
"current_avatar": "Avatarul curent",
@@ -52,7 +51,6 @@
"nsfw_clickthrough": "Permite ascunderea al atașamentelor NSFW",
"profile_background": "Fundalul de profil",
"profile_banner": "Banner de profil",
- "reply_link_preview": "Permite previzualizarea linkului de răspuns la planarea de mouse",
"set_new_avatar": "Setează avatar nou",
"set_new_profile_background": "Setează fundal nou",
"set_new_profile_banner": "Setează banner nou la profil",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index aa78db26..3444a26d 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -45,7 +45,8 @@
"timeline": "Лента",
"twkn": "Федеративная лента",
"search": "Поиск",
- "friend_requests": "Запросы на чтение"
+ "friend_requests": "Запросы на чтение",
+ "bookmarks": "Закладки"
},
"notifications": {
"broken_favorite": "Неизвестный статус, ищем...",
@@ -123,7 +124,6 @@
},
"attachmentRadius": "Прикреплённые файлы",
"attachments": "Вложения",
- "autoload": "Включить автоматическую загрузку при прокрутке вниз",
"avatar": "Аватар",
"avatarAltRadius": "Аватары в уведомлениях",
"avatarRadius": "Аватары",
@@ -210,7 +210,6 @@
"profile_tab": "Профиль",
"radii_help": "Скругление углов элементов интерфейса (в пикселях)",
"replies_in_timeline": "Ответы в ленте",
- "reply_link_preview": "Включить предварительный просмотр ответа при наведении мыши",
"reply_visibility_all": "Показывать все ответы",
"reply_visibility_following": "Показывать только ответы мне или тех на кого я подписан",
"reply_visibility_self": "Показывать только ответы мне",
@@ -349,12 +348,8 @@
"link": "ссылка"
}
},
- "notification_setting_non_followers": "Не читающие вас",
"allow_following_move": "Разрешить автоматически читать новый аккаунт при перемещении на другой сервер",
- "hide_user_stats": "Не показывать статистику пользователей (например количество читателей)",
- "notification_setting_followers": "Читающие вас",
- "notification_setting_follows": "Читаемые вами",
- "notification_setting_non_follows": "Не читаемые вами"
+ "hide_user_stats": "Не показывать статистику пользователей (например количество читателей)"
},
"timeline": {
"collapse": "Свернуть",
@@ -366,6 +361,10 @@
"show_new": "Показать новые",
"up_to_date": "Обновлено"
},
+ "status": {
+ "bookmark": "В закладки",
+ "unbookmark": "Удалить из закладок"
+ },
"user_card": {
"block": "Заблокировать",
"blocked": "Заблокирован",
@@ -421,7 +420,6 @@
"placeholder": "Ваш email или имя пользователя",
"check_email": "Проверьте ваш email и перейдите по ссылке для сброса пароля.",
"return_home": "Вернуться на главную страницу",
- "not_found": "Мы не смогли найти аккаунт с таким email-ом или именем пользователя.",
"too_many_requests": "Вы исчерпали допустимое количество попыток, попробуйте позже.",
"password_reset_disabled": "Сброс пароля отключен. Cвяжитесь с администратором вашего сервера."
},
diff --git a/src/i18n/te.json b/src/i18n/te.json
index 6022349d..bb68d29e 100644
--- a/src/i18n/te.json
+++ b/src/i18n/te.json
@@ -83,7 +83,6 @@
"settings.app_name": "అనువర్తన పేరు",
"settings.attachmentRadius": "జోడింపులు",
"settings.attachments": "జోడింపులు",
- "settings.autoload": "క్రిందికి స్క్రోల్ చేయబడినప్పుడు స్వయంచాలక లోడింగ్ని ప్రారంభించు",
"settings.avatar": "అవతారం",
"settings.avatarAltRadius": "అవతారాలు (ప్రకటనలు)",
"settings.avatarRadius": "అవతారాలు",
@@ -178,7 +177,6 @@
"settings.profile_tab": "Profile",
"settings.radii_help": "Set up interface edge rounding (in pixels)",
"settings.replies_in_timeline": "Replies in timeline",
- "settings.reply_link_preview": "Enable reply-link preview on mouse hover",
"settings.reply_visibility_all": "Show all replies",
"settings.reply_visibility_following": "Only show replies directed at me or users I'm following",
"settings.reply_visibility_self": "Only show replies directed at me",
diff --git a/src/i18n/zh.json b/src/i18n/zh.json
index f95dc498..8c693f4d 100644
--- a/src/i18n/zh.json
+++ b/src/i18n/zh.json
@@ -85,7 +85,7 @@
"administration": "管理员"
},
"notifications": {
- "broken_favorite": "未知的状态,正在搜索中...",
+ "broken_favorite": "未知的状态,正在搜索中…",
"favorited_you": "收藏了你的状态",
"followed_you": "关注了你",
"load_older": "加载更早的通知",
@@ -185,7 +185,7 @@
"generate_new_recovery_codes": "生成新的恢复码",
"warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。",
"recovery_codes": "恢复码。",
- "waiting_a_recovery_codes": "正在接收备份码……",
+ "waiting_a_recovery_codes": "正在接收备份码…",
"recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app,也丢失了你的恢复码,你的账号就再也无法登录了。",
"authentication_methods": "身份验证方法",
"scan": {
@@ -199,7 +199,6 @@
},
"attachmentRadius": "附件",
"attachments": "附件",
- "autoload": "启用滚动到底部时的自动加载",
"avatar": "头像",
"avatarAltRadius": "头像(通知)",
"avatarRadius": "头像",
@@ -299,7 +298,6 @@
"profile_tab": "个人资料",
"radii_help": "设置界面边缘的圆角 (单位:像素)",
"replies_in_timeline": "时间线中的回复",
- "reply_link_preview": "启用鼠标悬停时预览回复链接",
"reply_visibility_all": "显示所有回复",
"reply_visibility_following": "只显示发送给我的回复/发送给我关注的用户的回复",
"reply_visibility_self": "只显示发送给我的回复",
@@ -336,11 +334,6 @@
"true": "是"
},
"notifications": "通知",
- "notification_setting": "通知来源:",
- "notification_setting_follows": "你所关注的用户",
- "notification_setting_non_follows": "你没有关注的用户",
- "notification_setting_followers": "关注你的用户",
- "notification_setting_non_followers": "没有关注你的用户",
"notification_mutes": "要停止收到某个指定的用户的通知,请使用隐藏功能。",
"notification_blocks": "拉黑一个用户会停掉所有他的通知,等同于取消关注。",
"enable_web_push_notifications": "启用 web 推送通知",
@@ -564,11 +557,11 @@
"subscribe": "订阅",
"unsubscribe": "退订",
"unblock": "取消拉黑",
- "unblock_progress": "取消拉黑中...",
- "block_progress": "拉黑中...",
+ "unblock_progress": "取消拉黑中…",
+ "block_progress": "拉黑中…",
"unmute": "取消隐藏",
- "unmute_progress": "取消隐藏中...",
- "mute_progress": "隐藏中...",
+ "unmute_progress": "取消隐藏中…",
+ "mute_progress": "隐藏中…",
"admin_menu": {
"moderation": "权限",
"grant_admin": "赋予管理权限",
@@ -647,7 +640,6 @@
"placeholder": "你的电邮地址或者用户名",
"check_email": "检查你的邮箱,会有一个链接用于重置密码。",
"return_home": "回到首页",
- "not_found": "我们无法找到匹配的邮箱地址或者用户名。",
"too_many_requests": "你触发了尝试的限制,请稍后再试。",
"password_reset_disabled": "密码重置已经被禁用。请联系你的实例管理员。"
},
@@ -690,9 +682,9 @@
}
},
"domain_mute_card": {
- "unmute_progress": "正在取消隐藏……",
+ "unmute_progress": "正在取消隐藏…",
"unmute": "取消隐藏",
- "mute_progress": "隐藏中……",
+ "mute_progress": "隐藏中…",
"mute": "隐藏"
}
}
diff --git a/src/main.js b/src/main.js
index 9a201e4f..0a898022 100644
--- a/src/main.js
+++ b/src/main.js
@@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js'
+import chatsModule from './modules/chats.js'
import VueI18n from 'vue-i18n'
@@ -62,7 +63,15 @@ const persistedStateOptions = {
};
(async () => {
- const persistedState = await createPersistedState(persistedStateOptions)
+ let storageError = false
+ const plugins = [pushNotifications]
+ try {
+ const persistedState = await createPersistedState(persistedStateOptions)
+ plugins.push(persistedState)
+ } catch (e) {
+ console.error(e)
+ storageError = true
+ }
const store = new Vuex.Store({
modules: {
i18n: {
@@ -83,13 +92,16 @@ const persistedStateOptions = {
oauthTokens: oauthTokensModule,
reports: reportsModule,
polls: pollsModule,
- postStatus: postStatusModule
+ postStatus: postStatusModule,
+ chats: chatsModule
},
- plugins: [persistedState, pushNotifications],
+ plugins,
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
-
+ if (storageError) {
+ store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
+ }
afterStoreSetup({ store, i18n })
})()
diff --git a/src/modules/api.js b/src/modules/api.js
index 748570e5..5e213f0d 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,4 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
+import { WSConnectionStatus } from '../services/api/api.service.js'
+import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { Socket } from 'phoenix'
const api = {
@@ -7,6 +9,7 @@ const api = {
fetchers: {},
socket: null,
mastoUserSocket: null,
+ mastoUserSocketStatus: null,
followRequests: []
},
mutations: {
@@ -28,6 +31,9 @@ const api = {
},
setFollowRequests (state, value) {
state.followRequests = value
+ },
+ setMastoUserSocketStatus (state, value) {
+ state.mastoUserSocketStatus = value
}
},
actions: {
@@ -47,7 +53,7 @@ const api = {
startMastoUserSocket (store) {
return new Promise((resolve, reject) => {
try {
- const { state, dispatch, rootState } = store
+ const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
state.mastoUserSocket.addEventListener(
@@ -66,11 +72,23 @@ const api = {
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends'
})
+ } else if (message.event === 'pleroma:chat_update') {
+ dispatch('addChatMessages', {
+ chatId: message.chatUpdate.id,
+ messages: [message.chatUpdate.lastMessage]
+ })
+ dispatch('updateChat', { chat: message.chatUpdate })
+ maybeShowChatNotification(store, message.chatUpdate)
}
}
)
+ state.mastoUserSocket.addEventListener('open', () => {
+ commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
+ })
state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
+ commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
+ dispatch('clearOpenedChats')
})
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
const ignoreCodes = new Set([
@@ -84,8 +102,11 @@ const api = {
console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
+ dispatch('startFetchingChats')
dispatch('restartMastoUserSocket')
}
+ commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
+ dispatch('clearOpenedChats')
})
resolve()
} catch (e) {
@@ -99,12 +120,13 @@ const api = {
return dispatch('startMastoUserSocket').then(() => {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
+ dispatch('stopFetchingChats')
})
},
stopMastoUserSocket ({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
- console.log(state.mastoUserSocket)
+ dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
@@ -138,9 +160,6 @@ const api = {
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
- fetchAndUpdateNotifications (store) {
- store.state.backendInteractor.fetchAndUpdateNotifications({ store })
- },
// Follow requests
startFetchingFollowRequests (store) {
diff --git a/src/modules/chats.js b/src/modules/chats.js
new file mode 100644
index 00000000..c7609018
--- /dev/null
+++ b/src/modules/chats.js
@@ -0,0 +1,234 @@
+import Vue from 'vue'
+import { find, omitBy, orderBy, sumBy } from 'lodash'
+import chatService from '../services/chat_service/chat_service.js'
+import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
+import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
+
+const emptyChatList = () => ({
+ data: [],
+ idStore: {}
+})
+
+const defaultState = {
+ chatList: emptyChatList(),
+ chatListFetcher: null,
+ openedChats: {},
+ openedChatMessageServices: {},
+ fetcher: undefined,
+ currentChatId: null
+}
+
+const getChatById = (state, id) => {
+ return find(state.chatList.data, { id })
+}
+
+const sortedChatList = (state) => {
+ return orderBy(state.chatList.data, ['updated_at'], ['desc'])
+}
+
+const unreadChatCount = (state) => {
+ return sumBy(state.chatList.data, 'unread')
+}
+
+const chats = {
+ state: { ...defaultState },
+ getters: {
+ currentChat: state => state.openedChats[state.currentChatId],
+ currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId],
+ findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId),
+ sortedChatList,
+ unreadChatCount
+ },
+ actions: {
+ // Chat list
+ startFetchingChats ({ dispatch, commit }) {
+ const fetcher = () => {
+ dispatch('fetchChats', { latest: true })
+ }
+ fetcher()
+ commit('setChatListFetcher', {
+ fetcher: () => setInterval(() => { fetcher() }, 5000)
+ })
+ },
+ stopFetchingChats ({ commit }) {
+ commit('setChatListFetcher', { fetcher: undefined })
+ },
+ fetchChats ({ dispatch, rootState, commit }, params = {}) {
+ return rootState.api.backendInteractor.chats()
+ .then(({ chats }) => {
+ dispatch('addNewChats', { chats })
+ return chats
+ })
+ },
+ addNewChats (store, { chats }) {
+ const { commit, dispatch, rootGetters } = store
+ const newChatMessageSideEffects = (chat) => {
+ maybeShowChatNotification(store, chat)
+ }
+ commit('addNewChats', { dispatch, chats, rootGetters, newChatMessageSideEffects })
+ },
+ updateChat ({ commit }, { chat }) {
+ commit('updateChat', { chat })
+ },
+
+ // Opened Chats
+ startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
+ dispatch('setCurrentChatFetcher', { fetcher })
+ },
+ setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
+ commit('setCurrentChatFetcher', { fetcher })
+ },
+ addOpenedChat ({ rootState, commit, dispatch }, { chat }) {
+ commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
+ dispatch('addNewUsers', [chat.account])
+ },
+ addChatMessages ({ commit }, value) {
+ commit('addChatMessages', { commit, ...value })
+ },
+ resetChatNewMessageCount ({ commit }, value) {
+ commit('resetChatNewMessageCount', value)
+ },
+ clearCurrentChat ({ rootState, commit, dispatch }, value) {
+ commit('setCurrentChatId', { chatId: undefined })
+ commit('setCurrentChatFetcher', { fetcher: undefined })
+ },
+ readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
+ dispatch('resetChatNewMessageCount')
+ commit('readChat', { id })
+ rootState.api.backendInteractor.readChat({ id, lastReadId })
+ },
+ deleteChatMessage ({ rootState, commit }, value) {
+ rootState.api.backendInteractor.deleteChatMessage(value)
+ commit('deleteChatMessage', { commit, ...value })
+ },
+ resetChats ({ commit, dispatch }) {
+ dispatch('clearCurrentChat')
+ commit('resetChats', { commit })
+ },
+ clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
+ commit('clearOpenedChats', { commit })
+ }
+ },
+ mutations: {
+ setChatListFetcher (state, { commit, fetcher }) {
+ const prevFetcher = state.chatListFetcher
+ if (prevFetcher) {
+ clearInterval(prevFetcher)
+ }
+ state.chatListFetcher = fetcher && fetcher()
+ },
+ setCurrentChatFetcher (state, { fetcher }) {
+ const prevFetcher = state.fetcher
+ if (prevFetcher) {
+ clearInterval(prevFetcher)
+ }
+ state.fetcher = fetcher && fetcher()
+ },
+ addOpenedChat (state, { _dispatch, chat }) {
+ state.currentChatId = chat.id
+ Vue.set(state.openedChats, chat.id, chat)
+
+ if (!state.openedChatMessageServices[chat.id]) {
+ Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id))
+ }
+ },
+ setCurrentChatId (state, { chatId }) {
+ state.currentChatId = chatId
+ },
+ addNewChats (state, { chats, newChatMessageSideEffects }) {
+ chats.forEach((updatedChat) => {
+ const chat = getChatById(state, updatedChat.id)
+
+ if (chat) {
+ const isNewMessage = (chat.lastMessage && chat.lastMessage.id) !== (updatedChat.lastMessage && updatedChat.lastMessage.id)
+ chat.lastMessage = updatedChat.lastMessage
+ chat.unread = updatedChat.unread
+ if (isNewMessage && chat.unread) {
+ newChatMessageSideEffects(updatedChat)
+ }
+ } else {
+ state.chatList.data.push(updatedChat)
+ Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+ }
+ })
+ },
+ updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
+ const chat = getChatById(state, updatedChat.id)
+ if (chat) {
+ chat.lastMessage = updatedChat.lastMessage
+ chat.unread = updatedChat.unread
+ chat.updated_at = updatedChat.updated_at
+ }
+ if (!chat) { state.chatList.data.unshift(updatedChat) }
+ Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+ },
+ deleteChat (state, { _dispatch, id, _rootGetters }) {
+ state.chats.data = state.chats.data.filter(conversation =>
+ conversation.last_status.id !== id
+ )
+ state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id)
+ },
+ resetChats (state, { commit }) {
+ state.chatList = emptyChatList()
+ state.currentChatId = null
+ commit('setChatListFetcher', { fetcher: undefined })
+ for (const chatId in state.openedChats) {
+ chatService.clear(state.openedChatMessageServices[chatId])
+ Vue.delete(state.openedChats, chatId)
+ Vue.delete(state.openedChatMessageServices, chatId)
+ }
+ },
+ setChatsLoading (state, { value }) {
+ state.chats.loading = value
+ },
+ addChatMessages (state, { commit, chatId, messages }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) })
+ commit('refreshLastMessage', { chatId })
+ }
+ },
+ refreshLastMessage (state, { chatId }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ const chat = getChatById(state, chatId)
+ if (chat) {
+ chat.lastMessage = chatMessageService.lastMessage
+ if (chatMessageService.lastMessage) {
+ chat.updated_at = chatMessageService.lastMessage.created_at
+ }
+ }
+ }
+ },
+ deleteChatMessage (state, { commit, chatId, messageId }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ chatService.deleteMessage(chatMessageService, messageId)
+ commit('refreshLastMessage', { chatId })
+ }
+ },
+ resetChatNewMessageCount (state, _value) {
+ const chatMessageService = state.openedChatMessageServices[state.currentChatId]
+ chatService.resetNewMessageCount(chatMessageService)
+ },
+ // Used when a connection loss occurs
+ clearOpenedChats (state) {
+ const currentChatId = state.currentChatId
+ for (const chatId in state.openedChats) {
+ if (currentChatId !== chatId) {
+ chatService.clear(state.openedChatMessageServices[chatId])
+ Vue.delete(state.openedChats, chatId)
+ Vue.delete(state.openedChatMessageServices, chatId)
+ }
+ }
+ },
+ readChat (state, { id }) {
+ const chat = getChatById(state, id)
+ if (chat) {
+ chat.unread = 0
+ }
+ }
+ }
+}
+
+export default chats
diff --git a/src/modules/config.js b/src/modules/config.js
index 47b24d77..409d77a4 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -31,9 +31,7 @@ export const defaultState = {
preloadImage: true,
loopVideo: true,
loopVideoSilentOnly: true,
- autoLoad: true,
streaming: false,
- hoverPreview: true,
emojiReactionsOnTimeline: true,
autohideFloatingPostButton: false,
pauseOnUnfocused: true,
@@ -46,7 +44,8 @@ export const defaultState = {
repeats: true,
moves: true,
emojiReactions: false,
- followRequest: true
+ followRequest: true,
+ chatMention: true
},
webPushNotifications: false,
muteWords: [],
diff --git a/src/modules/instance.js b/src/modules/instance.js
index ec5f4e54..3fe3bbf3 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -15,6 +15,8 @@ const defaultState = {
// Stuff from static/config.json
alwaysShowSubjectInput: true,
+ defaultAvatar: '/images/avi.png',
+ defaultBanner: '/images/banner.png',
background: '/static/aurora_borealis.jpg',
collapseMessageWithSubject: false,
disableChat: false,
@@ -53,6 +55,7 @@ const defaultState = {
// Feature-set, apparently, not everything here is reported...
chatAvailable: false,
+ pleromaChatMessagesAvailable: false,
gopherAvailable: false,
mediaProxyAvailable: false,
suggestionsEnabled: false,
diff --git a/src/modules/interface.js b/src/modules/interface.js
index eeebd65e..d6db32fd 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -3,6 +3,7 @@ import { set, delete as del } from 'vue'
const defaultState = {
settingsModalState: 'hidden',
settingsModalLoaded: false,
+ settingsModalTargetTab: null,
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
@@ -14,7 +15,10 @@ const defaultState = {
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
)
},
- mobileLayout: false
+ mobileLayout: false,
+ globalNotices: [],
+ layoutHeight: 0,
+ lastTimeline: null
}
const interfaceMod = {
@@ -58,6 +62,21 @@ const interfaceMod = {
if (!state.settingsModalLoaded) {
state.settingsModalLoaded = true
}
+ },
+ setSettingsModalTargetTab (state, value) {
+ state.settingsModalTargetTab = value
+ },
+ pushGlobalNotice (state, notice) {
+ state.globalNotices.push(notice)
+ },
+ removeGlobalNotice (state, notice) {
+ state.globalNotices = state.globalNotices.filter(n => n !== notice)
+ },
+ setLayoutHeight (state, value) {
+ state.layoutHeight = value
+ },
+ setLastTimeline (state, value) {
+ state.lastTimeline = value
}
},
actions: {
@@ -81,6 +100,41 @@ const interfaceMod = {
},
togglePeekSettingsModal ({ commit }) {
commit('togglePeekSettingsModal')
+ },
+ clearSettingsModalTargetTab ({ commit }) {
+ commit('setSettingsModalTargetTab', null)
+ },
+ openSettingsModalTab ({ commit }, value) {
+ commit('setSettingsModalTargetTab', value)
+ commit('openSettingsModal')
+ },
+ pushGlobalNotice (
+ { commit, dispatch },
+ {
+ messageKey,
+ messageArgs = {},
+ level = 'error',
+ timeout = 0
+ }) {
+ const notice = {
+ messageKey,
+ messageArgs,
+ level
+ }
+ if (timeout) {
+ setTimeout(() => dispatch('removeGlobalNotice', notice), timeout)
+ }
+ commit('pushGlobalNotice', notice)
+ return notice
+ },
+ removeGlobalNotice ({ commit }, notice) {
+ commit('removeGlobalNotice', notice)
+ },
+ setLayoutHeight ({ commit }, value) {
+ commit('setLayoutHeight', value)
+ },
+ setLastTimeline ({ commit }, value) {
+ commit('setLastTimeline', value)
}
}
}
diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js
index a24b408d..721c25e6 100644
--- a/src/modules/media_viewer.js
+++ b/src/modules/media_viewer.js
@@ -22,7 +22,7 @@ const mediaViewer = {
setMedia ({ commit }, attachments) {
const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype)
- return type === 'image' || type === 'video'
+ return type === 'image' || type === 'video' || type === 'audio'
})
commit('setMedia', media)
},
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 073b15f1..e108b2a7 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -13,9 +13,8 @@ import {
omitBy
} from 'lodash'
import { set } from 'vue'
-import { isStatusNotification, prepareNotificationObject } from '../services/notification_utils/notification_utils.js'
+import { isStatusNotification, maybeShowNotification } from '../services/notification_utils/notification_utils.js'
import apiService from '../services/api/api.service.js'
-import { muteWordHits } from '../services/status_parser/status_parser.js'
const emptyTl = (userId = 0) => ({
statuses: [],
@@ -62,7 +61,8 @@ export const defaultState = () => ({
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
- dms: emptyTl()
+ dms: emptyTl(),
+ bookmarks: emptyTl()
}
})
@@ -76,17 +76,6 @@ export const prepareStatus = (status) => {
return status
}
-const visibleNotificationTypes = (rootState) => {
- return [
- rootState.config.notificationVisibility.likes && 'like',
- rootState.config.notificationVisibility.mentions && 'mention',
- rootState.config.notificationVisibility.repeats && 'repeat',
- rootState.config.notificationVisibility.follows && 'follow',
- rootState.config.notificationVisibility.moves && 'move',
- rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reactions'
- ].filter(_ => _)
-}
-
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
@@ -163,8 +152,7 @@ const removeStatusFromGlobalStorage = (state, status) => {
}
}
-const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {},
- noIdUpdate = false, userId }) => {
+const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
// Sanity check
if (!isArray(statuses)) {
return false
@@ -173,8 +161,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
- const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0
- const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0
+ // Mismatch between API pagination and our internal minId/maxId tracking systems:
+ // pagination.maxId is the oldest of the returned statuses when fetching older,
+ // and pagination.minId is the newest when fetching newer. The names come directly
+ // from the arguments they're supposed to be passed as for the next fetch.
+ const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
+ const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
+
const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
@@ -315,12 +308,12 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
})
// Keep the visible statuses sorted
- if (timeline) {
+ if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
-const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters }) => {
+const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters, newNotificationSideEffects }) => {
each(notifications, (notification) => {
if (isStatusNotification(notification.type)) {
notification.action = addStatusToGlobalStorage(state, notification.action).item
@@ -343,27 +336,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
state.notifications.data.push(notification)
state.notifications.idStore[notification.id] = notification
- if ('Notification' in window && window.Notification.permission === 'granted') {
- const notifObj = prepareNotificationObject(notification, rootGetters.i18n)
-
- const reasonsToMuteNotif = (
- notification.seen ||
- state.notifications.desktopNotificationSilence ||
- !visibleNotificationTypes.includes(notification.type) ||
- (
- notification.type === 'mention' && status && (
- status.muted ||
- muteWordHits(status, rootGetters.mergedConfig.muteWords).length === 0
- )
- )
- )
- if (!reasonsToMuteNotif) {
- let desktopNotification = new window.Notification(notifObj.title, notifObj)
- // Chrome is known for not closing notifications automatically
- // according to MDN, anyway.
- setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
- }
- }
+ newNotificationSideEffects(notification)
} else if (notification.seen) {
state.notifications.idStore[notification.id].seen = true
}
@@ -463,9 +436,17 @@ export const mutations = {
newStatus.rebloggedBy.push(user)
}
},
+ setBookmarked (state, { status, value }) {
+ const newStatus = state.allStatusesObject[status.id]
+ newStatus.bookmarked = value
+ },
+ setBookmarkedConfirm (state, { status }) {
+ const newStatus = state.allStatusesObject[status.id]
+ newStatus.bookmarked = status.bookmarked
+ },
setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
- newStatus.deleted = true
+ if (newStatus) newStatus.deleted = true
},
setManyDeleted (state, condition) {
Object.values(state.allStatusesObject).forEach(status => {
@@ -508,6 +489,9 @@ export const mutations = {
dismissNotification (state, { id }) {
state.notifications.data = state.notifications.data.filter(n => n.id !== id)
},
+ dismissNotifications (state, { finder }) {
+ state.notifications.data = state.notifications.data.filter(n => finder)
+ },
updateNotification (state, { id, updater }) {
const notification = find(state.notifications.data, n => n.id === id)
notification && updater(notification)
@@ -515,6 +499,11 @@ export const mutations = {
queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
+ queueFlushAll (state) {
+ Object.keys(state.timelines).forEach((timeline) => {
+ state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
+ })
+ },
addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
@@ -585,11 +574,16 @@ export const mutations = {
const statuses = {
state: defaultState(),
actions: {
- addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) {
- commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId })
+ addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
+ commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
},
- addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) {
- commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters })
+ addNewNotifications (store, { notifications, older }) {
+ const { commit, dispatch, rootGetters } = store
+
+ const newNotificationSideEffects = (notification) => {
+ maybeShowNotification(store, notification)
+ }
+ commit('addNewNotifications', { dispatch, notifications, older, rootGetters, newNotificationSideEffects })
},
setError ({ rootState, commit }, { value }) {
commit('setError', { value })
@@ -661,9 +655,26 @@ const statuses = {
rootState.api.backendInteractor.unretweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser }))
},
+ bookmark ({ rootState, commit }, status) {
+ commit('setBookmarked', { status, value: true })
+ rootState.api.backendInteractor.bookmarkStatus({ id: status.id })
+ .then(status => {
+ commit('setBookmarkedConfirm', { status })
+ })
+ },
+ unbookmark ({ rootState, commit }, status) {
+ commit('setBookmarked', { status, value: false })
+ rootState.api.backendInteractor.unbookmarkStatus({ id: status.id })
+ .then(status => {
+ commit('setBookmarkedConfirm', { status })
+ })
+ },
queueFlush ({ rootState, commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
+ queueFlushAll ({ rootState, commit }) {
+ commit('queueFlushAll')
+ },
markNotificationsAsSeen ({ rootState, commit }) {
commit('markNotificationsAsSeen')
apiService.markNotificationsAsSeen({
diff --git a/src/modules/users.js b/src/modules/users.js
index 68d02931..9245db5c 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -266,6 +266,11 @@ const users = {
mutations,
getters,
actions: {
+ fetchUserIfMissing (store, id) {
+ if (!store.getters.findUser(id)) {
+ store.dispatch('fetchUser', id)
+ }
+ },
fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => {
@@ -493,6 +498,8 @@ const users = {
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
+ store.dispatch('resetChats')
+ store.dispatch('setLastTimeline', 'public-timeline')
})
},
loginUser (store, accessToken) {
@@ -532,6 +539,9 @@ const users = {
// Start fetching notifications
store.dispatch('startFetchingNotifications')
+
+ // Start fetching chats
+ store.dispatch('startFetchingChats')
}
if (store.getters.mergedConfig.useStreamingApi) {
@@ -539,6 +549,7 @@ const users = {
console.error('Failed initializing MastoAPI Streaming socket', error)
startPolling()
}).then(() => {
+ store.dispatch('fetchChats', { latest: true })
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
})
} else {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index dfffc291..da519001 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
@@ -50,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
+const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
@@ -58,6 +59,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
+const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
+const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@@ -78,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
+const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
+const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
+const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
+const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
+const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const oldfetch = window.fetch
@@ -138,20 +146,11 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json())
}
-const updateAvatar = ({ credentials, avatar }) => {
+const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
const form = new FormData()
- form.append('avatar', avatar)
- return fetch(MASTODON_PROFILE_UPDATE_URL, {
- headers: authHeaders(credentials),
- method: 'PATCH',
- body: form
- }).then((data) => data.json())
- .then((data) => parseUser(data))
-}
-
-const updateBg = ({ credentials, background }) => {
- const form = new FormData()
- form.append('pleroma_background_image', background)
+ if (avatar !== null) form.append('avatar', avatar)
+ if (banner !== null) form.append('header', banner)
+ if (background !== null) form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
@@ -161,17 +160,6 @@ const updateBg = ({ credentials, background }) => {
.then((data) => parseUser(data))
}
-const updateBanner = ({ credentials, banner }) => {
- const form = new FormData()
- form.append('header', banner)
- return fetch(MASTODON_PROFILE_UPDATE_URL, {
- headers: authHeaders(credentials),
- method: 'PATCH',
- body: form
- }).then((data) => data.json())
- .then((data) => parseUser(data))
-}
-
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
@@ -498,7 +486,8 @@ const fetchTimeline = ({
until = false,
userId = false,
tag = false,
- withMuted = false
+ withMuted = false,
+ replyVisibility = 'all'
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
@@ -509,7 +498,8 @@ const fetchTimeline = ({
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
- tag: MASTODON_TAG_TIMELINE_URL
+ tag: MASTODON_TAG_TIMELINE_URL,
+ bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
}
const isNotifications = timeline === 'notifications'
const params = []
@@ -538,9 +528,12 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
- if (timeline !== 'favorites') {
+ if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted])
}
+ if (replyVisibility !== 'all') {
+ params.push(['reply_visibility', replyVisibility])
+ }
params.push(['limit', 20])
@@ -548,16 +541,20 @@ const fetchTimeline = ({
url += `?${queryString}`
let status = ''
let statusText = ''
+ let pagination = {}
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
status = data.status
statusText = data.statusText
+ pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
+ flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
+ })
return data
})
.then((data) => data.json())
.then((data) => {
if (!data.error) {
- return data.map(isNotifications ? parseNotification : parseStatus)
+ return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else {
data.status = status
data.statusText = statusText
@@ -608,6 +605,22 @@ const unretweet = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
+const bookmarkStatus = ({ id, credentials }) => {
+ return promisedRequest({
+ url: MASTODON_BOOKMARK_STATUS_URL(id),
+ headers: authHeaders(credentials),
+ method: 'POST'
+ })
+}
+
+const unbookmarkStatus = ({ id, credentials }) => {
+ return promisedRequest({
+ url: MASTODON_UNBOOKMARK_STATUS_URL(id),
+ headers: authHeaders(credentials),
+ method: 'POST'
+ })
+}
+
const postStatus = ({
credentials,
status,
@@ -617,7 +630,9 @@ const postStatus = ({
poll,
mediaIds = [],
inReplyToStatusId,
- contentType
+ contentType,
+ preview,
+ idempotencyKey
}) => {
const form = new FormData()
const pollOptions = poll.options || []
@@ -647,20 +662,22 @@ const postStatus = ({
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
+ if (preview) {
+ form.append('preview', 'true')
+ }
+
+ let postHeaders = authHeaders(credentials)
+ if (idempotencyKey) {
+ postHeaders['idempotency-key'] = idempotencyKey
+ }
return fetch(MASTODON_POST_STATUS_URL, {
body: form,
method: 'POST',
- headers: authHeaders(credentials)
+ headers: postHeaders
})
.then((response) => {
- if (response.ok) {
- return response.json()
- } else {
- return {
- error: response
- }
- }
+ return response.json()
})
.then((data) => data.error ? data : parseStatus(data))
}
@@ -682,6 +699,17 @@ const uploadMedia = ({ formData, credentials }) => {
.then((data) => parseAttachment(data))
}
+const setMediaDescription = ({ id, description, credentials }) => {
+ return promisedRequest({
+ url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
+ method: 'PUT',
+ headers: authHeaders(credentials),
+ payload: {
+ description
+ }
+ }).then((data) => parseAttachment(data))
+}
+
const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
@@ -1050,6 +1078,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
'filters_changed'
])
+const PLEROMA_STREAMING_EVENTS = new Set([
+ 'pleroma:chat_update'
+])
+
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
@@ -1106,7 +1138,7 @@ export const handleMastoWS = (wsEvent) => {
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
- if (MASTODON_STREAMING_EVENTS.has(event)) {
+ if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
@@ -1116,6 +1148,8 @@ export const handleMastoWS = (wsEvent) => {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
+ } else if (event === 'pleroma:chat_update') {
+ return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
@@ -1123,6 +1157,81 @@ export const handleMastoWS = (wsEvent) => {
}
}
+export const WSConnectionStatus = Object.freeze({
+ 'JOINED': 1,
+ 'CLOSED': 2,
+ 'ERROR': 3
+})
+
+const chats = ({ credentials }) => {
+ return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+ .then((data) => {
+ return { chats: data.map(parseChat).filter(c => c) }
+ })
+}
+
+const getOrCreateChat = ({ accountId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_URL(accountId),
+ method: 'POST',
+ credentials
+ })
+}
+
+const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
+ let url = PLEROMA_CHAT_MESSAGES_URL(id)
+ const args = [
+ maxId && `max_id=${maxId}`,
+ sinceId && `since_id=${sinceId}`,
+ limit && `limit=${limit}`
+ ].filter(_ => _).join('&')
+
+ url = url + (args ? '?' + args : '')
+
+ return promisedRequest({
+ url,
+ method: 'GET',
+ credentials
+ })
+}
+
+const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
+ const payload = {
+ 'content': content
+ }
+
+ if (mediaId) {
+ payload['media_id'] = mediaId
+ }
+
+ return promisedRequest({
+ url: PLEROMA_CHAT_MESSAGES_URL(id),
+ method: 'POST',
+ payload: payload,
+ credentials
+ })
+}
+
+const readChat = ({ id, lastReadId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_READ_URL(id),
+ method: 'POST',
+ payload: {
+ 'last_read_id': lastReadId
+ },
+ credentials
+ })
+}
+
+const deleteChatMessage = ({ chatId, messageId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
+ method: 'DELETE',
+ credentials
+ })
+}
+
const apiService = {
verifyCredentials,
fetchTimeline,
@@ -1146,9 +1255,12 @@ const apiService = {
unfavorite,
retweet,
unretweet,
+ bookmarkStatus,
+ unbookmarkStatus,
postStatus,
deleteStatus,
uploadMedia,
+ setMediaDescription,
fetchMutes,
muteUser,
unmuteUser,
@@ -1166,10 +1278,8 @@ const apiService = {
deactivateUser,
register,
getCaptcha,
- updateAvatar,
- updateBg,
+ updateProfileImages,
updateProfile,
- updateBanner,
importBlocks,
importFollows,
deleteAccount,
@@ -1200,7 +1310,13 @@ const apiService = {
fetchKnownDomains,
fetchDomainMutes,
muteDomain,
- unmuteDomain
+ unmuteDomain,
+ chats,
+ getOrCreateChat,
+ chatMessages,
+ sendChatMessage,
+ readChat,
+ deleteChatMessage
}
export default apiService
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index e1c32860..45e6bd0e 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.startFetching({ store, credentials })
},
- fetchAndUpdateNotifications ({ store }) {
- return notificationsFetcher.fetchAndUpdate({ store, credentials })
- },
-
startFetchingFollowRequests ({ store }) {
return followRequestFetcher.startFetching({ store, credentials })
},
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
new file mode 100644
index 00000000..b60a889b
--- /dev/null
+++ b/src/services/chat_service/chat_service.js
@@ -0,0 +1,151 @@
+import _ from 'lodash'
+
+const empty = (chatId) => {
+ return {
+ idIndex: {},
+ messages: [],
+ newMessageCount: 0,
+ lastSeenTimestamp: 0,
+ chatId: chatId,
+ minId: undefined,
+ lastMessage: undefined
+ }
+}
+
+const clear = (storage) => {
+ storage.idIndex = {}
+ storage.messages.splice(0, storage.messages.length)
+ storage.newMessageCount = 0
+ storage.lastSeenTimestamp = 0
+ storage.minId = undefined
+ storage.lastMessage = undefined
+}
+
+const deleteMessage = (storage, messageId) => {
+ if (!storage) { return }
+ storage.messages = storage.messages.filter(m => m.id !== messageId)
+ delete storage.idIndex[messageId]
+
+ if (storage.lastMessage && (storage.lastMessage.id === messageId)) {
+ storage.lastMessage = _.maxBy(storage.messages, 'id')
+ }
+
+ if (storage.minId === messageId) {
+ const firstMessage = _.minBy(storage.messages, 'id')
+ storage.minId = firstMessage.id
+ }
+}
+
+const add = (storage, { messages: newMessages }) => {
+ if (!storage) { return }
+ for (let i = 0; i < newMessages.length; i++) {
+ const message = newMessages[i]
+
+ // sanity check
+ if (message.chat_id !== storage.chatId) { return }
+
+ if (!storage.minId || message.id < storage.minId) {
+ storage.minId = message.id
+ }
+
+ if (!storage.lastMessage || message.id > storage.lastMessage.id) {
+ storage.lastMessage = message
+ }
+
+ if (!storage.idIndex[message.id]) {
+ if (storage.lastSeenTimestamp < message.created_at) {
+ storage.newMessageCount++
+ }
+ storage.messages.push(message)
+ storage.idIndex[message.id] = message
+ }
+ }
+}
+
+const resetNewMessageCount = (storage) => {
+ if (!storage) { return }
+ storage.newMessageCount = 0
+ storage.lastSeenTimestamp = new Date()
+}
+
+// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
+const getView = (storage) => {
+ if (!storage) { return [] }
+
+ const result = []
+ const messages = _.sortBy(storage.messages, ['id', 'desc'])
+ const firstMessage = messages[0]
+ let previousMessage = messages[messages.length - 1]
+ let currentMessageChainId
+
+ if (firstMessage) {
+ const date = new Date(firstMessage.created_at)
+ date.setHours(0, 0, 0, 0)
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+ }
+
+ let afterDate = false
+
+ for (let i = 0; i < messages.length; i++) {
+ const message = messages[i]
+ const nextMessage = messages[i + 1]
+
+ const date = new Date(message.created_at)
+ date.setHours(0, 0, 0, 0)
+
+ // insert date separator and start a new message chain
+ if (previousMessage && previousMessage.date < date) {
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+
+ previousMessage['isTail'] = true
+ currentMessageChainId = undefined
+ afterDate = true
+ }
+
+ const object = {
+ type: 'message',
+ data: message,
+ date,
+ id: message.id,
+ messageChainId: currentMessageChainId
+ }
+
+ // end a message chian
+ if ((nextMessage && nextMessage.account_id) !== message.account_id) {
+ object['isTail'] = true
+ currentMessageChainId = undefined
+ }
+
+ // start a new message chain
+ if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) {
+ currentMessageChainId = _.uniqueId()
+ object['isHead'] = true
+ object['messageChainId'] = currentMessageChainId
+ }
+
+ result.push(object)
+ previousMessage = object
+ afterDate = false
+ }
+
+ return result
+}
+
+const ChatService = {
+ add,
+ empty,
+ getView,
+ deleteMessage,
+ resetNewMessageCount,
+ clear
+}
+
+export default ChatService
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
new file mode 100644
index 00000000..ab898ced
--- /dev/null
+++ b/src/services/chat_utils/chat_utils.js
@@ -0,0 +1,19 @@
+import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
+
+export const maybeShowChatNotification = (store, chat) => {
+ if (!chat.lastMessage) return
+ if (store.rootState.chats.currentChatId === chat.id && !document.hidden) return
+
+ const opts = {
+ tag: chat.lastMessage.id,
+ title: chat.account.name,
+ icon: chat.account.profile_image_url,
+ body: chat.lastMessage.content
+ }
+
+ if (chat.lastMessage.attachment && chat.lastMessage.attachment.type === 'image') {
+ opts.image = chat.lastMessage.attachment.preview_url
+ }
+
+ showDesktopNotification(store.rootState, opts)
+}
diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js
index df83d03d..8a6eba7e 100644
--- a/src/services/completion/completion.js
+++ b/src/services/completion/completion.js
@@ -5,7 +5,7 @@ export const replaceWord = (str, toReplace, replacement) => {
}
export const wordAtPosition = (str, pos) => {
- const words = splitIntoWords(str)
+ const words = splitByWhitespaceBoundary(str)
const wordsWithPosition = addPositionToWords(words)
return find(wordsWithPosition, ({ start, end }) => start <= pos && end > pos)
@@ -34,36 +34,36 @@ export const addPositionToWords = (words) => {
}, [])
}
-export const splitIntoWords = (str) => {
- // Split at word boundaries
- const regex = /\b/
- const triggers = /[@#:]+$/
-
- let split = str.split(regex)
-
- // Add trailing @ and # to the following word.
- const words = reduce(split, (result, word) => {
- if (result.length > 0) {
- let previous = result.pop()
- const matches = previous.match(triggers)
- if (matches) {
- previous = previous.replace(triggers, '')
- word = matches[0] + word
- }
- result.push(previous)
+export const splitByWhitespaceBoundary = (str) => {
+ let result = []
+ let currentWord = ''
+ for (let i = 0; i < str.length; i++) {
+ const currentChar = str[i]
+ // Starting a new word
+ if (!currentWord) {
+ currentWord = currentChar
+ continue
}
- result.push(word)
-
- return result
- }, [])
-
- return words
+ // current character is whitespace while word isn't, or vice versa:
+ // add our current word to results, start over the current word.
+ if (!!currentChar.trim() !== !!currentWord.trim()) {
+ result.push(currentWord)
+ currentWord = currentChar
+ continue
+ }
+ currentWord += currentChar
+ }
+ // Add the last word we were working on
+ if (currentWord) {
+ result.push(currentWord)
+ }
+ return result
}
const completion = {
wordAtPosition,
addPositionToWords,
- splitIntoWords,
+ splitByWhitespaceBoundary,
replaceWord
}
diff --git a/src/services/desktop_notification_utils/desktop_notification_utils.js b/src/services/desktop_notification_utils/desktop_notification_utils.js
new file mode 100644
index 00000000..b84a1f75
--- /dev/null
+++ b/src/services/desktop_notification_utils/desktop_notification_utils.js
@@ -0,0 +1,9 @@
+export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
+ if (!('Notification' in window && window.Notification.permission === 'granted')) return
+ if (rootState.statuses.notifications.desktopNotificationSilence) { return }
+
+ const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
+ // Chrome is known for not closing notifications automatically
+ // according to MDN, anyway.
+ setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
+}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 3bdb92f3..1884478a 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,4 +1,5 @@
import escape from 'escape-html'
+import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => {
@@ -78,6 +79,7 @@ export const parseUser = (data) => {
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
+ output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
@@ -182,6 +184,7 @@ export const parseUser = (data) => {
output.deactivated = data.pleroma.deactivated
output.notification_settings = data.pleroma.notification_settings
+ output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
@@ -206,6 +209,7 @@ export const parseAttachment = (data) => {
}
output.url = data.url
+ output.large_thumb_url = data.preview_url
output.description = data.description
return output
@@ -232,6 +236,8 @@ export const parseStatus = (data) => {
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
+ output.bookmarked = data.bookmarked
+
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
@@ -248,6 +254,7 @@ export const parseStatus = (data) => {
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
+ output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
} else {
output.text = data.content
output.summary = data.spoiler_text
@@ -368,7 +375,7 @@ export const parseNotification = (data) => {
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
- output.from_profile = parseUser(data.from_profile)
+ output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
@@ -381,3 +388,47 @@ const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
}
+
+export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
+ const flakeId = opts.flakeId
+ const parsedLinkHeader = parseLinkHeader(linkHeader)
+ if (!parsedLinkHeader) return
+ const maxId = parsedLinkHeader.next.max_id
+ const minId = parsedLinkHeader.prev.min_id
+
+ return {
+ maxId: flakeId ? maxId : parseInt(maxId, 10),
+ minId: flakeId ? minId : parseInt(minId, 10)
+ }
+}
+
+export const parseChat = (chat) => {
+ const output = {}
+ output.id = chat.id
+ output.account = parseUser(chat.account)
+ output.unread = chat.unread
+ output.lastMessage = parseChatMessage(chat.last_message)
+ output.updated_at = new Date(chat.updated_at)
+ return output
+}
+
+export const parseChatMessage = (message) => {
+ if (!message) { return }
+ if (message.isNormalized) { return message }
+ const output = message
+ output.id = message.id
+ output.created_at = new Date(message.created_at)
+ output.chat_id = message.chat_id
+ if (message.content) {
+ output.content = addEmojis(message.content, message.emojis)
+ } else {
+ output.content = ''
+ }
+ if (message.attachment) {
+ output.attachments = [parseAttachment(message.attachment)]
+ } else {
+ output.attachments = []
+ }
+ output.isNormalized = true
+ return output
+}
diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js
index 786740b7..93fac9bc 100644
--- a/src/services/follow_request_fetcher/follow_request_fetcher.service.js
+++ b/src/services/follow_request_fetcher/follow_request_fetcher.service.js
@@ -4,6 +4,7 @@ const fetchAndUpdate = ({ store, credentials }) => {
return apiService.fetchFollowRequests({ credentials })
.then((requests) => {
store.commit('setFollowRequests', requests)
+ store.commit('addNewUsers', requests)
}, () => {})
.catch(() => {})
}
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 5cc19215..d912d19f 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,16 +1,22 @@
import { filter, sortBy, includes } from 'lodash'
+import { muteWordHits } from '../status_parser/status_parser.js'
+import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
export const notificationsFromStore = store => store.state.statuses.notifications.data
-export const visibleTypes = store => ([
- store.state.config.notificationVisibility.likes && 'like',
- store.state.config.notificationVisibility.mentions && 'mention',
- store.state.config.notificationVisibility.repeats && 'repeat',
- store.state.config.notificationVisibility.follows && 'follow',
- store.state.config.notificationVisibility.followRequest && 'follow_request',
- store.state.config.notificationVisibility.moves && 'move',
- store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
-].filter(_ => _))
+export const visibleTypes = store => {
+ const rootState = store.rootState || store.state
+
+ return ([
+ rootState.config.notificationVisibility.likes && 'like',
+ rootState.config.notificationVisibility.mentions && 'mention',
+ rootState.config.notificationVisibility.repeats && 'repeat',
+ rootState.config.notificationVisibility.follows && 'follow',
+ rootState.config.notificationVisibility.followRequest && 'follow_request',
+ rootState.config.notificationVisibility.moves && 'move',
+ rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
+ ].filter(_ => _))
+}
const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction']
@@ -32,6 +38,22 @@ const sortById = (a, b) => {
}
}
+const isMutedNotification = (store, notification) => {
+ if (!notification.status) return
+ return notification.status.muted || muteWordHits(notification.status, store.rootGetters.mergedConfig.muteWords).length > 0
+}
+
+export const maybeShowNotification = (store, notification) => {
+ const rootState = store.rootState || store.state
+
+ if (notification.seen) return
+ if (!visibleTypes(store).includes(notification.type)) return
+ if (notification.type === 'mention' && isMutedNotification(store, notification)) return
+
+ const notificationObject = prepareNotificationObject(notification, store.rootGetters.i18n)
+ showDesktopNotification(rootState, notificationObject)
+}
+
export const filteredNotificationsFromStore = (store, types) => {
// map is just to clone the array since sort mutates it and it causes some issues
let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 64499a1b..80be02ca 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
}
const result = fetchNotifications({ store, args, older })
- // load unread notifications repeatedly to provide consistency between browser tabs
+ // If there's any unread notifications, try fetch notifications since
+ // the newest read notification to check if any of the unread notifs
+ // have changed their 'seen' state (marked as read in another session), so
+ // we can update the state in this session to mark them as read as well.
+ // The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
- if (readNotifsIds.length) {
+ const numUnseenNotifs = notifications.length - readNotifsIds.length
+ if (numUnseenNotifs > 0 && readNotifsIds.length > 0) {
args['since'] = Math.max(...readNotifsIds)
fetchNotifications({ store, args, older })
}
-
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args)
- .then((notifications) => {
+ .then(({ data: notifications }) => {
update({ store, notifications, older })
return notifications
}, () => store.dispatch('setNotificationsError', { value: true }))
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index 9e904d3a..f09196aa 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -1,7 +1,19 @@
import { map } from 'lodash'
import apiService from '../api/api.service.js'
-const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
+const postStatus = ({
+ store,
+ status,
+ spoilerText,
+ visibility,
+ sensitive,
+ poll,
+ media = [],
+ inReplyToStatusId = undefined,
+ contentType = 'text/plain',
+ preview = false,
+ idempotencyKey = ''
+}) => {
const mediaIds = map(media, 'id')
return apiService.postStatus({
@@ -13,9 +25,12 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
mediaIds,
inReplyToStatusId,
contentType,
- poll })
+ poll,
+ preview,
+ idempotencyKey
+ })
.then((data) => {
- if (!data.error) {
+ if (!data.error && !preview) {
store.dispatch('addNewStatuses', {
statuses: [data],
timeline: 'friends',
@@ -34,13 +49,18 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials
-
return apiService.uploadMedia({ credentials, formData })
}
+const setMediaDescription = ({ store, id, description }) => {
+ const credentials = store.state.users.currentUser.credentials
+ return apiService.setMediaDescription({ credentials, id, description })
+}
+
const statusPosterService = {
postStatus,
- uploadMedia
+ uploadMedia,
+ setMediaDescription
}
export default statusPosterService
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index fbdcf562..07425abd 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -106,7 +106,8 @@ export const generateRadii = (input) => {
avatar: 5,
avatarAlt: 50,
tooltip: 2,
- attachment: 5
+ attachment: 5,
+ chatMessage: inputRadii.panel
})
return {
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index b577cfab..7ed85797 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -23,7 +23,9 @@ export const LAYERS = {
inputTopBar: 'topBar',
alert: 'bg',
alertPanel: 'panel',
- poll: 'bg'
+ poll: 'bg',
+ chatBg: 'underlay',
+ chatMessage: 'chatBg'
}
/* By default opacity slots have 1 as default opacity
@@ -34,7 +36,8 @@ export const DEFAULT_OPACITY = {
alert: 0.5,
input: 0.5,
faint: 0.5,
- underlay: 0.15
+ underlay: 0.15,
+ alertPopup: 0.95
}
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@@ -627,11 +630,97 @@ export const SLOT_INHERITANCE = {
textColor: true
},
+ alertPopupError: {
+ depends: ['alertError'],
+ opacity: 'alertPopup'
+ },
+ alertPopupErrorText: {
+ depends: ['alertErrorText'],
+ layer: 'popover',
+ variant: 'alertPopupError',
+ textColor: true
+ },
+
+ alertPopupWarning: {
+ depends: ['alertWarning'],
+ opacity: 'alertPopup'
+ },
+ alertPopupWarningText: {
+ depends: ['alertWarningText'],
+ layer: 'popover',
+ variant: 'alertPopupWarning',
+ textColor: true
+ },
+
+ alertPopupNeutral: {
+ depends: ['alertNeutral'],
+ opacity: 'alertPopup'
+ },
+ alertPopupNeutralText: {
+ depends: ['alertNeutralText'],
+ layer: 'popover',
+ variant: 'alertPopupNeutral',
+ textColor: true
+ },
+
badgeNotification: '--cRed',
badgeNotificationText: {
depends: ['text', 'badgeNotification'],
layer: 'badge',
variant: 'badgeNotification',
textColor: 'bw'
+ },
+
+ chatBg: {
+ depends: ['bg']
+ },
+
+ chatMessageIncomingBg: {
+ depends: ['chatBg']
+ },
+
+ chatMessageIncomingText: {
+ depends: ['text'],
+ layer: 'chatMessage',
+ variant: 'chatMessageIncomingBg',
+ textColor: true
+ },
+
+ chatMessageIncomingLink: {
+ depends: ['link'],
+ layer: 'chatMessage',
+ variant: 'chatMessageIncomingBg',
+ textColor: 'preserve'
+ },
+
+ chatMessageIncomingBorder: {
+ depends: ['border'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
+ },
+
+ chatMessageOutgoingBg: {
+ depends: ['chatMessageIncomingBg'],
+ color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb
+ },
+
+ chatMessageOutgoingText: {
+ depends: ['text'],
+ layer: 'chatMessage',
+ variant: 'chatMessageOutgoingBg',
+ textColor: true
+ },
+
+ chatMessageOutgoingLink: {
+ depends: ['link'],
+ layer: 'chatMessage',
+ variant: 'chatMessageOutgoingBg',
+ textColor: 'preserve'
+ },
+
+ chatMessageOutgoingBorder: {
+ depends: ['chatMessageOutgoingBg'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
}
}
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index dd87e3cf..b619f810 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -128,14 +128,17 @@ export const topoSort = (
while (unprocessed.length > 0) {
step(unprocessed.pop())
}
- return output.sort((a, b) => {
+
+ // The index thing is to make sorting stable on browsers
+ // where Array.sort() isn't stable
+ return output.map((data, index) => ({ data, index })).sort(({ data: a, index: ai }, { data: b, index: bi }) => {
const depsA = getDeps(a, inheritance).length
const depsB = getDeps(b, inheritance).length
- if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return 0
+ if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
- })
+ }).map(({ data }) => data)
}
const expandSlotValue = (value) => {
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index c6b28ad5..d0cddf84 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js'
-const update = ({ store, statuses, timeline, showImmediately, userId }) => {
+const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => {
const ccTimeline = camelCase(timeline)
store.dispatch('setError', { value: false })
@@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
timeline: ccTimeline,
userId,
statuses,
- showImmediately
+ showImmediately,
+ pagination
})
}
@@ -30,7 +31,8 @@ const fetchAndUpdate = ({
const rootState = store.rootState || store.state
const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
- const hideMutedPosts = getters.mergedConfig.hideMutedPosts
+ const { hideMutedPosts, replyVisibility } = getters.mergedConfig
+ const loggedIn = !!rootState.users.currentUser
if (older) {
args['until'] = until || timelineData.minId
@@ -41,20 +43,25 @@ const fetchAndUpdate = ({
args['userId'] = userId
args['tag'] = tag
args['withMuted'] = !hideMutedPosts
+ if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
+ args['replyVisibility'] = replyVisibility
+ }
const numStatusesBeforeFetch = timelineData.statuses.length
return apiService.fetchTimeline(args)
- .then((statuses) => {
- if (statuses.error) {
- store.dispatch('setErrorData', { value: statuses })
+ .then(response => {
+ if (response.error) {
+ store.dispatch('setErrorData', { value: response })
return
}
+
+ const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
}
- update({ store, statuses, timeline, showImmediately, userId })
- return statuses
+ update({ store, statuses, timeline, showImmediately, userId, pagination })
+ return { statuses, pagination }
}, () => store.dispatch('setError', { value: true }))
}
diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js
index faff6cb9..909088db 100644
--- a/src/services/window_utils/window_utils.js
+++ b/src/services/window_utils/window_utils.js
@@ -3,3 +3,8 @@ export const windowWidth = () =>
window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth
+
+export const windowHeight = () =>
+ window.innerHeight ||
+ document.documentElement.clientHeight ||
+ document.body.clientHeight