diff options
Diffstat (limited to 'src')
371 files changed, 17307 insertions, 5182 deletions
@@ -1,28 +1,29 @@ import UserPanel from './components/user_panel/user_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue' -import Notifications from './components/notifications/notifications.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import ShoutPanel from './components/shout_panel/shout_panel.vue' -import SettingsModal from './components/settings_modal/settings_modal.vue' import MediaModal from './components/media_modal/media_modal.vue' import SideDrawer from './components/side_drawer/side_drawer.vue' import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue' import DesktopNav from './components/desktop_nav/desktop_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' +import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' +import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' import { windowWidth, windowHeight } from './services/window_utils/window_utils' import { mapGetters } from 'vuex' +import { defineAsyncComponent } from 'vue' export default { name: 'app', components: { UserPanel, NavPanel, - Notifications, + Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')), InstanceSpecificPanel, FeaturesPanel, WhoToFollowPanel, @@ -32,9 +33,12 @@ export default { MobilePostStatusButton, MobileNav, DesktopNav, - SettingsModal, + SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')), + UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')), UserReportingModal, PostStatusModal, + EditStatusModal, + StatusHistoryModal, GlobalNoticeList }, data: () => ({ @@ -46,10 +50,27 @@ export default { this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) window.addEventListener('resize', this.updateMobileState) }, - destroyed () { + unmounted () { window.removeEventListener('resize', this.updateMobileState) }, computed: { + classes () { + return [ + { + '-reverse': this.reverseLayout, + '-no-sticky-headers': this.noSticky, + '-has-new-post-button': this.newPostButtonShown + }, + '-' + this.layoutType + ] + }, + navClasses () { + const { navbarColumnStretch } = this.$store.getters.mergedConfig + return [ + '-' + this.layoutType, + ...(navbarColumnStretch ? ['-column-stretch'] : []) + ] + }, currentUser () { return this.$store.state.users.currentUser }, userBackground () { return this.currentUser.background_image }, instanceBackground () { @@ -65,38 +86,50 @@ export default { } } }, - shout () { return this.$store.state.shout.channel.state === 'joined' }, + shout () { return this.$store.state.shout.joined }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel && !this.$store.getters.mergedConfig.hideISP && this.$store.state.instance.instanceSpecificPanelContent }, + isChats () { + return this.$route.name === 'chat' || this.$route.name === 'chats' + }, + isListEdit () { + return this.$route.name === 'lists-edit' + }, + newPostButtonShown () { + if (this.isChats) return false + if (this.isListEdit) return false + return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' + }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, + editingAvailable () { return this.$store.state.instance.editingAvailable }, shoutboxPosition () { - return this.$store.getters.mergedConfig.showNewPostButton || false + return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false }, hideShoutbox () { return this.$store.getters.mergedConfig.hideShoutbox }, - isMobileLayout () { return this.$store.state.interface.mobileLayout }, + layoutType () { return this.$store.state.interface.layoutType }, privateMode () { return this.$store.state.instance.private }, - sidebarAlign () { - return { - 'order': this.$store.getters.mergedConfig.sidebarRight ? 99 : 0 + reverseLayout () { + const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig + if (this.layoutType !== 'wide') { + return reverseSetting + } else { + return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting } }, + noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, + showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars }, ...mapGetters(['mergedConfig']) }, methods: { 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) + this.$store.dispatch('setLayoutWidth', windowWidth()) + this.$store.dispatch('setLayoutHeight', windowHeight()) } } } diff --git a/src/App.scss b/src/App.scss index bc027f4f..75b2667c 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,77 +1,374 @@ +// stylelint-disable rscss/class-format @import './_variables.scss'; -#app { - min-height: 100vh; - max-width: 100%; - overflow: hidden; -} - -.app-bg-wrapper { - position: fixed; - z-index: -1; - height: 100%; - left: 0; - right: -20px; - background-size: cover; - background-repeat: no-repeat; - background-color: var(--wallpaper); - background-image: var(--body-background-image); - background-position: 50% 50px; -} - -i[class^='icon-'] { - user-select: none; -} - -h4 { - margin: 0; -} - -#content { - box-sizing: border-box; - padding-top: 60px; - margin: auto; - min-height: 100vh; - max-width: 980px; - align-content: flex-start; -} - -.underlay { - background-color: rgba(0,0,0,0.15); - background-color: var(--underlay, rgba(0,0,0,0.15)); -} - -.text-center { - text-align: center; +:root { + --navbar-height: 3.5rem; + --post-line-height: 1.4; + // Z-Index stuff + --ZI_media_modal: 9000; + --ZI_modals_popovers: 8500; + --ZI_modals: 8000; + --ZI_navbar_popovers: 7500; + --ZI_navbar: 7000; + --ZI_popovers: 6000; } html { font-size: 14px; + // overflow-x: clip causes my browser's tab to crash with SIGILL lul } body { - overscroll-behavior-y: none; font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); margin: 0; color: $fallback--text; color: var(--text, $fallback--text); - max-width: 100vw; - overflow-x: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + overscroll-behavior-y: none; + overflow-x: clip; + overflow-y: scroll; &.hidden { display: none; } } +// ## Custom scrollbars +// Only show custom scrollbars on devices which +// have a cursor/pointer to operate them +@media (any-pointer: fine) { + * { + scrollbar-color: var(--btn) transparent; + + &::-webkit-scrollbar { + background: transparent; + } + + &::-webkit-scrollbar-button, + &::-webkit-scrollbar-thumb { + background-color: var(--btn); + box-shadow: var(--buttonShadow); + border-radius: var(--btnRadius); + } + + // horizontal/vertical/increment/decrement are webkit-specific stuff + // that indicates whether we're affecting vertical scrollbar, increase button etc + // stylelint-disable selector-pseudo-class-no-unknown + &::-webkit-scrollbar-button { + --___bgPadding: 2px; + + color: var(--btnText); + background-repeat: no-repeat, no-repeat; + + &:horizontal { + background-size: 50% calc(50% - var(--___bgPadding)), 50% calc(50% - var(--___bgPadding)); + + &:increment { + background-image: + linear-gradient(45deg, var(--btnText) 50%, transparent 51%), + linear-gradient(-45deg, transparent 50%, var(--btnText) 51%); + background-position: top var(--___bgPadding) left 50%, right 50% bottom var(--___bgPadding); + } + + &:decrement { + background-image: + linear-gradient(45deg, transparent 50%, var(--btnText) 51%), + linear-gradient(-45deg, var(--btnText) 50%, transparent 51%); + background-position: bottom var(--___bgPadding) right 50%, left 50% top var(--___bgPadding); + } + } + + &:vertical { + background-size: calc(50% - var(--___bgPadding)) 50%, calc(50% - var(--___bgPadding)) 50%; + + &:increment { + background-image: + linear-gradient(-45deg, transparent 50%, var(--btnText) 51%), + linear-gradient(45deg, transparent 50%, var(--btnText) 51%); + background-position: right var(--___bgPadding) top 50%, left var(--___bgPadding) top 50%; + } + + &:decrement { + background-image: + linear-gradient(-45deg, var(--btnText) 50%, transparent 51%), + linear-gradient(45deg, var(--btnText) 50%, transparent 51%); + background-position: left var(--___bgPadding) top 50%, right var(--___bgPadding) top 50%; + } + } + } + // stylelint-enable selector-pseudo-class-no-unknown + } + // Body should have background to scrollbar otherwise it will use white (body color?) + html { + scrollbar-color: var(--selectedMenu) var(--wallpaper); + background: var(--wallpaper); + } +} + a { text-decoration: none; color: $fallback--link; color: var(--link, $fallback--link); } +h4 { + margin: 0; +} + +.iconLetter { + display: inline-block; + text-align: center; + font-weight: 1000; +} + +i[class*=icon-], +.svg-inline--fa, +.iconLetter { + color: $fallback--icon; + color: var(--icon, $fallback--icon); +} + +.button-unstyled:hover, +a:hover { + > i[class*=icon-], + > .svg-inline--fa, + > .iconLetter { + color: var(--text); + } +} + +nav { + z-index: var(--ZI_navbar); + color: var(--topBarText); + background-color: $fallback--fg; + background-color: var(--topBar, $fallback--fg); + color: $fallback--faint; + color: var(--faint, $fallback--faint); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.6); + box-shadow: var(--topBarShadow); + box-sizing: border-box; + height: var(--navbar-height); + position: fixed; +} + +#sidebar { + grid-area: sidebar; +} + +#modal { + position: absolute; + z-index: var(--ZI_modals); +} + +.column.-scrollable { + top: var(--navbar-height); + position: sticky; +} + +#main-scroller { + grid-area: content; + position: relative; +} + +#notifs-column { + grid-area: notifs; +} + +.app-bg-wrapper { + position: fixed; + height: 100%; + top: var(--navbar-height); + z-index: -1000; + left: 0; + right: -20px; + background-size: cover; + background-repeat: no-repeat; + background-color: var(--wallpaper); + background-image: var(--body-background-image); + background-position: 50%; +} + +.underlay { + grid-column-start: 1; + grid-column-end: span 3; + grid-row-start: 1; + grid-row-end: 1; + pointer-events: none; + background-color: rgba(0, 0, 0, 0.15); + background-color: var(--underlay, rgba(0, 0, 0, 0.15)); + z-index: -1000; +} + +.app-layout { + --miniColumn: 25rem; + --maxiColumn: 45rem; + --columnGap: 1em; + --status-margin: 0.75em; + --effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn))); + --effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn))); + --effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn))); + + position: relative; + display: grid; + grid-template-columns: + var(--effectiveSidebarColumnWidth) + var(--effectiveContentColumnWidth); + grid-template-areas: "sidebar content"; + grid-template-rows: 1fr; + box-sizing: border-box; + margin: 0 auto; + align-content: flex-start; + flex-wrap: wrap; + justify-content: center; + min-height: 100vh; + overflow-x: clip; + + .column { + --___columnMargin: var(--columnGap); + + display: grid; + grid-template-columns: 100%; + box-sizing: border-box; + grid-row-start: 1; + grid-row-end: 1; + margin: 0 calc(var(--___columnMargin) / 2); + padding: calc(var(--___columnMargin)) 0; + row-gap: var(--___columnMargin); + align-content: start; + + &:not(.-scrollable) { + margin-top: var(--navbar-height); + } + + &:hover { + z-index: 2; + } + + &.-full-height { + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + + &.-scrollable { + --___paddingIncrease: calc(var(--columnGap) / 2); + + position: sticky; + top: var(--navbar-height); + max-height: calc(100vh - var(--navbar-height)); + overflow-y: auto; + overflow-x: hidden; + margin-left: calc(var(--___paddingIncrease) * -1); + padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2); + + // On browsers that don't support hiding scrollbars we enforce "show scrolbars" mode + // might implement old style of hiding scrollbars later if there's demand + @supports (scrollbar-width: none) or (-webkit-text-fill-color: initial) { + &:not(.-show-scrollbar) { + scrollbar-width: none; + margin-right: calc(var(--___paddingIncrease) * -1); + padding-right: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2); + + &::-webkit-scrollbar { + display: block; + width: 0; + } + } + } + + .panel-heading.-sticky { + top: calc(var(--columnGap) / -1); + } + } + } + + &.-has-new-post-button { + .column { + padding-bottom: 10rem; + } + } + + &.-no-sticky-headers { + .column { + .panel-heading.-sticky { + position: relative; + top: 0; + } + } + } + + .column-inner { + display: grid; + grid-template-columns: 100%; + box-sizing: border-box; + row-gap: 1em; + align-content: start; + } + + &.-reverse:not(.-wide):not(.-mobile) { + grid-template-columns: + var(--effectiveContentColumnWidth) + var(--effectiveSidebarColumnWidth); + grid-template-areas: "content sidebar"; + } + + &.-wide { + grid-template-columns: + var(--effectiveSidebarColumnWidth) + var(--effectiveContentColumnWidth) + var(--effectiveNotifsColumnWidth); + grid-template-areas: "sidebar content notifs"; + + &.-reverse { + grid-template-columns: + var(--effectiveNotifsColumnWidth) + var(--effectiveContentColumnWidth) + var(--effectiveSidebarColumnWidth); + grid-template-areas: "notifs content sidebar"; + } + } + + &.-mobile { + grid-template-columns: 100vw; + grid-template-areas: "content"; + padding: 0; + + .column { + margin-left: 0; + margin-right: 0; + padding-top: 0; + margin-top: var(--navbar-height); + margin-bottom: 0; + } + + .panel-heading, + .panel-heading::after, + .panel-heading::before, + .panel, + .panel::after { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + #sidebar, + #notifs-column { + display: none; + } + } + + &.-normal { + #notifs-column { + display: none; + } + } +} + +.text-center { + text-align: center; +} + .button-default { user-select: none; color: $fallback--text; @@ -84,7 +381,7 @@ a { cursor: pointer; box-shadow: $fallback--buttonShadow; box-shadow: var(--buttonShadow); - font-size: 14px; + font-size: 1em; font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); @@ -103,12 +400,12 @@ a { } &:hover { - box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3); + box-shadow: 0 0 4px rgba(255, 255, 255, 0.3); box-shadow: var(--buttonHoverShadow); } &:active { - box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset; + box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset; box-shadow: var(--buttonPressedShadow); color: $fallback--text; color: var(--btnPressedText, $fallback--text); @@ -141,7 +438,7 @@ a { color: var(--btnToggledText, $fallback--text); background-color: $fallback--fg; background-color: var(--btnToggled, $fallback--fg); - box-shadow: 0px 0px 4px 0px rgba(255, 255, 255, 0.3), 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset; + box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset; box-shadow: var(--buttonPressedShadow); svg, @@ -191,8 +488,9 @@ a { } } -input, textarea, .input { - +input, +textarea, +.input { &.unstyled { border-radius: 0; background: none; @@ -200,10 +498,12 @@ input, textarea, .input { height: unset; } + --_padding: 0.5em; + border: none; border-radius: $fallback--inputRadius; border-radius: var(--inputRadius, $fallback--inputRadius); - box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.2) inset, 0px -1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px 0px 2px 0px rgba(0, 0, 0, 1) inset; + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset, 0 0 2px 0 rgba(0, 0, 0, 1) inset; box-shadow: var(--inputShadow); background-color: $fallback--fg; background-color: var(--input, $fallback--fg); @@ -211,17 +511,18 @@ input, textarea, .input { color: var(--inputText, $fallback--lightText); font-family: sans-serif; font-family: var(--inputFont, sans-serif); - font-size: 14px; + font-size: 1em; margin: 0; box-sizing: border-box; display: inline-block; position: relative; - height: 28px; - line-height: 16px; + line-height: 2; hyphens: none; - padding: 8px .5em; + padding: 0 var(--_padding); - &:disabled, &[disabled=disabled], &.disabled { + &:disabled, + &[disabled=disabled], + &.disabled { cursor: not-allowed; opacity: 0.5; } @@ -236,18 +537,21 @@ input, textarea, .input { &[type=radio] { display: none; + &:checked + label::before { - box-shadow: 0px 0px 2px black inset, 0px 0px 0px 4px $fallback--fg inset; - box-shadow: var(--inputShadow), 0px 0px 0px 4px var(--fg, $fallback--fg) inset; + box-shadow: 0 0 2px black inset, 0 0 0 4px $fallback--fg inset; + box-shadow: var(--inputShadow), 0 0 0 4px var(--fg, $fallback--fg) inset; background-color: var(--accent, $fallback--link); } + &:disabled { &, & + label, & + label::before { - opacity: .5; + opacity: 0.5; } } + + label::before { flex-shrink: 0; display: inline-block; @@ -256,35 +560,37 @@ input, textarea, .input { width: 1.1em; height: 1.1em; border-radius: 100%; // Radio buttons should always be circle - box-shadow: 0px 0px 2px black inset; + box-shadow: 0 0 2px black inset; box-shadow: var(--inputShadow); - margin-right: .5em; + margin-right: 0.5em; background-color: $fallback--fg; background-color: var(--input, $fallback--fg); vertical-align: top; text-align: center; - line-height: 1.1em; + line-height: 1.1; font-size: 1.1em; box-sizing: border-box; color: transparent; overflow: hidden; - box-sizing: border-box; } } &[type=checkbox] { display: none; + &:checked + label::before { color: $fallback--text; color: var(--inputText, $fallback--text); } + &:disabled { &, & + label, & + label::before { - opacity: .5; + opacity: 0.5; } } + + label::before { flex-shrink: 0; display: inline-block; @@ -294,19 +600,18 @@ input, textarea, .input { height: 1.1em; border-radius: $fallback--checkboxRadius; border-radius: var(--checkboxRadius, $fallback--checkboxRadius); - box-shadow: 0px 0px 2px black inset; + box-shadow: 0 0 2px black inset; box-shadow: var(--inputShadow); - margin-right: .5em; + margin-right: 0.5em; background-color: $fallback--fg; background-color: var(--input, $fallback--fg); vertical-align: top; text-align: center; - line-height: 1.1em; + line-height: 1.1; font-size: 1.1em; box-sizing: border-box; color: transparent; overflow: hidden; - box-sizing: border-box; } } @@ -315,6 +620,12 @@ input, textarea, .input { } } +// Textareas should have stock line-height + vertical padding instead of huge line-height +textarea { + padding: var(--_padding); + line-height: var(--post-line-height); +} + option { color: $fallback--text; color: var(--text, $fallback--text); @@ -324,6 +635,7 @@ option { .hide-number-spinner { -moz-appearance: textfield; + &[type=number]::-webkit-inner-spin-button, &[type=number]::-webkit-outer-spin-button { opacity: 0; @@ -331,11 +643,6 @@ option { } } -i[class*=icon-], .svg-inline--fa { - color: $fallback--icon; - color: var(--icon, $fallback--icon); -} - .btn-block { display: block; width: 100%; @@ -362,273 +669,16 @@ i[class*=icon-], .svg-inline--fa { } } -.container { - display: flex; - flex-wrap: wrap; - margin: 0; - padding: 0 10px 0 10px; -} - -.auto-size { - flex: 1 -} - -main-router { - flex: 1; -} - -.status.compact { - color: rgba(0, 0, 0, 0.42); - font-weight: 300; - - p { - margin: 0; - font-size: 0.8em - } -} - -/* Panel */ - -.panel { - display: flex; - position: relative; - - flex-direction: column; - margin: 0.5em; - - background-color: $fallback--bg; - background-color: var(--bg, $fallback--bg); - - &::after, & { - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); - } - - &::after { - content: ''; - position: absolute; - - top: 0; - bottom: 0; - left: 0; - right: 0; - - pointer-events: none; - - box-shadow: 1px 1px 4px rgba(0,0,0,.6); - box-shadow: var(--panelShadow); - } -} - -.panel-body:empty::before { - content: "¯\\_(ã)_/¯"; // Could use words but it'd require translations - display: block; - margin: 1em; - text-align: center; -} - -.panel-heading { - display: flex; - flex: none; - border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; - border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; - background-size: cover; - padding: .6em .6em; - text-align: left; - line-height: 28px; - color: var(--panelText); - background-color: $fallback--fg; - background-color: var(--panel, $fallback--fg); - align-items: baseline; - box-shadow: var(--panelHeaderShadow); - - .title { - flex: 1 0 auto; - font-size: 1.3em; - } - - .faint { - background-color: transparent; - color: $fallback--faint; - color: var(--panelFaint, $fallback--faint); - } - - .faint-link { - color: $fallback--faint; - color: var(--faintLink, $fallback--faint); - } - - .alert { - white-space: nowrap; - text-overflow: ellipsis; - overflow-x: hidden; - } - - .button-default, - .alert { - // height: 100%; - line-height: 21px; - min-height: 0; - box-sizing: border-box; - margin: 0; - margin-left: .5em; - min-width: 1px; - align-self: stretch; - } - - .button-default { - flex-shrink: 0; - - &, - i[class*=icon-] { - color: $fallback--text; - color: var(--btnPanelText, $fallback--text); - } - - &:active { - background-color: $fallback--fg; - background-color: var(--btnPressedPanel, $fallback--fg); - color: $fallback--text; - color: var(--btnPressedPanelText, $fallback--text); - } - - &:disabled { - color: $fallback--text; - color: var(--btnDisabledPanelText, $fallback--text); - } - - &.toggled { - color: $fallback--text; - color: var(--btnToggledPanelText, $fallback--text); - } - } - - a, - .-link { - color: $fallback--link; - color: var(--panelLink, $fallback--link) - } -} - -.panel-heading.stub { - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); -} - -/* TODO Should remove timeline-footer from here when we refactor panels into - * separate component and utilize slots - */ -.panel-footer, .timeline-footer { - display: flex; - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - flex: none; - padding: 0.6em 0.6em; - text-align: left; - line-height: 28px; - align-items: baseline; - border-width: 1px 0 0 0; - border-style: solid; - border-color: var(--border, $fallback--border); - - .faint { - color: $fallback--faint; - color: var(--panelFaint, $fallback--faint); - } - - a, - .-link { - color: $fallback--link; - color: var(--panelLink, $fallback--link); - } -} - -.panel-body > p { - line-height: 18px; - padding: 1em; - margin: 0; -} - -.container > * { - min-width: 0px; -} +@import './panel.scss'; .fa { color: grey; } -nav { - z-index: 1000; - color: var(--topBarText); - background-color: $fallback--fg; - background-color: var(--topBar, $fallback--fg); - color: $fallback--faint; - color: var(--faint, $fallback--faint); - box-shadow: 0px 0px 4px rgba(0,0,0,.6); - box-shadow: var(--topBarShadow); - box-sizing: border-box; -} - -.fade-enter-active, .fade-leave-active { - transition: opacity .2s -} -.fade-enter, .fade-leave-active { - opacity: 0 -} - -.main { - flex-basis: 50%; - flex-grow: 1; - flex-shrink: 1; -} - -.sidebar-bounds { - flex: 0; - flex-basis: 35%; -} - -.sidebar-flexer { - flex: 1; - flex-basis: 345px; - width: 365px; -} - .mobile-shown { display: none; } -@media all and (min-width: 800px) { - body { - overflow-y: scroll; - } - - .sidebar-bounds { - overflow: hidden; - max-height: 100vh; - width: 345px; - position: fixed; - margin-top: -10px; - - .sidebar-scroller { - height: 96vh; - width: 365px; - padding-top: 10px; - padding-right: 50px; - overflow-x: hidden; - overflow-y: scroll; - } - - .sidebar { - width: 345px; - } - } - .sidebar-flexer { - max-height: 96vh; - flex-shrink: 0; - flex-grow: 0; - } -} - .badge { box-sizing: border-box; display: inline-block; @@ -656,12 +706,10 @@ nav { } .alert { - margin: 0.35em; - padding: 0.25em; + margin: 0 0.35em; + padding: 0 0.25em; border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - min-height: 28px; - line-height: 28px; &.error { background-color: $fallback--alertError; @@ -712,7 +760,7 @@ nav { } .visibility-notice { - padding: .5em; + padding: 0.5em; border: 1px solid $fallback--faint; border: 1px solid var(--faint, $fallback--faint); border-radius: $fallback--inputRadius; @@ -727,87 +775,27 @@ nav { position: absolute; top: 0; right: 0; - padding: .5em; + padding: 0.5em; color: inherit; } } .fa-scale-110 { - &.svg-inline--fa { + &.svg-inline--fa, + &.iconLetter { font-size: 1.1em; } } .fa-old-padding { - &.svg-inline--fa { + &.iconLetter, + &.svg-inline--fa, &-layer { padding: 0 0.3em; } } -@keyframes shakeError { - 0% { - transform: translateX(0); - } - 15% { - transform: translateX(0.375rem); - } - 30% { - transform: translateX(-0.375rem); - } - 45% { - transform: translateX(0.375rem); - } - 60% { - transform: translateX(-0.375rem); - } - 75% { - transform: translateX(0.375rem); - } - 90% { - transform: translateX(-0.375rem); - } - 100% { - transform: translateX(0); - } -} - -@media all and (max-width: 800px) { - .mobile-hidden { - display: none; - } - - .panel-switcher { - display: flex; - } - - .container { - padding: 0; - } - - .panel { - margin: 0.5em 0 0.5em 0; - } - - .menu-button { - display: block; - margin-right: 0.8em; - } - - .main { - margin-bottom: 7em; - } -} - -.setting-list, -.option-list{ - list-style-type: none; - padding-left: 2em; - li { - margin-bottom: 0.5em; - } - .suboptions { - margin-top: 0.3em - } +.veryfaint { + opacity: 0.25; } .login-hint { @@ -819,18 +807,26 @@ nav { a { display: inline-block; - padding: 1em 0px; + padding: 1em 0; width: 100%; } } .btn.button-default { - min-height: 28px; + min-height: 2em; } -.animate-spin { - animation: spin 2s infinite linear; - display: inline-block; +.new-status-notification { + position: relative; + font-size: 1.1em; + z-index: 1; + flex: 1; +} + +@media all and (max-width: 800px) { + .mobile-hidden { + display: none; + } } @keyframes spin { @@ -843,49 +839,47 @@ nav { } } -.new-status-notification { - position: relative; - font-size: 1.1em; - z-index: 1; - flex: 1; -} +@keyframes shakeError { + 0% { + transform: translateX(0); + } -.chat-layout { - // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). - overflow: hidden; - height: 100%; + 15% { + transform: translateX(0.375rem); + } - // Get rid of scrollbar on body as scrolling happens on different element - body { - overflow: hidden; + 30% { + transform: translateX(-0.375rem); } - // 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%; - } + 45% { + transform: translateX(0.375rem); + } - #app { - height: 100%; - overflow: hidden; - min-height: auto; - } + 60% { + transform: translateX(-0.375rem); + } - #app_bg_wrapper { - overflow: hidden; - } + 75% { + transform: translateX(0.375rem); + } - .main { - overflow: hidden; - height: 100%; - } + 90% { + transform: translateX(-0.375rem); + } - #content { - padding-top: 0; - height: 100%; - overflow: visible; - } + 100% { + transform: translateX(0); } } + +// Vue transitions +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s; +} + +.fade-enter-from, +.fade-leave-active { + opacity: 0; +} diff --git a/src/App.vue b/src/App.vue index eb65b548..23a388a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,39 +1,43 @@ <template> <div - id="app" + id="app-loaded" :style="bgStyle" > <div id="app_bg_wrapper" class="app-bg-wrapper" /> - <MobileNav v-if="isMobileLayout" /> - <DesktopNav v-else /> - <div class="app-bg-wrapper app-container-wrapper" /> + <MobileNav v-if="layoutType === 'mobile'" /> + <DesktopNav + v-else + :class="navClasses" + /> + <Notifications v-if="currentUser" /> <div id="content" - class="container underlay" + class="app-layout container" + :class="classes" > + <div class="underlay" /> <div - class="sidebar-flexer mobile-hidden" - :style="sidebarAlign" + id="sidebar" + class="column -scrollable" + :class="{ '-show-scrollbar': showScrollbars }" > - <div class="sidebar-bounds"> - <div class="sidebar-scroller"> - <div class="sidebar"> - <user-panel /> - <div v-if="!isMobileLayout"> - <nav-panel /> - <instance-specific-panel v-if="showInstanceSpecificPanel" /> - <features-panel v-if="!currentUser && showFeaturesPanel" /> - <who-to-follow-panel v-if="currentUser && suggestionsEnabled" /> - <notifications v-if="currentUser" /> - </div> - </div> - </div> - </div> + <user-panel /> + <template v-if="layoutType !== 'mobile'"> + <nav-panel /> + <instance-specific-panel v-if="showInstanceSpecificPanel" /> + <features-panel v-if="!currentUser && showFeaturesPanel" /> + <who-to-follow-panel v-if="currentUser && suggestionsEnabled" /> + <div id="notifs-sidebar" /> + </template> </div> - <div class="main"> + <main + id="main-scroller" + class="column main" + :class="{ '-full-height': isChats || isListEdit }" + > <div v-if="!currentUser" class="login-hint panel panel-default" @@ -46,20 +50,28 @@ </router-link> </div> <router-view /> - </div> - <media-modal /> + </main> + <div + id="notifs-column" + class="column -scrollable" + :class="{ '-show-scrollbar': showScrollbars }" + /> </div> + <MediaModal /> <shout-panel v-if="currentUser && shout && !hideShoutbox" :floating="true" class="floating-shout mobile-hidden" - :class="{ 'left': shoutboxPosition }" + :class="{ '-left': shoutboxPosition }" /> <MobilePostStatusButton /> <UserReportingModal /> <PostStatusModal /> + <EditStatusModal v-if="editingAvailable" /> + <StatusHistoryModal v-if="editingAvailable" /> <SettingsModal /> - <portal-target name="modal" /> + <UpdateNotification /> + <div id="modal" /> <GlobalNoticeList /> </div> </template> diff --git a/src/_mixins.scss b/src/_mixins.scss new file mode 100644 index 00000000..1fed16c3 --- /dev/null +++ b/src/_mixins.scss @@ -0,0 +1,17 @@ +@mixin unfocused-style { + @content; + + &:focus:not(:focus-visible):not(:hover) { + @content; + } +} + +@mixin focused-style { + &:hover, &:focus { + @content; + } + + &:focus-visible { + @content; + } +} diff --git a/src/_variables.scss b/src/_variables.scss index 9004d551..099d3606 100644 --- a/src/_variables.scss +++ b/src/_variables.scss @@ -30,3 +30,5 @@ $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; + +$status-margin: 0.75em; diff --git a/src/assets/pleromatan_apology.png b/src/assets/pleromatan_apology.png Binary files differnew file mode 100644 index 00000000..36ad7aeb --- /dev/null +++ b/src/assets/pleromatan_apology.png diff --git a/src/assets/pleromatan_apology_fox.png b/src/assets/pleromatan_apology_fox.png Binary files differnew file mode 100644 index 00000000..17f87694 --- /dev/null +++ b/src/assets/pleromatan_apology_fox.png diff --git a/src/assets/pleromatan_apology_fox_mask.png b/src/assets/pleromatan_apology_fox_mask.png Binary files differnew file mode 100644 index 00000000..4d1990d5 --- /dev/null +++ b/src/assets/pleromatan_apology_fox_mask.png diff --git a/src/assets/pleromatan_apology_mask.png b/src/assets/pleromatan_apology_mask.png Binary files differnew file mode 100644 index 00000000..18adafff --- /dev/null +++ b/src/assets/pleromatan_apology_mask.png diff --git a/src/boot/after_store.js b/src/boot/after_store.js index cc0c7c5e..7a4672b6 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -1,12 +1,18 @@ -import Vue from 'vue' -import VueRouter from 'vue-router' -import routes from './routes' +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import vClickOutside from 'click-outside-vue3' + +import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' + import App from '../App.vue' -import { windowWidth } from '../services/window_utils/window_utils' +import routes from './routes' +import VBodyScrollLock from 'src/directives/body_scroll_lock' + +import { windowWidth, windowHeight } from '../services/window_utils/window_utils' import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' -import { applyTheme } from '../services/style_setter/style_setter.js' +import { applyTheme, applyConfig } from '../services/style_setter/style_setter.js' import FaviconService from '../services/favicon_service/favicon_service.js' let staticInitialResults = null @@ -115,6 +121,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { copyInstanceOption('nsfwCensorImage') copyInstanceOption('background') copyInstanceOption('hidePostStats') + copyInstanceOption('hideBotIndication') copyInstanceOption('hideUserStats') copyInstanceOption('hideFilteredStatuses') copyInstanceOption('logo') @@ -149,7 +156,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { copyInstanceOption('hideSitename') copyInstanceOption('sidebarRight') - return store.dispatch('setTheme', config['theme']) + return store.dispatch('setTheme', config.theme) } const getTOS = async ({ store }) => { @@ -190,7 +197,7 @@ const getStickers = async ({ store }) => { const stickers = (await Promise.all( Object.entries(values).map(async ([name, path]) => { const resPack = await window.fetch(path + 'pack.json') - var meta = {} + let meta = {} if (resPack.ok) { meta = await resPack.json() } @@ -244,6 +251,7 @@ const getNodeInfo = async ({ store }) => { 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: 'editingAvailable', value: features.includes('editing') }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) @@ -312,6 +320,7 @@ const setConfig = async ({ store }) => { } const checkOAuthToken = async ({ store }) => { + // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { if (store.getters.getUserToken()) { try { @@ -325,8 +334,8 @@ const checkOAuthToken = async ({ store }) => { } const afterStoreSetup = async ({ store, i18n }) => { - const width = windowWidth() - store.dispatch('setMobileLayout', width <= 800) + store.dispatch('setLayoutWidth', windowWidth()) + store.dispatch('setLayoutHeight', windowHeight()) FaviconService.initFaviconService() @@ -352,6 +361,8 @@ const afterStoreSetup = async ({ store, i18n }) => { console.error('Failed to load any theme!') } + applyConfig(store.state.config) + // Now we can try getting the server settings and logging in // Most of these are preloaded into the index.html so blocking is minimized await Promise.all([ @@ -363,28 +374,39 @@ const afterStoreSetup = async ({ store, i18n }) => { // Start fetching things that don't need to block the UI store.dispatch('fetchMutes') + store.dispatch('startFetchingAnnouncements') getTOS({ store }) getStickers({ store }) - const router = new VueRouter({ - mode: 'history', + const router = createRouter({ + history: createWebHistory(), routes: routes(store), scrollBehavior: (to, _from, savedPosition) => { if (to.matched.some(m => m.meta.dontScroll)) { return false } - return savedPosition || { x: 0, y: 0 } + return savedPosition || { left: 0, top: 0 } } }) - /* eslint-disable no-new */ - return new Vue({ - router, - store, - i18n, - el: '#app', - render: h => h(App) - }) + const app = createApp(App) + + app.use(router) + app.use(store) + app.use(i18n) + + app.use(vClickOutside) + app.use(VBodyScrollLock) + + app.component('FAIcon', FontAwesomeIcon) + app.component('FALayers', FontAwesomeLayers) + + // remove after vue 3.3 + app.config.unwrapInjectedRef = true + + app.mount('#app') + + return app } export default afterStoreSetup diff --git a/src/boot/routes.js b/src/boot/routes.js index 1bc1f9f7..2dc900e7 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -20,6 +20,11 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import About from 'components/about/about.vue' import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue' +import Lists from 'components/lists/lists.vue' +import ListsTimeline from 'components/lists_timeline/lists_timeline.vue' +import ListsEdit from 'components/lists_edit/lists_edit.vue' +import NavPanel from 'src/components/nav_panel/nav_panel.vue' +import AnnouncementsPage from 'components/announcements_page/announcements_page.vue' export default (store) => { const validateAuthenticatedRoute = (to, from, next) => { @@ -31,7 +36,8 @@ export default (store) => { } let routes = [ - { name: 'root', + { + name: 'root', path: '/', redirect: _to => { return (store.state.users.currentUser @@ -45,31 +51,40 @@ export default (store) => { { 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([^/@]+)', + { + name: 'remote-user-profile-acct', + path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)', component: RemoteUserResolver, beforeEnter: validateAuthenticatedRoute }, - { name: 'remote-user-profile', + { + name: 'remote-user-profile', path: '/remote-users/:hostname/:username', component: RemoteUserResolver, beforeEnter: validateAuthenticatedRoute }, - { name: 'external-user-profile', path: '/users/:id', component: UserProfile }, + { name: 'external-user-profile', path: '/users/$:id', component: UserProfile }, { name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute }, { name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute }, { name: 'registration', path: '/registration', component: Registration }, { name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true }, { name: 'registration-token', path: '/registration/:token', component: Registration }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, - { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, + { name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute }, { name: 'login', path: '/login', component: AuthForm }, { name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, 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 } + { name: 'announcements', path: '/announcements', component: AnnouncementsPage }, + { name: 'user-profile', path: '/users/:name', component: UserProfile }, + { name: 'legacy-user-profile', path: '/:name', component: UserProfile }, + { name: 'lists', path: '/lists', component: Lists }, + { name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline }, + { name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit }, + { name: 'lists-new', path: '/lists/new', component: ListsEdit }, + { name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute } ] if (store.state.instance.pleromaChatMessagesAvailable) { diff --git a/src/components/about/about.vue b/src/components/about/about.vue index 518f6184..33586c97 100644 --- a/src/components/about/about.vue +++ b/src/components/about/about.vue @@ -1,5 +1,5 @@ <template> - <div class="sidebar"> + <div class="column-inner"> <instance-specific-panel v-if="showInstanceSpecificPanel" /> <staff-panel /> <terms-of-service-panel /> @@ -8,7 +8,7 @@ </div> </template> -<script src="./about.js" ></script> +<script src="./about.js"></script> <style lang="scss"> </style> diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index e53c4f77..c23407f9 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,6 +1,7 @@ import { mapState } from 'vuex' import ProgressButton from '../progress_button/progress_button.vue' import Popover from '../popover/popover.vue' +import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faEllipsisV @@ -19,7 +20,8 @@ const AccountActions = { }, components: { ProgressButton, - Popover + Popover, + UserListMenu }, methods: { showRepeats () { @@ -34,13 +36,16 @@ const AccountActions = { unblockUser () { this.$store.dispatch('unblockUser', this.user.id) }, + removeUserFromFollowers () { + this.$store.dispatch('removeUserFromFollowers', this.user.id) + }, reportUser () { this.$store.dispatch('openUserReportingModal', { userId: this.user.id }) }, openChat () { this.$router.push({ name: 'chat', - params: { recipient_id: this.user.id } + params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id } }) } }, diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index 1e31151c..218aa6b3 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -6,7 +6,7 @@ :bound-to="{ x: 'container' }" remove-padding > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> <template v-if="relationship.following"> <button @@ -28,6 +28,14 @@ class="dropdown-divider" /> </template> + <UserListMenu :user="user" /> + <button + v-if="relationship.followed_by" + class="btn button-default btn-block dropdown-item" + @click="removeUserFromFollowers" + > + {{ $t('user_card.remove_follower') }} + </button> <button v-if="relationship.blocking" class="btn button-default btn-block dropdown-item" @@ -57,7 +65,7 @@ </button> </div> </template> - <template v-slot:trigger> + <template #trigger> <button class="button-unstyled ellipsis-button"> <FAIcon class="icon" @@ -74,10 +82,6 @@ <style lang="scss"> @import '../../_variables.scss'; .AccountActions { - button.dropdown-item { - margin-left: 0; - } - .ellipsis-button { width: 2.5em; margin: -0.5em 0; diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js new file mode 100644 index 00000000..c10c7d90 --- /dev/null +++ b/src/components/announcement/announcement.js @@ -0,0 +1,105 @@ +import { mapState } from 'vuex' +import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' +import RichContent from '../rich_content/rich_content.jsx' +import localeService from '../../services/locale/locale.service.js' + +const Announcement = { + components: { + AnnouncementEditor, + RichContent + }, + data () { + return { + editing: false, + editedAnnouncement: { + content: '', + startsAt: undefined, + endsAt: undefined, + allDay: undefined + }, + editError: '' + } + }, + props: { + announcement: Object + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + content () { + return this.announcement.content + }, + isRead () { + return this.announcement.read + }, + publishedAt () { + const time = this.announcement.published_at + if (!time) { + return + } + + return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + }, + startsAt () { + const time = this.announcement.starts_at + if (!time) { + return + } + + return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + }, + endsAt () { + const time = this.announcement.ends_at + if (!time) { + return + } + + return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + }, + inactive () { + return this.announcement.inactive + } + }, + methods: { + markAsRead () { + if (!this.isRead) { + return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id) + } + }, + deleteAnnouncement () { + return this.$store.dispatch('deleteAnnouncement', this.announcement.id) + }, + formatTimeOrDate (time, locale) { + const d = new Date(time) + return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale) + }, + enterEditMode () { + this.editedAnnouncement.content = this.announcement.pleroma.raw_content + this.editedAnnouncement.startsAt = this.announcement.starts_at + this.editedAnnouncement.endsAt = this.announcement.ends_at + this.editedAnnouncement.allDay = this.announcement.all_day + this.editing = true + }, + submitEdit () { + this.$store.dispatch('editAnnouncement', { + id: this.announcement.id, + ...this.editedAnnouncement + }) + .then(() => { + this.editing = false + }) + .catch(error => { + this.editError = error.error + }) + }, + cancelEdit () { + this.editing = false + }, + clearError () { + this.editError = undefined + } + } +} + +export default Announcement diff --git a/src/components/announcement/announcement.vue b/src/components/announcement/announcement.vue new file mode 100644 index 00000000..5f64232a --- /dev/null +++ b/src/components/announcement/announcement.vue @@ -0,0 +1,136 @@ +<template> + <div class="announcement"> + <div class="heading"> + <h4>{{ $t('announcements.title') }}</h4> + </div> + <div class="body"> + <rich-content + v-if="!editing" + :html="content" + :emoji="announcement.emojis" + :handle-links="true" + /> + <announcement-editor + v-else + :announcement="editedAnnouncement" + /> + </div> + <div class="footer"> + <div + v-if="!editing" + class="times" + > + <span v-if="publishedAt"> + {{ $t('announcements.published_time_display', { time: publishedAt }) }} + </span> + <span v-if="startsAt"> + {{ $t('announcements.start_time_display', { time: startsAt }) }} + </span> + <span v-if="endsAt"> + {{ $t('announcements.end_time_display', { time: endsAt }) }} + </span> + </div> + <div + v-if="!editing" + class="actions" + > + <button + v-if="currentUser" + class="btn button-default" + :class="{ toggled: isRead }" + :disabled="inactive" + :title="inactive ? $t('announcements.inactive_message') : ''" + @click="markAsRead" + > + {{ $t('announcements.mark_as_read_action') }} + </button> + <button + v-if="currentUser && currentUser.role === 'admin'" + class="btn button-default" + @click="enterEditMode" + > + {{ $t('announcements.edit_action') }} + </button> + <button + v-if="currentUser && currentUser.role === 'admin'" + class="btn button-default" + @click="deleteAnnouncement" + > + {{ $t('announcements.delete_action') }} + </button> + </div> + <div + v-else + class="actions" + > + <button + class="btn button-default" + @click="submitEdit" + > + {{ $t('announcements.submit_edit_action') }} + </button> + <button + class="btn button-default" + @click="cancelEdit" + > + {{ $t('announcements.cancel_edit_action') }} + </button> + <div + v-if="editing && editError" + class="alert error" + > + {{ $t('announcements.edit_error', { error }) }} + <button + class="button-unstyled" + @click="clearError" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + :title="$t('announcements.close_error')" + /> + </button> + </div> + </div> + </div> + </div> +</template> + +<script src="./announcement.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.announcement { + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--border, $fallback--border); + border-radius: 0; + padding: var(--status-margin, $status-margin); + + .heading, .body { + margin-bottom: var(--status-margin, $status-margin); + } + + .footer { + display: flex; + flex-direction: column; + .times { + display: flex; + flex-direction: column; + } + } + + .footer .actions { + display: flex; + flex-direction: row; + justify-content: space-evenly; + + .btn { + flex: 1; + margin: 1em; + max-width: 10em; + } + } +} +</style> diff --git a/src/components/announcement_editor/announcement_editor.js b/src/components/announcement_editor/announcement_editor.js new file mode 100644 index 00000000..79a03afe --- /dev/null +++ b/src/components/announcement_editor/announcement_editor.js @@ -0,0 +1,13 @@ +import Checkbox from '../checkbox/checkbox.vue' + +const AnnouncementEditor = { + components: { + Checkbox + }, + props: { + announcement: Object, + disabled: Boolean + } +} + +export default AnnouncementEditor diff --git a/src/components/announcement_editor/announcement_editor.vue b/src/components/announcement_editor/announcement_editor.vue new file mode 100644 index 00000000..0f29f9f7 --- /dev/null +++ b/src/components/announcement_editor/announcement_editor.vue @@ -0,0 +1,60 @@ +<template> + <div class="announcement-editor"> + <textarea + ref="textarea" + v-model="announcement.content" + class="post-textarea" + rows="1" + cols="1" + :placeholder="$t('announcements.post_placeholder')" + :disabled="disabled" + /> + <span class="announcement-metadata"> + <label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label> + <input + id="announcement-start-time" + v-model="announcement.startsAt" + :type="announcement.allDay ? 'date' : 'datetime-local'" + :disabled="disabled" + > + </span> + <span class="announcement-metadata"> + <label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label> + <input + id="announcement-end-time" + v-model="announcement.endsAt" + :type="announcement.allDay ? 'date' : 'datetime-local'" + :disabled="disabled" + > + </span> + <span class="announcement-metadata"> + <Checkbox + id="announcement-all-day" + v-model="announcement.allDay" + :disabled="disabled" + /> + <label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label> + </span> + </div> +</template> + +<script src="./announcement_editor.js"></script> + +<style lang="scss"> +.announcement-editor { + display: flex; + align-items: stretch; + flex-direction: column; + + .announcement-metadata { + margin-top: 0.5em; + } + + .post-textarea { + resize: vertical; + height: 10em; + overflow: none; + box-sizing: content-box; + } +} +</style> diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js new file mode 100644 index 00000000..0bb4892e --- /dev/null +++ b/src/components/announcements_page/announcements_page.js @@ -0,0 +1,55 @@ +import { mapState } from 'vuex' +import Announcement from '../announcement/announcement.vue' +import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' + +const AnnouncementsPage = { + components: { + Announcement, + AnnouncementEditor + }, + data () { + return { + newAnnouncement: { + content: '', + startsAt: undefined, + endsAt: undefined, + allDay: false + }, + posting: false, + error: undefined + } + }, + mounted () { + this.$store.dispatch('fetchAnnouncements') + }, + computed: { + ...mapState({ + currentUser: state => state.users.currentUser + }), + announcements () { + return this.$store.state.announcements.announcements + } + }, + methods: { + postAnnouncement () { + this.posting = true + this.$store.dispatch('postAnnouncement', this.newAnnouncement) + .then(() => { + this.newAnnouncement.content = '' + this.startsAt = undefined + this.endsAt = undefined + }) + .catch(error => { + this.error = error.error + }) + .finally(() => { + this.posting = false + }) + }, + clearError () { + this.error = undefined + } + } +} + +export default AnnouncementsPage diff --git a/src/components/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue new file mode 100644 index 00000000..b1489dec --- /dev/null +++ b/src/components/announcements_page/announcements_page.vue @@ -0,0 +1,79 @@ +<template> + <div class="panel panel-default announcements-page"> + <div class="panel-heading"> + <span> + {{ $t('announcements.page_header') }} + </span> + </div> + <div class="panel-body"> + <section + v-if="currentUser && currentUser.role === 'admin'" + > + <div class="post-form"> + <div class="heading"> + <h4>{{ $t('announcements.post_form_header') }}</h4> + </div> + <div class="body"> + <announcement-editor + :announcement="newAnnouncement" + :disabled="posting" + /> + </div> + <div class="footer"> + <button + class="btn button-default post-button" + :disabled="posting" + @click.prevent="postAnnouncement" + > + {{ $t('announcements.post_action') }} + </button> + <div + v-if="error" + class="alert error" + > + {{ $t('announcements.post_error', { error }) }} + <button + class="button-unstyled" + @click="clearError" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + :title="$t('announcements.close_error')" + /> + </button> + </div> + </div> + </div> + </section> + <section + v-for="announcement in announcements" + :key="announcement.id" + > + <announcement + :announcement="announcement" + /> + </section> + </div> + </div> +</template> + +<script src="./announcements_page.js"></script> + +<style lang="scss"> +@import "../../variables"; + +.announcements-page { + .post-form { + padding: var(--status-margin, $status-margin); + + .heading, .body { + margin-bottom: var(--status-margin, $status-margin); + } + + .post-button { + min-width: 10em; + } + } +} +</style> diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue index b1b59638..26ab5d21 100644 --- a/src/components/async_component_error/async_component_error.vue +++ b/src/components/async_component_error/async_component_error.vue @@ -19,6 +19,7 @@ <script> export default { + emits: ['resetAsyncComponent'], methods: { retry () { this.$emit('resetAsyncComponent') diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 8849f501..5dc50475 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -11,7 +11,12 @@ import { faImage, faVideo, faPlayCircle, - faTimes + faTimes, + faStop, + faSearchPlus, + faTrashAlt, + faPencilAlt, + faAlignRight } from '@fortawesome/free-solid-svg-icons' library.add( @@ -20,27 +25,39 @@ library.add( faImage, faVideo, faPlayCircle, - faTimes + faTimes, + faStop, + faSearchPlus, + faTrashAlt, + faPencilAlt, + faAlignRight ) const Attachment = { props: [ 'attachment', + 'description', + 'hideDescription', 'nsfw', 'size', - 'allowPlay', 'setMedia', - 'naturalSizeLoad' + 'remove', + 'shiftUp', + 'shiftDn', + 'edit' ], data () { return { + localDescription: this.description || this.attachment.description, nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, preloadImage: this.$store.getters.mergedConfig.preloadImage, loading: false, img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), modalOpen: false, - showHidden: false + showHidden: false, + flashLoaded: false, + showDescription: false } }, components: { @@ -49,8 +66,23 @@ const Attachment = { VideoAttachment }, computed: { + classNames () { + return [ + { + '-loading': this.loading, + '-nsfw-placeholder': this.hidden, + '-editable': this.edit !== undefined + }, + '-type-' + this.type, + this.size && '-size-' + this.size, + `-${this.useContainFit ? 'contain' : 'cover'}-fit` + ] + }, usePlaceholder () { - return this.size === 'hide' || this.type === 'unknown' + return this.size === 'hide' + }, + useContainFit () { + return this.$store.getters.mergedConfig.useContainFit }, placeholderName () { if (this.attachment.description === '' || !this.attachment.description) { @@ -74,24 +106,36 @@ const Attachment = { return this.nsfw && this.hideNsfwLocal && !this.showHidden }, isEmpty () { - return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown' - }, - isSmall () { - return this.size === 'small' - }, - fullwidth () { - if (this.size === 'hide') return false - return this.type === 'html' || this.type === 'audio' || this.type === 'unknown' + return (this.type === 'html' && !this.attachment.oembed) }, useModal () { - const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio'] - : this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] + let modalTypes = [] + switch (this.size) { + case 'hide': + case 'small': + modalTypes = ['image', 'video', 'audio', 'flash'] + break + default: + modalTypes = this.mergedConfig.playVideosInModal + ? ['image', 'video', 'flash'] + : ['image'] + break + } return modalTypes.includes(this.type) }, + videoTag () { + return this.useModal ? 'button' : 'span' + }, ...mapGetters(['mergedConfig']) }, + watch: { + 'attachment.description' (newVal) { + this.localDescription = newVal + }, + localDescription (newVal) { + this.onEdit(newVal) + } + }, methods: { linkClicked ({ target }) { if (target.tagName === 'A') { @@ -100,12 +144,37 @@ const Attachment = { }, openModal (event) { if (this.useModal) { - event.stopPropagation() - event.preventDefault() - this.setMedia() - this.$store.dispatch('setCurrent', this.attachment) + this.$emit('setMedia') + this.$store.dispatch('setCurrentMedia', this.attachment) + } else if (this.type === 'unknown') { + window.open(this.attachment.url) } }, + openModalForce (event) { + this.$emit('setMedia') + this.$store.dispatch('setCurrentMedia', this.attachment) + }, + onEdit (event) { + this.edit && this.edit(this.attachment, event) + }, + onRemove () { + this.remove && this.remove(this.attachment) + }, + onShiftUp () { + this.shiftUp && this.shiftUp(this.attachment) + }, + onShiftDn () { + this.shiftDn && this.shiftDn(this.attachment) + }, + stopFlash () { + this.$refs.flash.closePlayer() + }, + setFlashLoaded (event) { + this.flashLoaded = event + }, + toggleDescription () { + this.showDescription = !this.showDescription + }, toggleHidden (event) { if ( (this.mergedConfig.useOneClickNsfw && !this.showHidden) && @@ -132,7 +201,7 @@ const Attachment = { onImageLoad (image) { const width = image.naturalWidth const height = image.naturalHeight - this.naturalSizeLoad && this.naturalSizeLoad({ width, height }) + this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height }) } } } diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss new file mode 100644 index 00000000..b2dea98d --- /dev/null +++ b/src/components/attachment/attachment.scss @@ -0,0 +1,268 @@ +@import '../../_variables.scss'; + +.Attachment { + display: inline-flex; + flex-direction: column; + position: relative; + align-self: flex-start; + line-height: 0; + height: 100%; + border-style: solid; + border-width: 1px; + border-radius: $fallback--attachmentRadius; + border-radius: var(--attachmentRadius, $fallback--attachmentRadius); + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + + .attachment-wrapper { + flex: 1 1 auto; + height: 100%; + position: relative; + overflow: hidden; + } + + .description-container { + flex: 0 1 0; + display: flex; + padding-top: 0.5em; + z-index: 1; + + p { + flex: 1; + text-align: center; + line-height: 1.5; + padding: 0.5em; + margin: 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + &.-static { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding-top: 0; + background: var(--popover); + box-shadow: var(--popupShadow); + } + } + + .description-field { + flex: 1; + min-width: 0; + } + + & .placeholder-container, + & .image-container, + & .audio-container, + & .video-container, + & .flash-container, + & .oembed-container { + display: flex; + justify-content: center; + width: 100%; + height: 100%; + } + + .image-container { + .image { + width: 100%; + height: 100%; + } + } + + & .flash-container, + & .video-container { + & .flash, + & video { + width: 100%; + height: 100%; + object-fit: contain; + align-self: center; + } + } + + .audio-container { + display: flex; + align-items: flex-end; + + audio { + width: 100%; + height: 100%; + } + } + + .placeholder-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 0.5em; + } + + + .play-icon { + position: absolute; + font-size: 64px; + top: calc(50% - 32px); + left: calc(50% - 32px); + color: rgba(255, 255, 255, 0.75); + text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); + + &::before { + margin: 0; + } + } + + .attachment-buttons { + display: flex; + position: absolute; + right: 0; + top: 0; + margin-top: 0.5em; + margin-right: 0.5em; + z-index: 1; + + .attachment-button { + padding: 0; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + text-align: center; + width: 2em; + height: 2em; + margin-left: 0.5em; + font-size: 1.25em; + // TODO: theming? hard to theme with unknown background image color + background: rgba(230, 230, 230, 0.7); + + .svg-inline--fa { + color: rgba(0, 0, 0, 0.6); + } + + &:hover .svg-inline--fa { + color: rgba(0, 0, 0, 0.9); + } + } + } + + .oembed-container { + line-height: 1.2em; + flex: 1 0 100%; + width: 100%; + margin-right: 15px; + display: flex; + + img { + width: 100%; + } + + .image { + flex: 1; + img { + border: 0px; + border-radius: 5px; + height: 100%; + object-fit: cover; + } + } + + .text { + flex: 2; + margin: 8px; + word-break: break-all; + h1 { + font-size: 1rem; + margin: 0px; + } + } + } + + &.-size-small { + .play-icon { + zoom: 0.5; + opacity: 0.7; + } + + .attachment-buttons { + zoom: 0.7; + opacity: 0.5; + } + } + + &.-editable { + padding: 0.5em; + + & .description-container, + & .attachment-buttons { + margin: 0; + } + } + + &.-placeholder { + display: inline-block; + color: $fallback--link; + color: var(--postLink, $fallback--link); + overflow: hidden; + white-space: nowrap; + height: auto; + line-height: 1.5; + + &:not(.-editable) { + border: none; + } + + &.-editable { + display: flex; + flex-direction: row; + align-items: baseline; + + & .description-container, + & .attachment-buttons { + margin: 0; + padding: 0; + position: relative; + } + + .description-container { + flex: 1; + padding-left: 0.5em; + } + + .attachment-buttons { + order: 99; + align-self: center; + } + } + + a { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + svg { + color: inherit; + } + } + + &.-loading { + cursor: progress; + } + + &.-contain-fit { + img, + canvas { + object-fit: contain; + } + } + + &.-cover-fit { + img, + canvas { + object-fit: cover; + } + } +} diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index f80badfd..2a89886d 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,7 +1,8 @@ <template> - <div + <button v-if="usePlaceholder" - :class="{ 'fullwidth': fullwidth }" + class="Attachment -placeholder button-unstyled" + :class="classNames" @click="openModal" > <a @@ -11,318 +12,257 @@ :href="attachment.url" :alt="attachment.description" :title="attachment.description" + @click.prevent > <FAIcon :icon="placeholderIconClass" /> - <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} + <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }} </a> - </div> - <div - v-else - v-show="!isEmpty" - class="attachment" - :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" - > - <a - v-if="hidden" - class="image-attachment" - :href="attachment.url" - :alt="attachment.description" - :title="attachment.description" - @click.prevent.stop="toggleHidden" + <div + v-if="edit || remove" + class="attachment-buttons" > - <img - :key="nsfwImage" - class="nsfw" - :src="nsfwImage" - :class="{'small': isSmall}" + <button + v-if="remove" + class="button-unstyled attachment-button" + @click.prevent="onRemove" > - <FAIcon - v-if="type === 'video'" - class="play-icon" - icon="play-circle" - /> - </a> - <button - v-if="nsfw && hideNsfwLocal && !hidden" - class="button-unstyled hider" - @click.prevent="toggleHidden" + <FAIcon icon="trash-alt" /> + </button> + </div> + <div + v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)" + class="description-container" + :class="{ '-static': !edit }" > - <FAIcon icon="times" /> - </button> - - <a - v-if="type === 'image' && (!hidden || preloadImage)" - class="image-attachment" - :class="{'hidden': hidden && preloadImage }" - :href="attachment.url" - target="_blank" - @click="openModal" + <input + v-if="edit" + v-model="localDescription" + type="text" + class="description-field" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" + > + <p v-else> + {{ localDescription }} + </p> + </div> + </button> + <div + v-else + class="Attachment" + :class="classNames" + > + <div + v-show="!isEmpty" + class="attachment-wrapper" > - <StillImage - class="image" - :referrerpolicy="referrerpolicy" - :mimetype="attachment.mimetype" - :src="attachment.large_thumb_url || attachment.url" - :image-load-handler="onImageLoad" + <a + v-if="hidden" + class="image-container" + :href="attachment.url" :alt="attachment.description" - /> - </a> + :title="attachment.description" + @click.prevent.stop="toggleHidden" + > + <img + :key="nsfwImage" + class="nsfw" + :src="nsfwImage" + > + <FAIcon + v-if="type === 'video'" + class="play-icon" + icon="play-circle" + /> + </a> + <div + v-if="!hidden" + class="attachment-buttons" + > + <button + v-if="type === 'flash' && flashLoaded" + class="button-unstyled attachment-button" + :title="$t('status.attachment_stop_flash')" + @click.prevent="stopFlash" + > + <FAIcon icon="stop" /> + </button> + <button + v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'" + class="button-unstyled attachment-button" + :title="$t('status.show_attachment_description')" + @click.prevent="toggleDescription" + > + <FAIcon icon="align-right" /> + </button> + <button + v-if="!useModal && type !== 'unknown'" + class="button-unstyled attachment-button" + :title="$t('status.show_attachment_in_modal')" + @click.prevent="openModalForce" + > + <FAIcon icon="search-plus" /> + </button> + <button + v-if="nsfw && hideNsfwLocal" + class="button-unstyled attachment-button" + :title="$t('status.hide_attachment')" + @click.prevent="toggleHidden" + > + <FAIcon icon="times" /> + </button> + <button + v-if="shiftUp" + class="button-unstyled attachment-button" + :title="$t('status.move_up')" + @click.prevent="onShiftUp" + > + <FAIcon icon="chevron-left" /> + </button> + <button + v-if="shiftDn" + class="button-unstyled attachment-button" + :title="$t('status.move_down')" + @click.prevent="onShiftDn" + > + <FAIcon icon="chevron-right" /> + </button> + <button + v-if="remove" + class="button-unstyled attachment-button" + :title="$t('status.remove_attachment')" + @click.prevent="onRemove" + > + <FAIcon icon="trash-alt" /> + </button> + </div> - <a - v-if="type === 'video' && !hidden" - class="video-container" - :class="{'small': isSmall}" - :href="allowPlay ? undefined : attachment.url" - @click="openModal" - > - <VideoAttachment - class="video" - :attachment="attachment" - :controls="allowPlay" - @play="$emit('play')" - @pause="$emit('pause')" - /> - <FAIcon - v-if="!allowPlay" - class="play-icon" - icon="play-circle" - /> - </a> + <a + v-if="type === 'image' && (!hidden || preloadImage)" + class="image-container" + :class="{'-hidden': hidden && preloadImage }" + :href="attachment.url" + target="_blank" + @click.stop.prevent="openModal" + > + <StillImage + class="image" + :referrerpolicy="referrerpolicy" + :mimetype="attachment.mimetype" + :src="attachment.large_thumb_url || attachment.url" + :image-load-handler="onImageLoad" + :alt="attachment.description" + /> + </a> + + <a + v-if="type === 'unknown' && !hidden" + class="placeholder-container" + :href="attachment.url" + target="_blank" + > + <FAIcon + size="5x" + :icon="placeholderIconClass" + /> + <p> + {{ localDescription }} + </p> + </a> + + <component + :is="videoTag" + v-if="type === 'video' && !hidden" + class="video-container" + :class="{ 'button-unstyled': 'isModal' }" + :href="attachment.url" + @click.stop.prevent="openModal" + > + <VideoAttachment + class="video" + :attachment="attachment" + :controls="!useModal" + @play="$emit('play')" + @pause="$emit('pause')" + /> + <FAIcon + v-if="useModal" + class="play-icon" + icon="play-circle" + /> + </component> + + <span + v-if="type === 'audio' && !hidden" + class="audio-container" + :href="attachment.url" + @click.stop.prevent="openModal" + > + <audio + v-if="type === 'audio'" + :src="attachment.url" + :alt="attachment.description" + :title="attachment.description" + controls + @play="$emit('play')" + @pause="$emit('pause')" + /> + </span> - <audio - v-if="type === 'audio'" - :src="attachment.url" - :alt="attachment.description" - :title="attachment.description" - controls - @play="$emit('play')" - @pause="$emit('pause')" - /> + <div + v-if="type === 'html' && attachment.oembed" + class="oembed-container" + @click.prevent="linkClicked" + > + <div + v-if="attachment.thumb_url" + class="image" + > + <img :src="attachment.thumb_url"> + </div> + <div class="text"> + <!-- eslint-disable vue/no-v-html --> + <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> + <div v-html="attachment.oembed.oembedHTML" /> + <!-- eslint-enable vue/no-v-html --> + </div> + </div> + <span + v-if="type === 'flash' && !hidden" + class="flash-container" + :href="attachment.url" + @click.stop.prevent="openModal" + > + <Flash + ref="flash" + class="flash" + :src="attachment.large_thumb_url || attachment.url" + @playerOpened="setFlashLoaded(true)" + @playerClosed="setFlashLoaded(false)" + /> + </span> + </div> <div - v-if="type === 'html' && attachment.oembed" - class="oembed" - @click.prevent="linkClicked" + v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))" + class="description-container" + :class="{ '-static': !edit }" > - <div - v-if="attachment.thumb_url" - class="image" + <input + v-if="edit" + v-model="localDescription" + type="text" + class="description-field" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" > - <img :src="attachment.thumb_url"> - </div> - <div class="text"> - <!-- eslint-disable vue/no-v-html --> - <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> - <div v-html="attachment.oembed.oembedHTML" /> - <!-- eslint-enable vue/no-v-html --> - </div> + <p v-else> + {{ localDescription }} + </p> </div> - - <Flash - v-if="type === 'flash'" - :src="attachment.large_thumb_url || attachment.url" - /> </div> </template> <script src="./attachment.js"></script> -<style lang="scss"> -@import '../../_variables.scss'; - -.attachments { - display: flex; - flex-wrap: wrap; - - .non-gallery { - max-width: 100%; - } - - .placeholder { - 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%; - - svg { - color: inherit; - } - } - - .nsfw-placeholder { - cursor: pointer; - - &.loading { - cursor: progress; - } - } - - .attachment { - position: relative; - margin-top: 0.5em; - align-self: flex-start; - line-height: 0; - - border-style: solid; - border-width: 1px; - border-radius: $fallback--attachmentRadius; - border-radius: var(--attachmentRadius, $fallback--attachmentRadius); - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - overflow: hidden; - } - - .non-gallery.attachment { - &.flash, - &.video { - flex: 1 0 40%; - } - .nsfw { - height: 260px; - } - .small { - height: 120px; - flex-grow: 0; - } - .video { - height: 260px; - display: flex; - } - video { - max-height: 100%; - object-fit: contain; - } - } - - .fullwidth { - flex-basis: 100%; - } - // fixes small gap below video - &.video { - line-height: 0; - } - - .video-container { - display: flex; - max-height: 100%; - } - - .video { - width: 100%; - height: 100%; - } - - .play-icon { - position: absolute; - font-size: 64px; - top: calc(50% - 32px); - left: calc(50% - 32px); - color: rgba(255, 255, 255, 0.75); - text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); - } - - .play-icon::before { - margin: 0; - } - - &.html { - flex-basis: 90%; - width: 100%; - display: flex; - } - - .hider { - position: absolute; - right: 0; - margin: 10px; - padding: 0; - z-index: 4; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - text-align: center; - width: 2em; - height: 2em; - font-size: 1.25em; - // TODO: theming? hard to theme with unknown background image color - background: rgba(230, 230, 230, 0.7); - .svg-inline--fa { - color: rgba(0, 0, 0, 0.6); - } - &:hover .svg-inline--fa { - color: rgba(0, 0, 0, 0.9); - } - } - - video { - z-index: 0; - } - - audio { - width: 100%; - } - - img.media-upload { - line-height: 0; - max-height: 200px; - max-width: 100%; - } - - .oembed { - line-height: 1.2em; - flex: 1 0 100%; - width: 100%; - margin-right: 15px; - display: flex; - - img { - width: 100%; - } - - .image { - flex: 1; - img { - border: 0px; - border-radius: 5px; - height: 100%; - object-fit: cover; - } - } - - .text { - flex: 2; - margin: 8px; - word-break: break-all; - h1 { - font-size: 14px; - margin: 0px; - } - } - } - - .image-attachment { - &, - & .image { - width: 100%; - height: 100%; - } - - &.hidden { - display: none; - } - - .nsfw { - object-fit: cover; - width: 100%; - height: 100%; - } - - img { - image-orientation: from-image; // NOTE: only FF supports this - } - } -} -</style> +<style src="./attachment.scss" lang="scss"></style> diff --git a/src/components/auth_form/auth_form.js b/src/components/auth_form/auth_form.js index e9a6e2d5..a86a3dca 100644 --- a/src/components/auth_form/auth_form.js +++ b/src/components/auth_form/auth_form.js @@ -1,3 +1,4 @@ +import { h, resolveComponent } from 'vue' import LoginForm from '../login_form/login_form.vue' import MFARecoveryForm from '../mfa_form/recovery_form.vue' import MFATOTPForm from '../mfa_form/totp_form.vue' @@ -5,8 +6,8 @@ import { mapGetters } from 'vuex' const AuthForm = { name: 'AuthForm', - render (createElement) { - return createElement('component', { is: this.authForm }) + render () { + return h(resolveComponent(this.authForm)) }, computed: { authForm () { diff --git a/src/components/avatar_list/avatar_list.vue b/src/components/avatar_list/avatar_list.vue index e1b6e971..9a6ca3f6 100644 --- a/src/components/avatar_list/avatar_list.vue +++ b/src/components/avatar_list/avatar_list.vue @@ -14,7 +14,7 @@ </div> </template> -<script src="./avatar_list.js" ></script> +<script src="./avatar_list.js"></script> <style lang="scss"> @import '../../_variables.scss'; diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js index 8f41e2fb..31de2d75 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -1,5 +1,6 @@ -import UserCard from '../user_card/user_card.vue' +import UserPopover from '../user_popover/user_popover.vue' import UserAvatar from '../user_avatar/user_avatar.vue' +import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -7,20 +8,13 @@ const BasicUserCard = { props: [ 'user' ], - data () { - return { - userExpanded: false - } - }, components: { - UserCard, + UserPopover, UserAvatar, - RichContent + RichContent, + UserLink }, methods: { - toggleUserExpanded () { - this.userExpanded = !this.userExpanded - }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) } diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 53deb1df..418de926 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -1,24 +1,22 @@ <template> <div class="basic-user-card"> - <router-link :to="userProfileLink(user)"> - <UserAvatar - class="avatar" - :user="user" - @click.prevent.native="toggleUserExpanded" - /> - </router-link> - <div - v-if="userExpanded" - class="basic-user-card-expanded-content" + <router-link + :to="userProfileLink(user)" + @click.prevent > - <UserCard + <UserPopover :user-id="user.id" - :rounded="true" - :bordered="true" - /> - </div> + :overlay-centers="true" + overlay-centers-selector=".avatar" + > + <UserAvatar + class="user-avatar avatar" + :user="user" + @click.prevent + /> + </UserPopover> + </router-link> <div - v-else class="basic-user-card-collapsed-content" > <div @@ -32,12 +30,10 @@ /> </div> <div> - <router-link + <user-link class="basic-user-card-screen-name" - :to="userProfileLink(user)" - > - @{{ user.screen_name_ui }} - </router-link> + :user="user" + /> </div> <slot /> </div> @@ -53,6 +49,8 @@ margin: 0; padding: 0.6em 1em; + --emoji-size: 14px; + &-collapsed-content { margin-left: 0.7em; text-align: left; diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js index 64b69e5d..5ac43d90 100644 --- a/src/components/bookmark_timeline/bookmark_timeline.js +++ b/src/components/bookmark_timeline/bookmark_timeline.js @@ -9,7 +9,7 @@ const Bookmarks = { components: { Timeline }, - destroyed () { + unmounted () { this.$store.commit('clearTimeline', { timeline: 'bookmarks' }) } } diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index b54f5fb2..79f24771 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -6,7 +6,7 @@ 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 { promiseInterval } from '../../services/promise_interval/promise_interval.js' -import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js' +import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faChevronDown, @@ -20,7 +20,7 @@ library.add( ) const BOTTOMED_OUT_OFFSET = 10 -const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150 +const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10 const SAFE_RESIZE_TIME_OFFSET = 100 const MARK_AS_READ_DELAY = 1500 const MAX_RETRIES = 10 @@ -43,7 +43,7 @@ const Chat = { }, created () { this.startFetching() - window.addEventListener('resize', this.handleLayoutChange) + window.addEventListener('resize', this.handleResize) }, mounted () { window.addEventListener('scroll', this.handleScroll) @@ -52,15 +52,12 @@ const Chat = { } this.$nextTick(() => { - this.updateScrollableContainerHeight() this.handleResize() }) - this.setChatLayout() }, - destroyed () { + unmounted () { window.removeEventListener('scroll', this.handleScroll) - window.removeEventListener('resize', this.handleLayoutChange) - this.unsetChatLayout() + window.removeEventListener('resize', this.handleResize) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.dispatch('clearCurrentChat') }, @@ -96,8 +93,7 @@ const Chat = { ...mapState({ backendInteractor: state => state.api.backendInteractor, mastoUserSocketStatus: state => state.api.mastoUserSocketStatus, - mobileLayout: state => state.interface.mobileLayout, - layoutHeight: state => state.interface.layoutHeight, + mobileLayout: state => state.interface.layoutType === 'mobile', currentUser: state => state.users.currentUser }) }, @@ -112,12 +108,9 @@ const Chat = { } }) }, - '$route': function () { + $route: function () { this.startFetching() }, - layoutHeight () { - this.handleResize({ expand: true }) - }, mastoUserSocketStatus (newValue) { if (newValue === WSConnectionStatus.JOINED) { this.fetchChat({ isFirstFetch: true }) @@ -132,7 +125,6 @@ const Chat = { onFilesDropped () { this.$nextTick(() => { this.handleResize() - this.updateScrollableContainerHeight() }) }, handleVisibilityChange () { @@ -142,45 +134,9 @@ const Chat = { } }) }, - 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. + // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport handleResize (opts = {}) { - const { expand = false, delayed = false } = opts + const { delayed = false } = opts if (delayed) { setTimeout(() => { @@ -190,29 +146,20 @@ const Chat = { } 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)) { + const { offsetHeight = undefined } = getScrollPosition() + const diff = offsetHeight - this.lastScrollPosition.offsetHeight + if (diff !== 0 && !this.bottomedOut()) { this.$nextTick(() => { - this.updateScrollableContainerHeight() - this.$refs.scrollable.scrollTo({ - top: this.$refs.scrollable.scrollTop - diff, - left: 0 - }) + window.scrollBy({ top: -Math.trunc(diff) }) }) } + this.lastScrollPosition = getScrollPosition() }) }, 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 }) + window.scrollTo({ top: document.documentElement.scrollHeight, behavior }) }) if (forceRead) { this.readChat() @@ -228,11 +175,10 @@ const Chat = { }) }, bottomedOut (offset) { - return isBottomedOut(this.$refs.scrollable, offset) + return isBottomedOut(offset) }, reachedTop () { - const scrollable = this.$refs.scrollable - return scrollable && scrollable.scrollTop <= 0 + return window.scrollY <= 0 }, cullOlderCheck () { window.setTimeout(() => { @@ -242,6 +188,7 @@ const Chat = { }, 5000) }, handleScroll: _.throttle(function () { + this.lastScrollPosition = getScrollPosition() if (!this.currentChat) { return } if (this.reachedTop()) { @@ -263,10 +210,9 @@ const Chat = { } }, 200), handleScrollUp (positionBeforeLoading) { - const positionAfterLoading = getScrollPosition(this.$refs.scrollable) - this.$refs.scrollable.scrollTo({ - top: getNewTopPosition(positionBeforeLoading, positionAfterLoading), - left: 0 + const positionAfterLoading = getScrollPosition() + window.scrollTo({ + top: getNewTopPosition(positionBeforeLoading, positionAfterLoading) }) }, fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) { @@ -285,22 +231,18 @@ const Chat = { chatService.clear(chatMessageService) } - const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable) + const positionBeforeUpdate = getScrollPosition() this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { this.$nextTick(() => { if (fetchOlderMessages) { this.handleScrollUp(positionBeforeUpdate) } - if (isFirstFetch) { - this.updateScrollableContainerHeight() - } - // In vertical screens, the first batch of fetched messages may not always take the // full height of the scrollable container. // If this is the case, we want to fetch the messages until the scrollable container // is fully populated so that the user has the ability to scroll up and load the history. - if (!isScrollable(this.$refs.scrollable) && messages.length > 0) { + if (!isScrollable() && messages.length > 0) { this.fetchChat({ maxId: this.currentChatMessageService.minId }) } }) @@ -336,9 +278,6 @@ const Chat = { 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 }) }) }, diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index 3a26686c..f2e154ab 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -1,28 +1,22 @@ .chat-view { display: flex; - height: calc(100vh - 60px); - width: 100%; - - .chat-title { - // prevents chat header jumping on when the user avatar loads - height: 28px; - } + height: 100%; .chat-view-inner { height: auto; width: 100%; overflow: visible; display: flex; - margin: 0.5em 0.5em 0 0.5em; } .chat-view-body { + box-sizing: border-box; background-color: var(--chatBg, $fallback--bg); display: flex; flex-direction: column; width: 100%; overflow: visible; - min-height: 100%; + min-height: calc(100vh - var(--navbar-height)); margin: 0 0 0 0; border-radius: 10px 10px 0 0; border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0; @@ -32,36 +26,32 @@ } } - .scrollable-message-list { + .message-list { padding: 0 0.8em; height: 100%; - overflow-y: scroll; - overflow-x: hidden; display: flex; flex-direction: column; + justify-content: end; } .footer { position: sticky; bottom: 0; + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + z-index: 1; } .chat-view-heading { - align-items: center; - justify-content: space-between; - top: 50px; - display: flex; - z-index: 2; - position: sticky; - overflow: hidden; + grid-template-columns: auto minmax(50%, 1fr); } .go-back-button { - cursor: pointer; - width: 28px; text-align: center; - padding: 0.6em; - margin: -0.6em 0.6em -0.6em -0.6em; + line-height: 1; + height: 100%; + align-self: start; + width: var(--__panel-heading-height-inner); } .jump-to-bottom-button { @@ -115,56 +105,4 @@ } } } - - @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 { - box-sizing: border-box; - position: static; - z-index: 9999; - top: 0; - margin-top: 0; - border-radius: 0; - - /* This practically overlays the panel heading color over panel background - * color. This is needed because we allow transparent panel background and - * it doesn't work well in this "disjointed panel header" case - */ - background: - linear-gradient(to top, var(--panel), var(--panel)), - linear-gradient(to top, var(--bg), var(--bg)); - height: 50px; - } - - .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 index 94a0097c..2e7df7bd 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -2,23 +2,22 @@ <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" + class="panel-heading -sticky chat-view-heading" > - <a - class="go-back-button" + <button + class="button-unstyled go-back-button" @click="goBack" > <FAIcon size="lg" icon="chevron-left" /> - </a> + </button> <div class="title text-center"> <ChatTitle :user="recipient" @@ -26,73 +25,69 @@ /> </div> </div> - <template> + <div + class="message-list" + :style="{ height: scrollableContainerHeight }" + > + <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 - ref="scrollable" - class="scrollable-message-list" - :style="{ height: scrollableContainerHeight }" - @scroll="handleScroll" + v-else + class="chat-loading-error" > - <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 class="alert error"> + {{ $t('chats.error_loading_chat') }} </div> </div> + </div> + <div + ref="footer" + class="panel-body footer" + > <div - ref="footer" - class="panel-body footer" + class="jump-to-bottom-button" + :class="{ 'visible': jumpToBottomButtonVisible }" + @click="scrollDown({ behavior: 'smooth' })" > - <div - class="jump-to-bottom-button" - :class="{ 'visible': jumpToBottomButtonVisible }" - @click="scrollDown({ behavior: 'smooth' })" - > - <span> - <FAIcon icon="chevron-down" /> - <div - v-if="newMessageCount" - class="badge badge-notification unread-chat-count unread-message-count" - > - {{ newMessageCount }} - </div> - </span> - </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" - :optimistic-posting="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" - /> + <span> + <FAIcon icon="chevron-down" /> + <div + v-if="newMessageCount" + class="badge badge-notification unread-chat-count unread-message-count" + > + {{ newMessageCount }} + </div> + </span> </div> - </template> + <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" + :optimistic-posting="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> </div> </div> </div> diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js index 50a933ac..c187892d 100644 --- a/src/components/chat/chat_layout_utils.js +++ b/src/components/chat/chat_layout_utils.js @@ -1,9 +1,9 @@ // Captures a scroll position -export const getScrollPosition = (el) => { +export const getScrollPosition = () => { return { - scrollTop: el.scrollTop, - scrollHeight: el.scrollHeight, - offsetHeight: el.offsetHeight + scrollTop: window.scrollY, + scrollHeight: document.documentElement.scrollHeight, + offsetHeight: window.innerHeight } } @@ -13,21 +13,12 @@ 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 +export const isBottomedOut = (offset = 0) => { + const scrollHeight = window.scrollY + offset + const totalHeight = document.documentElement.scrollHeight - window.innerHeight 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 -} - // Returns whether or not the scrollbar is visible. -export const isScrollable = (el) => { - if (!el) return - - return el.scrollHeight > el.clientHeight +export const isScrollable = () => { + return document.documentElement.scrollHeight > window.innerHeight } diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue index f98b7ed2..1248c4c8 100644 --- a/src/components/chat_list/chat_list.vue +++ b/src/components/chat_list/chat_list.vue @@ -6,7 +6,7 @@ v-else class="chat-list panel panel-default" > - <div class="panel-heading"> + <div class="panel-heading -sticky"> <span class="title"> {{ $t("chats.chats") }} </span> @@ -23,7 +23,7 @@ class="timeline" > <List :items="sortedChatList"> - <template v-slot:item="{item}"> + <template #item="{item}"> <ChatListItem :key="item.id" :compact="false" diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 57332bed..c6b45c34 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -43,7 +43,7 @@ white-space: nowrap; overflow: hidden; flex-shrink: 1; - line-height: 1.4em; + line-height: var(--post-line-height); } .chat-preview { @@ -82,7 +82,7 @@ } .time-wrapper { - line-height: 1.4em; + line-height: var(--post-line-height); } .chat-preview-body { diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index eb195bc1..ebe09814 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -6,7 +6,7 @@ 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' +import { defineAsyncComponent } from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faTimes, @@ -27,6 +27,7 @@ const ChatMessage = { 'chatViewItem', 'hoveredMessageChain' ], + emits: ['hover'], components: { Popover, Attachment, @@ -34,7 +35,8 @@ const ChatMessage = { UserAvatar, Gallery, LinkPreview, - ChatMessageDate + ChatMessageDate, + UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) }, computed: { // Returns HH:MM (hours and minutes) in local time. @@ -48,9 +50,6 @@ const ChatMessage = { 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' }, diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index fcfa7c8a..1913479f 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -1,6 +1,7 @@ @import '../../_variables.scss'; .chat-message-wrapper { + &.hovered-message-chain { .animated.Avatar { canvas { @@ -40,6 +41,12 @@ .chat-message { display: flex; padding-bottom: 0.5em; + + .status-body:hover { + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; + } } .avatar-wrapper { @@ -62,10 +69,6 @@ &.with-media { width: 100%; - .gallery-row { - overflow: hidden; - } - .status { width: 100%; } diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index d62b831d..d635c47e 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -14,16 +14,16 @@ v-if="!isCurrentUser" class="avatar-wrapper" > - <router-link + <UserPopover v-if="chatViewItem.isHead" - :to="userProfileLink" + :user-id="author.id" > <UserAvatar :compact="true" :better-shadow="betterShadow" :user="author" /> - </router-link> + </UserPopover> </div> <div class="chat-message-inner"> <div @@ -44,13 +44,13 @@ <Popover trigger="click" placement="top" - :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'" + bound-to-selector=".chat-view-inner" :bound-to="{ x: 'container' }" :margin="popoverMarginStyle" @show="menuOpened = true" @close="menuOpened = false" > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> <button class="button-default dropdown-item dropdown-item-icon" @@ -60,7 +60,7 @@ </button> </div> </template> - <template v-slot:trigger> + <template #trigger> <button class="button-default menu-icon" :title="$t('chats.more')" @@ -75,7 +75,7 @@ :status="messageForStatusContent" :full-content="true" > - <template v-slot:footer> + <template #footer> <span class="created-at" > @@ -96,7 +96,7 @@ </div> </template> -<script src="./chat_message.js" ></script> +<script src="./chat_message.js"></script> <style lang="scss"> @import './chat_message.scss'; diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss index 5506143d..240e1a38 100644 --- a/src/components/chat_new/chat_new.scss +++ b/src/components/chat_new/chat_new.scss @@ -22,10 +22,10 @@ } .go-back-button { - cursor: pointer; - width: 28px; text-align: center; - padding: 0.6em; - margin: -0.6em 0.6em -0.6em -0.6em; + line-height: 1; + height: 100%; + align-self: start; + width: var(--__panel-heading-height-inner); } } diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue index f3894a3a..bf09a379 100644 --- a/src/components/chat_new/chat_new.vue +++ b/src/components/chat_new/chat_new.vue @@ -1,21 +1,20 @@ <template> <div - id="nav" class="panel-default panel chat-new" > <div ref="header" class="panel-heading" > - <a - class="go-back-button" + <button + class="button-unstyled go-back-button" @click="goBack" > <FAIcon size="lg" icon="chevron-left" /> - </a> + </button> </div> <div class="input-wrap"> <div class="input-search"> diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js index edfbe7a4..b8721126 100644 --- a/src/components/chat_title/chat_title.js +++ b/src/components/chat_title/chat_title.js @@ -1,11 +1,13 @@ -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' +import RichContent from 'src/components/rich_content/rich_content.jsx' +import { defineAsyncComponent } from 'vue' -export default Vue.component('chat-title', { +export default { name: 'ChatTitle', components: { - UserAvatar + UserAvatar, + RichContent, + UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) }, props: [ 'user', 'withAvatar' @@ -17,10 +19,5 @@ export default Vue.component('chat-title', { 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 index b16ed39d..ab7491fa 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -1,25 +1,26 @@ <template> - <!-- eslint-disable vue/no-v-html --> <div class="chat-title" :title="title" > - <router-link + <UserPopover v-if="withAvatar && user" - :to="getUserProfileLink(user)" + class="avatar-container" + :user-id="user.id" > <UserAvatar + class="titlebar-avatar" :user="user" - width="23px" - height="23px" /> - </router-link> - <span + </UserPopover> + <RichContent + v-if="user" class="username" - v-html="htmlTitle" + :title="'@'+(user && user.screen_name_ui)" + :html="htmlTitle" + :emoji="user.emoji || []" /> </div> - <!-- eslint-enable vue/no-v-html --> </template> <script src="./chat_title.js"></script> @@ -32,7 +33,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - align-items: center; + + --emoji-size: 14px; .username { max-width: 100%; @@ -41,21 +43,17 @@ display: inline; word-wrap: break-word; overflow: hidden; - text-overflow: ellipsis; + } - .emoji { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } + .avatar-container { + align-self: center; + line-height: 1; } - .Avatar { - width: 23px; - height: 23px; + .titlebar-avatar { margin-right: 0.5em; - + height: 1.5em; + width: 1.5em; border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index d28c2cfd..b6768d67 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -6,9 +6,9 @@ <input type="checkbox" :disabled="disabled" - :checked="checked" - :indeterminate.prop="indeterminate" - @change="$emit('change', $event.target.checked)" + :checked="modelValue" + :indeterminate="indeterminate" + @change="$emit('update:modelValue', $event.target.checked)" > <i class="checkbox-indicator" /> <span @@ -22,15 +22,12 @@ <script> export default { - model: { - prop: 'checked', - event: 'change' - }, props: [ - 'checked', + 'modelValue', 'indeterminate', 'disabled' - ] + ], + emits: ['update:modelValue'] } </script> diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss index 8e9923cf..3de31fde 100644 --- a/src/components/color_input/color_input.scss +++ b/src/components/color_input/color_input.scss @@ -27,16 +27,16 @@ &.nativeColor { flex: 0 0 2em; min-width: 2em; - align-self: center; - height: 100%; + align-self: stretch; + min-height: 100%; } } .computedIndicator, .transparentIndicator { flex: 0 0 2em; min-width: 2em; - align-self: center; - height: 100%; + align-self: stretch; + min-height: 100%; } .transparentIndicator { // forgot to install counter-strike source, ooops diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue index 8fb16113..dfc084f9 100644 --- a/src/components/color_input/color_input.vue +++ b/src/components/color_input/color_input.vue @@ -11,28 +11,28 @@ </label> <Checkbox v-if="typeof fallback !== 'undefined' && showOptionalTickbox" - :checked="present" + :model-value="present" :disabled="disabled" class="opt" - @change="$emit('input', typeof value === 'undefined' ? fallback : undefined)" + @update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)" /> <div class="input color-input-field"> <input :id="name + '-t'" class="textColor unstyled" type="text" - :value="value || fallback" + :value="modelValue || fallback" :disabled="!present || disabled" - @input="$emit('input', $event.target.value)" + @input="$emit('update:modelValue', $event.target.value)" > <input v-if="validColor" :id="name" class="nativeColor unstyled" type="color" - :value="value || fallback" + :value="modelValue || fallback" :disabled="!present || disabled" - @input="$emit('input', $event.target.value)" + @input="$emit('update:modelValue', $event.target.value)" > <div v-if="transparentColor" @@ -46,7 +46,6 @@ </div> </div> </template> -<style lang="scss" src="./color_input.scss"></style> <script> import Checkbox from '../checkbox/checkbox.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' @@ -67,7 +66,7 @@ export default { }, // Color value, should be required but vue cannot tell the difference // between "property missing" and "property set to undefined" - value: { + modelValue: { required: false, type: String, default: undefined @@ -91,22 +90,24 @@ export default { default: true } }, + emits: ['update:modelValue'], computed: { present () { - return typeof this.value !== 'undefined' + return typeof this.modelValue !== 'undefined' }, validColor () { - return hex2rgb(this.value || this.fallback) + return hex2rgb(this.modelValue || this.fallback) }, transparentColor () { - return this.value === 'transparent' + return this.modelValue === 'transparent' }, computedColor () { - return this.value && this.value.startsWith('--') + return this.modelValue && this.modelValue.startsWith('--') } } } </script> +<style lang="scss" src="./color_input.scss"></style> <style lang="scss"> .color-control { diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 069c0b40..85e6d8ad 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,5 +1,23 @@ import { reduce, filter, findIndex, clone, get } from 'lodash' import Status from '../status/status.vue' +import ThreadTree from '../thread_tree/thread_tree.vue' +import { WSConnectionStatus } from '../../services/api/api.service.js' +import { mapGetters, mapState } from 'vuex' +import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue' +import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faAngleDoubleDown, + faAngleDoubleLeft, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faAngleDoubleDown, + faAngleDoubleLeft, + faChevronLeft +) const sortById = (a, b) => { const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id @@ -35,7 +53,10 @@ const conversation = { data () { return { highlight: null, - expanded: false + expanded: false, + threadDisplayStatusObject: {}, // id => 'showing' | 'hidden' + statusContentPropertiesObject: {}, + inlineDivePosition: null } }, props: [ @@ -53,13 +74,54 @@ const conversation = { } }, computed: { - hideStatus () { + maxDepthToShowByDefault () { + // maxDepthInThread = max number of depths that is *visible* + // since our depth starts with 0 and "showing" means "showing children" + // there is a -2 here + const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2 + return maxDepth >= 1 ? maxDepth : 1 + }, + streamingEnabled () { + return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED + }, + displayStyle () { + return this.$store.getters.mergedConfig.conversationDisplay + }, + isTreeView () { + return !this.isLinearView + }, + treeViewIsSimple () { + return !this.$store.getters.mergedConfig.conversationTreeAdvanced + }, + isLinearView () { + return this.displayStyle === 'linear' + }, + shouldFadeAncestors () { + return this.$store.getters.mergedConfig.conversationTreeFadeAncestors + }, + otherRepliesButtonPosition () { + return this.$store.getters.mergedConfig.conversationOtherRepliesButton + }, + showOtherRepliesButtonBelowStatus () { + return this.otherRepliesButtonPosition === 'below' + }, + showOtherRepliesButtonInsideStatus () { + return this.otherRepliesButtonPosition === 'inside' + }, + suspendable () { + if (this.isTreeView) { + return Object.entries(this.statusContentProperties) + .every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0) + } if (this.$refs.statusComponent && this.$refs.statusComponent[0]) { - return this.virtualHidden && this.$refs.statusComponent[0].suspendable + return this.$refs.statusComponent.every(s => s.suspendable) } else { - return this.virtualHidden + return true } }, + hideStatus () { + return this.virtualHidden && this.suspendable + }, status () { return this.$store.state.statuses.allStatusesObject[this.statusId] }, @@ -90,6 +152,121 @@ const conversation = { return sortAndFilterConversation(conversation, this.status) }, + statusMap () { + return this.conversation.reduce((res, s) => { + res[s.id] = s + return res + }, {}) + }, + threadTree () { + const reverseLookupTable = this.conversation.reduce((table, status, index) => { + table[status.id] = index + return table + }, {}) + + const threads = this.conversation.reduce((a, cur) => { + const id = cur.id + a.forest[id] = this.getReplies(id) + .map(s => s.id) + + return a + }, { + forest: {} + }) + + const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => { + if (processed[id]) { + return [] + } + + processed[id] = true + return [{ + status: this.conversation[reverseLookupTable[id]], + id, + depth + }, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), []) + }).reduce((a, b) => a.concat(b), []) + + const linearized = walk(threads.forest, this.topLevel.map(k => k.id)) + + return linearized + }, + replyIds () { + return this.conversation.map(k => k.id) + .reduce((res, id) => { + res[id] = (this.replies[id] || []).map(k => k.id) + return res + }, {}) + }, + totalReplyCount () { + const sizes = {} + const subTreeSizeFor = (id) => { + if (sizes[id]) { + return sizes[id] + } + sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0) + return sizes[id] + } + this.conversation.map(k => k.id).map(subTreeSizeFor) + return Object.keys(sizes).reduce((res, id) => { + res[id] = sizes[id] - 1 // exclude itself + return res + }, {}) + }, + totalReplyDepth () { + const depths = {} + const subTreeDepthFor = (id) => { + if (depths[id]) { + return depths[id] + } + depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0) + return depths[id] + } + this.conversation.map(k => k.id).map(subTreeDepthFor) + return Object.keys(depths).reduce((res, id) => { + res[id] = depths[id] - 1 // exclude itself + return res + }, {}) + }, + depths () { + return this.threadTree.reduce((a, k) => { + a[k.id] = k.depth + return a + }, {}) + }, + topLevel () { + const topLevel = this.conversation.reduce((tl, cur) => + tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation) + return topLevel + }, + otherTopLevelCount () { + return this.topLevel.length - 1 + }, + showingTopLevel () { + if (this.canDive && this.diveRoot) { + return [this.statusMap[this.diveRoot]] + } + return this.topLevel + }, + diveRoot () { + const statusId = this.inlineDivePosition || this.statusId + const isTopLevel = !this.parentOf(statusId) + return isTopLevel ? null : statusId + }, + diveDepth () { + return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0 + }, + diveMode () { + return this.canDive && !!this.diveRoot + }, + shouldShowAllConversationButton () { + // The "show all conversation" button tells the user that there exist + // other toplevel statuses, so do not show it if there is only a single root + return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1 + }, + shouldShowAncestors () { + return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length + }, replies () { let i = 1 // eslint-disable-next-line camelcase @@ -101,7 +278,7 @@ const conversation = { result[irid] = result[irid] || [] result[irid].push({ name: `#${i}`, - id: id + id }) } i++ @@ -109,15 +286,77 @@ const conversation = { }, {}) }, isExpanded () { - return this.expanded || this.isPage + return !!(this.expanded || this.isPage) }, hiddenStyle () { const height = (this.status && this.status.virtualHeight) || '120px' return this.virtualHidden ? { height } : {} - } + }, + threadDisplayStatus () { + return this.conversation.reduce((a, k) => { + const id = k.id + const depth = this.depths[id] + const status = (() => { + if (this.threadDisplayStatusObject[id]) { + return this.threadDisplayStatusObject[id] + } + if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) { + return 'showing' + } else { + return 'hidden' + } + })() + + a[id] = status + return a + }, {}) + }, + statusContentProperties () { + return this.conversation.reduce((a, k) => { + const id = k.id + const props = (() => { + const def = { + showingTall: false, + expandingSubject: false, + showingLongSubject: false, + isReplying: false, + mediaPlaying: [] + } + + if (this.statusContentPropertiesObject[id]) { + return { + ...def, + ...this.statusContentPropertiesObject[id] + } + } + return def + })() + + a[id] = props + return a + }, {}) + }, + canDive () { + return this.isTreeView && this.isExpanded + }, + focused () { + return (id) => { + return (this.isExpanded) && id === this.highlight + } + }, + maybeHighlight () { + return this.isExpanded ? this.highlight : null + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mastoUserSocketStatus: state => state.api.mastoUserSocketStatus + }) }, components: { - Status + Status, + ThreadTree, + QuickFilterSettings, + QuickViewSettings }, watch: { statusId (newVal, oldVal) { @@ -132,6 +371,8 @@ const conversation = { expanded (value) { if (value) { this.fetchConversation() + } else { + this.resetDisplayState() } }, virtualHidden (value) { @@ -161,24 +402,153 @@ const conversation = { getReplies (id) { return this.replies[id] || [] }, - focused (id) { - return (this.isExpanded) && id === this.statusId + getHighlight () { + return this.isExpanded ? this.highlight : null }, setHighlight (id) { if (!id) return this.highlight = id + + if (!this.streamingEnabled) { + this.$store.dispatch('fetchStatus', id) + } + this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchEmojiReactionsBy', id) }, - getHighlight () { - return this.isExpanded ? this.highlight : null - }, toggleExpanded () { this.expanded = !this.expanded }, getConversationId (statusId) { const status = this.$store.state.statuses.allStatusesObject[statusId] return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id')) + }, + setThreadDisplay (id, nextStatus) { + this.threadDisplayStatusObject = { + ...this.threadDisplayStatusObject, + [id]: nextStatus + } + }, + toggleThreadDisplay (id) { + const curStatus = this.threadDisplayStatus[id] + const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing' + this.setThreadDisplay(id, nextStatus) + }, + setThreadDisplayRecursively (id, nextStatus) { + this.setThreadDisplay(id, nextStatus) + this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus)) + }, + showThreadRecursively (id) { + this.setThreadDisplayRecursively(id, 'showing') + }, + setStatusContentProperty (id, name, value) { + this.statusContentPropertiesObject = { + ...this.statusContentPropertiesObject, + [id]: { + ...this.statusContentPropertiesObject[id], + [name]: value + } + } + }, + toggleStatusContentProperty (id, name) { + this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name]) + }, + leastVisibleAncestor (id) { + let cur = id + let parent = this.parentOf(cur) + while (cur) { + // if the parent is showing it means cur is visible + if (this.threadDisplayStatus[parent] === 'showing') { + return cur + } + parent = this.parentOf(parent) + cur = this.parentOf(cur) + } + // nothing found, fall back to toplevel + return this.topLevel[0] ? this.topLevel[0].id : undefined + }, + diveIntoStatus (id, preventScroll) { + this.tryScrollTo(id) + }, + diveToTopLevel () { + this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id) + }, + // only used when we are not on a page + undive () { + this.inlineDivePosition = null + this.setHighlight(this.statusId) + }, + tryScrollTo (id) { + if (!id) { + return + } + if (this.isPage) { + // set statusId + this.$router.push({ name: 'conversation', params: { id } }) + } else { + this.inlineDivePosition = id + } + // Because the conversation can be unmounted when out of sight + // and mounted again when it comes into sight, + // the `mounted` or `created` function in `status` should not + // contain scrolling calls, as we do not want the page to jump + // when we scroll with an expanded conversation. + // + // Now the method is to rely solely on the `highlight` watcher + // in `status` components. + // In linear views, all statuses are rendered at all times, but + // in tree views, it is possible that a change in active status + // removes and adds status components (e.g. an originally child + // status becomes an ancestor status, and thus they will be + // different). + // Here, let the components be rendered first, in order to trigger + // the `highlight` watcher. + this.$nextTick(() => { + this.setHighlight(id) + }) + }, + goToCurrent () { + this.tryScrollTo(this.diveRoot || this.topLevel[0].id) + }, + statusById (id) { + return this.statusMap[id] + }, + parentOf (id) { + const status = this.statusById(id) + if (!status) { + return undefined + } + const { in_reply_to_status_id: parentId } = status + if (!this.statusMap[parentId]) { + return undefined + } + return parentId + }, + parentOrSelf (id) { + return this.parentOf(id) || id + }, + // Ancestors of some status, from top to bottom + ancestorsOf (id) { + const ancestors = [] + let cur = this.parentOf(id) + while (cur) { + ancestors.unshift(this.statusMap[cur]) + cur = this.parentOf(cur) + } + return ancestors + }, + topLevelAncestorOrSelfId (id) { + let cur = id + let parent = this.parentOf(id) + while (parent) { + cur = this.parentOf(cur) + parent = this.parentOf(parent) + } + return cur + }, + resetDisplayState () { + this.undive() + this.threadDisplayStatusObject = {} } } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 3fb26d92..afa04db0 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -7,7 +7,7 @@ > <div v-if="isExpanded" - class="panel-heading conversation-heading" + class="panel-heading conversation-heading -sticky" > <span class="title"> {{ $t('timeline.conversation') }} </span> <button @@ -17,25 +17,189 @@ > {{ $t('timeline.collapse') }} </button> + <QuickFilterSettings + v-if="!collapsable" + :conversation="true" + class="rightside-button" + /> + <QuickViewSettings + v-if="!collapsable" + :conversation="true" + class="rightside-button" + /> + </div> + <div class="conversation-body panel-body"> + <div + v-if="isTreeView" + class="thread-body" + > + <div + v-if="shouldShowAllConversationButton" + class="conversation-dive-to-top-level-box" + > + <i18n-t + keypath="status.show_all_conversation_with_icon" + tag="button" + class="button-unstyled -link" + scope="global" + @click.prevent="diveToTopLevel" + > + <template #icon> + <FAIcon + icon="angle-double-left" + /> + </template> + <template #text> + <span> + {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }} + </span> + </template> + </i18n-t> + </div> + <div + v-if="shouldShowAncestors" + class="thread-ancestors" + > + <article + v-for="status in ancestorsOf(diveRoot)" + :key="status.id" + class="thread-ancestor" + :class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}" + > + <status + ref="statusComponent" + :inline-expanded="collapsable && isExpanded" + :statusoid="status" + :expandable="!isExpanded" + :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" + :focused="focused(status.id)" + :in-conversation="isExpanded" + :highlight="getHighlight()" + :replies="getReplies(status.id)" + :in-profile="inProfile" + :profile-user-id="profileUserId" + class="conversation-status status-fadein panel-body" + + :simple-tree="treeViewIsSimple" + :toggle-thread-display="toggleThreadDisplay" + :thread-display-status="threadDisplayStatus" + :show-thread-recursively="showThreadRecursively" + :total-reply-count="totalReplyCount" + :total-reply-depth="totalReplyDepth" + :show-other-replies-as-button="showOtherRepliesButtonInsideStatus" + :dive="() => diveIntoStatus(status.id)" + + :controlled-showing-tall="statusContentProperties[status.id].showingTall" + :controlled-expanding-subject="statusContentProperties[status.id].expandingSubject" + :controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject" + :controlled-replying="statusContentProperties[status.id].replying" + :controlled-media-playing="statusContentProperties[status.id].mediaPlaying" + :controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')" + :controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')" + :controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')" + :controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')" + :controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)" + + @goto="setHighlight" + @toggleExpanded="toggleExpanded" + /> + <div + v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1" + class="thread-ancestor-dive-box" + > + <div + class="thread-ancestor-dive-box-inner" + > + <i18n-t + tag="button" + scope="global" + keypath="status.ancestor_follow_with_icon" + class="button-unstyled -link thread-tree-show-replies-button" + @click.prevent="diveIntoStatus(status.id)" + > + <template #icon> + <FAIcon + icon="angle-double-right" + /> + </template> + <template #text> + <span> + {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }} + </span> + </template> + </i18n-t> + </div> + </div> + </article> + </div> + <thread-tree + v-for="status in showingTopLevel" + :key="status.id" + ref="statusComponent" + :depth="0" + + :status="status" + :in-profile="inProfile" + :conversation="conversation" + :collapsable="collapsable" + :is-expanded="isExpanded" + :pinned-status-ids-object="pinnedStatusIdsObject" + :profile-user-id="profileUserId" + + :focused="focused" + :get-replies="getReplies" + :highlight="maybeHighlight" + :set-highlight="setHighlight" + :toggle-expanded="toggleExpanded" + + :simple="treeViewIsSimple" + :toggle-thread-display="toggleThreadDisplay" + :thread-display-status="threadDisplayStatus" + :show-thread-recursively="showThreadRecursively" + :total-reply-count="totalReplyCount" + :total-reply-depth="totalReplyDepth" + :status-content-properties="statusContentProperties" + :set-status-content-property="setStatusContentProperty" + :toggle-status-content-property="toggleStatusContentProperty" + :dive="canDive ? diveIntoStatus : undefined" + /> + </div> + <div + v-if="isLinearView" + class="thread-body" + > + <article> + <status + v-for="status in conversation" + :key="status.id" + ref="statusComponent" + :inline-expanded="collapsable && isExpanded" + :statusoid="status" + :expandable="!isExpanded" + :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" + :focused="focused(status.id)" + :in-conversation="isExpanded" + :highlight="getHighlight()" + :replies="getReplies(status.id)" + :in-profile="inProfile" + :profile-user-id="profileUserId" + class="conversation-status status-fadein panel-body" + + :toggle-thread-display="toggleThreadDisplay" + :thread-display-status="threadDisplayStatus" + :show-thread-recursively="showThreadRecursively" + :total-reply-count="totalReplyCount" + :total-reply-depth="totalReplyDepth" + :status-content-properties="statusContentProperties" + :set-status-content-property="setStatusContentProperty" + :toggle-status-content-property="toggleStatusContentProperty" + + @goto="setHighlight" + @toggleExpanded="toggleExpanded" + /> + </article> + </div> </div> - <status - v-for="status in conversation" - :key="status.id" - ref="statusComponent" - :inline-expanded="collapsable && isExpanded" - :statusoid="status" - :expandable="!isExpanded" - :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" - :focused="focused(status.id)" - :in-conversation="isExpanded" - :highlight="getHighlight()" - :replies="getReplies(status.id)" - :in-profile="inProfile" - :profile-user-id="profileUserId" - class="conversation-status status-fadein panel-body" - @goto="setHighlight" - @toggleExpanded="toggleExpanded" - /> </div> <div v-else @@ -49,19 +213,82 @@ @import '../../_variables.scss'; .Conversation { - .conversation-status { + z-index: 1; + + .conversation-dive-to-top-level-box { + padding: var(--status-margin, $status-margin); border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: var(--border, $fallback--border); border-radius: 0; + /* Make the button stretch along the whole row */ + display: flex; + align-items: stretch; + flex-direction: column; + } + + .thread-ancestors { + margin-left: var(--status-margin, $status-margin); + border-left: 2px solid var(--border, $fallback--border); } - &.-expanded { - .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); + .thread-ancestor.-faded .StatusContent { + --link: var(--faintLink); + --text: var(--faint); + color: var(--text); + } + + .thread-ancestor-dive-box { + padding-left: var(--status-margin, $status-margin); + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--border, $fallback--border); + border-radius: 0; + /* Make the button stretch along the whole row */ + &, &-inner { + display: flex; + align-items: stretch; + flex-direction: column; } } + .thread-ancestor-dive-box-inner { + padding: var(--status-margin, $status-margin); + } + + .conversation-status { + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--border, $fallback--border); + border-radius: 0; + } + + .thread-ancestor-has-other-replies .conversation-status, + .thread-ancestor:last-child .conversation-status, + .thread-ancestor:last-child .thread-ancestor-dive-box, + &:last-child .conversation-status, + &.-expanded .thread-tree .conversation-status { + border-bottom: none; + } + + .thread-ancestors + .thread-tree > .conversation-status { + border-top-width: 1px; + border-top-style: solid; + border-top-color: var(--border, $fallback--border); + } + + /* expanded conversation in timeline */ + &.status-fadein.-expanded .thread-body { + border-left-width: 4px; + border-left-style: solid; + border-left-color: $fallback--cRed; + border-left-color: var(--cRed, $fallback--cRed); + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + border-bottom: 1px solid var(--border, $fallback--border); + } + + &.-expanded.status-fadein { + margin: calc(var(--status-margin, $status-margin) / 2); + } } </style> diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index e048f53d..08c0e44e 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -46,23 +46,27 @@ export default { enableMask () { return this.supportsMask && this.$store.state.instance.logoMask }, logoStyle () { return { - 'visibility': this.enableMask ? 'hidden' : 'visible' + visibility: this.enableMask ? 'hidden' : 'visible' } }, logoMaskStyle () { - return this.enableMask ? { - 'mask-image': `url(${this.$store.state.instance.logo})` - } : { - 'background-color': this.enableMask ? '' : 'transparent' - } + return this.enableMask + ? { + 'mask-image': `url(${this.$store.state.instance.logo})` + } + : { + 'background-color': this.enableMask ? '' : 'transparent' + } }, logoBgStyle () { return Object.assign({ - 'margin': `${this.$store.state.instance.logoMargin} 0`, + margin: `${this.$store.state.instance.logoMargin} 0`, opacity: this.searchBarHidden ? 1 : 0 - }, this.enableMask ? {} : { - 'background-color': this.enableMask ? '' : 'transparent' - }) + }, this.enableMask + ? {} + : { + 'background-color': this.enableMask ? '' : 'transparent' + }) }, logo () { return this.$store.state.instance.logo }, sitename () { return this.$store.state.instance.name }, diff --git a/src/components/desktop_nav/desktop_nav.scss b/src/components/desktop_nav/desktop_nav.scss index 2d468588..1ec25385 100644 --- a/src/components/desktop_nav/desktop_nav.scss +++ b/src/components/desktop_nav/desktop_nav.scss @@ -1,9 +1,12 @@ @import '../../_variables.scss'; .DesktopNav { - height: 50px; width: 100%; - position: fixed; + z-index: var(--ZI_navbar); + + input { + color: var(--inputTopbarText, var(--inputText)); + } a { color: var(--topBarLink, $fallback--link); @@ -11,7 +14,7 @@ .inner-nav { display: grid; - grid-template-rows: 50px; + grid-template-rows: var(--navbar-height); grid-template-columns: 2fr auto 2fr; grid-template-areas: "sitename logo actions"; box-sizing: border-box; @@ -20,7 +23,27 @@ max-width: 980px; } - &.-logoLeft { + &.-column-stretch .inner-nav { + --miniColumn: 25rem; + --maxiColumn: 45rem; + --columnGap: 1em; + max-width: calc( + var(--sidebarColumnWidth, var(--miniColumn)) + + var(--contentColumnWidth, var(--maxiColumn)) + + var(--columnGap) + ); + } + + &.-column-stretch.-wide .inner-nav { + max-width: calc( + var(--sidebarColumnWidth, var(--miniColumn)) + + var(--contentColumnWidth, var(--maxiColumn)) + + var(--notifsColumnWidth, var(--miniColumn)) + + var(--columnGap) + ); + } + + &.-logoLeft .inner-nav { grid-template-columns: auto 2fr 2fr; grid-template-areas: "logo sitename actions"; } @@ -77,7 +100,7 @@ img { display: inline-block; - height: 50px; + height: var(--navbar-height); } } @@ -103,8 +126,8 @@ .item { flex: 1; - line-height: 50px; - height: 50px; + line-height: var(--navbar-height); + height: var(--navbar-height); overflow: hidden; display: flex; flex-wrap: wrap; @@ -114,4 +137,8 @@ text-align: right; } } + + .spacer { + width: 1em; + } } diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index 762aa610..5db7fc79 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -34,11 +34,11 @@ <search-bar v-if="currentUser || !privateMode" @toggled="onSearchBarToggled" - @click.stop.native + @click.stop /> <button class="button-unstyled nav-icon" - @click.stop="openSettingsModal" + @click="openSettingsModal" > <FAIcon fixed-width @@ -52,6 +52,7 @@ href="/pleroma/admin/#/login-pleroma" class="nav-icon" target="_blank" + @click.stop > <FAIcon fixed-width @@ -60,6 +61,7 @@ :title="$t('nav.administration')" /> </a> + <span class="spacer" /> <button v-if="currentUser" class="button-unstyled nav-icon" diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 3241ce3e..06b270c3 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -58,16 +58,7 @@ background-color: var(--bg, $fallback--bg); .dialog-modal-heading { - padding: .5em .5em; - margin-right: auto; - margin-bottom: 0; - white-space: nowrap; - color: var(--panelText); - background-color: $fallback--fg; - background-color: var(--panel, $fallback--fg); - .title { - margin-bottom: 0; text-align: center; } } diff --git a/src/components/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue index 836688aa..28c61631 100644 --- a/src/components/domain_mute_card/domain_mute_card.vue +++ b/src/components/domain_mute_card/domain_mute_card.vue @@ -9,7 +9,7 @@ class="btn button-default" > {{ $t('domain_mute_card.unmute') }} - <template v-slot:progress> + <template #progress> {{ $t('domain_mute_card.unmute_progress') }} </template> </ProgressButton> @@ -19,7 +19,7 @@ class="btn button-default" > {{ $t('domain_mute_card.mute') }} - <template v-slot:progress> + <template #progress> {{ $t('domain_mute_card.mute_progress') }} </template> </ProgressButton> diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js new file mode 100644 index 00000000..75adfea7 --- /dev/null +++ b/src/components/edit_status_modal/edit_status_modal.js @@ -0,0 +1,75 @@ +import PostStatusForm from '../post_status_form/post_status_form.vue' +import Modal from '../modal/modal.vue' +import statusPosterService from '../../services/status_poster/status_poster.service.js' +import get from 'lodash/get' + +const EditStatusModal = { + components: { + PostStatusForm, + Modal + }, + data () { + return { + resettingForm: false + } + }, + computed: { + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + modalActivated () { + return this.$store.state.editStatus.modalActivated + }, + isFormVisible () { + return this.isLoggedIn && !this.resettingForm && this.modalActivated + }, + params () { + return this.$store.state.editStatus.params || {} + } + }, + watch: { + params (newVal, oldVal) { + if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) { + this.resettingForm = true + this.$nextTick(() => { + this.resettingForm = false + }) + } + }, + isFormVisible (val) { + if (val) { + this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus()) + } + } + }, + methods: { + doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) { + const params = { + store: this.$store, + statusId: this.$store.state.editStatus.params.statusId, + status, + spoilerText, + sensitive, + poll, + media, + contentType + } + + return statusPosterService.editStatus(params) + .then((data) => { + return data + }) + .catch((err) => { + console.error('Error editing status', err) + return { + error: err.message + } + }) + }, + closeModal () { + this.$store.dispatch('closeEditStatusModal') + } + } +} + +export default EditStatusModal diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue new file mode 100644 index 00000000..1dbacaab --- /dev/null +++ b/src/components/edit_status_modal/edit_status_modal.vue @@ -0,0 +1,48 @@ +<template> + <Modal + v-if="isFormVisible" + class="edit-form-modal-view" + @backdropClicked="closeModal" + > + <div class="edit-form-modal-panel panel"> + <div class="panel-heading"> + {{ $t('post_status.edit_status') }} + </div> + <PostStatusForm + class="panel-body" + v-bind="params" + :post-handler="doEditStatus" + :disable-polls="true" + :disable-visibility-selector="true" + @posted="closeModal" + /> + </div> + </Modal> +</template> + +<script src="./edit_status_modal.js"></script> + +<style lang="scss"> +.modal-view.edit-form-modal-view { + align-items: flex-start; +} +.edit-form-modal-panel { + flex-shrink: 0; + margin-top: 25%; + margin-bottom: 2em; + width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } + + .form-bottom-left { + max-width: 6.5em; + + .emoji-icon { + justify-content: right; + } + } +} +</style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 902ec384..ba5f7552 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -1,8 +1,10 @@ import Completion from '../../services/completion/completion.js' import EmojiPicker from '../emoji_picker/emoji_picker.vue' +import Popover from 'src/components/popover/popover.vue' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import { take } from 'lodash' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' - +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faSmileBeam @@ -31,6 +33,7 @@ library.add( */ const EmojiInput = { + emits: ['update:modelValue', 'shown'], props: { suggest: { /** @@ -57,8 +60,7 @@ const EmojiInput = { required: true, type: Function }, - // TODO VUE3: change to modelValue, change 'input' event to 'input' - value: { + modelValue: { /** * Used for v-model */ @@ -108,46 +110,122 @@ const EmojiInput = { data () { return { input: undefined, + caretEl: undefined, highlighted: 0, caret: 0, focused: false, blurTimeout: null, - showPicker: false, temporarilyHideSuggestions: false, - keepOpen: false, disableClickOutside: false, - suggestions: [] + suggestions: [], + overlayStyle: {}, + pickerShown: false } }, components: { - EmojiPicker + Popover, + EmojiPicker, + UnicodeDomainIndicator }, computed: { padEmoji () { return this.$store.getters.mergedConfig.padEmoji }, + preText () { + return this.modelValue.slice(0, this.caret) + }, + postText () { + return this.modelValue.slice(this.caret) + }, showSuggestions () { return this.focused && this.suggestions && this.suggestions.length > 0 && - !this.showPicker && + !this.pickerShown && !this.temporarilyHideSuggestions }, textAtCaret () { - return (this.wordAtCaret || {}).word || '' + return this.wordAtCaret?.word }, wordAtCaret () { - if (this.value && this.caret) { - const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} + if (this.modelValue && this.caret) { + const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} return word } + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + }, + maybeLocalizedEmojiNamesAndKeywords () { + return emoji => { + const names = [emoji.displayText] + const keywords = [] + + if (emoji.displayTextI18n) { + names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + } + + if (emoji.annotations) { + this.languages.forEach(lang => { + names.push(emoji.annotations[lang]?.name) + + keywords.push(...(emoji.annotations[lang]?.keywords || [])) + }) + } + + return { + names: names.filter(k => k), + keywords: keywords.filter(k => k) + } + } + }, + maybeLocalizedEmojiName () { + return emoji => { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText + } + }, + onInputScroll () { + this.$refs.hiddenOverlay.scrollTo({ + top: this.input.scrollTop, + left: this.input.scrollLeft + }) } }, mounted () { - const { root } = this.$refs + const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') if (!input) return this.input = input + this.caretEl = hiddenOverlayCaret + if (suggestorPopover.setAnchorEl) { + suggestorPopover.setAnchorEl(this.caretEl) // unit test compat + this.$refs.picker.setAnchorEl(this.caretEl) + } else { + console.warn('setAnchorEl not found, are we in a unit test?') + } + const style = getComputedStyle(this.input) + this.overlayStyle.padding = style.padding + this.overlayStyle.border = style.border + this.overlayStyle.margin = style.margin + this.overlayStyle.lineHeight = style.lineHeight + this.overlayStyle.fontFamily = style.fontFamily + this.overlayStyle.fontSize = style.fontSize + this.overlayStyle.wordWrap = style.wordWrap + this.overlayStyle.whiteSpace = style.whiteSpace this.resize() input.addEventListener('blur', this.onBlur) input.addEventListener('focus', this.onFocus) @@ -157,6 +235,7 @@ const EmojiInput = { input.addEventListener('click', this.onClickInput) input.addEventListener('transitionend', this.onTransition) input.addEventListener('input', this.onInput) + input.addEventListener('scroll', this.onInputScroll) }, unmounted () { const { input } = this @@ -169,43 +248,43 @@ const EmojiInput = { input.removeEventListener('click', this.onClickInput) input.removeEventListener('transitionend', this.onTransition) input.removeEventListener('input', this.onInput) + input.removeEventListener('scroll', this.onInputScroll) } }, watch: { - showSuggestions: function (newValue) { + showSuggestions: function (newValue, oldValue) { this.$emit('shown', newValue) + if (newValue) { + this.$refs.suggestorPopover.showPopover() + } else { + this.$refs.suggestorPopover.hidePopover() + } }, textAtCaret: async function (newWord) { + if (newWord === undefined) return const firstchar = newWord.charAt(0) - this.suggestions = [] - if (newWord === firstchar) return - const matchedSuggestions = await this.suggest(newWord) + if (newWord === firstchar) { + this.suggestions = [] + return + } + const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) // Async: cancel if textAtCaret has changed during wait - if (this.textAtCaret !== newWord) return - if (matchedSuggestions.length <= 0) return + if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) { + this.suggestions = [] + return + } this.suggestions = take(matchedSuggestions, 5) .map(({ imageUrl, ...rest }) => ({ ...rest, img: imageUrl || '' })) - }, - suggestions (newValue) { - this.$nextTick(this.resize) } }, methods: { - focusPickerInput () { - const pickerEl = this.$refs.picker.$el - if (!pickerEl) return - const pickerInput = pickerEl.querySelector('input') - if (pickerInput) pickerInput.focus() - }, triggerShowPicker () { - this.showPicker = true - this.$refs.picker.startEmojiLoad() this.$nextTick(() => { + this.$refs.picker.showPicker() this.scrollIntoView() - this.focusPickerInput() }) // This temporarily disables "click outside" handler // since external trigger also means click originates @@ -217,21 +296,22 @@ const EmojiInput = { }, togglePicker () { this.input.focus() - this.showPicker = !this.showPicker - if (this.showPicker) { + if (!this.pickerShown) { this.scrollIntoView() + this.$refs.picker.showPicker() this.$refs.picker.startEmojiLoad() - this.$nextTick(this.focusPickerInput) + } else { + this.$refs.picker.hidePicker() } }, replace (replacement) { - const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) - this.$emit('input', newValue) + const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement) + this.$emit('update:modelValue', newValue) this.caret = 0 }, insert ({ insertion, keepOpen, surroundingSpace = true }) { - const before = this.value.substring(0, this.caret) || '' - const after = this.value.substring(this.caret) || '' + const before = this.modelValue.substring(0, this.caret) || '' + const after = this.modelValue.substring(this.caret) || '' /* Using a bit more smart approach to padding emojis with spaces: * - put a space before cursor if there isn't one already, unless we @@ -258,8 +338,7 @@ const EmojiInput = { spaceAfter, after ].join('') - this.keepOpen = keepOpen - this.$emit('input', newValue) + this.$emit('update:modelValue', newValue) const position = this.caret + (insertion + spaceAfter + spaceBefore).length if (!keepOpen) { this.input.focus() @@ -278,8 +357,8 @@ const EmojiInput = { if (len > 0 || suggestion) { const chosenSuggestion = suggestion || this.suggestions[this.highlighted] const replacement = chosenSuggestion.replacement - const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) - this.$emit('input', newValue) + const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement) + this.$emit('update:modelValue', newValue) this.highlighted = 0 const position = this.wordAtCaret.start + replacement.length @@ -318,7 +397,7 @@ const EmojiInput = { } }, scrollIntoView () { - const rootRef = this.$refs['picker'].$el + const rootRef = this.$refs.picker.$el /* Scroller is either `window` (replies in TL), sidebar (main post form, * replies in notifs) or mobile post form. Note that getting and setting * scroll is different for `Window` and `Element`s @@ -358,8 +437,11 @@ const EmojiInput = { } }) }, - onTransition (e) { - this.resize() + onPickerShown () { + this.pickerShown = true + }, + onPickerClosed () { + this.pickerShown = false }, onBlur (e) { // Clicking on any suggestion removes focus from autocomplete, @@ -367,7 +449,6 @@ const EmojiInput = { this.blurTimeout = setTimeout(() => { this.focused = false this.setCaret(e) - this.resize() }, 200) }, onClick (e, suggestion) { @@ -379,18 +460,13 @@ const EmojiInput = { this.blurTimeout = null } - if (!this.keepOpen) { - this.showPicker = false - } this.focused = true this.setCaret(e) - this.resize() this.temporarilyHideSuggestions = false }, onKeyUp (e) { const { key } = e this.setCaret(e) - this.resize() // Setting hider in keyUp to prevent suggestions from blinking // when moving away from suggested spot @@ -402,7 +478,6 @@ const EmojiInput = { }, onPaste (e) { this.setCaret(e) - this.resize() }, onKeyDown (e) { const { ctrlKey, shiftKey, key } = e @@ -447,58 +522,24 @@ const EmojiInput = { this.input.focus() } } - - this.showPicker = false - this.resize() }, onInput (e) { - this.showPicker = false this.setCaret(e) - this.resize() - this.$emit('input', e.target.value) - }, - onClickInput (e) { - this.showPicker = false - }, - onClickOutside (e) { - if (this.disableClickOutside) return - this.showPicker = false + this.$emit('update:modelValue', e.target.value) }, onStickerUploaded (e) { - this.showPicker = false this.$emit('sticker-uploaded', e) }, onStickerUploadFailed (e) { - this.showPicker = false this.$emit('sticker-upload-Failed', e) }, setCaret ({ target: { selectionStart } }) { this.caret = selectionStart + this.$nextTick(() => { + this.$refs.suggestorPopover.updateStyles() + }) }, resize () { - 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 - const offsetBottom = offsetTop + offsetHeight - - 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.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 aa2950ce..c9bbc18f 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -1,11 +1,23 @@ <template> <div ref="root" - v-click-outside="onClickOutside" class="emoji-input" :class="{ 'with-picker': !hideEmojiButton }" > <slot /> + <!-- TODO: make the 'x' disappear if at the end maybe? --> + <div + ref="hiddenOverlay" + class="hidden-overlay" + :style="overlayStyle" + > + <span>{{ preText }}</span> + <span + ref="hiddenOverlayCaret" + class="caret" + >x</span> + <span>{{ postText }}</span> + </div> <template v-if="enableEmojiPicker"> <button v-if="!hideEmojiButton" @@ -18,44 +30,61 @@ <EmojiPicker v-if="enableEmojiPicker" ref="picker" - :class="{ hide: !showPicker }" :enable-sticker-picker="enableStickerPicker" class="emoji-picker-panel" @emoji="insert" @sticker-uploaded="onStickerUploaded" @sticker-upload-failed="onStickerUploadFailed" + @show="onPickerShown" + @close="onPickerClosed" /> </template> - <div - ref="panel" + <Popover + ref="suggestorPopover" class="autocomplete-panel" - :class="{ hide: !showSuggestions }" + placement="bottom" > - <div - ref="panel-body" - class="autocomplete-panel-body" - > + <template #content> <div - v-for="(suggestion, index) in suggestions" - :key="index" - class="autocomplete-item" - :class="{ highlighted: index === highlighted }" - @click.stop.prevent="onClick($event, suggestion)" + ref="panel-body" + class="autocomplete-panel-body" > - <span class="image"> - <img - v-if="suggestion.img" - :src="suggestion.img" - > - <span v-else>{{ suggestion.replacement }}</span> - </span> - <div class="label"> - <span class="displayText">{{ suggestion.displayText }}</span> - <span class="detailText">{{ suggestion.detailText }}</span> + <div + v-for="(suggestion, index) in suggestions" + :key="index" + class="autocomplete-item" + :class="{ highlighted: index === highlighted }" + @click.stop.prevent="onClick($event, suggestion)" + > + <span class="image"> + <img + v-if="suggestion.img" + :src="suggestion.img" + > + <span v-else>{{ suggestion.replacement }}</span> + </span> + <div class="label"> + <span + v-if="suggestion.user" + class="displayText" + > + {{ suggestion.displayText }}<UnicodeDomainIndicator + :user="suggestion.user" + :at="false" + /> + </span> + <span + v-if="!suggestion.user" + class="displayText" + > + {{ maybeLocalizedEmojiName(suggestion) }} + </span> + <span class="detailText">{{ suggestion.detailText }}</span> + </div> </div> </div> - </div> - </div> + </template> + </Popover> </div> </template> @@ -78,7 +107,7 @@ top: 0; right: 0; margin: .2em .25em; - font-size: 16px; + font-size: 1.3em; cursor: pointer; line-height: 24px; @@ -87,6 +116,7 @@ color: var(--text, $fallback--text); } } + .emoji-picker-panel { position: absolute; z-index: 20; @@ -97,89 +127,83 @@ } } - .autocomplete { - &-panel { - position: absolute; - z-index: 20; - margin-top: 2px; - - &.hide { - display: none - } + input, textarea { + flex: 1 0 auto; + } - &-body { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - box-shadow: var(--popupShadow); - min-width: 75%; - background-color: $fallback--bg; - background-color: var(--popover, $fallback--bg); - color: $fallback--link; - color: var(--popoverText, $fallback--link); - --faint: var(--popoverFaintText, $fallback--faint); - --faintLink: var(--popoverFaintLink, $fallback--faint); - --lightText: var(--popoverLightText, $fallback--lightText); - --postLink: var(--popoverPostLink, $fallback--link); - --postFaintLink: var(--popoverPostFaintLink, $fallback--link); - --icon: var(--popoverIcon, $fallback--icon); - } + .hidden-overlay { + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + overflow: hidden; + /* DEBUG STUFF */ + color: red; + /* set opacity to non-zero to see the overlay */ + + .caret { + width: 0; + margin-right: calc(-1ch - 1px); + border: 1px solid red; } + } +} +.autocomplete { + &-panel { + position: absolute; + } - &-item { - display: flex; - cursor: pointer; - padding: 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); + &-item { + display: flex; + cursor: pointer; + padding: 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + height: 32px; + + .image { + width: 32px; height: 32px; + line-height: 32px; + text-align: center; + font-size: 32px; - .image { + margin-right: 4px; + + img { width: 32px; height: 32px; - line-height: 32px; - text-align: center; - font-size: 32px; - - margin-right: 4px; - - img { - width: 32px; - height: 32px; - object-fit: contain; - } + object-fit: contain; } + } - .label { - display: flex; - flex-direction: column; - justify-content: center; - margin: 0 0.1em 0 0.2em; - - .displayText { - line-height: 1.5; - } + .label { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0.1em 0 0.2em; - .detailText { - font-size: 9px; - line-height: 9px; - } + .displayText { + line-height: 1.5; } - &.highlighted { - background-color: $fallback--fg; - background-color: var(--selectedMenuPopover, $fallback--fg); - color: var(--selectedMenuPopoverText, $fallback--text); - --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); - --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); - --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); - --icon: var(--selectedMenuPopoverIcon, $fallback--icon); + .detailText { + font-size: 9px; + line-height: 9px; } } - } - input, textarea { - flex: 1 0 auto; + &.highlighted { + background-color: $fallback--fg; + background-color: var(--selectedMenuPopover, $fallback--fg); + color: var(--selectedMenuPopoverText, $fallback--text); + --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); + --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); + --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); + --icon: var(--selectedMenuPopoverIcon, $fallback--icon); + } } } </style> diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js index e8efbd1e..adaa879e 100644 --- a/src/components/emoji_input/suggestor.js +++ b/src/components/emoji_input/suggestor.js @@ -2,7 +2,7 @@ * suggest - generates a suggestor function to be used by emoji-input * data: object providing source information for specific types of suggestions: * data.emoji - optional, an array of all emoji available i.e. - * (state.instance.emoji + state.instance.customEmoji) + * (getters.standardEmojiList + state.instance.customEmoji) * data.users - optional, an array of all known users * updateUsersList - optional, a function to search and append to users * @@ -13,10 +13,10 @@ export default data => { const emojiCurry = suggestEmoji(data.emoji) const usersCurry = data.store && suggestUsers(data.store) - return input => { + return (input, nameKeywordLocalizer) => { const firstChar = input[0] if (firstChar === ':' && data.emoji) { - return emojiCurry(input) + return emojiCurry(input, nameKeywordLocalizer) } if (firstChar === '@' && usersCurry) { return usersCurry(input) @@ -25,34 +25,34 @@ export default data => { } } -export const suggestEmoji = emojis => input => { +export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => { const noPrefix = input.toLowerCase().substr(1) return emojis - .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix)) - .sort((a, b) => { - let aScore = 0 - let bScore = 0 + .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) })) + .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length) + .map(k => { + let score = 0 // An exact match always wins - aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0 - bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0 + score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0) // Prioritize custom emoji a lot - aScore += a.imageUrl ? 100 : 0 - bScore += b.imageUrl ? 100 : 0 + score += k.imageUrl ? 100 : 0 // Prioritize prefix matches somewhat - aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 - bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 + score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0) // Sort by length - aScore -= a.displayText.length - bScore -= b.displayText.length + score -= k.displayText.length + k.score = score + return k + }) + .sort((a, b) => { // Break ties alphabetically const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5 - return bScore - aScore + alphabetically + return b.score - a.score + alphabetically }) } @@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => { return diff + nameAlphabetically + screenNameAlphabetically /* eslint-disable camelcase */ - }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ - displayText: screen_name_ui, - detailText: name, - imageUrl: profile_image_url_original, - replacement: '@' + screen_name + ' ' + }).map((user) => ({ + user, + displayText: user.screen_name_ui, + detailText: user.name, + imageUrl: user.profile_image_url_original, + replacement: '@' + user.screen_name + ' ' })) /* eslint-enable camelcase */ diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 2716d93f..dd5e5217 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,31 +1,77 @@ +import { defineAsyncComponent } from 'vue' import Checkbox from '../checkbox/checkbox.vue' +import Popover from 'src/components/popover/popover.vue' +import StillImage from '../still-image/still-image.vue' +import { ensureFinalFallback } from '../../i18n/languages.js' +import lozad from 'lozad' import { library } from '@fortawesome/fontawesome-svg-core' import { faBoxOpen, faStickyNote, - faSmileBeam + faSmileBeam, + faSmile, + faUser, + faPaw, + faIceCream, + faBus, + faBasketballBall, + faLightbulb, + faCode, + faFlag } from '@fortawesome/free-solid-svg-icons' +import { debounce, trim } from 'lodash' library.add( faBoxOpen, faStickyNote, - faSmileBeam + faSmileBeam, + faSmile, + faUser, + faPaw, + faIceCream, + faBus, + faBasketballBall, + faLightbulb, + faCode, + faFlag ) -// At widest, approximately 20 emoji are visible in a row, -// loading 3 rows, could be overkill for narrow picker -const LOAD_EMOJI_BY = 60 +const UNICODE_EMOJI_GROUP_ICON = { + 'smileys-and-emotion': 'smile', + 'people-and-body': 'user', + 'animals-and-nature': 'paw', + 'food-and-drink': 'ice-cream', + 'travel-and-places': 'bus', + activities: 'basketball-ball', + objects: 'lightbulb', + symbols: 'code', + flags: 'flag' +} -// When to start loading new batch emoji, in pixels -const LOAD_EMOJI_MARGIN = 64 +const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => { + const res = [emoji.displayText, nameLocalizer(emoji)] + if (emoji.annotations) { + languages.forEach(lang => { + const keywords = emoji.annotations[lang]?.keywords || [] + const name = emoji.annotations[lang]?.name + res.push(...(keywords.concat([name]).filter(k => k))) + }) + } + return res +} -const filterByKeyword = (list, keyword = '') => { +const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => { if (keyword === '') return list const keywordLowercase = keyword.toLowerCase() - let orderedEmojiList = [] + const orderedEmojiList = [] for (const emoji of list) { - const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase) + const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer) + .map(k => k.toLowerCase().indexOf(keywordLowercase)) + .filter(k => k > -1) + + const indexOfKeyword = indices.length ? Math.min(...indices) : -1 + if (indexOfKeyword > -1) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { orderedEmojiList[indexOfKeyword] = [] @@ -51,16 +97,43 @@ const EmojiPicker = { showingStickers: false, groupsScrolledClass: 'scrolled-top', keepOpen: false, - customEmojiBufferSlice: LOAD_EMOJI_BY, customEmojiTimeout: null, - customEmojiLoadAllConfirmed: false + // Lazy-load only after the first time `showing` becomes true. + contentLoaded: false, + groupRefs: {}, + emojiRefs: {}, + filteredEmojiGroups: [] } }, components: { - StickerPicker: () => import('../sticker_picker/sticker_picker.vue'), - Checkbox + StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), + Checkbox, + StillImage, + Popover }, methods: { + showPicker () { + this.$refs.popover.showPopover() + this.onShowing() + }, + hidePicker () { + this.$refs.popover.hidePopover() + }, + setAnchorEl (el) { + this.$refs.popover.setAnchorEl(el) + }, + setGroupRef (name) { + return el => { this.groupRefs[name] = el } + }, + setEmojiRef (name) { + return el => { this.emojiRefs[name] = el } + }, + onPopoverShown () { + this.$emit('show') + }, + onPopoverClosed () { + this.$emit('close') + }, onStickerUploaded (e) { this.$emit('sticker-uploaded', e) }, @@ -69,17 +142,48 @@ const EmojiPicker = { }, onEmoji (emoji) { const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement + if (!this.keepOpen) { + this.$refs.popover.hidePopover() + } this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) }, onScroll (e) { const target = (e && e.target) || this.$refs['emoji-groups'] this.updateScrolledClass(target) this.scrolledGroup(target) - this.triggerLoadMore(target) + }, + scrolledGroup (target) { + const top = target.scrollTop + 5 + this.$nextTick(() => { + this.allEmojiGroups.forEach(group => { + const ref = this.groupRefs['group-' + group.id] + if (ref && ref.offsetTop <= top) { + this.activeGroup = group.id + } + }) + this.scrollHeader() + }) + }, + scrollHeader () { + // Scroll the active tab's header into view + const headerRef = this.groupRefs['group-header-' + this.activeGroup] + const left = headerRef.offsetLeft + const right = left + headerRef.offsetWidth + const headerCont = this.$refs.header + const currentScroll = headerCont.scrollLeft + const currentScrollRight = currentScroll + headerCont.clientWidth + const setScroll = s => { headerCont.scrollLeft = s } + + const margin = 7 // .emoji-tabs-item: padding + if (left - margin < currentScroll) { + setScroll(left - margin) + } else if (right + margin > currentScrollRight) { + setScroll(right + margin - headerCont.clientWidth) + } }, highlight (key) { - const ref = this.$refs['group-' + key] - const top = ref[0].offsetTop + const ref = this.groupRefs['group-' + key] + const top = ref.offsetTop this.setShowStickers(false) this.activeGroup = key this.$nextTick(() => { @@ -95,73 +199,83 @@ const EmojiPicker = { this.groupsScrolledClass = 'scrolled-middle' } }, - triggerLoadMore (target) { - const ref = this.$refs['group-end-custom'][0] - if (!ref) return - const bottom = ref.offsetTop + ref.offsetHeight - - const scrollerBottom = target.scrollTop + target.clientHeight - const scrollerTop = target.scrollTop - const scrollerMax = target.scrollHeight - - // Loads more emoji when they come into view - const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN - // Always load when at the very top in case there's no scroll space yet - const atTop = scrollerTop < 5 - // Don't load when looking at unicode category or at the very bottom - const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax - if (!bottomAboveViewport && (approachingBottom || atTop)) { - this.loadEmoji() - } + toggleStickers () { + this.showingStickers = !this.showingStickers }, - scrolledGroup (target) { - const top = target.scrollTop + 5 + setShowStickers (value) { + this.showingStickers = value + }, + filterByKeyword (list, keyword) { + return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName) + }, + initializeLazyLoad () { + this.destroyLazyLoad() this.$nextTick(() => { - this.emojisView.forEach(group => { - const ref = this.$refs['group-' + group.id] - if (ref[0].offsetTop <= top) { - this.activeGroup = group.id + this.$lozad = lozad('.still-image.emoji-picker-emoji', { + load: el => { + const name = el.getAttribute('data-emoji-name') + const vn = this.emojiRefs[name] + if (!vn) { + return + } + + vn.loadLazy() } }) + this.$lozad.observe() }) }, - loadEmoji () { - const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length - - if (allLoaded) { - return - } - - this.customEmojiBufferSlice += LOAD_EMOJI_BY + waitForDomAndInitializeLazyLoad () { + this.$nextTick(() => this.initializeLazyLoad()) }, - startEmojiLoad (forceUpdate = false) { - if (!forceUpdate) { - this.keyword = '' + destroyLazyLoad () { + if (this.$lozad) { + if (this.$lozad.observer) { + this.$lozad.observer.disconnect() + } + if (this.$lozad.mutationObserver) { + this.$lozad.mutationObserver.disconnect() + } } + }, + onShowing () { + const oldContentLoaded = this.contentLoaded this.$nextTick(() => { - this.$refs['emoji-groups'].scrollTop = 0 + this.$refs.search.focus() }) - const bufferSize = this.customEmojiBuffer.length - const bufferPrefilledAll = bufferSize === this.filteredEmoji.length - if (bufferPrefilledAll && !forceUpdate) { - return + this.contentLoaded = true + this.waitForDomAndInitializeLazyLoad() + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + if (!oldContentLoaded) { + this.$nextTick(() => { + if (this.defaultGroup) { + this.highlight(this.defaultGroup) + } + }) } - this.customEmojiBufferSlice = LOAD_EMOJI_BY }, - toggleStickers () { - this.showingStickers = !this.showingStickers - }, - setShowStickers (value) { - this.showingStickers = value + getFilteredEmojiGroups () { + return this.allEmojiGroups + .map(group => ({ + ...group, + emojis: this.filterByKeyword(group.emojis, trim(this.keyword)) + })) + .filter(group => group.emojis.length > 0) } }, watch: { keyword () { - this.customEmojiLoadAllConfirmed = false this.onScroll() - this.startEmojiLoad(true) + this.debouncedHandleKeywordChange() + }, + allCustomGroups () { + this.waitForDomAndInitializeLazyLoad() + this.filteredEmojiGroups = this.getFilteredEmojiGroups() } }, + destroyed () { + this.destroyLazyLoad() + }, computed: { activeGroupView () { return this.showingStickers ? '' : this.activeGroup @@ -172,39 +286,55 @@ const EmojiPicker = { } return 0 }, - filteredEmoji () { - return filterByKeyword( - this.$store.state.instance.customEmoji || [], - this.keyword - ) + allCustomGroups () { + return this.$store.getters.groupedCustomEmojis }, - customEmojiBuffer () { - return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) + defaultGroup () { + return Object.keys(this.allCustomGroups)[0] }, - emojis () { - const standardEmojis = this.$store.state.instance.emoji || [] - const customEmojis = this.customEmojiBuffer - - return [ - { - id: 'custom', - text: this.$t('emoji.custom'), - icon: 'smile-beam', - emojis: customEmojis - }, - { - id: 'standard', - text: this.$t('emoji.unicode'), - icon: 'box-open', - emojis: filterByKeyword(standardEmojis, this.keyword) - } - ] + unicodeEmojiGroups () { + return this.$store.getters.standardEmojiGroupList.map(group => ({ + id: `standard-${group.id}`, + text: this.$t(`emoji.unicode_groups.${group.id}`), + icon: UNICODE_EMOJI_GROUP_ICON[group.id], + emojis: group.emojis + })) }, - emojisView () { - return this.emojis.filter(value => value.emojis.length > 0) + allEmojiGroups () { + return Object.entries(this.allCustomGroups) + .map(([_, v]) => v) + .concat(this.unicodeEmojiGroups) }, stickerPickerEnabled () { return (this.$store.state.instance.stickers || []).length !== 0 + }, + debouncedHandleKeywordChange () { + return debounce(() => { + this.waitForDomAndInitializeLazyLoad() + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + }, 500) + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + }, + maybeLocalizedEmojiName () { + return emoji => { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText + } } } } diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index ec711758..53363ec1 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -1,13 +1,15 @@ @import '../../_variables.scss'; +$emoji-picker-header-height: 36px; +$emoji-picker-header-picture-width: 32px; +$emoji-picker-header-picture-height: 32px; +$emoji-picker-emoji-size: 32px; + .emoji-picker { + width: 25em; + max-width: 100vw; display: flex; flex-direction: column; - position: absolute; - right: 0; - left: 0; - margin: 0 !important; - z-index: 1; background-color: $fallback--bg; background-color: var(--popover, $fallback--bg); color: $fallback--link; @@ -18,6 +20,23 @@ --lightText: var(--popoverLightText, $fallback--lightText); --icon: var(--popoverIcon, $fallback--icon); + &-header-image { + display: inline-flex; + justify-content: center; + align-items: center; + width: $emoji-picker-header-picture-width; + max-width: $emoji-picker-header-picture-width; + height: $emoji-picker-header-picture-height; + max-height: $emoji-picker-header-picture-height; + .still-image { + max-width: 100%; + max-height: 100%; + height: 100%; + width: 100%; + object-fit: contain; + } + } + .keep-open, .too-many-emoji { padding: 7px; @@ -36,7 +55,6 @@ .heading { display: flex; - height: 32px; padding: 10px 7px 5px; } @@ -49,6 +67,10 @@ .emoji-tabs { flex-grow: 1; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; } .emoji-groups { @@ -56,6 +78,8 @@ } .additional-tabs { + display: flex; + flex: 1; border-left: 1px solid; border-left-color: $fallback--icon; border-left-color: var(--icon, $fallback--icon); @@ -65,20 +89,26 @@ .additional-tabs, .emoji-tabs { - display: block; - min-width: 0; flex-basis: auto; - flex-shrink: 1; + display: flex; + align-content: center; &-item { padding: 0 7px; cursor: pointer; - font-size: 24px; + font-size: 1.85em; + width: $emoji-picker-header-picture-width; + max-width: $emoji-picker-header-picture-width; + height: $emoji-picker-header-picture-height; + max-height: $emoji-picker-header-picture-height; + display: flex; + align-items: center; &.disabled { opacity: 0.5; pointer-events: none; } + &.active { border-bottom: 4px solid; @@ -151,9 +181,10 @@ justify-content: left; &-title { - font-size: 12px; + font-size: 0.85em; width: 100%; margin: 0; + &.disabled { display: none; } @@ -161,22 +192,26 @@ } &-item { - width: 32px; - height: 32px; + width: $emoji-picker-emoji-size; + height: $emoji-picker-emoji-size; box-sizing: border-box; display: flex; - font-size: 32px; + line-height: $emoji-picker-emoji-size; align-items: center; justify-content: center; margin: 4px; cursor: pointer; - img { + .emoji-picker-emoji.-custom { object-fit: contain; max-width: 100%; max-height: 100%; } + .emoji-picker-emoji.-unicode { + font-size: 24px; + overflow: hidden; + } } } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 3262a3d9..ff56d637 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -1,104 +1,136 @@ <template> - <div class="emoji-picker panel panel-default panel-body"> - <div class="heading"> - <span class="emoji-tabs"> + <Popover + ref="popover" + trigger="click" + popover-class="emoji-picker popover-default" + @show="onPopoverShown" + @close="onPopoverClosed" + > + <template #content> + <div class="heading"> <span - v-for="group in emojis" - :key="group.id" - class="emoji-tabs-item" - :class="{ - active: activeGroupView === group.id, - disabled: group.emojis.length === 0 - }" - :title="group.text" - @click.prevent="highlight(group.id)" + ref="header" + class="emoji-tabs" > - <FAIcon - :icon="group.icon" - fixed-width - /> + <span + v-for="group in filteredEmojiGroups" + :ref="setGroupRef('group-header-' + group.id)" + :key="group.id" + class="emoji-tabs-item" + :class="{ + active: activeGroupView === group.id + }" + :title="group.text" + @click.prevent="highlight(group.id)" + > + <span + v-if="group.image" + class="emoji-picker-header-image" + > + <still-image + :alt="group.text" + :src="group.image" + /> + </span> + <FAIcon + v-else + :icon="group.icon" + fixed-width + /> + </span> </span> - </span> - <span - v-if="stickerPickerEnabled" - class="additional-tabs" - > <span - class="stickers-tab-icon additional-tabs-item" - :class="{active: showingStickers}" - :title="$t('emoji.stickers')" - @click.prevent="toggleStickers" + v-if="stickerPickerEnabled" + class="additional-tabs" > - <FAIcon - icon="sticky-note" - fixed-width - /> + <span + class="stickers-tab-icon additional-tabs-item" + :class="{active: showingStickers}" + :title="$t('emoji.stickers')" + @click.prevent="toggleStickers" + > + <FAIcon + icon="sticky-note" + fixed-width + /> + </span> </span> - </span> - </div> - <div class="content"> + </div> <div - class="emoji-content" - :class="{hidden: showingStickers}" + v-if="contentLoaded" + class="content" > - <div class="emoji-search"> - <input - v-model="keyword" - type="text" - class="form-control" - :placeholder="$t('emoji.search_emoji')" - > - </div> <div - ref="emoji-groups" - class="emoji-groups" - :class="groupsScrolledClass" - @scroll="onScroll" + class="emoji-content" + :class="{hidden: showingStickers}" > + <div class="emoji-search"> + <input + ref="search" + v-model="keyword" + type="text" + class="form-control" + :placeholder="$t('emoji.search_emoji')" + @input="$event.target.composing = false" + > + </div> <div - v-for="group in emojisView" - :key="group.id" - class="emoji-group" + ref="emoji-groups" + class="emoji-groups" + :class="groupsScrolledClass" + @scroll="onScroll" > - <h6 - :ref="'group-' + group.id" - class="emoji-group-title" - > - {{ group.text }} - </h6> - <span - v-for="emoji in group.emojis" - :key="group.id + emoji.displayText" - :title="emoji.displayText" - class="emoji-item" - @click.stop.prevent="onEmoji(emoji)" + <div + v-for="group in filteredEmojiGroups" + :key="group.id" + class="emoji-group" > - <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> - <img - v-else - :src="emoji.imageUrl" + <h6 + :ref="setGroupRef('group-' + group.id)" + class="emoji-group-title" > - </span> - <span :ref="'group-end-' + group.id" /> + {{ group.text }} + </h6> + <span + v-for="emoji in group.emojis" + :key="group.id + emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" + class="emoji-item" + @click.stop.prevent="onEmoji(emoji)" + > + <span + v-if="!emoji.imageUrl" + class="emoji-picker-emoji -unicode" + >{{ emoji.replacement }}</span> + <still-image + v-else + :ref="setEmojiRef(group.id + emoji.displayText)" + class="emoji-picker-emoji -custom" + :data-src="emoji.imageUrl" + :data-emoji-name="group.id + emoji.displayText" + /> + </span> + <span :ref="setGroupRef('group-end-' + group.id)" /> + </div> + </div> + <div class="keep-open"> + <Checkbox v-model="keepOpen"> + {{ $t('emoji.keep_open') }} + </Checkbox> </div> </div> - <div class="keep-open"> - <Checkbox v-model="keepOpen"> - {{ $t('emoji.keep_open') }} - </Checkbox> + <div + v-if="showingStickers" + class="stickers-content" + > + <sticker-picker + @uploaded="onStickerUploaded" + @upload-failed="onStickerUploadFailed" + /> </div> </div> - <div - v-if="showingStickers" - class="stickers-content" - > - <sticker-picker - @uploaded="onStickerUploaded" - @upload-failed="onStickerUploadFailed" - /> - </div> - </div> - </div> + </template> + </Popover> </template> <script src="./emoji_picker.js"></script> diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index 51d50359..4eb22a65 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -1,5 +1,5 @@ <template> - <div class="emoji-reactions"> + <div class="EmojiReactions"> <UserListPopover v-for="(reaction) in emojiReactions" :key="reaction.name" @@ -7,7 +7,7 @@ > <button class="emoji-reaction btn button-default" - :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" + :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" @click="emojiOnClick(reaction.name, $event)" @mouseenter="fetchEmojiReactionsByIfMissing()" > @@ -26,57 +26,59 @@ </div> </template> -<script src="./emoji_reactions.js" ></script> +<script src="./emoji_reactions.js"></script> <style lang="scss"> @import '../../_variables.scss'; -.emoji-reactions { +.EmojiReactions { display: flex; margin-top: 0.25em; flex-wrap: wrap; -} -.emoji-reaction { - padding: 0 0.5em; - margin-right: 0.5em; - margin-top: 0.5em; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - .reaction-emoji { - width: 1.25em; - margin-right: 0.25em; - } - &:focus { - outline: none; - } + .emoji-reaction { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; - &.not-clickable { - cursor: default; - &:hover { - box-shadow: $fallback--buttonShadow; - box-shadow: var(--buttonShadow); + .reaction-emoji { + width: 1.25em; + margin-right: 0.25em; + } + + &:focus { + outline: none; + } + + &.not-clickable { + cursor: default; + &:hover { + box-shadow: $fallback--buttonShadow; + box-shadow: var(--buttonShadow); + } + } + + &.-picked-reaction { + border: 1px solid var(--accent, $fallback--link); + margin-left: -1px; // offset the border, can't use inset shadows either + margin-right: calc(0.5em - 1px); } } -} -.emoji-reaction-expand { - padding: 0 0.5em; - margin-right: 0.5em; - margin-top: 0.5em; - display: flex; - align-items: center; - justify-content: center; - &:hover { - text-decoration: underline; + .emoji-reaction-expand { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + &:hover { + text-decoration: underline; + } } -} -.picked-reaction { - border: 1px solid var(--accent, $fallback--link); - margin-left: -1px; // offset the border, can't use inset shadows either - margin-right: calc(0.5em - 1px); } - </style> diff --git a/src/components/exporter/exporter.js b/src/components/exporter/exporter.js index 51912ac3..fc75372e 100644 --- a/src/components/exporter/exporter.js +++ b/src/components/exporter/exporter.js @@ -15,18 +15,8 @@ const Exporter = { type: String, default: 'export.csv' }, - exportButtonLabel: { - type: String, - default () { - return this.$t('exporter.export') - } - }, - processingMessage: { - type: String, - default () { - return this.$t('exporter.processing') - } - } + exportButtonLabel: { type: String }, + processingMessage: { type: String } }, data () { return { diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue index d6a03088..79defdf6 100644 --- a/src/components/exporter/exporter.vue +++ b/src/components/exporter/exporter.vue @@ -7,14 +7,14 @@ spin /> - <span>{{ processingMessage }}</span> + <span>{{ processingMessage || $t('exporter.processing') }}</span> </div> <button v-else class="btn button-default" @click="process" > - {{ exportButtonLabel }} + {{ exportButtonLabel || $t('exporter.export') }} </button> </div> </template> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index dd45b6b9..3dc968c9 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -6,7 +6,10 @@ import { faEyeSlash, faThumbtack, faShareAlt, - faExternalLinkAlt + faExternalLinkAlt, + faHistory, + faPlus, + faTimes } from '@fortawesome/free-solid-svg-icons' import { faBookmark as faBookmarkReg, @@ -21,13 +24,27 @@ library.add( faThumbtack, faShareAlt, faExternalLinkAlt, - faFlag + faFlag, + faHistory, + faPlus, + faTimes ) const ExtraButtons = { - props: [ 'status' ], + props: ['status'], components: { Popover }, + data () { + return { + expanded: false + } + }, methods: { + onShow () { + this.expanded = true + }, + onClose () { + this.expanded = false + }, deleteStatus () { const confirmed = window.confirm(this.$t('status.delete_confirm')) if (confirmed) { @@ -71,14 +88,32 @@ const ExtraButtons = { }, reportStatus () { this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] }) + }, + editStatus () { + this.$store.dispatch('fetchStatusSource', { id: this.status.id }) + .then(data => this.$store.dispatch('openEditStatusModal', { + statusId: this.status.id, + subject: data.spoiler_text, + statusText: data.text, + statusIsSensitive: this.status.nsfw, + statusPoll: this.status.poll, + statusFiles: [...this.status.attachments], + visibility: this.status.visibility, + statusContentType: data.content_type + })) + }, + showStatusHistory () { + const originalStatus = { ...this.status } + const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html'] + stripFieldsList.forEach(p => delete originalStatus[p]) + this.$store.dispatch('openStatusHistoryModal', originalStatus) } }, computed: { currentUser () { return this.$store.state.users.currentUser }, canDelete () { if (!this.currentUser) { return } - const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin - return superuser || this.status.user.id === this.currentUser.id + return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id }, ownStatus () { return this.status.user.id === this.currentUser.id @@ -89,9 +124,16 @@ const ExtraButtons = { canMute () { return !!this.currentUser }, + canBookmark () { + return !!this.currentUser + }, statusLink () { return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}` - } + }, + isEdited () { + return this.status.edited_at !== null + }, + editingAvailable () { return this.$store.state.instance.editingAvailable } } } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index a3c3c767..b2fad1c9 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -6,8 +6,10 @@ :offset="{ y: 5 }" :bound-to="{ x: 'container' }" remove-padding + @show="onShow" + @close="onClose" > - <template v-slot:content="{close}"> + <template #content="{close}"> <div class="dropdown-menu"> <button v-if="canMute && !status.thread_muted" @@ -51,27 +53,51 @@ icon="thumbtack" /><span>{{ $t("status.unpin") }}</span> </button> + <template v-if="canBookmark"> + <button + v-if="!status.bookmarked" + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="bookmarkStatus" + @click="close" + > + <FAIcon + fixed-width + :icon="['far', 'bookmark']" + /><span>{{ $t("status.bookmark") }}</span> + </button> + <button + v-if="status.bookmarked" + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="unbookmarkStatus" + @click="close" + > + <FAIcon + fixed-width + icon="bookmark" + /><span>{{ $t("status.unbookmark") }}</span> + </button> + </template> <button - v-if="!status.bookmarked" + v-if="ownStatus && editingAvailable" class="button-default dropdown-item dropdown-item-icon" - @click.prevent="bookmarkStatus" + @click.prevent="editStatus" @click="close" > <FAIcon fixed-width - :icon="['far', 'bookmark']" - /><span>{{ $t("status.bookmark") }}</span> + icon="pen" + /><span>{{ $t("status.edit") }}</span> </button> <button - v-if="status.bookmarked" + v-if="isEdited && editingAvailable" class="button-default dropdown-item dropdown-item-icon" - @click.prevent="unbookmarkStatus" + @click.prevent="showStatusHistory" @click="close" > <FAIcon fixed-width - icon="bookmark" - /><span>{{ $t("status.unbookmark") }}</span> + icon="history" + /><span>{{ $t("status.status_history") }}</span> </button> <button v-if="canDelete" @@ -118,21 +144,36 @@ </button> </div> </template> - <template v-slot:trigger> - <button class="button-unstyled popover-trigger"> - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="ellipsis-h" - /> - </button> + <template #trigger> + <span class="button-unstyled popover-trigger"> + <FALayers class="fa-old-padding-layer"> + <FAIcon + class="fa-scale-110 " + icon="ellipsis-h" + /> + <FAIcon + v-show="!expanded" + class="focus-marker" + transform="shrink-6 up-8 right-16" + icon="plus" + /> + <FAIcon + v-show="expanded" + class="focus-marker" + transform="shrink-6 up-8 right-16" + icon="times" + /> + </FALayers> + </span> </template> </Popover> </template> -<script src="./extra_buttons.js" ></script> +<script src="./extra_buttons.js"></script> <style lang="scss"> @import '../../_variables.scss'; +@import '../../_mixins.scss'; .ExtraButtons { /* override of popover internal stuff */ @@ -149,6 +190,21 @@ color: $fallback--text; color: var(--text, $fallback--text); } + + } + + .popover-trigger-button { + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + } } } </style> diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js index 5cd05f73..cf3378c9 100644 --- a/src/components/favorite_button/favorite_button.js +++ b/src/components/favorite_button/favorite_button.js @@ -1,13 +1,21 @@ import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' -import { faStar } from '@fortawesome/free-solid-svg-icons' +import { + faStar, + faPlus, + faMinus, + faCheck +} from '@fortawesome/free-solid-svg-icons' import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons' library.add( faStar, - faStarRegular + faStarRegular, + faPlus, + faMinus, + faCheck ) const FavoriteButton = { @@ -31,7 +39,10 @@ const FavoriteButton = { } }, computed: { - ...mapGetters(['mergedConfig']) + ...mapGetters(['mergedConfig']), + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) + } } } diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index dce25e24..ea01720a 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -7,19 +7,45 @@ :title="$t('tool_tip.favorite')" @click.prevent="favorite()" > - <FAIcon - class="fa-scale-110 fa-old-padding" - :icon="[status.favorited ? 'fas' : 'far', 'star']" - :spin="animated" - /> + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon + class="fa-scale-110" + :icon="[status.favorited ? 'fas' : 'far', 'star']" + :spin="animated" + /> + <FAIcon + v-if="status.favorited" + class="active-marker" + transform="shrink-6 up-9 right-12" + icon="check" + /> + <FAIcon + v-if="!status.favorited" + class="focus-marker" + transform="shrink-6 up-9 right-12" + icon="plus" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-9 right-12" + icon="minus" + /> + </FALayers> </button> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon class="fa-scale-110 fa-old-padding" :title="$t('tool_tip.favorite')" :icon="['far', 'star']" /> - </span> + </a> <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0" class="action-counter" @@ -29,10 +55,11 @@ </div> </template> -<script src="./favorite_button.js" ></script> +<script src="./favorite_button.js"></script> <style lang="scss"> @import '../../_variables.scss'; +@import '../../_mixins.scss'; .FavoriteButton { display: flex; @@ -57,6 +84,26 @@ color: $fallback--cOrange; color: var(--cOrange, $fallback--cOrange); } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + + .active-marker { + visibility: visible; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + + .active-marker { + visibility: hidden; + } + } } } </style> diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue index a58a99af..4cdf56d0 100644 --- a/src/components/features_panel/features_panel.vue +++ b/src/components/features_panel/features_panel.vue @@ -32,7 +32,7 @@ </div> </template> -<script src="./features_panel.js" ></script> +<script src="./features_panel.js"></script> <style lang="scss"> .features-panel li { diff --git a/src/components/flash/flash.js b/src/components/flash/flash.js index d03384c7..87c1d650 100644 --- a/src/components/flash/flash.js +++ b/src/components/flash/flash.js @@ -11,7 +11,7 @@ library.add( ) const Flash = { - props: [ 'src' ], + props: ['src'], data () { return { player: false, // can be true, "hidden", false. hidden = element exists @@ -39,12 +39,13 @@ const Flash = { this.player = 'error' }) this.ruffleInstance = player + this.$emit('playerOpened') }) }, closePlayer () { - console.log(this.ruffleInstance) - this.ruffleInstance.remove() + this.ruffleInstance && this.ruffleInstance.remove() this.player = false + this.$emit('playerClosed') } } } diff --git a/src/components/flash/flash.vue b/src/components/flash/flash.vue index d20d037b..95f71950 100644 --- a/src/components/flash/flash.vue +++ b/src/components/flash/flash.vue @@ -36,13 +36,6 @@ </p> </span> </button> - <button - v-if="player" - class="button-unstyled hider" - @click="closePlayer" - > - <FAIcon icon="stop" /> - </button> </div> </template> @@ -51,8 +44,9 @@ <style lang="scss"> @import '../../_variables.scss'; .Flash { + display: inline-block; width: 100%; - height: 260px; + height: 100%; position: relative; .player { @@ -60,6 +54,16 @@ width: 100%; } + .placeholder { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + color: var(--link); + } + .hider { top: 0; } @@ -76,13 +80,5 @@ display: none; visibility: 'hidden'; } - - .placeholder { - height: 100%; - flex: 1; - display: flex; - align-items: center; - justify-content: center; - } } </style> diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js index df42692b..3edbcb86 100644 --- a/src/components/follow_button/follow_button.js +++ b/src/components/follow_button/follow_button.js @@ -1,6 +1,6 @@ import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' export default { - props: ['relationship', 'labelFollowing', 'buttonClass'], + props: ['relationship', 'user', 'labelFollowing', 'buttonClass'], data () { return { inProgress: false @@ -29,6 +29,9 @@ export default { } else { return this.$t('user_card.follow') } + }, + disabled () { + return this.inProgress || this.user.deactivated } }, methods: { diff --git a/src/components/follow_button/follow_button.vue b/src/components/follow_button/follow_button.vue index 7f85f1d7..965d5256 100644 --- a/src/components/follow_button/follow_button.vue +++ b/src/components/follow_button/follow_button.vue @@ -2,7 +2,7 @@ <button class="btn button-default follow-button" :class="{ toggled: isPressed }" - :disabled="inProgress" + :disabled="disabled" :title="title" @click="onClick" > diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index 6dcb6d47..b26b27a7 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -1,6 +1,7 @@ import BasicUserCard from '../basic_user_card/basic_user_card.vue' import RemoteFollow from '../remote_follow/remote_follow.vue' import FollowButton from '../follow_button/follow_button.vue' +import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue' const FollowCard = { props: [ @@ -10,7 +11,8 @@ const FollowCard = { components: { BasicUserCard, RemoteFollow, - FollowButton + FollowButton, + RemoveFollowerButton }, computed: { isMe () { diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index b503783f..c919b11a 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -20,6 +20,12 @@ :relationship="relationship" :label-following="$t('user_card.follow_unfollow')" class="follow-card-follow-button" + :user="user" + /> + <RemoveFollowerButton + v-if="noFollowsYou && relationship.followed_by" + :relationship="relationship" + class="follow-card-button" /> </template> </div> @@ -39,6 +45,12 @@ line-height: 1.5em; } + &-button { + margin-top: 0.5em; + padding: 0 1.5em; + margin-left: 1em; + } + &-follow-button { margin-top: 0.5em; margin-left: auto; diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js index 137ef9c0..92ee3f30 100644 --- a/src/components/font_control/font_control.js +++ b/src/components/font_control/font_control.js @@ -1,4 +1,4 @@ -import { set } from 'vue' +import { set } from 'lodash' import Select from '../select/select.vue' export default { @@ -6,11 +6,12 @@ export default { Select }, props: [ - 'name', 'label', 'value', 'fallback', 'options', 'no-inherit' + 'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit' ], + emits: ['update:modelValue'], data () { return { - lValue: this.value, + lValue: this.modelValue, availableOptions: [ this.noInherit ? '' : 'inherit', 'custom', @@ -22,7 +23,7 @@ export default { } }, beforeUpdate () { - this.lValue = this.value + this.lValue = this.modelValue }, computed: { present () { @@ -37,7 +38,7 @@ export default { }, set (v) { set(this.lValue, 'family', v) - this.$emit('input', this.lValue) + this.$emit('update:modelValue', this.lValue) } }, isCustom () { diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue index 29605084..83c1cef7 100644 --- a/src/components/font_control/font_control.vue +++ b/src/components/font_control/font_control.vue @@ -15,13 +15,14 @@ class="opt exlcude-disabled" type="checkbox" :checked="present" - @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)" + @change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)" > <label v-if="typeof fallback !== 'undefined'" class="opt-l" :for="name + '-o'" /> + {{ ' ' }} <Select :id="name + '-font-switcher'" v-model="preset" @@ -46,7 +47,7 @@ </div> </template> -<script src="./font_control.js" ></script> +<script src="./font_control.js"></script> <style lang="scss"> @import '../../_variables.scss'; diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js index f856fd0a..4e1bda55 100644 --- a/src/components/gallery/gallery.js +++ b/src/components/gallery/gallery.js @@ -1,15 +1,26 @@ import Attachment from '../attachment/attachment.vue' -import { chunk, last, dropRight, sumBy } from 'lodash' +import { sumBy, set } from 'lodash' const Gallery = { props: [ 'attachments', + 'limitRows', + 'descriptions', + 'limit', 'nsfw', - 'setMedia' + 'setMedia', + 'size', + 'editable', + 'removeAttachment', + 'shiftUpAttachment', + 'shiftDnAttachment', + 'editAttachment', + 'grid' ], data () { return { - sizes: {} + sizes: {}, + hidingLong: true } }, components: { Attachment }, @@ -18,26 +29,70 @@ const Gallery = { if (!this.attachments) { return [] } - const rows = chunk(this.attachments, 3) - if (last(rows).length === 1 && rows.length > 1) { - // if 1 attachment on last row -> add it to the previous row instead - const lastAttachment = last(rows)[0] - const allButLastRow = dropRight(rows) - last(allButLastRow).push(lastAttachment) - return allButLastRow + const attachments = this.limit > 0 + ? this.attachments.slice(0, this.limit) + : this.attachments + if (this.size === 'hide') { + return attachments.map(item => ({ minimal: true, items: [item] })) } + const rows = this.grid + ? [{ grid: true, items: attachments }] + : attachments.reduce((acc, attachment, i) => { + if (attachment.mimetype.includes('audio')) { + return [...acc, { audio: true, items: [attachment] }, { items: [] }] + } + if (!( + attachment.mimetype.includes('image') || + attachment.mimetype.includes('video') || + attachment.mimetype.includes('flash') + )) { + return [...acc, { minimal: true, items: [attachment] }, { items: [] }] + } + const maxPerRow = 3 + const attachmentsRemaining = this.attachments.length - i + 1 + const currentRow = acc[acc.length - 1].items + currentRow.push(attachment) + if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) { + return [...acc, { items: [] }] + } else { + return acc + } + }, [{ items: [] }]).filter(_ => _.items.length > 0) return rows }, - useContainFit () { - return this.$store.getters.mergedConfig.useContainFit + attachmentsDimensionalScore () { + return this.rows.reduce((acc, row) => { + let size = 0 + if (row.minimal) { + size += 1 / 8 + } else if (row.audio) { + size += 1 / 4 + } else { + size += 1 / (row.items.length + 0.6) + } + return acc + size + }, 0) + }, + tooManyAttachments () { + if (this.editable || this.size === 'small') { + return false + } else if (this.size === 'hide') { + return this.attachments.length > 8 + } else { + return this.attachmentsDimensionalScore > 1 + } } }, methods: { - onNaturalSizeLoad (id, size) { - this.$set(this.sizes, id, size) + onNaturalSizeLoad ({ id, width, height }) { + set(this.sizes, id, { width, height }) }, - rowStyle (itemsPerRow) { - return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` } + rowStyle (row) { + if (row.audio) { + return { 'padding-bottom': '25%' } // fixed reduced height for audio + } else if (!row.minimal && !row.grid) { + return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` } + } }, itemStyle (id, row) { const total = sumBy(row, item => this.getAspectRatio(item.id)) @@ -46,6 +101,16 @@ const Gallery = { getAspectRatio (id) { const size = this.sizes[id] return size ? size.width / size.height : 1 + }, + toggleHidingLong (event) { + this.hidingLong = event + }, + openGallery () { + this.$store.dispatch('setMedia', this.attachments) + this.$store.dispatch('setCurrentMedia', this.attachments[0]) + }, + onMedia () { + this.$store.dispatch('setMedia', this.attachments) } } } diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index ca91c9c1..ccf6e3e2 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -1,26 +1,83 @@ <template> <div ref="galleryContainer" - style="width: 100%;" + class="Gallery" + :class="{ '-long': tooManyAttachments && hidingLong }" > + <div class="gallery-rows"> + <div + v-for="(row, rowIndex) in rows" + :key="rowIndex" + class="gallery-row" + :style="rowStyle(row)" + :class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }" + > + <div + class="gallery-row-inner" + :class="{ '-grid': grid }" + > + <Attachment + v-for="(attachment, attachmentIndex) in row.items" + :key="attachment.id" + class="gallery-item" + :nsfw="nsfw" + :attachment="attachment" + :size="size" + :editable="editable" + :remove="removeAttachment" + :shift-up="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment" + :shift-dn="!(attachmentIndex === row.items.length - 1 && rowIndex === rows.length - 1) && shiftDnAttachment" + :edit="editAttachment" + :description="descriptions && descriptions[attachment.id]" + :hide-description="size === 'small' || tooManyAttachments && hidingLong" + :style="itemStyle(attachment.id, row.items)" + @setMedia="onMedia" + @naturalSizeLoad="onNaturalSizeLoad" + /> + </div> + </div> + </div> <div - v-for="(row, index) in rows" - :key="index" - class="gallery-row" - :style="rowStyle(row.length)" - :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }" + v-if="tooManyAttachments" + class="many-attachments" > - <div class="gallery-row-inner"> - <attachment - v-for="attachment in row" - :key="attachment.id" - :set-media="setMedia" - :nsfw="nsfw" - :attachment="attachment" - :allow-play="false" - :natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)" - :style="itemStyle(attachment.id, row)" - /> + <div class="many-attachments-text"> + {{ $t("status.many_attachments", { number: attachments.length }) }} + </div> + <div class="many-attachments-buttons"> + <span + v-if="!hidingLong" + class="many-attachments-button" + > + <button + class="button-unstyled -link" + @click="toggleHidingLong(true)" + > + {{ $t("status.collapse_attachments") }} + </button> + </span> + <span + v-if="hidingLong" + class="many-attachments-button" + > + <button + class="button-unstyled -link" + @click="toggleHidingLong(false)" + > + {{ $t("status.show_all_attachments") }} + </button> + </span> + <span + v-if="hidingLong" + class="many-attachments-button" + > + <button + class="button-unstyled -link" + @click="openGallery" + > + {{ $t("status.open_gallery") }} + </button> + </span> </div> </div> </div> @@ -31,12 +88,66 @@ <style lang="scss"> @import '../../_variables.scss'; -.gallery-row { - position: relative; - height: 0; - width: 100%; - flex-grow: 1; - margin-top: 0.5em; +.Gallery { + .gallery-rows { + display: flex; + flex-direction: column; + } + + .gallery-row { + position: relative; + height: 0; + width: 100%; + flex-grow: 1; + + &:not(:first-child) { + margin-top: 0.5em; + } + } + + &.-long { + .gallery-rows { + max-height: 25em; + overflow: hidden; + 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 */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + } + + .many-attachments-text { + text-align: center; + line-height: 2; + } + + .many-attachments-buttons { + display: flex; + } + + .many-attachments-button { + display: flex; + flex: 1; + justify-content: center; + line-height: 2; + + button { + padding: 0 2em; + } + } + + .gallery-row { + &.-grid, + &.-minimal { + height: auto; + .gallery-row-inner { + position: relative; + } + } + } .gallery-row-inner { position: absolute; @@ -48,9 +159,24 @@ flex-direction: row; flex-wrap: nowrap; align-content: stretch; + + &.-grid { + width: 100%; + height: auto; + position: relative; + display: grid; + grid-column-gap: 0.5em; + grid-row-gap: 0.5em; + grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); + + .gallery-item { + margin: 0; + height: 200px; + } + } } - .gallery-row-inner .attachment { + .gallery-item { margin: 0 0.5em 0 0; flex-grow: 1; height: 100%; @@ -61,32 +187,5 @@ margin: 0; } } - - .image-attachment { - width: 100%; - height: 100%; - } - - .video-container { - height: 100%; - } - - &.contain-fit { - img, - video, - canvas { - object-fit: contain; - height: 100%; - } - } - - &.cover-fit { - img, - video, - canvas { - object-fit: cover; - } - } } - </style> diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue index a45f4586..d828b819 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -29,10 +29,10 @@ .global-notice-list { position: fixed; - top: 50px; + top: calc(var(--navbar-height) + 0.5em); width: 100%; pointer-events: none; - z-index: 1001; + z-index: var(--ZI_navbar_popovers); display: flex; flex-direction: column; align-items: center; @@ -44,20 +44,18 @@ max-width: calc(100% - 3em); display: flex; padding-left: 1.5em; - line-height: 2em; + line-height: 2; + margin-bottom: 0.5em; + .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); + .svg-inline--fa { color: var(--alertPopupErrorText, $fallback--text); } @@ -66,6 +64,7 @@ .global-warning { background-color: var(--alertPopupWarning, $fallback--cOrange); color: var(--alertPopupWarningText, $fallback--text); + .svg-inline--fa { color: var(--alertPopupWarningText, $fallback--text); } diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue index 918ed26b..596851b9 100644 --- a/src/components/hashtag_link/hashtag_link.vue +++ b/src/components/hashtag_link/hashtag_link.vue @@ -14,6 +14,6 @@ </span> </template> -<script src="./hashtag_link.js"/> +<script src="./hashtag_link.js" /> -<style lang="scss" src="./hashtag_link.scss"/> +<style lang="scss" src="./hashtag_link.scss" /> diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js index e8d5ec6d..55e901a0 100644 --- a/src/components/image_cropper/image_cropper.js +++ b/src/components/image_cropper/image_cropper.js @@ -95,7 +95,7 @@ const ImageCropper = { const fileInput = this.$refs.input if (fileInput.files != null && fileInput.files[0] != null) { this.file = fileInput.files[0] - let reader = new window.FileReader() + const reader = new window.FileReader() reader.onload = (e) => { this.dataUrl = e.target.result this.$emit('open') @@ -117,7 +117,7 @@ const ImageCropper = { const fileInput = this.$refs.input fileInput.addEventListener('change', this.readFile) }, - beforeDestroy: function () { + beforeUnmount: function () { // remove the event listeners const trigger = this.getTriggerDOM() if (trigger) { diff --git a/src/components/importer/importer.js b/src/components/importer/importer.js index 59f9beb1..da86a223 100644 --- a/src/components/importer/importer.js +++ b/src/components/importer/importer.js @@ -15,24 +15,9 @@ const Importer = { type: Function, required: true }, - submitButtonLabel: { - type: String, - default () { - return this.$t('importer.submit') - } - }, - successMessage: { - type: String, - default () { - return this.$t('importer.success') - } - }, - errorMessage: { - type: String, - default () { - return this.$t('importer.error') - } - } + submitButtonLabel: { type: String }, + successMessage: { type: String }, + errorMessage: { type: String } }, data () { return { diff --git a/src/components/importer/importer.vue b/src/components/importer/importer.vue index 210823f5..2a63b31a 100644 --- a/src/components/importer/importer.vue +++ b/src/components/importer/importer.vue @@ -18,21 +18,31 @@ class="btn button-default" @click="submit" > - {{ submitButtonLabel }} + {{ submitButtonLabel || $t('importer.submit') }} </button> <div v-if="success"> - <FAIcon - icon="times" + <button + class="button-unstyled" @click="dismiss" - /> - <p>{{ successMessage }}</p> + > + <FAIcon + icon="times" + /> + </button> + {{ ' ' }} + <span>{{ successMessage || $t('importer.success') }}</span> </div> <div v-else-if="error"> - <FAIcon - icon="times" + <button + class="button-unstyled" @click="dismiss" - /> - <p>{{ errorMessage }}</p> + > + <FAIcon + icon="times" + /> + </button> + {{ ' ' }} + <span>{{ errorMessage || $t('importer.error') }}</span> </div> </div> </template> diff --git a/src/components/instance_specific_panel/instance_specific_panel.vue b/src/components/instance_specific_panel/instance_specific_panel.vue index 7448ca06..c8ed0a2d 100644 --- a/src/components/instance_specific_panel/instance_specific_panel.vue +++ b/src/components/instance_specific_panel/instance_specific_panel.vue @@ -10,4 +10,4 @@ </div> </template> -<script src="./instance_specific_panel.js" ></script> +<script src="./instance_specific_panel.js"></script> diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js index 7fe5e76d..1ae1d01c 100644 --- a/src/components/interactions/interactions.js +++ b/src/components/interactions/interactions.js @@ -1,9 +1,12 @@ import Notifications from '../notifications/notifications.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' const tabModeDict = { mentions: ['mention'], 'likes+repeats': ['repeat', 'like'], follows: ['follow'], + reactions: ['pleroma:emoji_reaction'], + reports: ['pleroma:report'], moves: ['move'] } @@ -11,7 +14,8 @@ const Interactions = { data () { return { allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, - filterMode: tabModeDict['mentions'] + filterMode: tabModeDict.mentions, + canSeeReports: this.$store.state.users.currentUser.privileges.includes('reports_manage_reports') } }, methods: { @@ -20,7 +24,8 @@ const Interactions = { } }, components: { - Notifications + Notifications, + TabSwitcher } } diff --git a/src/components/interactions/interactions.vue b/src/components/interactions/interactions.vue index 57d5d87c..b7291c02 100644 --- a/src/components/interactions/interactions.vue +++ b/src/components/interactions/interactions.vue @@ -22,6 +22,15 @@ :label="$t('interactions.follows')" /> <span + key="reactions" + :label="$t('interactions.emoji_reactions')" + /> + <span + v-if="canSeeReports" + key="reports" + :label="$t('interactions.reports')" + /> + <span v-if="!allowFollowingMove" key="moves" :label="$t('interactions.moves')" diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index cf307a24..6997f149 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -1,11 +1,12 @@ <template> <div> <label for="interface-language-switcher"> - {{ $t('settings.interfaceLanguage') }} + {{ promptText }} </label> + {{ ' ' }} <Select id="interface-language-switcher" - v-model="language" + v-model="controlledLanguage" > <option v-for="lang in languages" @@ -19,39 +20,44 @@ </template> <script> -import languagesObject from '../../i18n/messages' import localeService from '../../services/locale/locale.service.js' -import ISO6391 from 'iso-639-1' -import _ from 'lodash' import Select from '../select/select.vue' export default { components: { + // eslint-disable-next-line vue/no-reserved-component-names Select }, + props: { + promptText: { + type: String, + required: true + }, + language: { + type: String, + required: true + }, + setLanguage: { + type: Function, + required: true + } + }, computed: { languages () { - return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name)) + return localeService.languages }, - language: { - get: function () { return this.$store.getters.mergedConfig.interfaceLanguage }, + controlledLanguage: { + get: function () { return this.language }, set: function (val) { - this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) + this.setLanguage(val) } } }, methods: { getLanguageName (code) { - const specialLanguageNames = { - 'ja_easy': 'ãããããĢãģãã', - 'zh': 'įŽäŊ䏿', - 'zh_Hant': 'įšéĢ䏿' - } - const languageName = specialLanguageNames[code] || ISO6391.getNativeName(code) - const browserLocale = localeService.internalToBrowserLocale(code) - return languageName.charAt(0).toLocaleUpperCase(browserLocale) + languageName.slice(1) + return localeService.getLanguageName(code) } } } diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue index d3ca39b8..220527f2 100644 --- a/src/components/link-preview/link-preview.vue +++ b/src/components/link-preview/link-preview.vue @@ -63,7 +63,7 @@ } .card-host { - font-size: 12px; + font-size: 0.85em; } .card-description { diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js new file mode 100644 index 00000000..56d68430 --- /dev/null +++ b/src/components/lists/lists.js @@ -0,0 +1,27 @@ +import ListsCard from '../lists_card/lists_card.vue' + +const Lists = { + data () { + return { + isNew: false + } + }, + components: { + ListsCard + }, + computed: { + lists () { + return this.$store.state.lists.allLists + } + }, + methods: { + cancelNewList () { + this.isNew = false + }, + newList () { + this.isNew = true + } + } +} + +export default Lists diff --git a/src/components/lists/lists.vue b/src/components/lists/lists.vue new file mode 100644 index 00000000..b8bab0a0 --- /dev/null +++ b/src/components/lists/lists.vue @@ -0,0 +1,33 @@ +<template> + <div class="Lists panel panel-default"> + <div class="panel-heading"> + <div class="title"> + {{ $t('lists.lists') }} + </div> + <router-link + :to="{ name: 'lists-new' }" + class="button-default btn new-list-button" + > + {{ $t("lists.new") }} + </router-link> + </div> + <div class="panel-body"> + <ListsCard + v-for="list in lists.slice().reverse()" + :key="list" + :list="list" + class="list-item" + /> + </div> + </div> +</template> + +<script src="./lists.js"></script> + +<style lang="scss"> +.Lists { + .new-list-button { + padding: 0 0.5em; + } +} +</style> diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js new file mode 100644 index 00000000..b503caec --- /dev/null +++ b/src/components/lists_card/lists_card.js @@ -0,0 +1,16 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEllipsisH +) + +const ListsCard = { + props: [ + 'list' + ] +} + +export default ListsCard diff --git a/src/components/lists_card/lists_card.vue b/src/components/lists_card/lists_card.vue new file mode 100644 index 00000000..13866d8c --- /dev/null +++ b/src/components/lists_card/lists_card.vue @@ -0,0 +1,51 @@ +<template> + <div class="list-card"> + <router-link + :to="{ name: 'lists-timeline', params: { id: list.id } }" + class="list-name" + > + {{ list.title }} + </router-link> + <router-link + :to="{ name: 'lists-edit', params: { id: list.id } }" + class="button-list-edit" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="ellipsis-h" + /> + </router-link> + </div> +</template> + +<script src="./lists_card.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.list-card { + display: flex; +} + +.list-name, +.button-list-edit { + margin: 0; + padding: 1em; + color: $fallback--link; + color: var(--link, $fallback--link); + + &: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); + } +} + +.list-name { + flex-grow: 1; +} +</style> diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js new file mode 100644 index 00000000..c22d1323 --- /dev/null +++ b/src/components/lists_edit/lists_edit.js @@ -0,0 +1,145 @@ +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import ListsUserSearch from '../lists_user_search/lists_user_search.vue' +import PanelLoading from 'src/components/panel_loading/panel_loading.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faSearch, + faChevronLeft +) + +const ListsNew = { + components: { + BasicUserCard, + UserAvatar, + ListsUserSearch, + TabSwitcher, + PanelLoading + }, + data () { + return { + title: '', + titleDraft: '', + membersUserIds: [], + removedUserIds: new Set([]), // users we added for members, to undo + searchUserIds: [], + addedUserIds: new Set([]), // users we added from search, to undo + searchLoading: false, + reallyDelete: false + } + }, + created () { + if (!this.id) return + this.$store.dispatch('fetchList', { listId: this.id }) + .then(() => { + this.title = this.findListTitle(this.id) + this.titleDraft = this.title + }) + this.$store.dispatch('fetchListAccounts', { listId: this.id }) + .then(() => { + this.membersUserIds = this.findListAccounts(this.id) + this.membersUserIds.forEach(userId => { + this.$store.dispatch('fetchUserIfMissing', userId) + }) + }) + }, + computed: { + id () { + return this.$route.params.id + }, + membersUsers () { + return [...this.membersUserIds, ...this.addedUserIds] + .map(userId => this.findUser(userId)).filter(user => user) + }, + searchUsers () { + return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user) + }, + ...mapState({ + currentUser: state => state.users.currentUser + }), + ...mapGetters(['findUser', 'findListTitle', 'findListAccounts']) + }, + methods: { + onInput () { + this.search(this.query) + }, + toggleRemoveMember (user) { + if (this.removedUserIds.has(user.id)) { + this.id && this.addUser(user) + this.removedUserIds.delete(user.id) + } else { + this.id && this.removeUser(user.id) + this.removedUserIds.add(user.id) + } + }, + toggleAddFromSearch (user) { + if (this.addedUserIds.has(user.id)) { + this.id && this.removeUser(user.id) + this.addedUserIds.delete(user.id) + } else { + this.id && this.addUser(user) + this.addedUserIds.add(user.id) + } + }, + isRemoved (user) { + return this.removedUserIds.has(user.id) + }, + isAdded (user) { + return this.addedUserIds.has(user.id) + }, + addUser (user) { + this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id }) + }, + removeUser (userId) { + this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id }) + }, + onSearchLoading (results) { + this.searchLoading = true + }, + onSearchLoadingDone (results) { + this.searchLoading = false + }, + onSearchResults (results) { + this.searchLoading = false + this.searchUserIds = results + }, + updateListTitle () { + this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft }) + .then(() => { + this.title = this.findListTitle(this.id) + }) + }, + createList () { + this.$store.dispatch('createList', { title: this.titleDraft }) + .then((list) => { + return this + .$store + .dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] }) + .then(() => list.id) + }) + .then((listId) => { + this.$router.push({ name: 'lists-timeline', params: { id: listId } }) + }) + .catch((e) => { + this.$store.dispatch('pushGlobalNotice', { + messageKey: 'lists.error', + messageArgs: [e.message], + level: 'error' + }) + }) + }, + deleteList () { + this.$store.dispatch('deleteList', { listId: this.id }) + this.$router.push({ name: 'lists' }) + } + } +} + +export default ListsNew diff --git a/src/components/lists_edit/lists_edit.vue b/src/components/lists_edit/lists_edit.vue new file mode 100644 index 00000000..6521aba6 --- /dev/null +++ b/src/components/lists_edit/lists_edit.vue @@ -0,0 +1,228 @@ +<template> + <div class="panel-default panel ListEdit"> + <div + ref="header" + class="panel-heading list-edit-heading" + > + <button + class="button-unstyled go-back-button" + @click="$router.back" + > + <FAIcon + size="lg" + icon="chevron-left" + /> + </button> + <div class="title"> + <i18n-t + v-if="id" + keypath="lists.editing_list" + > + <template #listTitle> + {{ title }} + </template> + </i18n-t> + <i18n-t + v-else + keypath="lists.creating_list" + /> + </div> + </div> + <div class="panel-body"> + <div class="input-wrap"> + <label for="list-edit-title">{{ $t('lists.title') }}</label> + {{ ' ' }} + <input + id="list-edit-title" + ref="title" + v-model="titleDraft" + > + <button + v-if="id" + class="btn button-default follow-button" + @click="updateListTitle" + > + {{ $t('lists.update_title') }} + </button> + </div> + <tab-switcher + class="list-member-management" + :scrollable-tabs="true" + > + <div + v-if="id || addedUserIds.size > 0" + :label="$t('lists.manage_members')" + class="members-list" + > + <div class="users-list"> + <div + v-for="user in membersUsers" + :key="user.id" + class="member" + > + <BasicUserCard + :user="user" + > + <button + class="btn button-default follow-button" + @click="toggleRemoveMember(user)" + > + {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }} + </button> + </BasicUserCard> + </div> + </div> + </div> + + <div + class="search-list" + :label="$t('lists.add_members')" + > + <ListsUserSearch + @results="onSearchResults" + @loading="onSearchLoading" + @loadingDone="onSearchLoadingDone" + /> + <div + v-if="searchLoading" + class="loading" + > + <PanelLoading /> + </div> + <div + v-else + class="users-list" + > + <div + v-for="user in searchUsers" + :key="user.id" + class="member" + > + <BasicUserCard + :user="user" + > + <span + v-if="membersUserIds.includes(user.id)" + > + {{ $t('lists.is_in_list') }} + </span> + <button + v-if="!membersUserIds.includes(user.id)" + class="btn button-default follow-button" + @click="toggleAddFromSearch(user)" + > + {{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }} + </button> + <button + v-else + class="btn button-default follow-button" + @click="toggleRemoveMember(user)" + > + {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }} + </button> + </BasicUserCard> + </div> + </div> + </div> + </tab-switcher> + </div> + <div class="panel-footer"> + <span class="spacer" /> + <button + v-if="!id" + class="btn button-default footer-button" + @click="createList" + > + {{ $t('lists.create') }} + </button> + <button + v-else-if="!reallyDelete" + class="btn button-default footer-button" + @click="reallyDelete = true" + > + {{ $t('lists.delete') }} + </button> + <template v-else> + {{ $t('lists.really_delete') }} + <button + class="btn button-default footer-button" + @click="deleteList" + > + {{ $t('general.yes') }} + </button> + <button + class="btn button-default footer-button" + @click="reallyDelete = false" + > + {{ $t('general.no') }} + </button> + </template> + </div> + </div> +</template> + +<script src="./lists_edit.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.ListEdit { + --panel-body-padding: 0.5em; + + height: calc(100vh - var(--navbar-height)); + overflow: hidden; + display: flex; + flex-direction: column; + + .list-edit-heading { + grid-template-columns: auto minmax(50%, 1fr); + } + + .panel-body { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + } + + .list-member-management { + flex: 1 0 auto; + } + + .search-icon { + margin-right: 0.3em; + } + + .users-list { + padding-bottom: 0.7rem; + overflow-y: auto; + } + + & .search-list, + & .members-list { + overflow: hidden; + flex-direction: column; + min-height: 0; + } + + .go-back-button { + text-align: center; + line-height: 1; + height: 100%; + align-self: start; + width: var(--__panel-heading-height-inner); + } + + .btn { + margin: 0 0.5em; + } + + .panel-footer { + grid-template-columns: minmax(10%, 1fr); + + .footer-button { + min-width: 9em; + } + } +} +</style> diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js new file mode 100644 index 00000000..97b32210 --- /dev/null +++ b/src/components/lists_menu/lists_menu_content.js @@ -0,0 +1,22 @@ +import { mapState } from 'vuex' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import { getListEntries } from 'src/components/navigation/filter.js' + +export const ListsMenuContent = { + props: [ + 'showPin' + ], + components: { + NavigationEntry + }, + computed: { + ...mapState({ + lists: getListEntries, + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }) + } +} + +export default ListsMenuContent diff --git a/src/components/lists_menu/lists_menu_content.vue b/src/components/lists_menu/lists_menu_content.vue new file mode 100644 index 00000000..f93e80c9 --- /dev/null +++ b/src/components/lists_menu/lists_menu_content.vue @@ -0,0 +1,12 @@ +<template> + <ul> + <NavigationEntry + v-for="item in lists" + :key="item.name" + :show-pin="showPin" + :item="item" + /> + </ul> +</template> + +<script src="./lists_menu_content.js"></script> diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js new file mode 100644 index 00000000..c3f408bd --- /dev/null +++ b/src/components/lists_timeline/lists_timeline.js @@ -0,0 +1,36 @@ +import Timeline from '../timeline/timeline.vue' +const ListsTimeline = { + data () { + return { + listId: null + } + }, + components: { + Timeline + }, + computed: { + timeline () { return this.$store.state.statuses.timelines.list } + }, + watch: { + $route: function (route) { + if (route.name === 'lists-timeline' && route.params.id !== this.listId) { + this.listId = route.params.id + this.$store.dispatch('stopFetchingTimeline', 'list') + this.$store.commit('clearTimeline', { timeline: 'list' }) + this.$store.dispatch('fetchList', { listId: this.listId }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + } + } + }, + created () { + this.listId = this.$route.params.id + this.$store.dispatch('fetchList', { listId: this.listId }) + this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + }, + unmounted () { + this.$store.dispatch('stopFetchingTimeline', 'list') + this.$store.commit('clearTimeline', { timeline: 'list' }) + } +} + +export default ListsTimeline diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue new file mode 100644 index 00000000..18156b81 --- /dev/null +++ b/src/components/lists_timeline/lists_timeline.vue @@ -0,0 +1,10 @@ +<template> + <Timeline + title="list.name" + :timeline="timeline" + :list-id="listId" + timeline-name="list" + /> +</template> + +<script src="./lists_timeline.js"></script> diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js new file mode 100644 index 00000000..c92ec0ee --- /dev/null +++ b/src/components/lists_user_search/lists_user_search.js @@ -0,0 +1,51 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' +import { debounce } from 'lodash' +import Checkbox from '../checkbox/checkbox.vue' + +library.add( + faSearch, + faChevronLeft +) + +const ListsUserSearch = { + components: { + Checkbox + }, + emits: ['loading', 'loadingDone', 'results'], + data () { + return { + loading: false, + query: '', + followingOnly: true + } + }, + methods: { + onInput: debounce(function () { + this.search(this.query) + }, 2000), + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.$emit('loading') + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly }) + .then(data => { + this.$emit('results', data.accounts.map(a => a.id)) + }) + .finally(() => { + this.loading = false + this.$emit('loadingDone') + }) + } + } +} + +export default ListsUserSearch diff --git a/src/components/lists_user_search/lists_user_search.vue b/src/components/lists_user_search/lists_user_search.vue new file mode 100644 index 00000000..8633170c --- /dev/null +++ b/src/components/lists_user_search/lists_user_search.vue @@ -0,0 +1,47 @@ +<template> + <div class="ListsUserSearch"> + <div class="input-wrap"> + <div class="input-search"> + <FAIcon + class="search-icon fa-scale-110 fa-old-padding" + icon="search" + /> + </div> + <input + ref="search" + v-model="query" + :placeholder="$t('lists.search')" + @input="onInput" + > + </div> + <div class="input-wrap"> + <Checkbox + v-model="followingOnly" + @change="onInput" + > + {{ $t('lists.following_only') }} + </Checkbox> + </div> + </div> +</template> + +<script src="./lists_user_search.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.ListsUserSearch { + .input-wrap { + display: flex; + margin: 0.7em 0.5em 0.7em 0.5em; + + input { + width: 100%; + } + } + + .search-icon { + margin-right: 0.3em; + } +} + +</style> diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index 638bd812..b795640e 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -83,7 +83,7 @@ const LoginForm = { }, clearError () { this.error = false }, focusOnPasswordInput () { - let passwordInput = this.$refs.passwordInput + const passwordInput = this.$refs.passwordInput passwordInput.focus() passwordInput.setSelectionRange(0, passwordInput.value.length) } diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue index bfabb946..7a430c51 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -76,17 +76,21 @@ > <div class="alert error"> {{ error }} - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="times" + <button + class="button-unstyled" @click="clearError" - /> + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + /> + </button> </div> </div> </div> </template> -<script src="./login_form.js" ></script> +<script src="./login_form.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -97,7 +101,7 @@ padding: 0.6em; .btn { - min-height: 28px; + min-height: 2em; width: 10em; } diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index e7384c93..ff993664 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -1,24 +1,46 @@ import StillImage from '../still-image/still-image.vue' import VideoAttachment from '../video_attachment/video_attachment.vue' import Modal from '../modal/modal.vue' -import fileTypeService from '../../services/file_type/file_type.service.js' +import PinchZoom from '../pinch_zoom/pinch_zoom.vue' +import SwipeClick from '../swipe_click/swipe_click.vue' import GestureService from '../../services/gesture_service/gesture_service' +import Flash from 'src/components/flash/flash.vue' +import fileTypeService from '../../services/file_type/file_type.service.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faChevronLeft, - faChevronRight + faChevronRight, + faCircleNotch, + faTimes } from '@fortawesome/free-solid-svg-icons' library.add( faChevronLeft, - faChevronRight + faChevronRight, + faCircleNotch, + faTimes ) const MediaModal = { components: { StillImage, VideoAttachment, - Modal + PinchZoom, + SwipeClick, + Modal, + Flash + }, + data () { + return { + loading: false, + swipeDirection: GestureService.DIRECTION_LEFT, + swipeThreshold: () => { + const considerableMoveRatio = 1 / 4 + return window.innerWidth * considerableMoveRatio + }, + pinchZoomMinScale: 1, + pinchZoomScaleResetLimit: 1.2 + } }, computed: { showing () { @@ -27,6 +49,9 @@ const MediaModal = { media () { return this.$store.state.mediaViewer.media }, + description () { + return this.currentMedia.description + }, currentIndex () { return this.$store.state.mediaViewer.currentIndex }, @@ -37,43 +62,62 @@ const MediaModal = { return this.media.length > 1 }, type () { - return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null + return this.currentMedia ? this.getType(this.currentMedia) : null } }, - created () { - this.mediaSwipeGestureRight = GestureService.swipeGesture( - GestureService.DIRECTION_RIGHT, - this.goPrev, - 50 - ) - this.mediaSwipeGestureLeft = GestureService.swipeGesture( - GestureService.DIRECTION_LEFT, - this.goNext, - 50 - ) - }, methods: { - mediaTouchStart (e) { - GestureService.beginSwipe(e, this.mediaSwipeGestureRight) - GestureService.beginSwipe(e, this.mediaSwipeGestureLeft) - }, - mediaTouchMove (e) { - GestureService.updateSwipe(e, this.mediaSwipeGestureRight) - GestureService.updateSwipe(e, this.mediaSwipeGestureLeft) + getType (media) { + return fileTypeService.fileType(media.mimetype) }, hide () { - this.$store.dispatch('closeMediaViewer') + // HACK: Closing immediately via a touch will cause the click + // to be processed on the content below the overlay + const transitionTime = 100 // ms + setTimeout(() => { + this.$store.dispatch('closeMediaViewer') + }, transitionTime) + }, + hideIfNotSwiped (event) { + // If we have swiped over SwipeClick, do not trigger hide + const comp = this.$refs.swipeClick + if (!comp) { + this.hide() + } else { + comp.$gesture.click(event) + } }, goPrev () { if (this.canNavigate) { const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) - this.$store.dispatch('setCurrent', this.media[prevIndex]) + const newMedia = this.media[prevIndex] + if (this.getType(newMedia) === 'image') { + this.loading = true + } + this.$store.dispatch('setCurrentMedia', newMedia) } }, goNext () { if (this.canNavigate) { const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) - this.$store.dispatch('setCurrent', this.media[nextIndex]) + const newMedia = this.media[nextIndex] + if (this.getType(newMedia) === 'image') { + this.loading = true + } + this.$store.dispatch('setCurrentMedia', newMedia) + } + }, + onImageLoaded () { + this.loading = false + }, + handleSwipePreview (offsets) { + this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 }) + }, + handleSwipeEnd (sign) { + this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 }) + if (sign > 0) { + this.goNext() + } else if (sign < 0) { + this.goPrev() } }, handleKeyupEvent (e) { @@ -98,7 +142,7 @@ const MediaModal = { document.addEventListener('keyup', this.handleKeyupEvent) document.addEventListener('keydown', this.handleKeydownEvent) }, - destroyed () { + unmounted () { window.removeEventListener('popstate', this.hide) document.removeEventListener('keyup', this.handleKeyupEvent) document.removeEventListener('keydown', this.handleKeydownEvent) diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 54bc5335..d59055b3 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -2,18 +2,38 @@ <Modal v-if="showing" class="media-modal-view" - @backdropClicked="hide" + @backdropClicked="hideIfNotSwiped" > - <img + <SwipeClick v-if="type === 'image'" - class="modal-image" - :src="currentMedia.url" - :alt="currentMedia.description" - :title="currentMedia.description" - @touchstart.stop="mediaTouchStart" - @touchmove.stop="mediaTouchMove" - @click="hide" + ref="swipeClick" + class="modal-image-container" + :direction="swipeDirection" + :threshold="swipeThreshold" + @preview-requested="handleSwipePreview" + @swipe-finished="handleSwipeEnd" + @swipeless-clicked="hide" > + <PinchZoom + ref="pinchZoom" + class="modal-image-container-inner" + selector=".modal-image" + reach-min-scale-strategy="reset" + stop-propagate-handled="stop-propgate-handled" + :allow-pan-min-scale="pinchZoomMinScale" + :min-scale="pinchZoomMinScale" + :reset-to-min-scale-limit="pinchZoomScaleResetLimit" + > + <img + :class="{ loading }" + class="modal-image" + :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" + @load="onImageLoaded" + > + </PinchZoom> + </SwipeClick> <VideoAttachment v-if="type === 'video'" class="modal-image" @@ -28,38 +48,84 @@ :title="currentMedia.description" controls /> + <Flash + v-if="type === 'flash'" + class="modal-image" + :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" + /> <button v-if="canNavigate" :title="$t('media_modal.previous')" - class="modal-view-button-arrow modal-view-button-arrow--prev" + class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev" @click.stop.prevent="goPrev" > <FAIcon - class="arrow-icon" + class="button-icon arrow-icon" icon="chevron-left" /> </button> <button v-if="canNavigate" :title="$t('media_modal.next')" - class="modal-view-button-arrow modal-view-button-arrow--next" + class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next" @click.stop.prevent="goNext" > <FAIcon - class="arrow-icon" + class="button-icon arrow-icon" icon="chevron-right" /> </button> + <button + class="modal-view-button modal-view-button-hide" + :title="$t('media_modal.hide')" + @click.stop.prevent="hide" + > + <FAIcon + class="button-icon" + icon="times" + /> + </button> + + <span + v-if="description" + class="description" + > + {{ description }} + </span> + <span + class="counter" + > + {{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }} + </span> + <span + v-if="loading" + class="loading-spinner" + > + <FAIcon + spin + icon="circle-notch" + size="5x" + /> + </span> </Modal> </template> <script src="./media_modal.js"></script> <style lang="scss"> +$modal-view-button-icon-height: 3em; +$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2); +$modal-view-button-icon-width: 3em; +$modal-view-button-icon-margin: 0.5em; + .modal-view.media-modal-view { - z-index: 1001; + z-index: var(--ZI_media_modal); + flex-direction: column; - .modal-view-button-arrow { + .modal-view-button-arrow, + .modal-view-button-hide { opacity: 0.75; &:focus, @@ -67,69 +133,154 @@ outline: none; box-shadow: none; } + &:hover { opacity: 1; } } + overflow: hidden; } -@keyframes media-fadein { - from { - opacity: 0; +.media-modal-view { + @keyframes media-fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } } - to { - opacity: 1; + + .modal-image-container { + display: flex; + overflow: hidden; + align-items: center; + flex-direction: column; + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + flex-grow: 1; + justify-content: center; + + &-inner { + width: 100%; + height: 100%; + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } } -} -.modal-image { - max-width: 90%; - max-height: 90%; - box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); - image-orientation: from-image; // NOTE: only FF supports this - animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein; -} + .description, + .counter { + /* Hardcoded since background is also hardcoded */ + color: white; + margin-top: 1em; + text-shadow: 0 0 10px black, 0 0 10px black; + padding: 0.2em 2em; + } + + .description { + flex: 0 0 auto; + overflow-y: auto; + min-height: 1em; + max-width: 500px; + max-height: 9.5em; + word-break: break-all; + } -.modal-view-button-arrow { - position: absolute; - display: block; - top: 50%; - margin-top: -50px; - width: 70px; - height: 100px; - border: 0; - padding: 0; - opacity: 0; - box-shadow: none; - background: none; - appearance: none; - overflow: visible; - cursor: pointer; - transition: opacity 333ms cubic-bezier(.4,0,.22,1); - - .arrow-icon { + .modal-image { + max-width: 100%; + max-height: 100%; + image-orientation: from-image; // NOTE: only FF supports this + animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein; + + &.loading { + opacity: 0.5; + } + } + + .loading-spinner { + width: 100%; + height: 100%; position: absolute; - top: 35px; - height: 30px; - width: 32px; - font-size: 14px; - line-height: 30px; - color: #FFF; - text-align: center; - background-color: rgba(0,0,0,.3); + pointer-events: none; + display: flex; + justify-content: center; + align-items: center; + + svg { + color: white; + } } - &--prev { - left: 0; + .modal-view-button { + border: 0; + padding: 0; + opacity: 0; + box-shadow: none; + background: none; + appearance: none; + overflow: visible; + cursor: pointer; + transition: opacity 333ms cubic-bezier(.4,0,.22,1); + height: $modal-view-button-icon-height; + width: $modal-view-button-icon-width; + + .button-icon { + position: absolute; + height: $modal-view-button-icon-height; + width: $modal-view-button-icon-width; + font-size: 1rem; + line-height: $modal-view-button-icon-height; + color: #FFF; + text-align: center; + background-color: rgba(0,0,0,.3); + } + } + + .modal-view-button-arrow { + position: absolute; + display: block; + top: 50%; + margin-top: $modal-view-button-icon-half-height; + width: $modal-view-button-icon-width; + height: $modal-view-button-icon-height; + .arrow-icon { - left: 6px; + position: absolute; + top: 0; + line-height: $modal-view-button-icon-height; + color: #FFF; + text-align: center; + background-color: rgba(0,0,0,.3); + } + + &--prev { + left: 0; + .arrow-icon { + left: $modal-view-button-icon-margin; + } + } + + &--next { + right: 0; + .arrow-icon { + right: $modal-view-button-icon-margin; + } } } - &--next { + .modal-view-button-hide { + position: absolute; + top: 0; right: 0; - .arrow-icon { - right: 6px; + .button-icon { + top: $modal-view-button-icon-margin; + right: $modal-view-button-icon-margin; } } } diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 669d8190..cfd42d4c 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -42,7 +42,8 @@ const mediaUpload = { .then((fileData) => { self.$emit('uploaded', fileData) self.decreaseUploadCount() - }, (error) => { // eslint-disable-line handle-callback-err + }, (error) => { + console.error('Error uploading file', error) self.$emit('upload-failed', 'default') self.decreaseUploadCount() }) @@ -73,7 +74,7 @@ const mediaUpload = { 'disabled' ], watch: { - 'dropFiles': function (fileInfos) { + dropFiles: function (fileInfos) { if (!this.uploading) { this.multiUpload(fileInfos) } diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index e955aa72..a538a5ed 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -17,21 +17,25 @@ /> <input v-if="uploadReady" + class="hidden-input-file" :disabled="disabled" type="file" - style="position: fixed; top: -100em" multiple="true" @change="change" > </label> </template> -<script src="./media_upload.js" ></script> +<script src="./media_upload.js"></script> <style lang="scss"> @import '../../_variables.scss'; .media-upload { - cursor: pointer; + cursor: pointer; // We use <label> for interactivity... i wonder if it's fine + + .hidden-input-file { + display: none; + } } </style> diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js index 65c62baa..6515bd11 100644 --- a/src/components/mention_link/mention_link.js +++ b/src/components/mention_link/mention_link.js @@ -1,6 +1,9 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters, mapState } from 'vuex' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' +import UserAvatar from '../user_avatar/user_avatar.vue' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' +import { defineAsyncComponent } from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faAt @@ -12,6 +15,11 @@ library.add( const MentionLink = { name: 'MentionLink', + components: { + UserAvatar, + UnicodeDomainIndicator, + UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) + }, props: { url: { required: true, @@ -30,15 +38,30 @@ const MentionLink = { type: String } }, + data () { + return { + hasSelection: false + } + }, methods: { onClick () { + if (this.shouldShowTooltip) return const link = generateProfileLink( this.userId || this.user.id, this.userScreenName || this.user.screen_name ) this.$router.push(link) + }, + handleSelection () { + this.hasSelection = document.getSelection().containsNode(this.$refs.full, true) } }, + mounted () { + document.addEventListener('selectionchange', this.handleSelection) + }, + unmounted () { + document.removeEventListener('selectionchange', this.handleSelection) + }, computed: { user () { return this.url && this.$store && this.$store.getters.findUserByUrl(this.url) @@ -50,6 +73,10 @@ const MentionLink = { userName () { return this.user && this.userNameFullUi.split('@')[0] }, + serverName () { + // XXX assumed that domain does not contain @ + return this.user && (this.userNameFullUi.split('@')[1] || this.$store.getters.instanceDomain) + }, userNameFull () { return this.user && this.user.screen_name }, @@ -79,12 +106,44 @@ const MentionLink = { classnames () { return [ { - '-you': this.isYou, - '-highlighted': this.highlight + '-you': this.isYou && this.shouldBoldenYou, + '-highlighted': this.highlight, + '-has-selection': this.hasSelection }, this.highlightType ] }, + useAtIcon () { + return this.mergedConfig.useAtIcon + }, + isRemote () { + return this.userName !== this.userNameFull + }, + shouldShowFullUserName () { + const conf = this.mergedConfig.mentionLinkDisplay + if (conf === 'short') { + return false + } else if (conf === 'full') { + return true + } else { // full_for_remote + return this.isRemote + } + }, + shouldShowTooltip () { + return this.mergedConfig.mentionLinkShowTooltip + }, + shouldShowAvatar () { + return this.mergedConfig.mentionLinkShowAvatar + }, + shouldShowYous () { + return this.mergedConfig.mentionLinkShowYous + }, + shouldBoldenYou () { + return this.mergedConfig.mentionLinkBoldenYou + }, + shouldFadeDomain () { + return this.mergedConfig.mentionLinkFadeDomain + }, ...mapGetters(['mergedConfig']), ...mapState({ currentUser: state => state.users.currentUser diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss index ec2689f8..8b2af926 100644 --- a/src/components/mention_link/mention_link.scss +++ b/src/components/mention_link/mention_link.scss @@ -1,15 +1,27 @@ +@import '../../_variables.scss'; + .MentionLink { position: relative; white-space: normal; - display: inline-block; + display: inline; color: var(--link); + word-break: normal; & .new, & .original { - display: inline-block; + display: inline; border-radius: 2px; } + .mention-avatar { + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + width: 1.5em; + height: 1.5em; + vertical-align: middle; + user-select: none; + margin-right: 0.2em; + } + .full { position: absolute; display: inline-block; @@ -27,7 +39,8 @@ user-select: all; } - .short { + & .short.-with-tooltip, + & .you { user-select: none; } @@ -36,19 +49,25 @@ white-space: nowrap; } + .shortName { + white-space: normal; + } + .new { &.-you { - & .shortName, - & .full { + .shortName { font-weight: 600; } } + &.-has-selection { + color: var(--alertNeutralText, $fallback--text); + background-color: var(--alertNeutral, $fallback--fg); + } .at { color: var(--link); opacity: 0.8; display: inline-block; - height: 50%; line-height: 1; padding: 0 0.1em; vertical-align: -25%; @@ -56,8 +75,7 @@ } &.-striped { - & .userName, - & .full { + & .shortName { background-image: repeating-linear-gradient( 135deg, @@ -70,22 +88,29 @@ } &.-solid { - & .userName, - & .full { + .shortName { background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2)); } } &.-side { - & .userName, - & .userNameFull { + .shortName { box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor); } } } - &:hover .new .full { - opacity: 1; - pointer-events: initial; + .full { + pointer-events: none; } + + .serverName.-faded { + color: var(--faintLink, $fallback--link); + } +} + +.mention-link-popover { + max-width: 70ch; + max-height: 20rem; + overflow: hidden; } diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue index a22b486c..869a3257 100644 --- a/src/components/mention_link/mention_link.vue +++ b/src/components/mention_link/mention_link.vue @@ -9,48 +9,67 @@ class="original" target="_blank" v-html="content" - /> - <!-- eslint-enable vue/no-v-html --> - <span - v-if="user" - class="new" - :style="style" - :class="classnames" + /><!-- eslint-enable vue/no-v-html --> + <UserPopover + v-else + :user-id="user.id" + :disabled="!shouldShowTooltip" > - <a - class="short button-unstyled" - :href="url" - @click.prevent="onClick" - > - <!-- eslint-disable vue/no-v-html --> - <FAIcon - size="sm" - icon="at" - class="at" - /><span class="shortName"><span - class="userName" - v-html="userName" - /></span> - <span - v-if="isYou" - class="you" - >{{ $t('status.you') }}</span> - <!-- eslint-enable vue/no-v-html --> - </a> <span - v-if="userName !== userNameFull" - class="full popover-default" - :class="[highlightType]" + v-if="user" + class="new" + :style="style" + :class="classnames" > - <span - class="userNameFull" - v-text="'@' + userNameFull" - /> + <a + class="short button-unstyled" + :class="{ '-with-tooltip': shouldShowTooltip }" + :href="url" + @click.prevent="onClick" + > + <!-- eslint-disable vue/no-v-html --> + <UserAvatar + v-if="shouldShowAvatar" + class="mention-avatar" + :user="user" + /><span + class="shortName" + ><FAIcon + v-if="useAtIcon" + size="sm" + icon="at" + class="at" + />{{ !useAtIcon ? '@' : '' }}<span + class="userName" + v-html="userName" + /><span + v-if="shouldShowFullUserName" + class="serverName" + :class="{ '-faded': shouldFadeDomain }" + v-html="'@' + serverName" + /><UnicodeDomainIndicator + v-if="shouldShowFullUserName" + :user="user" + /> + </span> + <span + v-if="isYou && shouldShowYous" + :class="{ '-you': shouldBoldenYou }" + > {{ ' ' + $t('status.you') }}</span> + <!-- eslint-enable vue/no-v-html --> + </a><span + ref="full" + class="full" + > + <!-- eslint-disable vue/no-v-html --> + @<span v-html="userName" /><span v-html="'@' + serverName" /> + <!-- eslint-enable vue/no-v-html --> + </span> </span> - </span> + </UserPopover> </span> </template> -<script src="./mention_link.js"/> +<script src="./mention_link.js" /> -<style lang="scss" src="./mention_link.scss"/> +<style lang="scss" src="./mention_link.scss" /> diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss index b9d5c14a..9a622e75 100644 --- a/src/components/mentions_line/mentions_line.scss +++ b/src/components/mentions_line/mentions_line.scss @@ -1,11 +1,13 @@ .MentionsLine { + word-break: break-all; + + .mention-link:not(:first-child)::before { + content: ' '; + } + .showMoreLess { + margin-left: 0.5em; white-space: normal; color: var(--link); } - - .fullExtraMentions, - .mention-link:not(:last-child) { - margin-right: 0.25em; - } } diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue index f375e3b0..64c19bf1 100644 --- a/src/components/mentions_line/mentions_line.vue +++ b/src/components/mentions_line/mentions_line.vue @@ -6,7 +6,6 @@ class="mention-link" :content="mention.content" :url="mention.url" - :first-mention="false" /><span v-if="manyMentions" class="extraMentions" @@ -14,15 +13,13 @@ <span v-if="expanded" class="fullExtraMentions" - > - <MentionLink - v-for="mention in extraMentions" - :key="mention.index" - class="mention-link" - :content="mention.content" - :url="mention.url" - :first-mention="false" - /> + >{{ ' ' }}<MentionLink + v-for="mention in extraMentions" + :key="mention.index" + class="mention-link" + :content="mention.content" + :url="mention.url" + /> </span><button v-if="!expanded" class="button-unstyled showMoreLess" @@ -39,5 +36,5 @@ </span> </span> </template> -<script src="./mentions_line.js" ></script> +<script src="./mentions_line.js"></script> <style lang="scss" src="./mentions_line.scss" /> diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue index 7c594228..5988fa51 100644 --- a/src/components/mfa_form/recovery_form.vue +++ b/src/components/mfa_form/recovery_form.vue @@ -56,13 +56,17 @@ > <div class="alert error"> {{ error }} - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="times" + <button + class="button-unstyled" @click="clearError" - /> + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + /> + </button> </div> </div> </div> </template> -<script src="./recovery_form.js" ></script> +<script src="./recovery_form.js"></script> diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue index 4ee13992..709eb9b8 100644 --- a/src/components/mfa_form/totp_form.vue +++ b/src/components/mfa_form/totp_form.vue @@ -58,12 +58,16 @@ > <div class="alert error"> {{ error }} - <FAIcon - size="lg" - class="fa-scale-110 fa-old-padding" - icon="times" + <button + class="button-unstyled" @click="clearError" - /> + > + <FAIcon + size="lg" + class="fa-scale-110 fa-old-padding" + icon="times" + /> + </button> </div> </div> </div> diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js index 9e736cfb..cdbbb812 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -2,33 +2,40 @@ 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 NavigationPins from 'src/components/navigation/navigation_pins.vue' import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { faTimes, faBell, - faBars + faBars, + faArrowUp, + faMinus } from '@fortawesome/free-solid-svg-icons' library.add( faTimes, faBell, - faBars + faBars, + faArrowUp, + faMinus ) const MobileNav = { components: { SideDrawer, - Notifications + Notifications, + NavigationPins }, data: () => ({ notificationsCloseGesture: undefined, - notificationsOpen: false + notificationsOpen: false, + notificationsAtTop: true }), created () { this.notificationsCloseGesture = GestureService.swipeGesture( GestureService.DIRECTION_RIGHT, - this.closeMobileNotifications, + () => this.closeMobileNotifications(true), 50 ) }, @@ -47,7 +54,10 @@ const MobileNav = { isChat () { return this.$route.name === 'chat' }, - ...mapGetters(['unreadChatCount']) + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']), + chatsPinned () { + return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats') + } }, methods: { toggleMobileSidebar () { @@ -56,12 +66,14 @@ const MobileNav = { openMobileNotifications () { this.notificationsOpen = true }, - closeMobileNotifications () { + closeMobileNotifications (markRead) { if (this.notificationsOpen) { // make sure to mark notifs seen only when the notifs were open and not // from close-calls. this.notificationsOpen = false - this.markNotificationsAsSeen() + if (markRead) { + this.markNotificationsAsSeen() + } } }, notificationsTouchStart (e) { @@ -73,14 +85,19 @@ const MobileNav = { scrollToTop () { window.scrollTo(0, 0) }, + scrollMobileNotificationsToTop () { + this.$refs.mobileNotifications.scrollTo(0, 0) + }, logout () { this.$router.replace('/main/public') this.$store.dispatch('logout') }, markNotificationsAsSeen () { - this.$refs.notifications.markAsSeen() + // this.$refs.notifications.markAsSeen() + this.$store.dispatch('markNotificationsAsSeen') }, onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { + this.notificationsAtTop = scrollTop > 0 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 0f0ea457..0f1fe621 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -5,12 +5,13 @@ <nav id="nav" class="mobile-nav" - :class="{ 'mobile-hidden': isChat }" @click="scrollToTop()" > <div class="item"> <button class="button-unstyled mobile-nav-button" + :title="$t('nav.mobile_sidebar')" + :aria-expanaded="$refs.sideDrawer && !$refs.sideDrawer.closed" @click.stop.prevent="toggleMobileSidebar()" > <FAIcon @@ -18,23 +19,16 @@ icon="bars" /> <div - v-if="unreadChatCount" + v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount" class="alert-dot" /> </button> - <router-link - v-if="!hideSitename" - class="site-name" - :to="{ name: 'root' }" - active-class="home" - > - {{ sitename }} - </router-link> - </div> - <div class="item right"> + <NavigationPins class="pins" /> + </div> <div class="item right"> <button v-if="currentUser" class="button-unstyled mobile-nav-button" + :title="unseenNotificationsCount ? $t('nav.mobile_notifications_unread_active') : $t('nav.mobile_notifications')" @click.stop.prevent="openMobileNotifications()" > <FAIcon @@ -48,35 +42,48 @@ </button> </div> </nav> - <div + <aside v-if="currentUser" class="mobile-notifications-drawer" - :class="{ 'closed': !notificationsOpen }" + :class="{ '-closed': !notificationsOpen }" @touchstart.stop="notificationsTouchStart" @touchmove.stop="notificationsTouchMove" > <div class="mobile-notifications-header"> <span class="title">{{ $t('notifications.notifications') }}</span> - <a - class="mobile-nav-button" - @click.stop.prevent="closeMobileNotifications()" + <span class="spacer" /> + <button + v-if="notificationsAtTop" + class="button-unstyled mobile-nav-button" + :title="$t('general.scroll_to_top')" + @click.stop.prevent="scrollMobileNotificationsToTop" + > + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon icon="arrow-up" /> + <FAIcon + icon="minus" + transform="up-7" + /> + </FALayers> + </button> + <button + class="button-unstyled mobile-nav-button" + :title="$t('nav.mobile_notifications_close')" + @click.stop.prevent="closeMobileNotifications(true)" > <FAIcon class="fa-scale-110 fa-old-padding" icon="times" /> - </a> + </button> </div> <div + id="mobile-notifications" + ref="mobileNotifications" class="mobile-notifications" @scroll="onScroll" - > - <Notifications - ref="notifications" - :no-heading="true" - /> - </div> - </div> + /> + </aside> <SideDrawer ref="sideDrawer" :logout="logout" @@ -90,15 +97,19 @@ @import '../../_variables.scss'; .MobileNav { + z-index: var(--ZI_navbar); + .mobile-nav { display: grid; - line-height: 50px; - height: 50px; + line-height: var(--navbar-height); grid-template-rows: 50px; grid-template-columns: 2fr auto; width: 100%; - position: fixed; box-sizing: border-box; + + a { + color: var(--topBarLink, $fallback--link); + } } .mobile-inner-nav { @@ -150,11 +161,12 @@ transition-property: transform; transition-duration: 0.25s; transform: translateX(0); - z-index: 1001; + z-index: var(--ZI_navbar); -webkit-overflow-scrolling: touch; - &.closed { + &.-closed { transform: translateX(100%); + box-shadow: none; } } @@ -162,7 +174,7 @@ display: flex; align-items: center; justify-content: space-between; - z-index: 1; + z-index: calc(var(--ZI_navbar) + 100); width: 100%; height: 50px; line-height: 50px; @@ -173,19 +185,30 @@ box-shadow: 0px 0px 4px rgba(0,0,0,.6); box-shadow: var(--topBarShadow); + .spacer { + flex: 1; + } + .title { font-size: 1.3em; margin-left: 0.6em; } } + .pins { + flex: 1; + + .pinned-item { + flex-grow: 1; + } + } + .mobile-notifications { margin-top: 50px; width: 100vw; - height: calc(100vh - 50px); + height: calc(100vh - var(--navbar-height)); overflow-x: hidden; overflow-y: scroll; - color: $fallback--text; color: var(--text, $fallback--text); background-color: $fallback--bg; @@ -195,14 +218,17 @@ padding: 0; border-radius: 0; box-shadow: none; + .panel { border-radius: 0; margin: 0; box-shadow: none; } - .panel:after { + + .panel::after { border-radius: 0; } + .panel .panel-heading { border-radius: 0; box-shadow: none; 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 d27fb3b8..f7f96cd6 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 @@ -10,7 +10,8 @@ library.add( const HIDDEN_FOR_PAGES = new Set([ 'chats', - 'chat' + 'chat', + 'lists-edit' ]) const MobilePostStatusButton = { @@ -29,7 +30,7 @@ const MobilePostStatusButton = { } window.addEventListener('resize', this.handleOSK) }, - destroyed () { + unmounted () { if (this.autohideFloatingPostButton) { this.deactivateFloatingPostButtonAutohide() } @@ -45,7 +46,7 @@ const MobilePostStatusButton = { return this.autohideFloatingPostButton && (this.hidden || this.inputActive) }, isPersistent () { - return !!this.$store.getters.mergedConfig.showNewPostButton + return !!this.$store.getters.mergedConfig.alwaysShowNewPostButton }, autohideFloatingPostButton () { return !!this.$store.getters.mergedConfig.autohideFloatingPostButton diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue index 37becf4c..28a2c440 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.vue +++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue @@ -1,13 +1,13 @@ <template> - <div v-if="isLoggedIn"> - <button - class="button-default new-status-button" - :class="{ 'hidden': isHidden, 'always-show': isPersistent }" - @click="openPostForm" - > - <FAIcon icon="pen" /> - </button> - </div> + <button + v-if="isLoggedIn" + class="MobilePostButton button-default new-status-button" + :class="{ 'hidden': isHidden, 'always-show': isPersistent }" + :title="$t('post_status.new_status')" + @click="openPostForm" + > + <FAIcon icon="pen" /> + </button> </template> <script src="./mobile_post_status_button.js"></script> @@ -15,25 +15,27 @@ <style lang="scss"> @import '../../_variables.scss'; -.new-status-button { - width: 5em; - height: 5em; - border-radius: 100%; - position: fixed; - bottom: 1.5em; - right: 1.5em; - // TODO: this needs its own color, it has to stand out enough and link color - // is not very optimal for this particular use. - background-color: $fallback--fg; - background-color: var(--btn, $fallback--fg); - display: flex; - justify-content: center; - align-items: center; - box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3); - z-index: 10; - - transition: 0.35s transform; - transition-timing-function: cubic-bezier(0, 1, 0.5, 1); +.MobilePostButton { + &.button-default { + width: 5em; + height: 5em; + border-radius: 100%; + position: fixed; + bottom: 1.5em; + right: 1.5em; + // TODO: this needs its own color, it has to stand out enough and link color + // is not very optimal for this particular use. + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3); + z-index: 10; + + transition: 0.35s transform; + transition-timing-function: cubic-bezier(0, 1, 0.5, 1); + } &.hidden { transform: translateY(150%); diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue index 2b58913f..2187f392 100644 --- a/src/components/modal/modal.vue +++ b/src/components/modal/modal.vue @@ -12,6 +12,9 @@ <script> export default { + provide: { + popoversZLayer: 'modals' + }, props: { isOpen: { type: Boolean, @@ -26,7 +29,7 @@ export default { classes () { return { 'modal-background': !this.noBackground, - 'open': this.isOpen + open: this.isOpen } } } @@ -35,7 +38,7 @@ export default { <style lang="scss"> .modal-view { - z-index: 1000; + z-index: var(--ZI_modals); position: fixed; top: 0; left: 0; diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js index 2469327a..a5ce8656 100644 --- a/src/components/moderation_tools/moderation_tools.js +++ b/src/components/moderation_tools/moderation_tools.js @@ -41,14 +41,26 @@ const ModerationTools = { tagsSet () { return new Set(this.user.tags) }, - hasTagPolicy () { - return this.$store.state.instance.tagPolicyAvailable + canGrantRole () { + return this.user.is_local && !this.user.deactivated && this.$store.state.users.currentUser.role === 'admin' + }, + canChangeActivationState () { + return this.privileged('users_manage_activation_state') + }, + canDeleteAccount () { + return this.privileged('users_delete') + }, + canUseTagPolicy () { + return this.$store.state.instance.tagPolicyAvailable && this.privileged('users_manage_tags') } }, methods: { hasTag (tagName) { return this.tagsSet.has(tagName) }, + privileged (privilege) { + return this.$store.state.users.currentUser.privileges.includes(privilege) + }, toggleTag (tag) { const store = this.$store if (this.tagsSet.has(tag)) { diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index 96476abe..8535ef27 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -8,9 +8,9 @@ @show="setToggled(true)" @close="setToggled(false)" > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> - <span v-if="user.is_local"> + <span v-if="canGrantRole"> <button class="button-default dropdown-item" @click="toggleRight("admin")" @@ -24,28 +24,31 @@ {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} </button> <div + v-if="canChangeActivationState || canDeleteAccount" role="separator" class="dropdown-divider" /> </span> <button + v-if="canChangeActivationState" class="button-default dropdown-item" @click="toggleActivationStatus()" > {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} </button> <button + v-if="canDeleteAccount" class="button-default dropdown-item" @click="deleteUserDialog(true)" > {{ $t('user_card.admin_menu.delete_account') }} </button> <div - v-if="hasTagPolicy" + v-if="canUseTagPolicy" role="separator" class="dropdown-divider" /> - <span v-if="hasTagPolicy"> + <span v-if="canUseTagPolicy"> <button class="button-default dropdown-item" @click="toggleTag(tags.FORCE_NSFW)" @@ -122,7 +125,7 @@ </span> </div> </template> - <template v-slot:trigger> + <template #trigger> <button class="btn button-default btn-block moderation-tools-button" :class="{ toggled }" @@ -132,16 +135,16 @@ </button> </template> </Popover> - <portal to="modal"> + <teleport to="#modal"> <DialogModal v-if="showDeleteUserDialog" :on-cancel="deleteUserDialog.bind(this, false)" > - <template v-slot:header> + <template #header> {{ $t('user_card.admin_menu.delete_user') }} </template> <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> - <template v-slot:footer> + <template #footer> <button class="btn button-default" @click="deleteUserDialog(false)" @@ -156,7 +159,7 @@ </button> </template> </DialogModal> - </portal> + </teleport> </div> </template> diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js index 3fde8106..13cfb52e 100644 --- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js +++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js @@ -9,10 +9,10 @@ import { get } from 'lodash' */ const toInstanceReasonObject = (instances, info, key) => { return instances.map(instance => { - if (info[key] && info[key][instance] && info[key][instance]['reason']) { - return { instance: instance, reason: info[key][instance]['reason'] } + if (info[key] && info[key][instance] && info[key][instance].reason) { + return { instance, reason: info[key][instance].reason } } - return { instance: instance, reason: '' } + return { instance, reason: '' } }) } diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 37bcb409..8c9c3b11 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,5 +1,10 @@ -import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' +import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue' import { mapState, mapGetters } from 'vuex' +import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js' +import { filterNavigation } from 'src/components/navigation/filter.js' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import NavigationPins from 'src/components/navigation/navigation_pins.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -12,7 +17,9 @@ import { faComments, faBell, faInfoCircle, - faStream + faStream, + faList, + faBullhorn } from '@fortawesome/free-solid-svg-icons' library.add( @@ -25,26 +32,53 @@ library.add( faComments, faBell, faInfoCircle, - faStream + faStream, + faList, + faBullhorn ) - const NavPanel = { + props: ['forceExpand', 'forceEditMode'], created () { - if (this.currentUser && this.currentUser.locked) { - this.$store.dispatch('startFetchingFollowRequests') - } }, components: { - TimelineMenuContent + ListsMenuContent, + NavigationEntry, + NavigationPins, + Checkbox }, data () { return { - showTimelines: false + editMode: false, + showTimelines: false, + showLists: false, + timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })), + rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k })) } }, methods: { toggleTimelines () { this.showTimelines = !this.showTimelines + }, + toggleLists () { + this.showLists = !this.showLists + }, + toggleEditMode () { + this.editMode = !this.editMode + }, + toggleCollapse () { + this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed }) + this.$store.dispatch('pushServerSideStorage') + }, + isPinned (item) { + return this.pinnedItems.has(item) + }, + togglePin (item) { + if (this.isPinned(item)) { + this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item }) + } else { + this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item }) + } + this.$store.dispatch('pushServerSideStorage') } }, computed: { @@ -53,9 +87,40 @@ const NavPanel = { followRequestCount: state => state.api.followRequests.length, privateMode: state => state.instance.private, federating: state => state.instance.federating, - pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, + supportsAnnouncements: state => state.announcements.supportsAnnouncements, + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems), + collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav }), - ...mapGetters(['unreadChatCount']) + timelinesItems () { + return filterNavigation( + Object + .entries({ ...TIMELINES }) + .map(([k, v]) => ({ ...v, name: k })), + { + hasChats: this.pleromaChatMessagesAvailable, + hasAnnouncements: this.supportsAnnouncements, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ) + }, + rootItems () { + return filterNavigation( + Object + .entries({ ...ROOT_ITEMS }) + .map(([k, v]) => ({ ...v, name: k })), + { + hasChats: this.pleromaChatMessagesAvailable, + hasAnnouncements: this.supportsAnnouncements, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ) + }, + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) } } diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 7ae7b1d6..d628c380 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -1,96 +1,105 @@ <template> <div class="NavPanel"> <div class="panel panel-default"> - <ul> - <li v-if="currentUser || !privateMode"> - <button - class="button-unstyled menu-item" - @click="toggleTimelines" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="stream" - />{{ $t("nav.timelines") }} - <FAIcon - class="timelines-chevron" - fixed-width - :icon="showTimelines ? 'chevron-up' : 'chevron-down'" + <div + v-if="!forceExpand" + class="panel-heading nav-panel-heading" + > + <NavigationPins :limit="6" /> + <div class="spacer" /> + <button + class="button-unstyled" + @click="toggleCollapse" + > + <FAIcon + class="navigation-chevron" + fixed-width + :icon="collapsed ? 'chevron-down' : 'chevron-up'" + /> + </button> + </div> + <ul + v-if="!collapsed || forceExpand" + class="panel-body" + > + <NavigationEntry + v-if="currentUser || !privateMode" + :show-pin="false" + :item="{ icon: 'stream', label: 'nav.timelines' }" + :aria-expanded="showTimelines ? 'true' : 'false'" + @click="toggleTimelines" + > + <FAIcon + class="timelines-chevron" + fixed-width + :icon="showTimelines ? 'chevron-up' : 'chevron-down'" + /> + </NavigationEntry> + <div + v-show="showTimelines" + class="timelines-background" + > + <div class="timelines"> + <NavigationEntry + v-for="item in timelinesItems" + :key="item.name" + :show-pin="editMode || forceEditMode" + :item="item" /> - </button> - <div - v-show="showTimelines" - class="timelines-background" - > - <TimelineMenuContent class="timelines" /> </div> - </li> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'interactions', params: { username: currentUser.screen_name } }" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="bell" - />{{ $t("nav.interactions") }} - </router-link> - </li> - <li v-if="currentUser && pleromaChatMessagesAvailable"> - <router-link - class="menu-item" - :to="{ name: 'chats', params: { username: currentUser.screen_name } }" - > - <div - v-if="unreadChatCount" - class="badge badge-notification" - > - {{ unreadChatCount }} - </div> - <FAIcon - fixed-width - class="fa-scale-110" - icon="comments" - />{{ $t("nav.chats") }} - </router-link> - </li> - <li v-if="currentUser && currentUser.locked"> - <router-link - class="menu-item" - :to="{ name: 'friend-requests' }" - > - <FAIcon - fixed-width - class="fa-scale-110" - icon="user-plus" - />{{ $t("nav.friend_requests") }} - <span - v-if="followRequestCount > 0" - class="badge badge-notification" - > - {{ followRequestCount }} - </span> - </router-link> - </li> - <li> + </div> + <NavigationEntry + v-if="currentUser" + :show-pin="false" + :item="{ icon: 'list', label: 'nav.lists' }" + :aria-expanded="showLists ? 'true' : 'false'" + @click="toggleLists" + > <router-link - class="menu-item" - :to="{ name: 'about' }" + :title="$t('lists.manage_lists')" + class="extra-button" + :to="{ name: 'lists' }" + @click.stop > <FAIcon + class="extra-button" fixed-width - class="fa-scale-110" - icon="info-circle" - />{{ $t("nav.about") }} + icon="wrench" + /> </router-link> - </li> + <FAIcon + class="timelines-chevron" + fixed-width + :icon="showLists ? 'chevron-up' : 'chevron-down'" + /> + </NavigationEntry> + <div + v-show="showLists" + class="timelines-background" + > + <ListsMenuContent + :show-pin="editMode || forceEditMode" + class="timelines" + /> + </div> + <NavigationEntry + v-for="item in rootItems" + :key="item.name" + :show-pin="editMode || forceEditMode" + :item="item" + /> + <NavigationEntry + v-if="!forceEditMode && currentUser" + :show-pin="false" + :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }" + @click="toggleEditMode" + /> </ul> </div> </div> </template> -<script src="./nav_panel.js" ></script> +<script src="./nav_panel.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -112,8 +121,9 @@ border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); - padding: 0; + } + > li { &:first-child .menu-item { border-top-right-radius: $fallback--panelRadius; border-top-right-radius: var(--panelRadius, $fallback--panelRadius); @@ -133,42 +143,10 @@ border: none; } - .menu-item { - display: block; - box-sizing: border-box; - height: 3.5em; - line-height: 3.5em; - padding: 0 1em; - width: 100%; - color: $fallback--link; - color: var(--link, $fallback--link); - - &: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; - } - } + .navigation-chevron { + margin-left: 0.8em; + margin-right: 0.8em; + font-size: 1.1em; } .timelines-chevron { @@ -180,7 +158,7 @@ padding: 0 0 0 0.6em; background-color: $fallback--lightBg; background-color: var(--selectedMenu, $fallback--lightBg); - border-top: 1px solid; + border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); } @@ -190,14 +168,9 @@ background-color: var(--bg, $fallback--bg); } - .fa-scale-110 { - margin-right: 0.8em; - } - - .badge { - position: absolute; - right: 0.6rem; - top: 1.25em; + .nav-panel-heading { + // breaks without a unit + --panel-heading-height-padding: 0em; } } </style> diff --git a/src/components/navigation/filter.js b/src/components/navigation/filter.js new file mode 100644 index 00000000..e8e77f8f --- /dev/null +++ b/src/components/navigation/filter.js @@ -0,0 +1,19 @@ +export const filterNavigation = (list = [], { hasChats, hasAnnouncements, isFederating, isPrivate, currentUser }) => { + return list.filter(({ criteria, anon, anonRoute }) => { + const set = new Set(criteria || []) + if (!isFederating && set.has('federating')) return false + if (!currentUser && isPrivate && set.has('!private')) return false + if (!currentUser && !(anon || anonRoute)) return false + if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false + if (!hasChats && set.has('chats')) return false + if (!hasAnnouncements && set.has('announcements')) return false + return true + }) +} + +export const getListEntries = state => state.lists.allLists.map(list => ({ + name: 'list-' + list.id, + routeObject: { name: 'lists-timeline', params: { id: list.id } }, + labelRaw: list.title, + iconLetter: list.title[0] +})) diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js new file mode 100644 index 00000000..7f096316 --- /dev/null +++ b/src/components/navigation/navigation.js @@ -0,0 +1,82 @@ +export const USERNAME_ROUTES = new Set([ + 'bookmarks', + 'dms', + 'interactions', + 'notifications', + 'chat', + 'chats', + 'user-profile' +]) + +export const TIMELINES = { + home: { + route: 'friends', + icon: 'home', + label: 'nav.home_timeline', + criteria: ['!private'] + }, + public: { + route: 'public-timeline', + anon: true, + icon: 'users', + label: 'nav.public_tl', + criteria: ['!private'] + }, + twkn: { + route: 'public-external-timeline', + anon: true, + icon: 'globe', + label: 'nav.twkn', + criteria: ['!private', 'federating'] + }, + bookmarks: { + route: 'bookmarks', + icon: 'bookmark', + label: 'nav.bookmarks' + }, + favorites: { + routeObject: { name: 'user-profile', query: { tab: 'favorites' } }, + icon: 'star', + label: 'user_card.favorites' + }, + dms: { + route: 'dms', + icon: 'envelope', + label: 'nav.dms' + } +} + +export const ROOT_ITEMS = { + interactions: { + route: 'interactions', + icon: 'bell', + label: 'nav.interactions' + }, + chats: { + route: 'chats', + icon: 'comments', + label: 'nav.chats', + badgeGetter: 'unreadChatCount', + criteria: ['chats'] + }, + friendRequests: { + route: 'friend-requests', + icon: 'user-plus', + label: 'nav.friend_requests', + criteria: ['lockedUser'], + badgeGetter: 'followRequestCount' + }, + about: { + route: 'about', + anon: true, + icon: 'info-circle', + label: 'nav.about' + }, + announcements: { + route: 'announcements', + icon: 'bullhorn', + label: 'nav.announcements', + badgeGetter: 'unreadAnnouncementCount', + criteria: ['announcements'] + } +} diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js new file mode 100644 index 00000000..81cc936a --- /dev/null +++ b/src/components/navigation/navigation_entry.js @@ -0,0 +1,51 @@ +import { mapState } from 'vuex' +import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js' +import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faThumbtack } from '@fortawesome/free-solid-svg-icons' + +library.add(faThumbtack) + +const NavigationEntry = { + props: ['item', 'showPin'], + components: { + OptionalRouterLink + }, + methods: { + isPinned (value) { + return this.pinnedItems.has(value) + }, + togglePin (value) { + if (this.isPinned(value)) { + this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value }) + } else { + this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value }) + } + this.$store.dispatch('pushServerSideStorage') + } + }, + computed: { + routeTo () { + if (!this.item.route && !this.item.routeObject) return null + let route + if (this.item.routeObject) { + route = this.item.routeObject + } else { + route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute } + } + if (USERNAME_ROUTES.has(route.name)) { + route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name } + } + return route + }, + getters () { + return this.$store.getters + }, + ...mapState({ + currentUser: state => state.users.currentUser, + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems) + }) + } +} + +export default NavigationEntry diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue new file mode 100644 index 00000000..f4d53836 --- /dev/null +++ b/src/components/navigation/navigation_entry.vue @@ -0,0 +1,133 @@ +<template> + <OptionalRouterLink + v-slot="{ isActive, href, navigate } = {}" + ass="ass" + :to="routeTo" + > + <li + class="NavigationEntry menu-item" + :class="{ '-active': isActive }" + v-bind="$attrs" + > + <component + :is="routeTo ? 'a' : 'button'" + class="main-link button-unstyled" + :href="href" + @click="navigate" + > + <span> + <FAIcon + v-if="item.icon" + fixed-width + class="fa-scale-110 menu-icon" + :icon="item.icon" + /> + </span> + <span + v-if="item.iconLetter" + class="icon iconLetter fa-scale-110 menu-icon" + >{{ item.iconLetter }} + </span> + <span class="label"> + {{ item.labelRaw || $t(item.label) }} + </span> + </component> + <slot /> + <div + v-if="item.badgeGetter && getters[item.badgeGetter]" + class="badge badge-notification" + > + {{ getters[item.badgeGetter] }} + </div> + <button + v-if="showPin && currentUser" + type="button" + class="button-unstyled extra-button" + :title="$t(isPinned ? 'general.unpin' : 'general.pin' )" + :aria-pressed="!!isPinned" + @click.stop.prevent="togglePin(item.name)" + > + <FAIcon + v-if="showPin && currentUser" + fixed-width + class="fa-scale-110" + :class="{ 'veryfaint': !isPinned(item.name) }" + :transform="!isPinned(item.name) ? 'rotate-45' : ''" + icon="thumbtack" + /> + </button> + </li> + </OptionalRouterLink> +</template> + +<script src="./navigation_entry.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.NavigationEntry { + display: flex; + box-sizing: border-box; + align-items: baseline; + height: 3.5em; + line-height: 3.5em; + padding: 0 1em; + width: 100%; + color: $fallback--link; + color: var(--link, $fallback--link); + + .timelines-chevron { + margin-right: 0; + } + + .main-link { + flex: 1; + } + + .menu-icon { + margin-right: 0.8em; + } + + .extra-button { + width: 3em; + text-align: center; + + &:last-child { + margin-right: -0.8em; + } + } + + &: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); + + .menu-icon { + --icon: var(--text, $fallback--icon); + } + } + + &.-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); + + .menu-icon { + --icon: var(--text, $fallback--icon); + } + + &:hover { + text-decoration: underline; + } + } +} +</style> diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js new file mode 100644 index 00000000..9dd795aa --- /dev/null +++ b/src/components/navigation/navigation_pins.js @@ -0,0 +1,94 @@ +import { mapState } from 'vuex' +import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js' +import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faComments, + faBell, + faInfoCircle, + faStream, + faList +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faComments, + faBell, + faInfoCircle, + faStream, + faList +) + +const NavPanel = { + props: ['limit'], + methods: { + getRouteTo (item) { + if (item.routeObject) { + return item.routeObject + } + const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute } + if (USERNAME_ROUTES.has(route.name)) { + route.params = { username: this.currentUser.screen_name } + } + return route + } + }, + computed: { + getters () { + return this.$store.getters + }, + ...mapState({ + lists: getListEntries, + 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, + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems) + }), + pinnedList () { + if (!this.currentUser) { + return filterNavigation([ + { ...TIMELINES.public, name: 'public' }, + { ...TIMELINES.twkn, name: 'twkn' }, + { ...ROOT_ITEMS.about, name: 'about' } + ], + { + hasChats: this.pleromaChatMessagesAvailable, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + }) + } + return filterNavigation( + [ + ...Object + .entries({ ...TIMELINES }) + .filter(([k]) => this.pinnedItems.has(k)) + .map(([k, v]) => ({ ...v, name: k })), + ...this.lists.filter((k) => this.pinnedItems.has(k.name)), + ...Object + .entries({ ...ROOT_ITEMS }) + .filter(([k]) => this.pinnedItems.has(k)) + .map(([k, v]) => ({ ...v, name: k })) + ], + { + hasChats: this.pleromaChatMessagesAvailable, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ).slice(0, this.limit) + } + } +} + +export default NavPanel diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue new file mode 100644 index 00000000..6a9ed6f5 --- /dev/null +++ b/src/components/navigation/navigation_pins.vue @@ -0,0 +1,74 @@ +<template> + <span class="NavigationPins"> + <router-link + v-for="item in pinnedList" + :key="item.name" + class="pinned-item" + :to="getRouteTo(item)" + :title="item.labelRaw || $t(item.label)" + > + <FAIcon + v-if="item.icon" + fixed-width + :icon="item.icon" + /> + <span + v-if="item.iconLetter" + class="iconLetter fa-scale-110 fa-old-padding" + >{{ item.iconLetter }}</span> + <div + v-if="item.badgeGetter && getters[item.badgeGetter]" + class="alert-dot" + /> + </router-link> + </span> +</template> + +<script src="./navigation_pins.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +.NavigationPins { + display: flex; + flex-wrap: wrap; + overflow: hidden; + height: 100%; + + .alert-dot { + border-radius: 100%; + height: 0.5em; + width: 0.5em; + position: absolute; + right: calc(50% - 0.75em); + top: calc(50% - 0.5em); + background-color: $fallback--cRed; + background-color: var(--badgeNotification, $fallback--cRed); + } + + .pinned-item { + position: relative; + flex: 1 0 3em; + min-width: 2em; + text-align: center; + overflow: visible; + box-sizing: border-box; + height: 100%; + + & .svg-inline--fa, + & .iconLetter { + margin: 0; + } + + &.router-link-active { + color: $fallback--text; + color: var(--panelText, $fallback--text); + border-bottom: 4px solid; + + & .svg-inline--fa, + & .iconLetter { + color: inherit; + } + } + } +} +</style> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 398bb7a9..265aaee0 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -4,7 +4,10 @@ import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' import Timeago from '../timeago/timeago.vue' +import Report from '../report/report.vue' +import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' +import UserPopover from '../user_popover/user_popover.vue' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -17,7 +20,9 @@ import { faUserPlus, faEyeSlash, faUser, - faSuitcaseRolling + faSuitcaseRolling, + faExpandAlt, + faCompressAlt } from '@fortawesome/free-solid-svg-icons' library.add( @@ -28,29 +33,34 @@ library.add( faUserPlus, faUser, faEyeSlash, - faSuitcaseRolling + faSuitcaseRolling, + faExpandAlt, + faCompressAlt ) const Notification = { data () { return { - userExpanded: false, + statusExpanded: false, betterShadow: this.$store.state.interface.browserSupport.cssFilter, unmuted: false } }, - props: [ 'notification' ], + props: ['notification'], components: { StatusContent, UserAvatar, UserCard, Timeago, Status, - RichContent + Report, + RichContent, + UserPopover, + UserLink }, methods: { - toggleUserExpanded () { - this.userExpanded = !this.userExpanded + toggleStatusExpanded () { + this.statusExpanded = !this.statusExpanded }, generateUserProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss index ec291547..38978137 100644 --- a/src/components/notification/notification.scss +++ b/src/components/notification/notification.scss @@ -2,7 +2,18 @@ // TODO Copypaste from Status, should unify it somehow .Notification { - --emoji-size: 14px; + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + word-wrap: break-word; + word-break: break-word; + --emoji-size: 14px; + + &:hover { + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; + } &.-muted { padding: 0.25em 0.6em; diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 634ec8ee..f1aa5420 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,18 +1,23 @@ <template> - <Status + <article v-if="notification.type === 'mention'" - :compact="true" - :statusoid="notification.status" - /> - <div v-else> + > + <Status + class="Notification" + :compact="true" + :statusoid="notification.status" + /> + </article> + <article v-else> <div v-if="needMute && !unmuted" class="Notification container -muted" > <small> - <router-link :to="userProfileLink"> - {{ notification.from_profile.screen_name_ui }} - </router-link> + <user-link + :user="notification.from_profile" + :at="false" + /> </small> <button class="button-unstyled unmute" @@ -32,22 +37,23 @@ > <a class="avatar-container" - :href="notification.from_profile.statusnet_profile_url" - @click.stop.prevent.capture="toggleUserExpanded" + :href="$router.resolve(userProfileLink).href" + @click.prevent > - <UserAvatar - :compact="true" - :better-shadow="betterShadow" - :user="notification.from_profile" - /> + <UserPopover + :user-id="notification.from_profile.id" + :overlay-centers="true" + > + <UserAvatar + class="post-avatar" + :bot="botIndicator" + :compact="true" + :better-shadow="betterShadow" + :user="notification.from_profile" + /> + </UserPopover> </a> <div class="notification-right"> - <UserCard - v-if="userExpanded" - :user-id="getUser(notification).id" - :rounded="true" - :bordered="true" - /> <span class="notification-details"> <div class="name-and-action"> <!-- eslint-disable vue/no-v-html --> @@ -64,12 +70,16 @@ v-else class="username" :title="'@'+notification.from_profile.screen_name_ui" - >{{ notification.from_profile.name }}</span> + > + {{ notification.from_profile.name }} + </span> + {{ ' ' }} <span v-if="notification.type === 'like'"> <FAIcon class="type-icon" icon="star" /> + {{ ' ' }} <small>{{ $t('notifications.favorited_you') }}</small> </span> <span v-if="notification.type === 'repeat'"> @@ -78,6 +88,7 @@ icon="retweet" :title="$t('tool_tip.repeat')" /> + {{ ' ' }} <small>{{ $t('notifications.repeated_you') }}</small> </span> <span v-if="notification.type === 'follow'"> @@ -85,6 +96,7 @@ class="type-icon" icon="user-plus" /> + {{ ' ' }} <small>{{ $t('notifications.followed_you') }}</small> </span> <span v-if="notification.type === 'follow_request'"> @@ -92,6 +104,7 @@ class="type-icon" icon="user" /> + {{ ' ' }} <small>{{ $t('notifications.follow_request') }}</small> </span> <span v-if="notification.type === 'move'"> @@ -99,15 +112,30 @@ class="type-icon" icon="suitcase-rolling" /> + {{ ' ' }} <small>{{ $t('notifications.migrated_to') }}</small> </span> <span v-if="notification.type === 'pleroma:emoji_reaction'"> <small> - <i18n path="notifications.reacted_with"> + <i18n-t + scope="global" + keypath="notifications.reacted_with" + > <span class="emoji-reaction-emoji">{{ notification.emoji }}</span> - </i18n> + </i18n-t> </small> </span> + <span v-if="notification.type === 'pleroma:report'"> + <small>{{ $t('notifications.submitted_report') }}</small> + </span> + <span v-if="notification.type === 'poll'"> + <FAIcon + class="type-icon" + icon="poll-h" + /> + {{ ' ' }} + <small>{{ $t('notifications.poll_ended') }}</small> + </span> </div> <div v-if="isStatusNotification" @@ -116,13 +144,25 @@ <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" - class="faint-link" + class="timeago-link faint-link" > <Timeago :time="notification.created_at" :auto-update="240" /> </router-link> + <button + class="button-unstyled expand-icon" + @click.prevent="toggleStatusExpanded" + :title="$t('tool_tip.toggle_expand')" + :aria-expanded="statusExpanded" + > + <FAIcon + class="fa-scale-110" + fixed-width + :icon="statusExpanded ? 'compress-alt' : 'expand-alt'" + /> + </button> </div> <div v-else @@ -138,6 +178,8 @@ <button v-if="needMute" class="button-unstyled" + :title="$t('tool_tip.toggle_mute')" + :aria-expanded="!unmuted" @click.prevent="toggleMute" > <FAIcon @@ -150,47 +192,58 @@ v-if="notification.type === 'follow' || notification.type === 'follow_request'" class="follow-text" > - <router-link - :to="userProfileLink" + <user-link class="follow-name" - > - @{{ notification.from_profile.screen_name_ui }} - </router-link> + :user="notification.from_profile" + /> <div v-if="notification.type === 'follow_request'" style="white-space: nowrap;" > - <FAIcon - icon="check" - class="fa-scale-110 fa-old-padding follow-request-accept" + <button + class="button-unstyled" :title="$t('tool_tip.accept_follow_request')" @click="approveUser()" - /> - <FAIcon - icon="times" - class="fa-scale-110 fa-old-padding follow-request-reject" + > + <FAIcon + icon="check" + class="fa-scale-110 fa-old-padding follow-request-accept" + /> + </button> + <button + class="button-unstyled" :title="$t('tool_tip.reject_follow_request')" @click="denyUser()" - /> + > + <FAIcon + icon="times" + class="fa-scale-110 fa-old-padding follow-request-reject" + /> + </button> </div> </div> <div v-else-if="notification.type === 'move'" class="move-text" > - <router-link :to="targetUserProfileLink"> - @{{ notification.target.screen_name_ui }} - </router-link> + <user-link + :user="notification.target" + /> </div> + <Report + v-else-if="notification.type === 'pleroma:report'" + :report-id="notification.report.id" + /> <template v-else> - <status-content - class="faint" + <StatusContent + :class="{ faint: !statusExpanded }" + :compact="!statusExpanded" :status="notification.action" /> </template> </div> </div> - </div> + </article> </template> <script src="./notification.js"></script> diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue index ba0e90a0..1315b51a 100644 --- a/src/components/notifications/notification_filters.vue +++ b/src/components/notifications/notification_filters.vue @@ -5,7 +5,7 @@ placement="bottom" :bound-to="{ x: 'container' }" > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> <button class="button-default dropdown-item" @@ -61,10 +61,19 @@ :class="{ 'menu-checkbox-checked': filters.moves }" />{{ $t('settings.notification_visibility_moves') }} </button> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('polls')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.polls }" + />{{ $t('settings.notification_visibility_polls') }} + </button> </div> </template> - <template v-slot:trigger> - <button class="button-unstyled"> + <template #trigger> + <button class="filter-trigger-button button-unstyled"> <FAIcon icon="filter" /> </button> </template> @@ -100,23 +109,3 @@ export default { } } </script> - -<style lang="scss"> - -.NotificationFilters { - align-self: stretch; - - > button { - font-size: 1.2em; - padding-left: 0.7em; - padding-right: 0.2em; - line-height: 100%; - height: 100%; - } - - .dropdown-item { - margin: 0; - } -} - -</style> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index c8f1ebcb..d499d3d6 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,3 +1,4 @@ +import { computed } from 'vue' import { mapGetters } from 'vuex' import Notification from '../notification/notification.vue' import NotificationFilters from './notification_filters.vue' @@ -9,10 +10,12 @@ import { } from '../../services/notification_utils/notification_utils.js' import FaviconService from '../../services/favicon_service/favicon_service.js' import { library } from '@fortawesome/fontawesome-svg-core' -import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' +import { faCircleNotch, faArrowUp, faMinus } from '@fortawesome/free-solid-svg-icons' library.add( - faCircleNotch + faCircleNotch, + faArrowUp, + faMinus ) const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 @@ -23,16 +26,17 @@ const Notifications = { NotificationFilters }, props: { - // Disables display of panel header - noHeading: Boolean, // Disables panel styles, unread mark, potentially other notification-related actions // meant for "Interactions" timeline minimalMode: Boolean, // Custom filter mode, an array of strings, possible values 'mention', 'repeat', 'like', 'follow', used to override global filter for use in "Interactions" timeline - filterMode: Array + filterMode: Array, + // Disable teleporting (i.e. for /users/user/notifications) + disableTeleport: Boolean }, data () { return { + showScrollTop: false, bottomedOut: false, // How many seen notifications to display in the list. The more there are, // the heavier the page becomes. This count is increased when loading @@ -40,6 +44,11 @@ const Notifications = { seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT } }, + provide () { + return { + popoversZLayer: computed(() => this.popoversZLayer) + } + }, computed: { mainClass () { return this.minimalMode ? '' : 'panel panel-default' @@ -60,15 +69,46 @@ const Notifications = { return this.unseenNotifications.length }, unseenCountTitle () { - return this.unseenCount + (this.unreadChatCount) + return this.unseenCount + (this.unreadChatCount) + this.unreadAnnouncementCount }, loading () { return this.$store.state.statuses.notifications.loading }, + noHeading () { + const { layoutType } = this.$store.state.interface + return this.minimalMode || layoutType === 'mobile' + }, + teleportTarget () { + const { layoutType } = this.$store.state.interface + const map = { + wide: '#notifs-column', + mobile: '#mobile-notifications' + } + return map[layoutType] || '#notifs-sidebar' + }, + popoversZLayer () { + const { layoutType } = this.$store.state.interface + return layoutType === 'mobile' ? 'navbar' : null + }, notificationsToDisplay () { return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) }, - ...mapGetters(['unreadChatCount']) + noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) + }, + mounted () { + this.scrollerRef = this.$refs.root.closest('.column.-scrollable') + if (!this.scrollerRef) { + this.scrollerRef = this.$refs.root.closest('.mobile-notifications') + } + if (!this.scrollerRef) { + this.scrollerRef = this.$refs.root.closest('.column.main') + } + this.scrollerRef.addEventListener('scroll', this.updateScrollPosition) + }, + unmounted () { + if (!this.scrollerRef) return + this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition) }, watch: { unseenCountTitle (count) { @@ -79,9 +119,29 @@ const Notifications = { FaviconService.clearFaviconBadge() this.$store.dispatch('setPageTitle', '') } + }, + teleportTarget () { + // handle scroller change + this.$nextTick(() => { + this.scrollerRef.removeEventListener('scroll', this.updateScrollPosition) + this.scrollerRef = this.$refs.root.closest('.column.-scrollable') + if (!this.scrollerRef) { + this.scrollerRef = this.$refs.root.closest('.mobile-notifications') + } + this.scrollerRef.addEventListener('scroll', this.updateScrollPosition) + this.updateScrollPosition() + }) } }, methods: { + scrollToTop () { + const scrollable = this.scrollerRef + scrollable.scrollTo({ top: this.$refs.root.offsetTop }) + // this.$refs.root.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, + updateScrollPosition () { + this.showScrollTop = this.$refs.root.offsetTop < this.scrollerRef.scrollTop + }, markAsSeen () { this.$store.dispatch('markNotificationsAsSeen') this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 77b3c438..9b241565 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -11,10 +11,6 @@ color: var(--text, $fallback--text); } - .notifications-footer { - border: none; - } - .notification { position: relative; @@ -37,11 +33,6 @@ .notification { box-sizing: border-box; - border-bottom: 1px solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - word-wrap: break-word; - word-break: break-word; &:hover .animated.Avatar { canvas { @@ -52,6 +43,10 @@ } } + &:last-child .Notification { + border-bottom: none; + } + .non-mention { display: flex; flex: 1; @@ -64,13 +59,13 @@ height: 32px; } - --link: var(--faintLink); - --text: var(--faint); + .faint { + --link: var(--faintLink); + --text: var(--faint); + } } .follow-request-accept { - cursor: pointer; - &:hover { color: $fallback--text; color: var(--text, $fallback--text); @@ -78,8 +73,6 @@ } .follow-request-reject { - cursor: pointer; - &:hover { color: $fallback--cRed; color: var(--cRed, $fallback--cRed); @@ -119,16 +112,26 @@ min-width: 3em; text-align: right; } + + .timeago-link { + margin-right: 0.2em; + } + + .expand-icon { + .svg-inline--fa { + margin-left: 0.25em; + } + } } .emoji-reaction-emoji { - font-size: 16px; + font-size: 1.3em; } .notification-details { - min-width: 0px; + min-width: 0; word-wrap: break-word; - line-height:18px; + line-height: var(--post-line-height); position: relative; overflow: hidden; width: 100%; @@ -151,7 +154,7 @@ } .timeago { - margin-right: .2em; + margin-right: 0.2em; } .status-content { @@ -164,7 +167,8 @@ margin: 0 0 0.3em; padding: 0; font-size: 1em; - line-height:20px; + line-height: 1.5; + small { font-weight: lighter; } diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 2ce5d56f..633efca6 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -1,69 +1,100 @@ <template> - <div - :class="{ minimal: minimalMode }" - class="Notifications" + <teleport + :disabled="minimalMode || disableTeleport" + :to="teleportTarget" > - <div :class="mainClass"> - <div - v-if="!noHeading" - class="panel-heading" - > - <div class="title"> - {{ $t('notifications.notifications') }} - <span - v-if="unseenCount" - class="badge badge-notification unseen-count" - >{{ unseenCount }}</span> - </div> - <button - v-if="unseenCount" - class="button-default read-button" - @click.prevent="markAsSeen" - > - {{ $t('notifications.read') }} - </button> - <NotificationFilters /> - </div> - <div class="panel-body"> + <component + :is="noHeading ? 'div' : 'aside'" + ref="root" + :class="{ minimal: minimalMode }" + class="Notifications" + > + <div :class="mainClass"> <div - v-for="notification in notificationsToDisplay" - :key="notification.id" - class="notification" - :class="{"unseen": !minimalMode && !notification.seen}" + v-if="!noHeading" + class="notifications-heading panel-heading -sticky" > - <div class="notification-overlay" /> - <notification :notification="notification" /> + <div class="title"> + {{ $t('notifications.notifications') }} + <span + v-if="unseenCount" + class="badge badge-notification unseen-count" + >{{ unseenCount }}</span> + </div> + <div + v-if="showScrollTop" + class="rightside-button" + > + <button + class="button-unstyled scroll-to-top-button" + type="button" + :title="$t('general.scroll_to_top')" + @click="scrollToTop" + > + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon icon="arrow-up" /> + <FAIcon + icon="minus" + transform="up-7" + /> + </FALayers> + </button> + </div> + <button + v-if="unseenCount" + class="button-default read-button" + type="button" + @click.prevent="markAsSeen" + > + {{ $t('notifications.read') }} + </button> + <NotificationFilters class="rightside-button" /> </div> - </div> - <div class="panel-footer notifications-footer"> <div - v-if="bottomedOut" - class="new-status-notification text-center faint" + class="panel-body" + role="feed" > - {{ $t('notifications.no_more_notifications') }} + <div + v-for="notification in notificationsToDisplay" + :key="notification.id" + role="listitem" + class="notification" + :class="{unseen: !minimalMode && !notification.seen}" + > + <div class="notification-overlay" /> + <notification :notification="notification" /> + </div> </div> - <button - v-else-if="!loading" - class="button-unstyled -link -fullwidth" - @click.prevent="fetchOlderNotifications()" - > - <div class="new-status-notification text-center"> - {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }} + <div class="panel-footer"> + <div + v-if="bottomedOut" + class="new-status-notification text-center faint" + > + {{ $t('notifications.no_more_notifications') }} + </div> + <button + v-else-if="!loading" + class="button-unstyled -link -fullwidth" + @click.prevent="fetchOlderNotifications()" + > + <div class="new-status-notification text-center"> + {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }} + </div> + </button> + <div + v-else + class="new-status-notification text-center" + > + <FAIcon + icon="circle-notch" + spin + size="lg" + /> </div> - </button> - <div - v-else - class="new-status-notification text-center" - > - <FAIcon - icon="circle-notch" - spin - size="lg" - /> </div> </div> - </div> - </div> + </component> + </teleport> </template> <script src="./notifications.js"></script> diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue index 3cc3942b..15d08e04 100644 --- a/src/components/opacity_input/opacity_input.vue +++ b/src/components/opacity_input/opacity_input.vue @@ -11,21 +11,21 @@ </label> <Checkbox v-if="typeof fallback !== 'undefined'" - :checked="present" + :model-value="present" :disabled="disabled" class="opt" - @change="$emit('input', !present ? fallback : undefined)" + @update:modelValue="$emit('update:modelValue', !present ? fallback : undefined)" /> <input :id="name" class="input-number" type="number" - :value="value || fallback" + :value="modelValue || fallback" :disabled="!present || disabled" max="1" min="0" step=".05" - @input="$emit('input', $event.target.value)" + @input="$emit('update:modelValue', $event.target.value)" > </div> </template> @@ -37,11 +37,12 @@ export default { Checkbox }, props: [ - 'name', 'value', 'fallback', 'disabled' + 'name', 'modelValue', 'fallback', 'disabled' ], + emits: ['update:modelValue'], computed: { present () { - return typeof this.value !== 'undefined' + return typeof this.modelValue !== 'undefined' } } } diff --git a/src/components/optional_router_link/optional_router_link.vue b/src/components/optional_router_link/optional_router_link.vue new file mode 100644 index 00000000..d56ad268 --- /dev/null +++ b/src/components/optional_router_link/optional_router_link.vue @@ -0,0 +1,23 @@ +<template> + <!-- eslint-disable vue/no-multiple-template-root --> + <router-link + v-if="to" + v-slot="props" + :to="to" + custom + > + <slot + v-bind="props" + /> + </router-link> + <slot + v-else + v-bind="{}" + /> +</template> + +<script> +export default { + props: ['to'] +} +</script> diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue index 3ffa5425..90673f44 100644 --- a/src/components/password_reset/password_reset.vue +++ b/src/components/password_reset/password_reset.vue @@ -91,14 +91,18 @@ flex-direction: column; margin-top: 0.6em; max-width: 18rem; + + > * { + min-width: 0; + } } .form-group { display: flex; flex-direction: column; margin-bottom: 1em; - padding: 0.3em 0.0em 0.3em; - line-height: 24px; + padding: 0.3em 0; + line-height: 1.85em; } .error { @@ -110,7 +114,7 @@ .alert { padding: 0.5em; - margin: 0.3em 0.0em 1em; + margin: 0.3em 0 1em; } .password-reset-required { diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js new file mode 100644 index 00000000..82670ddf --- /dev/null +++ b/src/components/pinch_zoom/pinch_zoom.js @@ -0,0 +1,13 @@ +import PinchZoom from '@kazvmoe-infra/pinch-zoom-element' + +export default { + methods: { + setTransform ({ scale, x, y }) { + this.$el.setTransform({ scale, x, y }) + } + }, + created () { + // Make lint happy + (() => PinchZoom)() + } +} diff --git a/src/components/pinch_zoom/pinch_zoom.vue b/src/components/pinch_zoom/pinch_zoom.vue new file mode 100644 index 00000000..18d69719 --- /dev/null +++ b/src/components/pinch_zoom/pinch_zoom.vue @@ -0,0 +1,11 @@ +<template> + <pinch-zoom + class="pinch-zoom-parent" + v-bind="$attrs" + v-on="$listeners" + > + <slot /> + </pinch-zoom> +</template> + +<script src="./pinch_zoom.js"></script> diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js index a69b7886..eda1733a 100644 --- a/src/components/poll/poll.js +++ b/src/components/poll/poll.js @@ -21,7 +21,7 @@ export default { } this.$store.dispatch('trackPoll', this.pollId) }, - destroyed () { + unmounted () { this.$store.dispatch('untrackPoll', this.pollId) }, computed: { diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue index 63b44e4f..f6b12a54 100644 --- a/src/components/poll/poll.vue +++ b/src/components/poll/poll.vue @@ -71,13 +71,18 @@ {{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }} ¡ </template> </div> - <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> - <Timeago - :time="expiresAt" - :auto-update="60" - :now-threshold="0" - /> - </i18n> + <span> + <i18n-t + scope="global" + :keypath="expired ? 'polls.expired' : 'polls.expires_in'" + > + <Timeago + :time="expiresAt" + :auto-update="60" + :now-threshold="0" + /> + </i18n-t> + </span> </div> </div> </template> diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue index 3620075a..146754db 100644 --- a/src/components/poll/poll_form.vue +++ b/src/components/poll/poll_form.vue @@ -72,6 +72,7 @@ :max="maxExpirationInCurrentUnit" @change="expiryAmountChange" > + {{ ' ' }} <Select v-model="expiryUnit" unstyled="true" @@ -83,7 +84,7 @@ :key="unit" :value="unit" > - {{ $t(`time.${unit}_short`, ['']) }} + {{ $tc(`time.unit.${unit}_short`, expiryAmount, ['']) }} </option> </Select> </div> diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 6ccf32f0..d44b266b 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -4,7 +4,7 @@ const Popover = { // Action to trigger popover: either 'hover' or 'click' trigger: String, - // Either 'top' or 'bottom' + // 'top', 'bottom', 'left', 'right' placement: String, // Takes object with properties 'x' and 'y', values of these can be @@ -31,40 +31,88 @@ const Popover = { // If true, subtract padding when calculating position for the popover, // use it when popover offset looks to be different on top vs bottom. - removePadding: Boolean + removePadding: Boolean, + + // self-explanatory (i hope) + disabled: Boolean, + + // Instead of putting popover next to anchor, overlay popover's center on top of anchor's center + overlayCenters: Boolean, + + // What selector (witin popover!) to use for determining center of popover + overlayCentersSelector: String, + + // Lets hover popover stay when clicking inside of it + stayOnClick: Boolean, + + triggerAttrs: { + type: Object, + default: {} + } }, + inject: ['popoversZLayer'], // override popover z layer data () { return { + // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content + // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance + // with popovers refusing to be hidden when user wants to interact with something in below popover + anchorEl: null, + // There's an issue where having teleport enabled by default causes things just... + // not render at all, i.e. main post status form and its emoji inputs + teleport: false, + lockReEntry: false, hidden: true, - styles: { opacity: 0 }, - oldSize: { width: 0, height: 0 } + styles: {}, + oldSize: { width: 0, height: 0 }, + scrollable: null, + // used to avoid blinking if hovered onto popover + graceTimeout: null, + parentPopover: null, + disableClickOutside: false, + childrenShown: new Set() } }, methods: { + setAnchorEl (el) { + this.anchorEl = el + this.updateStyles() + }, containerBoundingClientRect () { const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent return container.getBoundingClientRect() }, updateStyles () { if (this.hidden) { - this.styles = { - opacity: 0 - } + this.styles = {} return } // Popover will be anchored around this element, trigger ref is the container, so // its children are what are inside the slot. Expect only one v-slot:trigger. - const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el + const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el // SVGs don't have offsetWidth/Height, use fallback - const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight - const screenBox = anchorEl.getBoundingClientRect() - // Screen position of the origin point for popover - const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top } + const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth + const anchorScreenBox = anchorEl.getBoundingClientRect() + + const anchorStyle = getComputedStyle(anchorEl) + const topPadding = parseFloat(anchorStyle.paddingTop) + const bottomPadding = parseFloat(anchorStyle.paddingBottom) + const rightPadding = parseFloat(anchorStyle.paddingRight) + const leftPadding = parseFloat(anchorStyle.paddingLeft) + + // Screen position of the origin point for popover = center of the anchor + const origin = { + x: anchorScreenBox.left + anchorWidth * 0.5, + y: anchorScreenBox.top + anchorHeight * 0.5 + } const content = this.$refs.content + const overlayCenter = this.overlayCenters + ? this.$refs.content.querySelector(this.overlayCentersSelector) + : null + // Minor optimization, don't call a slow reflow call if we don't have to - const parentBounds = this.boundTo && + const parentScreenBox = this.boundTo && (this.boundTo.x === 'container' || this.boundTo.y === 'container') && this.containerBoundingClientRect() @@ -72,82 +120,179 @@ const Popover = { // What are the screen bounds for the popover? Viewport vs container // when using viewport, using default margin values to dodge the navbar - const xBounds = this.boundTo && this.boundTo.x === 'container' ? { - min: parentBounds.left + (margin.left || 0), - max: parentBounds.right - (margin.right || 0) - } : { - min: 0 + (margin.left || 10), - max: window.innerWidth - (margin.right || 10) - } + const xBounds = this.boundTo && this.boundTo.x === 'container' + ? { + min: parentScreenBox.left + (margin.left || 0), + max: parentScreenBox.right - (margin.right || 0) + } + : { + min: 0 + (margin.left || 10), + max: window.innerWidth - (margin.right || 10) + } - const yBounds = this.boundTo && this.boundTo.y === 'container' ? { - min: parentBounds.top + (margin.top || 0), - max: parentBounds.bottom - (margin.bottom || 0) - } : { - min: 0 + (margin.top || 50), - max: window.innerHeight - (margin.bottom || 5) - } + const yBounds = this.boundTo && this.boundTo.y === 'container' + ? { + min: parentScreenBox.top + (margin.top || 0), + max: parentScreenBox.bottom - (margin.bottom || 0) + } + : { + min: 0 + (margin.top || 50), + max: window.innerHeight - (margin.bottom || 5) + } let horizOffset = 0 + let vertOffset = 0 + + if (overlayCenter) { + const box = content.getBoundingClientRect() + const overlayCenterScreenBox = overlayCenter.getBoundingClientRect() + const leftInnerOffset = overlayCenterScreenBox.left - box.left + const topInnerOffset = overlayCenterScreenBox.top - box.top + horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5 + vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5 + } else { + horizOffset = content.offsetWidth * -0.5 + vertOffset = content.offsetHeight * -0.5 + } + + const leftBorder = origin.x + horizOffset + const rightBorder = leftBorder + content.offsetWidth + const topBorder = origin.y + vertOffset + const bottomBorder = topBorder + content.offsetHeight // If overflowing from left, move it so that it doesn't - if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) { - horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min + if (leftBorder < xBounds.min) { + horizOffset += xBounds.min - leftBorder } // If overflowing from right, move it so that it doesn't - if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) { - horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max + if (rightBorder > xBounds.max) { + horizOffset -= rightBorder - xBounds.max } - // Default to whatever user wished with placement prop - let usingTop = this.placement !== 'bottom' - - // Handle special cases, first force to displaying on top if there's not space on bottom, - // regardless of what placement value was. Then check if there's not space on top, and - // force to bottom, again regardless of what placement value was. - if (origin.y + content.offsetHeight > yBounds.max) usingTop = true - if (origin.y - content.offsetHeight < yBounds.min) usingTop = false + // If overflowing from top, move it so that it doesn't + if (topBorder < yBounds.min) { + vertOffset += yBounds.min - topBorder + } - let vPadding = 0 - if (this.removePadding && usingTop) { - const anchorStyle = getComputedStyle(anchorEl) - vPadding = parseFloat(anchorStyle.paddingTop) + parseFloat(anchorStyle.paddingBottom) + // If overflowing from bottom, move it so that it doesn't + if (bottomBorder > yBounds.max) { + vertOffset -= bottomBorder - yBounds.max } - const yOffset = (this.offset && this.offset.y) || 0 - const translateY = usingTop - ? -anchorHeight + vPadding - yOffset - content.offsetHeight - : yOffset + let translateX = 0 + let translateY = 0 + + if (overlayCenter) { + translateX = origin.x + horizOffset + translateY = origin.y + vertOffset + } else if (this.placement !== 'right' && this.placement !== 'left') { + // Default to whatever user wished with placement prop + let usingTop = this.placement !== 'bottom' + + // Handle special cases, first force to displaying on top if there's not space on bottom, + // regardless of what placement value was. Then check if there's not space on top, and + // force to bottom, again regardless of what placement value was. + const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0) + const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0) + if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true + if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false - const xOffset = (this.offset && this.offset.x) || 0 - const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset + const yOffset = (this.offset && this.offset.y) || 0 + translateY = usingTop + ? topBoundary - yOffset - content.offsetHeight + : bottomBoundary + yOffset + + const xOffset = (this.offset && this.offset.x) || 0 + translateX = origin.x + horizOffset + xOffset + } else { + // Default to whatever user wished with placement prop + let usingRight = this.placement !== 'left' + + // Handle special cases, first force to displaying on top if there's not space on bottom, + // regardless of what placement value was. Then check if there's not space on top, and + // force to bottom, again regardless of what placement value was. + const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0) + const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0) + if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true + if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false + + const xOffset = (this.offset && this.offset.x) || 0 + translateX = usingRight + ? rightBoundary - xOffset - content.offsetWidth + : leftBoundary + xOffset + + const yOffset = (this.offset && this.offset.y) || 0 + translateY = origin.y + vertOffset + yOffset + } - // Note, separate translateX and translateY avoids blurry text on chromium, - // single translate or translate3d resulted in blurry text. this.styles = { - opacity: 1, - transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)` + left: `${Math.round(translateX)}px`, + top: `${Math.round(translateY)}px` + } + + if (this.popoversZLayer) { + this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)` + } + if (parentScreenBox) { + this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px` } }, showPopover () { + if (this.disabled) return + this.disableClickOutside = true + setTimeout(() => { + this.disableClickOutside = false + }, 0) const wasHidden = this.hidden this.hidden = false + this.parentPopover && this.parentPopover.onChildPopoverState(this, true) + if (this.trigger === 'click' || this.stayOnClick) { + document.addEventListener('click', this.onClickOutside) + } + this.scrollable.addEventListener('scroll', this.onScroll) + this.scrollable.addEventListener('resize', this.onResize) this.$nextTick(() => { if (wasHidden) this.$emit('show') this.updateStyles() }) }, hidePopover () { + if (this.disabled) return if (!this.hidden) this.$emit('close') this.hidden = true - this.styles = { opacity: 0 } + this.parentPopover && this.parentPopover.onChildPopoverState(this, false) + if (this.trigger === 'click') { + document.removeEventListener('click', this.onClickOutside) + } + this.scrollable.removeEventListener('scroll', this.onScroll) + this.scrollable.removeEventListener('resize', this.onResize) }, onMouseenter (e) { - if (this.trigger === 'hover') this.showPopover() + if (this.trigger === 'hover') { + this.lockReEntry = false + clearTimeout(this.graceTimeout) + this.graceTimeout = null + this.showPopover() + } }, onMouseleave (e) { - if (this.trigger === 'hover') this.hidePopover() + if (this.trigger === 'hover' && this.childrenShown.size === 0) { + this.graceTimeout = setTimeout(() => this.hidePopover(), 1) + } + }, + onMouseenterContent (e) { + if (this.trigger === 'hover' && !this.lockReEntry) { + this.lockReEntry = true + clearTimeout(this.graceTimeout) + this.graceTimeout = null + this.showPopover() + } + }, + onMouseleaveContent (e) { + if (this.trigger === 'hover' && this.childrenShown.size === 0) { + this.graceTimeout = setTimeout(() => this.hidePopover(), 1) + } }, onClick (e) { if (this.trigger === 'click') { @@ -159,9 +304,26 @@ const Popover = { } }, onClickOutside (e) { + if (this.disableClickOutside) return if (this.hidden) return + if (this.$refs.content && this.$refs.content.contains(e.target)) return if (this.$el.contains(e.target)) return + if (this.childrenShown.size > 0) return this.hidePopover() + if (this.parentPopover) this.parentPopover.onClickOutside(e) + }, + onScroll (e) { + this.updateStyles() + }, + onResize (e) { + this.updateStyles() + }, + onChildPopoverState (childRef, state) { + if (state) { + this.childrenShown.add(childRef) + } else { + this.childrenShown.delete(childRef) + } } }, updated () { @@ -175,11 +337,19 @@ const Popover = { this.oldSize = { width: content.offsetWidth, height: content.offsetHeight } } }, - created () { - document.addEventListener('click', this.onClickOutside) + mounted () { + this.teleport = true + let scrollable = this.$refs.trigger.closest('.column.-scrollable') || + this.$refs.trigger.closest('.mobile-notifications') + if (!scrollable) scrollable = window + this.scrollable = scrollable + let parent = this.$parent + while (parent && parent.$.type.name !== 'Popover') { + parent = parent.$parent + } + this.parentPopover = parent }, - destroyed () { - document.removeEventListener('click', this.onClickOutside) + beforeUnmount () { this.hidePopover() } } diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index 2e78a09e..2869d736 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -1,30 +1,41 @@ <template> - <div + <span @mouseenter="onMouseenter" @mouseleave="onMouseleave" > <button ref="trigger" - class="button-unstyled -fullwidth popover-trigger-button" + class="button-unstyled popover-trigger-button" type="button" + v-bind="triggerAttrs" @click="onClick" > <slot name="trigger" /> </button> - <div - v-if="!hidden" - ref="content" - :style="styles" - class="popover" - :class="popoverClass || 'popover-default'" + <teleport + :disabled="!teleport" + to="#popovers" > - <slot - name="content" - class="popover-inner" - :close="hidePopover" - /> - </div> - </div> + <transition name="fade"> + <div + v-if="!hidden" + ref="content" + :style="styles" + class="popover" + :class="popoverClass || 'popover-default'" + @mouseenter="onMouseenterContent" + @mouseleave="onMouseleaveContent" + @click="onClickContent" + > + <slot + name="content" + class="popover-inner" + :close="hidePopover" + /> + </div> + </transition> + </teleport> + </span> </template> <script src="./popover.js" /> @@ -33,20 +44,32 @@ @import '../../_variables.scss'; .popover-trigger-button { - display: block; + display: inline-block; } .popover { - z-index: 8; - position: absolute; + z-index: var(--ZI_popover_override, var(--ZI_popovers)); + position: fixed; min-width: 0; + max-width: calc(100vw - 20px); + box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); + box-shadow: var(--popupShadow); } .popover-default { - transition: opacity 0.3s; + &:after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 3; + box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); + box-shadow: var(--panelShadow); + pointer-events: none; + } - box-shadow: 1px 1px 4px rgba(0,0,0,.6); - box-shadow: var(--panelShadow); border-radius: $fallback--btnRadius; border-radius: var(--btnRadius, $fallback--btnRadius); @@ -65,11 +88,11 @@ .dropdown-menu { display: block; padding: .5rem 0; - font-size: 1rem; + font-size: 1em; text-align: left; list-style: none; max-width: 100vw; - z-index: 10; + z-index: var(--ZI_popover_override, var(--ZI_popovers)); white-space: nowrap; .dropdown-divider { @@ -82,9 +105,9 @@ .dropdown-item { line-height: 21px; - overflow: auto; + overflow: hidden; display: block; - padding: .5em 0.75em; + padding: 0.5em 0.75em; clear: both; font-weight: 400; text-align: inherit; @@ -107,17 +130,25 @@ } } + &.-has-submenu { + .chevron-icon { + margin-right: 0.25rem; + margin-left: 2rem; + } + } + &:active, &:hover { background-color: $fallback--lightBg; background-color: var(--selectedMenuPopover, $fallback--lightBg); - color: $fallback--link; - color: var(--selectedMenuPopoverText, $fallback--link); + box-shadow: none; + --btnText: var(--selectedMenuPopoverText, $fallback--link); --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); --icon: var(--selectedMenuPopoverIcon, $fallback--icon); svg { color: var(--selectedMenuPopoverIcon, $fallback--icon); + --icon: var(--selectedMenuPopoverIcon, $fallback--icon); } } @@ -142,12 +173,41 @@ content: 'â'; } - &.menu-checkbox-radio::after { - font-size: 2em; - content: 'âĸ'; + &.-radio { + border-radius: 9999px; + + &.menu-checkbox-checked::after { + font-size: 2em; + content: 'âĸ'; + } } } } + + .button-default.dropdown-item { + &, + i[class*=icon-] { + color: $fallback--text; + color: var(--btnText, $fallback--text); + } + + &:active { + background-color: $fallback--lightBg; + background-color: var(--selectedMenuPopover, $fallback--lightBg); + color: $fallback--link; + color: var(--selectedMenuPopoverText, $fallback--link); + } + + &:disabled { + color: $fallback--text; + color: var(--btnDisabledText, $fallback--text); + } + + &.toggled { + color: $fallback--text; + color: var(--btnToggledText, $fallback--text); + } + } } </style> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 5342894f..eb55cfcc 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -4,6 +4,7 @@ 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 Gallery from 'src/components/gallery/gallery.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' @@ -40,7 +41,7 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => { allAttentions = uniqBy(allAttentions, 'id') allAttentions = reject(allAttentions, { id: currentUser.id }) - let mentions = map(allAttentions, (attention) => { + const mentions = map(allAttentions, (attention) => { return `@${attention.screen_name}` }) @@ -54,6 +55,14 @@ const pxStringToNumber = (str) => { const PostStatusForm = { props: [ + 'statusId', + 'statusText', + 'statusIsSensitive', + 'statusPoll', + 'statusFiles', + 'statusMediaDescriptions', + 'statusScope', + 'statusContentType', 'replyTo', 'repliedUser', 'attentions', @@ -61,6 +70,7 @@ const PostStatusForm = { 'subject', 'disableSubject', 'disableScopeSelector', + 'disableVisibilitySelector', 'disableNotice', 'disableLockWarning', 'disablePolls', @@ -77,6 +87,12 @@ const PostStatusForm = { 'emojiPickerPlacement', 'optimisticPosting' ], + emits: [ + 'posted', + 'resize', + 'mediaplay', + 'mediapause' + ], components: { MediaUpload, EmojiInput, @@ -85,7 +101,8 @@ const PostStatusForm = { Checkbox, Select, Attachment, - StatusContent + StatusContent, + Gallery }, mounted () { this.updateIdempotencyKey() @@ -117,22 +134,38 @@ const PostStatusForm = { const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig + let statusParams = { + spoilerText: this.subject || '', + status: statusText, + nsfw: !!sensitiveByDefault, + files: [], + poll: {}, + mediaDescriptions: {}, + visibility: scope, + contentType + } + + if (this.statusId) { + const statusContentType = this.statusContentType || contentType + statusParams = { + spoilerText: this.subject || '', + status: this.statusText || '', + nsfw: this.statusIsSensitive || !!sensitiveByDefault, + files: this.statusFiles || [], + poll: this.statusPoll || {}, + mediaDescriptions: this.statusMediaDescriptions || {}, + visibility: this.statusScope || scope, + contentType: statusContentType + } + } + return { dropFiles: [], uploadingFiles: false, error: null, posting: false, highlighted: 0, - newStatus: { - spoilerText: this.subject || '', - status: statusText, - nsfw: !!sensitiveByDefault, - files: [], - poll: {}, - mediaDescriptions: {}, - visibility: scope, - contentType - }, + newStatus: statusParams, caret: 0, pollFormVisible: false, showDropIcon: 'hide', @@ -156,7 +189,7 @@ const PostStatusForm = { emojiUserSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ], store: this.$store @@ -165,13 +198,13 @@ const PostStatusForm = { emojiSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ] }) }, emoji () { - return this.$store.state.instance.emoji || [] + return this.$store.getters.standardEmojiList || [] }, customEmoji () { return this.$store.state.instance.customEmoji || [] @@ -228,13 +261,16 @@ const PostStatusForm = { uploadFileLimitReached () { return this.newStatus.files.length >= this.fileLimit }, + isEdit () { + return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' + }, ...mapGetters(['mergedConfig']), ...mapState({ mobileLayout: state => state.interface.mobileLayout }) }, watch: { - 'newStatus': { + newStatus: { deep: true, handler () { this.statusChanged() @@ -265,7 +301,7 @@ const PostStatusForm = { this.$refs.textarea.focus() }) } - let el = this.$el.querySelector('textarea') + const el = this.$el.querySelector('textarea') el.style.height = 'auto' el.style.height = undefined this.error = null @@ -384,10 +420,25 @@ const PostStatusForm = { this.$emit('resize', { delayed: true }) }, removeMediaFile (fileInfo) { - let index = this.newStatus.files.indexOf(fileInfo) + const index = this.newStatus.files.indexOf(fileInfo) this.newStatus.files.splice(index, 1) this.$emit('resize') }, + editAttachment (fileInfo, newText) { + this.newStatus.mediaDescriptions[fileInfo.id] = newText + }, + shiftUpMediaFile (fileInfo) { + const { files } = this.newStatus + const index = this.newStatus.files.indexOf(fileInfo) + files.splice(index, 1) + files.splice(index - 1, 0, fileInfo) + }, + shiftDnMediaFile (fileInfo) { + const { files } = this.newStatus + const index = this.newStatus.files.indexOf(fileInfo) + files.splice(index, 1) + files.splice(index + 1, 0, fileInfo) + }, uploadFailed (errString, templateArgs) { templateArgs = templateArgs || {} this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) @@ -439,7 +490,7 @@ const PostStatusForm = { }, onEmojiInputInput (e) { this.$nextTick(() => { - this.resize(this.$refs['textarea']) + this.resize(this.$refs.textarea) }) }, resize (e) { @@ -450,12 +501,11 @@ const PostStatusForm = { if (target.value === '') { target.style.height = null this.$emit('resize') - this.$refs['emoji-input'].resize() return } - const formRef = this.$refs['form'] - const bottomRef = this.$refs['bottom'] + const formRef = this.$refs.form + const bottomRef = this.$refs.bottom /* Scroller is either `window` (replies in TL), sidebar (main post form, * replies in notifs) or mobile post form. Note that getting and setting * scroll is different for `Window` and `Element`s @@ -463,7 +513,7 @@ const PostStatusForm = { const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom'] const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr) - const scrollerRef = this.$el.closest('.sidebar-scroller') || + const scrollerRef = this.$el.closest('.column.-scrollable') || this.$el.closest('.post-form-modal-view') || window @@ -537,11 +587,9 @@ const PostStatusForm = { } else { scrollerRef.scrollTop = targetScroll } - - this.$refs['emoji-input'].resize() }, showEmojiPicker () { - this.$refs['textarea'].focus() + this.$refs.textarea.focus() this.$refs['emoji-input'].triggerShowPicker() }, clearError () { diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index fbda41d6..f65058f4 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -8,21 +8,13 @@ @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" - @dragleave="fileDragStop" - @drop.stop="fileDrop" - > - <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" /> - </div> <div class="form-group"> - <i18n + <i18n-t v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning" - path="post_status.account_not_locked_warning" + keypath="post_status.account_not_locked_warning" tag="p" class="visibility-notice" + scope="global" > <button class="button-unstyled -link" @@ -30,7 +22,7 @@ > {{ $t('post_status.account_not_locked_warning_link') }} </button> - </i18n> + </i18n-t> <p v-if="!hideScopeNotice && newStatus.visibility === 'public'" class="visibility-notice notice-dismissible" @@ -75,6 +67,13 @@ <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> <div + v-if="isEdit" + class="visibility-notice edit-warning" + > + <p>{{ $t('post_status.edit_remote_warning') }}</p> + <p>{{ $t('post_status.edit_unsupported_warning') }}</p> + </div> + <div v-if="!disablePreview" class="preview-heading faint" > @@ -178,6 +177,7 @@ class="visibility-tray" > <scope-selector + v-if="!disableVisibilitySelector" :show-all="showAllScopes" :user-default="userDefaultScope" :original-scope="copyMessageScope" @@ -277,42 +277,45 @@ </button> </div> <div + v-show="showDropIcon !== 'hide'" + :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" + class="drop-indicator" + @dragleave="fileDragStop" + @drop.stop="fileDrop" + > + <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" /> + </div> + <div v-if="error" class="alert error" > Error: {{ error }} - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="times" + <button + class="button-unstyled" @click="clearError" - /> - </div> - <div class="attachments"> - <div - v-for="file in newStatus.files" - :key="file.url" - class="media-upload-wrapper" > - <button - class="button-unstyled hider" - @click="removeMediaFile(file)" - > - <FAIcon icon="times" /> - </button> - <attachment - :attachment="file" - :set-media="() => $store.dispatch('setMedia', newStatus.files)" - size="small" - allow-play="false" + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" /> - <input - v-model="newStatus.mediaDescriptions[file.id]" - type="text" - :placeholder="$t('post_status.media_description')" - @keydown.enter.prevent="" - > - </div> + </button> </div> + <gallery + v-if="newStatus.files && newStatus.files.length > 0" + class="attachments" + :grid="true" + :nsfw="false" + :attachments="newStatus.files" + :descriptions="newStatus.mediaDescriptions" + :set-media="() => $store.dispatch('setMedia', newStatus.files)" + :editable="true" + :edit-attachment="editAttachment" + :remove-attachment="removeMediaFile" + :shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile" + :shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile" + @play="$emit('mediaplay', attachment.id)" + @pause="$emit('mediapause', attachment.id)" + /> <div v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" class="upload_settings" @@ -330,31 +333,18 @@ <style lang="scss"> @import '../../_variables.scss'; -.tribute-container { - ul { - padding: 0px; - li { - display: flex; - align-items: center; - } - } - img { - padding: 3px; - width: 16px; - height: 16px; - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - } -} - .post-status-form { position: relative; + .attachments { + margin-bottom: 0.5em; + } + .form-bottom { display: flex; justify-content: space-between; padding: 0.5em; - height: 32px; + height: 2.5em; button { width: 10em; @@ -412,7 +402,6 @@ border-radius: var(--tooltipRadius, $fallback--tooltipRadius); padding: 0.5em; margin: 0; - line-height: 1.4em; } .text-format { @@ -426,13 +415,26 @@ display: flex; justify-content: space-between; padding-top: 5px; + align-items: baseline; + } + + .visibility-notice.edit-warning { + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } } .media-upload-icon, .poll-icon, .emoji-icon { - font-size: 26px; + font-size: 1.85em; line-height: 1.1; flex: 1; padding: 0 0.1em; + display: flex; + align-items: center; &.selected, &:hover { // needs to be specific to override icon default color @@ -459,21 +461,17 @@ // Order is not necessary but a good indicator .media-upload-icon { order: 1; - text-align: left; + justify-content: left; } .emoji-icon { order: 2; - text-align: center; + justify-content: center; } .poll-icon { order: 3; - text-align: right; - } - - .poll-icon { - cursor: pointer; + justify-content: right; } .error { @@ -507,19 +505,6 @@ flex-direction: column; } - .attachments .media-upload-wrapper { - position: relative; - - .attachment { - margin: 0; - padding: 0; - } - } - - .btn { - cursor: pointer; - } - .btn[disabled] { cursor: not-allowed; } @@ -535,26 +520,20 @@ display: flex; flex-direction: column; padding: 0.25em 0.5em 0.5em; - line-height:24px; - } - - form textarea.form-cw { - line-height:16px; - resize: none; - overflow: hidden; - transition: min-height 200ms 100ms; - min-height: 1px; + line-height: 1.85; } .form-post-body { - height: 16px; // Only affects the empty-height - line-height: 16px; - resize: none; + // TODO: make a resizable textarea component? + box-sizing: content-box; // needed for easier computation of dynamic size overflow: hidden; transition: min-height 200ms 100ms; - padding-bottom: 1.75em; - min-height: 1px; - box-sizing: content-box; + // stock padding + 1 line of text (for counter) + padding-bottom: calc(var(--_padding) + var(--post-line-height) * 1em); + // two lines of text + height: calc(var(--post-line-height) * 1em); + min-height: calc(var(--post-line-height) * 1em); + resize: none; &.scrollable-form { overflow-y: auto; @@ -578,10 +557,6 @@ } } - .btn { - cursor: pointer; - } - .btn[disabled] { cursor: not-allowed; } @@ -598,7 +573,6 @@ .drop-indicator { position: absolute; - z-index: 1; width: 100%; height: 100%; font-size: 5em; @@ -616,11 +590,4 @@ 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/public_and_external_timeline/public_and_external_timeline.js b/src/components/public_and_external_timeline/public_and_external_timeline.js index cbd4491b..bfcce6ae 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.js +++ b/src/components/public_and_external_timeline/public_and_external_timeline.js @@ -9,7 +9,7 @@ const PublicAndExternalTimeline = { created () { this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' }) }, - destroyed () { + unmounted () { this.$store.dispatch('stopFetchingTimeline', 'publicAndExternal') } } diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js index 66c40d3a..30693544 100644 --- a/src/components/public_timeline/public_timeline.js +++ b/src/components/public_timeline/public_timeline.js @@ -9,7 +9,7 @@ const PublicTimeline = { created () { this.$store.dispatch('startFetchingTimeline', { timeline: 'public' }) }, - destroyed () { + unmounted () { this.$store.dispatch('stopFetchingTimeline', 'public') } diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/quick_filter_settings/quick_filter_settings.js index eae65a55..e67e3a4b 100644 --- a/src/components/timeline/timeline_quick_settings.js +++ b/src/components/quick_filter_settings/quick_filter_settings.js @@ -9,7 +9,10 @@ library.add( faWrench ) -const TimelineQuickSettings = { +const QuickFilterSettings = { + props: { + conversation: Boolean + }, components: { Popover }, @@ -48,14 +51,20 @@ const TimelineQuickSettings = { } }, hideMutedPosts: { - get () { return this.mergedConfig.hideMutedPosts || this.mergedConfig.hideFilteredStatuses }, + get () { return this.mergedConfig.hideFilteredStatuses }, set () { const value = !this.hideMutedPosts - this.$store.dispatch('setOption', { name: 'hideMutedPosts', value }) this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value }) } + }, + muteBotStatuses: { + get () { return this.mergedConfig.muteBotStatuses }, + set () { + const value = !this.muteBotStatuses + this.$store.dispatch('setOption', { name: 'muteBotStatuses', value }) + } } } } -export default TimelineQuickSettings +export default QuickFilterSettings diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/quick_filter_settings/quick_filter_settings.vue index 98996ebd..f2aa61ee 100644 --- a/src/components/timeline/timeline_quick_settings.vue +++ b/src/components/quick_filter_settings/quick_filter_settings.vue @@ -1,46 +1,60 @@ <template> <Popover trigger="click" - class="TimelineQuickSettings" + class="QuickFilterSettings" :bound-to="{ x: 'container' }" + :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }" > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> <div v-if="loggedIn"> <button + v-if="!conversation" class="button-default dropdown-item" @click="replyVisibilityAll = true" > <span - class="menu-checkbox" - :class="{ 'menu-checkbox-radio': replyVisibilityAll }" + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': replyVisibilityAll }" />{{ $t('settings.reply_visibility_all') }} </button> <button + v-if="!conversation" class="button-default dropdown-item" @click="replyVisibilityFollowing = true" > <span - class="menu-checkbox" - :class="{ 'menu-checkbox-radio': replyVisibilityFollowing }" + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': replyVisibilityFollowing }" />{{ $t('settings.reply_visibility_following_short') }} </button> <button + v-if="!conversation" class="button-default dropdown-item" @click="replyVisibilitySelf = true" > <span - class="menu-checkbox" - :class="{ 'menu-checkbox-radio': replyVisibilitySelf }" + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': replyVisibilitySelf }" />{{ $t('settings.reply_visibility_self_short') }} </button> <div + v-if="!conversation" role="separator" class="dropdown-divider" /> </div> <button class="button-default dropdown-item" + @click="muteBotStatuses = !muteBotStatuses" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': muteBotStatuses }" + />{{ $t('settings.mute_bot_posts') }} + </button> + <button + class="button-default dropdown-item" @click="hideMedia = !hideMedia" > <span @@ -61,42 +75,14 @@ class="button-default dropdown-item dropdown-item-icon" @click="openTab('filtering')" > - <FAIcon icon="font" />{{ $t('settings.word_filter') }} - </button> - <button - class="button-default dropdown-item dropdown-item-icon" - @click="openTab('general')" - > - <FAIcon icon="wrench" />{{ $t('settings.more_settings') }} + <FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }} </button> </div> </template> - <template v-slot:trigger> - <button class="button-unstyled"> - <FAIcon icon="filter" /> - </button> + <template #trigger> + <FAIcon icon="filter" /> </template> </Popover> </template> -<script src="./timeline_quick_settings.js"></script> - -<style lang="scss"> - -.TimelineQuickSettings { - align-self: stretch; - - > button { - font-size: 1.2em; - padding-left: 0.7em; - padding-right: 0.2em; - line-height: 100%; - height: 100%; - } - - .dropdown-item { - margin: 0; - } -} - -</style> +<script src="./quick_filter_settings.js"></script> diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js new file mode 100644 index 00000000..2798f37a --- /dev/null +++ b/src/components/quick_view_settings/quick_view_settings.js @@ -0,0 +1,69 @@ +import Popover from '../popover/popover.vue' +import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faList, faFolderTree, faBars, faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faList, + faFolderTree, + faBars, + faWrench +) + +const QuickViewSettings = { + props: { + conversation: Boolean + }, + components: { + Popover + }, + methods: { + setConversationDisplay (visibility) { + this.$store.dispatch('setOption', { name: 'conversationDisplay', value: visibility }) + }, + openTab (tab) { + this.$store.dispatch('openSettingsModalTab', tab) + } + }, + computed: { + ...mapGetters(['mergedConfig']), + loggedIn () { + return !!this.$store.state.users.currentUser + }, + conversationDisplay: { + get () { return this.mergedConfig.conversationDisplay }, + set (newVal) { this.setConversationDisplay(newVal) } + }, + autoUpdate: { + get () { return this.mergedConfig.streaming }, + set () { + const value = !this.autoUpdate + this.$store.dispatch('setOption', { name: 'streaming', value }) + } + }, + collapseWithSubjects: { + get () { return this.mergedConfig.collapseMessageWithSubject }, + set () { + const value = !this.collapseWithSubjects + this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value }) + } + }, + showUserAvatars: { + get () { return this.mergedConfig.mentionLinkShowAvatar }, + set () { + const value = !this.showUserAvatars + console.log(value) + this.$store.dispatch('setOption', { name: 'mentionLinkShowAvatar', value }) + } + }, + muteBotStatuses: { + get () { return this.mergedConfig.muteBotStatuses }, + set () { + const value = !this.muteBotStatuses + this.$store.dispatch('setOption', { name: 'muteBotStatuses', value }) + } + } + } +} + +export default QuickViewSettings diff --git a/src/components/quick_view_settings/quick_view_settings.vue b/src/components/quick_view_settings/quick_view_settings.vue new file mode 100644 index 00000000..4bd81c5b --- /dev/null +++ b/src/components/quick_view_settings/quick_view_settings.vue @@ -0,0 +1,75 @@ +<template> + <Popover + trigger="click" + class="QuickViewSettings" + :bound-to="{ x: 'container' }" + :trigger-attrs="{ title: $t('timeline.quick_view_settings') }" + > + <template #content> + <div class="dropdown-menu"> + <button + class="button-default dropdown-item" + @click="conversationDisplay = 'tree'" + > + <span + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }" + /><FAIcon icon="folder-tree" /> {{ $t('settings.conversation_display_tree_quick') }} + </button> + <button + class="button-default dropdown-item" + @click="conversationDisplay = 'linear'" + > + <span + class="menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }" + /><FAIcon icon="list" /> {{ $t('settings.conversation_display_linear_quick') }} + </button> + <div + role="separator" + class="dropdown-divider" + /> + <button + class="button-default dropdown-item" + @click="showUserAvatars = !showUserAvatars" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': showUserAvatars }" + />{{ $t('settings.mention_link_show_avatar_quick') }} + </button> + <button + v-if="!conversation" + class="button-default dropdown-item" + @click="autoUpdate = !autoUpdate" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': autoUpdate }" + />{{ $t('settings.auto_update') }} + </button> + <button + v-if="!conversation" + class="button-default dropdown-item" + @click="collapseWithSubjects = !collapseWithSubjects" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': collapseWithSubjects }" + />{{ $t('settings.collapse_subject') }} + </button> + <button + class="button-default dropdown-item dropdown-item-icon" + @click="openTab('general')" + > + <FAIcon icon="wrench" />{{ $t('settings.more_settings') }} + </button> + </div> + </template> + <template #trigger> + <FAIcon icon="bars" /> + </template> + </Popover> +</template> + +<script src="./quick_view_settings.js"></script> diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue index 5857a5c1..1e7e42d5 100644 --- a/src/components/range_input/range_input.vue +++ b/src/components/range_input/range_input.vue @@ -15,7 +15,7 @@ class="opt" type="checkbox" :checked="present" - @input="$emit('input', !present ? fallback : undefined)" + @change="$emit('update:modelValue', !present ? fallback : undefined)" > <label v-if="typeof fallback !== 'undefined'" @@ -26,23 +26,23 @@ :id="name" class="input-number" type="range" - :value="value || fallback" + :value="modelValue || fallback" :disabled="!present || disabled" :max="max || hardMax || 100" :min="min || hardMin || 0" :step="step || 1" - @input="$emit('input', $event.target.value)" + @input="$emit('update:modelValue', $event.target.value)" > <input :id="name" class="input-number" type="number" - :value="value || fallback" + :value="modelValue || fallback" :disabled="!present || disabled" :max="hardMax" :min="hardMin" :step="step || 1" - @input="$emit('input', $event.target.value)" + @input="$emit('update:modelValue', $event.target.value)" > </div> </template> @@ -50,11 +50,12 @@ <script> export default { props: [ - 'name', 'value', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax' + 'name', 'modelValue', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax' ], + emits: ['update:modelValue'], computed: { present () { - return typeof this.value !== 'undefined' + return typeof this.modelValue !== 'undefined' } } } diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index ce82c90d..2a0dac85 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -1,14 +1,22 @@ import Popover from '../popover/popover.vue' +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' +import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' +import { trim } from 'lodash' -library.add(faSmileBeam) +library.add( + faPlus, + faTimes, + faSmileBeam +) const ReactButton = { props: ['status'], data () { return { - filterWord: '' + filterWord: '', + expanded: false } }, components: { @@ -24,41 +32,90 @@ const ReactButton = { } close() }, + onShow () { + this.expanded = true + this.focusInput() + }, + onClose () { + this.expanded = false + }, focusInput () { this.$nextTick(() => { const input = this.$el.querySelector('input') if (input) input.focus() }) + }, + // Vaguely adjusted copypaste from emoji_input and emoji_picker! + maybeLocalizedEmojiNamesAndKeywords (emoji) { + const names = [emoji.displayText] + const keywords = [] + + if (emoji.displayTextI18n) { + names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + } + + if (emoji.annotations) { + this.languages.forEach(lang => { + names.push(emoji.annotations[lang]?.name) + + keywords.push(...(emoji.annotations[lang]?.keywords || [])) + }) + } + + return { + names: names.filter(k => k), + keywords: keywords.filter(k => k) + } + }, + maybeLocalizedEmojiName (emoji) { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText } }, computed: { commonEmojis () { - return [ - { displayText: 'thumbsup', replacement: 'đ' }, - { displayText: 'angry', replacement: 'đ ' }, - { displayText: 'eyes', replacement: 'đ' }, - { displayText: 'joy', replacement: 'đ' }, - { displayText: 'fire', replacement: 'đĨ' } - ] + const hardcodedSet = new Set(['đ', 'đ ', 'đ', 'đ', 'đĨ']) + return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement)) + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) }, emojis () { if (this.filterWord !== '') { - const filterWordLowercase = this.filterWord.toLowerCase() - let orderedEmojiList = [] - for (const emoji of this.$store.state.instance.emoji) { - if (emoji.replacement === this.filterWord) return [emoji] + const keywordLowercase = trim(this.filterWord.toLowerCase()) + + const orderedEmojiList = [] + for (const emoji of this.$store.getters.standardEmojiList) { + const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji) + .keywords + .map(k => k.toLowerCase().indexOf(keywordLowercase)) + .filter(k => k > -1) + + const indexOfKeyword = indices.length ? Math.min(...indices) : -1 - const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) - if (indexOfFilterWord > -1) { - if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) { - orderedEmojiList[indexOfFilterWord] = [] + if (indexOfKeyword > -1) { + if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { + orderedEmojiList[indexOfKeyword] = [] } - orderedEmojiList[indexOfFilterWord].push(emoji) + orderedEmojiList[indexOfKeyword].push(emoji) } } return orderedEmojiList.flat() } - return this.$store.state.instance.emoji || [] + return this.$store.getters.standardEmojiList || [] }, mergedConfig () { return this.$store.getters.mergedConfig diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index c69c315b..0c5fe321 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -6,14 +6,17 @@ :offset="{ y: 5 }" :bound-to="{ x: 'container' }" remove-padding - @show="focusInput" + popover-class="ReactButton popover-default" + @show="onShow" + @close="onClose" > - <template v-slot:content="{close}"> + <template #content="{close}"> <div class="reaction-picker-filter"> <input v-model="filterWord" size="1" :placeholder="$t('emoji.search_emoji')" + @input="$event.target.composing = false" > </div> <div class="reaction-picker"> @@ -21,7 +24,7 @@ v-for="emoji in commonEmojis" :key="emoji.replacement" class="emoji-button" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" @click="addReaction($event, emoji.replacement, close)" > {{ emoji.replacement }} @@ -31,7 +34,7 @@ v-for="(emoji, key) in emojis" :key="key" class="emoji-button" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" @click="addReaction($event, emoji.replacement, close)" > {{ emoji.replacement }} @@ -39,24 +42,39 @@ <div class="reaction-bottom-fader" /> </div> </template> - <template v-slot:trigger> - <button + <template #trigger> + <span class="button-unstyled popover-trigger" :title="$t('tool_tip.add_reaction')" > - <FAIcon - class="fa-scale-110 fa-old-padding" - :icon="['far', 'smile-beam']" - /> - </button> + <FALayers> + <FAIcon + class="fa-scale-110 fa-old-padding" + :icon="['far', 'smile-beam']" + /> + <FAIcon + v-show="!expanded" + class="focus-marker" + transform="shrink-6 up-9 right-17" + icon="plus" + /> + <FAIcon + v-show="expanded" + class="focus-marker" + transform="shrink-6 up-9 right-17" + icon="times" + /> + </FALayers> + </span> </template> </Popover> </template> -<script src="./react_button.js" ></script> +<script src="./react_button.js"></script> <style lang="scss"> @import '../../_variables.scss'; +@import '../../_mixins.scss'; .ReactButton { .reaction-picker-filter { @@ -101,7 +119,7 @@ cursor: pointer; flex-basis: 20%; - line-height: 1.5em; + line-height: 1.5; align-content: center; &:hover { @@ -123,6 +141,21 @@ color: $fallback--text; color: var(--text, $fallback--text); } + + } + + .popover-trigger-button { + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + } } } diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index 1ac8e8be..6eb316d0 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -1,9 +1,11 @@ -import { validationMixin } from 'vuelidate' -import { required, requiredIf, sameAs } from 'vuelidate/lib/validators' +import useVuelidate from '@vuelidate/core' +import { required, requiredIf, sameAs } from '@vuelidate/validators' import { mapActions, mapState } from 'vuex' +import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue' +import localeService from '../../services/locale/locale.service.js' const registration = { - mixins: [validationMixin], + setup () { return { v$: useVuelidate() } }, data: () => ({ user: { email: '', @@ -11,10 +13,14 @@ const registration = { username: '', password: '', confirm: '', - reason: '' + reason: '', + language: '' }, captcha: {} }), + components: { + InterfaceLanguageSwitcher + }, validations () { return { user: { @@ -24,9 +30,10 @@ const registration = { password: { required }, confirm: { required, - sameAsPassword: sameAs('password') + sameAs: sameAs(this.user.password) }, - reason: { required: requiredIf(() => this.accountApprovalRequired) } + reason: { required: requiredIf(() => this.accountApprovalRequired) }, + language: {} } } }, @@ -64,10 +71,13 @@ const registration = { this.user.captcha_solution = this.captcha.solution this.user.captcha_token = this.captcha.token this.user.captcha_answer_data = this.captcha.answer_data + if (this.user.language) { + this.user.language = localeService.internalToBackendLocale(this.user.language) + } - this.$v.$touch() + this.v$.$touch() - if (!this.$v.$invalid) { + if (!this.v$.$invalid) { try { await this.signUp(this.user) this.$router.push({ name: 'friends' }) diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 65b4bb33..24d9b59b 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -12,7 +12,7 @@ <div class="text-fields"> <div class="form-group" - :class="{ 'form-group--error': $v.user.username.$error }" + :class="{ 'form-group--error': v$.user.username.$error }" > <label class="form--label" @@ -20,18 +20,19 @@ >{{ $t('login.username') }}</label> <input id="sign-up-username" - v-model.trim="$v.user.username.$model" + v-model.trim="v$.user.username.$model" :disabled="isPending" class="form-control" + :aria-required="true" :placeholder="$t('registration.username_placeholder')" > </div> <div - v-if="$v.user.username.$dirty" + v-if="v$.user.username.$dirty" class="form-error" > <ul> - <li v-if="!$v.user.username.required"> + <li v-if="!v$.user.username.required"> <span>{{ $t('registration.validations.username_required') }}</span> </li> </ul> @@ -39,7 +40,7 @@ <div class="form-group" - :class="{ 'form-group--error': $v.user.fullname.$error }" + :class="{ 'form-group--error': v$.user.fullname.$error }" > <label class="form--label" @@ -47,18 +48,19 @@ >{{ $t('registration.fullname') }}</label> <input id="sign-up-fullname" - v-model.trim="$v.user.fullname.$model" + v-model.trim="v$.user.fullname.$model" :disabled="isPending" class="form-control" + :aria-required="true" :placeholder="$t('registration.fullname_placeholder')" > </div> <div - v-if="$v.user.fullname.$dirty" + v-if="v$.user.fullname.$dirty" class="form-error" > <ul> - <li v-if="!$v.user.fullname.required"> + <li v-if="!v$.user.fullname.required"> <span>{{ $t('registration.validations.fullname_required') }}</span> </li> </ul> @@ -66,26 +68,27 @@ <div class="form-group" - :class="{ 'form-group--error': $v.user.email.$error }" + :class="{ 'form-group--error': v$.user.email.$error }" > <label class="form--label" for="email" - >{{ $t('registration.email') }}</label> + >{{ accountActivationRequired ? $t('registration.email') : $t('registration.email_optional') }}</label> <input id="email" - v-model="$v.user.email.$model" + v-model="v$.user.email.$model" :disabled="isPending" class="form-control" type="email" + :aria-required="accountActivationRequired" > </div> <div - v-if="$v.user.email.$dirty" + v-if="v$.user.email.$dirty" class="form-error" > <ul> - <li v-if="!$v.user.email.required"> + <li v-if="!v$.user.email.required"> <span>{{ $t('registration.validations.email_required') }}</span> </li> </ul> @@ -95,7 +98,7 @@ <label class="form--label" for="bio" - >{{ $t('registration.bio') }} ({{ $t('general.optional') }})</label> + >{{ $t('registration.bio_optional') }}</label> <textarea id="bio" v-model="user.bio" @@ -107,7 +110,7 @@ <div class="form-group" - :class="{ 'form-group--error': $v.user.password.$error }" + :class="{ 'form-group--error': v$.user.password.$error }" > <label class="form--label" @@ -119,14 +122,15 @@ :disabled="isPending" class="form-control" type="password" + :aria-required="true" > </div> <div - v-if="$v.user.password.$dirty" + v-if="v$.user.password.$dirty" class="form-error" > <ul> - <li v-if="!$v.user.password.required"> + <li v-if="!v$.user.password.required"> <span>{{ $t('registration.validations.password_required') }}</span> </li> </ul> @@ -134,7 +138,7 @@ <div class="form-group" - :class="{ 'form-group--error': $v.user.confirm.$error }" + :class="{ 'form-group--error': v$.user.confirm.$error }" > <label class="form--label" @@ -146,23 +150,36 @@ :disabled="isPending" class="form-control" type="password" + :aria-required="true" > </div> <div - v-if="$v.user.confirm.$dirty" + v-if="v$.user.confirm.$dirty" class="form-error" > <ul> - <li v-if="!$v.user.confirm.required"> + <li v-if="v$.user.confirm.required.$invalid"> <span>{{ $t('registration.validations.password_confirmation_required') }}</span> </li> - <li v-if="!$v.user.confirm.sameAsPassword"> + <li v-if="v$.user.confirm.sameAs.$invalid"> <span>{{ $t('registration.validations.password_confirmation_match') }}</span> </li> </ul> </div> <div + class="form-group" + :class="{ 'form-group--error': v$.user.language.$error }" + > + <interface-language-switcher + for="email-language" + :prompt-text="$t('registration.email_language')" + :language="v$.user.language.$model" + :set-language="val => v$.user.language.$model = val" + /> + </div> + + <div v-if="accountApprovalRequired" class="form-group" > @@ -271,7 +288,10 @@ $validations-cRed: #f04124; .container { display: flex; flex-direction: row; - //margin-bottom: 1em; + + > * { + min-width: 0; + } } .terms-of-service { @@ -294,8 +314,8 @@ $validations-cRed: #f04124; .form-group { display: flex; flex-direction: column; - padding: 0.3em 0.0em 0.3em; - line-height:24px; + padding: 0.3em 0; + line-height: 2; margin-bottom: 1em; } @@ -315,7 +335,7 @@ $validations-cRed: #f04124; text-align: left; span { - font-size: 12px; + font-size: 0.85em; } } @@ -341,7 +361,7 @@ $validations-cRed: #f04124; .btn { margin-top: 0.6em; - height: 28px; + height: 2em; } .error { diff --git a/src/components/remote_follow/remote_follow.js b/src/components/remote_follow/remote_follow.js index 461d58c9..56b264fc 100644 --- a/src/components/remote_follow/remote_follow.js +++ b/src/components/remote_follow/remote_follow.js @@ -1,5 +1,5 @@ export default { - props: [ 'user' ], + props: ['user'], computed: { subscribeUrl () { // eslint-disable-next-line no-undef diff --git a/src/components/remote_follow/remote_follow.vue b/src/components/remote_follow/remote_follow.vue index be827400..e17aa2e9 100644 --- a/src/components/remote_follow/remote_follow.vue +++ b/src/components/remote_follow/remote_follow.vue @@ -32,7 +32,7 @@ .remote-button { width: 100%; - min-height: 28px; + min-height: 2em; } } </style> diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js new file mode 100644 index 00000000..e1a7531b --- /dev/null +++ b/src/components/remove_follower_button/remove_follower_button.js @@ -0,0 +1,25 @@ +export default { + props: ['relationship'], + data () { + return { + inProgress: false + } + }, + computed: { + label () { + if (this.inProgress) { + return this.$t('user_card.follow_progress') + } else { + return this.$t('user_card.remove_follower') + } + } + }, + methods: { + onClick () { + this.inProgress = true + this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => { + this.inProgress = false + }) + } + } +} diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue new file mode 100644 index 00000000..a3a4c242 --- /dev/null +++ b/src/components/remove_follower_button/remove_follower_button.vue @@ -0,0 +1,13 @@ +<template> + <button + class="btn button-default follow-button" + :class="{ toggled: inProgress }" + :disabled="inProgress" + :title="$t('user_card.remove_follower')" + @click="onClick" + > + {{ label }} + </button> +</template> + +<script src="./remove_follower_button.js"></script> diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js index c7bd2a2b..543d25ac 100644 --- a/src/components/reply_button/reply_button.js +++ b/src/components/reply_button/reply_button.js @@ -1,7 +1,15 @@ import { library } from '@fortawesome/fontawesome-svg-core' -import { faReply } from '@fortawesome/free-solid-svg-icons' +import { + faReply, + faPlus, + faTimes +} from '@fortawesome/free-solid-svg-icons' -library.add(faReply) +library.add( + faReply, + faPlus, + faTimes +) const ReplyButton = { name: 'ReplyButton', @@ -9,6 +17,9 @@ const ReplyButton = { computed: { loggedIn () { return !!this.$store.state.users.currentUser + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } } } diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue index c17041da..dada511b 100644 --- a/src/components/reply_button/reply_button.vue +++ b/src/components/reply_button/reply_button.vue @@ -7,18 +7,38 @@ :title="$t('tool_tip.reply')" @click.prevent="$emit('toggle')" > - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="reply" - /> + <FALayers class="fa-old-padding-layer"> + <FAIcon + class="fa-scale-110" + icon="reply" + /> + <FAIcon + v-if="!replying" + class="focus-marker" + transform="shrink-6 up-8 right-11" + icon="plus" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-8 right-11" + icon="times" + /> + </FALayers> </button> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon icon="reply" class="fa-scale-110 fa-old-padding" :title="$t('tool_tip.reply')" /> - </span> + </a> <span v-if="status.replies_count > 0" class="action-counter" @@ -32,6 +52,7 @@ <style lang="scss"> @import '../../_variables.scss'; +@import '../../_mixins.scss'; .ReplyButton { display: flex; @@ -52,6 +73,18 @@ color: $fallback--cBlue; color: var(--cBlue, $fallback--cBlue); } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + } } } diff --git a/src/components/report/report.js b/src/components/report/report.js new file mode 100644 index 00000000..76055764 --- /dev/null +++ b/src/components/report/report.js @@ -0,0 +1,34 @@ +import Select from '../select/select.vue' +import StatusContent from '../status_content/status_content.vue' +import Timeago from '../timeago/timeago.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const Report = { + props: [ + 'reportId' + ], + components: { + Select, + StatusContent, + Timeago + }, + computed: { + report () { + return this.$store.state.reports.reports[this.reportId] || {} + }, + state: { + get: function () { return this.report.state }, + set: function (val) { this.setReportState(val) } + } + }, + methods: { + generateUserProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + setReportState (state) { + return this.$store.dispatch('setReportState', { id: this.report.id, state }) + } + } +} + +export default Report diff --git a/src/components/report/report.scss b/src/components/report/report.scss new file mode 100644 index 00000000..578b4eb1 --- /dev/null +++ b/src/components/report/report.scss @@ -0,0 +1,43 @@ +@import '../../_variables.scss'; + +.Report { + .report-content { + margin: 0.5em 0 1em; + } + + .report-state { + margin: 0.5em 0 1em; + } + + .reported-status { + border: 1px solid $fallback--faint; + border-color: var(--faint, $fallback--faint); + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); + color: $fallback--text; + color: var(--text, $fallback--text); + display: block; + padding: 0.5em; + margin: 0.5em 0; + + .status-content { + pointer-events: none; + } + + .reported-status-heading { + display: flex; + width: 100%; + justify-content: space-between; + margin-bottom: 0.2em; + } + + .reported-status-name { + font-weight: bold; + } + } + + .note { + width: 100%; + margin-bottom: 0.5em; + } +} diff --git a/src/components/report/report.vue b/src/components/report/report.vue new file mode 100644 index 00000000..1f19cc25 --- /dev/null +++ b/src/components/report/report.vue @@ -0,0 +1,74 @@ +<template> + <div class="Report"> + <div class="reported-user"> + <span>{{ $t('report.reported_user') }}</span> + <router-link :to="generateUserProfileLink(report.acct)"> + @{{ report.acct.screen_name }} + </router-link> + </div> + <div class="reporter"> + <span>{{ $t('report.reporter') }}</span> + <router-link :to="generateUserProfileLink(report.actor)"> + @{{ report.actor.screen_name }} + </router-link> + </div> + <div class="report-state"> + <span>{{ $t('report.state') }}</span> + <Select + :id="report-state" + v-model="state" + class="form-control" + > + <option + v-for="state in ['open', 'closed', 'resolved']" + :key="state" + :value="state" + > + {{ $t('report.state_' + state) }} + </option> + </Select> + </div> + <RichContent + class="report-content" + :html="report.content" + :emoji="[]" + /> + <div v-if="report.statuses.length"> + <small>{{ $t('report.reported_statuses') }}</small> + <router-link + v-for="status in report.statuses" + :key="status.id" + :to="{ name: 'conversation', params: { id: status.id } }" + class="reported-status" + > + <div class="reported-status-heading"> + <span class="reported-status-name">{{ status.user.name }}</span> + <Timeago + :time="status.created_at" + :auto-update="240" + class="faint" + /> + </div> + <status-content :status="status" /> + </router-link> + </div> + <div v-if="report.notes.length"> + <small>{{ $t('report.notes') }}</small> + <div + v-for="note in report.notes" + :key="note.id" + class="note" + > + <span>{{ note.content }}</span> + <Timeago + :time="note.created_at" + :auto-update="240" + class="faint" + /> + </div> + </div> + </div> +</template> + +<script src="./report.js"></script> +<style src="./report.scss" lang="scss"></style> diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index 2103fd0b..4d92b5fa 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -1,7 +1,17 @@ import { library } from '@fortawesome/fontawesome-svg-core' -import { faRetweet } from '@fortawesome/free-solid-svg-icons' +import { + faRetweet, + faPlus, + faMinus, + faCheck +} from '@fortawesome/free-solid-svg-icons' -library.add(faRetweet) +library.add( + faRetweet, + faPlus, + faMinus, + faCheck +) const RetweetButton = { props: ['status', 'loggedIn', 'visibility'], @@ -26,6 +36,9 @@ const RetweetButton = { computed: { mergedConfig () { return this.$store.getters.mergedConfig + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } } } diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 859ce499..240828e3 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -7,11 +7,31 @@ :title="$t('tool_tip.repeat')" @click.prevent="retweet()" > - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="retweet" - :spin="animated" - /> + <FALayers class="fa-old-padding-layer"> + <FAIcon + class="fa-scale-110" + icon="retweet" + :spin="animated" + /> + <FAIcon + v-if="status.repeated" + class="active-marker" + transform="shrink-6 up-9 right-12" + icon="check" + /> + <FAIcon + v-if="!status.repeated" + class="focus-marker" + transform="shrink-6 up-9 right-12" + icon="plus" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-9 right-12" + icon="minus" + /> + </FALayers> </button> <span v-else-if="loggedIn"> <FAIcon @@ -20,13 +40,19 @@ :title="$t('timeline.no_retweet_hint')" /> </span> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon class="fa-scale-110 fa-old-padding" icon="retweet" :title="$t('tool_tip.repeat')" /> - </span> + </a> <span v-if="!mergedConfig.hidePostStats && status.repeat_num > 0" class="no-event" @@ -36,10 +62,11 @@ </div> </template> -<script src="./retweet_button.js" ></script> +<script src="./retweet_button.js"></script> <style lang="scss"> @import '../../_variables.scss'; +@import '../../_mixins.scss'; .RetweetButton { display: flex; @@ -64,6 +91,26 @@ color: $fallback--cGreen; color: var(--cGreen, $fallback--cGreen); } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + + .active-marker { + visibility: visible; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + + .active-marker { + visibility: hidden; + } + } } } </style> diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index c0d20c5e..7881e365 100644 --- a/src/components/rich_content/rich_content.jsx +++ b/src/components/rich_content/rich_content.jsx @@ -1,4 +1,3 @@ -import Vue from 'vue' import { unescape, flattenDeep } from 'lodash' import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js' import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' @@ -27,8 +26,12 @@ import './rich_content.scss' * * Apart from that one small hiccup with emit in render this _should_ be vue3-ready */ -export default Vue.component('RichContent', { +export default { name: 'RichContent', + components: { + MentionsLine, + HashtagLink + }, props: { // Original html content html: { @@ -58,7 +61,7 @@ export default Vue.component('RichContent', { } }, // NEVER EVER TOUCH DATA INSIDE RENDER - render (h) { + render () { // Pre-process HTML const { newHtml: html } = preProcessPerLine(this.html, this.greentext) let currentMentions = null // Current chain of mentions, we group all mentions together @@ -76,18 +79,19 @@ export default Vue.component('RichContent', { const renderImage = (tag) => { return <StillImage - {...{ attrs: getAttrs(tag) }} + {...getAttrs(tag)} class="img" /> } const renderHashtag = (attrs, children, encounteredTextReverse) => { - const linkData = getLinkData(attrs, children, tagsIndex++) + const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++) writtenTags.push(linkData) if (!encounteredTextReverse) { lastTags.push(linkData) } - return <HashtagLink {...{ props: linkData }}/> + const { url, tag, content } = linkData + return <HashtagLink url={url} tag={tag} content={content}/> } const renderMention = (attrs, children) => { @@ -120,7 +124,8 @@ export default Vue.component('RichContent', { // don't include spaces when processing mentions - we'll include them // in MentionsLine lastSpacing = item - return currentMentions !== null ? item.trim() : item + // Don't remove last space in a container (fixes poast mentions) + return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item } currentMentions = null @@ -145,6 +150,7 @@ export default Vue.component('RichContent', { if (Array.isArray(item)) { const [opener, children, closer] = item const Tag = getTagName(opener) + const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null /* During grouping of mentions we trim all the empty text elements @@ -166,7 +172,7 @@ export default Vue.component('RichContent', { return ['', [mentionsLinePadding, renderImage(opener)], ''] case 'a': // replace mentions with MentionLink if (!this.handleLinks) break - if (attrs['class'] && attrs['class'].includes('mention')) { + if (fullAttrs.class && fullAttrs.class.includes('mention')) { // Handling mentions here return renderMention(attrs, children) } else { @@ -174,7 +180,7 @@ export default Vue.component('RichContent', { break } case 'span': - if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) { + if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) { return ['', children.map(processItem), ''] } } @@ -208,23 +214,25 @@ export default Vue.component('RichContent', { const [opener, children] = item const Tag = opener === '' ? '' : getTagName(opener) switch (Tag) { - case 'a': // replace mentions with MentionLink + case 'a': { // replace mentions with MentionLink if (!this.handleLinks) break - const attrs = getAttrs(opener) + const fullAttrs = getAttrs(opener, () => true) + const attrs = getAttrs(opener, () => true) // should only be this if ( - (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style - (attrs['rel'] === 'tag') // Mastodon style + (fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style + (fullAttrs.rel === 'tag') // Mastodon style ) { return renderHashtag(attrs, children, encounteredTextReverse) } else { attrs.target = '_blank' const newChildren = [...children].reverse().map(processItemReverse).reverse() - return <a {...{ attrs }}> + return <a {...attrs}> { newChildren } </a> } + } case '': return [...children].reverse().map(processItemReverse).reverse() } @@ -234,7 +242,7 @@ export default Vue.component('RichContent', { const newChildren = Array.isArray(children) ? [...children].reverse().map(processItemReverse).reverse() : children - return <Tag {...{ attrs: getAttrs(opener) }}> + return <Tag {...getAttrs(opener)}> { newChildren } </Tag> } else { @@ -265,7 +273,7 @@ export default Vue.component('RichContent', { return result } -}) +} const getLinkData = (attrs, children, index) => { const stripTags = (item) => { diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue index a01242fc..f3bee183 100644 --- a/src/components/scope_selector/scope_selector.vue +++ b/src/components/scope_selector/scope_selector.vue @@ -16,6 +16,7 @@ class="fa-scale-110 fa-old-padding" /> </button> + {{ ' ' }} <button v-if="showPrivate" class="button-unstyled scope" @@ -29,6 +30,7 @@ class="fa-scale-110 fa-old-padding" /> </button> + {{ ' ' }} <button v-if="showUnlisted" class="button-unstyled scope" @@ -42,6 +44,7 @@ class="fa-scale-110 fa-old-padding" /> </button> + {{ ' ' }} <button v-if="showPublic" class="button-unstyled scope" diff --git a/src/components/search/search.js b/src/components/search/search.js index b62bc2c5..877d6f30 100644 --- a/src/components/search/search.js +++ b/src/components/search/search.js @@ -1,12 +1,14 @@ import FollowCard from '../follow_card/follow_card.vue' import Conversation from '../conversation/conversation.vue' import Status from '../status/status.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import map from 'lodash/map' import { library } from '@fortawesome/fontawesome-svg-core' import { faCircleNotch, faSearch } from '@fortawesome/free-solid-svg-icons' +import { uniqBy } from 'lodash' library.add( faCircleNotch, @@ -17,7 +19,8 @@ const Search = { components: { FollowCard, Conversation, - Status + Status, + TabSwitcher }, props: [ 'query' @@ -30,7 +33,11 @@ const Search = { userIds: [], statuses: [], hashtags: [], - currenResultTab: 'statuses' + currenResultTab: 'statuses', + + statusesOffset: 0, + lastStatusFetchCount: 0, + lastQuery: '' } }, computed: { @@ -59,26 +66,42 @@ const Search = { this.$router.push({ name: 'search', query: { query } }) this.$refs.searchInput.focus() }, - search (query) { + search (query, searchType = null) { if (!query) { this.loading = false return } this.loading = true - this.userIds = [] - this.statuses = [] - this.hashtags = [] this.$refs.searchInput.blur() + if (this.lastQuery !== query) { + this.userIds = [] + this.hashtags = [] + this.statuses = [] + + this.statusesOffset = 0 + this.lastStatusFetchCount = 0 + } - this.$store.dispatch('search', { q: query, resolve: true }) + this.$store.dispatch('search', { q: query, resolve: true, offset: this.statusesOffset, type: searchType }) .then(data => { this.loading = false - this.userIds = map(data.accounts, 'id') - this.statuses = data.statuses - this.hashtags = data.hashtags + + const oldLength = this.statuses.length + + // Always append to old results. If new results are empty, this doesn't change anything + this.userIds = this.userIds.concat(map(data.accounts, 'id')) + this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id') + this.hashtags = this.hashtags.concat(data.hashtags) + this.currenResultTab = this.getActiveTab() this.loaded = true + + // Offset from whatever we already have + this.statusesOffset = this.statuses.length + // Because the amount of new statuses can actually be zero, compare to old lenght instead + this.lastStatusFetchCount = this.statuses.length - oldLength + this.lastQuery = query }) }, resultCount (tabName) { diff --git a/src/components/search/search.vue b/src/components/search/search.vue index b7bfc1f3..6fc6a0de 100644 --- a/src/components/search/search.vue +++ b/src/components/search/search.vue @@ -22,7 +22,7 @@ </button> </div> <div - v-if="loading" + v-if="loading && statusesOffset == 0" class="text-center loading-icon" > <FAIcon @@ -55,12 +55,6 @@ </div> <div class="panel-body"> <div v-if="currenResultTab === 'statuses'"> - <div - v-if="visibleStatuses.length === 0 && !loading && loaded" - class="search-result-heading" - > - <h4>{{ $t('search.no_results') }}</h4> - </div> <Status v-for="status in visibleStatuses" :key="status.id" @@ -71,6 +65,33 @@ :statusoid="status" :no-heading="false" /> + <button + v-if="!loading && loaded && lastStatusFetchCount > 0" + class="more-statuses-button button-unstyled -link -fullwidth" + @click.prevent="search(searchTerm, 'statuses')" + > + <div class="new-status-notification text-center"> + {{ $t('search.load_more') }} + </div> + </button> + <div + v-else-if="loading && statusesOffset > 0" + class="text-center loading-icon" + > + <FAIcon + icon="circle-notch" + spin + size="lg" + /> + </div> + <div + v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded" + class="search-result-heading" + > + <h4> + {{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }} + </h4> + </div> </div> <div v-else-if="currenResultTab === 'people'"> <div @@ -208,6 +229,11 @@ color: $fallback--text; color: var(--text, $fallback--text); } -} + } + + .more-statuses-button { + height: 3.5em; + line-height: 3.5em; + } </style> diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js index 551649c7..3b297f09 100644 --- a/src/components/search_bar/search_bar.js +++ b/src/components/search_bar/search_bar.js @@ -16,7 +16,7 @@ const SearchBar = { error: false }), watch: { - '$route': function (route) { + $route: function (route) { if (route.name === 'search') { this.searchTerm = route.query.query } diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue index 222f57ba..199a7500 100644 --- a/src/components/search_bar/search_bar.vue +++ b/src/components/search_bar/search_bar.vue @@ -47,6 +47,8 @@ class="cancel-icon fa-scale-110 fa-old-padding" /> </button> + <span class="spacer" /> + <span class="spacer" /> </template> </div> </template> diff --git a/src/components/select/select.js b/src/components/select/select.js index 49535d07..ec571a14 100644 --- a/src/components/select/select.js +++ b/src/components/select/select.js @@ -8,12 +8,9 @@ library.add( ) export default { - model: { - prop: 'value', - event: 'change' - }, + emits: ['update:modelValue'], props: [ - 'value', + 'modelValue', 'disabled', 'unstyled', 'kind' diff --git a/src/components/select/select.vue b/src/components/select/select.vue index 5ade1fa6..92493b0b 100644 --- a/src/components/select/select.vue +++ b/src/components/select/select.vue @@ -1,4 +1,3 @@ - <template> <label class="Select input" @@ -6,11 +5,12 @@ > <select :disabled="disabled" - :value="value" - @change="$emit('change', $event.target.value)" + :value="modelValue" + @change="$emit('update:modelValue', $event.target.value)" > <slot /> </select> + {{ ' ' }} <FAIcon class="select-down-icon" icon="chevron-down" @@ -23,7 +23,8 @@ <style lang="scss"> @import '../../_variables.scss'; -.Select { +/* TODO fix order of styles */ +label.Select { padding: 0; select { @@ -38,10 +39,10 @@ padding: 0 2em 0 .2em; font-family: sans-serif; font-family: var(--inputFont, sans-serif); - font-size: 14px; + font-size: 1em; width: 100%; z-index: 1; - height: 28px; + height: 2em; line-height: 16px; } @@ -51,9 +52,10 @@ bottom: 0; right: 5px; height: 100%; + width: 0.875em; color: $fallback--text; color: var(--inputText, $fallback--text); - line-height: 28px; + line-height: 2; z-index: 0; pointer-events: none; } diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue index 3f885881..1f7683ab 100644 --- a/src/components/selectable_list/selectable_list.vue +++ b/src/components/selectable_list/selectable_list.vue @@ -6,9 +6,9 @@ > <div class="selectable-list-checkbox-wrapper"> <Checkbox - :checked="allSelected" + :model-value="allSelected" :indeterminate="someSelected" - @change="toggleAll" + @update:model-value="toggleAll" > {{ $t('selectable_list.select_all') }} </Checkbox> @@ -24,15 +24,15 @@ :items="items" :get-key="getKey" > - <template v-slot:item="{item}"> + <template #item="{item}"> <div class="selectable-list-item-inner" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }" > <div class="selectable-list-checkbox-wrapper"> <Checkbox - :checked="isSelected(item)" - @change="checked => toggle(checked, item)" + :model-value="isSelected(item)" + @update:model-value="checked => toggle(checked, item)" /> </div> <slot @@ -41,7 +41,7 @@ /> </div> </template> - <template v-slot:empty> + <template #empty> <slot name="empty" /> </template> </List> diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js index 5c52f697..2e6992cb 100644 --- a/src/components/settings_modal/helpers/boolean_setting.js +++ b/src/components/settings_modal/helpers/boolean_setting.js @@ -1,14 +1,17 @@ import { get, set } from 'lodash' import Checkbox from 'src/components/checkbox/checkbox.vue' import ModifiedIndicator from './modified_indicator.vue' +import ServerSideIndicator from './server_side_indicator.vue' export default { components: { Checkbox, - ModifiedIndicator + ModifiedIndicator, + ServerSideIndicator }, props: [ 'path', - 'disabled' + 'disabled', + 'expert' ], computed: { pathDefault () { @@ -26,13 +29,28 @@ export default { defaultState () { return get(this.$parent, this.pathDefault) }, + isServerSide () { + return this.path.startsWith('serverSide_') + }, isChanged () { - return this.state !== this.defaultState + return !this.path.startsWith('serverSide_') && this.state !== this.defaultState + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$parent.expertLevel } }, methods: { update (e) { + const [firstSegment, ...rest] = this.path.split('.') set(this.$parent, this.path, e) + // Updating nested properties does not trigger update on its parent. + // probably still not as reliable, but works for depth=1 at least + if (rest.length > 0) { + set(this.$parent, firstSegment, { ...get(this.$parent, firstSegment) }) + } + }, + reset () { + set(this.$parent, this.path, this.defaultState) } } } diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index c3ee6583..41142966 100644 --- a/src/components/settings_modal/helpers/boolean_setting.vue +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -1,11 +1,12 @@ <template> <label + v-if="matchesExpertLevel" class="BooleanSetting" > <Checkbox - :checked="state" + :model-value="state" :disabled="disabled" - @change="update" + @update:modelValue="update" > <span v-if="!!$slots.default" @@ -13,7 +14,12 @@ > <slot /> </span> - <ModifiedIndicator :changed="isChanged" /> + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ServerSideIndicator :server-side="isServerSide" /> </Checkbox> </label> </template> diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js index a15f6bac..3da559fe 100644 --- a/src/components/settings_modal/helpers/choice_setting.js +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -1,15 +1,18 @@ import { get, set } from 'lodash' import Select from 'src/components/select/select.vue' import ModifiedIndicator from './modified_indicator.vue' +import ServerSideIndicator from './server_side_indicator.vue' export default { components: { Select, - ModifiedIndicator + ModifiedIndicator, + ServerSideIndicator }, props: [ 'path', 'disabled', - 'options' + 'options', + 'expert' ], computed: { pathDefault () { @@ -27,13 +30,22 @@ export default { defaultState () { return get(this.$parent, this.pathDefault) }, + isServerSide () { + return this.path.startsWith('serverSide_') + }, isChanged () { - return this.state !== this.defaultState + return !this.path.startsWith('serverSide_') && this.state !== this.defaultState + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$parent.expertLevel } }, methods: { update (e) { set(this.$parent, this.path, e) + }, + reset () { + set(this.$parent, this.path, this.defaultState) } } } diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index fa17661b..d141a0d6 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -1,12 +1,14 @@ <template> <label + v-if="matchesExpertLevel" class="ChoiceSetting" > <slot /> + {{ ' ' }} <Select - :value="state" + :model-value="state" :disabled="disabled" - @change="update" + @update:modelValue="update" > <option v-for="option in options" @@ -17,7 +19,11 @@ {{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }} </option> </Select> - <ModifiedIndicator :changed="isChanged" /> + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + <ServerSideIndicator :server-side="isServerSide" /> </label> </template> diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js new file mode 100644 index 00000000..e64d0cee --- /dev/null +++ b/src/components/settings_modal/helpers/integer_setting.js @@ -0,0 +1,44 @@ +import { get, set } from 'lodash' +import ModifiedIndicator from './modified_indicator.vue' +export default { + components: { + ModifiedIndicator + }, + props: { + path: String, + disabled: Boolean, + min: Number, + expert: [Number, String] + }, + computed: { + pathDefault () { + const [firstSegment, ...rest] = this.path.split('.') + return [firstSegment + 'DefaultValue', ...rest].join('.') + }, + state () { + const value = get(this.$parent, this.path) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + defaultState () { + return get(this.$parent, this.pathDefault) + }, + isChanged () { + return this.state !== this.defaultState + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$parent.expertLevel + } + }, + methods: { + update (e) { + set(this.$parent, this.path, parseInt(e.target.value)) + }, + reset () { + set(this.$parent, this.path, this.defaultState) + } + } +} diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue new file mode 100644 index 00000000..695e2673 --- /dev/null +++ b/src/components/settings_modal/helpers/integer_setting.vue @@ -0,0 +1,27 @@ +<template> + <span + v-if="matchesExpertLevel" + class="IntegerSetting" + > + <label :for="path"> + <slot /> + </label> + <input + :id="path" + class="number-input" + type="number" + step="1" + :disabled="disabled" + :min="min || 0" + :value="state" + @change="update" + > + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + </span> +</template> + +<script src="./integer_setting.js"></script> diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue index ad212db9..8311533a 100644 --- a/src/components/settings_modal/helpers/modified_indicator.vue +++ b/src/components/settings_modal/helpers/modified_indicator.vue @@ -6,14 +6,14 @@ <Popover trigger="hover" > - <template v-slot:trigger> + <template #trigger> <FAIcon icon="wrench" :aria-label="$t('settings.setting_changed')" /> </template> - <template v-slot:content> + <template #content> <div class="modified-tooltip"> {{ $t('settings.setting_changed') }} </div> @@ -41,11 +41,11 @@ export default { .ModifiedIndicator { display: inline-block; position: relative; +} - .modified-tooltip { - margin: 0.5em 1em; - min-width: 10em; - text-align: center; - } +.modified-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; } </style> diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/src/components/settings_modal/helpers/server_side_indicator.vue new file mode 100644 index 00000000..bf181959 --- /dev/null +++ b/src/components/settings_modal/helpers/server_side_indicator.vue @@ -0,0 +1,51 @@ +<template> + <span + v-if="serverSide" + class="ServerSideIndicator" + > + <Popover + trigger="hover" + > + <template #trigger> + + <FAIcon + icon="server" + :aria-label="$t('settings.setting_server_side')" + /> + </template> + <template #content> + <div class="serverside-tooltip"> + {{ $t('settings.setting_server_side') }} + </div> + </template> + </Popover> + </span> +</template> + +<script> +import Popover from 'src/components/popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faServer } from '@fortawesome/free-solid-svg-icons' + +library.add( + faServer +) + +export default { + components: { Popover }, + props: ['serverSide'] +} +</script> + +<style lang="scss"> +.ServerSideIndicator { + display: inline-block; + position: relative; +} + +.serverside-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; +} +</style> diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js index 2c833c0c..12431dca 100644 --- a/src/components/settings_modal/helpers/shared_computed_object.js +++ b/src/components/settings_modal/helpers/shared_computed_object.js @@ -1,4 +1,5 @@ import { defaultState as configDefaultState } from 'src/modules/config.js' +import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js' const SharedComputedObject = () => ({ user () { @@ -22,6 +23,14 @@ const SharedComputedObject = () => ({ } }]) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), + ...Object.keys(serverSideConfigDefaultState) + .map(key => ['serverSide_' + key, { + get () { return this.$store.state.serverSideConfig[key] }, + set (value) { + this.$store.dispatch('setServerSideOption', { name: key, value }) + } + }]) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), // Special cases (need to transform values or perform actions first) useStreamingApi: { get () { return this.$store.getters.mergedConfig.useStreamingApi }, diff --git a/src/components/settings_modal/helpers/size_setting.js b/src/components/settings_modal/helpers/size_setting.js new file mode 100644 index 00000000..58697412 --- /dev/null +++ b/src/components/settings_modal/helpers/size_setting.js @@ -0,0 +1,67 @@ +import { get, set } from 'lodash' +import ModifiedIndicator from './modified_indicator.vue' +import Select from 'src/components/select/select.vue' + +export const allCssUnits = ['cm', 'mm', 'in', 'px', 'pt', 'pc', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax', '%'] +export const defaultHorizontalUnits = ['px', 'rem', 'vw'] +export const defaultVerticalUnits = ['px', 'rem', 'vh'] + +export default { + components: { + ModifiedIndicator, + Select + }, + props: { + path: String, + disabled: Boolean, + min: Number, + units: { + type: [String], + default: () => allCssUnits + }, + expert: [Number, String] + }, + computed: { + pathDefault () { + const [firstSegment, ...rest] = this.path.split('.') + return [firstSegment + 'DefaultValue', ...rest].join('.') + }, + stateUnit () { + return (this.state || '').replace(/\d+/, '') + }, + stateValue () { + return (this.state || '').replace(/\D+/, '') + }, + state () { + const value = get(this.$parent, this.path) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + defaultState () { + return get(this.$parent, this.pathDefault) + }, + isChanged () { + return this.state !== this.defaultState + }, + matchesExpertLevel () { + return (this.expert || 0) <= this.$parent.expertLevel + } + }, + methods: { + update (e) { + set(this.$parent, this.path, e) + }, + reset () { + set(this.$parent, this.path, this.defaultState) + }, + updateValue (e) { + set(this.$parent, this.path, parseInt(e.target.value) + this.stateUnit) + }, + updateUnit (e) { + set(this.$parent, this.path, this.stateValue + e.target.value) + } + } +} diff --git a/src/components/settings_modal/helpers/size_setting.vue b/src/components/settings_modal/helpers/size_setting.vue new file mode 100644 index 00000000..90c9f538 --- /dev/null +++ b/src/components/settings_modal/helpers/size_setting.vue @@ -0,0 +1,54 @@ +<template> + <span + v-if="matchesExpertLevel" + class="SizeSetting" + > + <label + :for="path" + class="size-label" + > + <slot /> + </label> + <input + :id="path" + class="number-input" + type="number" + step="1" + :disabled="disabled" + :min="min || 0" + :value="stateValue" + @change="updateValue" + > + <Select + :id="path" + :model-value="stateUnit" + :disabled="disabled" + class="css-unit-input" + @change="updateUnit" + > + <option + v-for="option in units" + :key="option" + :value="option" + > + {{ option }} + </option> + </Select> + {{ ' ' }} + <ModifiedIndicator + :changed="isChanged" + :onclick="reset" + /> + </span> +</template> + +<script src="./size_setting.js"></script> + +<style lang="scss"> +.css-unit-input, .css-unit-input select { + margin-left: 0.5em; + width: 4em !important; + max-width: 4em !important; + min-width: 4em !important; +} +</style> diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index 04043483..0a72dca1 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -3,6 +3,7 @@ import PanelLoading from 'src/components/panel_loading/panel_loading.vue' import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue' import getResettableAsyncComponent from 'src/services/resettable_async_component.js' import Popover from '../popover/popover.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { cloneDeep } from 'lodash' import { @@ -51,11 +52,12 @@ const SettingsModal = { components: { Modal, Popover, + Checkbox, SettingsModalContent: getResettableAsyncComponent( () => import('./settings_modal_content.vue'), { - loading: PanelLoading, - error: AsyncComponentError, + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, delay: 0 } ) @@ -159,6 +161,15 @@ const SettingsModal = { }, modalPeeked () { return this.$store.state.interface.settingsModalState === 'minimized' + }, + expertLevel: { + get () { + return this.$store.state.config.expertLevel > 0 + }, + set (value) { + console.log(value) + this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 }) + } } } } diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index 90446b36..13cb0e65 100644 --- a/src/components/settings_modal/settings_modal.scss +++ b/src/components/settings_modal/settings_modal.scss @@ -2,6 +2,18 @@ .settings-modal { overflow: hidden; + .setting-list, + .option-list { + list-style-type: none; + padding-left: 2em; + li { + margin-bottom: 0.5em; + } + .suboptions { + margin-top: 0.3em + } + } + &.peek { .settings-modal-panel { /* Explanation: @@ -42,10 +54,22 @@ overflow-y: hidden; .btn { - min-height: 28px; + min-height: 2em; min-width: 10em; padding: 0 2em; } } } + + .settings-footer { + display: flex; + >* { + margin-right: 0.5em; + } + + .extra-content { + display: flex; + flex-grow: 1; + } + } } diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue index 583c2ecc..7b457371 100644 --- a/src/components/settings_modal/settings_modal.vue +++ b/src/components/settings_modal/settings_modal.vue @@ -11,23 +11,14 @@ {{ $t('settings.settings') }} </span> <transition name="fade"> - <template v-if="currentSaveStateNotice"> - <div - v-if="currentSaveStateNotice.error" - class="alert error" - @click.prevent - > - {{ $t('settings.saving_err') }} - </div> - - <div - v-if="!currentSaveStateNotice.error" - class="alert transparent" - @click.prevent - > - {{ $t('settings.saving_ok') }} - </div> - </template> + <div + v-if="currentSaveStateNotice" + class="alert" + :class="{ transparent: !currentSaveStateNotice.error, error: currentSaveStateNotice.error}" + @click.prevent + > + {{ currentSaveStateNotice.error ? $t('settings.saving_err') : $t('settings.saving_ok') }} + </div> </transition> <button class="btn button-default" @@ -53,7 +44,7 @@ <div class="panel-body"> <SettingsModalContent v-if="modalOpenedOnce" /> </div> - <div class="panel-footer"> + <div class="panel-footer settings-footer"> <Popover class="export" trigger="click" @@ -62,18 +53,19 @@ :bound-to="{ x: 'container' }" remove-padding > - <template v-slot:trigger> + <template #trigger> <button class="btn button-default" :title="$t('general.close')" > <span>{{ $t("settings.file_export_import.backup_restore") }}</span> + {{ ' ' }} <FAIcon icon="chevron-down" /> </button> </template> - <template v-slot:content="{close}"> + <template #content="{close}"> <div class="dropdown-menu"> <button class="button-default dropdown-item dropdown-item-icon" @@ -108,6 +100,17 @@ </div> </template> </Popover> + + <Checkbox + :model-value="!!expertLevel" + @update:modelValue="expertLevel = Number($event)" + > + {{ $t("settings.expert_mode") }} + </Checkbox> + <span + id="unscrolled-content" + class="extra-content" + /> </div> </div> </Modal> diff --git a/src/components/settings_modal/settings_modal_content.js b/src/components/settings_modal/settings_modal_content.js index 9dcf1b5a..9ac0301f 100644 --- a/src/components/settings_modal/settings_modal_content.js +++ b/src/components/settings_modal/settings_modal_content.js @@ -1,4 +1,4 @@ -import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import DataImportExportTab from './tabs/data_import_export_tab.vue' import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue' @@ -53,6 +53,9 @@ const SettingsModalContent = { }, open () { return this.$store.state.interface.settingsModalState !== 'hidden' + }, + bodyLock () { + return this.$store.state.interface.settingsModalState === 'visible' } }, methods: { @@ -60,8 +63,8 @@ const SettingsModalContent = { 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 + const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => { + return elm.props && elm.props['data-tab-name'] === targetTab }) if (tabIndex >= 0) { this.$refs.tabSwitcher.setTab(tabIndex) diff --git a/src/components/settings_modal/settings_modal_content.vue b/src/components/settings_modal/settings_modal_content.vue index c9ed2a38..0be76d22 100644 --- a/src/components/settings_modal/settings_modal_content.vue +++ b/src/components/settings_modal/settings_modal_content.vue @@ -4,6 +4,7 @@ class="settings_tab-switcher" :side-tab-bar="true" :scrollable-tabs="true" + :body-scroll-lock="bodyLock" > <div :label="$t('settings.general')" diff --git a/src/components/settings_modal/tabs/data_import_export_tab.js b/src/components/settings_modal/tabs/data_import_export_tab.js index f4b736d2..4895733c 100644 --- a/src/components/settings_modal/tabs/data_import_export_tab.js +++ b/src/components/settings_modal/tabs/data_import_export_tab.js @@ -7,11 +7,16 @@ const DataImportExportTab = { data () { return { activeTab: 'profile', - newDomainToMute: '' + newDomainToMute: '', + listBackupsError: false, + addBackupError: false, + addedBackup: false, + backups: [] } }, created () { this.$store.dispatch('fetchTokens') + this.fetchBackups() }, components: { Importer, @@ -72,6 +77,28 @@ const DataImportExportTab = { } return user.screen_name }).join('\n') + }, + addBackup () { + this.$store.state.api.backendInteractor.addBackup() + .then((res) => { + this.addedBackup = true + this.addBackupError = false + }) + .catch((error) => { + this.addedBackup = false + this.addBackupError = error + }) + .then(() => this.fetchBackups()) + }, + fetchBackups () { + this.$store.state.api.backendInteractor.listBackups() + .then((res) => { + this.backups = res + this.listBackupsError = false + }) + .catch((error) => { + this.listBackupsError = error.error + }) } } } diff --git a/src/components/settings_modal/tabs/data_import_export_tab.vue b/src/components/settings_modal/tabs/data_import_export_tab.vue index a406077d..e3b7f407 100644 --- a/src/components/settings_modal/tabs/data_import_export_tab.vue +++ b/src/components/settings_modal/tabs/data_import_export_tab.vue @@ -53,6 +53,67 @@ :export-button-label="$t('settings.mute_export_button')" /> </div> + <div class="setting-item"> + <h2>{{ $t('settings.account_backup') }}</h2> + <p>{{ $t('settings.account_backup_description') }}</p> + <table> + <thead> + <tr> + <th>{{ $t('settings.account_backup_table_head') }}</th> + <th /> + </tr> + </thead> + <tbody> + <tr + v-for="backup in backups" + :key="backup.id" + > + <td>{{ backup.inserted_at }}</td> + <td class="actions"> + <a + v-if="backup.processed" + target="_blank" + :href="backup.url" + > + {{ $t('settings.download_backup') }} + </a> + <span + v-else + > + {{ $t('settings.backup_not_ready') }} + </span> + </td> + </tr> + </tbody> + </table> + <div + v-if="listBackupsError" + class="alert error" + > + {{ $t('settings.list_backups_error', { error }) }} + <button + :title="$t('settings.hide_list_backups_error_action')" + @click="listBackupsError = false" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + /> + </button> + </div> + <button + class="btn button-default" + @click="addBackup" + > + {{ $t('settings.add_backup') }} + </button> + <p v-if="addedBackup"> + {{ $t('settings.added_backup') }} + </p> + <template v-if="addBackupError !== false"> + <p>{{ $t('settings.add_backup_error', { error: addBackupError }) }}</p> + </template> + </div> </div> </template> diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index 4eaf4217..5354e5db 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -1,6 +1,7 @@ import { filter, trim } from 'lodash' import BooleanSetting from '../helpers/boolean_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue' +import IntegerSetting from '../helpers/integer_setting.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' @@ -17,7 +18,8 @@ const FilteringTab = { }, components: { BooleanSetting, - ChoiceSetting + ChoiceSetting, + IntegerSetting }, computed: { ...SharedComputedObject(), @@ -36,15 +38,6 @@ const FilteringTab = { }, // Updating nested properties watch: { - notificationVisibility: { - handler (value) { - this.$store.dispatch('setOption', { - name: 'notificationVisibility', - value: this.$store.getters.mergedConfig.notificationVisibility - }) - }, - deep: true - }, replyVisibility () { this.$store.dispatch('queueFlushAll') } diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 6fc9ceaa..97046ff0 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -1,73 +1,110 @@ <template> <div :label="$t('settings.filtering')"> <div class="setting-item"> - <div class="select-multiple"> - <span class="label">{{ $t('settings.notification_visibility') }}</span> - <ul class="option-list"> - <li> - <BooleanSetting path="notificationVisibility.likes"> - {{ $t('settings.notification_visibility_likes') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="notificationVisibility.repeats"> - {{ $t('settings.notification_visibility_repeats') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="notificationVisibility.follows"> - {{ $t('settings.notification_visibility_follows') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="notificationVisibility.mentions"> - {{ $t('settings.notification_visibility_mentions') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="notificationVisibility.moves"> - {{ $t('settings.notification_visibility_moves') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="notificationVisibility.emojiReactions"> - {{ $t('settings.notification_visibility_emoji_reactions') }} - </BooleanSetting> - </li> - </ul> - </div> - <ChoiceSetting - id="replyVisibility" - path="replyVisibility" - :options="replyVisibilityOptions" - > - {{ $t('settings.replies_in_timeline') }} - </ChoiceSetting> - <div> - <BooleanSetting path="hidePostStats"> - {{ $t('settings.hide_post_stats') }} - </BooleanSetting> - </div> - <div> - <BooleanSetting path="hideUserStats"> - {{ $t('settings.hide_user_stats') }} - </BooleanSetting> - </div> + <h2>{{ $t('settings.posts') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path="hideFilteredStatuses"> + {{ $t('settings.hide_filtered_statuses') }} + </BooleanSetting> + <ul + class="setting-list suboptions" + :class="[{disabled: !streaming}]" + > + <li> + <BooleanSetting + :disabled="hideFilteredStatuses" + path="hideWordFilteredPosts" + > + {{ $t('settings.hide_wordfiltered_statuses') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + v-if="user" + :disabled="hideFilteredStatuses" + path="hideMutedThreads" + > + {{ $t('settings.hide_muted_threads') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + v-if="user" + :disabled="hideFilteredStatuses" + path="hideMutedPosts" + > + {{ $t('settings.hide_muted_posts') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <BooleanSetting path="muteBotStatuses"> + {{ $t('settings.mute_bot_posts') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="hidePostStats"> + {{ $t('settings.hide_post_stats') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="hideBotIndication"> + {{ $t('settings.hide_bot_indication') }} + </BooleanSetting> + </li> + <ChoiceSetting + v-if="user" + id="replyVisibility" + path="replyVisibility" + :options="replyVisibilityOptions" + > + {{ $t('settings.replies_in_timeline') }} + </ChoiceSetting> + <li> + <h3>{{ $t('settings.wordfilter') }}</h3> + <textarea + id="muteWords" + v-model="muteWordsString" + class="resize-height" + /> + <div>{{ $t('settings.filtering_explanation') }}</div> + </li> + <h3>{{ $t('settings.attachments') }}</h3> + <li> + <IntegerSetting + path="maxThumbnails" + expert="1" + :min="0" + > + {{ $t('settings.max_thumbnails') }} + </IntegerSetting> + </li> + <li> + <BooleanSetting path="hideAttachments"> + {{ $t('settings.hide_attachments_in_tl') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="hideAttachmentsInConv"> + {{ $t('settings.hide_attachments_in_convo') }} + </BooleanSetting> + </li> + </ul> </div> - <div class="setting-item"> - <div> - <p>{{ $t('settings.filtering_explanation') }}</p> - <textarea - id="muteWords" - v-model="muteWordsString" - class="resize-height" - /> - </div> - <div> - <BooleanSetting path="hideFilteredStatuses"> - {{ $t('settings.hide_filtered_statuses') }} - </BooleanSetting> - </div> + <div + v-if="expertLevel > 0" + class="setting-item" + > + <h2>{{ $t('settings.user_profiles') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path="hideUserStats"> + {{ $t('settings.hide_user_stats') }} + </BooleanSetting> + </li> + </ul> </div> </div> </template> diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index eeda61bf..ea24d6ad 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -1,8 +1,12 @@ import BooleanSetting from '../helpers/boolean_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue' +import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' +import IntegerSetting from '../helpers/integer_setting.vue' +import SizeSetting, { defaultHorizontalUnits } from '../helpers/size_setting.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' +import ServerSideIndicator from '../helpers/server_side_indicator.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faGlobe @@ -20,6 +24,31 @@ const GeneralTab = { value: mode, label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`) })), + conversationDisplayOptions: ['tree', 'linear'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.conversation_display_${mode}`) + })), + conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.conversation_other_replies_button_${mode}`) + })), + mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.mention_link_display_${mode}`) + })), + thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.third_column_mode_${mode}`) + })), + userPopoverAvatarActionOptions: ['close', 'zoom', 'open'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.user_popover_avatar_action_${mode}`) + })), loopSilentAvailable: // Firefox Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || @@ -32,9 +61,16 @@ const GeneralTab = { components: { BooleanSetting, ChoiceSetting, - InterfaceLanguageSwitcher + IntegerSetting, + SizeSetting, + InterfaceLanguageSwitcher, + ScopeSelector, + ServerSideIndicator }, computed: { + horizontalUnits () { + return defaultHorizontalUnits + }, postFormats () { return this.$store.state.instance.postFormats || [] }, @@ -45,13 +81,35 @@ const GeneralTab = { label: this.$t(`post_status.content_type["${format}"]`) })) }, + columns () { + const mode = this.$store.getters.mergedConfig.thirdColumnMode + + const notif = mode === 'none' ? [] : ['notifs'] + + if (this.$store.getters.mergedConfig.sidebarRight || mode === 'postform') { + return [...notif, 'content', 'sidebar'] + } else { + return ['sidebar', 'content', ...notif] + } + }, instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, instanceWallpaperUsed () { return this.$store.state.instance.background && !this.$store.state.users.currentUser.background_image }, instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable }, + language: { + get: function () { return this.$store.getters.mergedConfig.interfaceLanguage }, + set: function (val) { + this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) + } + }, ...SharedComputedObject() + }, + methods: { + changeDefaultScope (value) { + this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value }) + } } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index f2ec7d64..8561647b 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -4,41 +4,25 @@ <h2>{{ $t('settings.interface') }}</h2> <ul class="setting-list"> <li> - <interface-language-switcher /> + <interface-language-switcher + :prompt-text="$t('settings.interfaceLanguage')" + :language="language" + :set-language="val => language = val" + /> </li> <li v-if="instanceSpecificPanelPresent"> <BooleanSetting path="hideISP"> {{ $t('settings.hide_isp') }} </BooleanSetting> </li> - <li> - <BooleanSetting path="sidebarRight"> - {{ $t('settings.right_sidebar') }} - </BooleanSetting> - </li> <li v-if="instanceWallpaperUsed"> <BooleanSetting path="hideInstanceWallpaper"> {{ $t('settings.hide_wallpaper') }} </BooleanSetting> </li> - <li v-if="instanceShoutboxPresent"> - <BooleanSetting path="hideShoutbox"> - {{ $t('settings.hide_shoutbox') }} - </BooleanSetting> - </li> - </ul> - </div> - <div class="setting-item"> - <h2>{{ $t('nav.timeline') }}</h2> - <ul class="setting-list"> - <li> - <BooleanSetting path="hideMutedPosts"> - {{ $t('settings.hide_muted_posts') }} - </BooleanSetting> - </li> <li> - <BooleanSetting path="collapseMessageWithSubject"> - {{ $t('settings.collapse_subject') }} + <BooleanSetting path="stopGifs"> + {{ $t('settings.stop_gifs') }} </BooleanSetting> </li> <li> @@ -60,111 +44,191 @@ </ul> </li> <li> - <BooleanSetting path="useStreamingApi"> + <BooleanSetting + path="useStreamingApi" + expert="1" + > {{ $t('settings.useStreamingApi') }} - <br> - <small> - {{ $t('settings.useStreamingApiWarning') }} - </small> </BooleanSetting> </li> <li> - <BooleanSetting path="emojiReactionsOnTimeline"> - {{ $t('settings.emoji_reactions_on_timeline') }} + <BooleanSetting + path="virtualScrolling" + expert="1" + > + {{ $t('settings.virtual_scrolling') }} </BooleanSetting> </li> <li> - <BooleanSetting path="virtualScrolling"> - {{ $t('settings.virtual_scrolling') }} - </BooleanSetting> + <ChoiceSetting + id="userPopoverAvatarAction" + path="userPopoverAvatarAction" + :options="userPopoverAvatarActionOptions" + expert="1" + > + {{ $t('settings.user_popover_avatar_action') }} + </ChoiceSetting> </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.composing') }}</h2> - <ul class="setting-list"> <li> - <BooleanSetting path="scopeCopy"> - {{ $t('settings.scope_copy') }} + <BooleanSetting + path="userPopoverOverlay" + expert="1" + > + {{ $t('settings.user_popover_avatar_overlay') }} </BooleanSetting> </li> <li> - <BooleanSetting path="alwaysShowSubjectInput"> - {{ $t('settings.subject_input_always_show') }} + <BooleanSetting + path="alwaysShowNewPostButton" + expert="1" + > + {{ $t('settings.always_show_post_button') }} </BooleanSetting> </li> <li> - <ChoiceSetting - id="subjectLineBehavior" - path="subjectLineBehavior" - :options="subjectLineOptions" + <BooleanSetting + path="autohideFloatingPostButton" + expert="1" > - {{ $t('settings.subject_line_behavior') }} - </ChoiceSetting> + {{ $t('settings.autohide_floating_post_button') }} + </BooleanSetting> </li> - <li v-if="postFormats.length > 0"> - <ChoiceSetting - id="postContentType" - path="postContentType" - :options="postContentOptions" + <li v-if="instanceShoutboxPresent"> + <BooleanSetting + path="hideShoutbox" + expert="1" > - {{ $t('settings.post_status_content_type') }} - </ChoiceSetting> + {{ $t('settings.hide_shoutbox') }} + </BooleanSetting> </li> <li> - <BooleanSetting path="minimalScopesMode"> - {{ $t('settings.minimal_scopes_mode') }} - </BooleanSetting> + <h3>{{ $t('settings.columns') }}</h3> </li> <li> - <BooleanSetting path="sensitiveByDefault"> - {{ $t('settings.sensitive_by_default') }} + <BooleanSetting path="disableStickyHeaders"> + {{ $t('settings.disable_sticky_headers') }} </BooleanSetting> </li> <li> - <BooleanSetting path="alwaysShowNewPostButton"> - {{ $t('settings.always_show_post_button') }} + <BooleanSetting path="showScrollbars"> + {{ $t('settings.show_scrollbars') }} </BooleanSetting> </li> <li> - <BooleanSetting path="autohideFloatingPostButton"> - {{ $t('settings.autohide_floating_post_button') }} + <BooleanSetting path="sidebarRight"> + {{ $t('settings.right_sidebar') }} </BooleanSetting> </li> <li> - <BooleanSetting path="padEmoji"> - {{ $t('settings.pad_emoji') }} + <BooleanSetting path="navbarColumnStretch"> + {{ $t('settings.navbar_column_stretch') }} </BooleanSetting> </li> + <li> + <ChoiceSetting + v-if="user" + id="thirdColumnMode" + path="thirdColumnMode" + :options="thirdColumnModeOptions" + > + {{ $t('settings.third_column_mode') }} + </ChoiceSetting> + </li> + <li v-if="expertLevel > 0"> + {{ $t('settings.column_sizes') }} + <div class="column-settings"> + <SizeSetting + v-for="column in columns" + :key="column" + :path="column + 'ColumnWidth'" + :units="horizontalUnits" + expert="1" + > + {{ $t('settings.column_sizes_' + column) }} + </SizeSetting> + </div> + </li> </ul> </div> - <div class="setting-item"> - <h2>{{ $t('settings.attachments') }}</h2> + <h2>{{ $t('settings.post_look_feel') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="hideAttachments"> - {{ $t('settings.hide_attachments_in_tl') }} + <ChoiceSetting + id="conversationDisplay" + path="conversationDisplay" + :options="conversationDisplayOptions" + > + {{ $t('settings.conversation_display') }} + </ChoiceSetting> + </li> + <ul + v-if="conversationDisplay !== 'linear'" + class="setting-list suboptions" + > + <li> + <BooleanSetting path="conversationTreeAdvanced"> + {{ $t('settings.tree_advanced') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="conversationTreeFadeAncestors" + :expert="1" + > + {{ $t('settings.tree_fade_ancestors') }} + </BooleanSetting> + </li> + <li> + <IntegerSetting + path="maxDepthInThread" + :min="3" + :expert="1" + > + {{ $t('settings.max_depth_in_thread') }} + </IntegerSetting> + </li> + <li> + <ChoiceSetting + id="conversationOtherRepliesButton" + path="conversationOtherRepliesButton" + :options="conversationOtherRepliesButtonOptions" + :expert="1" + > + {{ $t('settings.conversation_other_replies_button') }} + </ChoiceSetting> + </li> + </ul> + <li> + <BooleanSetting path="collapseMessageWithSubject"> + {{ $t('settings.collapse_subject') }} </BooleanSetting> </li> <li> - <BooleanSetting path="hideAttachmentsInConv"> - {{ $t('settings.hide_attachments_in_convo') }} + <BooleanSetting + path="emojiReactionsOnTimeline" + expert="1" + > + {{ $t('settings.emoji_reactions_on_timeline') }} </BooleanSetting> </li> <li> - <label for="maxThumbnails"> - {{ $t('settings.max_thumbnails') }} - </label> - <input - id="maxThumbnails" - path.number="maxThumbnails" - class="number-input" - type="number" - min="0" - step="1" + <BooleanSetting + v-if="user" + path="serverSide_stripRichContent" + expert="1" > + {{ $t('settings.no_rich_text_description') }} + </BooleanSetting> + </li> + <h3>{{ $t('settings.attachments') }}</h3> + <li> + <BooleanSetting + path="useContainFit" + expert="1" + > + {{ $t('settings.use_contain_fit') }} + </BooleanSetting> </li> <li> <BooleanSetting path="hideNsfw"> @@ -175,6 +239,7 @@ <li> <BooleanSetting path="preloadImage" + expert="1" :disabled="!hideNsfw" > {{ $t('settings.preload_images') }} @@ -183,6 +248,7 @@ <li> <BooleanSetting path="useOneClickNsfw" + expert="1" :disabled="!hideNsfw" > {{ $t('settings.use_one_click_nsfw') }} @@ -190,12 +256,10 @@ </li> </ul> <li> - <BooleanSetting path="stopGifs"> - {{ $t('settings.stop_gifs') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="loopVideo"> + <BooleanSetting + path="loopVideo" + expert="1" + > {{ $t('settings.loop_video') }} </BooleanSetting> <ul @@ -205,6 +269,7 @@ <li> <BooleanSetting path="loopVideoSilentOnly" + expert="1" :disabled="!loopVideo || !loopSilentAvailable" > {{ $t('settings.loop_video_silent_only') }} @@ -219,35 +284,171 @@ </ul> </li> <li> - <BooleanSetting path="playVideosInModal"> + <BooleanSetting + path="playVideosInModal" + expert="1" + > {{ $t('settings.play_videos_in_modal') }} </BooleanSetting> </li> + <h3>{{ $t('settings.mention_links') }}</h3> <li> - <BooleanSetting path="useContainFit"> - {{ $t('settings.use_contain_fit') }} + <ChoiceSetting + id="mentionLinkDisplay" + path="mentionLinkDisplay" + :options="mentionLinkDisplayOptions" + > + {{ $t('settings.mention_link_display') }} + </ChoiceSetting> + </li> + <li> + <BooleanSetting + path="mentionLinkShowTooltip" + expert="1" + > + {{ $t('settings.mention_link_use_tooltip') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="useAtIcon" + expert="1" + > + {{ $t('settings.use_at_icon') }} </BooleanSetting> </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.notifications') }}</h2> - <ul class="setting-list"> <li> - <BooleanSetting path="webPushNotifications"> - {{ $t('settings.enable_web_push_notifications') }} + <BooleanSetting path="mentionLinkShowAvatar"> + {{ $t('settings.mention_link_show_avatar') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="mentionLinkFadeDomain" + expert="1" + > + {{ $t('settings.mention_link_fade_domain') }} + </BooleanSetting> + </li> + <li v-if="user"> + <BooleanSetting + path="mentionLinkBoldenYou" + expert="1" + > + {{ $t('settings.mention_link_bolden_you') }} + </BooleanSetting> + </li> + <h3 v-if="expertLevel > 0"> + {{ $t('settings.fun') }} + </h3> + <li> + <BooleanSetting + path="greentext" + expert="1" + > + {{ $t('settings.greentext') }} + </BooleanSetting> + </li> + <li v-if="user"> + <BooleanSetting + path="mentionLinkShowYous" + expert="1" + > + {{ $t('settings.show_yous') }} </BooleanSetting> </li> </ul> </div> - <div class="setting-item"> - <h2>{{ $t('settings.fun') }}</h2> + <div + v-if="user" + class="setting-item" + > + <h2>{{ $t('settings.composing') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="greentext"> - {{ $t('settings.greentext') }} + <label for="default-vis"> + {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" /> + <ScopeSelector + class="scope-selector" + :show-all="true" + :user-default="serverSide_defaultScope" + :initial-scope="serverSide_defaultScope" + :on-scope-change="changeDefaultScope" + /> + </label> + </li> + <li> + <!-- <BooleanSetting path="serverSide_defaultNSFW"> --> + <BooleanSetting path="sensitiveByDefault"> + {{ $t('settings.sensitive_by_default') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="scopeCopy" + expert="1" + > + {{ $t('settings.scope_copy') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="alwaysShowSubjectInput" + expert="1" + > + {{ $t('settings.subject_input_always_show') }} + </BooleanSetting> + </li> + <li> + <ChoiceSetting + id="subjectLineBehavior" + path="subjectLineBehavior" + :options="subjectLineOptions" + expert="1" + > + {{ $t('settings.subject_line_behavior') }} + </ChoiceSetting> + </li> + <li v-if="postFormats.length > 0"> + <ChoiceSetting + id="postContentType" + path="postContentType" + :options="postContentOptions" + > + {{ $t('settings.post_status_content_type') }} + </ChoiceSetting> + </li> + <li> + <BooleanSetting + path="minimalScopesMode" + expert="1" + > + {{ $t('settings.minimal_scopes_mode') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="alwaysShowNewPostButton" + expert="1" + > + {{ $t('settings.always_show_post_button') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="autohideFloatingPostButton" + expert="1" + > + {{ $t('settings.autohide_floating_post_button') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="padEmoji" + expert="1" + > + {{ $t('settings.pad_emoji') }} </BooleanSetting> </li> </ul> @@ -256,3 +457,16 @@ </template> <script src="./general_tab.js"></script> + +<style lang="scss"> +.column-settings { + display: flex; + justify-content: space-evenly; + flex-wrap: wrap; +} +.column-settings .size-label { + display: block; + margin-bottom: 0.5em; + margin-top: 0.5em; +} +</style> diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js index 40a87b81..6cfeea35 100644 --- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.js +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.js @@ -2,7 +2,7 @@ import get from 'lodash/get' import map from 'lodash/map' import reject from 'lodash/reject' import Autosuggest from 'src/components/autosuggest/autosuggest.vue' -import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import BlockCard from 'src/components/block_card/block_card.vue' import MuteCard from 'src/components/mute_card/mute_card.vue' import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue' diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss index ceb64efb..2adff847 100644 --- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.scss @@ -8,7 +8,7 @@ .bulk-actions { text-align: right; padding: 0 1em; - min-height: 28px; + min-height: 2em; } .bulk-action-button { diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue index 32a21415..ed4b15a4 100644 --- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue @@ -10,7 +10,7 @@ :query="queryUserIds" :placeholder="$t('settings.search_user_to_block')" > - <template v-slot="row"> + <template #default="row"> <BlockCard :user-id="row.item" /> @@ -21,7 +21,7 @@ :refresh="true" :get-key="i => i" > - <template v-slot:header="{selected}"> + <template #header="{selected}"> <div class="bulk-actions"> <ProgressButton v-if="selected.length > 0" @@ -29,7 +29,7 @@ :click="() => blockUsers(selected)" > {{ $t('user_card.block') }} - <template v-slot:progress> + <template #progress> {{ $t('user_card.block_progress') }} </template> </ProgressButton> @@ -39,16 +39,16 @@ :click="() => unblockUsers(selected)" > {{ $t('user_card.unblock') }} - <template v-slot:progress> + <template #progress> {{ $t('user_card.unblock_progress') }} </template> </ProgressButton> </div> </template> - <template v-slot:item="{item}"> + <template #item="{item}"> <BlockCard :user-id="item" /> </template> - <template v-slot:empty> + <template #empty> {{ $t('settings.no_blocks') }} </template> </BlockList> @@ -56,14 +56,14 @@ <div :label="$t('settings.mutes_tab')"> <tab-switcher> - <div label="Users"> + <div :label="$t('settings.user_mutes')"> <div class="usersearch-wrapper"> <Autosuggest :filter="filterUnMutedUsers" :query="queryUserIds" :placeholder="$t('settings.search_user_to_mute')" > - <template v-slot="row"> + <template #default="row"> <MuteCard :user-id="row.item" /> @@ -74,7 +74,7 @@ :refresh="true" :get-key="i => i" > - <template v-slot:header="{selected}"> + <template #header="{selected}"> <div class="bulk-actions"> <ProgressButton v-if="selected.length > 0" @@ -82,7 +82,7 @@ :click="() => muteUsers(selected)" > {{ $t('user_card.mute') }} - <template v-slot:progress> + <template #progress> {{ $t('user_card.mute_progress') }} </template> </ProgressButton> @@ -92,16 +92,16 @@ :click="() => unmuteUsers(selected)" > {{ $t('user_card.unmute') }} - <template v-slot:progress> + <template #progress> {{ $t('user_card.unmute_progress') }} </template> </ProgressButton> </div> </template> - <template v-slot:item="{item}"> + <template #item="{item}"> <MuteCard :user-id="item" /> </template> - <template v-slot:empty> + <template #empty> {{ $t('settings.no_mutes') }} </template> </MuteList> @@ -114,7 +114,7 @@ :query="queryKnownDomains" :placeholder="$t('settings.type_domains_to_mute')" > - <template v-slot="row"> + <template #default="row"> <DomainMuteCard :domain="row.item" /> @@ -125,7 +125,7 @@ :refresh="true" :get-key="i => i" > - <template v-slot:header="{selected}"> + <template #header="{selected}"> <div class="bulk-actions"> <ProgressButton v-if="selected.length > 0" @@ -133,16 +133,16 @@ :click="() => unmuteDomains(selected)" > {{ $t('domain_mute_card.unmute') }} - <template v-slot:progress> + <template #progress> {{ $t('domain_mute_card.unmute_progress') }} </template> </ProgressButton> </div> </template> - <template v-slot:item="{item}"> + <template #item="{item}"> <DomainMuteCard :domain="item" /> </template> - <template v-slot:empty> + <template #empty> {{ $t('settings.no_mutes') }} </template> </DomainMuteList> diff --git a/src/components/settings_modal/tabs/notifications_tab.js b/src/components/settings_modal/tabs/notifications_tab.js index 3e44c95d..3c6ab87f 100644 --- a/src/components/settings_modal/tabs/notifications_tab.js +++ b/src/components/settings_modal/tabs/notifications_tab.js @@ -1,4 +1,5 @@ -import Checkbox from 'src/components/checkbox/checkbox.vue' +import BooleanSetting from '../helpers/boolean_setting.vue' +import SharedComputedObject from '../helpers/shared_computed_object.js' const NotificationsTab = { data () { @@ -9,12 +10,13 @@ const NotificationsTab = { } }, components: { - Checkbox + BooleanSetting }, computed: { user () { return this.$store.state.users.currentUser - } + }, + ...SharedComputedObject() }, methods: { updateNotificationSettings () { diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index 7e0568ea..dd3806ed 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -2,30 +2,82 @@ <div :label="$t('settings.notifications')"> <div class="setting-item"> <h2>{{ $t('settings.notification_setting_filters') }}</h2> - <p> - <Checkbox v-model="notificationSettings.block_from_strangers"> - {{ $t('settings.notification_setting_block_from_strangers') }} - </Checkbox> - </p> + <ul class="setting-list"> + <li> + <BooleanSetting path="serverSide_blockNotificationsFromStrangers"> + {{ $t('settings.notification_setting_block_from_strangers') }} + </BooleanSetting> + </li> + <li class="select-multiple"> + <span class="label">{{ $t('settings.notification_visibility') }}</span> + <ul class="option-list"> + <li> + <BooleanSetting path="notificationVisibility.likes"> + {{ $t('settings.notification_visibility_likes') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.repeats"> + {{ $t('settings.notification_visibility_repeats') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.follows"> + {{ $t('settings.notification_visibility_follows') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.mentions"> + {{ $t('settings.notification_visibility_mentions') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.moves"> + {{ $t('settings.notification_visibility_moves') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.emojiReactions"> + {{ $t('settings.notification_visibility_emoji_reactions') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.polls"> + {{ $t('settings.notification_visibility_polls') }} + </BooleanSetting> + </li> + </ul> + </li> + </ul> </div> - <div class="setting-item"> + <div + v-if="expertLevel > 0" + class="setting-item" + > <h2>{{ $t('settings.notification_setting_privacy') }}</h2> - <p> - <Checkbox v-model="notificationSettings.hide_notification_contents"> - {{ $t('settings.notification_setting_hide_notification_contents') }} - </Checkbox> - </p> + <ul class="setting-list"> + <li> + <BooleanSetting + path="webPushNotifications" + expert="1" + > + {{ $t('settings.enable_web_push_notifications') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="serverSide_webPushHideContents" + expert="1" + > + {{ $t('settings.notification_setting_hide_notification_contents') }} + </BooleanSetting> + </li> + </ul> </div> <div class="setting-item"> <p>{{ $t('settings.notification_mutes') }}</p> <p>{{ $t('settings.notification_blocks') }}</p> - <button - class="btn button-default" - @click="updateNotificationSettings" - > - {{ $t('settings.save') }} - </button> </div> </div> </template> diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index 64079fcd..b86faef0 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -8,6 +8,11 @@ import EmojiInput from 'src/components/emoji_input/emoji_input.vue' import suggestor from 'src/components/emoji_input/suggestor.js' import Autosuggest from 'src/components/autosuggest/autosuggest.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' +import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' +import BooleanSetting from '../helpers/boolean_setting.vue' +import SharedComputedObject from '../helpers/shared_computed_object.js' +import localeService from 'src/services/locale/locale.service.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { faTimes, @@ -27,25 +32,18 @@ const ProfileTab = { newName: this.$store.state.users.currentUser.name_unescaped, newBio: unescape(this.$store.state.users.currentUser.description), newLocked: this.$store.state.users.currentUser.locked, - newNoRichText: this.$store.state.users.currentUser.no_rich_text, - newDefaultScope: this.$store.state.users.currentUser.default_scope, newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })), - hideFollows: this.$store.state.users.currentUser.hide_follows, - hideFollowers: this.$store.state.users.currentUser.hide_followers, - hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count, - hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count, showRole: this.$store.state.users.currentUser.show_role, role: this.$store.state.users.currentUser.role, - discoverable: this.$store.state.users.currentUser.discoverable, bot: this.$store.state.users.currentUser.bot, - allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, banner: null, bannerPreview: null, background: null, - backgroundPreview: null + backgroundPreview: null, + emailLanguage: this.$store.state.users.currentUser.language || '' } }, components: { @@ -54,26 +52,31 @@ const ProfileTab = { EmojiInput, Autosuggest, ProgressButton, - Checkbox + Checkbox, + BooleanSetting, + InterfaceLanguageSwitcher }, computed: { user () { return this.$store.state.users.currentUser }, + ...SharedComputedObject(), emojiUserSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ], store: this.$store }) }, emojiSuggestor () { - return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, - ...this.$store.state.instance.customEmoji - ] }) + return suggestor({ + emoji: [ + ...this.$store.getters.standardEmojiList, + ...this.$store.state.instance.customEmoji + ] + }) }, userSuggestor () { return suggestor({ store: this.$store }) @@ -114,27 +117,25 @@ const ProfileTab = { }, methods: { updateProfile () { + const params = { + note: this.newBio, + locked: this.newLocked, + // Backend notation. + /* eslint-disable camelcase */ + display_name: this.newName, + fields_attributes: this.newFields.filter(el => el != null), + bot: this.bot, + show_role: this.showRole + /* eslint-enable camelcase */ + } + + if (this.emailLanguage) { + params.language = localeService.internalToBackendLocale(this.emailLanguage) + } + this.$store.state.api.backendInteractor - .updateProfile({ - params: { - note: this.newBio, - locked: this.newLocked, - // Backend notation. - /* eslint-disable camelcase */ - display_name: this.newName, - fields_attributes: this.newFields.filter(el => el != null), - default_scope: this.newDefaultScope, - no_rich_text: this.newNoRichText, - hide_follows: this.hideFollows, - hide_followers: this.hideFollowers, - discoverable: this.discoverable, - bot: this.bot, - allow_following_move: this.allowFollowingMove, - hide_follows_count: this.hideFollowsCount, - hide_followers_count: this.hideFollowersCount, - show_role: this.showRole - /* eslint-enable camelcase */ - } }).then((user) => { + .updateProfile({ params }) + .then((user) => { this.newFields.splice(user.fields.length) merge(this.newFields, user.fields) this.$store.commit('addNewUsers', [user]) @@ -204,8 +205,8 @@ const ProfileTab = { submitAvatar (cropper, file) { const that = this return new Promise((resolve, reject) => { - function updateAvatar (avatar) { - that.$store.state.api.backendInteractor.updateProfileImages({ avatar }) + function updateAvatar (avatar, avatarName) { + that.$store.state.api.backendInteractor.updateProfileImages({ avatar, avatarName }) .then((user) => { that.$store.commit('addNewUsers', [user]) that.$store.commit('setCurrentUser', user) @@ -218,9 +219,9 @@ const ProfileTab = { } if (cropper) { - cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) + cropper.getCroppedCanvas().toBlob((data) => updateAvatar(data, file.name), file.type) } else { - updateAvatar(file) + updateAvatar(file, file.name) } }) }, diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss index 111eaed3..201f1a76 100644 --- a/src/components/settings_modal/tabs/profile_tab.scss +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -54,16 +54,20 @@ 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; } + + svg { + color: white; + } } .oauth-tokens { @@ -85,7 +89,7 @@ &-bulk-actions { text-align: right; padding: 0 1em; - min-height: 28px; + min-height: 2em; button { width: 10em; diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index bb3c301d..642d54ca 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -25,61 +25,6 @@ class="bio resize-height" /> </EmojiInput> - <p> - <Checkbox v-model="newLocked"> - {{ $t('settings.lock_account_description') }} - </Checkbox> - </p> - <div> - <label for="default-vis">{{ $t('settings.default_vis') }}</label> - <div - id="default-vis" - class="visibility-tray" - > - <scope-selector - :show-all="true" - :user-default="newDefaultScope" - :initial-scope="newDefaultScope" - :on-scope-change="changeVis" - /> - </div> - </div> - <p> - <Checkbox v-model="newNoRichText"> - {{ $t('settings.no_rich_text_description') }} - </Checkbox> - </p> - <p> - <Checkbox v-model="hideFollows"> - {{ $t('settings.hide_follows_description') }} - </Checkbox> - </p> - <p class="setting-subitem"> - <Checkbox - v-model="hideFollowsCount" - :disabled="!hideFollows" - > - {{ $t('settings.hide_follows_count_description') }} - </Checkbox> - </p> - <p> - <Checkbox v-model="hideFollowers"> - {{ $t('settings.hide_followers_description') }} - </Checkbox> - </p> - <p class="setting-subitem"> - <Checkbox - v-model="hideFollowersCount" - :disabled="!hideFollowers" - > - {{ $t('settings.hide_followers_count_description') }} - </Checkbox> - </p> - <p> - <Checkbox v-model="allowFollowingMove"> - {{ $t('settings.allow_following_move') }} - </Checkbox> - </p> <p v-if="role === 'admin' || role === 'moderator'"> <Checkbox v-model="showRole"> <template v-if="role === 'admin'"> @@ -90,11 +35,6 @@ </template> </Checkbox> </p> - <p> - <Checkbox v-model="discoverable"> - {{ $t('settings.discoverable') }} - </Checkbox> - </p> <div v-if="maxFields > 0"> <p>{{ $t('settings.profile_fields.label') }}</p> <div @@ -128,8 +68,9 @@ class="delete-field button-unstyled -hover-highlight" @click="deleteField(i)" > + <!-- TODO something is wrong with v-show here --> <FAIcon - v-show="newFields.length > 1" + v-if="newFields.length > 1" icon="times" /> </button> @@ -148,6 +89,13 @@ {{ $t('settings.bot') }} </Checkbox> </p> + <p> + <interface-language-switcher + :prompt-text="$t('settings.email_language')" + :language="emailLanguage" + :set-language="val => emailLanguage = val" + /> + </p> <button :disabled="newName && newName.length === 0" class="btn button-default" @@ -166,14 +114,17 @@ :src="user.profile_image_url_original" class="current-avatar" > - <FAIcon + <button v-if="!isDefaultAvatar && pickAvatarBtnVisible" :title="$t('settings.reset_avatar')" - class="reset-button" - icon="times" - type="button" + class="button-unstyled reset-button" @click="resetAvatar" - /> + > + <FAIcon + icon="times" + type="button" + /> + </button> </div> <p>{{ $t('settings.set_new_avatar') }}</p> <button @@ -195,14 +146,17 @@ <h2>{{ $t('settings.profile_banner') }}</h2> <div class="banner-background-preview"> <img :src="user.cover_photo"> - <FAIcon + <button v-if="!isDefaultBanner" + class="button-unstyled reset-button" :title="$t('settings.reset_profile_banner')" - class="reset-button" - icon="times" - type="button" @click="resetBanner" - /> + > + <FAIcon + icon="times" + type="button" + /> + </button> </div> <p>{{ $t('settings.set_new_profile_banner') }}</p> <img @@ -234,14 +188,17 @@ <h2>{{ $t('settings.profile_background') }}</h2> <div class="banner-background-preview"> <img :src="user.background_image"> - <FAIcon + <button v-if="!isDefaultBackground" + class="button-unstyled reset-button" :title="$t('settings.reset_profile_background')" - class="reset-button" - icon="times" - type="button" @click="resetBackground" - /> + > + <FAIcon + icon="times" + type="button" + /> + </button> </div> <p>{{ $t('settings.set_new_profile_background') }}</p> <img @@ -269,6 +226,67 @@ {{ $t('settings.save') }} </button> </div> + <div class="setting-item"> + <h2>{{ $t('settings.account_privacy') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path="serverSide_locked"> + {{ $t('settings.lock_account_description') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="serverSide_discoverable"> + {{ $t('settings.discoverable') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="serverSide_allowFollowingMove"> + {{ $t('settings.allow_following_move') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="serverSide_hideFavorites"> + {{ $t('settings.hide_favorites_description') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="serverSide_hideFollowers"> + {{ $t('settings.hide_followers_description') }} + </BooleanSetting> + <ul + class="setting-list suboptions" + :class="[{disabled: !serverSide_hideFollowers}]" + > + <li> + <BooleanSetting + path="serverSide_hideFollowersCount" + :disabled="!serverSide_hideFollowers" + > + {{ $t('settings.hide_followers_count_description') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <BooleanSetting path="serverSide_hideFollows"> + {{ $t('settings.hide_follows_description') }} + </BooleanSetting> + <ul + class="setting-list suboptions" + :class="[{disabled: !serverSide_hideFollows}]" + > + <li> + <BooleanSetting + path="serverSide_hideFollowsCount" + :disabled="!serverSide_hideFollows" + > + {{ $t('settings.hide_follows_count_description') }} + </BooleanSetting> + </li> + </ul> + </li> + </ul> + </div> </div> </template> diff --git a/src/components/settings_modal/tabs/security_tab/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js index abf37062..5337d150 100644 --- a/src/components/settings_modal/tabs/security_tab/mfa.js +++ b/src/components/settings_modal/tabs/security_tab/mfa.js @@ -32,8 +32,8 @@ const Mfa = { components: { 'recovery-codes': RecoveryCodes, 'totp-item': TOTP, - 'qrcode': VueQrcode, - 'confirm': Confirm + qrcode: VueQrcode, + confirm: Confirm }, computed: { canSetupOTP () { @@ -139,7 +139,7 @@ const Mfa = { // fetch settings from server async fetchSettings () { - let result = await this.backendInteractor.settingsMFA() + const result = await this.backendInteractor.settingsMFA() if (result.error) return this.settings = result.settings this.settings.available = true diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js index 8408d8e9..b0adb530 100644 --- a/src/components/settings_modal/tabs/security_tab/mfa_totp.js +++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js @@ -10,7 +10,7 @@ export default { inProgress: false // progress peform request to disable otp method }), components: { - 'confirm': Confirm + confirm: Confirm }, computed: { isActivated () { diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js index 65d20fc0..d253bc79 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.js +++ b/src/components/settings_modal/tabs/security_tab/security_tab.js @@ -13,13 +13,23 @@ const SecurityTab = { deletingAccount: false, deleteAccountConfirmPasswordInput: '', deleteAccountError: false, - changePasswordInputs: [ '', '', '' ], + changePasswordInputs: ['', '', ''], changedPassword: false, - changePasswordError: false + changePasswordError: false, + moveAccountTarget: '', + moveAccountPassword: '', + movedAccount: false, + moveAccountError: false, + aliases: [], + listAliasesError: false, + addAliasTarget: '', + addedAlias: false, + addAliasError: false } }, created () { this.$store.dispatch('fetchTokens') + this.fetchAliases() }, components: { ProgressButton, @@ -92,6 +102,49 @@ const SecurityTab = { } }) }, + moveAccount () { + const params = { + targetAccount: this.moveAccountTarget, + password: this.moveAccountPassword + } + this.$store.state.api.backendInteractor.moveAccount(params) + .then((res) => { + if (res.status === 'success') { + this.movedAccount = true + this.moveAccountError = false + } else { + this.movedAccount = false + this.moveAccountError = res.error + } + }) + }, + removeAlias (alias) { + this.$store.state.api.backendInteractor.deleteAlias({ alias }) + .then(() => this.fetchAliases()) + }, + addAlias () { + this.$store.state.api.backendInteractor.addAlias({ alias: this.addAliasTarget }) + .then((res) => { + this.addedAlias = true + this.addAliasError = false + this.addAliasTarget = '' + }) + .catch((error) => { + this.addedAlias = false + this.addAliasError = error + }) + .then(() => this.fetchAliases()) + }, + fetchAliases () { + this.$store.state.api.backendInteractor.listAliases() + .then((res) => { + this.aliases = res.aliases + this.listAliasesError = false + }) + .catch((error) => { + this.listAliasesError = error.error + }) + }, logout () { this.$store.dispatch('logout') this.$router.replace('/') diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index 275d4616..6e03bef4 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -103,6 +103,114 @@ </table> </div> <mfa /> + + <div class="setting-item"> + <h2>{{ $t('settings.account_alias') }}</h2> + <table> + <thead> + <tr> + <th>{{ $t('settings.account_alias_table_head') }}</th> + <th /> + </tr> + </thead> + <tbody> + <tr + v-for="alias in aliases" + :key="alias" + > + <td>{{ alias }}</td> + <td class="actions"> + <button + class="btn button-default" + @click="removeAlias(alias)" + > + {{ $t('settings.remove_alias') }} + </button> + </td> + </tr> + </tbody> + </table> + <div + v-if="listAliasesError" + class="alert error" + > + {{ $t('settings.list_aliases_error', { error }) }} + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + :title="$t('settings.hide_list_aliases_error_action')" + @click="listAliasesError = false" + /> + </div> + <div> + <i18n + path="settings.new_alias_target" + tag="p" + > + <code + place="example" + > + foo@example.org + </code> + </i18n> + <input + v-model="addAliasTarget" + > + </div> + <button + class="btn button-default" + @click="addAlias" + > + {{ $t('settings.save') }} + </button> + <p v-if="addedAlias"> + {{ $t('settings.added_alias') }} + </p> + <template v-if="addAliasError !== false"> + <p>{{ $t('settings.add_alias_error', { error: addAliasError }) }}</p> + </template> + </div> + + <div class="setting-item"> + <h2>{{ $t('settings.move_account') }}</h2> + <p>{{ $t('settings.move_account_notes') }}</p> + <div> + <i18n + path="settings.move_account_target" + tag="p" + > + <code + place="example" + > + foo@example.org + </code> + </i18n> + <input + v-model="moveAccountTarget" + > + </div> + <div> + <p>{{ $t('settings.current_password') }}</p> + <input + v-model="moveAccountPassword" + type="password" + autocomplete="current-password" + > + </div> + <button + class="btn button-default" + @click="moveAccount" + > + {{ $t('settings.save') }} + </button> + <p v-if="movedAccount"> + {{ $t('settings.moved_account') }} + </p> + <template v-if="moveAccountError !== false"> + <p>{{ $t('settings.move_account_error', { error: moveAccountError }) }}</p> + </template> + </div> + <div class="setting-item"> <h2>{{ $t('settings.delete_account') }}</h2> <p v-if="!deletingAccount"> @@ -133,7 +241,7 @@ class="btn button-default" @click="confirmDelete" > - {{ $t('settings.save') }} + {{ $t('settings.delete_account') }} </button> </div> </div> diff --git a/src/components/settings_modal/tabs/theme_tab/preview.vue b/src/components/settings_modal/tabs/theme_tab/preview.vue index 7ac7b9d3..ba6bd529 100644 --- a/src/components/settings_modal/tabs/theme_tab/preview.vue +++ b/src/components/settings_modal/tabs/theme_tab/preview.vue @@ -29,14 +29,17 @@ {{ $t('settings.style.preview.content') }} </h4> - <i18n path="settings.style.preview.text"> + <i18n-t + scope="global" + keypath="settings.style.preview.text" + > <code style="font-family: var(--postCodeFont)"> {{ $t('settings.style.preview.mono') }} </code> <a style="color: var(--link)"> {{ $t('settings.style.preview.link') }} </a> - </i18n> + </i18n-t> <div class="icons"> <FAIcon @@ -72,15 +75,16 @@ :^) </div> <div class="content"> - <i18n - path="settings.style.preview.fine_print" + <i18n-t + keypath="settings.style.preview.fine_print" tag="span" class="faint" + scope="global" > <a style="color: var(--faintLink)"> {{ $t('settings.style.preview.faint_link') }} </a> - </i18n> + </i18n-t> </div> </div> <div class="separator" /> 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 0b6669fc..4a739f73 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -1,4 +1,3 @@ -import { set, delete as del } from 'vue' import { rgb2hex, hex2rgb, @@ -34,7 +33,7 @@ import OpacityInput from 'src/components/opacity_input/opacity_input.vue' import ShadowControl from 'src/components/shadow_control/shadow_control.vue' import FontControl from 'src/components/font_control/font_control.vue' import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue' -import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import Checkbox from 'src/components/checkbox/checkbox.vue' import Select from 'src/components/select/select.vue' @@ -96,11 +95,11 @@ export default { ...Object.keys(SLOT_INHERITANCE) .map(key => [key, '']) - .reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}), + .reduce((acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }), {}), ...Object.keys(OPACITIES) .map(key => [key, '']) - .reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}), + .reduce((acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }), {}), shadowSelected: undefined, shadowsLocal: {}, @@ -213,12 +212,12 @@ export default { currentColors () { return Object.keys(SLOT_INHERITANCE) .map(key => [key, this[key + 'ColorLocal']]) - .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {}) + .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {}) }, currentOpacity () { return Object.keys(OPACITIES) .map(key => [key, this[key + 'OpacityLocal']]) - .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {}) + .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {}) }, currentRadii () { return { @@ -280,6 +279,9 @@ export default { opacity ) + // Temporary patch for null-y value errors + if (layers.flat().some(v => v == null)) return acc + return { ...acc, ...textColors.reduce((acc, textColorKey) => { @@ -301,6 +303,7 @@ export default { return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {}) } catch (e) { console.warn('Failure computing contrasts', e) + return {} } }, previewRules () { @@ -320,9 +323,9 @@ export default { }, set (val) { if (val) { - set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _))) + this.shadowsLocal[this.shadowSelected] = this.currentShadowFallback.map(_ => Object.assign({}, _)) } else { - del(this.shadowsLocal, this.shadowSelected) + delete this.shadowsLocal[this.shadowSelected] } } }, @@ -334,7 +337,7 @@ export default { return this.shadowsLocal[this.shadowSelected] }, set (v) { - set(this.shadowsLocal, this.shadowSelected, v) + this.shadowsLocal[this.shadowSelected] = v } }, themeValid () { @@ -378,6 +381,10 @@ export default { // To separate from other random JSON files and possible future source formats _pleroma_theme_version: 2, theme, source } + }, + isActive () { + const tabSwitcher = this.$parent + return tabSwitcher ? tabSwitcher.isActive('theme') : false } }, components: { @@ -557,7 +564,7 @@ export default { .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal')) .filter(_ => !v1OnlyNames.includes(_)) .forEach(key => { - set(this.$data, key, undefined) + this.$data[key] = undefined }) }, @@ -565,7 +572,7 @@ export default { Object.keys(this.$data) .filter(_ => _.endsWith('RadiusLocal')) .forEach(key => { - set(this.$data, key, undefined) + this.$data[key] = undefined }) }, @@ -573,7 +580,7 @@ export default { Object.keys(this.$data) .filter(_ => _.endsWith('OpacityLocal')) .forEach(key => { - set(this.$data, key, undefined) + this.$data[key] = undefined }) }, diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss index 0db21537..bad6f51b 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss @@ -245,36 +245,12 @@ border-color: var(--border, $fallback--border); } - .panel-heading { - .badge, .alert, .btn, .faint { - margin-left: 1em; - white-space: nowrap; - } - .faint { - text-overflow: ellipsis; - min-width: 2em; - overflow-x: hidden; - } - .flex-spacer { - flex: 1; - } - } .btn { - margin-left: 0; - padding: 0 1em; min-width: 3em; - min-height: 30px; } } } - .apply-container { - justify-content: center; - position: absolute; - bottom: 8px; - right: 5px; - } - .radius-item, .color-item { min-width: 20em; @@ -334,16 +310,25 @@ padding: 20px; } - .apply-container { - .btn { - min-height: 28px; - min-width: 10em; - padding: 0 2em; - } - } - .btn { margin-left: .25em; margin-right: .25em; } } + +.extra-content { + .apply-container { + display: flex; + flex-direction: row; + justify-content: space-around; + flex-grow: 1; + + .btn { + flex-grow: 1; + min-height: 2em; + min-width: 0; + max-width: 10em; + padding: 0; + } + } +} 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 c02986ed..ff2fece9 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -903,6 +903,7 @@ <div class="tab-header shadow-selector"> <div class="select-container"> {{ $t('settings.style.shadows.component') }} + {{ ' ' }} <Select id="shadow-switcher" v-model="shadowSelected" @@ -924,6 +925,7 @@ > {{ $t('settings.style.shadows.override') }} </label> + {{ ' ' }} <input id="override" v-model="currentShadowOverriden" @@ -949,27 +951,30 @@ :fallback="currentShadowFallback" /> <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"> - <i18n - path="settings.style.shadows.filter_hint.always_drop_shadow" + <i18n-t + scope="global" + keypath="settings.style.shadows.filter_hint.always_drop_shadow" tag="p" > <code>filter: drop-shadow()</code> - </i18n> + </i18n-t> <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p> - <i18n - path="settings.style.shadows.filter_hint.drop_shadow_syntax" + <i18n-t + scope="global" + keypath="settings.style.shadows.filter_hint.drop_shadow_syntax" tag="p" > <code>drop-shadow</code> <code>spread-radius</code> <code>inset</code> - </i18n> - <i18n - path="settings.style.shadows.filter_hint.inset_classic" + </i18n-t> + <i18n-t + scope="global" + keypath="settings.style.shadows.filter_hint.inset_classic" tag="p" > <code>box-shadow</code> - </i18n> + </i18n-t> <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p> </div> </div> @@ -1016,21 +1021,26 @@ </tab-switcher> </keep-alive> - <div class="apply-container"> - <button - class="btn button-default submit" - :disabled="!themeValid" - @click="setCustomTheme" - > - {{ $t('general.apply') }} - </button> - <button - class="btn button-default" - @click="clearAll" - > - {{ $t('settings.style.switcher.reset') }} - </button> - </div> + <teleport + v-if="isActive" + to="#unscrolled-content" + > + <div class="apply-container"> + <button + class="btn button-default submit" + :disabled="!themeValid" + @click="setCustomTheme" + > + {{ $t('general.apply') }} + </button> + <button + class="btn button-default" + @click="clearAll" + > + {{ $t('settings.style.switcher.reset') }} + </button> + </div> + </teleport> </div> </template> diff --git a/src/components/settings_modal/tabs/version_tab.vue b/src/components/settings_modal/tabs/version_tab.vue index d35ff25e..0330d49f 100644 --- a/src/components/settings_modal/tabs/version_tab.vue +++ b/src/components/settings_modal/tabs/version_tab.vue @@ -28,4 +28,4 @@ </div> </div> </template> -<script src="./version_tab.js"> +<script src="./version_tab.js" /> diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js index 2d5d6eb1..a1d1012b 100644 --- a/src/components/shadow_control/shadow_control.js +++ b/src/components/shadow_control/shadow_control.js @@ -30,18 +30,19 @@ const toModel = (object = {}) => ({ }) export default { - // 'Value' and 'Fallback' can be undefined, but if they are + // 'modelValue' and 'Fallback' can be undefined, but if they are // initially vue won't detect it when they become something else // therefore i'm using "ready" which should be passed as true when // data becomes available props: [ - 'value', 'fallback', 'ready' + 'modelValue', 'fallback', 'ready' ], + emits: ['update:modelValue'], data () { return { selectedId: 0, // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason) - cValue: (this.value || this.fallback || []).map(toModel) + cValue: (this.modelValue || this.fallback || []).map(toModel) } }, components: { @@ -70,7 +71,7 @@ export default { } }, beforeUpdate () { - this.cValue = this.value || this.fallback + this.cValue = this.modelValue || this.fallback }, computed: { anyShadows () { @@ -105,15 +106,17 @@ export default { !this.usingFallback }, usingFallback () { - return typeof this.value === 'undefined' + return typeof this.modelValue === 'undefined' }, rgb () { return hex2rgb(this.selected.color) }, style () { - return this.ready ? { - boxShadow: getCssShadow(this.fallback) - } : {} + return this.ready + ? { + boxShadow: getCssShadow(this.fallback) + } + : {} } } } diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue index 511e07f3..669cac71 100644 --- a/src/components/shadow_control/shadow_control.vue +++ b/src/components/shadow_control/shadow_control.vue @@ -204,17 +204,18 @@ v-model="selected.alpha" :disabled="!present" /> - <i18n - path="settings.style.shadows.hintV3" + <i18n-t + scope="global" + keypath="settings.style.shadows.hintV3" tag="p" > <code>--variable,mod</code> - </i18n> + </i18n-t> </div> </div> </template> -<script src="./shadow_control.js" ></script> +<script src="./shadow_control.js"></script> <style lang="scss"> @import '../../_variables.scss'; diff --git a/src/components/shout_panel/shout_panel.js b/src/components/shout_panel/shout_panel.js index a6168971..fb0c5aa2 100644 --- a/src/components/shout_panel/shout_panel.js +++ b/src/components/shout_panel/shout_panel.js @@ -11,7 +11,7 @@ library.add( ) const shoutPanel = { - props: [ 'floating' ], + props: ['floating'], data () { return { currentMessage: '', diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue index c88797d1..688c2d61 100644 --- a/src/components/shout_panel/shout_panel.vue +++ b/src/components/shout_panel/shout_panel.vue @@ -57,7 +57,7 @@ > <div class="panel panel-default"> <div - class="panel-heading stub timeline-heading shout-heading" + class="panel-heading -stub timeline-heading shout-heading" @click.stop.prevent="togglePanel" > <div class="title"> @@ -79,17 +79,17 @@ .floating-shout { position: fixed; - bottom: 0px; - z-index: 1000; + bottom: 0.5em; + z-index: var(--ZI_popovers); max-width: 25em; -} -.floating-shout.left { - left: 0px; -} + &.-left { + left: 0.5em; + } -.floating-shout:not(.left) { - right: 0px; + &:not(.-left) { + right: 0.5em; + } } .shout-panel { @@ -98,7 +98,7 @@ .icon { color: $fallback--text; - color: var(--text, $fallback--text); + color: var(--panelText, $fallback--text); margin-right: 0.5em; } @@ -121,7 +121,7 @@ .shout-message { display: flex; - padding: 0.2em 0.5em + padding: 0.2em 0.5em; } .shout-avatar { @@ -137,6 +137,7 @@ .shout-input { display: flex; + textarea { flex: 1; margin: 0.6em; diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 89719df3..27019577 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -2,6 +2,7 @@ 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' +import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faSignInAlt, @@ -14,7 +15,9 @@ import { faSearch, faTachometerAlt, faCog, - faInfoCircle + faInfoCircle, + faCompass, + faList } from '@fortawesome/free-solid-svg-icons' library.add( @@ -28,11 +31,13 @@ library.add( faSearch, faTachometerAlt, faCog, - faInfoCircle + faInfoCircle, + faCompass, + faList ) const SideDrawer = { - props: [ 'logout' ], + props: ['logout'], data: () => ({ closed: true, closeGesture: undefined @@ -49,7 +54,7 @@ const SideDrawer = { currentUser () { return this.$store.state.users.currentUser }, - shout () { return this.$store.state.shout.channel.state === 'joined' }, + shout () { return this.$store.state.shout.joined }, unseenNotifications () { return unseenNotificationsFromStore(this.$store) }, @@ -78,15 +83,22 @@ const SideDrawer = { return this.$store.state.instance.federating }, timelinesRoute () { + let name if (this.$store.state.interface.lastTimeline) { - return this.$store.state.interface.lastTimeline + name = this.$store.state.interface.lastTimeline + } + name = this.currentUser ? 'friends' : 'public-timeline' + if (USERNAME_ROUTES.has(name)) { + return { name, params: { username: this.currentUser.screen_name } } + } else { + return { name } } - return this.currentUser ? 'friends' : 'public-timeline' }, ...mapState({ - pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable + pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, + supportsAnnouncements: state => state.announcements.supportsAnnouncements }), - ...mapGetters(['unreadChatCount']) + ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) }, methods: { toggleDrawer () { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index dd88de7d..887596f8 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -47,7 +47,7 @@ v-if="currentUser || !privateMode" @click="toggleDrawer" > - <router-link :to="{ name: timelinesRoute }"> + <router-link :to="timelinesRoute"> <FAIcon fixed-width class="fa-scale-110 fa-old-padding" @@ -56,6 +56,18 @@ </router-link> </li> <li + v-if="currentUser" + @click="toggleDrawer" + > + <router-link :to="{ name: 'lists' }"> + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="list" + /> {{ $t("nav.lists") }} + </router-link> + </li> + <li v-if="currentUser && pleromaChatMessagesAvailable" @click="toggleDrawer" > @@ -180,6 +192,38 @@ </a> </li> <li + v-if="currentUser && supportsAnnouncements" + @click="toggleDrawer" + > + <router-link + :to="{ name: 'announcements' }" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="bullhorn" + /> {{ $t("nav.announcements") }} + <span + v-if="unreadAnnouncementCount" + class="badge badge-notification" + > + {{ unreadAnnouncementCount }} + </span> + </router-link> + </li> + <li + v-if="currentUser" + @click="toggleDrawer" + > + <router-link :to="{ name: 'edit-navigation' }"> + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding" + icon="compass" + /> {{ $t("nav.edit_nav_mobile") }} + </router-link> + </li> + <li v-if="currentUser" @click="toggleDrawer" > @@ -204,14 +248,14 @@ </div> </template> -<script src="./side_drawer.js" ></script> +<script src="./side_drawer.js"></script> <style lang="scss"> @import '../../_variables.scss'; .side-drawer-container { position: fixed; - z-index: 1000; + z-index: var(--ZI_navbar); top: 0; left: 0; width: 100%; diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index b9561bf1..46a92ac7 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -13,16 +13,16 @@ const StaffPanel = { }, computed: { groupedStaffAccounts () { - const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _) + const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _) const groupedStaffAccounts = groupBy(staffAccounts, 'role') return [ - { role: 'admin', users: groupedStaffAccounts['admin'] }, - { role: 'moderator', users: groupedStaffAccounts['moderator'] } + { role: 'admin', users: groupedStaffAccounts.admin }, + { role: 'moderator', users: groupedStaffAccounts.moderator } ].filter(group => group.users) }, ...mapGetters([ - 'findUser' + 'findUserByName' ]), ...mapState({ staffAccounts: state => state.instance.staffAccounts diff --git a/src/components/staff_panel/staff_panel.vue b/src/components/staff_panel/staff_panel.vue index c52ade42..6b9e61f2 100644 --- a/src/components/staff_panel/staff_panel.vue +++ b/src/components/staff_panel/staff_panel.vue @@ -24,7 +24,7 @@ </div> </template> -<script src="./staff_panel.js" ></script> +<script src="./staff_panel.js"></script> <style lang="scss"> diff --git a/src/components/status/status.js b/src/components/status/status.js index ac481534..9a9bca7a 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -4,15 +4,16 @@ import ReactButton from '../react_button/react_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' -import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' import StatusContent from '../status_content/status_content.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' import StatusPopover from '../status_popover/status_popover.vue' +import UserPopover from '../user_popover/user_popover.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' +import UserLink from '../user_link/user_link.vue' import MentionsLine from 'src/components/mentions_line/mentions_line.vue' import MentionLink from 'src/components/mention_link/mention_link.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -35,7 +36,10 @@ import { faStar, faEyeSlash, faEye, - faThumbtack + faThumbtack, + faChevronUp, + faChevronDown, + faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons' library.add( @@ -52,9 +56,47 @@ library.add( faEllipsisH, faEyeSlash, faEye, - faThumbtack + faThumbtack, + faChevronUp, + faChevronDown, + faAngleDoubleRight ) +const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1) + +const controlledOrUncontrolledGetters = list => list.reduce((res, name) => { + const camelized = camelCase(name) + const toggle = `controlledToggle${camelized}` + const controlledName = `controlled${camelized}` + const uncontrolledName = `uncontrolled${camelized}` + res[name] = function () { + return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName] + } + return res +}, {}) + +const controlledOrUncontrolledToggle = (obj, name) => { + const camelized = camelCase(name) + const toggle = `controlledToggle${camelized}` + const uncontrolledName = `uncontrolled${camelized}` + if (obj[toggle]) { + obj[toggle]() + } else { + obj[uncontrolledName] = !obj[uncontrolledName] + } +} + +const controlledOrUncontrolledSet = (obj, name, val) => { + const camelized = camelCase(name) + const set = `controlledSet${camelized}` + const uncontrolledName = `uncontrolled${camelized}` + if (obj[set]) { + obj[set](val) + } else { + obj[uncontrolledName] = val + } +} + const Status = { name: 'Status', components: { @@ -64,7 +106,6 @@ const Status = { RetweetButton, ExtraButtons, PostStatusForm, - UserCard, UserAvatar, AvatarList, Timeago, @@ -74,7 +115,9 @@ const Status = { StatusContent, RichContent, MentionLink, - MentionsLine + MentionsLine, + UserPopover, + UserLink }, props: [ 'statusoid', @@ -89,20 +132,38 @@ const Status = { 'inlineExpanded', 'showPinned', 'inProfile', - 'profileUserId' + 'profileUserId', + + 'simpleTree', + 'controlledThreadDisplayStatus', + 'controlledToggleThreadDisplay', + 'showOtherRepliesAsButton', + + 'controlledShowingTall', + 'controlledToggleShowingTall', + 'controlledExpandingSubject', + 'controlledToggleExpandingSubject', + 'controlledShowingLongSubject', + 'controlledToggleShowingLongSubject', + 'controlledReplying', + 'controlledToggleReplying', + 'controlledMediaPlaying', + 'controlledSetMediaPlaying', + 'dive' ], data () { return { - replying: false, + uncontrolledReplying: false, unmuted: false, userExpanded: false, - mediaPlaying: [], + uncontrolledMediaPlaying: [], suspendable: true, error: null, headTailLinks: null } }, computed: { + ...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']), muteWords () { return this.mergedConfig.muteWords }, @@ -166,6 +227,18 @@ const Status = { muteWordHits () { return muteWordHits(this.status, this.muteWords) }, + rtBotStatus () { + return this.statusoid.user.bot + }, + botStatus () { + return this.status.user.bot + }, + botIndicator () { + return this.botStatus && !this.hideBotIndication + }, + rtBotIndicator () { + return this.rtBotStatus && !this.hideBotIndication + }, mentionsLine () { if (!this.headTailLinks) return [] const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url)) @@ -187,25 +260,33 @@ const Status = { }, muted () { if (this.statusoid.user.id === this.currentUser.id) return false + const reasonsToMute = this.userIsMuted || + // Thread is muted + status.thread_muted || + // Wordfiltered + this.muteWordHits.length > 0 || + // bot status + (this.muteBotStatuses && this.botStatus && !this.compact) + return !this.unmuted && !this.shouldNotMute && reasonsToMute + }, + userIsMuted () { + if (this.statusoid.user.id === this.currentUser.id) return false const { status } = this const { reblog } = status const relationship = this.$store.getters.relationship(status.user.id) const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id) - const reasonsToMute = ( - // Post is muted according to BE - status.muted || + return status.muted || // ReprÃļÃļt of a muted post according to BE (reblog && reblog.muted) || // Muted user relationship.muting || // Muted user of a reprÃļÃļt - (relationshipReblog && relationshipReblog.muting) || - // Thread is muted - status.thread_muted || - // Wordfiltered - this.muteWordHits.length > 0 - ) - const excusesNotToMute = ( + (relationshipReblog && relationshipReblog.muting) + }, + shouldNotMute () { + const { status } = this + const { reblog } = status + return ( ( this.inProfile && ( // Don't mute user's posts on user timeline (except reblogs) @@ -218,14 +299,26 @@ const Status = { (this.inConversation && status.thread_muted) // No excuses if post has muted words ) && !this.muteWordHits.length > 0 - - return !this.unmuted && !excusesNotToMute && reasonsToMute + }, + hideMutedUsers () { + return this.mergedConfig.hideMutedPosts + }, + hideMutedThreads () { + return this.mergedConfig.hideMutedThreads }, hideFilteredStatuses () { return this.mergedConfig.hideFilteredStatuses }, + hideWordFilteredPosts () { + return this.mergedConfig.hideWordFilteredPosts + }, hideStatus () { - return (this.muted && this.hideFilteredStatuses) || this.virtualHidden + return (!this.shouldNotMute) && ( + (this.muted && this.hideFilteredStatuses) || + (this.userIsMuted && this.hideMutedUsers) || + (this.status.thread_muted && this.hideMutedThreads) || + (this.muteWordHits.length > 0 && this.hideWordFilteredPosts) + ) }, isFocused () { // retweet or root of an expanded conversation @@ -270,11 +363,18 @@ const Status = { return uniqBy(combinedUsers, 'id') }, tags () { + // eslint-disable-next-line no-prototype-builtins return this.status.tags.filter(tagObj => tagObj.hasOwnProperty('name')).map(tagObj => tagObj.name).join(' ') }, hidePostStats () { return this.mergedConfig.hidePostStats }, + muteBotStatuses () { + return this.mergedConfig.muteBotStatuses + }, + hideBotIndication () { + return this.mergedConfig.hideBotIndication + }, currentUser () { return this.$store.state.users.currentUser }, @@ -286,6 +386,21 @@ const Status = { }, isSuspendable () { return !this.replying && this.mediaPlaying.length === 0 + }, + inThreadForest () { + return !!this.controlledThreadDisplayStatus + }, + threadShowing () { + return this.controlledThreadDisplayStatus === 'showing' + }, + visibilityLocalized () { + return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility) + }, + isEdited () { + return this.status.edited_at !== null + }, + editingAvailable () { + return this.$store.state.instance.editingAvailable } }, methods: { @@ -308,7 +423,7 @@ const Status = { this.error = undefined }, toggleReplying () { - this.replying = !this.replying + controlledOrUncontrolledToggle(this, 'replying') }, gotoOriginal (id) { if (this.inConversation) { @@ -328,19 +443,21 @@ const Status = { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) }, addMediaPlaying (id) { - this.mediaPlaying.push(id) + controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id)) }, removeMediaPlaying (id) { - this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id) + controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id)) }, setHeadTailLinks (headTailLinks) { this.headTailLinks = headTailLinks - } - }, - watch: { - 'highlight': function (id) { + }, + toggleThreadDisplay () { + this.controlledToggleThreadDisplay() + }, + scrollIfHighlighted (highlightId) { + const id = highlightId if (this.status.id === id) { - let rect = this.$el.getBoundingClientRect() + const rect = this.$el.getBoundingClientRect() if (rect.top < 100) { // Post is above screen, match its top to screen top window.scrollBy(0, rect.top - 100) @@ -352,6 +469,11 @@ const Status = { window.scrollBy(0, rect.bottom - window.innerHeight + 50) } } + } + }, + watch: { + highlight: function (id) { + this.scrollIfHighlighted(id) }, 'status.repeat_num': function (num) { // refetch repeats when repeat_num is changed in any way @@ -365,14 +487,9 @@ const Status = { this.$store.dispatch('fetchFavs', this.status.id) } }, - 'isSuspendable': function (val) { + isSuspendable: function (val) { this.suspendable = val } - }, - filters: { - capitalize: function (str) { - return str.charAt(0).toUpperCase() + str.slice(1) - } } } diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 71305dd7..ada9841e 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -1,10 +1,10 @@ @import '../../_variables.scss'; -$status-margin: 0.75em; - .Status { min-width: 0; white-space: normal; + word-wrap: break-word; + word-break: break-word; &:hover { --_still-image-img-visibility: visible; @@ -26,15 +26,8 @@ $status-margin: 0.75em; --icon: var(--selectedPostIcon, $fallback--icon); } - &.-conversation { - border-left-width: 4px; - border-left-style: solid; - border-left-color: $fallback--cRed; - border-left-color: var(--cRed, $fallback--cRed); - } - .gravestone { - padding: $status-margin; + padding: var(--status-margin, $status-margin); color: $fallback--faint; color: var(--faint, $fallback--faint); display: flex; @@ -47,7 +40,11 @@ $status-margin: 0.75em; .status-container { display: flex; - padding: $status-margin; + padding: var(--status-margin, $status-margin); + + > * { + min-width: 0; + } &.-repeat { padding-top: 0; @@ -55,7 +52,7 @@ $status-margin: 0.75em; } .pin { - padding: $status-margin $status-margin 0; + padding: var(--status-margin, $status-margin) var(--status-margin, $status-margin) 0; display: flex; align-items: center; justify-content: flex-end; @@ -71,7 +68,7 @@ $status-margin: 0.75em; } .left-side { - margin-right: $status-margin; + margin-right: var(--status-margin, $status-margin); } .right-side { @@ -80,12 +77,11 @@ $status-margin: 0.75em; } .usercard { - margin-bottom: $status-margin; + margin-bottom: var(--status-margin, $status-margin); } .status-username { white-space: nowrap; - font-size: 14px; overflow: hidden; max-width: 85%; font-weight: bold; @@ -110,7 +106,7 @@ $status-margin: 0.75em; .heading-name-row { display: flex; justify-content: space-between; - line-height: 18px; + line-height: 1.3; a { display: inline-block; @@ -160,22 +156,29 @@ $status-margin: 0.75em; margin-right: 0.2em; } - & .heading-reply-row { + & .heading-reply-row, + & .heading-edited-row { position: relative; align-content: baseline; - font-size: 12px; - line-height: 160%; + font-size: 0.85em; + margin-top: 0.2em; + line-height: 130%; max-width: 100%; align-items: stretch; } & .reply-to-popover, - & .reply-to-no-popover { + & .reply-to-no-popover, + & .mentions { min-width: 0; margin-right: 0.4em; flex-shrink: 0; } + .reply-glued-label { + margin-right: 0.5em; + } + .reply-to-popover { .reply-to:hover::before { content: ''; @@ -209,7 +212,6 @@ $status-margin: 0.75em; & .reply-to { white-space: nowrap; position: relative; - padding-right: 0.25em; } & .mentions-text, @@ -226,8 +228,8 @@ $status-margin: 0.75em; .replies { margin-top: 0.25em; - line-height: 18px; - font-size: 12px; + line-height: 1.3; + font-size: 0.85em; display: flex; flex-wrap: wrap; @@ -241,7 +243,7 @@ $status-margin: 0.75em; } .repeat-info { - padding: 0.4em $status-margin; + padding: 0.4em var(--status-margin, $status-margin); .repeat-icon { color: $fallback--cGreen; @@ -287,7 +289,7 @@ $status-margin: 0.75em; position: relative; width: 100%; display: flex; - margin-top: $status-margin; + margin-top: var(--status-margin, $status-margin); > * { max-width: 4em; @@ -355,7 +357,7 @@ $status-margin: 0.75em; } .favs-repeated-users { - margin-top: $status-margin; + margin-top: var(--status-margin, $status-margin); } .stats { @@ -382,19 +384,19 @@ $status-margin: 0.75em; } .stat-count { - margin-right: $status-margin; + margin-right: var(--status-margin, $status-margin); user-select: none; .stat-title { color: var(--faint, $fallback--faint); - font-size: 12px; + font-size: 0.85em; text-transform: uppercase; position: relative; } .stat-number { font-weight: bolder; - font-size: 16px; + font-size: 1.1em; line-height: 1em; } @@ -408,13 +410,13 @@ $status-margin: 0.75em; margin-left: 20px; } - .avatar:not(.repeater-avatar) { + .post-avatar { width: 40px; height: 40px; // TODO define those other way somehow? // stylelint-disable rscss/class-format - &.avatar-compact { + &.-compact { width: 32px; height: 32px; } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 2684e415..82eb7ac6 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,6 +1,7 @@ <template> <div v-if="!hideStatus" + ref="root" class="Status" :class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]" > @@ -24,9 +25,10 @@ class="fa-scale-110 fa-old-padding repeat-icon" icon="retweet" /> - <router-link :to="userProfileLink"> - {{ status.user.screen_name_ui }} - </router-link> + <user-link + :user="status.user" + :at="false" + /> </small> <small v-if="showReasonMutedThread" @@ -77,6 +79,7 @@ <UserAvatar v-if="retweet" class="left-side repeater-avatar" + :bot="rtBotIndicator" :better-shadow="betterShadow" :user="statusoid.user" /> @@ -99,6 +102,7 @@ :to="retweeterProfileLink" >{{ retweeter }}</router-link> </span> + {{ ' ' }} <FAIcon icon="retweet" class="repeat-icon" @@ -119,25 +123,25 @@ v-if="!noHeading" class="left-side" > - <router-link - :to="userProfileLink" - @click.stop.prevent.capture.native="toggleUserExpanded" + <a + :href="$router.resolve(userProfileLink).href" + @click.prevent > - <UserAvatar - :compact="compact" - :better-shadow="betterShadow" - :user="status.user" - /> - </router-link> + <UserPopover + :user-id="status.user.id" + :overlay-centers="true" + > + <UserAvatar + class="post-avatar" + :bot="botIndicator" + :compact="compact" + :better-shadow="betterShadow" + :user="status.user" + /> + </UserPopover> + </a> </div> <div class="right-side"> - <UserCard - v-if="userExpanded" - :user-id="status.user.id" - :rounded="true" - :bordered="true" - class="usercard" - /> <div v-if="!noHeading" class="status-heading" @@ -161,13 +165,12 @@ > {{ status.user.name }} </h4> - <router-link + <user-link class="account-name" :title="status.user.screen_name_ui" - :to="userProfileLink" - > - {{ status.user.screen_name_ui }} - </router-link> + :user="status.user" + :at="false" + /> <img v-if="!!(status.user && status.user.favicon)" class="status-favicon" @@ -188,7 +191,7 @@ <span v-if="status.visibility" class="visibility-icon" - :title="status.visibility | capitalize" + :title="visibilityLocalized" > <FAIcon fixed-width @@ -219,6 +222,31 @@ class="fa-scale-110" /> </button> + <button + v-if="inThreadForest && replies && replies.length && !simpleTree" + class="button-unstyled" + :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')" + :aria-expanded="threadShowing ? 'true' : 'false'" + @click.prevent="toggleThreadDisplay" + > + <FAIcon + fixed-width + class="fa-scale-110" + :icon="threadShowing ? 'chevron-up' : 'chevron-down'" + /> + </button> + <button + v-if="dive && !simpleTree" + class="button-unstyled" + :title="$t('status.show_only_conversation_under_this')" + @click.prevent="dive" + > + <FAIcon + fixed-width + class="fa-scale-110" + :icon="'angle-double-right'" + /> + </button> </span> </div> <div @@ -227,7 +255,7 @@ > <span v-if="isReply" - class="glued-label" + class="glued-label reply-glued-label" > <StatusPopover v-if="!isPreview" @@ -246,6 +274,7 @@ icon="reply" flip="horizontal" /> + {{ ' ' }} <span class="reply-to-text" > @@ -265,7 +294,6 @@ :url="replyProfileLink" :user-id="status.in_reply_to_user_id" :user-screen-name="status.in_reply_to_screen_name" - :first-mention="false" /> </span> @@ -292,12 +320,31 @@ class="mentions-line-first" /> </span> + {{ ' ' }} <MentionsLine v-if="hasMentionsLine" :mentions="mentionsLine.slice(1)" class="mentions-line" /> </div> + <div + v-if="isEdited && editingAvailable && !isPreview" + class="heading-edited-row" + > + <i18n-t + keypath="status.edited_at" + tag="span" + > + <template #time> + <Timeago + template-key="time.in_past" + :time="status.edited_at" + :auto-update="60" + :long-format="true" + /> + </template> + </i18n-t> + </div> </div> <StatusContent @@ -306,6 +353,12 @@ :no-heading="noHeading" :highlight="highlight" :focused="isFocused" + :controlled-showing-tall="controlledShowingTall" + :controlled-expanding-subject="controlledExpandingSubject" + :controlled-showing-long-subject="controlledShowingLongSubject" + :controlled-toggle-showing-tall="controlledToggleShowingTall" + :controlled-toggle-expanding-subject="controlledToggleExpandingSubject" + :controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject" @mediaplay="addMediaPlaying($event)" @mediapause="removeMediaPlaying($event)" @parseReady="setHeadTailLinks" @@ -315,7 +368,20 @@ v-if="inConversation && !isPreview && replies && replies.length" class="replies" > - <span class="faint">{{ $t('status.replies_list') }}</span> + <button + v-if="showOtherRepliesAsButton && replies.length > 1" + class="button-unstyled -link faint" + :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })" + @click.prevent="dive" + > + {{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }} + </button> + <span + v-else + class="faint" + > + {{ $t('status.replies_list') }} + </span> <StatusPopover v-for="reply in replies" :key="reply.id" @@ -407,7 +473,11 @@ class="gravestone" > <div class="left-side"> - <UserAvatar :compact="compact" /> + <UserAvatar + class="post-avatar" + :compact="compact" + :bot="botIndicator" + /> </div> <div class="right-side"> <div class="deleted-text"> @@ -439,6 +509,6 @@ </div> </template> -<script src="./status.js" ></script> +<script src="./status.js"></script> <style src="./status.scss" lang="scss"></style> diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js index ef542307..b8f6f9a0 100644 --- a/src/components/status_body/status_body.js +++ b/src/components/status_body/status_body.js @@ -21,18 +21,21 @@ library.add( const StatusContent = { name: 'StatusContent', props: [ + 'compact', 'status', 'focused', 'noHeading', 'fullContent', - 'singleLine' + 'singleLine', + 'showingTall', + 'expandingSubject', + 'showingLongSubject', + 'toggleShowingTall', + 'toggleExpandingSubject', + 'toggleShowingLongSubject' ], data () { return { - 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, postLength: this.status.text.length, parseReadyDone: false } @@ -49,6 +52,7 @@ const StatusContent = { // Using max-height + overflow: auto for status components resulted in false positives // very often with japanese characters, and it was very annoying. tallStatus () { + if (this.singleLine || this.compact) return false const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80 return lengthScore > 20 }, @@ -113,9 +117,9 @@ const StatusContent = { }, toggleShowMore () { if (this.mightHideBecauseTall) { - this.showingTall = !this.showingTall + this.toggleShowingTall() } else if (this.mightHideBecauseSubject) { - this.expandingSubject = !this.expandingSubject + this.toggleExpandingSubject() } }, generateTagLink (tag) { diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss index c7732bfe..039d4c7f 100644 --- a/src/components/status_body/status_body.scss +++ b/src/components/status_body/status_body.scss @@ -1,11 +1,17 @@ @import '../../_variables.scss'; .StatusBody { + display: flex; + flex-direction: column; .emoji { --_still_image-label-scale: 0.5; } + .attachments { + margin-top: 0.5em; + } + & .text, & .summary { font-family: var(--postFont, sans-serif); @@ -13,7 +19,7 @@ overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; - line-height: 1.4em; + line-height: var(--post-line-height); } .summary { @@ -115,4 +121,54 @@ .cyantext { color: var(--postCyantext, $fallback--cBlue); } + + &.-compact { + align-items: top; + flex-direction: row; + + --emoji-size: 16px; + + & .body, + & .attachments { + max-height: 3.25em; + } + + .body { + overflow: hidden; + white-space: normal; + min-width: 5em; + flex: 5 1 auto; + mask-size: auto 3.5em, auto auto; + mask-position: 0 0, 0 0; + mask-repeat: repeat-x, repeat; + mask-image: linear-gradient(to bottom, white 2em, transparent 3em); + + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + + .attachments { + margin-top: 0; + flex: 1 1 0; + min-width: 5em; + height: 100%; + margin-left: 0.5em; + } + + .summary-wrapper { + .summary::after { + content: ': '; + } + + line-height: inherit; + margin: 0; + border: none; + display: inline-block; + } + + .text-wrapper { + display: inline-block; + } + } } diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue index 9f01c470..fb356360 100644 --- a/src/components/status_body/status_body.vue +++ b/src/components/status_body/status_body.vue @@ -1,5 +1,8 @@ <template> - <div class="StatusBody"> + <div + class="StatusBody" + :class="{ '-compact': compact }" + > <div class="body"> <div v-if="status.summary_raw_html" @@ -12,16 +15,16 @@ :emoji="status.emojis" /> <button - v-if="longSubject && showingLongSubject" + v-show="longSubject && showingLongSubject" class="button-unstyled -link tall-subject-hider" - @click.prevent="showingLongSubject=false" + @click.prevent="toggleShowingLongSubject" > {{ $t("status.hide_full_subject") }} </button> <button - v-else-if="longSubject" + v-show="longSubject && !showingLongSubject" class="button-unstyled -link tall-subject-hider" - @click.prevent="showingLongSubject=true" + @click.prevent="toggleShowingLongSubject" > {{ $t("status.show_full_subject") }} </button> @@ -31,7 +34,7 @@ class="text-wrapper" > <button - v-if="hideTallStatus" + v-show="hideTallStatus" class="button-unstyled -link tall-status-hider" :class="{ '-focused': focused }" @click.prevent="toggleShowMore" @@ -51,7 +54,7 @@ /> <button - v-if="hideSubjectStatus" + v-show="hideSubjectStatus" class="button-unstyled -link cw-status-hider" @click.prevent="toggleShowMore" > @@ -82,7 +85,7 @@ /> </button> <button - v-if="showingMore && !fullContent" + v-show="showingMore && !fullContent" class="button-unstyled -link status-unhider" @click.prevent="toggleShowMore" > @@ -93,5 +96,5 @@ <slot v-if="!hideSubjectStatus" /> </div> </template> -<script src="./status_body.js" ></script> +<script src="./status_body.js"></script> <style lang="scss" src="./status_body.scss" /> diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index 1b80ee09..89f0aa51 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -3,7 +3,6 @@ import Poll from '../poll/poll.vue' import Gallery from '../gallery/gallery.vue' import StatusBody from 'src/components/status_body/status_body.vue' import LinkPreview from '../link-preview/link-preview.vue' -import fileType from 'src/services/file_type/file_type.service' import { mapGetters, mapState } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -24,16 +23,56 @@ library.add( faPollH ) +const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1) + +const controlledOrUncontrolledGetters = list => list.reduce((res, name) => { + const camelized = camelCase(name) + const toggle = `controlledToggle${camelized}` + const controlledName = `controlled${camelized}` + const uncontrolledName = `uncontrolled${camelized}` + res[name] = function () { + return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName] + } + return res +}, {}) + +const controlledOrUncontrolledToggle = (obj, name) => { + const camelized = camelCase(name) + const toggle = `controlledToggle${camelized}` + const uncontrolledName = `uncontrolled${camelized}` + if (obj[toggle]) { + obj[toggle]() + } else { + obj[uncontrolledName] = !obj[uncontrolledName] + } +} + const StatusContent = { name: 'StatusContent', props: [ 'status', + 'compact', 'focused', 'noHeading', 'fullContent', - 'singleLine' + 'singleLine', + 'controlledShowingTall', + 'controlledExpandingSubject', + 'controlledToggleShowingTall', + 'controlledToggleExpandingSubject', + 'controlledShowingLongSubject', + 'controlledToggleShowingLongSubject' ], + data () { + return { + uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused), + uncontrolledShowingLongSubject: false, + // not as computed because it sets the initial state which will be changed later + uncontrolledExpandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject + } + }, computed: { + ...controlledOrUncontrolledGetters(['showingTall', 'expandingSubject', 'showingLongSubject']), hideAttachments () { return (this.mergedConfig.hideAttachments && !this.inConversation) || (this.mergedConfig.hideAttachmentsInConv && this.inConversation) @@ -48,33 +87,15 @@ const StatusContent = { return true }, attachmentSize () { - if ((this.mergedConfig.hideAttachments && !this.inConversation) || + if (this.compact) { + return 'small' + } else if ((this.mergedConfig.hideAttachments && !this.inConversation) || (this.mergedConfig.hideAttachmentsInConv && this.inConversation) || (this.status.attachments.length > this.maxThumbnails)) { return 'hide' - } else if (this.compact) { - return 'small' } return 'normal' }, - galleryTypes () { - if (this.attachmentSize === 'hide') { - return [] - } - return this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] - }, - galleryAttachments () { - return this.status.attachments.filter( - file => fileType.fileMatchesSomeType(this.galleryTypes, file) - ) - }, - nonGalleryAttachments () { - return this.status.attachments.filter( - file => !fileType.fileMatchesSomeType(this.galleryTypes, file) - ) - }, maxThumbnails () { return this.mergedConfig.maxThumbnails }, @@ -91,6 +112,15 @@ const StatusContent = { StatusBody }, methods: { + toggleShowingTall () { + controlledOrUncontrolledToggle(this, 'showingTall') + }, + toggleExpandingSubject () { + controlledOrUncontrolledToggle(this, 'expandingSubject') + }, + toggleShowingLongSubject () { + controlledOrUncontrolledToggle(this, 'showingLongSubject') + }, setMedia () { const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments return () => this.$store.dispatch('setMedia', attachments) diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 5cebc697..e2120f7a 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -1,44 +1,48 @@ <template> - <div class="StatusContent"> + <div + class="StatusContent" + :class="{ '-compact': compact }" + > <slot name="header" /> <StatusBody :status="status" + :compact="compact" :single-line="singleLine" + :showing-tall="showingTall" + :expanding-subject="expandingSubject" + :showing-long-subject="showingLongSubject" + :toggle-showing-tall="toggleShowingTall" + :toggle-expanding-subject="toggleExpandingSubject" + :toggle-showing-long-subject="toggleShowingLongSubject" @parseReady="$emit('parseReady', $event)" > - <div v-if="status.poll && status.poll.options"> + <div v-if="status.poll && status.poll.options && !compact"> <Poll :base-poll="status.poll" :emoji="status.emojis" /> </div> - <div - v-if="status.attachments.length !== 0" - class="attachments media-body" - > - <attachment - v-for="attachment in nonGalleryAttachments" - :key="attachment.id" - class="non-gallery" - :size="attachmentSize" - :nsfw="nsfwClickthrough" - :attachment="attachment" - :allow-play="true" - :set-media="setMedia()" - @play="$emit('mediaplay', attachment.id)" - @pause="$emit('mediapause', attachment.id)" - /> - <gallery - v-if="galleryAttachments.length > 0" - :nsfw="nsfwClickthrough" - :attachments="galleryAttachments" - :set-media="setMedia()" + <div v-else-if="status.poll && status.poll.options && compact"> + <FAIcon + icon="poll-h" + size="2x" /> </div> + <gallery + v-if="status.attachments.length !== 0" + class="attachments media-body" + :nsfw="nsfwClickthrough" + :attachments="status.attachments" + :limit="compact ? 1 : 0" + :size="attachmentSize" + @play="$emit('mediaplay', attachment.id)" + @pause="$emit('mediapause', attachment.id)" + /> + <div - v-if="status.card && !noHeading" + v-if="status.card && !noHeading && !compact" class="link-preview media-body" > <link-preview @@ -52,12 +56,8 @@ </div> </template> -<script src="./status_content.js" ></script> +<script src="./status_content.js"></script> <style lang="scss"> -@import '../../_variables.scss'; - -$status-margin: 0.75em; - .StatusContent { flex: 1; min-width: 0; diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js new file mode 100644 index 00000000..3941a56f --- /dev/null +++ b/src/components/status_history_modal/status_history_modal.js @@ -0,0 +1,60 @@ +import { get } from 'lodash' +import Modal from '../modal/modal.vue' +import Status from '../status/status.vue' + +const StatusHistoryModal = { + components: { + Modal, + Status + }, + data () { + return { + statuses: [] + } + }, + computed: { + modalActivated () { + return this.$store.state.statusHistory.modalActivated + }, + params () { + return this.$store.state.statusHistory.params + }, + statusId () { + return this.params.id + }, + historyCount () { + return this.statuses.length + }, + history () { + return this.statuses + } + }, + watch: { + params (newVal, oldVal) { + const newStatusId = get(newVal, 'id') !== get(oldVal, 'id') + if (newStatusId) { + this.resetHistory() + } + + if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) { + this.fetchStatusHistory() + } + } + }, + methods: { + resetHistory () { + this.statuses = [] + }, + fetchStatusHistory () { + this.$store.dispatch('fetchStatusHistory', this.params) + .then(data => { + this.statuses = data + }) + }, + closeModal () { + this.$store.dispatch('closeStatusHistoryModal') + } + } +} + +export default StatusHistoryModal diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue new file mode 100644 index 00000000..990be35b --- /dev/null +++ b/src/components/status_history_modal/status_history_modal.vue @@ -0,0 +1,46 @@ +<template> + <Modal + v-if="modalActivated" + class="status-history-modal-view" + @backdropClicked="closeModal" + > + <div class="status-history-modal-panel panel"> + <div class="panel-heading"> + {{ $t('status.status_history') }} ({{ historyCount }}) + </div> + <div class="panel-body"> + <div + v-if="historyCount > 0" + class="history-body" + > + <status + v-for="status in history" + :key="status.id" + :statusoid="status" + :is-preview="true" + class="conversation-status status-fadein panel-body" + /> + </div> + </div> + </div> + </Modal> +</template> + +<script src="./status_history_modal.js"></script> + +<style lang="scss"> +.modal-view.status-history-modal-view { + align-items: flex-start; +} +.status-history-modal-panel { + flex-shrink: 0; + margin-top: 25%; + margin-bottom: 2em; + width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } +} +</style> diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index c47f5631..c55bd85b 100644 --- a/src/components/status_popover/status_popover.js +++ b/src/components/status_popover/status_popover.js @@ -1,6 +1,7 @@ import { find } from 'lodash' import { library } from '@fortawesome/fontawesome-svg-core' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' +import { defineAsyncComponent } from 'vue' library.add( faCircleNotch @@ -22,8 +23,8 @@ const StatusPopover = { } }, components: { - Status: () => import('../status/status.vue'), - Popover: () => import('../popover/popover.vue') + Status: defineAsyncComponent(() => import('../status/status.vue')), + Popover: defineAsyncComponent(() => import('../popover/popover.vue')) }, methods: { enter () { @@ -37,6 +38,13 @@ const StatusPopover = { .catch(e => (this.error = true)) } } + }, + watch: { + status (newStatus, oldStatus) { + if (newStatus !== oldStatus) { + this.$nextTick(() => this.$refs.popover.updateStyles()) + } + } } } diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue index fdca8c9c..f4ab357b 100644 --- a/src/components/status_popover/status_popover.vue +++ b/src/components/status_popover/status_popover.vue @@ -1,14 +1,16 @@ <template> <Popover + ref="popover" trigger="hover" + :stay-on-click="true" popover-class="popover-default status-popover" :bound-to="{ x: 'container' }" @show="enter" > - <template v-slot:trigger> + <template #trigger> <slot /> </template> - <template v-slot:content> + <template #content> <Status v-if="status" :is-preview="true" @@ -35,7 +37,7 @@ </Popover> </template> -<script src="./status_popover.js" ></script> +<script src="./status_popover.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -52,8 +54,6 @@ border-width: 1px; border-radius: $fallback--tooltipRadius; border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); - box-shadow: var(--popupShadow); /* TODO cleanup this */ .Status.Status { diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js index 8daf3f07..b06384e5 100644 --- a/src/components/sticker_picker/sticker_picker.js +++ b/src/components/sticker_picker/sticker_picker.js @@ -1,6 +1,6 @@ /* eslint-env browser */ import statusPosterService from '../../services/status_poster/status_poster.service.js' -import TabSwitcher from '../tab_switcher/tab_switcher.js' +import TabSwitcher from '../tab_switcher/tab_switcher.jsx' const StickerPicker = { components: { @@ -31,8 +31,8 @@ const StickerPicker = { fetch(sticker) .then((res) => { res.blob().then((blob) => { - var file = new File([blob], name, { mimetype: 'image/png' }) - var formData = new FormData() + const file = new File([blob], name, { mimetype: 'image/png' }) + const formData = new FormData() formData.append('file', file) statusPosterService.uploadMedia({ store, formData }) .then((fileData) => { diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 8044e994..200ef147 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -5,20 +5,44 @@ const StillImage = { 'mimetype', 'imageLoadError', 'imageLoadHandler', - 'alt' + 'alt', + 'height', + 'width', + 'dataSrc' ], data () { return { + // for lazy loading, see loadLazy() + realSrc: this.src, stopGifs: this.$store.getters.mergedConfig.stopGifs } }, computed: { animated () { - return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) + if (!this.realSrc) { + return false + } + + return this.stopGifs && (this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif')) + }, + style () { + const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str + return { + height: this.height ? appendPx(this.height) : null, + width: this.width ? appendPx(this.width) : null + } } }, methods: { + loadLazy () { + if (this.dataSrc) { + this.realSrc = this.dataSrc + } + }, onLoad () { + if (!this.realSrc) { + return + } const image = this.$refs.src if (!image) return this.imageLoadHandler && this.imageLoadHandler(image) @@ -33,6 +57,14 @@ const StillImage = { onError () { this.imageLoadError && this.imageLoadError() } + }, + watch: { + src () { + this.realSrc = this.src + }, + dataSrc () { + this.$el.removeAttribute('data-loaded') + } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index 0623b42e..633fb229 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -2,6 +2,7 @@ <div class="still-image" :class="{ animated: animated }" + :style="style" > <canvas v-if="animated" @@ -10,14 +11,16 @@ <!-- NOTE: key is required to force to re-render img tag when src is changed --> <img ref="src" - :key="src" + :key="realSrc" :alt="alt" :title="alt" - :src="src" + :data-src="dataSrc" + :src="realSrc" :referrerpolicy="referrerpolicy" @load="onLoad" @error="onError" > + <slot /> </div> </template> @@ -56,10 +59,10 @@ zoom: var(--_still_image-label-scale, 1); content: 'gif'; position: absolute; - line-height: 10px; - font-size: 10px; - top: 5px; - left: 5px; + line-height: 1; + font-size: 0.7em; + top: 0.5em; + left: 0.5em; background: rgba(127, 127, 127, 0.5); color: #fff; display: block; diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js new file mode 100644 index 00000000..238e6df8 --- /dev/null +++ b/src/components/swipe_click/swipe_click.js @@ -0,0 +1,84 @@ +import GestureService from '../../services/gesture_service/gesture_service' + +/** + * props: + * direction: a vector that indicates the direction of the intended swipe + * threshold: the minimum distance in pixels the swipe has moved on `direction' + * for swipe-finished() to have a non-zero sign + * perpendicularTolerance: see gesture_service + * + * Events: + * preview-requested(offsets) + * Emitted when the pointer has moved. + * offsets: the offsets from the start of the swipe to the current cursor position + * + * swipe-canceled() + * Emitted when the swipe has been canceled due to a pointercancel event. + * + * swipe-finished(sign: 0|-1|1) + * Emitted when the swipe has finished. + * sign: if the swipe does not meet the threshold, 0 + * if the swipe meets the threshold in the positive direction, 1 + * if the swipe meets the threshold in the negative direction, -1 + * + * swipeless-clicked() + * Emitted when there is a click without swipe. + * This and swipe-finished() cannot be emitted for the same pointerup event. + */ +const SwipeClick = { + props: { + direction: { + type: Array + }, + threshold: { + type: Function, + default: () => 30 + }, + perpendicularTolerance: { + type: Number, + default: 1.0 + } + }, + methods: { + handlePointerDown (event) { + this.$gesture.start(event) + }, + handlePointerMove (event) { + this.$gesture.move(event) + }, + handlePointerUp (event) { + this.$gesture.end(event) + }, + handlePointerCancel (event) { + this.$gesture.cancel(event) + }, + handleNativeClick (event) { + this.$gesture.click(event) + }, + preview (offsets) { + this.$emit('preview-requested', offsets) + }, + end (sign) { + this.$emit('swipe-finished', sign) + }, + click () { + this.$emit('swipeless-clicked') + }, + cancel () { + this.$emit('swipe-canceled') + } + }, + created () { + this.$gesture = new GestureService.SwipeAndClickGesture({ + direction: this.direction, + threshold: this.threshold, + perpendicularTolerance: this.perpendicularTolerance, + swipePreviewCallback: this.preview, + swipeEndCallback: this.end, + swipeCancelCallback: this.cancel, + swipelessClickCallback: this.click + }) + } +} + +export default SwipeClick diff --git a/src/components/swipe_click/swipe_click.vue b/src/components/swipe_click/swipe_click.vue new file mode 100644 index 00000000..5372071d --- /dev/null +++ b/src/components/swipe_click/swipe_click.vue @@ -0,0 +1,14 @@ +<template> + <div + v-bind="$attrs" + @pointerdown="handlePointerDown" + @pointermove="handlePointerMove" + @pointerup="handlePointerUp" + @pointercancel="handlePointerCancel" + @click="handleNativeClick" + > + <slot /> + </div> +</template> + +<script src="./swipe_click.js"></script> diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.jsx index 12aac8e6..c8d390bc 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -1,10 +1,13 @@ -import Vue from 'vue' +// eslint-disable-next-line no-unused +import { h, Fragment } from 'vue' import { mapState } from 'vuex' import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome' import './tab_switcher.scss' -export default Vue.component('tab-switcher', { +const findFirstUsable = (slots) => slots.findIndex(_ => _.props) + +export default { name: 'TabSwitcher', props: { renderOnlyFocused: { @@ -31,22 +34,33 @@ export default Vue.component('tab-switcher', { required: false, type: Boolean, default: false + }, + bodyScrollLock: { + required: false, + type: Boolean, + default: false } }, data () { return { - active: this.$slots.default.findIndex(_ => _.tag) + active: findFirstUsable(this.slots()) } }, computed: { activeIndex () { // In case of controlled component if (this.activeTab) { - return this.$slots.default.findIndex(slot => this.activeTab === slot.key) + return this.slots().findIndex(slot => slot && slot.props && this.activeTab === slot.props.key) } else { return this.active } }, + isActive () { + return tabName => { + const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName + return this.$slots.default().findIndex(isWanted) === this.activeIndex + } + }, settingsModalVisible () { return this.settingsModalState === 'visible' }, @@ -55,9 +69,9 @@ export default Vue.component('tab-switcher', { }) }, beforeUpdate () { - const currentSlot = this.$slots.default[this.active] - if (!currentSlot.tag) { - this.active = this.$slots.default.findIndex(_ => _.tag) + const currentSlot = this.slots()[this.active] + if (!currentSlot.props) { + this.active = findFirstUsable(this.slots()) } }, methods: { @@ -67,9 +81,16 @@ export default Vue.component('tab-switcher', { this.setTab(index) } }, + // DO NOT put it to computed, it doesn't work (caching?) + slots () { + if (this.$slots.default()[0].type === Fragment) { + return this.$slots.default()[0].children + } + return this.$slots.default() + }, setTab (index) { if (typeof this.onSwitch === 'function') { - this.onSwitch.call(null, this.$slots.default[index].key) + this.onSwitch.call(null, this.slots()[index].key) } this.active = index if (this.scrollableTabs) { @@ -77,27 +98,28 @@ export default Vue.component('tab-switcher', { } } }, - render (h) { - const tabs = this.$slots.default + render () { + const tabs = this.slots() .map((slot, index) => { - if (!slot.tag) return + const props = slot.props + if (!props) return const classesTab = ['tab', 'button-default'] const classesWrapper = ['tab-wrapper'] if (this.activeIndex === index) { classesTab.push('active') classesWrapper.push('active') } - if (slot.data.attrs.image) { + if (props.image) { return ( <div class={classesWrapper.join(' ')}> <button - disabled={slot.data.attrs.disabled} + disabled={props.disabled} onClick={this.clickTab(index)} class={classesTab.join(' ')} type="button" > - <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/> - {slot.data.attrs.label ? '' : slot.data.attrs.label} + <img src={props.image} title={props['image-tooltip']}/> + {props.label ? '' : props.label} </button> </div> ) @@ -105,25 +127,26 @@ export default Vue.component('tab-switcher', { return ( <div class={classesWrapper.join(' ')}> <button - disabled={slot.data.attrs.disabled} + disabled={props.disabled} onClick={this.clickTab(index)} class={classesTab.join(' ')} type="button" > - {!slot.data.attrs.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={slot.data.attrs.icon}/>)} + {!props.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={props.icon}/>)} <span class="text"> - {slot.data.attrs.label} + {props.label} </span> </button> </div> ) }) - const contents = this.$slots.default.map((slot, index) => { - if (!slot.tag) return + const contents = this.slots().map((slot, index) => { + const props = slot.props + if (!props) return const active = this.activeIndex === index const classes = [ active ? 'active' : 'hidden' ] - if (slot.data.attrs.fullHeight) { + if (props.fullHeight) { classes.push('full-height') } const renderSlot = (!this.renderOnlyFocused || active) @@ -134,7 +157,7 @@ export default Vue.component('tab-switcher', { <div class={classes}> { this.sideTabBar - ? <h1 class="mobile-label">{slot.data.attrs.label}</h1> + ? <h1 class="mobile-label">{props.label}</h1> : '' } {renderSlot} @@ -147,10 +170,14 @@ export default Vue.component('tab-switcher', { <div class="tabs"> {tabs} </div> - <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}> + <div + ref="contents" + class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} + v-body-scroll-lock={this.bodyScrollLock} + > {contents} </div> </div> ) } -}) +} diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss index 0ed614b7..d930368c 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -17,6 +17,7 @@ overflow-x: auto; padding-top: 5px; flex-direction: row; + flex: 0 0 auto; &::after, &::before { content: ''; @@ -25,8 +26,9 @@ border-bottom-color: $fallback--border; border-bottom-color: var(--border, $fallback--border); } + .tab-wrapper { - height: 28px; + height: 2em; &:not(.active)::after { left: 0; @@ -166,13 +168,6 @@ position: relative; white-space: nowrap; padding: 6px 1em; - background-color: $fallback--fg; - background-color: var(--tab, $fallback--fg); - - &, &:active .tab-icon { - color: $fallback--text; - color: var(--tabText, $fallback--text); - } &:not(.active) { z-index: 4; diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js index 400c6a4b..bda61ae0 100644 --- a/src/components/tag_timeline/tag_timeline.js +++ b/src/components/tag_timeline/tag_timeline.js @@ -18,7 +18,7 @@ const TagTimeline = { this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag }) } }, - destroyed () { + unmounted () { this.$store.dispatch('stopFetchingTimeline', 'tag') } } diff --git a/src/components/terms_of_service_panel/terms_of_service_panel.vue b/src/components/terms_of_service_panel/terms_of_service_panel.vue index 63dc58b8..1df41d70 100644 --- a/src/components/terms_of_service_panel/terms_of_service_panel.vue +++ b/src/components/terms_of_service_panel/terms_of_service_panel.vue @@ -13,7 +13,7 @@ </div> </template> -<script src="./terms_of_service_panel.js" ></script> +<script src="./terms_of_service_panel.js"></script> <style lang="scss"> .tos-content { diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js new file mode 100644 index 00000000..71e63725 --- /dev/null +++ b/src/components/thread_tree/thread_tree.js @@ -0,0 +1,90 @@ +import Status from '../status/status.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faAngleDoubleDown, + faAngleDoubleRight +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faAngleDoubleDown, + faAngleDoubleRight +) + +const ThreadTree = { + components: { + Status + }, + name: 'ThreadTree', + props: { + depth: Number, + status: Object, + inProfile: Boolean, + conversation: Array, + collapsable: Boolean, + isExpanded: Boolean, + pinnedStatusIdsObject: Object, + profileUserId: String, + + focused: Function, + highlight: String, + getReplies: Function, + setHighlight: Function, + toggleExpanded: Function, + + simple: Boolean, + // to control display of the whole thread forest + toggleThreadDisplay: Function, + threadDisplayStatus: Object, + showThreadRecursively: Function, + totalReplyCount: Object, + totalReplyDepth: Object, + statusContentProperties: Object, + setStatusContentProperty: Function, + toggleStatusContentProperty: Function, + dive: Function + }, + computed: { + suspendable () { + const selfSuspendable = this.$refs.statusComponent ? this.$refs.statusComponent.suspendable : true + if (this.$refs.childComponent) { + return selfSuspendable && this.$refs.childComponent.every(s => s.suspendable) + } + return selfSuspendable + }, + reverseLookupTable () { + return this.conversation.reduce((table, status, index) => { + table[status.id] = index + return table + }, {}) + }, + currentReplies () { + return this.getReplies(this.status.id).map(({ id }) => this.statusById(id)) + }, + threadShowing () { + return this.threadDisplayStatus[this.status.id] === 'showing' + }, + currentProp () { + return this.statusContentProperties[this.status.id] + } + }, + methods: { + statusById (id) { + return this.conversation[this.reverseLookupTable[id]] + }, + collapseThread () { + }, + showThread () { + }, + showAllSubthreads () { + }, + toggleCurrentProp (name) { + this.toggleStatusContentProperty(this.status.id, name) + }, + setCurrentProp (name, newVal) { + this.setStatusContentProperty(this.status.id, name) + } + } +} + +export default ThreadTree diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue new file mode 100644 index 00000000..c6fffc71 --- /dev/null +++ b/src/components/thread_tree/thread_tree.vue @@ -0,0 +1,135 @@ +<template> + <article class="thread-tree"> + <status + :key="status.id" + ref="statusComponent" + :inline-expanded="collapsable && isExpanded" + :statusoid="status" + :expandable="!isExpanded" + :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]" + :focused="focused(status.id)" + :in-conversation="isExpanded" + :highlight="highlight" + :replies="getReplies(status.id)" + :in-profile="inProfile" + :profile-user-id="profileUserId" + class="conversation-status conversation-status-treeview status-fadein panel-body" + + :simple-tree="simple" + :controlled-thread-display-status="threadDisplayStatus[status.id]" + :controlled-toggle-thread-display="() => toggleThreadDisplay(status.id)" + + :controlled-showing-tall="currentProp.showingTall" + :controlled-expanding-subject="currentProp.expandingSubject" + :controlled-showing-long-subject="currentProp.showingLongSubject" + :controlled-replying="currentProp.replying" + :controlled-media-playing="currentProp.mediaPlaying" + :controlled-toggle-showing-tall="() => toggleCurrentProp('showingTall')" + :controlled-toggle-expanding-subject="() => toggleCurrentProp('expandingSubject')" + :controlled-toggle-showing-long-subject="() => toggleCurrentProp('showingLongSubject')" + :controlled-toggle-replying="() => toggleCurrentProp('replying')" + :controlled-set-media-playing="(newVal) => setCurrentProp('mediaPlaying', newVal)" + :dive="dive ? () => dive(status.id) : undefined" + + @goto="setHighlight" + @toggleExpanded="toggleExpanded" + /> + <div + v-if="currentReplies.length && threadShowing" + class="thread-tree-replies" + > + <thread-tree + v-for="replyStatus in currentReplies" + :key="replyStatus.id" + ref="childComponent" + :depth="depth + 1" + :status="replyStatus" + + :in-profile="inProfile" + :conversation="conversation" + :collapsable="collapsable" + :is-expanded="isExpanded" + :pinned-status-ids-object="pinnedStatusIdsObject" + :profile-user-id="profileUserId" + + :focused="focused" + :get-replies="getReplies" + :highlight="highlight" + :set-highlight="setHighlight" + :toggle-expanded="toggleExpanded" + + :simple="simple" + :toggle-thread-display="toggleThreadDisplay" + :thread-display-status="threadDisplayStatus" + :show-thread-recursively="showThreadRecursively" + :total-reply-count="totalReplyCount" + :total-reply-depth="totalReplyDepth" + :status-content-properties="statusContentProperties" + :set-status-content-property="setStatusContentProperty" + :toggle-status-content-property="toggleStatusContentProperty" + :dive="dive" + /> + </div> + <div + v-if="currentReplies.length && !threadShowing" + class="thread-tree-replies thread-tree-replies-hidden" + > + <i18n-t + v-if="simple" + scope="global" + tag="button" + keypath="status.thread_follow_with_icon" + class="button-unstyled -link thread-tree-show-replies-button" + @click.prevent="dive(status.id)" + > + <template #icon> + <FAIcon + icon="angle-double-right" + /> + </template> + <template #text> + <span> + {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }} + </span> + </template> + </i18n-t> + <i18n-t + v-else + scope="global" + tag="button" + keypath="status.thread_show_full_with_icon" + class="button-unstyled -link thread-tree-show-replies-button" + @click.prevent="showThreadRecursively(status.id)" + > + <template #icon> + <FAIcon + icon="angle-double-down" + /> + </template> + <template #text> + <span> + {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }} + </span> + </template> + </i18n-t> + </div> + </article> +</template> + +<script src="./thread_tree.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +.thread-tree-replies { + margin-left: var(--status-margin, $status-margin); + border-left: 2px solid var(--border, $fallback--border); +} + +.thread-tree-replies-hidden { + padding: var(--status-margin, $status-margin); + /* Make the button stretch along the whole row */ + display: flex; + align-items: stretch; + flex-direction: column; +} +</style> diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue index 55a2dd94..b5f49515 100644 --- a/src/components/timeago/timeago.vue +++ b/src/components/timeago/timeago.vue @@ -3,7 +3,7 @@ :datetime="time" :title="localeDateString" > - {{ $t(relativeTime.key, [relativeTime.num]) }} + {{ relativeTimeString }} </time> </template> @@ -13,7 +13,7 @@ import localeService from 'src/services/locale/locale.service.js' export default { name: 'Timeago', - props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'], + props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'], data () { return { relativeTime: { key: 'time.now', num: 0 }, @@ -26,12 +26,29 @@ export default { return typeof this.time === 'string' ? new Date(Date.parse(this.time)).toLocaleString(browserLocale) : this.time.toLocaleString(browserLocale) + }, + relativeTimeString () { + const timeString = this.$i18n.tc(this.relativeTime.key, this.relativeTime.num, [this.relativeTime.num]) + + if (typeof this.templateKey === 'string' && this.relativeTime.key !== 'time.now') { + return this.$i18n.t(this.templateKey, [timeString]) + } + + return timeString + } + }, + watch: { + time (newVal, oldVal) { + if (oldVal !== newVal) { + clearTimeout(this.interval) + this.refreshRelativeTimeObject() + } } }, created () { this.refreshRelativeTimeObject() }, - destroyed () { + unmounted () { clearTimeout(this.interval) }, methods: { diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 44f749c3..b7414610 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,44 +1,40 @@ import Status from '../status/status.vue' +import { mapState } from 'vuex' 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 TimelineQuickSettings from './timeline_quick_settings.vue' +import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue' +import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' import { debounce, throttle, keyBy } from 'lodash' import { library } from '@fortawesome/fontawesome-svg-core' -import { faCircleNotch, faCog } from '@fortawesome/free-solid-svg-icons' +import { faCircleNotch, faCirclePlus, faCog, faMinus, faArrowUp, faCheck } from '@fortawesome/free-solid-svg-icons' library.add( faCircleNotch, - faCog + faCog, + faMinus, + faArrowUp, + faCirclePlus, + faCheck ) -export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => { - const ids = [] - if (pinnedStatusIds && pinnedStatusIds.length > 0) { - for (let status of statuses) { - if (!pinnedStatusIds.includes(status.id)) { - break - } - ids.push(status.id) - } - } - return ids -} - const Timeline = { props: [ 'timeline', 'timelineName', 'title', 'userId', + 'listId', 'tag', 'embedded', 'count', 'pinnedStatusIds', - 'inProfile' + 'inProfile', + 'footerSlipgate' // reference to an element where we should put our footer ], data () { return { + showScrollTop: false, paused: false, unfocused: false, bottomedOut: false, @@ -50,9 +46,16 @@ const Timeline = { Status, Conversation, TimelineMenu, - TimelineQuickSettings + QuickFilterSettings, + QuickViewSettings }, computed: { + filteredVisibleStatuses () { + return this.timeline.visibleStatuses.filter(status => this.timelineName !== 'user' || (status.id >= this.timeline.minId && status.id <= this.timeline.maxId)) + }, + filteredPinnedStatusIds () { + return (this.pinnedStatusIds || []).filter(statusId => this.timeline.statusesObject[statusId]) + }, newStatusCount () { return this.timeline.newStatusCount }, @@ -66,35 +69,41 @@ const Timeline = { return `${this.$t('timeline.show_new')} (${this.newStatusCount})` } }, + mobileLoadButtonString () { + if (this.timeline.flushMarker !== 0) { + return '+' + } else { + return this.newStatusCount > 99 ? 'â' : this.newStatusCount + } + }, classes () { - let rootClasses = !this.embedded ? ['panel', 'panel-default'] : [] + let rootClasses = !this.embedded ? ['panel', 'panel-default'] : ['-nonpanel'] if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention']) return { root: rootClasses, - header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []), + header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading', '-sticky'] : []), body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []), footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : []) } }, // id map of statuses which need to be hidden in the main list due to pinning logic - excludedStatusIdsObject () { - const ids = getExcludedStatusIdsByPinning(this.timeline.visibleStatuses, this.pinnedStatusIds) - // Convert id array to object - return keyBy(ids) - }, pinnedStatusIdsObject () { return keyBy(this.pinnedStatusIds) }, statusesToDisplay () { const amount = this.timeline.visibleStatuses.length const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80)) - const min = Math.max(0, this.virtualScrollIndex - statusesPerSide) - const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide) + const nonPinnedIndex = this.virtualScrollIndex - this.filteredPinnedStatusIds.length + const min = Math.max(0, nonPinnedIndex - statusesPerSide) + const max = Math.min(amount, nonPinnedIndex + statusesPerSide) return this.timeline.visibleStatuses.slice(min, max).map(_ => _.id) }, virtualScrollingEnabled () { return this.$store.getters.mergedConfig.virtualScrolling - } + }, + ...mapState({ + mobileLayout: state => state.interface.layoutType === 'mobile' + }) }, created () { const store = this.$store @@ -111,6 +120,7 @@ const Timeline = { timeline: this.timelineName, showImmediately, userId: this.userId, + listId: this.listId, tag: this.tag }) }, @@ -122,13 +132,16 @@ const Timeline = { window.addEventListener('keydown', this.handleShortKey) setTimeout(this.determineVisibleStatuses, 250) }, - destroyed () { + unmounted () { window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('keydown', this.handleShortKey) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { + scrollToTop () { + window.scrollTo({ top: this.$el.offsetTop }) + }, stopBlockingClicks: debounce(function () { this.blockingClicks = false }, 1000), @@ -153,6 +166,7 @@ const Timeline = { this.$store.commit('showNewStatuses', { timeline: this.timelineName }) this.paused = false } + window.scrollTo({ top: 0 }) }, fetchOlderStatuses: throttle(function () { const store = this.$store @@ -165,6 +179,7 @@ const Timeline = { older: true, showImmediately: true, userId: this.userId, + listId: this.listId, tag: this.tag }).then(({ statuses }) => { if (statuses && statuses.length === 0) { @@ -226,6 +241,7 @@ const Timeline = { } }, handleScroll: throttle(function (e) { + this.showScrollTop = this.$el.offsetTop < window.scrollY this.determineVisibleStatuses() this.scrollLoad(e) }, 200), diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss index 2c5a67e2..c6fb1ca7 100644 --- a/src/components/timeline/timeline.scss +++ b/src/components/timeline/timeline.scss @@ -1,31 +1,58 @@ @import '../../_variables.scss'; .Timeline { - .loadmore-text { - opacity: 1; + .alert-dot { + border-radius: 100%; + height: 8px; + width: 8px; + position: absolute; + left: calc(50% - 4px); + top: calc(50% - 4px); + margin-left: 6px; + margin-top: -6px; + background-color: var(--badgeNeutral); + } + + .alert-badge { + font-size: 0.75em; + line-height: 1; + text-align: right; + border-radius: var(--tooltipRadius); + position: absolute; + left: calc(50% - 0.5em); + top: calc(50% - 0.4em); + padding: 0.2em; + margin-left: 0.7em; + margin-top: -1em; + background-color: var(--badgeNeutral); + color: var(--badgeNeutralText); + } + + .loadmore-button { + position: relative; } &.-blocked { cursor: progress; } - .timeline-heading { - max-width: 100%; - flex-wrap: nowrap; - align-items: center; - position: relative; + .conversation-heading { + top: calc(var(--__panel-heading-height) * var(--currentPanelStack, 2)); + z-index: 2; + } - .loadmore-button { - flex-shrink: 0; + &.-nonpanel { + .timeline-heading { + text-align: center; + line-height: 2.75em; + padding: 0 0.5em; } - .loadmore-text { - flex-shrink: 0; - line-height: 1em; + .timeline-heading { + .button-default, .alert { + line-height: 2em; + width: 100%; + } } } - - .timeline-footer { - border: none; - } } diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 767428f0..2279f21a 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -1,86 +1,153 @@ <template> - <div :class="[classes.root, 'Timeline']"> + <div :class="['Timeline', classes.root]"> <div :class="classes.header"> - <TimelineMenu v-if="!embedded" /> - <button - v-if="showLoadButton" - class="button-default loadmore-button" - @click.prevent="showNewStatuses" - > - {{ loadButtonString }} - </button> + <TimelineMenu + v-if="!embedded" + :timeline-name="timelineName" + /> <div - v-else - class="loadmore-text faint" - @click.prevent + v-if="showScrollTop && !embedded" + class="rightside-button" > - {{ $t('timeline.up_to_date') }} + <button + class="button-unstyled scroll-to-top-button" + type="button" + :title="$t('general.scroll_to_top')" + @click="scrollToTop" + > + <FALayers class="fa-scale-110 fa-old-padding-layer"> + <FAIcon icon="arrow-up" /> + <FAIcon + icon="minus" + transform="up-7" + /> + </FALayers> + </button> </div> - <TimelineQuickSettings v-if="!embedded" /> + <template v-if="mobileLayout && !embedded"> + <div + v-if="showLoadButton" + class="rightside-button" + > + <button + class="button-unstyled loadmore-button" + :title="loadButtonString" + @click.prevent="showNewStatuses" + > + <FAIcon + fixed-width + icon="circle-plus" + /> + <div class="alert-badge"> + {{ mobileLoadButtonString }} + </div> + </button> + </div> + <div + v-else-if="!embedded" + class="loadmore-text faint veryfaint rightside-icon" + :title="$t('timeline.up_to_date')" + :aria-disabled="true" + @click.prevent + > + <FAIcon + fixed-width + icon="check" + /> + </div> + </template> + <template v-else> + <button + v-if="showLoadButton" + class="button-default loadmore-button" + @click.prevent="showNewStatuses" + > + {{ loadButtonString }} + </button> + <div + v-else-if="!embedded" + class="loadmore-text faint" + @click.prevent + > + {{ $t('timeline.up_to_date') }} + </div> + </template> + <QuickFilterSettings + v-if="!embedded" + class="rightside-button" + /> + <QuickViewSettings + v-if="!embedded" + class="rightside-button" + /> </div> <div :class="classes.body"> <div ref="timeline" class="timeline" + role="feed" > - <template v-for="statusId in pinnedStatusIds"> - <conversation - v-if="timeline.statusesObject[statusId]" - :key="statusId + '-pinned'" - class="status-fadein" - :status-id="statusId" - :collapsable="true" - :pinned-status-ids-object="pinnedStatusIdsObject" - :in-profile="inProfile" - :profile-user-id="userId" - /> - </template> - <template v-for="status in timeline.visibleStatuses"> - <conversation - v-if="!excludedStatusIdsObject[status.id]" - :key="status.id" - class="status-fadein" - :status-id="status.id" - :collapsable="true" - :in-profile="inProfile" - :profile-user-id="userId" - :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)" - /> - </template> + <conversation + v-for="statusId in filteredPinnedStatusIds" + :key="statusId + '-pinned'" + role="listitem" + class="status-fadein" + :status-id="statusId" + :collapsable="true" + :pinned-status-ids-object="pinnedStatusIdsObject" + :in-profile="inProfile" + :profile-user-id="userId" + /> + <conversation + v-for="status in filteredVisibleStatuses" + :key="status.id" + role="listitem" + class="status-fadein" + :status-id="status.id" + :collapsable="true" + :in-profile="inProfile" + :profile-user-id="userId" + :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)" + /> </div> </div> <div :class="classes.footer"> - <div - v-if="count===0" - class="new-status-notification text-center faint" - > - {{ $t('timeline.no_statuses') }} - </div> - <div - v-else-if="bottomedOut" - class="new-status-notification text-center faint" - > - {{ $t('timeline.no_more_statuses') }} - </div> - <button - v-else-if="!timeline.loading" - class="button-unstyled -link -fullwidth" - @click.prevent="fetchOlderStatuses()" + <teleport + :to="footerSlipgate" + :disabled="!embedded || !footerSlipgate" > - <div class="new-status-notification text-center"> - {{ $t('timeline.load_older') }} + <div + v-if="count===0" + class="new-status-notification text-center faint" + > + {{ $t('timeline.no_statuses') }} </div> - </button> - <div - v-else - class="new-status-notification text-center" - > - <FAIcon - icon="circle-notch" - spin - size="lg" - /> - </div> + <div + v-else-if="bottomedOut" + class="new-status-notification text-center faint" + > + {{ $t('timeline.no_more_statuses') }} + </div> + <button + v-else-if="!timeline.loading" + class="button-unstyled -link" + @click.prevent="fetchOlderStatuses()" + > + <div class="new-status-notification text-center"> + {{ $t('timeline.load_older') }} + </div> + </button> + <div + v-else + class="new-status-notification text-center" + > + <FAIcon + icon="circle-notch" + spin + size="lg" + /> + </div> + </teleport> </div> </div> </template> diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index bab51e75..5a2a86c2 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -1,6 +1,10 @@ import Popover from '../popover/popover.vue' -import TimelineMenuContent from './timeline_menu_content.vue' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import { mapState } from 'vuex' +import { ListsMenuContent } from '../lists_menu/lists_menu_content.vue' import { library } from '@fortawesome/fontawesome-svg-core' +import { TIMELINES } from 'src/components/navigation/navigation.js' +import { filterNavigation } from 'src/components/navigation/filter.js' import { faChevronDown } from '@fortawesome/free-solid-svg-icons' @@ -11,9 +15,9 @@ library.add(faChevronDown) // because nav panel benefits from the same information. export const timelineNames = () => { return { - 'friends': 'nav.home_timeline', - 'bookmarks': 'nav.bookmarks', - 'dms': 'nav.dms', + friends: 'nav.home_timeline', + bookmarks: 'nav.bookmarks', + dms: 'nav.dms', 'public-timeline': 'nav.public_tl', 'public-external-timeline': 'nav.twkn' } @@ -22,7 +26,8 @@ export const timelineNames = () => { const TimelineMenu = { components: { Popover, - TimelineMenuContent + NavigationEntry, + ListsMenuContent }, data () { return { @@ -34,6 +39,28 @@ const TimelineMenu = { this.$store.dispatch('setLastTimeline', this.$route.name) } }, + computed: { + useListsMenu () { + const route = this.$route.name + return route === 'lists-timeline' + }, + ...mapState({ + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }), + timelinesList () { + return filterNavigation( + Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })), + { + hasChats: this.pleromaChatMessagesAvailable, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser + } + ) + } + }, methods: { openMenu () { // $nextTick is too fast, animation won't play back but @@ -58,6 +85,9 @@ const TimelineMenu = { if (route === 'tag-timeline') { return '#' + this.$route.params.tag } + if (route === 'lists-timeline') { + return this.$store.getters.findListTitle(this.$route.params.id) + } const i18nkey = timelineNames()[this.$route.name] return i18nkey ? this.$t(i18nkey) : route } diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue index 8f14093f..e7250282 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -3,19 +3,29 @@ trigger="click" class="TimelineMenu" :class="{ 'open': isOpen }" - :margin="{ left: -15, right: -200 }" :bound-to="{ x: 'container' }" - popover-class="timeline-menu-popover-wrap" + bound-to-selector=".Timeline" + popover-class="timeline-menu-popover popover-default" @show="openMenu" @close="() => isOpen = false" > - <template v-slot:content> - <div class="timeline-menu-popover popover-default"> - <TimelineMenuContent /> - </div> + <template #content> + <ListsMenuContent + v-if="useListsMenu" + :show-pin="false" + class="timelines" + /> + <ul v-else> + <NavigationEntry + v-for="item in timelinesList" + :key="item.name" + :show-pin="false" + :item="item" + /> + </ul> </template> - <template v-slot:trigger> - <button class="button-unstyled title timeline-menu-title"> + <template #trigger> + <span class="button-unstyled title timeline-menu-title"> <span class="timeline-title">{{ timelineName() }}</span> <span> <FAIcon @@ -27,38 +37,22 @@ class="click-blocker" @click="blockOpen" /> - </button> + </span> </template> </Popover> </template> -<script src="./timeline_menu.js" ></script> +<script src="./timeline_menu.js"></script> <style lang="scss"> @import '../../_variables.scss'; .TimelineMenu { - 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; + .popover-trigger-button { + vertical-align: bottom; } .panel::after { @@ -66,10 +60,6 @@ border-top-left-radius: 0; } - &.open .timeline-menu-popover { - transform: translateY(0); - } - .timeline-menu-title { margin: 0; cursor: pointer; @@ -104,6 +94,16 @@ box-shadow: var(--popoverShadow); } +} + +.timeline-menu-popover { + min-width: 24rem; + max-width: 100vw; + margin-top: 0.6rem; + font-size: 1rem; + border-top-right-radius: 0; + border-top-left-radius: 0; + ul { list-style: none; margin: 0; @@ -130,7 +130,9 @@ a { display: block; - padding: 0.6em 0.65em; + padding: 0 0.65em; + height: 3.5em; + line-height: 3.5em; &:hover { background-color: $fallback--lightBg; @@ -148,8 +150,7 @@ background-color: $fallback--lightBg; background-color: var(--selectedMenu, $fallback--lightBg); color: $fallback--text; - color: var(--selectedMenuText, $fallback--text); - --faint: var(--selectedMenuFaintText, $fallback--faint); + 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); diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js deleted file mode 100644 index 671570dd..00000000 --- a/src/components/timeline_menu/timeline_menu_content.js +++ /dev/null @@ -1,29 +0,0 @@ -import { mapState } from 'vuex' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faUsers, - faGlobe, - faBookmark, - faEnvelope, - faHome -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faUsers, - faGlobe, - faBookmark, - faEnvelope, - faHome -) - -const TimelineMenuContent = { - computed: { - ...mapState({ - currentUser: state => state.users.currentUser, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) - } -} - -export default TimelineMenuContent diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue deleted file mode 100644 index bed1b679..00000000 --- a/src/components/timeline_menu/timeline_menu_content.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> - <ul> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'friends' }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="home" - />{{ $t("nav.home_timeline") }} - </router-link> - </li> - <li v-if="currentUser || !privateMode"> - <router-link - class="menu-item" - :to="{ name: 'public-timeline' }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="users" - />{{ $t("nav.public_tl") }} - </router-link> - </li> - <li v-if="federating && (currentUser || !privateMode)"> - <router-link - class="menu-item" - :to="{ name: 'public-external-timeline' }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="globe" - />{{ $t("nav.twkn") }} - </router-link> - </li> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'bookmarks'}" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="bookmark" - />{{ $t("nav.bookmarks") }} - </router-link> - </li> - <li v-if="currentUser"> - <router-link - class="menu-item" - :to="{ name: 'dms', params: { username: currentUser.screen_name } }" - > - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="envelope" - />{{ $t("nav.dms") }} - </router-link> - </li> - </ul> -</template> - -<script src="./timeline_menu_content.js" ></script> diff --git a/src/components/unicode_domain_indicator/unicode_domain_indicator.vue b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue new file mode 100644 index 00000000..8f35245f --- /dev/null +++ b/src/components/unicode_domain_indicator/unicode_domain_indicator.vue @@ -0,0 +1,26 @@ +<template> + <FAIcon + v-if="user && user.screen_name_ui_contains_non_ascii" + icon="code" + :title="$t('unicode_domain_indicator.tooltip')" + /> +</template> + +<script> +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faCode +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faCode +) + +const UnicodeDomainIndicator = { + props: { + user: Object + } +} + +export default UnicodeDomainIndicator +</script> diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js new file mode 100644 index 00000000..ddf379f5 --- /dev/null +++ b/src/components/update_notification/update_notification.js @@ -0,0 +1,69 @@ +import Modal from 'src/components/modal/modal.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import pleromaTan from 'src/assets/pleromatan_apology.png' +import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png' +import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png' +import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png' + +import { + faTimes +} from '@fortawesome/free-solid-svg-icons' +library.add( + faTimes +) + +export const CURRENT_UPDATE_COUNTER = 1 + +const UpdateNotification = { + data () { + return { + showingImage: false, + pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox, + showingMore: false + } + }, + components: { + Modal + }, + computed: { + pleromaTanStyles () { + const mask = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask + return { + 'shape-outside': 'url(' + mask + ')' + } + }, + shouldShow () { + return !this.$store.state.instance.disableUpdateNotification && + this.$store.state.users.currentUser && + this.$store.state.serverSideStorage.flagStorage.updateCounter < CURRENT_UPDATE_COUNTER && + !this.$store.state.serverSideStorage.prefsStorage.simple.dontShowUpdateNotifs + } + }, + methods: { + toggleShow () { + this.showingMore = !this.showingMore + }, + neverShowAgain () { + this.toggleShow() + this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER }) + this.$store.commit('setPreference', { path: 'simple.dontShowUpdateNotifs', value: true }) + this.$store.dispatch('pushServerSideStorage') + }, + dismiss () { + this.$store.commit('setFlag', { flag: 'updateCounter', value: CURRENT_UPDATE_COUNTER }) + this.$store.dispatch('pushServerSideStorage') + } + }, + mounted () { + this.contentHeightNoImage = this.$refs.animatedText.scrollHeight + + // Workaround to get the text height only after mask loaded. A bit hacky. + const newImg = new Image() + newImg.onload = () => { + setTimeout(() => { this.showingImage = true }, 100) + } + newImg.src = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask + } +} + +export default UpdateNotification diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss new file mode 100644 index 00000000..ce8129d0 --- /dev/null +++ b/src/components/update_notification/update_notification.scss @@ -0,0 +1,113 @@ +@import 'src/_variables.scss'; +.UpdateNotification { + overflow: hidden; +} + +.UpdateNotificationModal { + --__top-fringe: 15em; // how much pleroma-tan should stick her head above + --__bottom-fringe: 80em; // just reserving as much as we can, number is mostly irrelevant + --__right-fringe: 8em; + + font-size: 15px; + position: relative; + transition: transform; + transition-timing-function: ease-in-out; + transition-duration: 500ms; + + .text { + max-width: 40em; + padding-left: 1em; + } + + @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. + */ + width: 100vw; + } + + @media all and (max-height: 600px) { + display: none; + } + + .content { + overflow: hidden; + margin-top: calc(-1 * var(--__top-fringe)); + margin-bottom: calc(-1 * var(--__bottom-fringe)); + margin-right: calc(-1 * var(--__right-fringe)); + + &.-noImage { + .text { + padding-right: var(--__right-fringe); + } + } + } + + .panel-body { + border-width: 0 0 1px 0; + border-style: solid; + border-color: var(--border, $fallback--border); + } + + .panel-footer { + z-index: 22; + position: relative; + border-width: 0; + grid-template-columns: auto; + } + + .pleroma-tan { + object-fit: cover; + object-position: top; + transition: position, left, right, top, bottom, max-width, max-height; + transition-timing-function: ease-in-out; + transition-duration: 500ms; + width: 25em; + float: right; + z-index: 20; + position: relative; + shape-margin: 0.5em; + filter: drop-shadow(5px 5px 10px rgba(0,0,0,0.5)); + pointer-events: none; + } + + .spacer-top { + min-height: var(--__top-fringe); + } + + .spacer-bottom { + min-height: var(--__bottom-fringe); + } + + .extra-info-group { + transition: max-height, padding, height; + transition-timing-function: ease-in; + transition-duration: 700ms; + max-height: 70vh; + mask: + linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat, + linear-gradient(to top, white, white); + } + + .art-credit { + text-align: right; + } + + &.-peek { + /* Explanation: + * 100vh - 100% = Distance between modal's top+bottom boundaries and screen + * (100vh - 100%) / 2 = Distance between bottom (or top) boundary and screen + */ + transform: translateY(calc(((100vh - 100%) / 2))); + + .pleroma-tan { + float: right; + z-index: 10; + shape-image-threshold: 0.7; + } + + .extra-info-group { + max-height: 0; + } + } +} diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue new file mode 100644 index 00000000..78e70a74 --- /dev/null +++ b/src/components/update_notification/update_notification.vue @@ -0,0 +1,103 @@ +<template> + <Modal + :is-open="!!shouldShow" + class="UpdateNotification" + :no-background="true" + > + <div + class="UpdateNotificationModal panel" + :class="{ '-peek': !showingMore }" + > + <div class="panel-heading"> + <span class="title"> + {{ $t('update.big_update_title') }} + </span> + </div> + <div class="panel-body"> + <div + class="content" + :class="{ '-noImage': !showingImage }" + > + <img + v-if="showingImage" + class="pleroma-tan" + :src="pleromaTanVariant" + :style="pleromaTanStyles" + > + <div class="spacer-top" /> + <div class="text"> + <p> + {{ $t('update.big_update_content') }} + </p> + <div + ref="animatedText" + class="extra-info-group" + > + <i18n-t + keypath="update.update_bugs" + tag="p" + > + <template #pleromaGitlab> + <a + target="_blank" + href="https://git.pleroma.social/" + >{{ $t('update.update_bugs_gitlab') }}</a> + </template> + </i18n-t> + <i18n-t + keypath="update.update_changelog" + tag="p" + > + <template #theFullChangelog> + <a + target="_blank" + href="https://pleroma.social/announcements/" + >{{ $t('update.update_changelog_here') }}</a> + </template> + </i18n-t> + <p class="art-credit"> + <i18n-t + keypath="update.art_by" + tag="small" + > + <template #linkToArtist> + <a + target="_blank" + href="https://post.ebin.club/users/pipivovott" + >pipivovott</a> + </template> + </i18n-t> + </p> + </div> + </div> + <div class="spacer-bottom" /> + </div> + </div> + <div class="panel-footer"> + <button + class="button-default" + @click.prevent="neverShowAgain" + > + {{ $t("general.never_show_again") }} + </button> + <button + v-if="!showingMore" + class="button-default" + @click.prevent="toggleShow" + > + {{ $t("general.show_more") }} + </button> + <button + class="button-default" + @click.prevent="dismiss" + > + {{ $t("general.dismiss") }} + </button> + </div> + </div> + </Modal> +</template> + +<script src="./update_notification.js"></script> + +<style src="./update_notification.scss" lang="scss"></style> diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index 94653004..33d9a258 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -1,10 +1,21 @@ import StillImage from '../still-image/still-image.vue' +import { library } from '@fortawesome/fontawesome-svg-core' + +import { + faRobot +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faRobot +) + const UserAvatar = { props: [ 'user', 'betterShadow', - 'compact' + 'compact', + 'bot' ], data () { return { diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 4040e263..f4d294df 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -1,18 +1,28 @@ <template> - <StillImage - v-if="user" + <span class="Avatar" - :alt="user.screen_name_ui" - :title="user.screen_name_ui" - :src="imgSrc(user.profile_image_url_original)" - :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" - :image-load-error="imageLoadError" - /> - <div - v-else - class="Avatar -placeholder" - :class="{ 'avatar-compact': compact }" - /> + :class="{ '-compact': compact }" + > + <StillImage + v-if="user" + class="avatar" + :alt="user.screen_name_ui" + :title="user.screen_name_ui" + :src="imgSrc(user.profile_image_url_original)" + :image-load-error="imageLoadError" + :class="{ '-compact': compact, '-better-shadow': betterShadow }" + /> + <div + v-else + class="avatar -placeholder" + :class="{ '-compact': compact }" + /> + <FAIcon + v-if="bot" + icon="robot" + class="bot-indicator" + /> + </span> </template> <script src="./user_avatar.js"></script> @@ -25,36 +35,60 @@ --_avatarShadowInset: var(--avatarStatusShadowInset); --_still-image-label-visibility: hidden; + display: inline-block; + position: relative; width: 48px; height: 48px; - box-shadow: var(--_avatarShadowBox); - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - img { + &.-compact { + width: 32px; + height: 32px; + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .avatar { width: 100%; height: 100%; - } + box-shadow: var(--_avatarShadowBox); + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); - &.better-shadow { - box-shadow: var(--_avatarShadowInset); - filter: var(--_avatarShadowFilter); - } + &.-better-shadow { + box-shadow: var(--_avatarShadowInset); + filter: var(--_avatarShadowFilter); + } + + &.-animated::before { + display: none; + } - &.animated::before { - display: none; + &.-compact { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + &.-placeholder { + background-color: $fallback--fg; + background-color: var(--fg, $fallback--fg); + } } - &.avatar-compact { - width: 32px; - height: 32px; - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + img { + width: 100%; + height: 100%; } - &.-placeholder { - background-color: $fallback--fg; - background-color: var(--fg, $fallback--fg); + .bot-indicator { + position: absolute; + bottom: 0; + right: 0; + margin: -0.2em; + padding: 0.2em; + background: rgba(127, 127, 127, 0.5); + color: #fff; + border-radius: var(--tooltipRadius); } + } </style> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index cd8ca420..67879307 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -4,7 +4,9 @@ import ProgressButton from '../progress_button/progress_button.vue' import FollowButton from '../follow_button/follow_button.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue' import AccountActions from '../account_actions/account_actions.vue' +import UserNote from '../user_note/user_note.vue' import Select from '../select/select.vue' +import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters } from 'vuex' @@ -14,7 +16,9 @@ import { faRss, faSearchPlus, faExternalLinkAlt, - faEdit + faEdit, + faTimes, + faExpandAlt } from '@fortawesome/free-solid-svg-icons' library.add( @@ -22,12 +26,22 @@ library.add( faBell, faSearchPlus, faExternalLinkAlt, - faEdit + faEdit, + faTimes, + faExpandAlt ) export default { props: [ - 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar' + 'userId', + 'switcher', + 'selected', + 'hideBio', + 'rounded', + 'bordered', + 'avatarAction', // default - open profile, 'zoom' - zoom, function - call function + 'onClose', + 'hasNoteEditor' ], data () { return { @@ -47,15 +61,16 @@ export default { }, classes () { return [{ - 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius - 'user-card-rounded': this.rounded === true, // set border-radius for all sides - 'user-card-bordered': this.bordered === true // set border for all sides + '-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius + '-rounded': this.rounded === true, // set border-radius for all sides + '-bordered': this.bordered === true, // set border for all sides + '-popover': !!this.onClose // set popover rounding }] }, style () { return { backgroundImage: [ - `linear-gradient(to bottom, var(--profileTint), var(--profileTint))`, + 'linear-gradient(to bottom, var(--profileTint), var(--profileTint))', `url(${this.user.cover_photo})` ].join(', ') } @@ -112,6 +127,16 @@ export default { hideFollowersCount () { return this.isOtherUser && this.user.hide_followers_count }, + showModerationMenu () { + const privileges = this.loggedIn.privileges + return this.loggedIn.role === 'admin' || privileges.includes('users_manage_activation_state') || privileges.includes('users_delete') || privileges.includes('users_manage_tags') + }, + hasNote () { + return this.relationship.note + }, + supportsNote () { + return 'note' in this.relationship + }, ...mapGetters(['mergedConfig']) }, components: { @@ -122,7 +147,9 @@ export default { ProgressButton, FollowButton, Select, - RichContent + RichContent, + UserLink, + UserNote }, methods: { muteUser () { @@ -166,10 +193,16 @@ export default { mimetype: 'image' } this.$store.dispatch('setMedia', [attachment]) - this.$store.dispatch('setCurrent', attachment) + this.$store.dispatch('setCurrentMedia', attachment) }, mentionUser () { this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user }) + }, + onAvatarClickHandler (e) { + if (this.onAvatarClick) { + e.preventDefault() + this.onAvatarClick() + } } } } diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss new file mode 100644 index 00000000..cdb8cb57 --- /dev/null +++ b/src/components/user_card/user_card.scss @@ -0,0 +1,352 @@ +@import '../../_variables.scss'; + +.user-card { + position: relative; + z-index: 1; + + &:hover { + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; + } + + .panel-heading { + padding: .5em 0; + text-align: center; + box-shadow: none; + background: transparent; + flex-direction: column; + align-items: stretch; + // create new stacking context + position: relative; + } + + .panel-body { + word-wrap: break-word; + border-bottom-right-radius: inherit; + border-bottom-left-radius: inherit; + // create new stacking context + position: relative; + } + + .background-image { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + mask: linear-gradient(to top, white, transparent) bottom no-repeat, + linear-gradient(to top, white, white); + // Autoprefixer seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + background-size: cover; + mask-size: 100% 60%; + border-top-left-radius: calc(var(--__roundnessTop, --panelRadius) - 1px); + border-top-right-radius: calc(var(--__roundnessTop, --panelRadius) - 1px); + border-bottom-left-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px); + border-bottom-right-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px); + background-color: var(--profileBg); + z-index: -2; + + &.hide-bio { + mask-size: 100% 40px; + } + } + + &-bio { + text-align: center; + display: block; + line-height: 1.3; + padding: 1em; + margin: 0; + + a { + color: $fallback--link; + color: var(--postLink, $fallback--link); + } + + img { + object-fit: contain; + vertical-align: middle; + max-width: 100%; + max-height: 400px; + } + } + + &.-rounded-t { + border-top-left-radius: $fallback--panelRadius; + border-top-left-radius: var(--panelRadius, $fallback--panelRadius); + border-top-right-radius: $fallback--panelRadius; + border-top-right-radius: var(--panelRadius, $fallback--panelRadius); + + --__roundnessTop: var(--panelRadius); + --__roundnessBottom: 0; + } + + &.-rounded { + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + + --__roundnessTop: var(--panelRadius); + --__roundnessBottom: var(--panelRadius); + } + + &.-popover { + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + + --__roundnessTop: var(--tooltipRadius); + --__roundnessBottom: var(--tooltipRadius); + } + + &.-bordered { + border-width: 1px; + border-style: solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + } +} + +.user-info { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + padding: 0 26px; + + a { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + + &:hover { + color: var(--icon); + } + } + + .container { + min-width: 0; + padding: 16px 0 6px; + display: flex; + align-items: flex-start; + max-height: 56px; + + > * { + min-width: 0; + } + + > a { + vertical-align: middle; + display: flex; + } + + .Avatar { + --_avatarShadowBox: var(--avatarShadow); + --_avatarShadowFilter: var(--avatarShadowFilter); + --_avatarShadowInset: var(--avatarShadowInset); + + width: 56px; + height: 56px; + object-fit: cover; + } + } + + &-avatar { + position: relative; + cursor: pointer; + + &.-overlay { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.3); + display: flex; + justify-content: center; + align-items: center; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + opacity: 0; + transition: opacity .2s ease; + + svg { + color: #FFF; + } + } + + &:hover &.-overlay { + opacity: 1; + } + } + + .external-link-button, .edit-profile-button { + cursor: pointer; + width: 2.5em; + text-align: center; + margin: -0.5em 0; + padding: 0.5em 0; + + &:not(:hover) .icon { + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + } + } + + .user-summary { + display: block; + margin-left: 0.6em; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 0; + // This is so that text doesn't get overlapped by avatar's shadow if it has + // big one + z-index: 1; + line-height: 2em; + + --emoji-size: 1.7em; + + .top-line, + .bottom-line { + display: flex; + } + } + + .user-name { + text-overflow: ellipsis; + overflow: hidden; + flex: 1 1 auto; + margin-right: 1em; + font-size: 1.1em; + } + + .bottom-line { + font-weight: light; + font-size: 1.1em; + align-items: baseline; + + .lock-icon { + margin-left: 0.5em; + } + + .user-screen-name { + min-width: 1px; + flex: 0 1 auto; + text-overflow: ellipsis; + overflow: hidden; + } + + .dailyAvg { + min-width: 1px; + flex: 0 0 auto; + margin-left: 1em; + font-size: 0.7em; + color: $fallback--text; + color: var(--text, $fallback--text); + } + + .user-role { + flex: none; + color: $fallback--text; + color: var(--alertNeutralText, $fallback--text); + background-color: $fallback--fg; + background-color: var(--alertNeutral, $fallback--fg); + } + } + + .user-meta { + margin-bottom: .15em; + display: flex; + align-items: baseline; + line-height: 22px; + flex-wrap: wrap; + + .following { + flex: 1 0 auto; + margin: 0; + margin-bottom: .25em; + text-align: left; + } + + .highlighter { + flex: 0 1 auto; + display: flex; + flex-wrap: wrap; + margin-right: -.5em; + align-self: start; + + .userHighlightCl { + padding: 2px 10px; + flex: 1 0 auto; + } + + .userHighlightSel { + padding-top: 0; + padding-bottom: 0; + flex: 1 0 auto; + } + + .userHighlightText { + width: 70px; + flex: 1 0 auto; + } + + .userHighlightCl, + .userHighlightText, + .userHighlightSel { + vertical-align: top; + margin-right: .5em; + margin-bottom: .25em; + } + } + } + .user-interactions { + position: relative; + display: flex; + flex-flow: row wrap; + margin-right: -.75em; + + > * { + margin: 0 .75em .6em 0; + white-space: nowrap; + min-width: 95px; + } + + button { + margin: 0; + } + } + + .user-note { + margin: 0 .75em .6em 0; + } +} + +.sidebar .edit-profile-button { + display: none; +} + +.user-counts { + display: flex; + line-height:16px; + padding: .5em 1.5em 0em 1.5em; + text-align: center; + justify-content: space-between; + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); + flex-wrap: wrap; +} + +.user-count { + flex: 1 0 auto; + padding: .5em 0 .5em 0; + margin: 0 .5em; + + h5 { + font-size:1em; + font-weight: bolder; + margin: 0 0 0.25em; + } + a { + text-decoration: none; + } +} diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 6b69d15a..349c7cb1 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -8,25 +8,32 @@ :style="style" class="background-image" /> - <div class="panel-heading"> + <div :class="onClose ? '' : panel-heading -flexible-height"> <div class="user-info"> <div class="container"> <a - v-if="allowZoomingAvatar" - class="user-info-avatar-link" + v-if="avatarAction === 'zoom'" + class="user-info-avatar -link" @click="zoomAvatar" > <UserAvatar :better-shadow="betterShadow" :user="user" /> - <div class="user-info-avatar-link-overlay"> + <div class="user-info-avatar -link -overlay"> <FAIcon class="fa-scale-110 fa-old-padding" icon="search-plus" /> </div> </a> + <UserAvatar + v-else-if="typeof avatarAction === 'function'" + class="user-info-avatar" + :better-shadow="betterShadow" + :user="user" + @click="avatarAction" + /> <router-link v-else :to="userProfileLink(user)" @@ -38,12 +45,16 @@ </router-link> <div class="user-summary"> <div class="top-line"> - <RichContent - :title="user.name" + <router-link + :to="userProfileLink(user)" class="user-name" - :html="user.name" - :emoji="user.emoji" - /> + > + <RichContent + :title="user.name" + :html="user.name" + :emoji="user.emoji" + /> + </router-link> <button v-if="!isOtherUser && user.is_local" class="button-unstyled edit-profile-button" @@ -72,17 +83,41 @@ :user="user" :relationship="relationship" /> - </div> - <div class="bottom-line"> <router-link - class="user-screen-name" - :title="user.screen_name_ui" + v-if="onClose" :to="userProfileLink(user)" + class="button-unstyled external-link-button" + @click="onClose" > - @{{ user.screen_name_ui }} + <FAIcon + class="icon" + icon="expand-alt" + /> </router-link> + <button + v-if="onClose" + class="button-unstyled external-link-button" + @click="onClose" + > + <FAIcon + class="icon" + icon="times" + /> + </button> + </div> + <div class="bottom-line"> + <user-link + class="user-screen-name" + :user="user" + /> <template v-if="!hideBio"> <span + v-if="user.deactivated" + class="alert user-role" + > + {{ $t('user_card.deactivated') }} + </span> + <span v-if="!!visibleRole" class="alert user-role" > @@ -135,6 +170,7 @@ class="userHighlightCl" type="color" > + {{ ' ' }} <Select :id="'userHighlightSel'+user.id" v-model="userHighlightType" @@ -160,7 +196,10 @@ class="user-interactions" > <div class="btn-group"> - <FollowButton :relationship="relationship" /> + <FollowButton + :relationship="relationship" + :user="user" + /> <template v-if="relationship.following"> <ProgressButton v-if="!relationship.subscribing" @@ -195,6 +234,7 @@ <button v-if="relationship.muting" class="btn button-default btn-block toggled" + :disabled="user.deactivated" @click="unmuteUser" > {{ $t('user_card.muted') }} @@ -202,6 +242,7 @@ <button v-else class="btn button-default btn-block" + :disabled="user.deactivated" @click="muteUser" > {{ $t('user_card.mute') }} @@ -210,13 +251,14 @@ <div> <button class="btn button-default btn-block" + :disabled="user.deactivated" @click="mentionUser" > {{ $t('user_card.mention') }} </button> </div> <ModerationTools - v-if="loggedIn.role === "admin"" + v-if="showModerationMenu" :user="user" /> </div> @@ -226,6 +268,12 @@ > <RemoteFollow :user="user" /> </div> + <UserNote + v-if="loggedIn && isOtherUser && (hasNote || (hasNoteEditor && supportsNote))" + :user="user" + :relationship="relationship" + :editable="hasNoteEditor" + /> </div> </div> <div @@ -263,6 +311,7 @@ class="user-card-bio" :html="user.description_html" :emoji="user.emoji" + :handle-links="true" /> </div> </div> @@ -270,320 +319,4 @@ <script src="./user_card.js"></script> -<style lang="scss"> -@import '../../_variables.scss'; - -.user-card { - position: relative; - - &:hover { - --_still-image-img-visibility: visible; - --_still-image-canvas-visibility: hidden; - --_still-image-label-visibility: hidden; - } - - .panel-heading { - padding: .5em 0; - text-align: center; - box-shadow: none; - background: transparent; - flex-direction: column; - align-items: stretch; - // create new stacking context - position: relative; - } - - .panel-body { - word-wrap: break-word; - border-bottom-right-radius: inherit; - border-bottom-left-radius: inherit; - // create new stacking context - position: relative; - } - - .background-image { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - mask: linear-gradient(to top, white, transparent) bottom no-repeat, - linear-gradient(to top, white, white); - // Autoprefixed seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; - mask-composite: exclude; - background-size: cover; - mask-size: 100% 60%; - border-top-left-radius: calc(var(--panelRadius) - 1px); - border-top-right-radius: calc(var(--panelRadius) - 1px); - background-color: var(--profileBg); - - &.hide-bio { - mask-size: 100% 40px; - } - } - - &-bio { - text-align: center; - display: block; - line-height: 18px; - padding: 1em; - margin: 0; - - a { - color: $fallback--link; - color: var(--postLink, $fallback--link); - } - - img { - object-fit: contain; - vertical-align: middle; - max-width: 100%; - max-height: 400px; - } - } - - // Modifiers - - &-rounded-t { - border-top-left-radius: $fallback--panelRadius; - border-top-left-radius: var(--panelRadius, $fallback--panelRadius); - border-top-right-radius: $fallback--panelRadius; - border-top-right-radius: var(--panelRadius, $fallback--panelRadius); - } - - &-rounded { - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); - } - - &-bordered { - border-width: 1px; - border-style: solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - } -} - -.user-info { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - padding: 0 26px; - - .container { - padding: 16px 0 6px; - display: flex; - align-items: flex-start; - max-height: 56px; - - .Avatar { - --_avatarShadowBox: var(--avatarShadow); - --_avatarShadowFilter: var(--avatarShadowFilter); - --_avatarShadowInset: var(--avatarShadowInset); - - flex: 1 0 100%; - width: 56px; - height: 56px; - object-fit: cover; - } - } - - &-avatar-link { - position: relative; - cursor: pointer; - - &-overlay { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.3); - display: flex; - justify-content: center; - align-items: center; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - opacity: 0; - transition: opacity .2s ease; - - svg { - color: #FFF; - } - } - - &:hover &-overlay { - opacity: 1; - } - } - - .external-link-button, .edit-profile-button { - cursor: pointer; - width: 2.5em; - text-align: center; - margin: -0.5em 0; - padding: 0.5em 0; - - &:not(:hover) .icon { - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - } - - .user-summary { - display: block; - margin-left: 0.6em; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1 1 0; - // This is so that text doesn't get overlapped by avatar's shadow if it has - // big one - z-index: 1; - - .top-line { - display: flex; - } - } - - .user-name { - text-overflow: ellipsis; - overflow: hidden; - flex: 1 1 auto; - margin-right: 1em; - font-size: 15px; - - --emoji-size: 14px; - } - - .bottom-line { - display: flex; - font-weight: light; - font-size: 15px; - - .lock-icon { - margin-left: 0.5em; - } - - .user-screen-name { - min-width: 1px; - flex: 0 1 auto; - text-overflow: ellipsis; - overflow: hidden; - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - - .dailyAvg { - min-width: 1px; - flex: 0 0 auto; - margin-left: 1em; - font-size: 0.7em; - color: $fallback--text; - color: var(--text, $fallback--text); - } - - .user-role { - flex: none; - color: $fallback--text; - color: var(--alertNeutralText, $fallback--text); - background-color: $fallback--fg; - background-color: var(--alertNeutral, $fallback--fg); - } - } - - .user-meta { - margin-bottom: .15em; - display: flex; - align-items: baseline; - font-size: 14px; - line-height: 22px; - flex-wrap: wrap; - - .following { - flex: 1 0 auto; - margin: 0; - margin-bottom: .25em; - text-align: left; - } - - .highlighter { - flex: 0 1 auto; - display: flex; - flex-wrap: wrap; - margin-right: -.5em; - align-self: start; - - .userHighlightCl { - padding: 2px 10px; - flex: 1 0 auto; - } - - .userHighlightSel { - padding-top: 0; - padding-bottom: 0; - flex: 1 0 auto; - } - - .userHighlightText { - width: 70px; - flex: 1 0 auto; - } - - .userHighlightCl, - .userHighlightText, - .userHighlightSel { - vertical-align: top; - margin-right: .5em; - margin-bottom: .25em; - } - } - } - .user-interactions { - position: relative; - display: flex; - flex-flow: row wrap; - margin-right: -.75em; - - > * { - margin: 0 .75em .6em 0; - white-space: nowrap; - min-width: 95px; - } - - button { - margin: 0; - } - } -} - -.sidebar .edit-profile-button { - display: none; -} - -.user-counts { - display: flex; - line-height:16px; - padding: .5em 1.5em 0em 1.5em; - text-align: center; - justify-content: space-between; - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - flex-wrap: wrap; -} - -.user-count { - flex: 1 0 auto; - padding: .5em 0 .5em 0; - margin: 0 .5em; - - h5 { - font-size:1em; - font-weight: bolder; - margin: 0 0 0.25em; - } - a { - text-decoration: none; - } -} -</style> +<style lang="scss" src="./user_card.scss" /> diff --git a/src/components/user_link/user_link.vue b/src/components/user_link/user_link.vue new file mode 100644 index 00000000..efd96e12 --- /dev/null +++ b/src/components/user_link/user_link.vue @@ -0,0 +1,38 @@ +<template> + <router-link + :title="user.screen_name_ui" + :to="userProfileLink(user)" + > + {{ at ? '@' : '' }}{{ user.screen_name_ui }}<UnicodeDomainIndicator + :user="user" + /> + </router-link> +</template> + +<script> +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const UserLink = { + props: { + user: Object, + at: { + type: Boolean, + default: true + } + }, + components: { + UnicodeDomainIndicator + }, + methods: { + userProfileLink (user) { + return generateProfileLink( + user.id, user.screen_name, + this.$store.state.instance.restrictedNicknames + ) + } + } +} + +export default UserLink +</script> diff --git a/src/components/user_list_menu/user_list_menu.js b/src/components/user_list_menu/user_list_menu.js new file mode 100644 index 00000000..21996031 --- /dev/null +++ b/src/components/user_list_menu/user_list_menu.js @@ -0,0 +1,93 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronRight } from '@fortawesome/free-solid-svg-icons' +import { mapState } from 'vuex' + +import DialogModal from '../dialog_modal/dialog_modal.vue' +import Popover from '../popover/popover.vue' + +library.add(faChevronRight) + +const UserListMenu = { + props: [ + 'user' + ], + data () { + return {} + }, + components: { + DialogModal, + Popover + }, + created () { + this.$store.dispatch('fetchUserInLists', this.user.id) + }, + computed: { + ...mapState({ + allLists: state => state.lists.allLists + }), + inListsSet () { + return new Set(this.user.inLists.map(x => x.id)) + }, + lists () { + if (!this.user.inLists) return [] + return this.allLists.map(list => ({ + ...list, + inList: this.inListsSet.has(list.id) + })) + } + }, + methods: { + toggleList (listId) { + if (this.inListsSet.has(listId)) { + this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId }).then((response) => { + if (!response.ok) { return } + this.$store.dispatch('fetchUserInLists', this.user.id) + }) + } else { + this.$store.dispatch('addListAccount', { accountId: this.user.id, listId }).then((response) => { + if (!response.ok) { return } + this.$store.dispatch('fetchUserInLists', this.user.id) + }) + } + }, + toggleRight (right) { + const store = this.$store + if (this.user.rights[right]) { + store.state.api.backendInteractor.deleteRight({ user: this.user, right }).then(response => { + if (!response.ok) { return } + store.commit('updateRight', { user: this.user, right, value: false }) + }) + } else { + store.state.api.backendInteractor.addRight({ user: this.user, right }).then(response => { + if (!response.ok) { return } + store.commit('updateRight', { user: this.user, right, value: true }) + }) + } + }, + toggleActivationStatus () { + this.$store.dispatch('toggleActivationStatus', { user: this.user }) + }, + deleteUserDialog (show) { + this.showDeleteUserDialog = show + }, + deleteUser () { + const store = this.$store + const user = this.user + const { id, name } = user + store.state.api.backendInteractor.deleteUser({ user }) + .then(e => { + this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id) + const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile' + const isTargetUser = this.$route.params.name === name || this.$route.params.id === id + if (isProfile && isTargetUser) { + window.history.back() + } + }) + }, + setToggled (value) { + this.toggled = value + } + } +} + +export default UserListMenu diff --git a/src/components/user_list_menu/user_list_menu.vue b/src/components/user_list_menu/user_list_menu.vue new file mode 100644 index 00000000..06947ab7 --- /dev/null +++ b/src/components/user_list_menu/user_list_menu.vue @@ -0,0 +1,38 @@ +<template> + <div class="UserListMenu"> + <Popover + trigger="hover" + placement="left" + remove-padding + > + <template #content> + <div class="dropdown-menu"> + <button + v-for="list in lists" + :key="list.id" + class="button-default dropdown-item" + @click="toggleList(list.id)" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': list.inList }" + /> + {{ list.title }} + </button> + </div> + </template> + <template #trigger> + <button class="btn button-default dropdown-item -has-submenu"> + {{ $t('lists.manage_lists') }} + <FAIcon + class="chevron-icon" + size="lg" + icon="chevron-right" + /> + </button> + </template> + </Popover> + </div> +</template> + +<script src="./user_list_menu.js"></script> diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js index 32ca2b8d..046e0abd 100644 --- a/src/components/user_list_popover/user_list_popover.js +++ b/src/components/user_list_popover/user_list_popover.js @@ -1,3 +1,7 @@ +import { defineAsyncComponent } from 'vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' + import { library } from '@fortawesome/fontawesome-svg-core' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' @@ -11,8 +15,10 @@ const UserListPopover = { 'users' ], components: { - Popover: () => import('../popover/popover.vue'), - UserAvatar: () => import('../user_avatar/user_avatar.vue') + RichContent, + UnicodeDomainIndicator, + Popover: defineAsyncComponent(() => import('../popover/popover.vue')), + UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue')) }, computed: { usersCapped () { diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue index f4b93c9a..635dc7f6 100644 --- a/src/components/user_list_popover/user_list_popover.vue +++ b/src/components/user_list_popover/user_list_popover.vue @@ -4,10 +4,10 @@ placement="top" :offset="{ y: 5 }" > - <template v-slot:trigger> + <template #trigger> <slot /> </template> - <template v-slot:content> + <template #content> <div class="user-list-popover"> <template v-if="users.length"> <div @@ -22,9 +22,14 @@ /> <div class="user-list-names"> <!-- eslint-disable vue/no-v-html --> - <span v-html="user.name_html" /> + <RichContent + class="username" + :title="'@'+user.screen_name_ui" + :html="user.name_html" + :emoji="user.emoji" + /> <!-- eslint-enable vue/no-v-html --> - <span class="user-list-screen-name">{{ user.screen_name_ui }}</span> + <span class="user-list-screen-name">{{ user.screen_name_ui }}</span><UnicodeDomainIndicator :user="user" /> </div> </div> </template> @@ -40,7 +45,7 @@ </Popover> </template> -<script src="./user_list_popover.js" ></script> +<script src="./user_list_popover.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -48,6 +53,8 @@ .user-list-popover { padding: 0.5em; + --emoji-size: 16px; + .user-list-row { padding: 0.25em; display: flex; @@ -66,7 +73,7 @@ } .user-list-screen-name { - font-size: 9px; + font-size: 0.65em; } } } diff --git a/src/components/user_note/user_note.js b/src/components/user_note/user_note.js new file mode 100644 index 00000000..830b2e59 --- /dev/null +++ b/src/components/user_note/user_note.js @@ -0,0 +1,45 @@ +const UserNote = { + props: { + user: Object, + relationship: Object, + editable: Boolean + }, + data () { + return { + localNote: '', + editing: false, + frozen: false + } + }, + computed: { + shouldShow () { + return this.relationship.note || this.editing + } + }, + methods: { + startEditing () { + this.localNote = this.relationship.note + this.editing = true + }, + cancelEditing () { + this.editing = false + }, + finalizeEditing () { + this.frozen = true + + this.$store.dispatch('editUserNote', { + id: this.user.id, + comment: this.localNote + }) + .then(() => { + this.frozen = false + this.editing = false + }) + .catch(() => { + this.frozen = false + }) + } + } +} + +export default UserNote diff --git a/src/components/user_note/user_note.vue b/src/components/user_note/user_note.vue new file mode 100644 index 00000000..4286e017 --- /dev/null +++ b/src/components/user_note/user_note.vue @@ -0,0 +1,88 @@ +<template> + <div + class="user-note" + > + <div class="heading"> + <span>{{ $t('user_card.note') }}</span> + <div class="buttons"> + <button + v-show="!editing && editable" + class="button-default btn" + @click="startEditing" + > + {{ $t('user_card.edit_note') }} + </button> + <button + v-show="editing" + class="button-default btn" + :disabled="frozen" + @click="finalizeEditing" + > + {{ $t('user_card.edit_note_apply') }} + </button> + <button + v-show="editing" + class="button-default btn" + :disabled="frozen" + @click="cancelEditing" + > + {{ $t('user_card.edit_note_cancel') }} + </button> + </div> + </div> + <textarea + v-show="editing" + v-model="localNote" + class="note-text" + /> + <span + v-show="!editing" + class="note-text" + :class="{ '-blank': !relationship.note }" + > + {{ relationship.note || $t('user_card.note_blank') }} + </span> + </div> +</template> + +<script src="./user_note.js"></script> + +<style lang="scss"> +@import '../../variables'; + +.user-note { + display: flex; + flex-direction: column; + + .heading { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75em; + + .btn { + min-width: 95px; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: right; + + .btn { + margin-left: 0.5em; + } + } + } + + .note-text { + align-self: stretch; + } + + .note-text.-blank { + font-style: italic; + color: var(--faint, $fallback--faint); + } +} +</style> diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 5685916a..95ec97af 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,8 +1,8 @@ <template> - <div class="user-panel"> + <aside class="user-panel"> <div v-if="signedIn" - key="user-panel" + key="user-panel-signed" class="panel panel-default signed-in" > <UserCard @@ -16,7 +16,7 @@ v-else key="user-panel" /> - </div> + </aside> </template> <script src="./user_panel.js"></script> @@ -24,5 +24,6 @@ <style lang="scss"> .user-panel .signed-in { overflow: visible; + z-index: 10; } </style> diff --git a/src/components/user_popover/user_popover.js b/src/components/user_popover/user_popover.js new file mode 100644 index 00000000..3b12aa1e --- /dev/null +++ b/src/components/user_popover/user_popover.js @@ -0,0 +1,23 @@ +import UserCard from '../user_card/user_card.vue' +import { defineAsyncComponent } from 'vue' + +const UserPopover = { + name: 'UserPopover', + props: [ + 'userId', 'overlayCenters', 'disabled', 'overlayCentersSelector' + ], + components: { + UserCard, + Popover: defineAsyncComponent(() => import('../popover/popover.vue')) + }, + computed: { + userPopoverAvatarAction () { + return this.$store.getters.mergedConfig.userPopoverAvatarAction + }, + userPopoverOverlay () { + return this.$store.getters.mergedConfig.userPopoverOverlay + } + } +} + +export default UserPopover diff --git a/src/components/user_popover/user_popover.vue b/src/components/user_popover/user_popover.vue new file mode 100644 index 00000000..53d51fc4 --- /dev/null +++ b/src/components/user_popover/user_popover.vue @@ -0,0 +1,33 @@ +<template> + <Popover + trigger="click" + popover-class="popover-default user-popover" + :overlay-centers-selector="overlayCentersSelector || '.user-info .Avatar'" + :overlay-centers="overlayCenters && userPopoverOverlay" + :disabled="disabled" + > + <template #trigger> + <slot /> + </template> + <template #content="{close}"> + <UserCard + class="user-popover" + :user-id="userId" + :hide-bio="true" + :avatar-action="userPopoverAvatarAction == 'close' ? close : userPopoverAvatarAction" + :on-close="close" + /> + </template> + </Popover> +</template> + +<script src="./user_popover.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +/* popover styles load on-demand, so we need to override */ +.user-popover.popover { +} + +</style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 7a475609..08adaeab 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -3,7 +3,7 @@ import UserCard from '../user_card/user_card.vue' import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import Conversation from '../conversation/conversation.vue' -import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' @@ -39,15 +39,16 @@ const UserProfile = { return { error: false, userId: null, - tab: defaultTabKey + tab: defaultTabKey, + footerRef: null } }, created () { const routeParams = this.$route.params - this.load(routeParams.name || routeParams.id) + this.load({ name: routeParams.name, id: routeParams.id }) this.tab = get(this.$route, 'query.tab', defaultTabKey) }, - destroyed () { + unmounted () { this.stopFetching() }, computed: { @@ -78,6 +79,9 @@ const UserProfile = { } }, methods: { + setFooterRef (el) { + this.footerRef = el + }, load (userNameOrId) { const startFetchingTimeline = (timeline, userId) => { // Clear timeline only if load another user's profile @@ -102,12 +106,17 @@ const UserProfile = { this.userId = null this.error = false + const maybeId = userNameOrId.id + const maybeName = userNameOrId.name + // Check if user data is already loaded in store - const user = this.$store.getters.findUser(userNameOrId) + const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName) if (user) { loadById(user.id) } else { - this.$store.dispatch('fetchUser', userNameOrId) + (maybeId + ? this.$store.dispatch('fetchUser', maybeId) + : this.$store.dispatch('fetchUserByName', maybeName)) .then(({ id }) => loadById(id)) .catch((reason) => { const errorMessage = get(reason, 'error.error') @@ -146,12 +155,12 @@ const UserProfile = { watch: { '$route.params.id': function (newVal) { if (newVal) { - this.switchUser(newVal) + this.switchUser({ id: newVal }) } }, '$route.params.name': function (newVal) { if (newVal) { - this.switchUser(newVal) + this.switchUser({ name: newVal }) } }, '$route.query': function (newVal) { diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 726216ff..d5e8d230 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -8,8 +8,9 @@ :user-id="userId" :switcher="true" :selected="timeline.viewing" - :allow-zooming-avatar="true" + avatar-action="zoom" rounded="top" + :has-note-editor="true" /> <div v-if="user.fields_html && user.fields_html.length > 0" @@ -56,6 +57,7 @@ :user-id="userId" :pinned-status-ids="user.pinnedStatusIds" :in-profile="true" + :footer-slipgate="footerRef" /> <div v-if="followsTabVisible" @@ -64,7 +66,7 @@ :disabled="!user.friends_count" > <FriendList :user-id="userId"> - <template v-slot:item="{item}"> + <template #item="{item}"> <FollowCard :user="item" /> </template> </FriendList> @@ -76,7 +78,7 @@ :disabled="!user.followers_count" > <FollowerList :user-id="userId"> - <template v-slot:item="{item}"> + <template #item="{item}"> <FollowCard :user="item" :no-follows-you="isUs" @@ -94,6 +96,7 @@ :timeline="media" :user-id="userId" :in-profile="true" + :footer-slipgate="footerRef" /> <Timeline v-if="isUs" @@ -105,8 +108,13 @@ timeline-name="favorites" :timeline="favorites" :in-profile="true" + :footer-slipgate="footerRef" /> </tab-switcher> + <div + :ref="setFooterRef" + class="panel-footer" + /> </div> <div v-else @@ -138,6 +146,9 @@ flex: 2; flex-basis: 500px; + // No sticky header on user profile + --currentPanelStack: 1; + .user-profile-fields { margin: 0 0.5em; @@ -176,7 +187,7 @@ } .user-profile-field-name, .user-profile-field-value { - line-height: 18px; + line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -192,24 +203,6 @@ align-items: middle; padding: 2em; } - - .timeline-heading { - display: flex; - justify-content: center; - - .loadmore-button, .alert { - flex: 1; - } - - .loadmore-button { - height: 28px; - margin: 10px .6em; - } - - .title, .loadmore-text { - display: none - } - } } .user-profile-placeholder { .panel-body { diff --git a/src/components/user_reporting_modal/user_reporting_modal.js b/src/components/user_reporting_modal/user_reporting_modal.js index 8d171b2d..67fde084 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.js +++ b/src/components/user_reporting_modal/user_reporting_modal.js @@ -1,15 +1,16 @@ - import Status from '../status/status.vue' import List from '../list/list.vue' import Checkbox from '../checkbox/checkbox.vue' import Modal from '../modal/modal.vue' +import UserLink from '../user_link/user_link.vue' const UserReportingModal = { components: { Status, List, Checkbox, - Modal + Modal, + UserLink }, data () { return { @@ -21,14 +22,17 @@ const UserReportingModal = { } }, computed: { + reportModal () { + return this.$store.state.reports.reportModal + }, isLoggedIn () { return !!this.$store.state.users.currentUser }, isOpen () { - return this.isLoggedIn && this.$store.state.reports.modalActivated + return this.isLoggedIn && this.reportModal.activated }, userId () { - return this.$store.state.reports.userId + return this.reportModal.userId }, user () { return this.$store.getters.findUser(this.userId) @@ -37,10 +41,10 @@ const UserReportingModal = { return !this.user.is_local && this.user.screen_name.substr(this.user.screen_name.indexOf('@') + 1) }, statuses () { - return this.$store.state.reports.statuses + return this.reportModal.statuses }, preTickedIds () { - return this.$store.state.reports.preTickedIds + return this.reportModal.preTickedIds } }, watch: { diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index 1f67a5cc..8c42ab7b 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -5,9 +5,13 @@ > <div class="user-reporting-panel panel"> <div class="panel-heading"> - <div class="title"> - {{ $t('user_reporting.title', [user.screen_name_ui]) }} - </div> + <i18n-t + tag="div" + keypath="user_reporting.title" + class="title" + > + <UserLink :user="user" /> + </i18n-t> </div> <div class="panel-body"> <div class="user-reporting-panel-left"> @@ -45,7 +49,7 @@ </div> <div class="user-reporting-panel-right"> <List :items="statuses"> - <template v-slot:item="{item}"> + <template #item="{item}"> <div class="status-fadein user-reporting-panel-sitem"> <Status :in-conversation="false" @@ -53,8 +57,8 @@ :statusoid="item" /> <Checkbox - :checked="isChecked(item.id)" - @change="checked => toggleStatus(checked, item.id)" + :model-value="isChecked(item.id)" + @update:model-value="checked => toggleStatus(checked, item.id)" /> </div> </template> @@ -76,17 +80,6 @@ min-height: 20vh; max-height: 80vh; - .panel-heading { - .title { - text-align: center; - // TODO: Consider making these as default of panel - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - .panel-body { display: flex; flex-direction: column-reverse; @@ -98,7 +91,7 @@ &-left { padding: 1.1em 0.7em 0.7em; - line-height: 1.4em; + line-height: var(--post-line-height); box-sizing: border-box; > div { diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js index ecd97dd7..53f05272 100644 --- a/src/components/who_to_follow/who_to_follow.js +++ b/src/components/who_to_follow/who_to_follow.js @@ -28,7 +28,7 @@ const WhoToFollow = { getWhoToFollow () { const credentials = this.$store.state.users.currentUser.credentials if (credentials) { - apiService.suggestions({ credentials: credentials }) + apiService.suggestions({ credentials }) .then((reply) => { this.showWhoToFollow(reply) }) 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 818e8bd5..f19ba948 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 @@ -6,9 +6,9 @@ function showWhoToFollow (panel, reply) { const shuffled = shuffle(reply) panel.usersToFollow.forEach((toFollow, index) => { - let user = shuffled[index] - let img = user.avatar || this.$store.state.instance.defaultAvatar - let name = user.acct + const user = shuffled[index] + const img = user.avatar || this.$store.state.instance.defaultAvatar + const name = user.acct toFollow.img = img toFollow.name = name @@ -24,12 +24,12 @@ function showWhoToFollow (panel, reply) { } function getWhoToFollow (panel) { - var credentials = panel.$store.state.users.currentUser.credentials + const credentials = panel.$store.state.users.currentUser.credentials if (credentials) { panel.usersToFollow.forEach(toFollow => { toFollow.name = 'Loading...' }) - apiService.suggestions({ credentials: credentials }) + apiService.suggestions({ credentials }) .then((reply) => { showWhoToFollow(panel, reply) }) diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue index 518acd97..c1ba6fb1 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.vue +++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue @@ -27,7 +27,7 @@ </div> </template> -<script src="./who_to_follow_panel.js" ></script> +<script src="./who_to_follow_panel.js"></script> <style lang="scss"> .who-to-follow * { diff --git a/src/directives/body_scroll_lock.js b/src/directives/body_scroll_lock.js index 13a6de1c..b6d16790 100644 --- a/src/directives/body_scroll_lock.js +++ b/src/directives/body_scroll_lock.js @@ -50,12 +50,12 @@ const enableBodyScroll = (el) => { } const directive = { - inserted: (el, binding) => { + mounted: (el, binding) => { if (binding.value) { disableBodyScroll(el) } }, - componentUpdated: (el, binding) => { + updated: (el, binding) => { if (binding.oldValue === binding.value) { return } @@ -66,7 +66,7 @@ const directive = { enableBodyScroll(el) } }, - unbind: (el) => { + unmounted: (el) => { enableBodyScroll(el) } } diff --git a/src/hocs/with_load_more/with_load_more.js b/src/hocs/with_load_more/with_load_more.jsx index 671b2b6f..c0ae1856 100644 --- a/src/hocs/with_load_more/with_load_more.js +++ b/src/hocs/with_load_more/with_load_more.jsx @@ -1,4 +1,5 @@ -import Vue from 'vue' +// eslint-disable-next-line no-unused +import { h } from 'vue' import isEmpty from 'lodash/isEmpty' import { getComponentProps } from '../../services/component_utils/component_utils' import './with_load_more.scss' @@ -16,14 +17,14 @@ library.add( const withLoadMore = ({ fetch, // function to fetch entries and return a promise select, // function to select data from store - destroy, // function called at "destroyed" lifecycle + unmounted, // function called at "destroyed" lifecycle childPropName = 'entries', // name of the prop to be passed into the wrapped component additionalPropNames = [] // additional prop name list of the wrapper component }) => (WrappedComponent) => { const originalProps = Object.keys(getComponentProps(WrappedComponent)) const props = originalProps.filter(v => v !== childPropName).concat(additionalPropNames) - return Vue.component('withLoadMore', { + return { props, data () { return { @@ -39,9 +40,9 @@ const withLoadMore = ({ this.fetchEntries() } }, - destroyed () { + unmounted () { window.removeEventListener('scroll', this.scrollLoad) - destroy && destroy(this.$props, this.$store) + unmounted && unmounted(this.$props, this.$store) }, methods: { // Entries is not a computed because computed can't track the dynamic @@ -79,16 +80,12 @@ const withLoadMore = ({ } } }, - render (h) { + render () { const props = { - props: { - ...this.$props, - [childPropName]: this.entries - }, - on: this.$listeners, - scopedSlots: this.$scopedSlots + ...this.$props, + [childPropName]: this.entries } - const children = Object.entries(this.$slots).map(([key, value]) => h('template', { slot: key }, value)) + const children = this.$slots return ( <div class="with-load-more"> <WrappedComponent {...props}> @@ -106,7 +103,7 @@ const withLoadMore = ({ </div> ) } - }) + } } export default withLoadMore diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss index 1a26eb8d..de86ed4a 100644 --- a/src/hocs/with_load_more/with_load_more.scss +++ b/src/hocs/with_load_more/with_load_more.scss @@ -10,7 +10,7 @@ border-top-color: var(--border, $fallback--border); .error { - font-size: 14px; + font-size: 1rem; } a { diff --git a/src/hocs/with_subscription/with_subscription.js b/src/hocs/with_subscription/with_subscription.jsx index b1244276..d3f5506a 100644 --- a/src/hocs/with_subscription/with_subscription.js +++ b/src/hocs/with_subscription/with_subscription.jsx @@ -1,4 +1,5 @@ -import Vue from 'vue' +// eslint-disable-next-line no-unused +import { h } from 'vue' import isEmpty from 'lodash/isEmpty' import { getComponentProps } from '../../services/component_utils/component_utils' import './with_subscription.scss' @@ -22,7 +23,7 @@ const withSubscription = ({ const originalProps = Object.keys(getComponentProps(WrappedComponent)) const props = originalProps.filter(v => v !== childPropName).concat(additionalPropNames) - return Vue.component('withSubscription', { + return { props: [ ...props, 'refresh' // boolean saying to force-fetch data whenever created @@ -59,17 +60,13 @@ const withSubscription = ({ } } }, - render (h) { + render () { if (!this.error && !this.loading) { const props = { - props: { - ...this.$props, - [childPropName]: this.fetchedData - }, - on: this.$listeners, - scopedSlots: this.$scopedSlots + ...this.$props, + [childPropName]: this.fetchedData } - const children = Object.entries(this.$slots).map(([key, value]) => h('template', { slot: key }, value)) + const children = this.$slots return ( <div class="with-subscription"> <WrappedComponent {...props}> @@ -88,7 +85,7 @@ const withSubscription = ({ ) } } - }) + } } export default withSubscription diff --git a/src/hocs/with_subscription/with_subscription.scss b/src/hocs/with_subscription/with_subscription.scss index 52c7d94c..7fd83802 100644 --- a/src/hocs/with_subscription/with_subscription.scss +++ b/src/hocs/with_subscription/with_subscription.scss @@ -4,7 +4,7 @@ text-align: center; .error { - font-size: 14px; + font-size: 1rem; } } }
\ No newline at end of file diff --git a/src/i18n/ar.json b/src/i18n/ar.json index a475d291..cd9a410a 100644 --- a/src/i18n/ar.json +++ b/src/i18n/ar.json @@ -200,5 +200,22 @@ "who_to_follow": { "more": "اŲŲ
Ø˛ŲØ¯", "who_to_follow": "ŲŲŲ
ØĒابؚ؊" + }, + "about": { + "mrf": { + "keyword": { + "ftl_removal": "ØĨØ˛Ø§ŲØŠ Ų
Ų Ø§ŲØŽØˇ Ø§ŲØ˛Ų
ŲŲ Ø§ŲØŽØ§Øĩ بØŦŲ
ب𠨧ب´Ø¨ŲاØĒ اŲŲ
ØšØąŲŲØŠ", + "reject": "ØąŲØļ", + "replace": "ØĨØŗØĒبداŲ", + "is_replaced_by": "â", + "keyword_policies": "ØŗŲØ§ØŗØŠ اŲŲŲŲ
اØĒ Ø§ŲØ¯ŲاŲŲØŠ" + }, + "simple": { + "simple_policies": "ØŗŲØ§ØŗØ§ØĒ Ø§ŲØŽØ§Ø¯Ų
" + }, + "federation": "Ø§ŲØ§ØĒØØ§Ø¯", + "mrf_policies": "ØĒŲØšŲŲ ØŗŲØ§ØŗØ§ØĒ ØĨؚاد؊ ŲØĒØ§Ø¨ØŠ اŲŲ
ŲØ´ŲØą", + "mrf_policies_desc": "؎اØĩŲØŠ ØĨؚاد؊ ŲØĒØ§Ø¨ØŠ اŲŲ
ŲØ§Ø´ŲØą ØĒŲŲŲ
بØĒؚدŲŲ ØĒŲØ§ØšŲ Ø§ŲØ§ØĒØØ§Ø¯ Ų
Øš ŲØ°Ø§ Ø§ŲØŽØ§Ø¯Ų
. Ø§ŲØŗŲØ§ØŗØ§ØĒ Ø§ŲØĒØ§ŲŲØŠ Ų
ŲØšŲŲØŠ:" + } } } diff --git a/src/i18n/ca.json b/src/i18n/ca.json index 1f5392f3..5f2795a8 100644 --- a/src/i18n/ca.json +++ b/src/i18n/ca.json @@ -323,7 +323,10 @@ "play_videos_in_modal": "Reproduir vÃdeos en un marc emergent", "file_export_import": { "errors": { - "invalid_file": "El fitxer seleccionat no Ês và lid com a cÃ˛pia de seguretat de la configuraciÃŗ. No s'ha realitzat cap canvi." + "invalid_file": "El fitxer seleccionat no Ês và lid com a cÃ˛pia de seguretat de la configuraciÃŗ. No s'ha realitzat cap canvi.", + "file_too_new": "VersiÃŗ important incompatible: {fileMajor}, aquest PleromaFE (configuraciÃŗ versiÃŗ {feMajor}) Ês massa antiga per gestionar-lo", + "file_too_old": "VersiÃŗ important incompatible: {fileMajor}, la versiÃŗ del fitxer Ês massa antiga i no està implementada (s'ha establert un mÃnim ver. {feMajor})", + "file_slightly_new": "La versiÃŗ menor del fitxer Ês diferent, alguns parà metres podrien no carregar-se" }, "backup_settings": "CÃ˛pia de seguretat de la configuraciÃŗ a un fitxer", "backup_settings_theme": "CÃ˛pia de seguretat de la configuraciÃŗ i tema a un fitxer", @@ -382,7 +385,8 @@ "postCode": "Text monoespai en publicaciÃŗ (text enriquit)", "input": "Camps d'entrada", "interface": "InterfÃcie" - } + }, + "weight": "Pes (negreta)" }, "preview": { "input": "Acabo d'aterrar a Los Angeles.", @@ -394,7 +398,9 @@ "error": "Exemple d'error", "faint_link": "Manual d'ajuda", "checkbox": "He llegit els termes i condicions", - "link": "un bonic enllaç" + "link": "un bonic enllaç", + "fine_print": "Llegiu el nostre {0} per no aprendre res Ãētil!", + "text": "Un grapat mÊs de {0} i {1}" }, "shadows": { "spread": "Difon", @@ -438,7 +444,8 @@ "snapshot_missing": "No hi havia cap instantà nia del tema al fitxer, per tant podria veure's diferent del previst originalment.", "upgraded_from_v2": "PleromaFE s'ha actualitzat, el tema pot veure's un poc diferent de com recordes.", "fe_downgraded": "VersiÃŗ de PleromaFE revertida.", - "older_version_imported": "El fitxer que has importat va ser creat en una versiÃŗ del front-end mÊs antiga." + "older_version_imported": "El fitxer que has importat va ser creat en una versiÃŗ del front-end mÊs antiga.", + "snapshot_present": "S'ha carregat la instantà nia del tema, de manera que tots els valors estan sobreescrits. En canvi, podeu carregar les dades reals del tema." }, "keep_as_is": "Mantindre com està ", "save_load_hint": "Les opcions \"Mantindre\" conserven les opcions configurades actualment al seleccionar o carregar temes, tambÊ emmagatzema aquestes opcions quan s'exporta un tema. Quan es desactiven totes les caselles de verificaciÃŗ, el tema exportat ho guardarà tot.", @@ -532,7 +539,13 @@ "notification_setting_hide_notification_contents": "Amagar el remitent i els continguts de les notificacions push", "notifications": "Notificacions", "notification_mutes": "Per a deixar de rebre notificacions d'un usuari en concret, silencia'l-ho.", - "theme_help_v2_2": "Les icones per baix d'algunes entrades sÃŗn indicadors del contrast del fons/text, desplaça el ratolà per a mÊs informaciÃŗ. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible." + "theme_help_v2_2": "Les icones per baix d'algunes entrades sÃŗn indicadors del contrast del fons/text, desplaça el ratolà per a mÊs informaciÃŗ. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible.", + "hide_shoutbox": "Oculta la casella de gà bia de grills", + "always_show_post_button": "Mostra sempre el botÃŗ flotant de publicaciÃŗ nova", + "pad_emoji": "Acompanya els emojis amb espais en afegir des del selector", + "mentions_new_style": "Enllaços d'esment mÊs elegants", + "mentions_new_place": "Posa les mencions en una lÃnia separada", + "post_status_content_type": "Format de publicaciÃŗ" }, "time": { "day": "{0} dia", @@ -608,7 +621,6 @@ "disable_any_subscription": "Deshabilita completament seguir algÃē", "quarantine": "Deshabilita la federaciÃŗ a les entrades de les usuà ries", "moderation": "ModeraciÃŗ", - "delete_user_confirmation": "Està s completament segur/a? Aquesta acciÃŗ no es pot desfer.", "revoke_admin": "Revoca l'Admin", "activate_account": "Activa el compte", "deactivate_account": "Desactiva el compte", @@ -617,7 +629,9 @@ "disable_remote_subscription": "Deshabilita seguir algÃē des d'una instà ncia remota", "delete_user": "Esborra la usuà ria", "grant_admin": "Concedir permisos d'AdministraciÃŗ", - "grant_moderator": "Concedir permisos de ModeraciÃŗ" + "grant_moderator": "Concedir permisos de ModeraciÃŗ", + "force_unlisted": "Força que les publicacions no estiguin llistades", + "sandbox": "Força que els missatges siguin nomÊs seguidors" }, "edit_profile": "Edita el perfil", "hidden": "Amagat", @@ -642,7 +656,8 @@ "solid": "Fons sÃ˛lid", "striped": "Fons a ratlles", "side": "Ratlla lateral" - } + }, + "media": "Media" }, "user_profile": { "timeline_title": "Flux personal", @@ -658,12 +673,14 @@ }, "remote_user_resolver": { "error": "No trobat.", - "searching_for": "Cercant per" + "searching_for": "Cercant per", + "remote_user_resolver": "ResoluciÃŗ d'usuari remot" }, "interactions": { "load_older": "Carrega antigues interaccions", "favs_repeats": "Repeticions i favorits", - "follows": "Nous seguidors" + "follows": "Nous seguidors", + "moves": "MigraciÃŗ d'usuaris" }, "emoji": { "stickers": "Adhesius", @@ -775,7 +792,10 @@ "pinned": "Destacat", "reply_to": "Contesta a", "pin": "Destaca al perfil", - "unmute_conversation": "Deixa de silenciar la conversa" + "unmute_conversation": "Deixa de silenciar la conversa", + "mentions": "Mencions", + "you": "(Tu)", + "plus_more": "+{number} mÊs" }, "user_reporting": { "additional_comments": "Comentaris addicionals", @@ -801,7 +821,8 @@ "no_results": "No hi ha resultats", "people": "Persones", "hashtags": "Etiquetes", - "people_talking": "{count} persones parlant" + "people_talking": "{count} persones parlant", + "person_talking": "{count} persones parlant" }, "upload": { "file_size_units": { diff --git a/src/i18n/de.json b/src/i18n/de.json index b6599594..4bf897ef 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -582,7 +582,6 @@ "statuses": "Beiträge", "admin_menu": { "sandbox": "Erzwinge Beiträge nur fÃŧr Follower sichtbar zu sein", - "delete_user_confirmation": "Achtung! Diese Entscheidung kann nicht rÃŧckgängig gemacht werden! Trotzdem durchfÃŧhren?", "grant_admin": "Administratorprivilegien gewähren", "delete_user": "Nutzer lÃļschen", "strip_media": "Medien von Beiträgen entfernen", diff --git a/src/i18n/en.json b/src/i18n/en.json index eef8d701..59ee1c17 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -32,6 +32,27 @@ }, "staff": "Staff" }, + "announcements": { + "page_header": "Announcements", + "title": "Announcement", + "mark_as_read_action": "Mark as read", + "post_form_header": "Post announcement", + "post_placeholder": "Type your announcement content here...", + "post_action": "Post", + "post_error": "Error: {error}", + "close_error": "Close", + "delete_action": "Delete", + "start_time_prompt": "Start time: ", + "end_time_prompt": "End time: ", + "all_day_prompt": "This is an all-day event", + "published_time_display": "Published at {time}", + "start_time_display": "Starts at {time}", + "end_time_display": "Ends at {time}", + "edit_action": "Edit", + "submit_edit_action": "Submit", + "cancel_edit_action": "Cancel", + "inactive_message": "This announcement is inactive" + }, "shoutbox": { "title": "Shoutbox" }, @@ -46,7 +67,7 @@ "processing": "Processing, you'll soon be asked to download your file" }, "features_panel": { - "chat": "Chat", + "shout": "Shoutbox", "pleroma_chat_messages": "Pleroma Chat", "gopher": "Gopher", "media_proxy": "Media proxy", @@ -66,11 +87,13 @@ "more": "More", "loading": "LoadingâĻ", "generic_error": "An error occured", + "generic_error_message": "An error occured: {0}", "error_retry": "Please try again", "retry": "Try again", "optional": "optional", "show_more": "Show more", "show_less": "Show less", + "never_show_again": "Never show again", "dismiss": "Dismiss", "cancel": "Cancel", "disable": "Disable", @@ -78,14 +101,26 @@ "confirm": "Confirm", "verify": "Verify", "close": "Close", + "undo": "Undo", + "yes": "Yes", + "no": "No", "peek": "Peek", + "scroll_to_top": "Scroll to top", "role": { "admin": "Admin", "moderator": "Moderator" }, + "unpin": "Unpin item", + "pin": "Pin item", "flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).", "flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.", - "flash_fail": "Failed to load flash content, see console for details." + "flash_fail": "Failed to load flash content, see console for details.", + "scope_in_timeline": { + "direct": "Direct", + "private": "Followers-only", + "public": "Public", + "unlisted": "Unlisted" + } }, "image_cropper": { "crop_picture": "Crop picture", @@ -118,7 +153,9 @@ }, "media_modal": { "previous": "Previous", - "next": "Next" + "next": "Next", + "counter": "{current} / {total}", + "hide": "Close media viewer" }, "nav": { "about": "About", @@ -138,7 +175,16 @@ "who_to_follow": "Who to follow", "preferences": "Preferences", "timelines": "Timelines", - "chats": "Chats" + "chats": "Chats", + "lists": "Lists", + "edit_nav_mobile": "Customize navigation bar", + "edit_pinned": "Edit pinned items", + "edit_finish": "Done editing", + "mobile_sidebar": "Toggle mobile sidebar", + "mobile_notifications": "Open notifications", + "mobile_notifications": "Open notifications (there are unread ones)", + "mobile_notifications_close": "Close notifications", + "announcements": "Announcements" }, "notifications": { "broken_favorite": "Unknown status, searching for itâĻ", @@ -152,7 +198,9 @@ "repeated_you": "repeated your status", "no_more_notifications": "No more notifications", "migrated_to": "migrated to", - "reacted_with": "reacted with {0}" + "reacted_with": "reacted with {0}", + "submitted_report": "submitted a report", + "poll_ended": "poll has ended" }, "polls": { "add_poll": "Add poll", @@ -178,8 +226,20 @@ "add_emoji": "Insert emoji", "custom": "Custom emoji", "unicode": "Unicode emoji", + "unicode_groups": { + "activities": "Activities", + "animals-and-nature": "Animals & Nature", + "flags": "Flags", + "food-and-drink": "Food & Drink", + "objects": "Objects", + "people-and-body": "People & Body", + "smileys-and-emotion": "Smileys & Emotion", + "symbols": "Symbols", + "travel-and-places": "Travel & Places" + }, "load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.", - "load_all": "Loading all {emojiAmount} emoji" + "load_all": "Loading all {emojiAmount} emoji", + "regional_indicator": "Regional indicator {letter}" }, "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." @@ -187,10 +247,13 @@ "interactions": { "favs_repeats": "Repeats and favorites", "follows": "New follows", + "emoji_reactions": "Emoji Reactions", + "reports": "Reports", "moves": "User migrates", "load_older": "Load older interactions" }, "post_status": { + "edit_status": "Edit status", "new_status": "Post new status", "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", @@ -206,6 +269,8 @@ "default": "Just landed in L.A.", "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.", + "edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.", + "edit_unsupported_warning": "Pleroma does not support editing mentions or polls.", "posting": "Posting", "post": "Post", "preview": "Preview", @@ -225,8 +290,9 @@ } }, "registration": { - "bio": "Bio", + "bio_optional": "Bio (optional)", "email": "Email", + "email_optional": "Email (optional)", "fullname": "Display name", "password_confirm": "Password confirmation", "registration": "Registration", @@ -246,24 +312,37 @@ "password_required": "cannot be left blank", "password_confirmation_required": "cannot be left blank", "password_confirmation_match": "should be the same as password" - } + }, + "email_language": "In which language do you want to receive emails from the server?" }, "remote_user_resolver": { "remote_user_resolver": "Remote user resolver", "searching_for": "Searching for", "error": "Not found." }, + "report": { + "reporter": "Reporter:", + "reported_user": "Reported user:", + "reported_statuses": "Reported statuses:", + "notes": "Notes:", + "state": "State:", + "state_open": "Open", + "state_closed": "Closed", + "state_resolved": "Resolved" + }, "selectable_list": { "select_all": "Select all" }, "settings": { "app_name": "App name", + "expert_mode": "Show advanced", "save": "Save changes", "security": "Security", "setting_changed": "Setting is different from default", + "setting_server_side": "This setting is tied to your profile and affects all sessions and clients", "enter_current_password_to_confirm": "Enter your current password to confirm your identity", - "mentions_new_style": "Fancier mention links", - "mentions_new_place": "Put mentions on a separate line", + "post_look_feel": "Posts Look & Feel", + "mention_links": "Mention links", "mfa": { "otp": "OTP", "setup_otp": "Setup OTP", @@ -285,6 +364,7 @@ "desc": "To enable two-factor authentication, enter the code from your two-factor app:" } }, + "lists_navigation": "Show lists in navigation", "allow_following_move": "Allow auto-follow when following account moves", "attachmentRadius": "Attachments", "attachments": "Attachments", @@ -293,6 +373,7 @@ "avatarRadius": "Avatars", "background": "Background", "bio": "Bio", + "email_language": "Language for receiving emails from the server", "block_export": "Block export", "block_export_button": "Export your blocks to a csv file", "block_import": "Block import", @@ -304,6 +385,16 @@ "mute_import_error": "Error importing mutes", "mutes_imported": "Mutes imported! Processing them will take a while.", "import_mutes_from_a_csv_file": "Import mutes from a csv file", + "account_backup": "Account backup", + "account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.", + "account_backup_table_head": "Backup", + "download_backup": "Download", + "backup_not_ready": "This backup is not ready yet.", + "remove_backup": "Remove", + "list_backups_error": "Error fetching backup list: {error}", + "add_backup": "Create a new backup", + "added_backup": "Added a new backup.", + "add_backup_error": "Error adding a new backup: {error}", "blocks_tab": "Blocks", "bot": "This is a bot account", "btnRadius": "Buttons", @@ -329,6 +420,19 @@ "delete_account_description": "Permanently delete your data and deactivate your account.", "delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.", "delete_account_instructions": "Type your password in the input below to confirm account deletion.", + "account_alias": "Account aliases", + "account_alias_table_head": "Alias", + "list_aliases_error": "Error fetching aliases: {error}", + "hide_list_aliases_error_action": "Close", + "remove_alias": "Remove this alias", + "new_alias_target": "Add a new alias (e.g. {example})", + "added_alias": "Alias is added.", + "add_alias_error": "Error adding alias: {error}", + "move_account": "Move account", + "move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.", + "move_account_target": "Target account (e.g. {example})", + "moved_account": "Account is moved.", + "move_account_error": "Error moving account: {error}", "discoverable": "Allow discovery of this account in search results and other services", "domain_mutes": "Domains", "avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.", @@ -336,8 +440,9 @@ "emoji_reactions_on_timeline": "Show emoji reactions on timeline", "export_theme": "Save preset", "filtering": "Filtering", + "wordfilter": "Wordfilter", "filtering_explanation": "All statuses containing these words will be muted, one per line", - "word_filter": "Word filter", + "word_filter_and_more": "Word filter and more...", "follow_export": "Follow export", "follow_export_button": "Export your follows to a csv file", "follow_import": "Follow import", @@ -350,18 +455,23 @@ "hide_attachments_in_tl": "Hide attachments in timeline", "hide_media_previews": "Hide media previews", "hide_muted_posts": "Hide posts of muted users", + "mute_bot_posts": "Mute bot posts", + "hide_bot_indication": "Hide bot indication in posts", "hide_all_muted_posts": "Hide muted posts", - "max_thumbnails": "Maximum amount of thumbnails per post", + "max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)", "hide_isp": "Hide instance-specific panel", "hide_shoutbox": "Hide instance shoutbox", - "right_sidebar": "Show sidebar on the right side", + "right_sidebar": "Reverse order of columns", + "navbar_column_stretch": "Stretch navbar to columns width", "always_show_post_button": "Always show floating New Post button", "hide_wallpaper": "Hide instance wallpaper", "preload_images": "Preload images", "use_one_click_nsfw": "Open NSFW attachments with just one click", "hide_post_stats": "Hide post statistics (e.g. the number of favorites)", "hide_user_stats": "Hide user statistics (e.g. the number of followers)", - "hide_filtered_statuses": "Hide filtered statuses", + "hide_filtered_statuses": "Hide all filtered posts", + "hide_wordfiltered_statuses": "Hide word-filtered statuses", + "hide_muted_threads": "Hide muted threads", "import_blocks_from_a_csv_file": "Import blocks from a csv file", "import_followers_from_a_csv_file": "Import follows from a csv file", "import_theme": "Load preset", @@ -397,11 +507,14 @@ "name": "Label", "value": "Content" }, + "account_privacy": "Privacy", "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", "name_bio": "Name & bio", "new_email": "New email", "new_password": "New password", + "posts": "Posts", + "user_profiles": "User Profiles", "notification_visibility": "Types of notifications to show", "notification_visibility_follows": "Follows", "notification_visibility_likes": "Favorites", @@ -409,23 +522,25 @@ "notification_visibility_repeats": "Repeats", "notification_visibility_moves": "User Migrates", "notification_visibility_emoji_reactions": "Reactions", + "notification_visibility_polls": "Ends of polls you voted in", "no_rich_text_description": "Strip rich text formatting from all posts", "no_blocks": "No blocks", "no_mutes": "No mutes", + "hide_favorites_description": "Don't show list of my favorites (people still get notified)", "hide_follows_description": "Don't show who I'm following", "hide_followers_description": "Don't show who's following me", "hide_follows_count_description": "Don't show follow count", "hide_followers_count_description": "Don't show follower count", "show_admin_badge": "Show \"Admin\" badge in my profile", "show_moderator_badge": "Show \"Moderator\" badge in my profile", - "nsfw_clickthrough": "Enable clickthrough attachment and link preview image hiding for NSFW statuses", + "nsfw_clickthrough": "Hide sensitive/NSFW media", "oauth_tokens": "OAuth tokens", "token": "Token", "refresh_token": "Refresh token", "valid_until": "Valid until", "revoke_token": "Revoke", "panelRadius": "Panels", - "pause_on_unfocused": "Pause streaming when tab is not focused", + "pause_on_unfocused": "Pause when tab is not focused", "presets": "Presets", "profile_background": "Profile background", "profile_banner": "Profile banner", @@ -460,13 +575,36 @@ "subject_line_email": "Like email: \"re: subject\"", "subject_line_mastodon": "Like mastodon: copy as is", "subject_line_noop": "Do not copy", + "conversation_display": "Conversation display style", + "conversation_display_tree": "Tree-style", + "conversation_display_tree_quick": "Tree view", + "disable_sticky_headers": "Don't stick column headers to top of the screen", + "show_scrollbars": "Show side column's scrollbars", + "third_column_mode": "When there's enough space, show third column containing", + "third_column_mode_none": "Don't show third column at all", + "third_column_mode_notifications": "Notifications column", + "third_column_mode_postform": "Main post form and navigation", + "columns": "Columns", + "column_sizes": "Column sizes", + "column_sizes_sidebar": "Sidebar", + "column_sizes_content": "Content", + "column_sizes_notifs": "Notifications", + "tree_advanced": "Allow more flexible navigation in tree view", + "tree_fade_ancestors": "Display ancestors of the current status in faint text", + "conversation_display_linear": "Linear-style", + "conversation_display_linear_quick": "Linear view", + "conversation_other_replies_button": "Show the \"other replies\" button", + "conversation_other_replies_button_below": "Below statuses", + "conversation_other_replies_button_inside": "Inside statuses", + "max_depth_in_thread": "Maximum number of levels in thread to display by default", "post_status_content_type": "Post status content type", "sensitive_by_default": "Mark posts as sensitive by default", - "stop_gifs": "Play-on-hover GIFs", - "streaming": "Enable automatic streaming of new posts when scrolled to the top", + "stop_gifs": "Pause animated images until you hover on them", + "streaming": "Automatically show new posts when scrolled to the top", + "auto_update": "Show new posts automatically", "user_mutes": "Users", "useStreamingApi": "Receive posts and notifications real-time", - "useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)", + "use_websockets": "Use websockets (Realtime updates)", "text": "Text", "theme": "Theme", "theme_help": "Use hex color codes (#rrggbb) to customize your color theme.", @@ -481,8 +619,24 @@ "true": "yes" }, "virtual_scrolling": "Optimize timeline rendering", + "use_at_icon": "Display {'@'} symbol as an icon instead of text", + "mention_link_display": "Display mention links", + "mention_link_display_short": "always as short names (e.g. {'@'}foo)", + "mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)", + "mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)", + "mention_link_use_tooltip": "Show user card when clicking mention links", + "mention_link_show_avatar": "Show user avatar beside the link", + "mention_link_show_avatar_quick": "Show user avatar next to mentions", + "mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)", + "mention_link_bolden_you": "Highlight mention of you when you are mentioned", + "user_popover_avatar_action": "Popover avatar click action", + "user_popover_avatar_action_zoom": "Zoom the avatar", + "user_popover_avatar_action_close": "Close the popover", + "user_popover_avatar_action_open": "Open profile", + "user_popover_avatar_overlay": "Show user popover over user avatar", "fun": "Fun", "greentext": "Meme arrows", + "show_yous": "Show (You)s", "notifications": "Notifications", "notification_setting_filters": "Filters", "notification_setting_block_from_strangers": "Block notifications from users who you do not follow", @@ -645,38 +799,26 @@ } }, "time": { - "day": "{0} day", - "days": "{0} days", - "day_short": "{0}d", - "days_short": "{0}d", - "hour": "{0} hour", - "hours": "{0} hours", - "hour_short": "{0}h", - "hours_short": "{0}h", + "unit": { + "days": "{0} day | {0} days", + "days_short": "{0}d", + "hours": "{0} hour | {0} hours", + "hours_short": "{0}h", + "minutes": "{0} minute | {0} minutes", + "minutes_short": "{0}min", + "months": "{0} month | {0} months", + "months_short": "{0}mo", + "seconds": "{0} second | {0} seconds", + "seconds_short": "{0}s", + "weeks": "{0} week | {0} weeks", + "weeks_short": "{0}w", + "years": "{0} year | {0} years", + "years_short": "{0}y" + }, "in_future": "in {0}", "in_past": "{0} ago", - "minute": "{0} minute", - "minutes": "{0} minutes", - "minute_short": "{0}min", - "minutes_short": "{0}min", - "month": "{0} month", - "months": "{0} months", - "month_short": "{0}mo", - "months_short": "{0}mo", "now": "just now", - "now_short": "now", - "second": "{0} second", - "seconds": "{0} seconds", - "second_short": "{0}s", - "seconds_short": "{0}s", - "week": "{0} week", - "weeks": "{0} weeks", - "week_short": "{0}w", - "weeks_short": "{0}w", - "year": "{0} year", - "years": "{0} years", - "year_short": "{0}y", - "years_short": "{0}y" + "now_short": "now" }, "timeline": { "collapse": "Collapse", @@ -691,12 +833,16 @@ "no_more_statuses": "No more statuses", "no_statuses": "No statuses", "socket_reconnected": "Realtime connection established", - "socket_broke": "Realtime connection lost: CloseEvent code {0}" + "socket_broke": "Realtime connection lost: CloseEvent code {0}", + "quick_view_settings": "Quick view settings", + "quick_filter_settings": "Quick filter settings" }, "status": { "favorites": "Favorites", "repeats": "Repeats", "delete": "Delete status", + "edit": "Edit status", + "edited_at": "(last edited {time})", "pin": "Pin on profile", "unpin": "Unpin from profile", "pinned": "Pinned", @@ -706,6 +852,7 @@ "reply_to": "Reply to", "mentions": "Mentions", "replies_list": "Replies:", + "replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):", "mute_conversation": "Mute conversation", "unmute_conversation": "Unmute conversation", "status_unavailable": "Status unavailable", @@ -721,12 +868,36 @@ "nsfw": "NSFW", "expand": "Expand", "you": "(You)", - "plus_more": "+{number} more" + "plus_more": "+{number} more", + "many_attachments": "Post has {number} attachment(s)", + "collapse_attachments": "Collapse attachments", + "show_all_attachments": "Show all attachments", + "show_attachment_in_modal": "Show in media modal", + "show_attachment_description": "Preview description (open attachment for full description)", + "hide_attachment": "Hide attachment", + "remove_attachment": "Remove attachment", + "attachment_stop_flash": "Stop Flash player", + "move_up": "Shift attachment left", + "move_down": "Shift attachment right", + "open_gallery": "Open gallery", + "thread_hide": "Hide this thread", + "thread_show": "Show this thread", + "thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)", + "show_only_conversation_under_this": "Only show replies to this status", + "status_history": "Status history" }, "user_card": { "approve": "Approve", "block": "Block", "blocked": "Blocked!", + "deactivated": "Deactivated", "deny": "Deny", "edit_profile": "Edit profile", "favorites": "Favorites", @@ -748,6 +919,7 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", + "remove_follower": "Remove follower", "report": "Report", "statuses": "Statuses", "subscribe": "Subscribe", @@ -778,14 +950,19 @@ "disable_any_subscription": "Disallow following user at all", "quarantine": "Disallow user posts from federating", "delete_user": "Delete user", - "delete_user_confirmation": "Are you absolutely sure? This action cannot be undone." + "delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?" }, "highlight": { "disabled": "No highlight", "solid": "Solid bg", "striped": "Striped bg", "side": "Side stripe" - } + }, + "note": "Note", + "note_blank": "(None)", + "edit_note": "Edit note", + "edit_note_apply": "Apply", + "edit_note_cancel": "Cancel" }, "user_profile": { "timeline_title": "User timeline", @@ -814,7 +991,9 @@ "user_settings": "User Settings", "accept_follow_request": "Accept follow request", "reject_follow_request": "Reject follow request", - "bookmark": "Bookmark" + "bookmark": "Bookmark", + "toggle_expand": "Expand or collapse notification to show post in full", + "toggle_mute": "Expand or collapse notification to reveal muted content" }, "upload": { "error": { @@ -836,7 +1015,9 @@ "hashtags": "Hashtags", "person_talking": "{count} person talking", "people_talking": "{count} people talking", - "no_results": "No results" + "no_results": "No results", + "no_more_results": "No more results", + "load_more": "Load more results" }, "password_reset": { "forgot_password": "Forgot password?", @@ -863,6 +1044,27 @@ "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!" }, + "lists": { + "lists": "Lists", + "new": "New List", + "title": "List title", + "search": "Search users", + "create": "Create", + "save": "Save changes", + "delete": "Delete list", + "following_only": "Limit to Following", + "manage_lists": "Manage lists", + "manage_members": "Manage list members", + "add_members": "Search for more users", + "remove_from_list": "Remove from list", + "add_to_list": "Add to list", + "is_in_list": "Already in list", + "editing_list": "Editing list {listTitle}", + "creating_list": "Creating new list", + "update_title": "Save Title", + "really_delete": "Really delete list?", + "error": "Error manipulating lists: {0}" + }, "file_type": { "audio": "Audio", "video": "Video", @@ -871,5 +1073,17 @@ }, "display_date": { "today": "Today" + }, + "update": { + "big_update_title": "Please bear with us", + "big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.", + "update_bugs": "Please report any issues and bugs on {pleromaGitlab}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.", + "update_bugs_gitlab": "Pleroma GitLab", + "update_changelog": "For more details on what's changed, see {theFullChangelog}.", + "update_changelog_here": "the full changelog", + "art_by": "Art by {linkToArtist}" + }, + "unicode_domain_indicator": { + "tooltip": "This domain contains non-ascii characters." } } diff --git a/src/i18n/eo.json b/src/i18n/eo.json index 169248bc..5a2c8afb 100644 --- a/src/i18n/eo.json +++ b/src/i18n/eo.json @@ -11,7 +11,8 @@ "title": "Funkcioj", "who_to_follow": "Kiun aboni", "pleroma_chat_messages": "Babilejo de Pleroma", - "upload_limit": "Limo de alÅutoj" + "upload_limit": "Limo de alÅutoj", + "shout": "Kriujo" }, "finder": { "error_fetching_user": "Eraris alporto de uzanto", @@ -42,7 +43,21 @@ }, "flash_content": "Klaku por montri enhavon de Flash per Ruffle. (Eksperimente, eble ne funkcios.)", "flash_security": "Sciu, ke tio povas esti danÄera, Äar la enhavo de Flash ja estas arbitra programo.", - "flash_fail": "Malsukcesis enlegi enhavon de Flash; vidu detalojn en konzolo." + "flash_fail": "Malsukcesis enlegi enhavon de Flash; vidu detalojn en konzolo.", + "scope_in_timeline": { + "direct": "Persona", + "private": "Nur abonantoj", + "public": "Publika", + "unlisted": "Nelistigita" + }, + "generic_error_message": "Eraris: {0}", + "never_show_again": "Neniam remontri", + "undo": "Malfari", + "yes": "Jes", + "no": "Ne", + "unpin": "Malfiksi eron", + "pin": "Fiksi eron", + "scroll_to_top": "Rulumi supren" }, "image_cropper": { "crop_picture": "Tondi bildon", @@ -70,7 +85,9 @@ }, "media_modal": { "previous": "AntaÅa", - "next": "Sekva" + "next": "Sekva", + "counter": "{current} / {total}", + "hide": "Fermi vidilon de vidaÅdaÄĩoj" }, "nav": { "about": "Pri", @@ -79,9 +96,9 @@ "friend_requests": "Petoj pri abono", "mentions": "Mencioj", "dms": "Rektaj mesaÄoj", - "public_tl": "Publika historio", + "public_tl": "Loka historio", "timeline": "Historio", - "twkn": "Konata reto", + "twkn": "Federa historio", "user_search": "SerÄi uzantojn", "who_to_follow": "Kiun aboni", "preferences": "Agordoj", @@ -91,7 +108,11 @@ "administration": "Administrado", "bookmarks": "Legosignoj", "timelines": "Historioj", - "home_timeline": "Hejma historio" + "home_timeline": "Hejma historio", + "edit_pinned": "Redakti fiksitajn erojn", + "lists": "Listoj", + "edit_nav_mobile": "Adapti navigan breton", + "edit_finish": "Fini redakton" }, "notifications": { "broken_favorite": "Nekonata stato, serÄante ÄinâĻ", @@ -105,7 +126,9 @@ "reacted_with": "reagis per {0}", "migrated_to": "migris al", "follow_request": "volas vin aboni", - "error": "Eraris akirado de sciigoj: {0}" + "error": "Eraris akirado de sciigoj: {0}", + "submitted_report": "sendis raporton", + "poll_ended": "enketo finiÄis" }, "post_status": { "new_status": "AfiÅi novan staton", @@ -129,7 +152,7 @@ "unlisted": "Nelistigita â ne afiÅi al publikaj historioj" }, "scope_notice": { - "unlisted": "Äi tiu afiÅo ne estos videbla en la Publika historio kaj La tuta konata reto", + "unlisted": "Äi tiu afiÅo ne estos videbla en la Loka historio kaj la Federa historio", "private": "Äi tiu afiÅo estos videbla nur al viaj abonantoj", "public": "Äi tiu afiÅo estos videbla al Äiuj" }, @@ -140,7 +163,10 @@ "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", - "post": "AfiÅo" + "post": "AfiÅo", + "edit_remote_warning": "Aliaj foraj nodoj eble ne subtenas redaktadon, kaj ne povos ricevi pli novan version de via afiÅo.", + "edit_unsupported_warning": "Pleroma ne subtenas redaktadon de mencioj aÅ enketoj.", + "edit_status": "Stato de redakto" }, "registration": { "bio": "Priskribo", @@ -164,7 +190,10 @@ }, "reason_placeholder": "Äi-node oni aprobas registriÄojn permane.\nSciigu la administrantojn kial vi volas registriÄi.", "reason": "Kialo registriÄi", - "register": "RegistriÄi" + "register": "RegistriÄi", + "bio_optional": "Prio (malnepra)", + "email_optional": "RetpoÅtadreso (malnepra)", + "email_language": "En kiu lingvo vi volus ricevi retleterojn de la servilo?" }, "settings": { "app_name": "Nomo de aplikaÄĩo", @@ -553,7 +582,87 @@ }, "right_sidebar": "Montri flankan breton dekstre", "save": "Konservi ÅanÄojn", - "hide_shoutbox": "KaÅi kriujon de nodo" + "hide_shoutbox": "KaÅi kriujon de nodo", + "always_show_post_button": "Äiam montri Åvebantan butonon por nova afiÅo", + "mentions_new_style": "Pli mojosaj menciligiloj", + "mentions_new_place": "Meti menciojn sur apartan linion", + "lists_navigation": "Montri listojn en navigiloj", + "account_backup": "Savkopio de konto", + "account_backup_description": "Äi tio povigas vin elÅuti arÄĨivon de viaj afiÅoj kaj Äiuj informoj pri via konto, sed ili ne jam povas enportiÄi en konton de Pleroma.", + "list_aliases_error": "Eraris akirado de kromnomoj: {error}", + "move_account_notes": "Se vi volas movi la konton aliloken, vi devas iri al via celata konto, kaj aldoni kromnomon ligitan al tie Äi.", + "navbar_column_stretch": "Etendi navigan breton laÅ larÄeco de kolumnoj", + "posts": "AfiÅoj", + "notification_visibility_polls": "Finoj de enketoj kun via voÄo", + "conversation_display": "Aspekto de interparoloj", + "disable_sticky_headers": "Ne alglui kapojn de kolumnoj al supro de la ekrano", + "conversation_display_linear_quick": "Linia vido", + "use_websockets": "Uzi teÄĨnikaron ÂĢwebsocketsÂģ (tuja Äisdatigo)", + "mention_link_display_full_for_remote": "plene nur je uzantoj foraj (ekz. {'@'}zozo{'@'}ekzemplo.org)", + "expert_mode": "Montri altnivelajn", + "setting_server_side": "Äi tiu agordo estas ligita al via profilo, kaj efektiviÄon en Äiuj viaj salutoj kaj klientoj", + "post_look_feel": "Aspekto de afiÅoj", + "mention_links": "Menciaj ligiloj", + "email_language": "Lingvo de leteroj ricevotaj de la servilo", + "account_backup_table_head": "Savkopio", + "download_backup": "ElÅuti", + "backup_not_ready": "Äi tiu savkopio ne jam pretas.", + "remove_backup": "Forigi", + "list_backups_error": "Eraris akirado de listo de savkopioj: {error}", + "add_backup": "Fari novan savkopion", + "added_backup": "Aldonis novan savkopion.", + "add_backup_error": "Eraris aldono de nova savkopio: {error}", + "account_alias": "Kromnomoj de konto", + "account_alias_table_head": "Kromnomo", + "hide_list_aliases_error_action": "Fermi", + "remove_alias": "Forigi Äi tiun kromnomon", + "new_alias_target": "Aldoni novan kromnomon (ekz. {example})", + "added_alias": "Kromnomo estas aldonita.", + "add_alias_error": "Eraris aldono de kromnomo: {error}", + "move_account": "Movi konton", + "move_account_target": "Celata konto (ekz. {example})", + "moved_account": "Konto moviÄis.", + "move_account_error": "Eraris movado de konto: {error}", + "wordfilter": "Vortofiltrado", + "word_filter_and_more": "Vortofiltrado kaj pliâĻ", + "mute_bot_posts": "Silentigi afiÅojn de robotoj", + "hide_bot_indication": "KaÅi markon de roboteco en afiÅoj", + "hide_wordfiltered_statuses": "KaÅi vorte filtritajn statojn", + "hide_muted_threads": "KaÅi silentigitajn fadenojn", + "account_privacy": "Privateco", + "user_profiles": "Profiloj de uzantoj", + "hide_favorites_description": "Ne montri liston de miaj Åatatoj (oni tamen sciiÄas)", + "conversation_display_tree": "Arba stilo", + "conversation_display_tree_quick": "Arba vido", + "show_scrollbars": "Montri rulumajn bretojn de flankaj kolumnoj", + "third_column_mode_none": "Neniam montri trian kolumnon", + "third_column_mode_notifications": "Kolumno de sciigoj", + "columns": "Kolumnoj", + "column_sizes": "Grandeco de kolumnoj", + "column_sizes_sidebar": "Flanka breto", + "column_sizes_content": "Enhavo", + "column_sizes_notifs": "Sciigoj", + "tree_advanced": "Permesi pli flekseblan navigadon en arba vido", + "conversation_display_linear": "Linia stilo", + "conversation_other_replies_button": "Montri la butonon ÂĢaliaj respondojÂģ", + "conversation_other_replies_button_below": "Sub statoj", + "conversation_other_replies_button_inside": "En statoj", + "max_depth_in_thread": "Maksimuma nombro de niveloj implicite montrataj en fadeno", + "auto_update": "Montri novajn afiÅojn memage", + "use_at_icon": "Montri simbolon {'@'} kiel bildon anstataÅ teksto", + "mention_link_display": "Montri menciajn ligilojn", + "mention_link_display_short": "Äiam mallonge (ekz. {'@'}zozo)", + "mention_link_display_full": "Äiam plene (ekz. {'@'}zozo{'@'}ekzemplo.org)", + "mention_link_show_avatar": "Montri profilbildon de uzanto apud la ligilo", + "mention_link_show_avatar_quick": "Montri profilbildon de uzanto apud mencioj", + "mention_link_fade_domain": "Malvigligi retnomojn (ekz. {'@'}ekzemplo.org en {'@'}zozo{'@'}ekzemplo.org)", + "mention_link_bolden_you": "Emfazi vian mencion, se vi estas menciita", + "mention_link_use_tooltip": "Montri karton de uzanto per klako al mencia ligilo", + "user_popover_avatar_action_close": "Fermi la ÅprucaÄĩon", + "user_popover_avatar_action_open": "Malfermi la profilon", + "user_popover_avatar_overlay": "Aperigi ÅprucaÄĩon pri uzanto sur profilbildo", + "show_yous": "Montri la markon ÂĢ(Vi)Âģ", + "user_popover_avatar_action_zoom": "Zomi la profilbildon" }, "timeline": { "collapse": "Maletendi", @@ -603,7 +712,6 @@ "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", @@ -619,7 +727,8 @@ "grant_moderator": "Nomumi reguligiston", "revoke_admin": "Malnomumi administranton", "grant_admin": "Nomumi administranton", - "moderation": "Reguligado" + "moderation": "Reguligado", + "delete_user_data_and_deactivate_confirmation": "Tio Äi por Äiam forigos datumojn de tiu Äi konto, kaj malaktivigos Äin. Äu vi plene certas?" }, "show_repeats": "Montri ripetojn", "hide_repeats": "KaÅi ripetojn", @@ -631,7 +740,11 @@ "striped": "Stria fono", "solid": "Unueca fono", "disabled": "Senemfaze" - } + }, + "edit_profile": "Redakti profilon", + "deactivated": "Malaktiva", + "follow_cancel": "Nuligi peton", + "remove_follower": "Forigi abonanton" }, "user_profile": { "timeline_title": "Historio de uzanto", @@ -677,7 +790,19 @@ "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" + "custom": "Propra bildosigno", + "unicode_groups": { + "activities": "Agado", + "animals-and-nature": "Bestoj kaj naturo", + "flags": "Flagoj", + "food-and-drink": "ManÄaÄĩoj kaj trinkaÄĩoj", + "objects": "AÄĩoj", + "people-and-body": "Homoj kaj korpo", + "smileys-and-emotion": "Mienbildoj kaj sentoj", + "symbols": "Simboloj", + "travel-and-places": "VojaÄoj kaj lokoj" + }, + "regional_indicator": "Regiona marko {letter}" }, "polls": { "not_enough_options": "Tro malmultaj unikaj elektebloj en la enketo", @@ -718,7 +843,7 @@ "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 el la historio de ÂĢKonata retoÂģ", + "ftl_removal": "Forigo el la ÂĢFedera historioÂģ", "quarantine_desc": "Äi tiu nodo sendos nur publikajn aīŦÅojn al la jenaj nodoj:", "quarantine": "Kvaranteno", "reject_desc": "Äi tiu nodo ne akceptos mesaÄojn de la jenaj nodoj:", @@ -726,14 +851,16 @@ "accept_desc": "Äi tiu nodo nur akceptas mesaÄojn de la jenaj nodoj:", "accept": "Akcepti", "simple_policies": "Specialaj politikoj de la nodo", - "ftl_removal_desc": "Äi tiu nodo forigas la jenajn nodojn el la historio de ÂĢKonata retoÂģ:" + "ftl_removal_desc": "Äi tiu nodo forigas la jenajn nodojn el la ÂĢFedera historioÂģ:", + "instance": "Nodo", + "reason": "Kialo" }, "mrf_policies": "Åaltis politikon de MesaÄa ÅanÄilaro (MRF)", "keyword": { "is_replaced_by": "â", "replace": "AnstataÅigi", "reject": "Rifuzi", - "ftl_removal": "Forigo el la historio de ÂĢLa tuta konata retoÂģ", + "ftl_removal": "Forigo el la historio de la ÂĢFedera historioÂģ", "keyword_policies": "Politiko pri Äefvortoj" }, "federation": "Federado", @@ -752,7 +879,9 @@ "load_older": "Enlegi pli malnovajn interagojn", "moves": "Migrado de uzantoj", "follows": "Novaj abonoj", - "favs_repeats": "Ripetoj kaj Åatoj" + "favs_repeats": "Ripetoj kaj Åatoj", + "emoji_reactions": "Bildosignaj reagoj", + "reports": "Raportoj" }, "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." @@ -782,7 +911,35 @@ "status_deleted": "Äi tiu afiÅo foriÄis", "nsfw": "Konsterna", "expand": "Etendi", - "external_source": "Ekstera fonto" + "external_source": "Ekstera fonto", + "mentions": "Mencioj", + "you": "(Vi)", + "plus_more": "+{number} pli", + "show_all_attachments": "Montri Äiujn kunsendaÄĩojn", + "collapse_attachments": "KaÅi iujn kunsendaÄĩojn", + "many_attachments": "AfiÅo havas {number} kunsendaÄĩo(j)n", + "show_attachment_in_modal": "Montri en vidilo de vidaÅdaÄĩoj", + "edit": "Redakti afiÅon", + "replies_list_with_others": "Respondoj (+{numReplies} alia): | Respondoj (+{numReplies} aliaj):", + "thread_show": "MalkaÅi Äi tiun fadenon", + "thread_show_full": "Montri Äion en Äi tiu fadeno ({numStatus} afiÅon sume, maksimume en profundeco {depth}) | Montri Äion en Äi tiu fadeno ({numStatus} afiÅojn sume, maksimume en profundeco {depth})", + "show_all_conversation": "Montri plenan interparolon ({numStatus} alian afiÅon) | Montri plenan interparolon ({numStatus} aliajn afiÅojn)", + "edited_at": "(lastafoje redaktita je {time})", + "remove_attachment": "Forigi kunsendaÄĩon", + "show_attachment_description": "AntaÅvidi priskribon (malfermu kunsendaÄĩon por vidi plenan priskribon)", + "hide_attachment": "KaÅi kunsendaÄĩon", + "attachment_stop_flash": "Äesigi ludilon de [Flash]", + "move_up": "Åovi kunsendaÄĩon antaÅen", + "move_down": "Åovi kunsendaÄĩon posten", + "thread_hide": "KaÅi Äi tiun fadenon", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "Montri ceteron de Äi tiu fadeno ({numStatus} afiÅon sume) | Montri ceteron de Äi tiu fadeno ({numStatus} afiÅojn sume)", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "Vidi {numReplies} alian respondon sub Äi tiu afiÅo | Vidi {numReplies} aliajn respondojn sub Äi tiu afiÅo", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "show_only_conversation_under_this": "Montri nur respondojn al Äi tiu afiÅo", + "status_history": "Historio de afiÅo" }, "time": { "years_short": "{0}j", @@ -816,7 +973,23 @@ "days_short": "{0}t", "day_short": "{0}t", "days": "{0} tagoj", - "day": "{0} tago" + "day": "{0} tago", + "unit": { + "days": "{0} tago | {0} tagoj", + "minutes": "{0} minuto | {0} minutoj", + "days_short": "{0}t", + "hours": "{0} horo | {0} horoj", + "hours_short": "{0}h", + "minutes_short": "{0}min", + "months": "{0} monato | {0} monatoj", + "months_short": "{0}mo", + "seconds": "{0} sekundo | {0} sekundoj", + "seconds_short": "{0}sek", + "weeks": "{0} semajno | {0} semajnoj", + "weeks_short": "{0}sem", + "years": "{0} jaro | {0} jaroj", + "years_short": "{0}j" + } }, "search": { "people": "Personoj", @@ -870,5 +1043,68 @@ }, "shoutbox": { "title": "Kriujo" + }, + "report": { + "reporter": "Raportinto:", + "reported_user": "Raportito:", + "reported_statuses": "Raportitaj statoj:", + "notes": "Notoj:", + "state": "Stato:", + "state_open": "Malfermita", + "state_closed": "Fermita", + "state_resolved": "Solvita" + }, + "lists": { + "editing_list": "Redaktado de listo {listTitle}", + "lists": "Listoj", + "new": "Nova listo", + "title": "Nomo de listo", + "search": "SerÄi uzantojn", + "create": "Krei", + "save": "Konservi ÅanÄojn", + "delete": "Forigi liston", + "following_only": "Limigi al abonatoj", + "manage_lists": "Mastrumi listojn", + "manage_members": "Mastrumi listanojn", + "add_members": "SerÄi pliajn uzantojn", + "remove_from_list": "Forigi de listo", + "add_to_list": "Aldoni al listo", + "is_in_list": "Jam en listo", + "creating_list": "Kreado de nova listo", + "update_title": "Konservi nomon", + "really_delete": "Äu vi certe volas forigi la liston?", + "error": "Eraris umado je listoj: {0}" + }, + "update": { + "big_update_content": "Ni longe ne eldonis novan version, kaj tial aferoj eble aspektos iom malsame, ol antaÅe.", + "update_bugs": "Bonvolu raporti problemojn kaj erarojn Äe {pleromaGitlab}, Äar ni ÅanÄis multon, kaj kvankam ni zorge testas kaj mem uzas la prilaboratajn versiojn, ni tamen povas preteratenti ion. Ni bonvenigas viajn rimarkojn kaj proponojn pri renkontitaj eraroj aÅ proponoj plibonigi Pleromon.", + "big_update_title": "Bonvolu pacienci", + "update_bugs_gitlab": "GitLab de Pleroma", + "update_changelog": "Por legi detalojn pri ÅanÄoj, vidu {theFullChangelog}.", + "update_changelog_here": "la plenan ÅanÄaron", + "art_by": "Arto de {linkToArtist}" + }, + "unicode_domain_indicator": { + "tooltip": "Äi tiu retnomo enhavas signojn ekster ASCII." + }, + "announcements": { + "all_day_prompt": "Äi tio estas tuttaga okazo", + "page_header": "Anoncoj", + "title": "Anonco", + "mark_as_read_action": "Marki legita", + "post_placeholder": "Entajpu vian anoncon tie ÄiâĻ", + "post_action": "AfiÅi", + "post_error": "Eraro: {error}", + "close_error": "Fermi", + "delete_action": "Forigi", + "start_time_prompt": "Komenca tempo: ", + "end_time_prompt": "Fina tempo: ", + "published_time_display": "Publikigita je {time}", + "start_time_display": "KomenciÄas je {time}", + "end_time_display": "FiniÄas je {time}", + "edit_action": "Redakti", + "submit_edit_action": "AfiÅi", + "cancel_edit_action": "Nuligi", + "inactive_message": "Äi tiu anonco estas neaktiva" } } diff --git a/src/i18n/es.json b/src/i18n/es.json index 5f4db163..9887f007 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -599,7 +599,10 @@ "backup_restore": "Copia de seguridad de la configuraciÃŗn" }, "hide_shoutbox": "Ocultar cuadro de diÃĄlogo de la instancia", - "right_sidebar": "Mostrar la barra lateral a la derecha" + "right_sidebar": "Mostrar la barra lateral a la derecha", + "always_show_post_button": "Muestra siempre el botÃŗn flotante de Nueva PlubicaciÃŗn", + "mentions_new_style": "Enlaces de menciones mÃĄs elegantes", + "mentions_new_place": "Situa las menciones en una lÃnea separada" }, "time": { "day": "{0} dÃa", @@ -676,7 +679,10 @@ "status_deleted": "Esta publicaciÃŗn ha sido eliminada", "nsfw": "NSFW (No apropiado para el trabajo)", "expand": "Expandir", - "external_source": "Fuente externa" + "external_source": "Fuente externa", + "mentions": "Menciones", + "you": "(TÃē)", + "plus_more": "+{number} mÃĄs" }, "user_card": { "approve": "Aprobar", @@ -725,8 +731,7 @@ "disable_remote_subscription": "No permitir que usuarios de instancias remotas te siga", "disable_any_subscription": "No permitir que ningÃēn usuario te siga", "quarantine": "No permitir publicaciones de usuarios de instancias remotas", - "delete_user": "Eliminar usuario", - "delete_user_confirmation": "ÂŋEstÃĄs completamente seguro? Esta acciÃŗn no se puede deshacer." + "delete_user": "Eliminar usuario" }, "show_repeats": "Mostrar repetidos", "hide_repeats": "Ocultar repetidos", diff --git a/src/i18n/eu.json b/src/i18n/eu.json index 539ee1bd..4e6ea550 100644 --- a/src/i18n/eu.json +++ b/src/i18n/eu.json @@ -609,8 +609,7 @@ "disable_remote_subscription": "Ez utzi istantzia kanpoko erabiltzaileak zuri jarraitzea", "disable_any_subscription": "Ez utzi beste erabiltzaileak zuri jarraitzea", "quarantine": "Ez onartu mezuak beste instantzietatik", - "delete_user": "Erabiltzailea ezabatu", - "delete_user_confirmation": "Erabat ziur zaude? Ekintza hau ezin da desegin." + "delete_user": "Erabiltzailea ezabatu" } }, "user_profile": { diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 7b5244cb..f8c3b4ae 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -620,8 +620,7 @@ "sandbox": "Pakota viestit vain seuraajille", "disable_remote_subscription": "Estä seuraaminen ulkopuolisilta sivuilta", "quarantine": "Estä käyttäjän viestin federoituminen", - "delete_user": "Poista käyttäjä", - "delete_user_confirmation": "Oletko aivan varma? Tätä ei voi kumota." + "delete_user": "Poista käyttäjä" }, "favorites": "Tykkäykset", "mention": "Mainitse", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 6d3c75d1..f86d1821 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -15,7 +15,8 @@ "title": "FonctionnalitÊs", "who_to_follow": "Suggestions de suivis", "pleroma_chat_messages": "Chat Pleroma", - "upload_limit": "Limite de tÊlÊversement" + "upload_limit": "Limite de tÊlÊversement", + "shout": "Shoutbox" }, "finder": { "error_fetching_user": "Erreur lors de la recherche du compte", @@ -44,9 +45,23 @@ "moderator": "Modo'", "admin": "Admin" }, - "flash_content": "Clique pour afficher le contenu Flash avec Ruffle (ExpÊrimental, peut ne pas fonctionner).", + "flash_content": "Cliquer pour afficher le contenu Flash avec Ruffle (ExpÊrimental, peut ne pas fonctionner).", "flash_security": "Cela reste potentiellement dangereux, Flash restant du code arbitraire.", - "flash_fail": "Ãchec de chargement du contenu Flash, voir la console pour les dÊtails." + "flash_fail": "Ãchec de chargement du contenu Flash, voir la console pour les dÊtails.", + "scope_in_timeline": { + "direct": "Direct", + "public": "Publique", + "private": "AbonnÊâ
eâ
s seulement", + "unlisted": "Non-listÊ" + }, + "undo": "DÊfaire", + "yes": "Oui", + "no": "Non", + "unpin": "DÊgrafer l'ÊlÊment", + "scroll_to_top": "DÊfiler au dÊbut", + "pin": "Agrafer l'ÊlÊment", + "generic_error_message": "Une erreur est apparue : {0}", + "never_show_again": "Ne plus afficher" }, "image_cropper": { "crop_picture": "Rogner l'image", @@ -79,7 +94,9 @@ }, "media_modal": { "previous": "PrÊcÊdent", - "next": "Suivant" + "next": "Suivant", + "counter": "{current} / {total}", + "hide": "Fermer le visualiseur multimÊdia" }, "nav": { "about": "à propos", @@ -100,7 +117,14 @@ "chats": "Chats", "bookmarks": "Marques-Pages", "timelines": "Flux", - "home_timeline": "Flux personnel" + "home_timeline": "Flux personnel", + "edit_nav_mobile": "Personnaliser la barre de navigation", + "mobile_notifications": "Ouvrir les notifications (il y en a de nouvelles)", + "lists": "Listes", + "edit_pinned": "Ãditer les ÊlÊments agrafÊs", + "edit_finish": "Ãdition terminÊe", + "mobile_sidebar": "(DÊs)activer le panneau latÊral", + "mobile_notifications_close": "Fermer les notifications" }, "notifications": { "broken_favorite": "Message inconnu, recherche en coursâĻ", @@ -114,13 +138,17 @@ "migrated_to": "a migrÊ à ", "reacted_with": "a rÊagi avec {0}", "follow_request": "veut vous suivre", - "error": "Erreur de chargement des notificationsâ¯: {0}" + "error": "Erreur de chargement des notificationsâ¯: {0}", + "poll_ended": "Sondage terminÊ", + "submitted_report": "Rapport envoyÊ" }, "interactions": { "favs_repeats": "Partages et favoris", "follows": "Nouveaux suivis", "load_older": "Chargez d'anciennes interactions", - "moves": "Migrations de comptes" + "moves": "Migrations de comptes", + "emoji_reactions": "Ãmoticônes de rÊaction", + "reports": "Rapports" }, "post_status": { "new_status": "Poster un nouveau statut", @@ -154,7 +182,10 @@ "preview_empty": "Vide", "preview": "PrÊvisualisation", "media_description": "Description de la pièce-jointe", - "post": "Post" + "post": "Post", + "edit_status": "Ãditer le status", + "edit_remote_warning": "Des instances distantes pourraient ne pas supporter l'Êdition et seront incapables de recevoir la nouvelle version de votre post.", + "edit_unsupported_warning": "Pleroma ne supporte pas l'Êdition de mentions ni de sondages." }, "registration": { "bio": "Biographie", @@ -178,7 +209,10 @@ }, "reason_placeholder": "Cette instance modère les inscriptions manuellement.\nExpliquer ce qui motive votre inscription à l'administration.", "reason": "Motivation d'inscription", - "register": "Enregistrer" + "register": "Enregistrer", + "email_language": "Dans quelle langue voulez-vous recevoir les emails du server ?", + "bio_optional": "Biographie (optionnelle)", + "email_optional": "Courriel (optionnel)" }, "selectable_list": { "select_all": "Tout selectionner" @@ -267,8 +301,8 @@ "import_theme": "Charger le thème", "inputRadius": "Champs de texte", "checkboxRadius": "Cases à cocher", - "instance_default": "(defaultâ¯: {value})", - "instance_default_simple": "(default)", + "instance_default": "(dÊfautâ¯: {value})", + "instance_default_simple": "(dÊfaut)", "interface": "Interface", "interfaceLanguage": "Langue de l'interface", "invalid_theme_imported": "Le fichier sÊlectionnÊ n'est pas un thème Pleroma pris en charge. Aucun changement n'a ÊtÊ apportÊ à votre thème.", @@ -570,7 +604,87 @@ "restore_settings": "Restaurer les paramètres depuis un fichier" }, "hide_shoutbox": "Cacher la shoutbox de l'instance", - "right_sidebar": "Afficher le paneau latÊral à droite" + "right_sidebar": "Afficher le paneau latÊral à droite", + "expert_mode": "PrÊfÊrences AvancÊes", + "post_look_feel": "Affichage des messages", + "mention_links": "Liens des mentions", + "email_language": "Langue pour recevoir les emails du server", + "account_backup_table_head": "Sauvegarde", + "download_backup": "TÊlÊcharger", + "backup_not_ready": "La sauvegarde n'est pas encore prÃĒte.", + "remove_backup": "Supprimer", + "list_backups_error": "Erreur d'obtention de la liste des sauvegardes : {error}", + "add_backup": "CrÊer une nouvelle sauvegarde", + "added_backup": "Ajouter une nouvelle sauvegarde.", + "account_alias": "Alias du compte", + "account_alias_table_head": "Alias", + "list_aliases_error": "Erreur à l'obtention des aliasâ¯: {error}", + "hide_list_aliases_error_action": "Fermer", + "remove_alias": "Supprimer cet alias", + "new_alias_target": "Ajouter un nouvel alias (ex. {example})", + "added_alias": "L'alias à ÊtÊ ajoutÊ.", + "add_alias_error": "Erreur à l'ajout de l'alias : {error}", + "move_account_target": "Compte cible (ex. {example})", + "moved_account": "Compte dÊplacÊ.", + "move_account_error": "Erreur au dÊplacement du compte : {error}", + "wordfilter": "Filtrage de mots", + "mute_bot_posts": "Masquer les messages des robots", + "hide_bot_indication": "Cacher l'indication d'un robot avec les messages", + "always_show_post_button": "Toujours montrer le bouton flottant Nouveau Message", + "hide_muted_threads": "Cacher les fils masquÊs", + "account_privacy": "IntimitÊ", + "posts": "Messages", + "disable_sticky_headers": "Ne pas coller les en-tÃĒtes des colonnes en haut de l'Êcran", + "show_scrollbars": "Montrer les ascenseurs des colonnes", + "third_column_mode_none": "Jamais afficher la troisième colonne", + "third_column_mode_notifications": "Colonne de notifications", + "third_column_mode_postform": "Ãdition de messages et navigation", + "tree_advanced": "Permettre une navigation plus flexible dans l'arborescence", + "conversation_display_linear": "Style linÊaire", + "conversation_other_replies_button": "Montrer le bouton \"autres rÊponses\"", + "conversation_other_replies_button_below": "En-dessous des messages", + "conversation_other_replies_button_inside": "Dans les messages", + "max_depth_in_thread": "Profondeur maximum à afficher par dÊfaut dans un fil", + "mention_link_display": "Afficher les mentions", + "mention_link_display_full_for_remote": "complet pour les comptes distants (ex. {'@'}foo{'@'}example.org)", + "mention_link_display_full": "toujours complet (ex. {'@'}foo{'@'}example.org)", + "mention_link_show_avatar": "Afficher les avatars à cotÊ du lien", + "mention_link_fade_domain": "Estomper les domaines (ex. {'@'}example.org en {'@'}foo{'@'}example.org)", + "mention_link_bolden_you": "Surligner les mentions qui vous sont destinÊes", + "show_yous": "Afficher (Vous)", + "setting_server_side": "Cette prÊfÊrence est liÊe au profile et affecte toutes les sessions et clients", + "account_backup": "Sauvegarde de compte", + "account_backup_description": "Ceci permet de tÊlÊcharger une archive des informations du compte et vos messages, mais ils ne peuvent pas actuellement ÃĒtre importÊ dans un compte Pleroma.", + "add_backup_error": "Erreur à l'ajout d'une nouvelle sauvegarde : {error}", + "move_account": "DÊplacer le compte", + "move_account_notes": "Si vous voulez dÊplacer le compte ailleurs, vous devez aller sur votre compte cible et y crÊer un alias pointant ici.", + "hide_wordfiltered_statuses": "Cacher les messages filtrÊ par un mot", + "user_profiles": "Profils des utilisateurâ
iceâ
s", + "notification_visibility_polls": "Fins de sondage auquel vous avez votÃŠÂˇe", + "hide_favorites_description": "Ne pas montrer ma liste de favoris (les personnes sont quand mÃĒme notifiÊs)", + "conversation_display": "Style d'affichage des conversations", + "conversation_display_tree": "Arborescence", + "third_column_mode": "Quand il-y-a assez d'espace, afficher une troisième colonne avec", + "tree_fade_ancestors": "Montrer les parents du message courant en texte lÊger", + "use_at_icon": "Montrer le symbole {'@'} comme une icône au lieu de textuelle", + "mention_link_display_short": "toujours raccourcies (ex. {'@'}foo)", + "mention_link_show_tooltip": "Montrer le nom complet pour les comptes distants dans une info-bulle", + "lists_navigation": "Afficher les listes dans la navigation", + "word_filter_and_more": "Filtrer par mots et plus ...", + "columns": "Colonnes", + "auto_update": "Afficher automatiquement les nouveaux posts", + "mention_link_use_tooltip": "Montrer le profil utilisateur en cliquant sur les liens de mentions", + "mention_link_show_avatar_quick": "Afficher l'avatar de l'utilisateur à côtÊ des mentions", + "navbar_column_stretch": "Ãlargir la barre de navigation à la taille des colonnes", + "column_sizes": "Taille des colonnes", + "column_sizes_sidebar": "Panneau latÊral", + "column_sizes_content": "Contenu", + "column_sizes_notifs": "Notifications", + "conversation_display_linear_quick": "Vue linÊaire", + "use_websockets": "Utiliser les websockets (mises à jour en temps rÊel)", + "user_popover_avatar_action_zoom": "Zoomer sur l'avatar", + "user_popover_avatar_action_open": "Ouvrir le profil", + "conversation_display_tree_quick": "Vue arborescente" }, "timeline": { "collapse": "Fermer", @@ -586,7 +700,9 @@ "reload": "Recharger", "error": "Erreur lors de l'affichage du flux : {0}", "socket_broke": "Connexion temps-rÊel perdue : CloseEvent code {0}", - "socket_reconnected": "Connexion temps-rÊel Êtablie" + "socket_reconnected": "Connexion temps-rÊel Êtablie", + "quick_view_settings": "Afficher les rÊglages rapides", + "quick_filter_settings": "Afficher les filtres rapides" }, "status": { "favorites": "Favoris", @@ -613,7 +729,36 @@ "thread_muted": "Fil de discussion masquÊ", "external_source": "Source externe", "unbookmark": "Supprimer des favoris", - "bookmark": "Ajouter aux favoris" + "bookmark": "Ajouter aux favoris", + "plus_more": "plus +{number}", + "many_attachments": "Message avec {number} pièce(s)-jointe(s)", + "collapse_attachments": "RÊduire les pièces jointes", + "show_attachment_in_modal": "Montrer dans le visionneur de mÊdias", + "hide_attachment": "Cacher la pièce jointe", + "you": "(Vous)", + "attachment_stop_flash": "ArrÃĒter Flash Player", + "move_down": "DÊcaler la pièce-jointe à droite", + "thread_hide": "Cacher ce fil", + "thread_show": "Montrer ce fil", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "Montrer le reste du fil ({numStatus} message) | Montrer le reste du fil ({numStatus} messages)", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "Monter les {numReplies} autres rÊponses après ce message | Monter les {numReplies} autres rÊponses après ce message", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "show_only_conversation_under_this": "Montrer uniquement les rÊponses à ce message", + "mentions": "Mentions", + "replies_list_with_others": "RÊponses (+{numReplies} autres) : | RÊponses (+{numReplies} autres) :", + "show_all_attachments": "Montrer toutes les pièces jointes", + "show_attachment_description": "PrÊvisualiser la description (ouvrir la pièce-jointe pour la description complète)", + "remove_attachment": "Enlever la pièce jointe", + "move_up": "DÊcaler la pièce-jointe à gauche", + "open_gallery": "Ouvrir la galerie", + "thread_show_full": "Montrer tout le fil ({numStatus} message, {depth} niveaux maximum) | Montrer tout le fil ({numStatus} messages, {depth} niveaux maximum)", + "show_all_conversation": "Montrer tout le fil ({numStatus} autre message) | Montrer tout le fil ({numStatus} autre messages)", + "edit": "Ãditer le status", + "edited_at": "(dernière Êdition {time})", + "status_history": "Historique du status" }, "user_card": { "approve": "Accepter", @@ -644,11 +789,11 @@ "unmute_progress": "DÊmasquageâĻ", "mute_progress": "MasquageâĻ", "admin_menu": { - "moderation": "Moderation", + "moderation": "ModÊration", "grant_admin": "Promouvoir Administrateurâ
ice", - "revoke_admin": "DÊgrader Administrateurâ
ice", + "revoke_admin": "DÊgrader L'administrateurâ
ice", "grant_moderator": "Promouvoir ModÊrateurâ
ice", - "revoke_moderator": "DÊgrader ModÊrateurâ
ice", + "revoke_moderator": "DÊgrader la¡e modÊrateurâ
ice", "activate_account": "Activer le compte", "deactivate_account": "DÊsactiver le compte", "delete_account": "Supprimer le compte", @@ -660,7 +805,7 @@ "disable_any_subscription": "Interdir de s'abonner à l'utilisateur tout court", "quarantine": "Interdir les statuts de l'utilisateur à fÊdÊrer", "delete_user": "Supprimer l'utilisateur", - "delete_user_confirmation": "Ãtes-vous absolument-sÃģrâ
e ? Cette action ne peut ÃĒtre annulÊe." + "delete_user_data_and_deactivate_confirmation": "Ceci va supprimer les donnÊes du compte de manière permanente et le dÊsactivÊ. Ãtes-vous vraiment sÃģr ?" }, "mention": "Mention", "hidden": "CachÊ", @@ -680,7 +825,10 @@ "striped": "Fond rayÊ" }, "bot": "Robot", - "edit_profile": "Ãditer le profil" + "edit_profile": "Ãditer le profil", + "deactivated": "DÊsactivÊ", + "follow_cancel": "Annuler la requÃĒte", + "remove_follower": "Retirer l'abonnÃŠÂˇe" }, "user_profile": { "timeline_title": "Flux du compte", @@ -748,13 +896,16 @@ "media_removal_desc": "Cette instance supprime le contenu multimÊdia des instances suivantes :", "media_nsfw": "Force le contenu multimÊdia comme sensible", "ftl_removal": "SupprimÊes du flux fÊdÊrÊ", - "media_nsfw_desc": "Cette instance force les pièce-jointes comme sensible pour les messages des instances suivantes :" + "media_nsfw_desc": "Cette instance force les pièce-jointes comme sensible pour les messages des instances suivantes :", + "reason": "Raison", + "not_applicable": "N/A", + "instance": "Instance" }, "federation": "FÊdÊration", "mrf_policies": "Politiques MRF actives", "mrf_policies_desc": "Les politiques MRF modifient la fÊdÊration entre les instances. Les politiques suivantes sont activÊes :" }, - "staff": "Staff" + "staff": "Ãquipe" }, "domain_mute_card": { "mute": "MasquÊ", @@ -787,7 +938,19 @@ "load_all": "Charger tout les {emojiAmount} Êmojis", "load_all_hint": "{saneAmount} Êmojis chargÊ, charger tout les Êmojis peuvent causer des problèmes de performances.", "stickers": "Stickers", - "keep_open": "Garder ouvert" + "keep_open": "Garder ouvert", + "unicode_groups": { + "activities": "ActivitÊs", + "animals-and-nature": "Animaux & nature", + "flags": "Drapeaux", + "food-and-drink": "Nourriture & boissons", + "objects": "Objets", + "people-and-body": "Personnes & Corps", + "smileys-and-emotion": "Emoticônes", + "symbols": "Symboles", + "travel-and-places": "Voyages & lieux" + }, + "regional_indicator": "Indicateur rÊgional {letter}" }, "remote_user_resolver": { "error": "Non trouvÊ.", @@ -826,14 +989,32 @@ "year": "{0} annÊe", "years": "{0} annÊes", "year_short": "{0}a", - "years_short": "{0}a" + "years_short": "{0}a", + "unit": { + "years": "{0} annÊe | {0} annÊes", + "years_short": "{0}ans", + "days_short": "{0}j", + "hours": "{0} heure | {0} heures", + "hours_short": "{0}h", + "minutes": "{0} minute | {0} minutes", + "minutes_short": "{0}min", + "months_short": "{0}mois", + "seconds": "{0} seconde | {0} secondes", + "seconds_short": "{0}s", + "weeks": "{0} semaine | {0} semaines", + "days": "{0} jour | {0} jours", + "months": "{0} mois | {0} mois", + "weeks_short": "{0}semaine" + } }, "search": { "people": "Comptes", "person_talking": "{count} personnes discutant", "hashtags": "Mot-dièses", "people_talking": "{count} personnes discutant", - "no_results": "Aucun rÊsultats" + "no_results": "Aucun rÊsultats", + "no_more_results": "Pas de rÊsultats supplÊmentaires", + "load_more": "Charger plus de rÊsultats" }, "password_reset": { "forgot_password": "Mot de passe oubliÊ ?", @@ -874,5 +1055,47 @@ "delete": "Effacer", "message_user": "Message à {nickname}", "you": "Vous :" + }, + "lists": { + "new": "Nouvelle liste", + "title": "Titre de la liste", + "create": "CrÊer", + "save": "Sauvegarder les changements", + "delete": "Supprimer la liste", + "following_only": "Limiter aux abonnÃŠÂˇe¡s", + "manage_lists": "GÊrer les listes", + "add_members": "Rechercher plus d'utilisateurs", + "remove_from_list": "Retirer de la liste", + "add_to_list": "Ajouter à la liste", + "is_in_list": "DÊjà dans la liste", + "editing_list": "Ãdition de la liste {listTitle}", + "creating_list": "CrÊation d'une nouvelle liste", + "really_delete": "Ãtes-vous sÃģr¡e de vouloir supprimer la liste ?", + "error": "Erreur en manipulant les listes : {0}", + "lists": "Listes", + "search": "Rechercher des utilisateurs", + "manage_members": "GÊrer les membres des listes", + "update_title": "Sauvegarder le titre" + }, + "update": { + "update_bugs_gitlab": "GitLab du projet Pleroma", + "update_changelog": "Pour plus de dÊtails sur les changements, consultez {theFullChangelog}.", + "update_changelog_here": "Liste compète des changements", + "art_by": "Åuvre par {linkToArtist}", + "big_update_content": "Nous n'avons pas fait de nouvelle version depuis un moment, les choses peuvent vous paraitre diffÊrentes de vos habitudes.", + "update_bugs": "Veuillez rapporter les problèmes sur {pleromaGitlab}, comme beaucoup de changements on ÊtÊ fait, mÃĒme si nous testons entièrement et utilisons la version de dÊvelopement nous-mÃĒme, nous avons pu en louper. Les retours et suggestions sont bienvenues sur ce que vous avez pu rencontrer, ou sur comment amÊliorer Pleroma (BE) et Pleroma-FE." + }, + "unicode_domain_indicator": { + "tooltip": "Ce domaine contient des caractères non ascii." + }, + "report": { + "reporter": "Rapporteur¡euse :", + "reported_user": "Compte rapportÊ :", + "reported_statuses": "Status rapportÊs :", + "notes": "Notes :", + "state": "Status :", + "state_open": "Ouvert", + "state_closed": "FermÊ", + "state_resolved": "RÊsolut" } } diff --git a/src/i18n/he.json b/src/i18n/he.json index b0c59a30..6c62acc4 100644 --- a/src/i18n/he.json +++ b/src/i18n/he.json @@ -347,8 +347,7 @@ "disable_remote_subscription": "×× ×Ē×פ׊ר ×ĸ×§××× ×Š× ××׊×Ē×׊ ×××× ×Ą×× ×Ą ××ר", "disable_any_subscription": "×× ×Ē×פ׊ר ×ĸ×§××× ×Š× ××׊×Ē×׊ ××××", "quarantine": "×× ×Ē×פ׊ר פ×ר×Ļ×× ×Š× ×××××ĸ××Ē ×Š× ××׊×Ē×׊", - "delete_user": "×××§ ×׊×Ē×׊", - "delete_user_confirmation": "××××? פ×ĸ××× ×× ××× × ×××Ē× ×פ×××." + "delete_user": "×××§ ×׊×Ē×׊" } }, "user_profile": { diff --git a/src/i18n/id.json b/src/i18n/id.json index e6b5eb94..73cc2a71 100644 --- a/src/i18n/id.json +++ b/src/i18n/id.json @@ -208,7 +208,13 @@ "enable_web_push_notifications": "Aktifkan notifikasi push web", "more_settings": "Lebih banyak pengaturan", "reply_visibility_all": "Tampilkan semua balasan", - "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya" + "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya", + "hide_muted_posts": "Sembunyikan postingan-postingan dari pengguna yang dibisukan", + "import_blocks_from_a_csv_file": "Impor blokiran dari berkas csv", + "domain_mutes": "Domain", + "composing": "Menulis", + "no_blocks": "Tidak ada yang diblokir", + "no_mutes": "Tidak ada yang dibisukan" }, "about": { "mrf": { @@ -222,7 +228,9 @@ "reject_desc": "Instansi ini tidak akan menerima pesan dari instansi-instansi berikut:", "reject": "Tolak", "accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:", - "accept": "Terima" + "accept": "Terima", + "media_removal": "Penghapusan Media", + "media_removal_desc": "Instansi ini menghapus media dari postingan yang berasal dari instansi-instansi berikut:" }, "federation": "Federasi", "mrf_policies": "Kebijakan MRF yang diaktifkan" @@ -319,8 +327,7 @@ "delete_account": "Hapus akun", "force_nsfw": "Tandai semua postingan sebagai NSFW", "strip_media": "Hapus media dari postingan-postingan", - "delete_user": "Hapus pengguna", - "delete_user_confirmation": "Apakah Anda benar-benar yakin? Tindakan ini tidak dapat dibatalkan." + "delete_user": "Hapus pengguna" }, "follow_unfollow": "Berhenti mengikuti", "followees": "Mengikuti", @@ -334,7 +341,9 @@ "message": "Kirimkan pesan" }, "user_profile": { - "timeline_title": "Linimasa pengguna" + "timeline_title": "Linimasa pengguna", + "profile_does_not_exist": "Maaf, profil ini tidak ada.", + "profile_loading_error": "Maaf, terjadi kesalahan ketika memuat profil ini." }, "user_reporting": { "title": "Melaporkan {0}", diff --git a/src/i18n/it.json b/src/i18n/it.json index 6fc1d05a..c8c74b70 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -448,7 +448,10 @@ "backup_restore": "Archiviazione impostazioni" }, "right_sidebar": "Mostra barra laterale a destra", - "hide_shoutbox": "Nascondi muro dei graffiti" + "hide_shoutbox": "Nascondi muro dei graffiti", + "mentions_new_style": "Menzioni abbreviate", + "mentions_new_place": "Segrega le menzioni", + "always_show_post_button": "Non nascondere il pulsante di composizione" }, "timeline": { "error_fetching": "Errore nell'aggiornamento", @@ -482,7 +485,6 @@ "deny": "Nega", "remote_follow": "Segui da remoto", "admin_menu": { - "delete_user_confirmation": "Ne sei completamente sicuro? Non potrai tornare indietro.", "delete_user": "Elimina utente", "quarantine": "I messaggi non arriveranno alle altre stanze", "disable_any_subscription": "Rendi utente non seguibile", @@ -757,7 +759,10 @@ "status_deleted": "Questo messagio è stato cancellato", "nsfw": "DISDICEVOLE", "external_source": "Vai all'origine", - "expand": "Espandi" + "expand": "Espandi", + "mentions": "Menzioni", + "you": "(Tu)", + "plus_more": "+{number} altri" }, "time": { "years_short": "{0} a", @@ -774,8 +779,8 @@ "second": "{0} secondo", "now_short": "adesso", "now": "adesso", - "months_short": "{0} ms", - "month_short": "{0} ms", + "months_short": "{0} mes", + "month_short": "{0} mes", "months": "{0} mesi", "month": "{0} mese", "minutes_short": "{0} min", diff --git a/src/i18n/ja_easy.json b/src/i18n/ja_easy.json index f64943d9..abca262b 100644 --- a/src/i18n/ja_easy.json +++ b/src/i18n/ja_easy.json @@ -608,8 +608,7 @@ "disable_remote_subscription": "ãģããŽã¤ãŗãšãŋãŗãšããããŠããŧãããĒããããĢãã", "disable_any_subscription": "ããŠããŧãããĒããããĢãã", "quarantine": "ãģããŽã¤ãŗãšãŋãŗãšãŽãĻãŧãļãŧãŽã¨ããããã¨ãã", - "delete_user": "ãĻãŧãļãŧããã", - "delete_user_confirmation": "ããĒãã¯ããģãã¨ããĢããã¯ãããã§ããīŧ ããã¯ãã¨ããããã¨ããã§ããžããã" + "delete_user": "ãĻãŧãļãŧããã" } }, "user_profile": { diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json index 7241c9ac..fddf24db 100644 --- a/src/i18n/ja_pedantic.json +++ b/src/i18n/ja_pedantic.json @@ -43,7 +43,10 @@ "role": { "moderator": "ãĸããŦãŧãŋãŧ", "admin": "įŽĄįč
" - } + }, + "flash_security": "FlashãŗãŗããŗããäģģæãŽåŊäģ¤ãåŽčĄããããã¨ãĢããããŗãŗããĨãŧãŋãŧãåąéēãĢããããããã¨ããããžãã", + "flash_fail": "FlashãŗãŗããŗããŽčĒãŋčžŧãŋãĢå¤ąæããžããããŗãŗãŊãŧãĢã§čŠŗį´°ãįĸēčĒã§ããžãã", + "flash_content": "īŧčŠĻé¨įæŠčŊīŧã¯ãĒãã¯ããĻFlashãŗãŗããŗããåįããžãã" }, "image_cropper": { "crop_picture": "įģåãåãæã", @@ -586,14 +589,18 @@ "word_filter": "åčĒããŖãĢãŋ", "file_export_import": { "errors": { - "invalid_file": "ããã¯PleromaãŽč¨åŽãããã¯ãĸããããããĄã¤ãĢã§ã¯ãããžããã" + "invalid_file": "ããã¯PleromaãŽč¨åŽãããã¯ãĸããããããĄã¤ãĢã§ã¯ãããžããã", + "file_slightly_new": "ããĄã¤ãĢãŽãã¤ããŧããŧã¸ã§ãŗãį°ãĒããä¸é¨ãŽč¨åŽãčĒãŋčžŧãžããĒããã¨ããããžã" }, "restore_settings": "č¨åŽãããĄã¤ãĢãã垊å
ãã", "backup_settings_theme": "ããŧããåĢãč¨åŽãããĄã¤ãĢãĢããã¯ãĸãããã", "backup_settings": "č¨åŽãããĄã¤ãĢãĢããã¯ãĸãããã", "backup_restore": "č¨åŽãããã¯ãĸãã" }, - "save": "夿´ãäŋå" + "save": "夿´ãäŋå", + "hide_shoutbox": "Shoutboxã襨į¤ēããĒã", + "always_show_post_button": "æį¨ŋããŋãŗã常ãĢ襨į¤ē", + "right_sidebar": "ãĩã¤ãããŧãåŗãĢ襨į¤ē" }, "time": { "day": "{0}æĨ", @@ -641,7 +648,9 @@ "no_more_statuses": "ããã§įĩããã§ã", "no_statuses": "ãšããŧãŋãšã¯ãããžãã", "reload": "åčĒãŋčžŧãŋ", - "error": "ãŋã¤ã ãŠã¤ãŗãŽčĒãŋčžŧãŋãĢå¤ąæããžãã: {0}" + "error": "ãŋã¤ã ãŠã¤ãŗãŽčĒãŋčžŧãŋãĢå¤ąæããžãã: {0}", + "socket_reconnected": "ãĒãĸãĢãŋã¤ã æĨįļãįĸēįĢãããžãã", + "socket_broke": "ãŗãŧã{0}ãĢãããĒãĸãĢãŋã¤ã æĨįļãåæãããžãã" }, "status": { "favorites": "ãæ°ãĢå
Ĩã", @@ -668,7 +677,10 @@ "copy_link": "ãĒãŗã¯ããŗããŧ", "status_unavailable": "åŠį¨ã§ããžãã", "unbookmark": "ããã¯ããŧã¯č§Ŗé¤", - "bookmark": "ããã¯ããŧã¯" + "bookmark": "ããã¯ããŧã¯", + "mentions": "ãĄãŗãˇã§ãŗ", + "you": "īŧããĒãīŧ", + "plus_more": "ãģã{number}äģļ" }, "user_card": { "approve": "åãå
Ĩã", @@ -717,8 +729,7 @@ "disable_remote_subscription": "äģãŽã¤ãŗãšãŋãŗãšããããŠããŧãããĒããããĢãã", "disable_any_subscription": "ããŠããŧãããĒããããĢãã", "quarantine": "äģãŽã¤ãŗãšãŋãŗãšãããŽæį¨ŋãæĸãã", - "delete_user": "ãĻãŧãļãŧãåé¤", - "delete_user_confirmation": "ããĒããŽį˛žįĨįļæ
ãĢäŊãåéĄã¯ããããžãããīŧ ããŽæäŊãåãæļããã¨ã¯ã§ããžããã" + "delete_user": "ãĻãŧãļãŧãåé¤" }, "roles": { "moderator": "ãĸããŦãŧãŋãŧ", @@ -734,7 +745,8 @@ "striped": "违ã᏿¨Ąæ§ãĢãã", "side": "į̝ãĢįˇãäģãã", "disabled": "åŧˇčĒŋããĒã" - } + }, + "edit_profile": "ããããŖãŧãĢãᎍé" }, "user_profile": { "timeline_title": "ãĻãŧãļãŧãŋã¤ã ãŠã¤ãŗ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 6386438a..657bb079 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -4,14 +4,15 @@ }, "features_panel": { "chat": "ėą", - "gopher": "ęŗ íŧ", + "gopher": "Gopher", "media_proxy": "미ëė´ íëĄė", "scope_options": "ë˛ė ėĩė
", "text_limit": "í
ė¤í¸ ė í", "title": "기ëĨ", "who_to_follow": "íëĄė° ėļė˛", "upload_limit": "ėĩë íėŧėŠë", - "pleroma_chat_messages": "Pleroma ėąí¸" + "pleroma_chat_messages": "Pleroma ėąí
", + "shout": "ė¸ėšę¸°" }, "finder": { "error_fetching_user": "ėŦėŠė ė ëŗ´ ëļëŦė¤ę¸° ė¤í¨", @@ -21,12 +22,12 @@ "apply": "ė ėŠ", "submit": "ëŗ´ë´ę¸°", "loading": "ëĄëŠė¤âĻ", - "peek": "ė¨ę¸°ę¸°", + "peek": "ė´ė§ ëŗ´ę¸°", "close": "ëĢ기", "verify": "ę˛ėŦ", "confirm": "íė¸", - "enable": "ė í¨", - "disable": "ëŦ´í¨", + "enable": "íėąí", + "disable": "ëšíėąí", "cancel": "뎍ė", "dismiss": "ëŦ´ė", "show_less": "ė 기", @@ -34,18 +35,35 @@ "optional": "íė ėë", "retry": "ë¤ė ėëíėėė¤", "error_retry": "ë¤ė ėëíėėė¤", - "generic_error": "ėëĒģëėėĩëë¤", + "generic_error": "ėëŦę° ë°ėíėĩëë¤", "more": "ë ëŗ´ę¸°", "role": { "moderator": "ė¤ėŦė", "admin": "ę´ëĻŦė" - } + }, + "undo": "뎍ė", + "yes": "ë¤", + "no": "ėëė¤", + "unpin": "ęŗ ė í´ė ", + "pin": "ęŗ ė ", + "scope_in_timeline": { + "private": "íëĄė ė ėŠ", + "public": "ęŗĩę°", + "unlisted": "ëšíė", + "direct": "ë¤ė´ë í¸" + }, + "flash_content": "í´ëĻí´ė íëė ėģ¨í
ė¸ ëŗ´ę¸° (Ruffle ėŦėŠ, ėëíė§ ėė ė ėėĩëë¤).", + "flash_security": "íëė ėģ¨í
ė¸ ë ėė ėŊë ė¤íė´ ėė§ë ę°ëĨí ė ė기ė ėíí ė ėėĩëë¤.", + "flash_fail": "íëėëĨŧ ëĄëíė§ ëĒģíėĩëë¤, ėŊėëĄ ėė¸í ë´ėŠė íė¸íė¸ė.", + "scroll_to_top": "맨 ėëĄ ėŦëŧę°ę¸°", + "generic_error_message": "ėëŦę° ë°ėíėĩëë¤: {0}", + "never_show_again": "ë¤ė ëŗ´ė§ ė기" }, "login": { "login": "ëĄęˇ¸ė¸", "description": "OAuthëĄ ëĄęˇ¸ė¸", "logout": "ëĄęˇ¸ėė", - "password": "ėí¸", + "password": "í¨ė¤ėë", "placeholder": "ėė: lain", "register": "ę°ė
", "username": "ėŦėŠė ė´ëĻ", @@ -57,7 +75,7 @@ "enter_two_factor_code": "2ë¨ęŗė¸ėĻ ėŊëëĨŧ ė
ë Ĩíėėė¤", "enter_recovery_code": "ëŗĩęĩŦ ėŊëëĨŧ ė
ë Ĩíėėė¤", "authentication_code": "ė¸ėĻ ėŊë", - "hint": "ëĄęˇ¸ė¸íėŦ ëíė ė°¸ę°íŠėë¤" + "hint": "ëĄęˇ¸ė¸í´ė ëíė ė°¸ėŦ" }, "nav": { "about": "ė¸ė¤í´ė¤ ėę°", @@ -71,68 +89,86 @@ "twkn": "ėë ¤ė§ ë¤í¸ėíŦ", "user_search": "ėŦėŠė ę˛ė", "preferences": "íę˛Ŋė¤ė ", - "chats": "ėąí¸", + "chats": "ėąí
", "timelines": "íėëŧė¸", "who_to_follow": "ėļė˛ë ėŦėŠė", "search": "ę˛ė", "bookmarks": "ëļë§íŦ", - "interactions": "ëí", + "interactions": "ėëĻŧ", "administration": "ę´ëĻŦ", - "home_timeline": "í íėëŧė¸" + "home_timeline": "í íėëŧė¸", + "mobile_notifications": "ėëĻŧ ė´ę¸° (ėŊė§ ėė ėëĻŧė´ ėėĩëë¤)", + "lists": "ëĻŦė¤í¸", + "edit_nav_mobile": "ë¤ëšę˛ė´ė
ë° ėģ¤ė¤í
í기", + "edit_pinned": "ėë¨ ęŗ ė í¸ė§", + "edit_finish": "í¸ė§ ėĸ
ëŖ", + "mobile_notifications_close": "ėëĻŧ ëĢ기", + "mobile_sidebar": "ëǍë°ėŧ ėŦė´ëë° í ę¸", + "announcements": "ęŗĩė§ėŦí" }, "notifications": { "broken_favorite": "ė ė ėë ę˛ėëŦŧė
ëë¤, ę˛ėíŠëë¤âĻ", - "favorited_you": "ëšė ė ę˛ėëŦŧė ėĻę˛¨ė°žę¸°", - "followed_you": "ëšė ė íëĄė°", - "load_older": "ė¤ë ë ėëĻŧ ëļëŦė¤ę¸°", + "favorited_you": "ę´ėŦė ę°ė§", + "followed_you": "íëĄė°í¨", + "load_older": "ė´ė ėëĻŧ ëļëŦė¤ę¸°", "notifications": "ėëĻŧ", "read": "ėŊė!", - "repeated_you": "ëšė ė ę˛ėëŦŧė ëĻŦí", + "repeated_you": "ëĻŦíí¨", "no_more_notifications": "ėëĻŧė´ ėėĩëë¤", "migrated_to": "ė´ėŦíėĩëë¤", "reacted_with": "{0} ëĄ ë°ėíėĩëë¤", "error": "ėëĻŧ ëļëŦė¤ę¸° ė¤í¨: {0}", - "follow_request": "ëšė ėę˛ íëĄė° ė ė˛" + "follow_request": "íëĄė° ėė˛", + "submitted_report": "ė ęŗ ë´ėŠė ė ėĄí¨", + "poll_ended": "íŦíę° ëë¨" }, "post_status": { "new_status": "ė ę˛ėëŦŧ ę˛ė", - "account_not_locked_warning": "ëšė ė ęŗė ė {0} ėíę° ėëëë¤. ëęĩŦë ëšė ė íëĄė° íęŗ íëĄė ė ėŠ ę˛ėëŦŧė ëŗŧ ė ėėĩëë¤.", + "account_not_locked_warning": "ęŗė ė´ {0} ėíę° ėëëë¤. ëęĩŦë ëšė ė íëĄė° íęŗ íëĄė ė ėŠ ę˛ėëŦŧė ëŗŧ ė ėėĩëë¤.", "account_not_locked_warning_link": "ė ęš", "attachments_sensitive": "랍ëļëŦŧė ë¯ŧę°í¨ėŧëĄ ė¤ė ", "content_type": { "text/plain": "íëŦ¸", "text/bbcode": "BBCode", - "text/markdown": "Markdown", + "text/markdown": "ë§íŦë¤ė´", "text/html": "HTML" }, - "content_warning": "ėŖŧė (íė ėë)", + "content_warning": "ė ëĒŠ (ė í)", "default": "ė¸ė˛ęŗĩíė ëė°Šíėĩëë¤.", "direct_warning": "ė´ ę˛ėëŦŧė ëŠė
ë ėŦėŠėë¤ėę˛ë§ ëŗ´ėŦė§ëë¤", - "posting": "ę˛ė", + "posting": "ę˛ė ė¤", "scope": { "direct": "ë¤ė´ë í¸ - ëŠė
ë ėŦėŠėë¤ėę˛ë§", "private": "íëĄė ė ėŠ - íëĄėë¤ėę˛ë§", "public": "ęŗĩę° - ęŗĩę° íėëŧė¸ėŧëĄ", - "unlisted": "ëšęŗĩę° - ęŗĩę° íėëŧė¸ė ę˛ė ė í¨" + "unlisted": "ëšíė - ęŗĩę° íėëŧė¸ėë ė ëŗ´ė´ę˛" }, - "preview_empty": "ėëŦ´ę˛ë ėėĩëë¤", + "preview_empty": "ëšė´ėė", "preview": "미ëĻŦëŗ´ę¸°", "scope_notice": { - "public": "ė´ ę¸ė ëęĩŦë ëŗŧ ė ėėĩëë¤" + "public": "ëęĩŦë ëŗŧ ė ėėĩëë¤", + "private": "íëĄėėę˛ë§ ëŗ´ėŦė§ëë¤", + "unlisted": "ęŗĩę° íėëŧė¸ė´ë ėë ¤ė§ ë¤í¸ėíŦėë ëŗ´ėŦė§ė§ ėėĩëë¤" }, - "media_description_error": "íėŧė ėŦëĻŦė§ ëĒģíėėĩëë¤. ë¤ėíë˛ ėëíėŦ ėŖŧėėė¤", - "empty_status_error": "ę¸ė ė
ë Ĩíėėė¤", - "media_description": "랍ëļíėŧ ė¤ëĒ
" + "media_description_error": "íėŧė ėŦëĻŦė§ ëĒģíėĩëë¤, ë¤ė ėëí´ ëŗ´ė¸ė", + "empty_status_error": "ę˛ėëŦŧė´ ëšė´ ėėĩëë¤", + "media_description": "랍ëļíėŧ ė¤ëĒ
", + "direct_warning_to_all": "ëŠė
í ëǍë ėŦėŠėėę˛ ëŗ´ėŦė§ëë¤.", + "edit_unsupported_warning": "Pleromaë ëŠė
ė´ë íŦíëĨŧ ėė íë 기ëĨė ė§ėíė§ ėėĩëë¤.", + "edit_status": "ėė ", + "edit_remote_warning": "ėė 기ëĨė´ ėë ë¤ëĨ¸ ė¸ė¤í´ė¤ėėë ėė í ėŦíė´ ë°ėëė§ ėė ė ėėĩëë¤.", + "post": "ę˛ė", + "direct_warning_to_first_only": "맨 ėė ëŠė
í ėŦėŠėë¤ėę˛ë§ ëŗ´ėŦė§ëë¤." }, "registration": { "bio": "ėę°", "email": "ė´ëŠėŧ", - "fullname": "íė ëë ė´ëĻ", - "password_confirm": "ėí¸ íė¸", + "fullname": "íėë ė´ëĻ", + "password_confirm": "í¨ė¤ėë íė¸", "registration": "ę°ė
í기", "token": "ė´ë í í°", "captcha": "ėēĄė°¨", - "new_captcha": "ė´ë¯¸ė§ëĨŧ í´ëĻí´ė ėëĄė´ ėēĄė°¨", + "new_captcha": "ė´ë¯¸ė§ëĨŧ í´ëĻí´ė ėëĄė´ ėēĄė°¨ ę°ė ¸ė¤ę¸°", "validations": { "username_required": "ęŗĩë°ąėŧëĄ ë ė ėėĩëë¤", "fullname_required": "ęŗĩë°ąėŧëĄ ë ė ėėĩëë¤", @@ -142,25 +178,32 @@ "password_confirmation_match": "í¨ė¤ėëė ėŧėší´ėŧ íŠëë¤" }, "fullname_placeholder": "ė: ęšëĄė¸", - "username_placeholder": "ė: lain" + "username_placeholder": "ė: lain", + "bio_placeholder": "ėė\nėë
íė¸ė, ëĄė¸ė
ëë¤.\nėŧëŗ¸ ėė¸ėė ė ëëŠė´ė
ėė´ëė íęŗ ėėĩëë¤. Wiredėė ė ëŗ´ė
¨ė ęą°ėė.", + "bio_optional": "ėę° (ė í)", + "email_optional": "ė´ëŠėŧ (ė í)", + "reason": "ę°ė
íë ¤ë ė´ė ", + "reason_placeholder": "ė´ ė¸ė¤í´ė¤ë ėëėŧëĄ ę°ė
ė ėšė¸íęŗ ėėĩëë¤.\nė ę°ė
íęŗ ėļėė§ ę´ëĻŦėėę˛ ėë ¤ėŖŧė¸ė.", + "register": "ę°ė
", + "email_language": "ëŦ´ė¨ ė¸ė´ëĄ ė´ëŠėŧė ë°ę¸¸ ėíėëė?" }, "settings": { "attachmentRadius": "랍ëļëŦŧ", "attachments": "랍ëļëŦŧ", - "avatar": "ėë°í", - "avatarAltRadius": "ėë°í (ėëĻŧ)", - "avatarRadius": "ėë°í", + "avatar": "íëĄí ėŦė§", + "avatarAltRadius": "íëĄí ėŦė§ (ėëĻŧė°Ŋ)", + "avatarRadius": "íëĄí ėŦė§", "background": "ë°°ę˛Ŋ", "bio": "ėę°", "btnRadius": "ë˛íŧ", "cBlue": "íë (ëĩę¸, íëĄė°)", "cGreen": "ė´ëĄ (ëĻŦí¸ė)", - "cOrange": "ėŖŧíŠ (ėĻę˛¨ė°žę¸°)", + "cOrange": "ėŖŧíŠ (ę´ėŦę¸)", "cRed": "ëš¨ę° (뎍ė)", - "change_password": "ėí¸ ë°ęž¸ę¸°", - "change_password_error": "ėí¸ëĨŧ ë°ęž¸ë ë° ëĒ ę°ė§ ëŦ¸ė ę° ėėĩëë¤.", - "changed_password": "ėí¸ëĨŧ ë°ęž¸ėėĩëë¤!", - "collapse_subject": "ėŖŧė ëĨŧ ę°ė§ ę˛ėëŦŧ ė 기", + "change_password": "í¨ė¤ėë ë°ęž¸ę¸°", + "change_password_error": "í¨ė¤ėëëĨŧ ë°ęž¸ë ë° ëŦ¸ė ę° ėėĩëë¤.", + "changed_password": "í¨ė¤ėëę° ë°ëėėĩëë¤!", + "collapse_subject": "ė ëĒŠė´ ėë ę˛ėëŦŧ ė 기", "composing": "ėėą", "confirm_new_password": "ė í¨ė¤ėë íė¸", "current_avatar": "íėŦ ėë°í", @@ -169,27 +212,27 @@ "data_import_export_tab": "ë°ė´í° ëļëŦė¤ę¸° / ë´ëŗ´ë´ę¸°", "default_vis": "ę¸°ëŗ¸ ęŗĩę° ë˛ė", "delete_account": "ęŗė ėė ", - "delete_account_description": "ë°ė´í°ę° ėęĩŦí ėė ëęŗ ęŗė ė´ ëļíėąíëŠëë¤.", + "delete_account_description": "ë°ė´í°ę° ėęĩŦí ėė ëęŗ ęŗė ė´ ëšíėąíëŠëë¤.", "delete_account_error": "ęŗė ė ėė íëë° ëŦ¸ė ę° ėėĩëë¤. ęŗė ë°ėíë¤ëŠ´ ė¸ė¤í´ė¤ ę´ëĻŦėėę˛ ëŦ¸ėíė¸ė.", - "delete_account_instructions": "ęŗė ėė ëĨŧ íė¸í기 ėí´ ėëė í¨ė¤ėë ė
ë Ĩ.", + "delete_account_instructions": "ėë í¨ė¤ėëëĨŧ ė
ë Ĩíė늴 ęŗė ė´ ėė ëŠëë¤.", "export_theme": "íëĻŦė
ė ėĨ", "filtering": "íí°ë§", - "filtering_explanation": "ėëė ë¨ė´ëĨŧ ę°ė§ ę˛ėëŦŧë¤ė ëŽ¤í¸ ëŠëë¤, í ė¤ė íëėŠ ė ėŧė¸ė", + "filtering_explanation": "ė´ ë¨ė´ëĨŧ ę°ė§ ę˛ėëŦŧë¤ė 뎤í¸ëŠëë¤, í ė¤ė íëėŠ ė ėŧė¸ė", "follow_export": "íëĄė° ë´ëŗ´ë´ę¸°", - "follow_export_button": "íëĄė° ëĒŠëĄė csvëĄ ë´ëŗ´ë´ę¸°", + "follow_export_button": "íëĄė° ëĒŠëĄė CSV íėŧëĄ ë´ëŗ´ë´ę¸°", "follow_export_processing": "ė§í ė¤ė
ëë¤, ęŗ§ ë¤ė´ëĄë ę°ëĨí´ ė§ ę˛ė
ëë¤", "follow_import": "íëĄė° ëļëŦė¤ę¸°", "follow_import_error": "íëĄė° ëļëŦė¤ę¸° ė¤í¨", "follows_imported": "íëĄė° ëĒŠëĄė ëļëŦėėĩëë¤! ė˛ëĻŦėë ėę°ė´ 깸ëĻŊëë¤.", - "foreground": "ė ę˛Ŋ", + "foreground": "í늴", "general": "ėŧë°", "hide_attachments_in_convo": "ëíė 랍ëļëŦŧ ė¨ę¸°ę¸°", "hide_attachments_in_tl": "íėëŧė¸ė 랍ëļëŦŧ ė¨ę¸°ę¸°", "hide_isp": "ė¸ė¤í´ė¤ ė ėŠ í¨ë ė¨ę¸°ę¸°", "preload_images": "ė´ë¯¸ė§ 미ëĻŦ ëļëŦė¤ę¸°", - "hide_post_stats": "ę˛ėëŦŧ íĩęŗ ė¨ę¸°ę¸° (ėĻę˛¨ė°žę¸° ė ëą)", + "hide_post_stats": "ę˛ėëŦŧ íĩęŗ ė¨ę¸°ę¸° (ę´ėŦę¸ ė ëą)", "hide_user_stats": "ėŦėŠė íĩęŗ ė¨ę¸°ę¸° (íëĄė ė ëą)", - "import_followers_from_a_csv_file": "csv íėŧėė íëĄė° ëĒŠëĄ ëļëŦė¤ę¸°", + "import_followers_from_a_csv_file": "CSV íėŧėė íëĄė° ëĒŠëĄ ëļëŦė¤ę¸°", "import_theme": "íëĻŦė
ëļëŦė¤ę¸°", "inputRadius": "ė
ë Ĩ ėš¸", "checkboxRadius": "랴íŦë°ė¤", @@ -197,58 +240,58 @@ "instance_default_simple": "(ę¸°ëŗ¸)", "interface": "ė¸í°íė´ė¤", "interfaceLanguage": "ė¸í°íė´ė¤ ė¸ė´", - "invalid_theme_imported": "ė íí íėŧė ė§ėíë íë ëĄë§ í
ë§ę° ėëëë¤. ėëŦ´ë° ëŗę˛Ŋë ėŧė´ëė§ ėėėĩëë¤.", + "invalid_theme_imported": "í´ëš íėŧė ė§ėëė§ ėë Pleroma í
ë§ė
ëë¤. ėëŦ´ ėŧë ėŧė´ëė§ ėėėĩëë¤.", "limited_availability": "ė´ ë¸ëŧė°ė ėė ėŦėŠ ëļę°", "links": "ë§íŦ", - "lock_account_description": "ęŗė ė ėšė¸ ë íëĄėë¤ëĄ ė í", + "lock_account_description": "íëĄėëĨŧ ėšė¸í´ė ë°ëëĄ ė í", "loop_video": "ëšëė¤ ë°ëŗĩėŦė", - "loop_video_silent_only": "ėëĻŦę° ėë ëšëė¤ë§ ë°ëŗĩ ėŦė (ë§ė¤í ëė \"gifs\" ę°ė ę˛ë¤)", + "loop_video_silent_only": "ėëĻŦę° ėë ëšëė¤ë§ ë°ëŗĩ ėŦė (ë§ė¤í ëė \"GIF\" ę°ė ę˛ë¤)", "name": "ė´ëĻ", "name_bio": "ė´ëĻ & ėę°", - "new_password": "ė ėí¸", - "notification_visibility": "ëŗ´ėŦ ė¤ ėëĻŧ ėĸ
ëĨ", + "new_password": "ė í¨ė¤ėë", + "notification_visibility": "ëŗ´ėŦė§ ėëĻŧ ėĸ
ëĨ", "notification_visibility_follows": "íëĄė°", - "notification_visibility_likes": "ėĸėí¨", + "notification_visibility_likes": "ę´ėŦę¸", "notification_visibility_mentions": "ëŠė
", - "notification_visibility_repeats": "ë°ëŗĩ", + "notification_visibility_repeats": "ëĻŦí", "no_rich_text_description": "ëǍë ę˛ėëŦŧė ėėė ė§ė°ę¸°", - "hide_follows_description": "ë´ę° íëĄė°íë ėŦëė íėíė§ ėė", - "hide_followers_description": "ëëĨŧ ë°ëĨ´ë ėŦëė ė¨ę¸°ę¸°", - "nsfw_clickthrough": "NSFW ė´ë¯¸ė§ \"í´ëĻí´ė ëŗ´ė´ę¸°\"ëĨŧ íėąí", + "hide_follows_description": "íëĄė° ė¤ė¸ ėŦë ė¨ę¸°ę¸°", + "hide_followers_description": "íëĄė ė¨ę¸°ę¸°", + "nsfw_clickthrough": "ë¯ŧę°í ė´ë¯¸ė§ëĨŧ ė¨ę¸°ę¸°", "oauth_tokens": "OAuth í í°", "token": "í í°", "refresh_token": "í í° ėëĄ ęŗ ėš¨", - "valid_until": "ęšė§ ė í¨íë¤", + "valid_until": "ë§ëŖėŧ", "revoke_token": "뎍ė", "panelRadius": "í¨ë", - "pause_on_unfocused": "íė´ íėą ėíę° ėë ë ė¤í¸ëĻŦë° ëŠėļ기", + "pause_on_unfocused": "íė´ íŦėģ¤ė¤ëė§ ėėė ë ëŠėļ기", "presets": "íëĻŦė
", "profile_background": "íëĄí ë°°ę˛Ŋ", "profile_banner": "íëĄí ë°°ë", "profile_tab": "íëĄí", "radii_help": "ė¸í°íė´ė¤ ëǍėëĻŦ ëĨę¸ę¸° (íŊė
ë¨ė)", - "replies_in_timeline": "ëĩę¸ė íėëŧė¸ė", + "replies_in_timeline": "íėëŧė¸ė ëĩę¸", "reply_visibility_all": "ëǍë ëĩę¸ ëŗ´ę¸°", - "reply_visibility_following": "ëėę˛ ė§ė ė¤ë ëĩę¸ė´ë ë´ę° íëĄė° ė¤ė¸ ėŦëėę˛ė ė¤ë ëĩę¸ë§ íė", - "reply_visibility_self": "ëėę˛ ė§ė ė ėĄ ë ëĩę¸ë§ ëŗ´ė´ę¸°", - "saving_err": "ė¤ė ė ėĨ ė¤í¨", + "reply_visibility_following": "ëėę˛ ė§ė ė¤ęą°ë ë´ę° íëĄė° ė¤ė¸ ėŦëė´ ëŗ´ë¸ ëĩę¸ë§ ëŗ´ę¸°", + "reply_visibility_self": "ëėę˛ ė§ė ė¨ ëĩę¸ë§ ëŗ´ę¸°", + "saving_err": "ė¤ė ė ė ėĨíë ë° ėëŦę° ë°ėíėĩëë¤", "saving_ok": "ė¤ė ė ėĨ ë¨", "security_tab": "ëŗ´ė", - "scope_copy": "ëĩę¸ė ëŦ ë ęŗĩę° ë˛ė ë°ëŧę°ëĻŦ (ë¤ė´ë í¸ ëŠėė§ë ė¸ė ë ë°ëŧę°)", - "set_new_avatar": "ė ėë°í ė¤ė ", + "scope_copy": "ëĩę¸ė ëŦ ë ęŗĩę° ë˛ė ë°ëŧę°ę¸° (ë¤ė´ë í¸ ëŠėė§ë ė¸ė ë ë°ëŧę°)", + "set_new_avatar": "ė íëĄí ėŦė§ ė¤ė ", "set_new_profile_background": "ė íëĄí ë°°ę˛Ŋ ė¤ė ", "set_new_profile_banner": "ė íëĄí ë°°ë ė¤ė ", "settings": "ė¤ė ", - "subject_input_always_show": "íė ėŖŧė ėš¸ ëŗ´ė´ę¸°", - "subject_line_behavior": "ëĩę¸ė ëŦ ë ėŖŧė ëŗĩėŦí기", - "subject_line_email": "ė´ëŠėŧė˛ëŧ: \"re: ėŖŧė \"", + "subject_input_always_show": "íė ė ëĒŠ ė
ë Ĩė°Ŋ ëŗ´ė´ę¸°", + "subject_line_behavior": "ëĩę¸ė ëŦ ë ė ëĒŠ ëŗĩėŦí기", + "subject_line_email": "ė´ëŠėŧė˛ëŧ: \"re: ė ëĒŠ\"", "subject_line_mastodon": "ë§ė¤í ëė˛ëŧ: ꡸ëëĄ ëŗĩėŦ", "subject_line_noop": "ëŗĩėŦ ė í¨", - "stop_gifs": "GIFíėŧė ë§ė°ė¤ëĨŧ ėŦë ¤ė ėŦė", - "streaming": "ėĩėë¨ė ëëŦí늴 ėëėŧëĄ ė ę˛ėëŦŧ ė¤í¸ëĻŦë°", + "stop_gifs": "ë§ė°ė¤ëĨŧ ėŦë ¤ė GIF ėŦė", + "streaming": "ėĩėë¨ė ëëŦí늴 ėėė ė ę˛ėëŦŧ ę°ė ¸ė¤ę¸°", "text": "í
ė¤í¸", "theme": "í
ë§", - "theme_help": "16ė§ė ėėėŊë(#rrggbb)ëĨŧ ėŦėŠí´ ėė í
ë§ëĨŧ ėģ¤ė¤í°ë§ė´ėĻ.", + "theme_help": "16ė§ė ėėėŊë(#rrggbb)ëĨŧ ėŦėŠí´ ėėė ėĄ°ė íė¸ė.", "theme_help_v2_1": "랴íŦë°ė¤ëĨŧ íĩí´ ëĒëĒ ėģ´íŦëí¸ė ėėęŗŧ ëļíŦëĒ
ëëĨŧ ėĄ°ė ę°ëĨ, \"ëǍë ė§ė°ę¸°\" ë˛íŧėŧëĄ ëŽė´ ėė´ ę˛ė ëǍë 뎍ė.", "theme_help_v2_2": "ëĒëĒ ė
ë Ĩėš¸ ë°ė ėė´ėŊė ė ę˛Ŋ/ë°°ę˛Ŋ ëëš ę´ë ¨ íėëąė
ëë¤, ë§ė°ė¤ëĨŧ ėŦë ¤ ėė¸í ė ëŗ´ëĨŧ ëŗŧ ė ėėĩëë¤. íŦëĒ
ë ëëš íėëąė´ ę°ėĨ ėĩė
ė ę˛Ŋė°ëĨŧ ëíë¸ë¤ë ę˛ė ė ėíė¸ė.", "tooltipRadius": "í´í/ę˛Ŋęŗ ", @@ -265,25 +308,42 @@ "keep_shadows": "꡸ëĻŧė ė ė§", "keep_opacity": "ëļíŦëĒ
ë ė ė§", "keep_roundness": "ëĨę¸ę¸° ė ė§", - "keep_fonts": "ę¸ė랴 ė ė§", + "keep_fonts": "ę¸ęŧ´ ė ė§", "save_load_hint": "\"ė ė§\" ėĩė
ë¤ė ë¤ëĨ¸ í
ë§ëĨŧ ęŗ ëĨ´ęą°ë ëļëŦ ėŦ ë íėŦ ė¤ė ë ėĩė
ë¤ė ęą´ëëĻŦė§ ėę˛ íŠëë¤, í
ë§ëĨŧ ë´ëŗ´ë´ę¸° í ëë ė´ ėĩė
ė ë°ëŧ ė ėĨíŠëë¤. ėëŦ´ ę˛ë 랴íŦ ëė§ ėėë¤ëŠ´ ëǍë ė¤ė ė ë´ëŗ´ë
ëë¤.", "reset": "ė´ę¸°í", "clear_all": "ëǍë ė§ė°ę¸°", - "clear_opacity": "ëļíŦëĒ
ë ė§ė°ę¸°" + "clear_opacity": "ëļíŦëĒ
ë ė§ė°ę¸°", + "help": { + "upgraded_from_v2": "PleromaFEę° ė
꡸ë ė´ë ëė기ė, í
ë§ę° 기ėĩíėë ę˛ęŗŧ ėĄ°ę¸ ë¤ëĨŧ ė ėėĩëë¤.", + "v2_imported": "ëļëŦė¨ íėŧė ė´ęŗŗëŗ´ë¤ ė´ė ë˛ė ė FEėė ë§ë¤ė´ėĄėĩëë¤. í¸íėąė ė ė§íę˛ ė§ë§ ęš¨ė§ ëļëļė´ ėė ė ėėĩëë¤.", + "migration_snapshot_ok": "íšėë ėļė´ė, í
ë§ ė¤ë
ėˇė ëļëŦėėĩëë¤. í
ë§ ë°ė´í°ëĨŧ ëļëŦėë ëŠëë¤.", + "snapshot_source_mismatch": "ë˛ė ė´ ėļŠëëŠëë¤: ėë§ FEę° ëĄ¤ë°ąëęŗ ë¤ė ė
ë°ė´í¸ ëė´ėėŧ ęą´ë°, ė´ė ë˛ė FEëĄ í
ë§ëĨŧ ėė íë¤ëŠ´ ė´ė ë˛ė FEëĨŧ ė¨ëŗ´ėë ę˛ ėĸęŗ , ėë늴 ė ë˛ė ė ė°ė¸ė.", + "future_version_imported": "ëļëŦė¨ íėŧė ė´ęŗŗëŗ´ë¤ ė ë˛ė ė FEėė ë§ë¤ė´ėĄėĩëë¤.", + "older_version_imported": "ëļëŦė¨ íėŧė ė´ęŗŗëŗ´ë¤ ė´ė ë˛ė ė FEėė ë§ë¤ė´ėĄėĩëë¤.", + "snapshot_present": "í
ë§ ė¤ë
ėˇė´ ėė´ė, ëǍë ę°ė´ ëŽė´ ėėėĄėĩëë¤. ė§ė í
ë§ė ė¤ė ë°ė´í°ëĨŧ ëė ëļëŦėë ëŠëë¤.", + "snapshot_missing": "íėŧė ė¤ë
ėˇė´ ėė´ė ėë ëŗ´ėë ę˛ëŗ´ë¤ ë¤ëĨ´ę˛ ëŗ´ėŧ ė ėėĩëë¤.", + "fe_upgraded": "ë˛ė ė
ë°ė´í¸ëĄ PleromaFEė í
ë§ ėė§ė´ ė
꡸ë ė´ë ëėėĩëë¤.", + "fe_downgraded": "PleromaFEė ë˛ė ė´ ëĄ¤ë°ąëėėĩëë¤.", + "migration_napshot_gone": "ë ėŧė¸ė§ ëǍëĨ´ę˛ ė§ë§ ė¤ë
ėˇė´ ėė´ė, ëĒëĒ ę°ę° 기ėĩíė ę˛ęŗŧ ëŦëĻŦ ëŗ´ėŧ ė ėėĩëë¤." + }, + "load_theme": "í
ë§ ëļëŦė¤ę¸°", + "keep_as_is": "꡸ëëĄ ë기", + "use_snapshot": "ė´ė ë˛ė ", + "use_source": "ė ë˛ė " }, "common": { "color": "ėė", "opacity": "ëļíŦëĒ
ë", "contrast": { - "hint": "ëëšė¨ė´ {ratio}ė
ëë¤, ė´ę˛ė {context} {level}", + "hint": "ėė ëëšė¨ė´ {ratio}ė
ëë¤, {context} {level}", "level": { - "aa": "AAëąę¸ ę°ė´ëëŧė¸ė ëļíŠíŠëë¤ (ėĩėíë)", - "aaa": "AAAëąę¸ ę°ė´ëëŧė¸ė ëļíŠíŠëë¤ (ęļėĨ)", - "bad": "ėëŦ´ë° ę°ė´ëëŧė¸ ëąę¸ėë 미ėšė§ ëĒģíŠëë¤" + "aa": "ė ęˇŧėą ę°ė´ëëŧė¸ AAëąę¸ė ėļŠėĄąíŠëë¤ (ėĩė)", + "aaa": "ė ęˇŧėą ę°ė´ëëŧė¸ AAAëąę¸ė ėļŠėĄąíŠëë¤ (ęļėĨ)", + "bad": "ė ęˇŧėą ę°ė´ëëŧė¸ė ėļŠėĄąíė§ ëĒģíŠëë¤" }, "context": { "18pt": "í° (18pt ė´ė) í
ė¤í¸ė ëí´", - "text": "í
ė¤í¸ė ëí´" + "text": "ėŧë° í
ė¤í¸ė ëí´" } } }, @@ -307,13 +367,23 @@ "faint_text": "íë ¤ė§ í
ė¤í¸", "chat": { "border": "ę˛Ŋęŗė ", - "outgoing": "ėĄė ", - "incoming": "ėė " + "outgoing": "ëŗ´ë", + "incoming": "ë°ė" }, "selectedMenu": "ė íë ëŠë´ ėė", "selectedPost": "ė íë ę¸", "icons": "ėė´ėŊ", - "alert_warning": "ę˛Ŋęŗ " + "alert_warning": "ę˛Ŋęŗ ", + "alert_neutral": "ė¤ëĻŊė ", + "post": "ę˛ėëŦŧ / ė ė ėę°", + "popover": "í´í, ëŠë´, íëĄí ėš´ë", + "disabled": "ëšíėąí", + "wallpaper": "ë°°ę˛ŊėŦė§", + "poll": "íŦí ꡸ëí", + "highlight": "ę°ėĄ° ėė", + "pressed": "ëë ¸ė ë", + "toggled": "í ę¸ë¨", + "tabs": "í" }, "radii": { "_tab_label": "ëĨę¸ę¸°" @@ -344,23 +414,24 @@ "button": "ë˛íŧ", "buttonHover": "ë˛íŧ (ë§ė°ė¤ ėŦë ¸ė ë)", "buttonPressed": "ë˛íŧ (ëë ¸ė ë)", - "buttonPressedHover": "Button (ë§ė°ė¤ ėŦëĻŧ + ëëĻŧ)", + "buttonPressedHover": "ë˛íŧ (ë§ė°ė¤ ėŦëĻŧ + ëëĻŧ)", "input": "ė
ë Ĩėš¸" - } + }, + "hintV3": "꡸ëĻŧėė ę˛Ŋė° {0} í기ë˛ėŧëĄ ë¤ëĨ¸ ėģŦëŦ ėŦëĄ¯ė ėŦėŠí ė ėėĩëë¤." }, "fonts": { - "_tab_label": "ę¸ė랴", - "help": "ė¸í°íė´ė¤ė ėėė ėŦėŠ ë ę¸ė랴ëĨŧ ęŗ ëĨ´ė¸ė. \"ėģ¤ė¤í
\"ė ėė¤í
ė ėë í°í¸ ė´ëĻė ė íí ė
ë Ĩí´ėŧ íŠëë¤.", + "_tab_label": "ę¸ęŧ´", + "help": "í늴ė ė ėŠí ę¸ęŧ´ė ęŗ ëĨ´ė¸ė. \"ė§ė ė
ë Ĩ\"ė ėė¤í
ė ėë ę¸ęŧ´ ė´ëĻė ė íí ė
ë Ĩí´ėŧ íŠëë¤.", "components": { "interface": "ė¸í°íė´ė¤", "input": "ė
ë Ĩėš¸", "post": "ę˛ėëŦŧ í
ė¤í¸", "postCode": "ę˛ėëŦŧė ęŗ ė í í
ė¤í¸ (ėė ėë í
ė¤í¸)" }, - "family": "ę¸ė랴 ė´ëĻ", + "family": "ę¸ęŧ´ ė´ëĻ", "size": "íŦ기 (px ë¨ė)", "weight": "ęĩĩ기", - "custom": "ėģ¤ė¤í
" + "custom": "ė§ė ė
ë Ĩ" }, "preview": { "header": "미ëĻŦëŗ´ę¸°", @@ -371,8 +442,8 @@ "mono": "ë´ėŠ", "input": "ė¸ė˛ęŗĩíė ëė°Šíėĩëë¤.", "faint_link": "ëė ëë ė¤ëĒ
ė", - "fine_print": "ė°ëĻŦė {0} ëĨŧ ėŊęŗ ëė ëė§ ėë ę˛ë¤ė ë°°ė°ė!", - "header_faint": "ė´ęą´ ę´ė°Žė", + "fine_print": "ė°ëĻŦė {0}ëĨŧ ėŊęŗ ëė ëė§ ėë ę˛ë¤ė ë°°ė°ė!", + "header_faint": "ę´ė°Žė í
ė¤í¸", "checkbox": "ëë ėŊę´ė ëėļŠ íė´ëŗ´ėėĩëë¤", "link": "ėęŗ ęˇėŦė´ ë§íŦ" } @@ -381,44 +452,224 @@ "mfa": { "scan": { "secret_code": "í¤", - "title": "ė¤ėē" + "title": "ė¤ėē", + "desc": "2ë¨ęŗ ė¸ėĻ ėąė íĩí´ QR ėŊëëĨŧ ė°ęą°ë í¤ëĨŧ ė
ë Ĩíė¸ė:" }, "authentication_methods": "ė¸ėĻ ë°Šë˛", - "waiting_a_recovery_codes": "ėëš ėŊëëĨŧ ėė íęŗ ėėĩëë¤âĻ", + "waiting_a_recovery_codes": "ëŗĩęĩŦ ėŊëëĨŧ ę°ė ¸ė¤ęŗ ėėĩëë¤âĻ", "recovery_codes": "ëŗĩęĩŦ ėŊë.", - "generate_new_recovery_codes": "ėëĄė´ ëŗĩęĩŦ ėŊëëĨŧ ėėą", - "title": "2ë¨ęŗė¸ėĻ", - "confirm_and_enable": "OTP íė¸ęŗŧ íėąí", - "setup_otp": "OTP ė¤ėš", - "otp": "OTP" + "generate_new_recovery_codes": "ė ëŗĩęĩŦ ėŊë ėėą", + "title": "2ë¨ęŗ ė¸ėĻ", + "confirm_and_enable": "íė¸ & OTP íėąí", + "setup_otp": "OTP ė¤ė ", + "otp": "OTP", + "warning_of_generate_new_codes": "ė ëŗĩęĩŦ ėŊëëĨŧ ėėąí늴, ė´ė ėŊëë ėëíė§ ėę˛ ëŠëë¤.", + "recovery_codes_warning": "ëŗĩęĩŦ ėŊëëĨŧ ė´ëę° ėė í ęŗŗė ė ė´ ëėŧė¸ė - ë ė´ė ė´ ėŊëëĨŧ ëŗ´ė¤ ė ėėĩëë¤. ë§ėŊ 2ë¨ęŗ ė¸ėĻ ėąęŗŧ ëŗĩęĩŦ ėŊë ë ë¤ ė ęˇŧí ė ėę˛ ëë¤ëŠ´ ęŗė ė ëĄęˇ¸ė¸í ė ėę˛ ëŠëë¤.", + "verify": { + "desc": "íėąííë ¤ëŠ´ 2ë¨ęŗ ė¸ėĻ ėąėė ë°ė ėŊëëĨŧ ė
ë Ĩíė¸ė:" + } }, "security": "ëŗ´ė", - "emoji_reactions_on_timeline": "ė´ëĒ¨ė§ ë°ėė íėëŧė¸ėŧëĄ íė", - "avatar_size_instruction": "íŦ기ëĨŧ 150x150 ė´ėėŧëĄ ė¤ė í ę˛ė ėļėĨíŠëë¤.", + "emoji_reactions_on_timeline": "ėëĒ¨ė§ ë°ėė íėëŧė¸ė íė", + "avatar_size_instruction": "ėĩė 150x150 íŊė
ëŗ´ë¤ í° ėŦė§ė ė
ëĄëíė늴 ėĸėĩëë¤.", "blocks_tab": "ė°¨ë¨", "notification_setting_privacy": "ëŗ´ė", "user_mutes": "ėŦėŠė", "notification_visibility_emoji_reactions": "ë°ė", "profile_fields": { - "value": "ë´ėŠ" + "value": "ë´ėŠ", + "label": "íëĄí ėļę°ė ëŗ´", + "add_field": "íë ėļę°", + "name": "ëŧ벨" }, - "mutes_and_blocks": "ėš¨ëŦĩęŗŧ ė°¨ë¨", - "chatMessageRadius": "ėą ëŠėė§", - "change_email": "ëŠėŧėŖŧė ë°ęž¸ę¸°", - "changed_email": "ëŠėŧėŖŧėę° ę°ąė ëėėĩëë¤!", - "bot": "ė´ ęŗė ė botė
ëë¤", - "mutes_tab": "ėš¨ëŦĩ", - "app_name": "ėą ė´ëĻ" + "mutes_and_blocks": "뎤í¸ė ė°¨ë¨", + "chatMessageRadius": "ėąí
ëŠėė§", + "change_email": "ëŠėŧ ėŖŧė ë°ęž¸ę¸°", + "changed_email": "ëŠėŧ ėŖŧėę° ë°ëėėĩëë¤!", + "bot": "ė´ ęŗė ė ėë ë´ė
ëë¤", + "mutes_tab": "뎤í¸", + "app_name": "ėą ė´ëĻ", + "notification_setting_block_from_strangers": "íëĄíė§ ėė ęŗė ėė ëŗ´ë´ë ėëĻŧ ė°¨ë¨", + "autohide_floating_post_button": "ėėė ė ę˛ėëŦŧ ë˛íŧ ė¨ę¸°ę¸° (ëǍë°ėŧ)", + "blocks_imported": "ė°¨ë¨ ëĒŠëĄė ę°ė ¸ėėĩëë¤! ė˛ëĻŦíë ë°ė ėę°ė´ 깸ëĻ´ ė ėėĩëë¤.", + "mutes_imported": "ëŽ¤í¸ ëĒŠëĄė ę°ė ¸ėėĩëë¤! ė˛ëĻŦíë ë°ė ėę°ė´ 깸ëĻ´ ė ėėĩëë¤.", + "account_backup_description": "ë´ ęŗė ė ëŗ´ė ę˛ėëŦŧė´ ë´ę¸´ ėėš´ė´ë¸ëĨŧ ë¤ė´ëĄë ë°ė ė ėė§ë§, ėė§ PleromaëĄ ë¤ė ëļëŦė¤ë 기ëĨė ė§ėíė§ ėėĩëë¤.", + "move_account_notes": "ęŗė ė ë¤ëĨ¸ ęŗŗėŧëĄ ė´ėŦíë ¤ëŠ´, ė´ėŦ ę° ęŗė ėŧëĄ ę°ė
ė ëŗėšė´ ė´ ęŗė ė ę°ëĻŦí¤ëëĄ íė¸ė.", + "hide_bot_indication": "ę˛ėëŦŧėė ë´ ėëĻŧ ė¨ę¸°ę¸°", + "navbar_column_stretch": "ėë¨ ë°ëĨŧ ėģŦëŧ ëëšë§íŧ ëëĻŦ기", + "show_admin_badge": "ë´ íëĄíė \"ę´ëĻŦė\" ë°°ė§ ëŦ기", + "sensitive_by_default": "ę˛ėëŦŧė ë¯ŧę°í¨ėŧëĄ ę¸°ëŗ¸ ė¤ė ", + "notification_mutes": "íšė ėŦėŠėė ėëĻŧė ë°ė§ ėėŧë ¤ëŠ´, 뎤í¸ëĨŧ ėŦėŠíė¸ė.", + "mention_link_fade_domain": "íë ¤ė§ ëëŠė¸ (ė: {'@'}foo{'@'}example.org ėėė {'@'}example.org)", + "notification_blocks": "ėŦėŠėëĨŧ ė°¨ë¨í늴 ėëĻŧė ë°ė§ ėëë°ë¤ ęĩŦë
ęšė§ 뎍ėíę˛ ëŠëë¤.", + "conversation_display_tree": "í¸ëĻŦ", + "save": "ëŗę˛Ŋ ėŦíė ė ėĨ", + "allow_following_move": "íëĄė° ė¤ė¸ ęŗė ė´ ė´ėŦëĨŧ í늴 ėëėŧëĄ íëĄė°í기", + "expert_mode": "ęŗ ę¸ ė¤ė ëŗ´ę¸°", + "setting_changed": "ę¸°ëŗ¸ ė¤ė ęŗŧ ë¤ëĻ
ëë¤", + "setting_server_side": "ė´ ė¤ė ė ęŗė ęŗŧ ëŦļėŦ ėėŧ늰 ė°ę˛°ë ëǍë ė¸ė
ęŗŧ í´ëŧė´ė¸í¸ė ėíĨė ė¤ëë¤", + "enter_current_password_to_confirm": "ëŗ¸ė¸ íė¸ė ėí´ íėŦ í¨ė¤ėëëĨŧ ė
ë Ĩíė¸ė", + "post_look_feel": "ę˛ėëŦŧ ëǍėė", + "mention_links": "ëŠė
ë§íŦ", + "lists_navigation": "ëŠë´ė ëĻŦė¤í¸ ëŗ´ė´ę¸°", + "email_language": "ėë˛ëĄëļí° ė´ëŠėŧė ë°ė ė¸ė´", + "block_import": "ė°¨ë¨ ëĒŠëĄ ę°ė ¸ė¤ę¸°", + "block_export_button": "ė°¨ë¨ ëĒŠëĄė CSV íėŧëĄ ë´ëŗ´ë´ę¸°", + "block_import_error": "ė°¨ë¨ ëĒŠëĄė ę°ė ¸ė¤ë ë°ė ëŦ¸ė ę° ë°ėíėĩëë¤", + "mute_export": "ëŽ¤í¸ ëĒŠëĄ ë´ëŗ´ë´ę¸°", + "mute_export_button": "ëŽ¤í¸ ëĒŠëĄė CSV íėŧëĄ ë´ëŗ´ë´ę¸°", + "mute_import": "ëŽ¤í¸ ëĒŠëĄ ę°ė ¸ė¤ę¸°", + "mute_import_error": "ëŽ¤í¸ ëĒŠëĄė ę°ė ¸ė¤ë ë°ė ëŦ¸ė ę° ë°ėíėĩëë¤", + "import_mutes_from_a_csv_file": "ëŽ¤í¸ ëĒŠëĄė CSV íėŧėė ę°ė ¸ė¤ę¸°", + "account_backup": "ęŗė ë°ąė
", + "account_backup_table_head": "ë°ąė
", + "download_backup": "ë¤ė´ëĄë", + "backup_not_ready": "ë°ąė
ė´ ėė§ ė¤ëšëė§ ėėėĩëë¤.", + "remove_backup": "ėė ", + "list_backups_error": "ë°ąė
ëĻŦė¤í¸ëĨŧ ę°ė ¸ė¤ë ë° ėëŦę° ë°ėíėĩëë¤: {error}", + "add_backup": "ė ë°ąė
ë§ë¤ę¸°", + "added_backup": "ė ë°ąė
ėļę°ë¨.", + "add_backup_error": "ė ë°ąė
ė ėļę°íë ë° ėëŦę° ë°ėíėĩëë¤: {error}", + "change_email_error": "ëŠėŧ ėŖŧėëĨŧ ë°ęž¸ë ë° ëŦ¸ė ę° ėėĩëë¤.", + "account_alias": "ęŗė ëŗėš", + "always_show_post_button": "íė ë ë¤ëë ę˛ėëŦŧ ėėą ë˛íŧ ëŗ´ę¸°", + "mute_bot_posts": "ë´ ę˛ėëŦŧ 뎤í¸í기", + "hide_all_muted_posts": "뎤í¸í ę˛ėëŦŧ ė¨ę¸°ę¸°", + "account_alias_table_head": "ëŗėš", + "hide_list_aliases_error_action": "ëĢ기", + "remove_alias": "ė´ ëŗėš ėė ", + "new_alias_target": "ė ëŗėš ėļę° (ėė. {example})", + "added_alias": "ëŗėšė´ ėļę°ëėėĩëë¤.", + "move_account": "ęŗė ė´ėŦ", + "move_account_target": "ė´ėŦ ę° ęŗė (ėė. {example})", + "moved_account": "ęŗė ė ė´ėŦíėĩëë¤.", + "discoverable": "ę˛ė 결ęŗŧë ë¤ëĨ¸ ėëšė¤ë¤ėė ė´ ęŗė ė ė°žė ė ėëëĄ íėŠ", + "pad_emoji": "ėëǍė§ëĨŧ ė íė°Ŋėė ęŗ ëĨŧ ë ëė´ė°ę¸°ëĨŧ ė§ė´ëŖę¸°", + "wordfilter": "ë¨ė´ íí°", + "word_filter_and_more": "ë¨ė´ íí° ęˇ¸ëĻŦęŗ ëëŗ´ę¸°...", + "accent": "ę°ėĄ°", + "hide_media_previews": "미ëė´ ë¯¸ëĻŦëŗ´ę¸° ė¨ę¸°ę¸°", + "max_thumbnails": "ę˛ėëŦŧ íë ëš ėĩëëĄ ëŗ´ėŦė§ ėŦë¤ėŧ ę°ė (ëšėë늴 ė íė ëė§ ėėĩëë¤)", + "hide_shoutbox": "ė¸ė¤í´ė¤ ė¸ėšę¸° ė¨ę¸°ę¸°", + "right_sidebar": "ėģŦëŧ ėė ë¤ė§ę¸°", + "hide_wallpaper": "ė¸ė¤í´ė¤ ë°°ę˛Ŋí늴 ę°ëĻŦ기", + "use_one_click_nsfw": "ë¯ŧę°í 랍ëļëŦŧė í´ëĻ í ë˛ėŧëĄ ė´ę¸°", + "move_account_error": "ęŗė ė ė´ėŦíë ë° ėëŦę° ë°ėíėĩëë¤: {error}", + "hide_muted_posts": "뎤í¸í ėŦėŠėė ę˛ėëŦŧ ė¨ę¸°ę¸°", + "hide_filtered_statuses": "íí°ë ëǍë ę˛ėëŦŧ ė¨ę¸°ę¸°", + "hide_wordfiltered_statuses": "ë¨ė´ íí°ë ę˛ėëŦŧ ė¨ę¸°ę¸°", + "use_contain_fit": "랍ëļíėŧė ėŦë¤ėŧė ėëĨ´ė§ ėė", + "hide_muted_threads": "뎤í¸í ė¤ë ë ė¨ę¸°ę¸°", + "import_blocks_from_a_csv_file": "CSV íėŧėė ė°¨ë¨ ëĒŠëĄ ëļëŦė¤ę¸°", + "play_videos_in_modal": "íė
íë ėėė ëšëė¤ëĨŧ ėŦė", + "file_export_import": { + "backup_restore": "ė¤ė ë°ąė
", + "backup_settings": "ė¤ė ė íėŧëĄ ë°ąė
", + "backup_settings_theme": "ė¤ė ęŗŧ í
ë§ëĨŧ íėŧëĄ ë°ąė
", + "restore_settings": "íėŧėė ė¤ė ëŗĩęĩŦí기", + "errors": { + "invalid_file": "í´ëš íėŧė ė§ėëė§ ėë Pleroma ë°ąė
ė
ëë¤. ėëŦ´ ėŧë ėŧė´ëė§ ėėėĩëë¤.", + "file_too_new": "í¸íëė§ ėë ë˛ė : {fileMajor}, ė´ PleromaFE (ė¤ė ë˛ė {feMajor}) ę° ëëŦ´ ëĄėė ė˛ëĻŦí ė ėėĩëë¤", + "file_too_old": "í¸íëė§ ėë ë˛ė : {fileMajor}, íėŧ ë˛ė ė´ ëëŦ´ ëĄėė ė˛ëĻŦí ė ėėĩëë¤ (ė§ėëë ėĩė ė¤ė ë˛ė {feMajor})", + "file_slightly_new": "íėŧ ë§ė´ë ë˛ė ė´ ëŦëŧė, ëĒëĒ ė¤ė ë¤ė´ ė ėŠëė§ ėėė ė ėėĩëë¤" + } + }, + "account_privacy": "ėŦėí ëŗ´ė", + "new_email": "ė ëŠėŧ ėŖŧė", + "hide_favorites_description": "ë´ ę´ėŦę¸ė ëŗ´ė´ė§ ėė (ėëĻŧė ę°ëë¤)", + "hide_follows_count_description": "íëĄė° ė¤ ėĢė ė¨ę¸°ę¸°", + "hide_followers_count_description": "íëĄė ėĢė ė¨ę¸°ę¸°", + "no_mutes": "ëŽ¤í¸ ėė", + "search_user_to_block": "ė°¨ë¨í ėŦë ę˛ėí기", + "search_user_to_mute": "뎤í¸í ėŦë ę˛ėí기", + "posts": "ę˛ėëŦŧ", + "notification_visibility_moves": "ęŗė ė´ėŦ", + "notification_visibility_polls": "ė°¸ėŦí íŦíę° ëë¨", + "no_blocks": "ė°¨ë¨ ėė", + "reply_visibility_self_short": "ë´ ëĩę¸ë§ ëŗ´ę¸°", + "reply_visibility_following_short": "íëĄė° ė¤ė¸ ėŦëë¤ëŧëĻŦė ëĩę¸ ëŗ´ę¸°", + "user_profiles": "ėŦėŠė íëĄí", + "show_moderator_badge": "ë´ íëĄíė \"ė¤ėŦė\" ë°°ė§ ëŦ기", + "type_domains_to_mute": "뎤í¸í ëëŠė¸ ę˛ėí기", + "disable_sticky_headers": "ėģŦëŧ í¤ëëĨŧ í늴 ėë¨ė ęŗ ė íė§ ėė", + "auto_update": "ėėė ė ę˛ėëŦŧ ę°ė ¸ė¤ę¸°", + "minimal_scopes_mode": "ęŗĩę° ë˛ė ė íė§ ė¤ė´ę¸°", + "reset_avatar": "íëĄí ėŦė§ ė´ę¸°í", + "reset_avatar_confirm": "ė ë§ íëĄí ėŦė§ė ė´ę¸°íí ęšė?", + "reset_profile_background": "íëĄí ë°°ę˛Ŋ ė´ę¸°í", + "reset_profile_banner": "íëĄí ë°°ë ė´ę¸°í", + "reset_banner_confirm": "ė ë§ íëĄí ë°°ëëĨŧ ė´ę¸°íí ęšė?", + "reset_background_confirm": "ė ë§ íëĄí ë°°ę˛Ŋė ė´ę¸°íí ęšė?", + "useStreamingApi": "ė¤ėę°ėŧëĄ ę˛ėëŦŧęŗŧ ėëĻŧ ë°ę¸°", + "use_websockets": "ėšėėŧ ėŦėŠ (ė¤ėę° ė
ë°ė´í¸)", + "upload_a_photo": "ėŦė§ ė
ëĄë", + "conversation_display": "ëí íė ëǍė", + "conversation_display_tree_quick": "í¸ëĻŦ 롰", + "show_scrollbars": "ė¸ĄëŠ´ ėģŦëŧė ė¤íŦëĄ¤ë° ëŗ´ę¸°", + "conversation_other_replies_button_inside": "ę˛ėëŦŧ ėė ë기", + "notification_setting_hide_notification_contents": "í¸ė ėëĻŧėė ëŗ´ë¸ ėŦëęŗŧ ë´ėŠė ė¨ęš", + "virtual_scrolling": "íėëŧė¸ ë ëë§ ėĩė í", + "use_at_icon": "{'@'} ëŦ¸ėëĨŧ í
ė¤í¸ ëė ėė´ėŊėŧëĄ íė", + "mention_link_display": "ëŠė
ė ë§íŦ íė", + "mention_link_display_short": "íė ė§§ė ė´ëĻ ėŦėŠ (ė: {'@'}foo)", + "mention_link_display_full_for_remote": "ë¤ëĨ¸ ė¸ė¤í´ė¤ ėŦėŠėë§ ė´ëĻ ė ëļ ëŗ´ę¸° (ė: {'@'}foo{'@'}example.org)", + "mention_link_display_full": "íė ė´ëĻ ė ëļ ëŗ´ę¸° (ė: {'@'}foo{'@'}example.org)", + "mention_link_use_tooltip": "ëŠė
ë§íŦëĨŧ ëëĨ´ëŠ´ ėŦėŠė ėš´ë ëŗ´ę¸°", + "mention_link_show_avatar": "ë§íŦ ėė íëĄí ėŦė§ ëŗ´ę¸°", + "mention_link_bolden_you": "ëę° ë ëŠė
íė ë ëŠė
ė ę°ėĄ° íė", + "user_popover_avatar_action_zoom": "ėŦė§ í¤ė°ę¸°", + "greentext": "ë° íė´í", + "show_yous": "\"(ëšė )\" ëŗ´ė´ę¸°", + "notification_setting_filters": "íí°", + "more_settings": "ėļę° ė¤ė ", + "user_popover_avatar_action_open": "íëĄí ė´ę¸°", + "version": { + "frontend_version": "íëĄ í¸ėë ë˛ė ", + "title": "ë˛ė ", + "backend_version": "ë°ąėë ë˛ė " + }, + "fun": "ėĻę˛ë¤", + "domain_mutes": "ëëŠė¸", + "third_column_mode": "ęŗĩę°ė´ ėļŠëļí늴, ė¸ ë˛ė§¸ ėģŦëŧ ėąė°ę¸°", + "third_column_mode_none": "ė¸ ë˛ė§¸ ėģŦëŧ ė ëŗ´ę¸°", + "third_column_mode_notifications": "ėëĻŧ ėģŦëŧ", + "third_column_mode_postform": "ę˛ėëŦŧ í¸ė§ė°Ŋęŗŧ ë´ëšę˛ė´ė
", + "columns": "ėģŦëŧ", + "column_sizes": "ėģŦëŧ íŦ기", + "column_sizes_sidebar": "ėŦė´ëë°", + "column_sizes_content": "ë´ėŠ", + "column_sizes_notifs": "ėëĻŧ", + "tree_advanced": "í¸ëĻŦ 롰ėė ë ė ė°í íėė íėŠ", + "tree_fade_ancestors": "íėŦ ę˛ėëŦŧëŗ´ë¤ ėë¨ė ę˛ėëŦŧë¤ė íëϰ í
ė¤í¸ëĄ íė", + "conversation_display_linear": "ė í", + "conversation_display_linear_quick": "ė í 롰", + "conversation_other_replies_button": "\"ëĩę¸ ë ëŗ´ę¸°\" ë˛íŧė", + "conversation_other_replies_button_below": "ę˛ėëŦŧ ėëė ë기", + "max_depth_in_thread": "ę¸°ëŗ¸ė ėŧëĄ ëŗ´ėŧ ėĩë ęšė´", + "user_popover_avatar_action": "íëĄí ėš´ëė ėŦė§ í´ëĻ ė", + "user_popover_avatar_action_close": "ėš´ë ëĢ기", + "user_popover_avatar_overlay": "íëĄí ėš´ëëĨŧ íëĄí ėŦė§ ėė ëė°ę¸°", + "post_status_content_type": "ę˛ėëŦŧ ë´ėŠ íė", + "list_aliases_error": "ëŗėšė ę°ė ¸ė¤ë ė¤ ėëŦ ë°ė: {error}", + "add_alias_error": "ëŗėšė ėļę°íë ė¤ ėëŦ ë°ė: {error}", + "mention_link_show_avatar_quick": "ëŠė
ėė ė ė íëĄí ėŦė§ė ëŗ´ė" }, "timeline": { "collapse": "ė 기", "conversation": "ëí", "error_fetching": "ė
ë°ė´í¸ ëļëŦė¤ę¸° ė¤í¨", - "load_older": "ë ė¤ë ë ę˛ėëŦŧ ëļëŦė¤ę¸°", - "no_retweet_hint": "íëĄė ė ėŠ, ë¤ė´ë í¸ ëŠėė§ë ë°ëŗĩí ė ėėĩëë¤", - "repeated": "ë°ëŗĩ ë¨", - "show_new": "ėëĄė´ ę˛ ëŗ´ę¸°", - "up_to_date": "ėĩė ėí" + "load_older": "ė´ė ę˛ėëŦŧ ëļëŦė¤ę¸°", + "no_retweet_hint": "íëĄė ė ėŠ ę˛ėëŦŧęŗŧ ë¤ė´ë í¸ ëŠėė§ë ëĻŦíí ė ėėĩëë¤", + "repeated": "ëĻŦíí¨", + "show_new": "ė ę˛ėëŦŧ ëŗ´ę¸°", + "up_to_date": "ėĩė ", + "error": "íėëŧė¸ė ę°ė ¸ė¤ė§ ëĒģíėĩëë¤: {0}", + "reload": "ėëĄęŗ ėš¨", + "no_statuses": "ę˛ėëŦŧ ėė", + "no_more_statuses": "ė ę˛ėëŦŧ ėė", + "socket_reconnected": "ė¤ėę° ė°ę˛° ë¨", + "socket_broke": "ė¤ėę° ė°ę˛°ė´ ëė´ė§: CloseEvent ėŊë {0}", + "quick_filter_settings": "ëš ëĨ¸ íí° ė¤ė " }, "user_card": { "approve": "ėšė¸", @@ -426,22 +677,70 @@ "blocked": "ė°¨ë¨ ë¨!", "deny": "ęą°ëļ", "follow": "íëĄė°", - "follow_sent": "ėė˛ ëŗ´ë´ė§!", + "follow_sent": "ėė˛ ëŗ´ë!", "follow_progress": "ėė˛ ė¤âĻ", - "follow_unfollow": "íëĄė° ė¤ė§", + "follow_unfollow": "ė¸íëĄė°", "followees": "íëĄė° ė¤", "followers": "íëĄė", "following": "íëĄė° ė¤!", - "follows_you": "ëšė ė íëĄė° íŠëë¤!", + "follows_you": "ëëĨŧ íëĄė° íŠëë¤!", "its_you": "ëšė ė
ëë¤!", - "mute": "ėš¨ëŦĩ", - "muted": "ėš¨ëŦĩ ë¨", - "per_day": "/ íëŖ¨", - "remote_follow": "ė겊 íëĄė°", - "statuses": "ę˛ėëŦŧ" + "mute": "뎤í¸", + "muted": "ëŽ¤í¸ ë¨", + "per_day": "ę° / ėŧ", + "remote_follow": "ë¤ëĨ¸ ė¸ė¤í´ė¤ėė íëĄė°", + "statuses": "ę˛ėëŦŧ", + "unmute_progress": "ëŽ¤í¸ í´ė ė¤âĻ", + "unblock_progress": "ė°¨ë¨ í´ė ė¤âĻ", + "admin_menu": { + "revoke_moderator": "ė¤ėŦė ííĩ", + "sandbox": "ę˛ėëŦŧ ęŗĩę° ë˛ėëĨŧ íëĄė ė ėŠėŧëĄ ę°ė ", + "disable_any_subscription": "ëęĩŦë íëĄė°ëĨŧ ëĒģíëëĄ ë§ę¸°", + "delete_user_data_and_deactivate_confirmation": "ėęĩŦė ėŧëĄ ė´ ęŗė ė ë°ė´í°ę° ėė ëęŗ ëšíėąí ëŠëë¤. ė ë§ëĄ ę´ė°Žę˛ ėĩëęš?", + "moderation": "ę´ëĻŦ", + "grant_admin": "ę´ëĻŦėëĄ ėëĒ
", + "grant_moderator": "ė¤ėŦėëĄ ėëĒ
", + "disable_remote_subscription": "ë¤ëĨ¸ ė¸ė¤í´ė¤ėė íëĄė°íė§ ëĒģíëëĄ ë§ę¸°", + "activate_account": "ęŗė íėąí", + "deactivate_account": "ęŗė ëšíėąí", + "delete_account": "ęŗė ėė ", + "force_nsfw": "ëǍë ę˛ėëŦŧė ë¯ŧę°í ë´ėŠėŧëĄ íė", + "strip_media": "ę˛ėëŦŧėė 미ëė´ ė ęą°", + "revoke_admin": "ę´ëĻŦė ííĩ", + "force_unlisted": "ę˛ėëŦŧ ęŗĩę° ë˛ėëĨŧ ëšíėëĄ ę°ė ", + "quarantine": "ė°íŠ íėëŧė¸ėė ėŦėŠė ę˛ėëŦŧ ëšíėŠ", + "delete_user": "ėŦėŠė ėė " + }, + "deactivated": "ëšíėąíë¨", + "edit_profile": "íëĄí í¸ė§", + "favorites": "ę´ėŦę¸", + "follow_cancel": "íëĄė° ėė˛ ėˇ¨ė", + "unmute": "ëŽ¤í¸ í´ė ", + "mute_progress": "ëŽ¤í¸ ė¤âĻ", + "hidden": "ė¨ę˛¨ė§", + "media": "미ëė´", + "mention": "ëŠė
", + "message": "ëŠėė§", + "remove_follower": "íëĄė ėė ", + "report": "ė ęŗ ", + "subscribe": "ęĩŦë
", + "unsubscribe": "ęĩŦë
í´ė ", + "unblock": "ė°¨ë¨ í´ė ", + "block_progress": "ė°¨ë¨ ė¤âĻ", + "hide_repeats": "ëĻŦí ė¨ę¸°ę¸°", + "show_repeats": "ëĻŦí ëŗ´ę¸°", + "bot": "ë´", + "highlight": { + "disabled": "ę°ėĄ° íė ėė", + "striped": "ė¤ëŦ´ëŦ ë°°ę˛Ŋ", + "solid": "ë¨ė ë°°ę˛Ŋ", + "side": "ėí¸ė" + } }, "user_profile": { - "timeline_title": "ėŦėŠė íėëŧė¸" + "timeline_title": "ėŦėŠė íėëŧė¸", + "profile_does_not_exist": "ėŖėĄíė§ë§, ė´ íëĄíė ėĄ´ėŦíė§ ėėĩëë¤.", + "profile_loading_error": "ėŖėĄíė§ë§, íëĄíė ëļëŦė¤ë ë° ėëŦę° ë°ėíėĩëë¤." }, "who_to_follow": { "more": "ë ëŗ´ę¸°", @@ -449,38 +748,60 @@ }, "tool_tip": { "media_upload": "미ëė´ ė
ëĄë", - "repeat": "ë°ëŗĩ", + "repeat": "ëĻŦí", "reply": "ëĩę¸", - "favorite": "ėĻę˛¨ė°žę¸°", - "user_settings": "ėŦėŠė ė¤ė " + "favorite": "ę´ėŦę¸", + "user_settings": "ėŦėŠė ė¤ė ", + "add_reaction": "ë°ė ėļę°", + "accept_follow_request": "íëĄė° ėė˛ ėšė¸", + "reject_follow_request": "íëĄė° ėė˛ ęą°ė ", + "bookmark": "ëļë§íŦ" }, "upload": { "error": { "base": "ė
ëĄë ė¤í¨.", "file_too_big": "íėŧė´ ëëŦ´ ėģ¤ė [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", - "default": "ė ė íė ë¤ė ėëí´ ëŗ´ė¸ė" + "default": "ė ė íė ë¤ė ėëí´ ëŗ´ė¸ė", + "message": "ė
ëĄë ė¤í¨: {0}" }, "file_size_units": { - "B": "ë°ė´í¸", - "KiB": "í¤ëšë°ė´í¸", - "MiB": "ëŠëšë°ė´í¸", - "GiB": "기ëšë°ė´í¸", - "TiB": "í
ëšë°ė´í¸" + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" } }, "interactions": { "follows": "ė íëĄė", - "favs_repeats": "ë°ëŗĩęŗŧ ėĻę˛¨ė°žę¸°", - "moves": "ęŗė íĩíŠ" + "favs_repeats": "ëĻŦíęŗŧ ę´ėŦ", + "moves": "ęŗė ė´ë", + "emoji_reactions": "ėëĒ¨ė§ ë°ė", + "reports": "ė ęŗ ", + "load_older": "ė´ė ë°ė ëļëŦė¤ę¸°" }, "emoji": { - "load_all": "ė 랴 {emojiAmount} ė´ëĒ¨ė§ ëļëŦė¤ę¸°", - "unicode": "Unicode ė´ëǍė§", - "custom": "ė ėŠ ė´ëǍė§", - "add_emoji": "ė´ëĒ¨ė§ ëŖę¸°", - "search_emoji": "ė´ëĒ¨ė§ ę˛ė", - "emoji": "ė´ëǍė§", - "stickers": "ė¤í°ėģ¤" + "load_all": "ė 랴 {emojiAmount}ę°ė ėëĒ¨ė§ ëļëŦė¤ę¸°", + "unicode": "Unicode ėëǍė§", + "custom": "ė ėŠ ėëǍė§", + "add_emoji": "ėëĒ¨ė§ ëŖę¸°", + "search_emoji": "ėëĒ¨ė§ ę˛ė", + "emoji": "ėëǍė§", + "stickers": "ė¤í°ėģ¤", + "load_all_hint": "ė˛Ģ {saneAmount}ę°ė ėëǍė§ëĨŧ ëļëŦėėĩëë¤, ėëǍė§ëĨŧ ė ëļ ëļëŦė¤ëŠ´ ėąëĨ ė íę° ėė ė ėėĩëë¤.", + "unicode_groups": { + "people-and-body": "ėŦë & ëǏ", + "smileys-and-emotion": "ėë ėŧęĩ´ & ę°ė ", + "travel-and-places": "ėŦí & ėĨė", + "activities": "íë", + "animals-and-nature": "ëëŦŧ & ėė°", + "flags": "ęšë°", + "food-and-drink": "ėė & ėëŖ", + "objects": "ėŦëŦŧ", + "symbols": "기í¸" + }, + "keep_open": "ė´ëϰ ėąëĄ ë기", + "regional_indicator": "ė§ė íė기 {letter}" }, "polls": { "add_poll": "íŦíëĨŧ ėļę°", @@ -491,11 +812,18 @@ "votes_count": "{count} í | {count} í", "people_voted_count": "{count} ëĒ
íŦí | {count} ëĒ
íŦí", "option": "ė íė§", - "add_option": "ė íė§ ėļę°" + "add_option": "ė íė§ ėļę°", + "expired": "íŦíë {0} ė ė ë§ę°ëėėĩëë¤", + "expires_in": "íŦíë {0}ė ë§ę°ëŠëë¤", + "single_choice": "íëë§ ė í", + "multiple_choices": "ėŦëŦ ę° ė í", + "not_enough_options": "ė íė§ę° ëëŦ´ ė ėĩëë¤" }, "media_modal": { "next": "ë¤ė", - "previous": "ė´ė " + "previous": "ė´ė ", + "counter": "{current} / {total}", + "hide": "미ëė´ ëˇ°ė´ ëĢ기" }, "importer": { "error": "ė´ íėŧė ę°ė ¸ėŦ ë ė¤ëĨę° ë°ėíėėĩëë¤.", @@ -509,14 +837,14 @@ "crop_picture": "ėŦė§ ėëĨ´ę¸°" }, "exporter": { - "processing": "ė˛ëĻŦė¤ė
ëë¤, ė˛ëĻŦę° ëë늴 íėŧė ë¤ė´ëĄëíëŧë ė§ėę° ėę˛ ėĩëë¤", + "processing": "ė˛ëĻŦė¤ė
ëë¤, ęŗ§ íėŧė ë¤ė´ëĄëí ė ėėĩëë¤", "export": "ë´ëŗ´ë´ę¸°" }, "domain_mute_card": { - "unmute_progress": "ėš¨ëŦĩė í´ė ė¤âĻ", - "unmute": "ėš¨ëŦĩ í´ė ", - "mute_progress": "ėš¨ëŦĩėŧëĄ ė¤ė ė¤âĻ", - "mute": "ėš¨ëŦĩ" + "unmute_progress": "ëŽ¤í¸ í´ė ė¤âĻ", + "unmute": "ëŽ¤í¸ í´ė ", + "mute_progress": "ëŽ¤í¸ ė¤âĻ", + "mute": "뎤í¸" }, "about": { "staff": "ė´ėė", @@ -534,21 +862,25 @@ "accept_desc": "ė´ ė¸ė¤í´ė¤ėėë ėëė ė¸ė¤í´ė¤ëĄëļí° ëŗ´ë´ė¨ íŦęŗ ë§ė´ ė ėëŠëë¤:", "reject": "ęą°ëļ", "accept": "íę°", - "simple_policies": "ė¸ė¤í´ė¤ íšė ė í´ëĻŦė" + "simple_policies": "ė¸ė¤í´ė¤ íšė ė ė ėą
", + "instance": "ė¸ė¤í´ė¤", + "reason": "ėŦė ", + "not_applicable": "ėė" }, - "mrf_policies": "ėŦėŠëë MRF í´ëĻŦė", + "mrf_policies": "ėŦėŠëë MRF ė ėą
", "keyword": { "is_replaced_by": "â", "replace": "ë°ęž¸ę¸°", "reject": "ęą°ëļ", "ftl_removal": "\"ėë ¤ė§ ëǍë ë¤í¸ėíŦ\" íėëŧė¸ėė ė ė¸", - "keyword_policies": "ë¨ė´ í´ëĻŦė" + "keyword_policies": "ë¨ė´ ė ėą
" }, - "federation": "ė°íŠ" + "federation": "ė°íŠ", + "mrf_policies_desc": "MRF ė ėą
ė ė´ ė¸ė¤í´ė¤ė íëë ė´ė
ëėė ė ė´íęŗ ėėĩëë¤. ė ėŠëęŗ ėë ė ėą
ė ë¤ėęŗŧ ę°ėĩëë¤:" } }, "shoutbox": { - "title": "Shoutbox" + "title": "ė¸ėšę¸°" }, "time": { "years_short": "{0} ë
", @@ -563,8 +895,8 @@ "second_short": "{0} ė´", "seconds": "{0} ė´", "second": "{0} ė´", - "now_short": "ë°Šę¸", - "now": "ë°Šë", + "now_short": "ė§ę¸", + "now": "ë°Šę¸", "months_short": "{0} ëŦ ė ", "month_short": "{0} ëŦ ė ", "months": "{0} ëŦ ė ", @@ -581,13 +913,205 @@ "days_short": "{0} ėŧ", "day_short": "{0} ėŧ", "days": "{0} ėŧ", - "day": "{0} ėŧ" + "day": "{0} ėŧ", + "unit": { + "weeks": "{0}ėŖŧ | {0}ėŖŧ", + "minutes": "{0}ëļ | {0}ëļ", + "seconds": "{0}ė´ | {0}ė´", + "seconds_short": "{0}ė´", + "weeks_short": "{0}ėŖŧ", + "years": "{0}ë
| {0}ë
", + "years_short": "{0}ë
", + "days": "{0}ėŧ | {0}ėŧ", + "days_short": "{0}ėŧ", + "hours": "{0}ėę° | {0}ėę°", + "hours_short": "{0}ėę°", + "minutes_short": "{0}ëļ", + "months": "{0}ëŦ | {0}ëŦ", + "months_short": "{0}ëŦ" + }, + "in_future": "{0} í" }, "remote_user_resolver": { "error": "ė°žė ė ėėĩëë¤.", - "searching_for": "ę˛ėė¤" + "searching_for": "ę˛ė:", + "remote_user_resolver": "ë¤ëĨ¸ ė¸ė¤í´ė¤ ėŦėŠė ėë´ę¸°" }, "selectable_list": { "select_all": "ëǍë ė í" + }, + "lists": { + "title": "ëĻŦė¤í¸ ė ëĒŠ", + "search": "ėŦėŠė ę˛ėí기", + "lists": "ëĻŦė¤í¸", + "new": "ëĻŦė¤í¸ ë§ë¤ę¸°", + "create": "ë§ë¤ę¸°", + "delete": "ëĻŦė¤í¸ ėė ", + "following_only": "íëĄė° ė¤ė¸ ėŦëë¤ë§", + "manage_lists": "ëĻŦė¤í¸ ę´ëĻŦ", + "manage_members": "ëŠ¤ë˛ ę´ëĻŦ", + "remove_from_list": "ëĻŦė¤í¸ėė ė ęą°", + "add_to_list": "ëĻŦė¤í¸ė ėļę°", + "is_in_list": "ëĻŦė¤í¸ė ė´ë¯¸ ėė", + "editing_list": "{listTitle} ëĻŦė¤í¸ í¸ė§", + "update_title": "ė ëĒŠ ė ėĨ", + "really_delete": "ëĻŦė¤í¸ëĨŧ ėė íėę˛ ė´ė?", + "save": "ëŗę˛Ŋ ėŦíė ė ėĨ", + "creating_list": "ė ëĻŦė¤í¸ ë§ë¤ę¸°", + "add_members": "ėŦėŠė ėļę°", + "error": "ëĻŦė¤í¸ëĨŧ ėĄ°ėíë ë° ė¤ëĨę° ë°ėíėĩëë¤: {0}" + }, + "search": { + "no_more_results": "결ęŗŧ ë ėė", + "load_more": "결ęŗŧ ë ëļëŦė¤ę¸°", + "people": "ėŦë", + "hashtags": "í´ėí꡸", + "person_talking": "{count}ëĒ
ė´ ë§íë ė¤", + "people_talking": "{count}ëĒ
ė´ ë§íë ė¤", + "no_results": "결ęŗŧ ėė" + }, + "password_reset": { + "forgot_password": "í¨ė¤ėëëĨŧ ėėŧė
¨ëė?", + "password_reset": "í¨ė¤ėë ėŦė¤ė ", + "placeholder": "ė´ëŠėŧ ėŖŧė ëë ėŦėŠė ė´ëĻ", + "password_reset_required_but_mailer_is_disabled": "í¨ė¤ėë ė´ę¸°íëĨŧ íė
ėŧ íė§ë§, ëĒģ íę˛ ë§í ėėĩëë¤. ė¸ė¤í´ė¤ ę´ëĻŦėėę˛ ëŦ¸ėí´ėŖŧė¸ė.", + "check_email": "í¨ė¤ėë ė´ę¸°íëĨŧ ėí´ ė´ëŠėŧė íė¸í´ėŖŧė¸ė.", + "return_home": "íėŧëĄ ëėę°ę¸°", + "password_reset_required": "ëĄęˇ¸ė¸íë ¤ëŠ´ í¨ė¤ėëëĨŧ ė´ę¸°íí´ėŧ íŠëë¤.", + "password_reset_disabled": "í¨ė¤ėë ė´ę¸°íëĨŧ ëĒģ íę˛ ëė´ ėėĩëë¤. ė¸ė¤í´ė¤ ę´ëĻŦėėę˛ ëŦ¸ėí´ėŖŧė¸ė.", + "instruction": "ė´ëŠėŧ ėŖŧė ëë ėŦėŠė ė´ëĻė ė
ë Ĩíė¸ė. í¨ė¤ėë ė´ę¸°í ë§íŦëĨŧ ëŠėŧëĄ ëŗ´ë´ëëĻŊëë¤.", + "too_many_requests": "ëëŦ´ ë§ė ėëëĨŧ íėĩëë¤, ëė¤ė ë¤ė í´ėŖŧė¸ė." + }, + "chats": { + "you": "ëšė :", + "delete": "ėė ", + "new": "ė ėąí
", + "chats": "ėąí
", + "empty_message_error": "ëŠėė§ę° ëšė´ ėėĩëë¤", + "more": "ë ëŗ´ę¸°", + "error_loading_chat": "ėė¸ė§ ëǍëĨ´ę˛ ëë° ėąí
ė ëļëŦė¤ė§ ëĒģíėĩëë¤.", + "error_sending_message": "ėė¸ė§ ëǍëĨ´ę˛ ëë° ëŠėė§ëĨŧ ė ėĄíė§ ëĒģíėĩëë¤.", + "delete_confirm": "ė´ ëŠėė§ëĨŧ ė ë§ ė§ė¸ęšė?", + "empty_chat_list_placeholder": "ėąí
ė´ ėë¤ė. ė ėąí
ė ėėí´ëŗ´ė¸ė!", + "message_user": "{nickname}ėę˛ ëŠėė§" + }, + "file_type": { + "audio": "ė¤ëė¤", + "video": "ėė", + "image": "ėŦė§", + "file": "íėŧ" + }, + "display_date": { + "today": "ė¤ë" + }, + "update": { + "big_update_title": "ėí´í´ėŖŧė¸ė", + "update_bugs_gitlab": "Pleroma GitLab", + "update_changelog_here": "ëŗę˛Ŋ ë´ė", + "update_changelog": "ëŦ´ėė´ ë°ëėëė§ ėė¸í ėėëŗ´ėë ¤ëŠ´, {theFullChangelog}ė ė°¸ėĄ°íė¸ė.", + "big_update_content": "ė íŦę° íëė ëĻ´ëĻŦėĻëĨŧ ė í´ė, ėĩėíė
¨ë ėęšėë ę˛Ŋíęŗŧ ë§ė´ ëŦëŧėĄė ė ėėĩëë¤.", + "update_bugs": "ė íŦę° ëšëĄ í
ė¤í¸ëĨŧ ë§ė´ íęŗ ė§ė ę°ë° ë˛ė ė ė°ę¸°ë íė§ë§, ë§ė´ ë°ęž¸ę¸°ë íęŗ , ëĒëĒ ę°ė§ ëėš ė ë¤ė´ ėė í°ė´ë, ėŦėŠí늴ė ëļí¸í ė ė´ë ëŦ¸ė ë {pleromaGitlab}ė ė ëŗ´í´ėŖŧė늴 ę°ėŦíę˛ ėĩëë¤. ė íŦë ę˛Ēėŧė ëŦ¸ė ė ė´ë Pleromaė Pleroma-FEė ëí íŧëë°ąęŗŧ ė ėė íėíŠëë¤." + }, + "unicode_domain_indicator": { + "tooltip": "ė´ ëëŠė¸ė ėė¤í¤ ëŦ¸ėę° ėë ëŦ¸ėëĨŧ íŦí¨íęŗ ėėĩëë¤." + }, + "status": { + "mute_conversation": "ëí 뎤í¸", + "thread_muted_and_words": ", ë¨ė´ íŦí¨:", + "unpin": "íëĄíėė ęŗ ė í´ė ", + "replies_list_with_others": "ëĩę¸ (+{numReplies}ę°): | ëĩę¸ (+{numReplies}ę°):", + "show_attachment_in_modal": "미ëė´ ëǍëŦėė ëŗ´ę¸°", + "thread_hide": "ė´ ė¤ë ë ė¨ę¸°ę¸°", + "show_attachment_description": "ė¤ëĒ
미ëĻŦëŗ´ę¸° (랍ëļëŦŧė ė´ė´ė ė 랴 ė¤ëĒ
ëŗ´ę¸°)", + "thread_show_full": "ė´ ė¤ë ëëĨŧ ė ëļ ë¤ėļ°ëŗ´ę¸° (ė´ {numStatus}ę° ėė, ėĩë ęšė´ {depth}) | ė´ ė¤ë ëëĨŧ ė ëļ ë¤ėļ°ëŗ´ę¸° (ė´ {numStatus}ę° ėė, ėĩë ęšė´ {depth})", + "thread_follow": "ė´ ė¤ë ëė ëë¨¸ė§ ëļëļ ëŗ´ę¸° (ė´ {numStatus}ę°) | ė´ ė¤ë ëė ëë¨¸ė§ ëļëļ ëŗ´ę¸° (ė´ {numStatus}ę°)", + "status_history": "ę˛ėëŦŧ ė´ë Ĩ", + "show_all_conversation": "ė 랴 ëí ëŗ´ę¸° ({numStatus}ę° ë ėė) | ė 랴 ëí ëŗ´ę¸° ({numStatus}ę° ë ėė)", + "repeats": "ëĻŦí", + "delete": "ėė ", + "edit": "ėė ", + "favorites": "ę´ėŦę¸", + "edited_at": "({time}ė ë§ė§ë§ėŧëĄ ėė ë¨)", + "pin": "íëĄíė ęŗ ė ", + "pinned": "ęŗ ė ë¨", + "bookmark": "ëļë§íŦ", + "unbookmark": "ëļë§íŦ í´ė ", + "delete_confirm": "ė ë§ ė§ė°ėę˛ ė´ė?", + "reply_to": "ëĩę¸", + "mentions": "ëŠė
", + "replies_list": "ëĩę¸:", + "unmute_conversation": "ëí ëŽ¤í¸ í´ė ", + "thread_muted": "ė¤ë ë 뎤í¸ë¨", + "status_unavailable": "ę˛ėëŦŧ ė ęˇŧ ëļę°", + "copy_link": "ę˛ėëŦŧ ë§íŦ ëŗĩėŦ", + "external_source": "ėëŗ¸ íė´ė§", + "show_full_subject": "ė 랴 ė ëĒŠ ëŗ´ę¸°", + "hide_full_subject": "ė 랴 ė ëĒŠ ė¨ę¸°ę¸°", + "show_content": "ë´ėŠ ëŗ´ę¸°", + "hide_content": "ë´ėŠ ė¨ę¸°ę¸°", + "status_deleted": "ė§ėė§ ę˛ėëŦŧė
ëë¤", + "nsfw": "ë¯ŧę°í ë´ėŠ", + "expand": "íŧėšę¸°", + "you": "(ëšė )", + "plus_more": "+{number}ę° ë ėė", + "many_attachments": "{number}ę°ė 랍ëļëŦŧė ę°ė§", + "show_all_attachments": "랍ëļëŦŧ ė ëļ ëŗ´ė´ę¸°", + "hide_attachment": "랍ëļëŦŧ ė¨ę¸°ę¸°", + "collapse_attachments": "랍ëļëŦŧ ė 기", + "remove_attachment": "랍ëļëŦŧ ė§ė°ę¸°", + "attachment_stop_flash": "íëė íë ė´ė´ ė ė§", + "move_up": "랍ëļëŦŧ ėŧėĒŊėŧëĄ ë°ę¸°", + "move_down": "랍ëļëŦŧ ė¤ëĨ¸ėĒŊėŧëĄ ë°ę¸°", + "open_gallery": "ę°¤ëŦëĻŦ ė´ę¸°", + "thread_show": "ė´ ė¤ë ë ëŗ´ė´ę¸°", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "ancestor_follow": "ė´ ę˛ėëŦŧ ėë {numReplies}ę° ëĩę¸ ë ëŗ´ę¸° | ė´ ę˛ėëŦŧ ėë {numReplies}ę° ëĩę¸ ë ëŗ´ę¸°", + "show_only_conversation_under_this": "ė´ ę˛ėëŦŧė ëĩę¸ë§ ëŗ´ę¸°" + }, + "errors": { + "storage_unavailable": "Pleromaę° ë¸ëŧė°ė ė ėĨėė ė ęˇŧí ė ėėĩëë¤. ëĄęˇ¸ė¸ė´ íëĻŦęą°ë ëĄėģŦ ė¤ė ė´ ė´ę¸°í ëë ëą ėėėš ëĒģí ëŦ¸ė ëĨŧ ę˛Ēė ė ėėĩëë¤. ėŋ í¤ëĨŧ íėąí í´ëŗ´ė¸ė." + }, + "report": { + "reporter": "ė ęŗ ė:", + "reported_statuses": "ė ęŗ ë ę˛ėëŦŧ:", + "notes": "기í:", + "state": "ėí:", + "state_open": "ė´ëĻŧ", + "state_closed": "ëĢí", + "reported_user": "ė ęŗ ë ėŦėŠė:", + "state_resolved": "í´ę˛°ë¨" + }, + "user_reporting": { + "title": "{0} ė ęŗ ", + "add_comment_description": "ė´ ė ęŗ ėë ë´ ė¸ė¤í´ė¤ė ė¤ėŦėėę˛ ė ëŦëŠëë¤. ė ė´ ęŗė ė ė ęŗ íë ¤ëė§ ėĸ ë ėė¸í ėë ¤ėŖŧė¸ė:", + "additional_comments": "ėļę° ė¤ëĒ
", + "forward_description": "ė´ ęŗė ė ë¤ëĨ¸ ėë˛ė ėë ęŗė ė
ëë¤. ꡸ėĒŊėŧëĄë ė ęŗ ëĨŧ ëŗ´ëŧęšė?", + "forward_to": "{0}ëĄ ė ëŦí기", + "submit": "ė ėĄ", + "generic_error": "ėė˛ė ė˛ëĻŦíë ė¤ ė¤ëĨę° ë°ėíėĩëë¤." + }, + "announcements": { + "end_time_prompt": "ëëë ėę°: ", + "page_header": "ęŗĩė§ėŦí", + "title": "ęŗĩė§ėŦí", + "mark_as_read_action": "ėŊėėŧëĄ íė", + "post_form_header": "ęŗĩė§ėŦí ėėą", + "post_placeholder": "ęŗĩė§ėŦí ë´ėŠė ėėąíė¸ė...", + "post_error": "ė¤ëĨ: {error}", + "close_error": "ëĢ기", + "delete_action": "ėė ", + "post_action": "ę˛ė", + "start_time_prompt": "ėė ėę°: ", + "all_day_prompt": "ė¨ėĸ
ėŧ ėë ė´ë˛¤í¸ė
ëë¤", + "published_time_display": "{time}ė ę˛ėí¨", + "start_time_display": "{time}ė ėėí¨", + "end_time_display": "{time}ė ëë¨", + "edit_action": "í¸ė§", + "submit_edit_action": "ėė ëŗ¸ ë°ė", + "cancel_edit_action": "뎍ė", + "inactive_message": "ė´ ęŗĩė§ėŦíė ëšíėąí ëėėĩëë¤" } } diff --git a/src/i18n/languages.js b/src/i18n/languages.js new file mode 100644 index 00000000..250b3b1a --- /dev/null +++ b/src/i18n/languages.js @@ -0,0 +1,53 @@ + +const languages = [ + 'ar', + 'ca', + 'cs', + 'de', + 'eo', + 'en', + 'es', + 'et', + 'eu', + 'fi', + 'fr', + 'ga', + 'he', + 'hu', + 'it', + 'ja', + 'ja_easy', + 'ko', + 'nb', + 'nl', + 'oc', + 'pl', + 'pt', + 'ro', + 'ru', + 'sk', + 'te', + 'uk', + 'zh', + 'zh_Hant' +] + +const specialJsonName = { + ja: 'ja_pedantic' +} + +const langCodeToJsonName = (code) => specialJsonName[code] || code + +const langCodeToCldrName = (code) => code + +const ensureFinalFallback = codes => { + const codeList = Array.isArray(codes) ? codes : [codes] + return codeList.includes('en') ? codeList : codeList.concat(['en']) +} + +module.exports = { + languages, + langCodeToJsonName, + langCodeToCldrName, + ensureFinalFallback +} diff --git a/src/i18n/messages.js b/src/i18n/messages.js index 2a1161be..74a89ca8 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -7,46 +7,27 @@ // sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json // There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry. -const loaders = { - ar: () => import('./ar.json'), - ca: () => import('./ca.json'), - cs: () => import('./cs.json'), - de: () => import('./de.json'), - eo: () => import('./eo.json'), - es: () => import('./es.json'), - et: () => import('./et.json'), - eu: () => import('./eu.json'), - fi: () => import('./fi.json'), - fr: () => import('./fr.json'), - ga: () => import('./ga.json'), - he: () => import('./he.json'), - hu: () => import('./hu.json'), - it: () => import('./it.json'), - ja: () => import('./ja_pedantic.json'), - ja_easy: () => import('./ja_easy.json'), - ko: () => import('./ko.json'), - nb: () => import('./nb.json'), - nl: () => import('./nl.json'), - oc: () => import('./oc.json'), - pl: () => import('./pl.json'), - pt: () => import('./pt.json'), - ro: () => import('./ro.json'), - ru: () => import('./ru.json'), - te: () => import('./te.json'), - uk: () => import('./uk.json'), - zh: () => import('./zh.json'), - zh_Hant: () => import('./zh_Hant.json') +import { languages, langCodeToJsonName } from './languages.js' + +const hasLanguageFile = (code) => languages.includes(code) + +const loadLanguageFile = (code) => { + return import( + /* webpackInclude: /\.json$/ */ + /* webpackChunkName: "i18n/[request]" */ + `./${langCodeToJsonName(code)}.json` + ) } const messages = { - languages: ['en', ...Object.keys(loaders)], + languages, default: { - en: require('./en.json') + en: require('./en.json').default }, setLanguage: async (i18n, language) => { - if (loaders[language]) { - let messages = await loaders[language]() - i18n.setLocaleMessage(language, messages) + if (hasLanguageFile(language)) { + const messages = await loadLanguageFile(language) + i18n.setLocaleMessage(language, messages.default) } i18n.locale = language } diff --git a/src/i18n/nb.json b/src/i18n/nb.json index 5e3e8ef3..1c160afb 100644 --- a/src/i18n/nb.json +++ b/src/i18n/nb.json @@ -553,8 +553,7 @@ "disable_remote_subscription": "Fjern mulighet til ÃĨ følge brukeren fra andre instanser", "disable_any_subscription": "Fjern mulighet til ÃĨ følge brukeren", "quarantine": "Gjør at statuser fra brukeren ikke kan sendes til andre instanser", - "delete_user": "Slett bruker", - "delete_user_confirmation": "Er du helt sikker? Denne handlingen kan ikke omgjøres." + "delete_user": "Slett bruker" } }, "user_profile": { diff --git a/src/i18n/nl.json b/src/i18n/nl.json index b113ffe4..c0ffe1cd 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -8,10 +8,11 @@ "media_proxy": "Mediaproxy", "scope_options": "Zichtbaarheidsopties", "text_limit": "Tekstlimiet", - "title": "Kenmerken", + "title": "Functies", "who_to_follow": "Wie te volgen", "upload_limit": "Upload limiet", - "pleroma_chat_messages": "Pleroma Chat" + "pleroma_chat_messages": "Pleroma Chat", + "shout": "Shoutbox" }, "finder": { "error_fetching_user": "Fout tijdens ophalen gebruiker", @@ -39,6 +40,15 @@ "role": { "moderator": "Moderator", "admin": "Beheerder" + }, + "flash_content": "Klik om Flash-content te laten zien met Ruffle (Experimenteel, werkt mogelijk niet).", + "flash_security": "Let op: Flash-inhoud is niet gescreend en kan malware bevatten.", + "flash_fail": "Laden van Flash-content is mislukt, zie console voor details.", + "scope_in_timeline": { + "direct": "PrivÊ", + "private": "Alleen-volgers", + "public": "Openbaar", + "unlisted": "Niet-openbaar" } }, "login": { @@ -60,7 +70,7 @@ } }, "nav": { - "about": "Over", + "about": "Over ons", "back": "Terug", "chat": "Lokale Chat", "friend_requests": "Volgverzoeken", @@ -68,7 +78,7 @@ "dms": "PrivÊberichten", "public_tl": "Openbare tijdlijn", "timeline": "Tijdlijn", - "twkn": "Bekende Netwerk", + "twkn": "Globale Netwerk", "user_search": "Gebruiker Zoeken", "who_to_follow": "Wie te volgen", "preferences": "Voorkeuren", @@ -81,22 +91,23 @@ "bookmarks": "Bladwijzers" }, "notifications": { - "broken_favorite": "Onbekende status, aan het zoekenâĻ", - "favorited_you": "vond je status leuk", + "broken_favorite": "Onbekend bericht, aan het zoekenâĻ", + "favorited_you": "vond je bericht leuk", "followed_you": "volgt jou", "load_older": "Oudere meldingen laden", "notifications": "Meldingen", "read": "Gelezen!", - "repeated_you": "herhaalde je status", + "repeated_you": "herhaalde je bericht", "no_more_notifications": "Geen meldingen meer", "migrated_to": "is gemigreerd naar", "follow_request": "wil je volgen", "reacted_with": "reageerde met {0}", - "error": "Fout bij ophalen van meldingen: {0}" + "error": "Fout bij ophalen van meldingen: {0}", + "poll_ended": "peiling is beÃĢindigd" }, "post_status": { - "new_status": "Nieuwe status plaatsen", - "account_not_locked_warning": "Je account is niet {0}. Iedereen kan je volgen om je alleen-volgers-berichten te lezen.", + "new_status": "Nieuw bericht plaatsen", + "account_not_locked_warning": "Je account is niet {0}. Iedereen kan je volgen om je alleen-volgersberichten te lezen.", "account_not_locked_warning_link": "gesloten", "attachments_sensitive": "Bijlagen als gevoelig markeren", "content_type": { @@ -108,10 +119,10 @@ "content_warning": "Onderwerp (optioneel)", "default": "Tijd voor anime!", "direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.", - "posting": "Plaatsen", + "posting": "Aan het plaatsen", "scope": { "direct": "PrivÊ - bericht enkel naar vermelde gebruikers sturen", - "private": "Enkel volgers - bericht enkel naar volgers sturen", + "private": "Alleen-volgers - bericht is enkel leesbaar voor volgers", "public": "Openbaar - bericht op openbare tijdlijnen plaatsen", "unlisted": "Niet vermelden - niet tonen op openbare tijdlijnen" }, @@ -119,11 +130,11 @@ "direct_warning_to_first_only": "Dit bericht zal alleen zichtbaar zijn voor de vermelde gebruikers aan het begin van het bericht.", "scope_notice": { "public": "Dit bericht zal voor iedereen zichtbaar zijn", - "unlisted": "Dit bericht zal niet zichtbaar zijn in de Openbare Tijdlijn en Het Geheel Bekende Netwerk", + "unlisted": "Dit bericht zal niet zichtbaar zijn in de Openbare Tijdlijn en Het Globale Netwerk", "private": "Dit bericht zal voor alleen je volgers zichtbaar zijn" }, - "post": "Bericht", - "empty_status_error": "Kan geen lege status zonder bijlagen plaatsen", + "post": "Plaatsen", + "empty_status_error": "Kan geen leeg bericht zonder bijlagen plaatsen", "preview_empty": "Leeg", "preview": "Voorbeeld", "media_description": "Mediaomschrijving", @@ -149,13 +160,14 @@ "username_placeholder": "bijv. lain", "fullname_placeholder": "bijv. Lain Iwakura", "bio_placeholder": "bijv.\nHallo, ik ben Lain.\nIk ben een animemeisje woonachtig in een buitenwijk in Japan. Je kent me misschien van the Wired.", - "reason_placeholder": "Deze instantie keurt registraties handmatig goed.\nLaat de beheerder weten waarom je wilt registreren.", + "reason_placeholder": "Deze instantie keurt registraties handmatig goed.\nLaat de beheerder weten waarom je je wilt registreren.", "reason": "Reden voor registratie", - "register": "Registreren" + "register": "Registreren", + "email_language": "In welke taal wil je e-mails ontvangen van de server?" }, "settings": { - "attachmentRadius": "Bijlages", - "attachments": "Bijlages", + "attachmentRadius": "Bijlagen", + "attachments": "Bijlagen", "avatar": "Avatar", "avatarAltRadius": "Avatars (meldingen)", "avatarRadius": "Avatars", @@ -169,7 +181,7 @@ "change_password": "Wachtwoord wijzigen", "change_password_error": "Er is een fout opgetreden bij het wijzigen van je wachtwoord.", "changed_password": "Wachtwoord succesvol gewijzigd!", - "collapse_subject": "Klap berichten met een onderwerp in", + "collapse_subject": "Berichten met een onderwerp inklappen", "composing": "Opstellen", "confirm_new_password": "Nieuw wachtwoord bevestigen", "current_avatar": "Je huidige avatar", @@ -181,9 +193,9 @@ "delete_account_description": "Permanent je gegevens verwijderen en account deactiveren.", "delete_account_error": "Er is een fout opgetreden bij het verwijderen van je account. Indien dit probleem zich voor blijft doen, neem dan contact op met de beheerder van deze instantie.", "delete_account_instructions": "Voer je wachtwoord in het onderstaande invoerveld in om het verwijderen van je account te bevestigen.", - "export_theme": "Voorinstelling opslaan", + "export_theme": "Preset opslaan", "filtering": "Filtering", - "filtering_explanation": "Alle statussen die deze woorden bevatten worden genegeerd, ÊÊn filter per regel", + "filtering_explanation": "Alle berichten die deze woorden bevatten worden genegeerd, ÊÊn filter per regel", "follow_export": "Volgers exporteren", "follow_export_button": "Exporteer je volgers naar een csv-bestand", "follow_export_processing": "Aan het verwerken, binnen enkele ogenblikken wordt je gevraagd je bestand te downloaden", @@ -192,13 +204,13 @@ "follows_imported": "Volgers geïmporteerd! Het kan even duren voordat deze verwerkt zijn.", "foreground": "Voorgrond", "general": "Algemeen", - "hide_attachments_in_convo": "Bijlagen in conversaties verbergen", + "hide_attachments_in_convo": "Bijlagen in gesprekken verbergen", "hide_attachments_in_tl": "Bijlagen in tijdlijn verbergen", "hide_isp": "Instantie-specifiek paneel verbergen", "preload_images": "Afbeeldingen vooraf laden", - "hide_post_stats": "Bericht statistieken verbergen (bijv. het aantal favorieten)", + "hide_post_stats": "Bericht-statistieken verbergen (bijv. het aantal favorieten)", "hide_user_stats": "Gebruikers-statistieken verbergen (bijv. het aantal volgers)", - "import_followers_from_a_csv_file": "Gevolgden uit een csv bestand importeren", + "import_followers_from_a_csv_file": "Gevolgde gebruikers uit een csv bestand importeren", "import_theme": "Preset laden", "inputRadius": "Invoervelden", "checkboxRadius": "Checkboxen", @@ -216,13 +228,13 @@ "name_bio": "Naam & bio", "new_password": "Nieuw wachtwoord", "notification_visibility": "Type meldingen die getoond worden", - "notification_visibility_follows": "Gevolgden", + "notification_visibility_follows": "Gevolgde gebruikers", "notification_visibility_likes": "Favorieten", "notification_visibility_mentions": "Vermeldingen", "notification_visibility_repeats": "Herhalingen", "no_rich_text_description": "Verwijder rich text formattering van alle berichten", "hide_network_description": "Toon niet wie mij volgt en wie ik volg.", - "nsfw_clickthrough": "Doorklikbaar verbergen van gevoelige bijlages en link voorbeelden inschakelen", + "nsfw_clickthrough": "Gevoelige media verbergen", "oauth_tokens": "OAuth-tokens", "token": "Token", "refresh_token": "Token vernieuwen", @@ -249,15 +261,15 @@ "settings": "Instellingen", "subject_input_always_show": "Altijd onderwerpveld tonen", "subject_line_behavior": "Onderwerp kopiÃĢren bij beantwoorden", - "subject_line_email": "Zoals email: \"re: onderwerp\"", - "subject_line_mastodon": "Zoals mastodon: kopieer zoals het is", + "subject_line_email": "Zoals e-mail: \"re: onderwerp\"", + "subject_line_mastodon": "Zoals mastodon: kopiÃĢren zoals het is", "subject_line_noop": "Niet kopiÃĢren", - "stop_gifs": "GIFs afspelen bij zweven", + "stop_gifs": "Geanimeerde afbeeldingen afspelen bij zweven", "streaming": "Automatisch streamen van nieuwe berichten inschakelen wanneer tot boven gescrold is", "text": "Tekst", "theme": "Thema", "theme_help": "Hex kleur codes (#rrggbb) gebruiken om je kleur thema te wijzigen.", - "theme_help_v2_1": "Je kan ook de kleur en transparantie van bepaalde componenten overschrijven door de checkbox aan te vinken, gebruik de \"Alles wissen\" knop om alle overschrijvingen te annuleren.", + "theme_help_v2_1": "Je kan ook de kleur en transparantie van bepaalde componenten overschrijven door de checkbox aan te vinken, gebruik de \"Alles wissen\" knop om alle overschrijvingen te herstellen.", "theme_help_v2_2": "Iconen onder sommige onderdelen zijn achtergrond/tekst contrast indicatoren, zweef er over voor gedetailleerde info. Hou er rekening mee dat bij doorzichtigheid de ergst mogelijke situatie wordt weer gegeven.", "tooltipRadius": "Tooltips/alarmen", "user_settings": "Gebruikersinstellingen", @@ -275,10 +287,10 @@ "keep_roundness": "Rondingen behouden", "keep_fonts": "Lettertypes behouden", "save_load_hint": "\"Behoud\" opties behouden de momenteel ingestelde opties bij het selecteren of laden van thema's, maar slaan ook de genoemde opties op bij het exporteren van een thema. Wanneer alle selectievakjes zijn uitgeschakeld, zal het exporteren van thema's alles opslaan.", - "reset": "Reset", + "reset": "Herstellen", "clear_all": "Alles wissen", "clear_opacity": "Transparantie wissen", - "keep_as_is": "Hou zoals het is", + "keep_as_is": "Houden zoals het is", "use_snapshot": "Oude versie", "use_source": "Nieuwe versie", "help": { @@ -289,7 +301,7 @@ "snapshot_source_mismatch": "Versie conflict: waarschijnlijk was FE terug gerold en opnieuw bijgewerkt, indien je het thema aangepast hebt met de oudere versie van FE wil je waarschijnlijk de oude versie gebruiken, gebruik anders de nieuwe versie.", "migration_napshot_gone": "Voor een onduidelijke reden mist de momentopname, dus sommige dingen kunnen anders uitzien dan je gewend bent.", "migration_snapshot_ok": "Voor de zekerheid is een momentopname van het thema geladen. Je kunt proberen om de thema gegevens te laden.", - "fe_downgraded": "PleromaFE's versie is terug gerold.", + "fe_downgraded": "PleromaFE's versie is terug gezet.", "fe_upgraded": "De thema-engine van PleromaFE is bijgewerkt na de versie update.", "snapshot_missing": "Het bestand bevat geen thema momentopname, dus het thema kan anders uitzien dan je oorspronkelijk bedacht had.", "snapshot_present": "Thema momentopname is geladen, alle waarden zijn overschreven. Je kunt in plaats daarvan ook de daadwerkelijke data van het thema laden." @@ -315,7 +327,7 @@ "common_colors": { "_tab_label": "Algemeen", "main": "Algemene kleuren", - "foreground_hint": "Zie \"Geavanceerd\" tab voor meer gedetailleerde controle", + "foreground_hint": "Zie \"Geavanceerd\" tab voor meer gedetailleerde opties", "rgbo": "Iconen, accenten, badges" }, "advanced_colors": { @@ -336,9 +348,9 @@ "selectedMenu": "Geselecteerd menu item", "selectedPost": "Geselecteerd bericht", "pressed": "Ingedrukt", - "highlight": "Gemarkeerde elementen", + "highlight": "Uitgelichte elementen", "icons": "Iconen", - "poll": "Poll grafiek", + "poll": "Peiling grafiek", "underlay": "Onderlaag", "popover": "Tooltips, menu's, popovers", "post": "Berichten / Gebruiker bios", @@ -352,7 +364,7 @@ "wallpaper": "Achtergrond" }, "radii": { - "_tab_label": "Rondheid" + "_tab_label": "Rondingen" }, "shadows": { "_tab_label": "Schaduw en belichting", @@ -374,8 +386,8 @@ "panel": "Paneel", "panelHeader": "Paneel koptekst", "topBar": "Top balk", - "avatar": "Gebruikers avatar (in profiel weergave)", - "avatarStatus": "Gebruikers avatar (in bericht weergave)", + "avatar": "Gebruikers-avatar (in profiel weergave)", + "avatarStatus": "Gebruikers-avatar (in bericht weergave)", "popup": "Popups en tooltips", "button": "Knop", "buttonHover": "Knop (zweven)", @@ -386,7 +398,7 @@ "hintV3": "Voor schaduwen kun je ook de {0} notatie gebruiken om de andere kleur invoer te gebruiken." }, "fonts": { - "_tab_label": "Lettertypes", + "_tab_label": "Lettertypen", "help": "Selecteer het lettertype om te gebruiken voor elementen van de UI. Voor \"aangepast\" dien je de exacte naam van het lettertype in te voeren zoals die in het systeem wordt weergegeven.", "components": { "interface": "Interface", @@ -426,10 +438,10 @@ "wait_pre_setup_otp": "OTP voorinstellen", "confirm_and_enable": "Bevestig en schakel OTP in", "title": "Twee-factorauthenticatie", - "generate_new_recovery_codes": "Genereer nieuwe herstelcodes", + "generate_new_recovery_codes": "Nieuwe herstelcodes genereren", "recovery_codes": "Herstelcodes.", "waiting_a_recovery_codes": "Back-upcodes ontvangenâĻ", - "authentication_methods": "Authenticatiemethodes", + "authentication_methods": "Authenticatiemethoden", "scan": { "title": "Scannen", "desc": "Scan de QR-code of voer een sleutel in met je twee-factorapplicatie:", @@ -441,39 +453,39 @@ "warning_of_generate_new_codes": "Wanneer je nieuwe herstelcodes genereert, zullen je oude codes niet langer werken.", "recovery_codes_warning": "Schrijf de codes op of sla ze op een veilige locatie op - anders kun je ze niet meer inzien. Als je toegang tot je 2FA-app en herstelcodes verliest, zal je buitengesloten zijn van je account." }, - "allow_following_move": "Automatisch volgen toestaan wanneer een gevolgd account migreert", - "block_export": "Blokkades exporteren", - "block_import": "Blokkades importeren", - "blocks_imported": "Blokkades geïmporteerd! Het kan even duren voordat deze verwerkt zijn.", - "blocks_tab": "Blokkades", + "allow_following_move": "Automatisch volgen toestaan wanneer een gevolgd account verhuist", + "block_export": "Geblokkeerde gebruikers exporteren", + "block_import": "Geblokkeerde gebruikers importeren", + "blocks_imported": "Geblokkeerde gebruikers geïmporteerd! Het kan even duren voordat deze verwerkt zijn.", + "blocks_tab": "Geblokkeerde gebruikers", "change_email": "E-mail wijzigen", "change_email_error": "Er is een fout opgetreden tijdens het wijzigen van je e-mailadres.", "changed_email": "E-mailadres succesvol gewijzigd!", "domain_mutes": "Domeinen", "avatar_size_instruction": "De aangeraden minimale afmeting voor avatar-afbeeldingen is 150x150 pixels.", - "pad_emoji": "Vul emoji aan met spaties wanneer deze met de picker ingevoegd worden", + "pad_emoji": "Emoji aan met spaties aanvullen wanneer deze met de picker ingevoegd worden", "emoji_reactions_on_timeline": "Toon emoji-reacties op de tijdlijn", "accent": "Accent", "hide_muted_posts": "Berichten van genegeerde gebruikers verbergen", "max_thumbnails": "Maximaal aantal miniaturen per bericht", "use_one_click_nsfw": "Gevoelige bijlagen met slechts ÊÊn klik openen", - "hide_filtered_statuses": "Gefilterde statussen verbergen", - "import_blocks_from_a_csv_file": "Blokkades van een csv bestand importeren", - "mutes_tab": "Genegeerden", - "play_videos_in_modal": "Video's in een popup frame afspelen", + "hide_filtered_statuses": "Gefilterde berichten verbergen", + "import_blocks_from_a_csv_file": "Geblokkeerde gebruikers van een csv bestand importeren", + "mutes_tab": "Genegeerde gebruikers", + "play_videos_in_modal": "Video's in een popup venster afspelen", "new_email": "Nieuwe e-mail", "notification_visibility_emoji_reactions": "Reacties", - "no_blocks": "Geen blokkades", - "no_mutes": "Geen genegeerden", + "no_blocks": "Geen geblokkeerde gebruikers", + "no_mutes": "Geen genegeerde gebruikers", "hide_followers_description": "Niet tonen wie mij volgt", "hide_followers_count_description": "Niet mijn volgers aantal tonen", - "hide_follows_count_description": "Niet mijn gevolgde aantal tonen", + "hide_follows_count_description": "Niet mijn gevolgden aantal tonen", "show_admin_badge": "\"Beheerder\" badge in mijn profiel tonen", - "autohide_floating_post_button": "Nieuw Bericht knop automatisch verbergen (mobiel)", + "autohide_floating_post_button": "\"Bericht opstellen\"-knop automatisch verbergen (mobiel)", "search_user_to_block": "Zoek wie je wilt blokkeren", "search_user_to_mute": "Zoek wie je wilt negeren", "minimal_scopes_mode": "Bericht bereik-opties minimaliseren", - "post_status_content_type": "Bericht status content type", + "post_status_content_type": "Standaard bericht content type", "user_mutes": "Gebruikers", "useStreamingApi": "Berichten en meldingen in real-time ontvangen", "useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)", @@ -482,7 +494,7 @@ "fun": "Plezier", "greentext": "Meme pijlen", "block_export_button": "Exporteer je geblokkeerde gebruikers naar een csv-bestand", - "block_import_error": "Fout bij importeren blokkades", + "block_import_error": "Fout bij importeren geblokkeerde gebruikers", "discoverable": "Sta toe dat dit account ontdekt kan worden in zoekresultaten en andere diensten", "use_contain_fit": "Bijlage in miniaturen niet bijsnijden", "notification_visibility_moves": "Gebruiker Migraties", @@ -495,7 +507,7 @@ "backend_version": "Backend versie", "title": "Versie" }, - "mutes_and_blocks": "Negeringen en Blokkades", + "mutes_and_blocks": "Negeren en Blokkeren", "profile_fields": { "value": "Inhoud", "name": "Label", @@ -508,15 +520,15 @@ "hide_media_previews": "Media voorbeelden verbergen", "word_filter": "Woord filter", "chatMessageRadius": "Chatbericht", - "mute_export": "Genegeerden export", - "mute_export_button": "Exporteer je genegeerden naar een csv-bestand", - "mute_import_error": "Fout tijdens het importeren van genegeerden", - "mute_import": "Genegeerden import", - "mutes_imported": "Genegeerden geïmporteerd! Het kan even duren voordat deze verwerkt zijn.", + "mute_export": "Genegeerde gebruikers export", + "mute_export_button": "Genegeerde gebruikers naar een csv-bestand exporteren", + "mute_import_error": "Fout tijdens het importeren van genegeerde gebruikers", + "mute_import": "Genegeerde gebruikers import", + "mutes_imported": "Genegeerde gebruikers geïmporteerd! Het kan even duren voordat deze verwerkt zijn.", "more_settings": "Meer instellingen", - "notification_setting_hide_notification_contents": "Afzender en inhoud van push meldingen verbergen", + "notification_setting_hide_notification_contents": "Afzender en inhoud van push-meldingen verbergen", "notification_setting_block_from_strangers": "Meldingen van gebruikers die je niet volgt blokkeren", - "virtual_scrolling": "Tijdlijn rendering optimaliseren", + "virtual_scrolling": "Tijdlijn weergave optimaliseren", "sensitive_by_default": "Berichten standaard als gevoelig markeren", "reset_avatar_confirm": "Wil je echt de avatar herstellen?", "reset_banner_confirm": "Wil je echt de banner herstellen?", @@ -528,7 +540,7 @@ "reply_visibility_following_short": "Antwoorden naar mijn gevolgden tonen", "file_export_import": { "errors": { - "file_slightly_new": "Bestand minor versie is verschillend, sommige instellingen kunnen mogelijk niet worden geladen", + "file_slightly_new": "Minor versie van bestand is verschillend, sommige instellingen kunnen mogelijk niet worden geladen", "file_too_old": "Incompatibele hoofdversie: {fileMajor}, bestandsversie is te oud en wordt niet ondersteund (minimale versie {feMajor})", "file_too_new": "Incompatibele hoofdversie: {fileMajor}, deze PleromaFE (instellingen versie {feMajor}) is te oud om deze te ondersteunen", "invalid_file": "Het geselecteerde bestand is niet een door Pleroma ondersteunde instellingen back-up. Er zijn geen wijzigingen gemaakt." @@ -536,27 +548,95 @@ "restore_settings": "Instellingen uit bestand herstellen", "backup_settings_theme": "Instellingen en thema naar bestand back-uppen", "backup_settings": "Instellingen naar bestand back-uppen", - "backup_restore": "Instellingen backup" + "backup_restore": "Instellingen back-up" }, - "hide_wallpaper": "Instantie achtergrond verbergen", + "hide_wallpaper": "Achtergrond-afbeelding verbergen", "hide_all_muted_posts": "Genegeerde berichten verbergen", - "import_mutes_from_a_csv_file": "Importeer genegeerden van een csv bestand" + "import_mutes_from_a_csv_file": "Genegeerde gebruikers uit een csv bestand importeren", + "added_alias": "Alias is toegevoegd.", + "add_alias_error": "Fout bij het toevoegen van alias: {error}", + "move_account": "Account verhuizen", + "move_account_notes": "Indien je het account ergens anders heen wilt verplaatsen, dien je eerst een alias naar dit account te maken in het nieuwe account.", + "move_account_target": "Doelwit account (b.v. {example})", + "moved_account": "Het account is verhuisd.", + "move_account_error": "Fout tijdens account verhuizen: {error}", + "wordfilter": "Woordfilter", + "third_column_mode": "Indien er genoeg plaats is, derde kolom tonen met", + "third_column_mode_none": "GÊÊn derde kolom tonen", + "third_column_mode_notifications": "Meldingen", + "third_column_mode_postform": "Berichtformulier en navigatie", + "tree_advanced": "Flexibelere navigatie toestaan in boom weergave", + "tree_fade_ancestors": "Ouders van huidige bericht met gedempte tekst tonen", + "conversation_display_linear": "Lineaire weergave", + "mention_link_display_full_for_remote": "als volledige namen alleen voor externe gebruikers (b.v. {'@'}foo{'@'}example.org)", + "mention_link_display_full": "altijd als volledige namen (b.v. {'@'}foo{'@'}example.org)", + "mention_link_show_avatar": "Profielfoto naast link tonen", + "mention_link_fade_domain": "Domeinen vervagen (b.v. {'@'}example.org in {'@'}foo{'@'}example.org)", + "mention_link_bolden_you": "Vermeldingen naar jezelf uitlichten", + "expert_mode": "Geavanceerde opties tonen", + "setting_server_side": "Deze instelling is gebonden aan je profiel en beïnvloed alle sessies en clients", + "post_look_feel": "Berichten Look & Feel", + "mention_links": "Vermelding-links", + "email_language": "Taal voor e-mails van de server", + "account_backup": "Account back-up", + "account_backup_description": "Hiermee kun je een archief van je account gegevens en berichten downloaden, maar deze kunnen nog niet geïmporteerd worden in een Pleroma account.", + "account_backup_table_head": "Back-up", + "download_backup": "Downloaden", + "backup_not_ready": "Deze back-up is nog niet gereed.", + "remove_backup": "Verwijderen", + "list_backups_error": "Fout bij het ophalen van back-ups: {error}", + "add_backup": "Nieuwe back-up aanmaken", + "added_backup": "Nieuwe back-up is toegevoegd.", + "add_backup_error": "Fout bij het maken van back-up: {error}", + "account_alias": "Account aliassen", + "account_alias_table_head": "Alias", + "list_aliases_error": "Fout bij het ophalen van aliassen: {error}", + "hide_list_aliases_error_action": "Sluiten", + "remove_alias": "Deze alias verwijderen", + "new_alias_target": "Nieuwe alias toevoegen (b.v. {example})", + "mute_bot_posts": "Bot-berichten negeren", + "hide_bot_indication": "Bot-indicatie in berichten verbergen", + "hide_shoutbox": "Shoutbox verbergen", + "right_sidebar": "Kolom-volgorde omdraaien", + "always_show_post_button": "Altijd de zwevende \"Bericht opstellen\"-knop tonen", + "hide_wordfiltered_statuses": "Berichten met gefilterde woorden verbergen", + "hide_muted_threads": "Genegeerde gesprekken verbergen", + "account_privacy": "Privacy", + "posts": "Berichten", + "user_profiles": "Gebruikersprofielen", + "notification_visibility_polls": "Einde van peilingen waar je in gestemd hebt", + "hide_favorites_description": "Lijst van favorieten verbergen (mensen krijgen wel nog meldingen)", + "conversation_display": "Gespreksweergave stijl", + "conversation_display_tree": "Boom weergave", + "disable_sticky_headers": "Kolomkopteksten niet bovenaan het scherm plakken", + "show_scrollbars": "Scrollbalk tonen in zijkolommen", + "conversation_other_replies_button": "\"Andere antwoorden\"-knop tonen", + "conversation_other_replies_button_below": "Onder berichten", + "conversation_other_replies_button_inside": "Binnen in berichten", + "max_depth_in_thread": "Maximum lagen van een gesprek welke standaard getoond dienen te worden", + "use_at_icon": "{'@'} symbool als icoon tonen in plaats van tekst", + "mention_link_display": "Vermelding-links tonen", + "mention_link_display_short": "altijd als korte namen (b.v. {'@'}foo)", + "mention_link_use_tooltip": "Volledige namen in tooltip tonen voor externe gebruikers", + "show_yous": "(Jij)'s tonen", + "user_popover_avatar_zoom": "Gebruikers-avatar inzoomen wanneer hier op geklikt wordt in een popover in plaats van de popover te sluiten", + "user_popover_avatar_overlay": "Gebruikers-popover tonen over gebruikers-avatar" }, "timeline": { - "collapse": "Inklappen", - "conversation": "Conversatie", + "collapse": "Invouwen", + "conversation": "Gesprek", "error_fetching": "Fout bij ophalen van updates", - "load_older": "Oudere statussen laden", - "no_retweet_hint": "Bericht is gemarkeerd als enkel volgers of direct en kan niet worden herhaald", + "load_older": "Oudere berichten laden", + "no_retweet_hint": "Bericht is gemarkeerd als enkel-volgers of privÊ en kan niet worden herhaald of geciteerd", "repeated": "herhaalde", "show_new": "Nieuwe tonen", "up_to_date": "Up-to-date", - "no_statuses": "Geen statussen", - "no_more_statuses": "Geen statussen meer", + "no_statuses": "Geen berichten", + "no_more_statuses": "Geen verdere berichten", "socket_broke": "Realtime verbinding verloren: CloseEvent code {0}", "socket_reconnected": "Realtime verbinding opgezet", "reload": "Verversen", - "error": "Fout tijdens het ophalen van tijdlijn: {0}" + "error": "Fout bij het ophalen van tijdlijn: {0}" }, "user_card": { "approve": "Goedkeuren", @@ -565,28 +645,27 @@ "deny": "Weigeren", "favorites": "Favorieten", "follow": "Volgen", - "follow_cancel": "Aanvraag annuleren", - "follow_sent": "Aanvraag verzonden!", + "follow_cancel": "Verzoek annuleren", + "follow_sent": "Verzoek verzonden!", "follow_progress": "AanvragenâĻ", - "follow_unfollow": "Stop volgen", - "followees": "Aan het volgen", + "follow_unfollow": "Ontvolgen", + "followees": "Volgen", "followers": "Volgers", - "following": "Aan het volgen!", + "following": "Gevolgd!", "follows_you": "Volgt jou!", "its_you": "'t is jij!", "mute": "Negeren", "muted": "Genegeerd", "per_day": "per dag", - "remote_follow": "Volg vanop afstand", - "statuses": "Statussen", + "remote_follow": "Van afstand volgen", + "statuses": "Berichten", "admin_menu": { - "delete_user_confirmation": "Weet je het heel zeker? Deze uitvoering kan niet ongedaan worden gemaakt.", "delete_user": "Gebruiker verwijderen", - "quarantine": "Federeren van gebruikers berichten verbieden", + "quarantine": "Federeren van berichten verbieden", "disable_any_subscription": "Volgen van gebruiker in zijn geheel verbieden", "disable_remote_subscription": "Volgen van gebruiker vanaf andere instanties verbieden", "sandbox": "Berichten forceren om alleen voor volgers zichtbaar te zijn", - "force_unlisted": "Berichten forceren om niet publiekelijk getoond te worden", + "force_unlisted": "Berichten forceren om niet openbaar getoond te worden", "strip_media": "Media van berichten verwijderen", "force_nsfw": "Alle berichten als gevoelig markeren", "delete_account": "Account verwijderen", @@ -596,30 +675,33 @@ "grant_moderator": "Moderatorsrechten toekennen", "revoke_admin": "Beheerdersrechten intrekken", "grant_admin": "Beheerdersrechten toekennen", - "moderation": "Moderatie" + "moderation": "Moderatie", + "delete_user_data_and_deactivate_confirmation": "Dit zal permanent alle data van dit account verwijderen en het account deactiveren. Weet je het zeker?" }, "show_repeats": "Herhalingen tonen", "hide_repeats": "Herhalingen verbergen", "mute_progress": "NegerenâĻ", - "unmute_progress": "Negering opheffenâĻ", - "unmute": "Negering opheffen", + "unmute_progress": "Negeren opheffenâĻ", + "unmute": "Negeren opheffen", "block_progress": "BlokkerenâĻ", - "unblock_progress": "Blokkade opheffenâĻ", - "unblock": "Blokkade opheffen", + "unblock_progress": "Blokkeren opheffenâĻ", + "unblock": "Blokkeren opheffen", "unsubscribe": "Abonnement opzeggen", "subscribe": "Abonneren", - "report": "Aangeven", - "mention": "Vermelding", + "report": "Rapporteren", + "mention": "Vermelden", "media": "Media", "hidden": "Verborgen", "highlight": { "side": "Zijstreep", "striped": "Gestreepte achtergrond", "solid": "Effen achtergrond", - "disabled": "Geen highlight" + "disabled": "Geen uitlichting" }, "bot": "Bot", - "message": "Bericht" + "message": "Bericht", + "edit_profile": "Profiel wijzigen", + "deactivated": "Gedeactiveerd" }, "user_profile": { "timeline_title": "Gebruikerstijdlijn", @@ -635,11 +717,11 @@ "repeat": "Herhalen", "reply": "Beantwoorden", "favorite": "Favoriet maken", - "user_settings": "Gebruikers Instellingen", - "reject_follow_request": "Volg-verzoek afwijzen", - "accept_follow_request": "Volg-aanvraag accepteren", + "user_settings": "Gebruikersinstellingen", + "reject_follow_request": "Volgverzoek afwijzen", + "accept_follow_request": "Volgverzoek accepteren", "add_reaction": "Reactie toevoegen", - "bookmark": "Bladwijzer" + "bookmark": "Bladwijzer maken" }, "upload": { "error": { @@ -664,27 +746,27 @@ "replace": "Vervangen", "is_replaced_by": "â", "keyword_policies": "Zoekwoordbeleid", - "ftl_removal": "Verwijdering van \"Het Geheel Bekende Netwerk\" Tijdlijn" + "ftl_removal": "Verwijderen van \"Het Globale Netwerk\" Tijdlijn" }, "mrf_policies_desc": "MRF-regels beïnvloeden het federatiegedrag van de instantie. De volgende regels zijn ingeschakeld:", "mrf_policies": "Ingeschakelde MRF-regels", "simple": { - "simple_policies": "Instantiespecifieke regels", + "simple_policies": "Instantie-specifieke regels", "instance": "Instantie", "reason": "Reden", "not_applicable": "n.v.t.", "accept": "Accepteren", "accept_desc": "Deze instantie accepteert alleen berichten van de volgende instanties:", "reject": "Afwijzen", - "reject_desc": "Deze instantie zal geen berichten accepteren van de volgende instanties:", + "reject_desc": "Deze instantie zal gÊÊn berichten accepteren van de volgende instanties:", "quarantine": "Quarantaine", - "quarantine_desc": "Deze instantie zal alleen openbare berichten sturen naar de volgende instanties:", - "ftl_removal_desc": "Deze instantie verwijdert de volgende instanties van \"Bekende Netwerk\" tijdlijn:", + "quarantine_desc": "Deze instantie zal gÊÊn berichten sturen naar de volgende instanties:", + "ftl_removal_desc": "Deze instantie verwijdert de volgende instanties van \"Globale Netwerk\" tijdlijn:", "media_removal_desc": "Deze instantie verwijdert media van berichten van de volgende instanties:", - "media_nsfw_desc": "Deze instantie stelt media in als gevoelig in berichten van de volgende instanties:", - "ftl_removal": "Verwijderen van \"Bekende Netwerk\" Tijdlijn", - "media_removal": "Mediaverwijdering", - "media_nsfw": "Forceer media als gevoelig" + "media_nsfw_desc": "Deze instantie markeert media als gevoelig in berichten van de volgende instanties:", + "ftl_removal": "Verwijderen van \"Globale Netwerk\" Tijdlijn", + "media_removal": "Verwijderen van media", + "media_nsfw": "Media als gevoelig markeren" } }, "staff": "Personeel" @@ -692,8 +774,8 @@ "domain_mute_card": { "mute": "Negeren", "mute_progress": "NegerenâĻ", - "unmute": "Negering opheffen", - "unmute_progress": "Negering wordt opgehevenâĻ" + "unmute": "Negeren opheffen", + "unmute_progress": "Negeren wordt opgehevenâĻ" }, "exporter": { "export": "Exporteren", @@ -712,21 +794,23 @@ }, "media_modal": { "previous": "Vorige", - "next": "Volgende" + "next": "Volgende", + "counter": "{current} / {total}", + "hide": "Media venster sluiten" }, "polls": { - "add_poll": "Poll toevoegen", + "add_poll": "Peiling toevoegen", "add_option": "Optie toevoegen", "option": "Optie", "votes": "stemmen", - "vote": "Stem", + "vote": "Stemmen", "single_choice": "Enkele keuze", "multiple_choices": "Meerkeuze", - "expiry": "Poll leeftijd", - "expires_in": "Poll eindigt in {0}", - "expired": "Poll is {0} geleden beÃĢindigd", - "not_enough_options": "Te weinig opties in poll", - "type": "Poll-type", + "expiry": "Peiling tijdsduur", + "expires_in": "Peiling eindigt in {0}", + "expired": "Peiling is {0} geleden beÃĢindigd", + "not_enough_options": "Te weinig opties in peiling", + "type": "Peiling-type", "votes_count": "{count} stem | {count} stemmen", "people_voted_count": "{count} persoon heeft gestemd | {count} personen hebben gestemd" }, @@ -743,28 +827,41 @@ }, "interactions": { "favs_repeats": "Herhalingen en favorieten", - "follows": "Nieuwe gevolgden", + "follows": "Nieuwe volgs", "moves": "Gebruikermigraties", + "emoji_reactions": "Emoji Reacties", + "reports": "Rapportages", "load_older": "Oudere interacties laden" }, "remote_user_resolver": { "searching_for": "Zoeken naar", "error": "Niet gevonden.", - "remote_user_resolver": "Externe gebruikers-zoeker" - }, + "remote_user_resolver": "Externe gebruiker zoeker" + }, + "report": { + "reporter": "Reporteerder:", + "reported_user": "Gerapporteerde gebruiker:", + "reported_statuses": "Gerapporteerde statussen:", + "notes": "Notas:", + "state": "Status:", + "state_open": "Open", + "state_closed": "Gesloten", + "state_resolved": "Opgelost" + }, + "selectable_list": { "select_all": "Alles selecteren" }, "password_reset": { - "password_reset_required_but_mailer_is_disabled": "Je dient je wachtwoord opnieuw in te stellen, maar wachtwoord reset is uitgeschakeld. Neem contact op met de beheerder van deze instantie.", + "password_reset_required_but_mailer_is_disabled": "Je dient je wachtwoord opnieuw in te stellen, maar wachtwoordherstel is uitgeschakeld. Neem contact op met de beheerder van deze instantie.", "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.", + "password_reset_disabled": "Wachtwoordherstel 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.", "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", "instruction": "Voer je email adres of gebruikersnaam in. We sturen je een link om je wachtwoord opnieuw in te stellen.", - "password_reset": "Wachtwoord opnieuw instellen", + "password_reset": "Wachtwoord herstellen", "forgot_password": "Wachtwoord vergeten?" }, "search": { @@ -780,26 +877,26 @@ "forward_to": "Doorsturen naar {0}", "forward_description": "Dit account hoort bij een andere server. Wil je een kopie van het rapport ook daarheen sturen?", "additional_comments": "Aanvullende opmerkingen", - "add_comment_description": "Het rapport zal naar de moderators van de instantie worden verstuurd. Je kunt hieronder uitleg bijvoegen waarom je dit account wilt aangeven:", - "title": "{0} aangeven" + "add_comment_description": "Het rapport zal naar de moderators van de instantie worden verstuurd. Je kunt hieronder uitleg bijvoegen waarom je dit account wilt rapporteren:", + "title": "{0} rapporteren" }, "status": { - "copy_link": "Link naar status kopiÃĢren", - "status_unavailable": "Status niet beschikbaar", - "unmute_conversation": "Conversatie niet meer negeren", - "mute_conversation": "Conversatie negeren", + "copy_link": "Link naar bericht kopiÃĢren", + "status_unavailable": "Bericht niet beschikbaar", + "unmute_conversation": "Gesprek niet meer negeren", + "mute_conversation": "Gesprek negeren", "replies_list": "Antwoorden:", "reply_to": "Antwoorden aan", - "delete_confirm": "Wil je echt deze status verwijderen?", + "delete_confirm": "Wil je echt dit bericht verwijderen?", "pin": "Aan profiel vastmaken", "pinned": "Vastgezet", "unpin": "Van profiel losmaken", - "delete": "Status verwijderen", + "delete": "Bericht verwijderen", "repeats": "Herhalingen", "favorites": "Favorieten", "thread_muted_and_words": ", heeft woorden:", - "thread_muted": "Thread genegeerd", - "expand": "Uitklappen", + "thread_muted": "Gesprek genegeerd", + "expand": "Uitvouwen", "nsfw": "Gevoelig", "status_deleted": "Dit bericht is verwijderd", "hide_content": "Inhoud verbergen", @@ -808,7 +905,33 @@ "show_full_subject": "Volledig onderwerp tonen", "external_source": "Externe bron", "unbookmark": "Bladwijzer verwijderen", - "bookmark": "Bladwijzer toevoegen" + "bookmark": "Bladwijzer toevoegen", + "show_attachment_description": "Voorbeeld beschrijving (open bijlage om de volledige beschrijving te zien)", + "remove_attachment": "Bijlage verwijderen", + "attachment_stop_flash": "Flash speler stoppen", + "move_up": "Bijlage naar links schuiven", + "move_down": "Bijlage naar rechts schuiven", + "open_gallery": "Gallerij openen", + "thread_hide": "Gesprek verbergen", + "thread_show": "Gesprek tonen", + "show_all_conversation": "Volledig gesprek tonen ({numStatus} ander bericht) | Volledig gesprek tonen ({numStatus} andere berichten)", + "show_only_conversation_under_this": "Alleen antwoorden op dit bericht tonen", + "mentions": "Vermeldingen", + "replies_list_with_others": "Antwoorden (+{numReplies} andere): | Antwoorden (+{numReplies} anderen):", + "you": "(Jij)", + "plus_more": "+{number} meer", + "many_attachments": "Bericht heeft {number} bijlage | Bericht heeft {number} bijlagen", + "collapse_attachments": "Bijlagen invouwen", + "show_all_attachments": "Alle bijlagen tonen", + "show_attachment_in_modal": "In media venster tonen", + "hide_attachment": "Bijlage verbergen", + "thread_show_full": "Alle berichten in dit gesprek tonen ({numStatus} bericht in totaal, max. diepte {depth}) | Alle berichten in dit gesprek tonen ({numStatus} berichten in totaal, max. diepte {depth})", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "Rest van gesprek tonen ({numStatus} bericht in totaal) | Rest van gesprek tonen ({numStatus} berichten in totaal)", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "{numReplies} ander antwoord onder dit bericht tonen | {numReplies} andere antwoorden onder dit bericht tonen", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}" }, "time": { "years_short": "{0}j", @@ -842,13 +965,29 @@ "days_short": "{0}d", "day_short": "{0}d", "days": "{0} dagen", - "day": "{0} dag" + "day": "{0} dag", + "unit": { + "months": "{0} maand | {0} maanden", + "months_short": "{0}ma", + "seconds": "{0} seconde | {0} seconden", + "seconds_short": "{0}s", + "weeks": "{0} week | {0} weken", + "weeks_short": "{0}w", + "years": "{0} jaar | {0} jaren", + "years_short": "{0}j", + "days": "{0} dag | {0} dagen", + "days_short": "{0}d", + "hours": "{0} uur | {0} uren", + "hours_short": "{0}u", + "minutes": "{0} minuut | {0} minuten", + "minutes_short": "{0}min" + } }, "shoutbox": { "title": "Shoutbox" }, "errors": { - "storage_unavailable": "Pleroma kon browseropslag niet benaderen. Je login of lokale instellingen worden niet opgeslagen en je kunt onverwachte problemen ondervinden. Probeer cookies te accepteren." + "storage_unavailable": "Pleroma kan de browseropslag niet benaderen. Je login of lokale instellingen worden niet opgeslagen en je kunt onverwachte problemen ondervinden. Probeer cookies te accepteren." }, "display_date": { "today": "Vandaag" diff --git a/src/i18n/oc.json b/src/i18n/oc.json index 40f48149..556b3d0b 100644 --- a/src/i18n/oc.json +++ b/src/i18n/oc.json @@ -501,8 +501,7 @@ "disable_remote_subscription": "Desactivar lo seguiment dâutilizaire dâinstà ncias alonhadas", "disable_any_subscription": "Desactivar tot seguiment", "quarantine": "Defendre la federacion de las publicacions de lâutilizaire", - "delete_user": "Suprimir lâutilizaire", - "delete_user_confirmation": "Volètz vertadièrament far aquÃ˛â¯? Aquesta accion se pÃ˛t pas anullar." + "delete_user": "Suprimir lâutilizaire" } }, "user_profile": { diff --git a/src/i18n/pl.json b/src/i18n/pl.json index 304a0349..efebcc83 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -762,8 +762,7 @@ "disable_remote_subscription": "ZakaÅŧ obserwowania uÅŧytkownika ze zdalnych instancji", "disable_any_subscription": "ZakaÅŧ caÅkowicie obserwowania uÅŧytkownika", "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." + "delete_user": "UsuÅ uÅŧytkownika" }, "message": "Napisz", "edit_profile": "Edytuj profil", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e32a95e4..b997701c 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -594,7 +594,6 @@ "unmute_progress": "A retirar silÃĒncioâĻ", "mute_progress": "A silenciarâĻ", "admin_menu": { - "delete_user_confirmation": "Tens a certeza? Esta aÃ§ÃŖo nÃŖo pode ser revertida.", "delete_user": "Eliminar utilizador", "quarantine": "NÃŖo permitir publicaçÃĩes de utilizadores de instÃĸncias remotas", "disable_any_subscription": "NÃŖo permitir que nenhum utilizador te siga", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index ba0cec28..02815f3e 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -456,6 +456,15 @@ "subject_line_mastodon": "ĐаĐē в Mastodon: ŅĐēĐžĐŋиŅОваŅŅ ĐēаĐē ĐĩŅŅŅ", "subject_line_email": "ĐаĐē в ŅĐģĐĩĐēŅŅĐžĐŊĐŊОК ĐŋĐžŅŅĐĩ: \"re: ŅĐĩĐŧа\"", "subject_line_behavior": "ĐĐžĐŋиŅОваŅŅ ŅĐĩĐŧŅ Đ˛ ĐžŅвĐĩŅаŅ
", + "third_column_mode": "ĐĐžĐŗĐ´Đ° ĐŊĐĩĐ´ĐžŅŅаŅĐžŅĐŊĐž ĐŧĐĩŅŅа, ĐŋĐžĐēаСŅваŅŅ ŅŅĐĩŅŅŅ ĐēĐžĐģĐžĐŊĐēŅ ŅОдĐĩŅĐļаŅŅŅ", + "third_column_mode_none": "ĐĐĩ ĐŋĐžĐēаСŅваŅŅ ŅŅĐĩŅŅŅ ĐēĐžĐģĐžĐŊĐēŅ ŅОвŅĐĩĐŧ", + "third_column_mode_notifications": "ĐĐžĐģĐžĐŊĐēŅ ŅвĐĩĐ´ĐžĐŧĐģĐĩĐŊиК", + "third_column_mode_postform": "ФОŅĐŧŅ ĐžŅĐŋŅавĐēи ŅООйŅĐĩĐŊĐ¸Ņ Đ¸ ĐŊĐ°Đ˛Đ¸ĐŗĐ°ŅиŅ", + "columns": "ĐĐžĐģĐžĐŊĐēи", + "column_sizes": "РаСĐŧĐĩŅŅ ĐēĐžĐģĐžĐŊĐžĐē", + "column_sizes_sidebar": "ĐĐžĐēОвОК", + "column_sizes_content": "ХОдĐĩŅĐļиĐŧĐžĐŗĐž", + "column_sizes_notifs": "ĐŖĐ˛ĐĩĐ´ĐžĐŧĐģĐĩĐŊиК", "no_mutes": "ĐĐĩŅ Đ¸ĐŗĐŊĐžŅиŅŅĐĩĐŧŅŅ
", "no_blocks": "ĐĐĩŅ ĐąĐģĐžĐēиŅОвОĐē", "notification_visibility_emoji_reactions": "Đ ĐĩаĐēŅии", @@ -576,8 +585,7 @@ "disable_remote_subscription": "ĐаĐŋŅĐĩŅиŅŅ ŅиŅаŅŅ Ņ Đ´ŅŅĐŗĐ¸Ņ
ŅСĐģОв", "disable_any_subscription": "ĐаĐŋŅĐĩŅиŅŅ ŅиŅаŅŅ ĐŋĐžĐģŅСОваŅĐĩĐģŅ", "quarantine": "ĐĐĩ ŅĐĩĐ´ĐĩŅиŅОваŅŅ ŅŅаŅŅŅŅ ĐŋĐžĐģŅСОваŅĐĩĐģŅ", - "delete_user": "ĐŖĐ´Đ°ĐģиŅŅ ĐŋĐžĐģŅСОваŅĐĩĐģŅ", - "delete_user_confirmation": "ĐŅ ŅвĐĩŅĐĩĐŊŅ? ĐŅĐž Đ´ĐĩĐšŅŅвиĐĩ ĐŊĐĩĐģŅĐˇŅ ĐžŅĐŧĐĩĐŊиŅŅ." + "delete_user": "ĐŖĐ´Đ°ĐģиŅŅ ĐŋĐžĐģŅСОваŅĐĩĐģŅ" }, "media": "ĐĄ вĐģĐžĐļĐĩĐŊиŅĐŧи", "mention": "ĐŖĐŋĐžĐŧŅĐŊŅŅŅ", diff --git a/src/i18n/service_worker_messages.js b/src/i18n/service_worker_messages.js index 270ed043..f691f1c4 100644 --- a/src/i18n/service_worker_messages.js +++ b/src/i18n/service_worker_messages.js @@ -27,6 +27,7 @@ const messages = { pt: require('../lib/notification-i18n-loader.js!./pt.json'), ro: require('../lib/notification-i18n-loader.js!./ro.json'), ru: require('../lib/notification-i18n-loader.js!./ru.json'), + sk: require('../lib/notification-i18n-loader.js!./sk.json'), te: require('../lib/notification-i18n-loader.js!./te.json'), zh: require('../lib/notification-i18n-loader.js!./zh.json'), en: require('../lib/notification-i18n-loader.js!./en.json') diff --git a/src/i18n/sk.json b/src/i18n/sk.json new file mode 100644 index 00000000..cee76f5e --- /dev/null +++ b/src/i18n/sk.json @@ -0,0 +1,512 @@ +{ + "about": { + "mrf": { + "federation": "FederÃĄcia", + "keyword": { + "keyword_policies": "PravidlÃĄ pre kÄžÃēÄovÊ slovÃĄ", + "ftl_removal": "OdstrÃĄnenie z Äasovej osy \"Celej znÃĄmej siete\"", + "reject": "Odmietni", + "replace": "NahraÄ", + "is_replaced_by": "â" + }, + "mrf_policies": "PovoliÅĨ MRF pravidlÃĄ", + "mrf_policies_desc": "MRF pravidlÃĄ upravujÃē sprÃĄvanie servera v rÃĄmci federÃĄcie s inÃŊmi. NasledovnÊ pravidlÃĄ sÃē aktÃvne:", + "simple": { + "simple_policies": "PravidlÃĄ ÅĄpecifickÊ pre tento server", + "instance": "Server", + "reason": "Dôvod", + "not_applicable": "N/A", + "accept": "PrijaÅĨ", + "accept_desc": "Tento server preberÃĄ sprÃĄvy len z nasledovnÃŊch serverov:", + "reject": "OdmietnuÅĨ", + "reject_desc": "Tento server preberÃĄ sprÃĄvy spravy z nasledovnÃŊch serverov:", + "quarantine": "KarantÊna", + "quarantine_desc": "Tento server posiela verejnÊ oznamy len na nasledovnÊ servre:", + "ftl_removal": "OdstrÃĄnenie Äasovej osy \"ZnÃĄma sieÅĨ\"", + "ftl_removal_desc": "Tento server odstraÅuje nasledovnÊ serverov zo svojej Äasovej osy \"ZnÃĄma sieÅĨ\":", + "media_removal": "OdstrÃĄnenie mÊdiÃ", + "media_removal_desc": "Tento server odstraÅuje mÊdiÃĄ zo sprÃĄv nasledovnÃŊch serverov:", + "media_nsfw": "OznaÄenie mÊdià ako citlivÃŊch", + "media_nsfw_desc": "Tento server oznaÄuje mÊdia ako citlivÊ v sprÃĄvach z nasledovnÃŊch serverov:" + } + }, + "staff": "PersonÃĄl" + }, + "shoutbox": { + "title": "VerejnÊ fÃŗrum" + }, + "domain_mute_card": { + "mute": "UtÃÅĄ", + "mute_progress": "UtiÅĄujemâĻ", + "unmute": "PovoÄž oznamy", + "unmute_progress": "PovoÄžujem oznamyâĻ" + }, + "exporter": { + "export": "Export", + "processing": "SpracovÃĄva sa, Äoskoro sa ti ponÃēknu na stiahnutie sÃēbory s dÃĄtami exportu" + }, + "features_panel": { + "shout": "VerejnÊ fÃŗrum", + "pleroma_chat_messages": "Pleroma Chat", + "gopher": "Gopher", + "media_proxy": "Proxy pre mÊdiÃĄ", + "scope_options": "Nastavenia rÃĄmca", + "text_limit": "Limit poÄtu znakov", + "title": "Vlastnosti", + "who_to_follow": "Koho nasledovaÅĨ", + "upload_limit": "Limit nahrÃĄvania" + }, + "finder": { + "error_fetching_user": "Chyba naÄÃtavania uÅžÃvateÄža", + "find_user": "NÃĄjsÅĨ uÅžÃvateÄža" + }, + "general": { + "apply": "PouÅžiÅĨ", + "submit": "OdoslaÅĨ", + "more": "Viac", + "loading": "NahrÃĄvamâĻ", + "generic_error": "Nastala chyba", + "error_retry": "Zopakuj znova, prosÃm", + "retry": "Zopakuj znova", + "optional": "nepovinnÊ", + "show_more": "Zobraz viac", + "show_less": "Zobraz menej", + "dismiss": "ZahoÄ", + "cancel": "ZruÅĄ", + "disable": "Vypni", + "enable": "Zapni", + "confirm": "PotvrdiÅĨ", + "verify": "OveriÅĨ", + "close": "ZatvoriÅĨ", + "peek": "VybraÅĨ", + "role": { + "admin": "SprÃĄvca", + "moderator": "ModerÃĄtor" + }, + "flash_content": "Klikni pre zobrazenie Flash obsahu prostrednÃctvom Ruffle (experimentÃĄlne, nemusà fungovaÅĨ).", + "flash_security": "Flash obsah je potencionÃĄlne nebezpeÄnÃŊ, keÄÅže je to produkt s uzatvorenÃŊm kÃŗdom.", + "flash_fail": "Nepodarilo sa nahraÅĨ Flash obsah, pre detaily pozri konzolu prehliadaÄa.", + "scope_in_timeline": { + "direct": "Priame", + "private": "Len pre nasledovnÃkov", + "public": "VerejnÊ", + "unlisted": "NezaradenÊ" + } + }, + "image_cropper": { + "crop_picture": "OrezaÅĨ obrÃĄzok", + "save": "UloÅžiÅĨ", + "save_without_cropping": "UloÅž bez orezania", + "cancel": "ZruÅĄiÅĨ" + }, + "importer": { + "submit": "OdoslaÅĨ", + "success": "ÃspeÄne naimportovanÊ.", + "error": "Pri importe sÃēboru nastala chyba." + }, + "login": { + "login": "PrihlÃĄsiÅĨ sa", + "description": "PrihlÃĄsiÅĨ pomocou OAuth", + "logout": "OdhlÃĄsiÅĨ sa", + "password": "Heslo", + "placeholder": "napr. peter", + "register": "RegistrÃĄcia", + "username": "Meno uÅžÃvateÄža", + "hint": "PrihlÃĄs sa, aby si sa mohol zÃēÄastniÅĨ konverzÃĄcie", + "authentication_code": "AutentifikaÄnÃŊ kÃŗd", + "enter_recovery_code": "Zadaj kÃŗd obnovenia", + "enter_two_factor_code": "Zadaj 2-fÃĄzovÃŊ validaÄnÃŊ kÃŗd", + "recovery_code": "KÃŗd obnovenia", + "heading": { + "totp": "2-fÃĄzovÊ overenie", + "recovery": "2-fÃĄzovÊ obnova" + } + }, + "media_modal": { + "previous": "PredchÃĄdzajÃēce", + "next": "NasledujÃēce", + "counter": "{current} / {total}", + "hide": "ZatvoriÅĨ prehliadaÄ mÊdiÃ" + }, + "nav": { + "about": "O strÃĄnke", + "administration": "AdministrÃĄcia", + "back": "SpäÅĨ", + "friend_requests": "ÅŊiadosti o priateÄžstvo", + "mentions": "Zmienky", + "interactions": "Interakcie", + "dms": "Priame sprÃĄvy", + "public_tl": "VerejnÃĄ ÄasovÃĄ os", + "timeline": "ÄasovÃĄ os", + "home_timeline": "DomÃĄca ÄasovÃĄ os", + "twkn": "ZnÃĄma sieÅĨ", + "bookmarks": "ZÃĄloÅžky", + "user_search": "HÄžadanie uÅžÃvateÄža", + "search": "HladaÅĨ", + "who_to_follow": "Koho nasledovaÅĨ", + "preferences": "Nastavenia", + "timelines": "ÄasovÊ osy", + "chats": "Chaty" + }, + "notifications": { + "broken_favorite": "NeznÃĄma sprÃĄva, dohÄžadÃĄvam juâĻ", + "error": "Chyba zÃskavania upozornenÃ: {0}", + "favorited_you": "si obÄžÃēbil tvoju sprÃĄvu", + "followed_you": "ÅĨa nasleduje", + "follow_request": "ÅĨa chce nasledovaÅĨ", + "load_older": "NahraÅĨ starÅĄie upozornenia", + "notifications": "Upozornenia", + "read": "PreÄÃtanÊ!", + "repeated_you": "zopakoval tvoju sprÃĄvu", + "no_more_notifications": "ÅŊiadne ÄalÅĄie upozornenia", + "migrated_to": "sa presÅĨahoval na", + "reacted_with": "reagoval nasledovne {0}" + }, + "polls": { + "add_poll": "PridaÅĨ anketu", + "add_option": "PridaÅĨ moÅžnosÅĨ", + "option": "MoÅžnosÅĨ", + "votes": "hlasy", + "people_voted_count": "{count} voliÄ | {count} voliÄov", + "votes_count": "{count} hlas | {count} hlasov", + "vote": "Hlas", + "type": "Typ ankety", + "single_choice": "VÃŊber jednej moÅžnosti", + "multiple_choices": "VÃŊber viacerÃŊch moÅžnostÃ", + "expiry": "Vek ankety", + "expires_in": "Anketa konÄà za {0}", + "expired": "Anketa skonÄila pre {0}", + "not_enough_options": "PrÃliÅĄ mÃĄlo jedineÄnÃŊch moÅžnostà v ankete" + }, + "emoji": { + "stickers": "NÃĄlepka", + "emoji": "Emotikon", + "keep_open": "Ponechaj okno vÃŊberu otvorenÊ", + "search_emoji": "VyhladaÅĨ emotikon", + "add_emoji": "VloÅžiÅĨ emotikon", + "custom": "VlastnÃŊ emotikon", + "unicode": "Unicode emotikon", + "load_all_hint": "Nahralo sa prvÃŊch {saneAmount} emotikonov, nahranie vÅĄetkÃŊch by mohlo spôsobiÅĨ znÃÅženie vÃŊkonu.", + "load_all": "NahraÅĨ vÅĄetkÃŊch {emojiAmount} emotikonov" + }, + "errors": { + "storage_unavailable": "Pleroma nemôŞe pouÅžÃvaÅĨ ÃēloÅžisko prehliadaÄa. Tvoje prihlasovacie meno a lokÃĄlne nastavenia nebudÃē uchovanÊ a môŞu sa vyskytnÃēÅĨ neoÄakÃĄvanÊ chyby. SkÃēs povoliÅĨ cookie." + }, + "interactions": { + "favs_repeats": "Zopakovania a obÄžÃēbenÊ", + "follows": "NovÃŊ nasledovatelia", + "moves": "UÅžÃvateÄž sa sÅĨahuje", + "load_older": "NahraÅĨ starÅĄiu komunikÃĄciu" + }, + "post_status": { + "new_status": "PoslaÅĨ novÃē sprÃĄvu", + "account_not_locked_warning": "Tvoj ÃēÄen nie je {0}. KtokoÄžvek ÅĨa môŞe zaÄaÅĨ nasledovaÅĨ a tak vidieÅĨ sprÃĄvy urÄenÊ len pre nasledovateÄžov.", + "account_not_locked_warning_link": "uzamknutÊ", + "attachments_sensitive": "OznaÄiÅĨ prÃlohy ako citlivÊ", + "media_description": "Popis mÊdia", + "content_type": { + "text/plain": "ObyÄajnÃŊ text", + "text/html": "HTML", + "text/markdown": "Markdown", + "text/bbcode": "BBCode" + }, + "content_warning": "Nadpis (nepovinnÊ)", + "default": "PrÃĄve som ...", + "direct_warning_to_all": "TÃēto sprÃĄvu bude vidieÅĨ kaÅždÃŊ uÅžÃvateÄž, ktorÊho v nej spomenieÅĄ.", + "direct_warning_to_first_only": "TÃĄto sprÃĄva bude viditeÄžnÃĄ len pre uÅžÃvateÄžov, ktorÃŊch vymenujeÅĄ na zaÄiatku sprÃĄvy.", + "posting": "Posielanie", + "post": "PoslaÅĨ", + "preview": "NÃĄhÄžad", + "preview_empty": "PrÃĄzdne", + "empty_status_error": "Nie je moÅžnÊ odoslaÅĨ prÃĄzdnu sprÃĄvu bez priloÅženÃŊch sÃēborov", + "media_description_error": "Nepodarilo sa aktualizovaÅĨ mÊdia, skÃēs znova", + "scope_notice": { + "public": "TÃēto sprÃĄvu bude vidieÅĨ kaÅždÃŊ", + "private": "TÃēto sprÃĄvu budÃē vidieÅĨ len tvoji nasledovnÃci", + "unlisted": "TÃĄto sprÃĄva nebude viditeÄžnÃĄ na verejnej Äasovej osi a v celej znÃĄmej sieti" + }, + "scope": { + "direct": "Priama sprÃĄva - zobrazà sa len uÅžÃvateÄžom spomenutÃŊm v sprÃĄve", + "private": "Pre nasledovnÃkov - zobrazà sa len tvojim nasledovnÃkom", + "public": "VerejnÊ - zobrazà sa vo vÅĄetkÃŊch ÄasovÃŊch osiach", + "unlisted": "NezaradenÊ - nezobrazà sa v Åžiadnej Äasovej osy" + } + }, + "registration": { + "bio": "ÅŊivotopis", + "email": "Email", + "fullname": "ZobrazovanÊ meno", + "password_confirm": "Potvrdenie hesla", + "registration": "RegistrÃĄcia", + "token": "PozÃŊvacà kÃŗd", + "captcha": "CAPTCHA", + "new_captcha": "Klikni na obrÃĄzok a vnikne novÃĄ captcha", + "username_placeholder": "napr. peter", + "fullname_placeholder": "napr. Peter Kukurica", + "bio_placeholder": "e.g.\nHi, I'm Lain.\nIâm an anime girl living in suburban Japan. You may know me from the Wired.", + "reason": "Dôvod registrÃĄcie", + "reason_placeholder": "Tento server schvaÄžuje registrÃĄcie manuÃĄlne.\nZanechaj sprÃĄvcom dôvod, preÄo mÃĄÅĄ zÃĄujem vytvoriÅĨ si tu ÃēÄet.", + "register": "RegistrÃĄcia", + "validations": { + "username_required": "nemôŞe byÅĨ prÃĄzdne", + "fullname_required": "nemôŞe byÅĨ prÃĄzdne", + "email_required": "nemôŞe byÅĨ prÃĄzdne", + "password_required": "nemôŞe byÅĨ prÃĄzdne", + "password_confirmation_required": "nemôŞe byÅĨ prÃĄzdne", + "password_confirmation_match": "musà byÅĨ rovnakÊ ako heslo" + } + }, + "remote_user_resolver": { + "remote_user_resolver": "VzdialenÊ overenie uÅžÃvateÄža", + "searching_for": "HÄžadÃĄm...", + "error": "NenÃĄjdenÊ." + }, + "selectable_list": { + "select_all": "VybraÅĨ vÅĄetko" + }, + "time": { + "day": "{0} deÅ", + "days": "{0} dnÃ", + "day_short": "{0}d", + "days_short": "{0}d", + "hour": "{0} hodina", + "hours": "{0} hodÃn", + "hour_short": "{0}h", + "hours_short": "{0}h", + "in_future": "za {0}", + "in_past": "pred {0}", + "minute": "{0} minÃēta", + "minutes": "{0} minÃēt", + "minute_short": "{0}min", + "minutes_short": "{0}min", + "month": "{0} mesiac", + "months": "{0} mesiacov", + "month_short": "{0}mes", + "months_short": "{0}mes", + "now": "prÃĄve teraz", + "now_short": "teraz", + "second": "{0} sekunda", + "seconds": "{0} sekÃēnd", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} tÃŊÅždeÅ", + "weeks": "{0} tÃŊÅždÅov", + "week_short": "{0}t", + "weeks_short": "{0}t", + "year": "{0} rok", + "years": "{0} rokov", + "year_short": "{0}r", + "years_short": "{0}r" + }, + "timeline": { + "collapse": "ZbaliÅĨ", + "conversation": "KonverzÃĄcia", + "error": "Chyba pri nahrÃĄvanà Äasovej sprÃĄvy: {0}", + "load_older": "NahraÅĨ starÅĄie sprÃĄvy", + "no_retweet_hint": "SprÃĄva je oznaÄenÃĄ ako len-pre-nasledovateÄžov alebo ako priama a nemôŞe byÅĨ zopakovanÃĄ na tvojej Äasovej osy.", + "repeated": "zopakovanÊ", + "show_new": "ZobraziÅĨ novÊ", + "reload": "Znovu nahraÅĨ", + "up_to_date": "AktuÃĄlne", + "no_more_statuses": "ÅŊiadne ÄalÅĄie sprÃĄvy", + "no_statuses": "ÅŊiadne sprÃĄvy", + "socket_reconnected": "Prepojenie v reÃĄlnom Äase bolo ÃēspeÅĄne vytvorenÊ", + "socket_broke": "Strata prepojenia v reÃĄlnom Äase: chyba CloseEvent kÃŗd {0}" + }, + "status": { + "favorites": "ObÄžÃēbenÊ", + "repeats": "Opakovania", + "delete": "ZmazaÅĨ sprÃĄvu", + "pin": "PripnÃēÅĨ na strÃĄnku uÅžÃvateÄža", + "unpin": "OdopnÃēÅĨ zo strÃĄnky uÅžÃvateÄža", + "pinned": "PripnutÊ", + "bookmark": "VytvoriÅĨ zÃĄloÅžku", + "unbookmark": "ZmazaÅĨ zÃĄloÅžku", + "delete_confirm": "SkutoÄne chceÅĄ zmazaÅĨ tÃēto sprÃĄvu?", + "reply_to": "OdpovedaÅĨ komu", + "mentions": "Spomenutia", + "replies_list": "Odpovede:", + "replies_list_with_others": "OdpoveÄ (+{numReplies} inÃŊ): | OdpoveÄ (+{numReplies} inÃŊch):", + "mute_conversation": "StÃÅĄiÅĨ konverzÃĄciu", + "unmute_conversation": "OznamovaÅĨ konverzÃĄciu", + "status_unavailable": "NeznÃĄmy status", + "copy_link": "SkopÃrovaÅĨ odkaz do sprÃĄvy", + "external_source": "VzdialenÃŊ zdroj", + "thread_muted": "KonverzÃĄcia stÃÅĄenÃĄ", + "thread_muted_and_words": ", mÃĄ slovÃĄ:", + "show_full_subject": "ZobraziÅĨ celÃŊ nadpis", + "hide_full_subject": "Skry celÃŊ nadpis", + "show_content": "ZobraziÅĨ obsah", + "hide_content": "SkryÅĨ obsah", + "status_deleted": "TÃĄto sprÃĄva bola zmazanÃĄ", + "nsfw": "NSFW", + "expand": "RozbaliÅĨ sprÃĄvu", + "you": "(ty)", + "plus_more": "+{number} ÄalÅĄÃch", + "many_attachments": "SprÃĄva mÃĄ {number} prÃloh", + "collapse_attachments": "ZabaliÅĨ mÊdiÃĄ", + "show_all_attachments": "Zobraz vÅĄetky prÃlohy", + "show_attachment_in_modal": "Zobraz mÊdiÃĄ modÃĄlne", + "show_attachment_description": "NÃĄhÄžad popisku (otvor prÃlohu pre zobrazenie celÊho popisku)", + "hide_attachment": "SkryÅĨ prÃlohy", + "remove_attachment": "OdstrÃĄniÅĨ prÃlohy", + "attachment_stop_flash": "ZastaviÅĨ prehrÃĄvaÄ Flashu", + "move_up": "PresuÅ prÃlohu doÄžava", + "move_down": "PresuÅ prÃlohu doprava", + "open_gallery": "OtvoriÅĨ galÊriu", + "thread_hide": "Skry tÃēto konverzÃĄciu", + "thread_show": "Zobraz tÃēto konverzÃĄciu", + "thread_show_full": "Zobraz vÅĄetko pod touto konverzÃĄciou (celkovo {numStatus} sprÃĄva, max hÄēbka {depth}) | Zobraz vÅĄetko pod touto konverzÃĄciou (celkovo {numStatus} sprÃĄv, max hÄēbka {depth})", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "Zobraz zvyÅĄnÃē ÄasÅĨ tejto konverzÃĄcie (celkovo {numStatus} sprÃĄva) | Zobraz zvyÅĄnÃē ÄasÅĨ tejto konverzÃĄcie (celkovo {numStatus} sprÃĄv)", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "Pozri {numReplies} ÄalÅĄiu odpoveÄ pod touto sprÃĄvou | Pozri {numReplies} ÄalÅĄÃch odpovedà pod touto sprÃĄvou", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "show_all_conversation": "Zobraz celÃē konverzÃĄciu ({numStatus} inÃĄ sprÃĄva) | Zobraz celÃē konverzÃĄciu ({numStatus} inÃŊch sprÃĄv)", + "show_only_conversation_under_this": "Zobraz len sprÃĄvy sÃēvisiace s touto sprÃĄvou" + }, + "user_card": { + "approve": "SchvÃĄliÅĨ", + "block": "ZablokovaÅĨ", + "blocked": "BlokovanÊ!", + "deactivated": "NeaktÃvne", + "deny": "ZakÃĄzanÊ", + "edit_profile": "UraviÅĨ profil", + "favorites": "ObÄžÃēbenÊ", + "follow": "NasledovaÅĨ", + "follow_cancel": "PoÅžiadavka zruÅĄenÃĄ", + "follow_sent": "PoÅžiadavka zaslanÃĄ!", + "follow_progress": "ÅŊiadam o povolenieâĻ", + "follow_unfollow": "PrestaÅĨ sledovaÅĨ", + "followees": "Nasleduje", + "followers": "Nasledovatelia", + "following": "NasledujeÅĄ!", + "follows_you": "Nasleduje teba!", + "hidden": "SkrytÊ", + "its_you": "To si ty!", + "media": "MÊdia", + "mention": "Spomenul", + "message": "SprÃĄva", + "mute": "StÃÅĄiÅĨ", + "muted": "StÃÅĄenÊ", + "per_day": "za deÅ", + "remote_follow": "Nasledovanie z Äaleka", + "report": "NahlÃĄsiÅĨ", + "statuses": "VytvorenÃŊch sprÃĄv", + "subscribe": "PrihlÃĄsiÅĨ k odberu", + "unsubscribe": "OdhlÃĄsiÅĨ z odberu", + "unblock": "OdblokovaÅĨ", + "unblock_progress": "OblokovÃĄva saâĻ", + "block_progress": "BlokujemâĻ", + "unmute": "PovoliÅĨ oznamy", + "unmute_progress": "PovoÄžujem oznamyâĻ", + "mute_progress": "StiÅĄujemâĻ", + "hide_repeats": "Skry zopakovania", + "show_repeats": "Zobraz zopakovania", + "bot": "Robot", + "admin_menu": { + "moderation": "Moderovanie", + "grant_admin": "PovoliÅĨ spravovanie", + "revoke_admin": "ZakÃĄzaÅĨ spravovanie", + "grant_moderator": "PovoliÅĨ moderovanie", + "revoke_moderator": "ZakÃĄzaÅĨ moderovanie", + "activate_account": "AktivovaÅĨ ÃēÄet", + "deactivate_account": "DeaktivovaÅĨ ÃēÄet", + "delete_account": "ZmazaÅĨ ÃēÄet", + "force_nsfw": "OznaÄ vÅĄetky sprÃĄvy ako NSFW", + "strip_media": "OdstrÃĄniÅĨ mÊdia zo sprÃĄvy", + "force_unlisted": "VynÃēÅĨ, aby sprÃĄvy neboli zobrazovanÊ", + "sandbox": "VynÃēÅĨ, aby sprÃĄvy boli len pre nasledovateÄžov", + "disable_remote_subscription": "OdstrÃĄniÅĨ prÃstup k serveru nasledovnÊmu vzdialenÊmu uÅžÃvateÄžovi", + "disable_any_subscription": "ZakÃĄzaÅĨ nasledovanie uÅžÃvateÄžov", + "quarantine": "ZakÃĄzaÅĨ federÃĄciu sprÃĄv uÅžÃvateÄža", + "delete_user": "ZmazaÅĨ uÅžÃvateÄža", + "delete_user_confirmation": "Si si Ãēplne istÃŊ? TÃĄto akcia sa nedÃĄ zobraÅĨ späÅĨ." + }, + "highlight": { + "disabled": "Bez zvÃŊraznenia", + "solid": "Jednoliate pozadie", + "striped": "Å rafovanÊ pozadie", + "side": "PÃĄsik na boku" + } + }, + "user_profile": { + "timeline_title": "ÄasovÃĄ os uÅžÃvateÄža", + "profile_does_not_exist": "PrepÃĄÄ, tento profil neexistuje.", + "profile_loading_error": "PrepÃĄÄ, nastala chyba pri nahrÃĄvanà profilu." + }, + "user_reporting": { + "title": "NahlÃĄsenà {0}", + "add_comment_description": "HlÃĄsnenie bude zaslanÊ moderÃĄtorom servera. NiÅžÅĄie môŞeÅĄ napÃsaÅĨ dôvod preÄo tento ÃēÄet nahlasujeÅĄ:", + "additional_comments": "ÄalÅĄie poznÃĄmky", + "forward_description": "ÃÄet je z inÊho servera. PoslaÅĨ kÃŗpiu tohto hlÃĄsenia aj tam?", + "forward_to": "PreposlaÅĨ komu {0}", + "submit": "OdoslaÅĨ", + "generic_error": "Nastala chyba pri vykonanà tvojej poÅžiadavky." + }, + "who_to_follow": { + "more": "Viac", + "who_to_follow": "Koho nasledovaÅĨ" + }, + "tool_tip": { + "media_upload": "NahraÅĨ mÊdium", + "repeat": "ZopakovaÅĨ", + "reply": "OdpovedaÅĨ", + "favorite": "ObÄžÃēbenÊ", + "add_reaction": "ReagovaÅĨ", + "user_settings": "Nastavenia uÅžÃvateÄža", + "accept_follow_request": "PrijaÅĨ poÅžiadavku nasledovnÃka", + "reject_follow_request": "OdmietnuÅĨ poÅžiadavku nasledovnÃka", + "bookmark": "ZÃĄloÅžka" + }, + "upload": { + "error": { + "base": "NahrÃĄvanie bolo neÃēspeÅĄnÊ.", + "message": "NahrÃĄvanie bolo neÃēspeÅĄnÊ: {0}", + "file_too_big": "SÃēbor je prÃliÅĄ veÄžkÃŊ [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "VyskÃēÅĄaj opäÅĨ neskôr" + } + }, + "search": { + "people": "ÄŊudia", + "hashtags": "HaÅĄtagy", + "person_talking": "{count} Älovek hovorÃ", + "people_talking": "{count} Äžudà hovorÃ", + "no_results": "ÅŊiadne vÃŊsledky" + }, + "password_reset": { + "forgot_password": "Zabudol si heslo?", + "password_reset": "Obnovenie hesla", + "instruction": "Zadaj svoju emailovÃē adresu alebo uÅžÃvateÄžskÊ meno. PoÅĄleme ti odkaz pomocou, ktorÊho môŞeÅĄ obnoviÅĨ svoje heslo.", + "placeholder": "Tvoj email alebo uÅžÃvateÄžskÊ meno", + "check_email": "V novom emaile ti bol doruÄenÃŊ odkaz na spôsob, ako obnovÃÅĄ svoje heslo.", + "return_home": "NÃĄvrat na domÃĄcu strÃĄnku", + "too_many_requests": "PrekroÄil si limit pokusov, skÃēs znova neskôr.", + "password_reset_disabled": "Obnova hesla je vypnutÃĄ. Kontaktuj, prosÃm, sprÃĄvcu tohto servera.", + "password_reset_required": "MusÃÅĄ najskôr obnoviÅĨ heslo, ak sa chceÅĄ prihlÃĄsiÅĨ.", + "password_reset_required_but_mailer_is_disabled": "MusÃÅĄ obnoviÅĨ svoje heslo, ale obnova hesla je na serveri vypnutÃĄ. Kontaktuj, prosÃm, sprÃĄvcu tohto servera." + }, + "chats": { + "you": "Ty:", + "message_user": "SprÃĄva {nickname}", + "delete": "ZmazaÅĨ", + "chats": "Rozhovor", + "new": "NovÃŊ rozhovor", + "empty_message_error": "Nie je moÅžnÊ odoslaÅĨ prÃĄzdnu sprÃĄvu", + "more": "Viac", + "delete_confirm": "SkutoÄne chceÅĄ zmazaÅĨ tÃēto sprÃĄvu?", + "error_loading_chat": "Nastala chyba pri nahrÃĄvanà rozhovoru.", + "error_sending_message": "Nastala chyba pri odosielanà sprÃĄv.", + "empty_chat_list_placeholder": "NemÃĄÅĄ za sebou Åžiadne rozhovory. ZaÄni novÃŊ rozhovor!" + }, + "file_type": { + "audio": "Audio", + "video": "Video", + "image": "ObrÃĄzok", + "file": "SÃēbor" + }, + "display_date": { + "today": "Dnes" + } +} diff --git a/src/i18n/te.json b/src/i18n/te.json index 1216de59..4f255505 100644 --- a/src/i18n/te.json +++ b/src/i18n/te.json @@ -49,7 +49,7 @@ "notifications.repeated_you": "ā°Žāą ā°¸āąā°Ĩā°ŋā°¤ā°ŋā°¨ā°ŋ ā°Ēāąā°¨ā°°ā°žā°ĩāąā°¤ā° ā°āąā°¸ā°žā°°āą", "notifications.no_more_notifications": "ā°ā° ā°¨āąā°ā°ŋā°Ģā°ŋā°āąā°ˇā°¨āąā°˛āą ā°˛āąā°ĩāą", "post_status.new_status": "ā°āąā°°āąā°¤āąā°¤ ā°¸āąā°Ĩā°ŋā°¤ā°ŋā°¨ā°ŋ ā°Ēāąā°¸āąā°āą ā°āąā°¯ā°ā°Ąā°ŋ", - "post_status.account_not_locked_warning": "ā°Žāą ā°ā°žā°¤ā°ž {āąĻ} ā°ā°žā°Ļāą. ā°ā°ĩā°°āąā°¨ā°ž ā°Žā°ŋā°Žāąā°Žā°˛āąā°¨ā°ŋ ā°
ā°¨āąā°¸ā°°ā°ŋā°ā°ā°ŋ ā°
ā°¨āąā°ā°°āąā°˛ā°āą ā°Žā°žā°¤āąā°°ā°Žāą ā°ā°Ļāąā°Ļāąā°ļā°ŋā°ā°ā°ŋā°¨ ā°Ēāąā°¸āąā°āąā°˛ā°¨āą ā°āąā°Ąā°ĩā°āąā°āą.", + "post_status.account_not_locked_warning": "ā°Žāą ā°ā°žā°¤ā°ž {0} ā°ā°žā°Ļāą. ā°ā°ĩā°°āąā°¨ā°ž ā°Žā°ŋā°Žāąā°Žā°˛āąā°¨ā°ŋ ā°
ā°¨āąā°¸ā°°ā°ŋā°ā°ā°ŋ ā°
ā°¨āąā°ā°°āąā°˛ā°āą ā°Žā°žā°¤āąā°°ā°Žāą ā°ā°Ļāąā°Ļāąā°ļā°ŋā°ā°ā°ŋā°¨ ā°Ēāąā°¸āąā°āąā°˛ā°¨āą ā°āąā°Ąā°ĩā°āąā°āą.", "post_status.account_not_locked_warning_link": "ā°¤ā°žā°ŗā° ā°ĩāąā°¯ā°Ŧā°Ąā°ŋā°¨ā°Ļā°ŋ", "post_status.attachments_sensitive": "ā°āąā°Ąā°ŋā°ā°Ēāąā°˛ā°¨āą ā°¸āąā°¨āąā°¨ā°ŋā°¤ā°Žāąā°¨ā°ĩā°ŋā°ā°ž ā°āąā°°āąā°¤ā°ŋā°ā°ā°ā°Ąā°ŋ", "post_status.content_type.text/plain": "ā°¸ā°žā°§ā°žā°°ā°Ŗ ā°
ā°āąā°ˇā°°ā°žā°˛āą", diff --git a/src/i18n/uk.json b/src/i18n/uk.json index d9833087..c75ed197 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -24,7 +24,15 @@ }, "flash_content": "ĐаŅиŅĐŊŅŅŅ Đ´ĐģŅ ĐŋĐĩŅĐĩĐŗĐģŅĐ´Ņ ĐˇĐŧŅŅŅŅ Flash Са Đ´ĐžĐŋĐžĐŧĐžĐŗĐžŅ Ruffle (ĐĩĐēŅĐŋĐĩŅиĐŧĐĩĐŊŅаĐģŅĐŊĐž, ĐŧĐžĐļĐĩ ĐŊĐĩ ĐŋŅаŅŅваŅи).", "flash_security": "ĐĻŅ ŅŅĐŊĐēŅŅŅ ĐŧĐžĐļĐĩ ŅŅаĐŊОвиŅи ŅиСиĐē, ĐžŅĐēŅĐģŅĐēи Flash-вĐŧŅŅŅ Đ˛ŅĐĩ ŅĐĩ Ņ ĐŋĐžŅĐĩĐŊŅŅĐšĐŊĐž ĐŊĐĩĐąĐĩСĐŋĐĩŅĐŊиĐŧ.", - "flash_fail": "ĐĐĩ вдаĐģĐžŅŅ ĐˇĐ°Đ˛Đ°ĐŊŅаĐļиŅи Flash-вĐŧŅŅŅ, Đ´ĐžĐēĐģадĐŊŅŅŅ ŅĐŊŅĐžŅĐŧаŅŅŅ Đ´Đ¸Đ˛Đ¸ŅŅ Ņ ĐēĐžĐŊŅĐžĐģŅ." + "flash_fail": "ĐĐĩ вдаĐģĐžŅŅ ĐˇĐ°Đ˛Đ°ĐŊŅаĐļиŅи Flash-вĐŧŅŅŅ, Đ´ĐžĐēĐģадĐŊŅŅŅ ŅĐŊŅĐžŅĐŧаŅŅŅ Đ´Đ¸Đ˛Đ¸ŅŅ Ņ ĐēĐžĐŊŅĐžĐģŅ.", + "generic_error_message": "ĐиĐŊиĐēĐģа ĐŋĐžĐŧиĐģĐēа: {0}", + "never_show_again": "ĐŅĐēĐžĐģи ĐŊĐĩ ĐŋĐžĐēаСŅваŅи СĐŊОвŅ", + "scope_in_timeline": { + "direct": "ĐŅиваŅĐŊĐĩ", + "private": "ĐиŅĐĩ ŅиŅаŅŅ", + "public": "ĐŅĐąĐģŅŅĐŊĐĩ", + "unlisted": "ĐĐĩĐŋŅĐąĐģŅŅĐŊĐĩ" + } }, "finder": { "error_fetching_user": "ĐĐžŅиŅŅŅваŅа ĐŊĐĩ СĐŊаКдĐĩĐŊĐž", @@ -39,7 +47,8 @@ "scope_options": "ĐаŅаĐŧĐĩŅŅи ОйŅŅĐŗŅ", "media_proxy": "ĐĐžŅĐĩŅĐĩĐ´ĐŊиĐē ĐŧĐĩĐ´Ņа-даĐŊиŅ
", "text_limit": "ĐŅĐŧŅŅ ŅиĐŧвОĐģŅв", - "upload_limit": "ĐĐąĐŧĐĩĐļĐĩĐŊĐŊŅ ĐˇĐ°Đ˛Đ°ĐŊŅаĐļĐĩĐŊŅ" + "upload_limit": "ĐĐąĐŧĐĩĐļĐĩĐŊĐŊŅ ĐˇĐ°Đ˛Đ°ĐŊŅаĐļĐĩĐŊŅ", + "shout": "ĐĐŗĐžĐģĐžŅĐĩĐŊĐŊŅ" }, "exporter": { "processing": "ĐĐŋŅаŅŅОвŅŅ, ŅĐēĐžŅĐž ви СĐŧĐžĐļĐĩŅĐĩ СаваĐŊŅаĐļиŅи ŅаКĐģ", @@ -70,7 +79,9 @@ "accept": "ĐŅиКĐŊŅŅи", "reject": "ĐŅĐ´Ņ
иĐģиŅи", "accept_desc": "ĐĐžŅĐžŅĐŊиК ŅĐŊŅŅаĐŊŅ ĐŋŅиКĐŧĐ°Ņ ĐŋОвŅĐ´ĐžĐŧĐģĐĩĐŊĐŊŅ ŅŅĐģŅĐēи С ĐŋĐĩŅĐĩĐģŅŅĐĩĐŊиŅ
ŅĐŊŅŅаĐŊŅŅв:", - "simple_policies": "ĐŅавиĐģа ĐŋĐžŅĐžŅĐŊĐžĐŗĐž ŅĐŊŅŅаĐŊŅŅ" + "simple_policies": "ĐŅавиĐģа ĐŋĐžŅĐžŅĐŊĐžĐŗĐž ŅĐŊŅŅаĐŊŅŅ", + "reason": "ĐŅиŅиĐŊа", + "not_applicable": "ĐŊ/в" }, "mrf_policies_desc": "ĐŅавиĐģа MRF ŅОСĐŋОвŅŅĐ´ĐļŅŅŅŅŅŅ ĐŊа даĐŊиК ŅĐŊŅŅаĐŊŅ. ĐаŅŅŅĐŋĐŊŅ ĐŋŅавиĐģа аĐēŅивĐŊŅ:", "mrf_policies": "ĐĐēŅивŅваŅи ĐŋŅавиĐģа MRF (ĐŧОдŅĐģŅ ĐŋĐĩŅĐĩĐŋиŅŅваĐŊĐŊŅ ĐŋОвŅĐ´ĐžĐŧĐģĐĩĐŊŅ)", @@ -141,7 +152,8 @@ "followed_you": "ĐŋŅĐ´ĐŋиŅавŅŅ(-ĐģаŅŅ) ĐŊа ваŅ", "favorited_you": "вĐŋОдОйав(-Đģа) Đ˛Đ°Ņ Đ´ĐžĐŋиŅ", "broken_favorite": "ĐĐĩвŅĐ´ĐžĐŧиК Đ´ĐžĐŋиŅ, ŅŅĐēĐ°Ņ ĐšĐžĐŗĐžâĻ", - "error": "ĐĐžĐŧиĐģĐēа ĐŋŅи ĐžĐŊОвĐģĐĩĐŊĐŊŅ ŅĐŋОвŅŅĐĩĐŊŅ: {0}" + "error": "ĐĐžĐŧиĐģĐēа ĐŋŅи ĐžĐŊОвĐģĐĩĐŊĐŊŅ ŅĐŋОвŅŅĐĩĐŊŅ: {0}", + "poll_ended": "ĐžĐŋиŅŅваĐŊĐŊŅ ĐˇĐ°ĐēŅĐŊŅĐĩĐŊĐž" }, "nav": { "chats": "ЧаŅи", @@ -161,11 +173,14 @@ "mentions": "ĐĐŗĐ°Đ´ŅваĐŊĐŊŅ", "back": "ĐаСад", "administration": "ĐĐ´ĐŧŅĐŊŅŅŅŅŅваĐŊĐŊŅ", - "home_timeline": "ĐĐžĐŧаŅĐŊŅ ŅŅŅŅŅĐēа" + "home_timeline": "ĐĐžĐŧаŅĐŊŅ ŅŅŅŅŅĐēа", + "lists": "ĐĄĐŋиŅĐēи" }, "media_modal": { "next": "ĐаŅŅŅĐŋĐŊа", - "previous": "ĐĐžĐŋĐĩŅĐĩĐ´ĐŊŅ" + "previous": "ĐĐžĐŋĐĩŅĐĩĐ´ĐŊŅ", + "counter": "{current} / {total}", + "hide": "ĐаĐēŅиŅи ĐŧĐĩĐ´ŅаĐŋĐĩŅĐĩĐŗĐģŅдаŅ" }, "password_reset": { "instruction": "ĐвĐĩĐ´ŅŅŅ ŅĐ˛ĐžŅ Đ°Đ´ŅĐĩŅŅ ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊĐžŅ ĐŋĐžŅŅи айО ŅĐŧâŅ ĐēĐžŅиŅŅŅваŅа. Đи ĐŊадŅŅĐģĐĩĐŧĐž ваĐŧ ĐŋĐžŅиĐģаĐŊĐŊŅ Đ´ĐģŅ ŅĐēидаĐŊĐŊŅ ĐŋаŅĐžĐģŅ.", @@ -205,7 +220,8 @@ "load_older": "ĐаваĐŊŅаĐļиŅи давĐŊŅŅŅ Đ˛ĐˇĐ°ŅĐŧОдŅŅ", "follows": "ĐĐžĐ˛Ņ ĐŋŅĐ´ĐŋиŅĐēи", "favs_repeats": "ĐĐžŅиŅĐĩĐŊĐŊŅ Ņа вĐŋОдОйаКĐēи", - "moves": "ĐŅĐŗŅаŅŅŅ ĐēĐžŅиŅŅŅваŅŅв" + "moves": "ĐŅĐŗŅаŅŅŅ ĐēĐžŅиŅŅŅваŅŅв", + "emoji_reactions": "ĐĐŧОдĐļŅ ŅĐĩаĐēŅŅŅ" }, "errors": { "storage_unavailable": "Pleroma ĐŊĐĩ СĐŧĐžĐŗĐģа ĐžŅŅиĐŧаŅи Đ´ĐžŅŅŅĐŋ Đ´Đž ŅŅ
ОвиŅа ĐąŅаŅСĐĩŅŅ. ĐаŅа ŅĐĩŅŅŅ Ņа ĐŊаĐģаŅŅŅваĐŊĐŊŅ ĐŊĐĩ ĐąŅĐ´ŅŅŅ ĐˇĐąĐĩŅĐĩĐļĐĩĐŊŅ, ŅĐĩ ĐŧĐžĐļĐĩ ŅĐŋŅиŅиĐŊиŅи ĐŊĐĩĐŋĐĩŅĐĩдйаŅŅваĐŊŅ ĐŋŅОйĐģĐĩĐŧи. ĐĄĐŋŅОйŅĐšŅĐĩ ŅвŅĐŧĐēĐŊŅŅи cookie." @@ -638,7 +654,35 @@ "backup_restore": "Đ ĐĩСĐĩŅвĐŊĐĩ ĐēĐžĐŋŅŅваĐŊĐŊŅ ĐŊаĐģаŅŅŅваĐŊŅ" }, "right_sidebar": "ĐĐžĐēаСŅваŅи йОĐēĐžĐ˛Ņ ĐŋаĐŊĐĩĐģŅ ŅĐŋŅава", - "hide_shoutbox": "ĐŅиŅ
ОваŅи ĐžĐŗĐžĐģĐžŅĐĩĐŊĐŊŅ ŅĐŊŅŅаĐŊŅŅ" + "hide_shoutbox": "ĐŅиŅ
ОваŅи ĐžĐŗĐžĐģĐžŅĐĩĐŊĐŊŅ ŅĐŊŅŅаĐŊŅŅ", + "setting_server_side": "ĐĻĐĩĐš ĐŋаŅаĐŧĐĩŅŅ ĐŋŅивâŅСаĐŊиК Đ´Đž ваŅĐžĐŗĐž ĐŋŅĐžŅŅĐģŅ Ņа вĐŋĐģĐ¸Đ˛Đ°Ņ ĐŊа вŅŅ ŅĐĩаĐŊŅи Ņа ĐēĐģŅŅĐŊŅи", + "lists_navigation": "ĐĐžĐēаСŅваŅи ŅĐŋиŅĐēи в ĐŊавŅĐŗĐ°ŅŅŅ", + "account_backup": "Đ ĐĩСĐĩŅвĐŊĐĩ ĐēĐžĐŋŅŅваĐŊĐŊŅ ĐžĐąĐģŅĐēĐžĐ˛ĐžĐŗĐž СаĐŋиŅŅ", + "account_backup_description": "ĐĻĐĩ дОСвОĐģŅŅ ĐˇĐ°Đ˛Đ°ĐŊŅаĐļиŅи аŅŅ
Ņв даĐŊиŅ
ваŅĐžĐŗĐž ОйĐģŅĐēĐžĐ˛ĐžĐŗĐž СаĐŋиŅŅ Ņа ваŅиŅ
Đ´ĐžĐŋиŅŅв, аĐģĐĩ ŅŅ
ŅĐĩ ĐŊĐĩ ĐŧĐžĐļĐŊа ŅĐŧĐŋĐžŅŅŅваŅи в ОйĐģŅĐēОвиК СаĐŋĐ¸Ņ Pleroma.", + "add_backup_error": "ĐĐĩ вдаĐģĐžŅŅ Đ´ĐžĐ´Đ°Ņи ĐŊĐžĐ˛Ņ ŅĐĩСĐĩŅвĐŊŅ ĐēĐžĐŋŅŅ: {error}", + "account_alias": "ĐŅĐĩвдОĐŊŅĐŧи ОйĐģŅĐēĐžĐ˛ĐžĐŗĐž СаĐŋиŅŅ", + "new_alias_target": "ĐОдаŅи ĐŊОвиК ĐŋŅĐĩвдОĐŊŅĐŧ (ĐŊаĐŋŅ. {example})", + "move_account_notes": "Đ¯ĐēŅĐž ви Ņ
ĐžŅĐĩŅĐĩ ĐŋĐĩŅĐĩĐŧŅŅŅиŅи ОйĐģŅĐēОвиК СаĐŋĐ¸Ņ ĐŊа ŅĐŊŅиК ŅĐŊŅŅаĐŊŅ, ваĐŧ ĐŋĐžŅŅŅĐąĐŊĐž ĐŋĐĩŅĐĩĐšŅи Đ´Đž ŅĐ˛ĐžĐŗĐž ŅŅĐģŅĐžĐ˛ĐžĐŗĐž ОйĐģŅĐēĐžĐ˛ĐžĐŗĐž СаĐŋиŅŅ Ņа дОдаŅи ĐŋŅĐĩвдОĐŊŅĐŧ, ŅĐž вĐēаСŅŅ ŅĐĩĐš ОйĐģŅĐēОвиК СаĐŋиŅ.", + "added_backup": "ĐОдаĐŊĐž ĐŊĐžĐ˛Ņ ŅĐĩСĐĩŅвĐŊŅ ĐēĐžĐŋŅŅ.", + "expert_mode": "ĐĐžĐēаСаŅи дОдаŅĐēĐžĐ˛Ņ ĐŋаŅаĐŧĐĩŅŅи", + "post_look_feel": "ĐŅдОйŅаĐļĐĩĐŊĐŊŅ Đ´ĐžĐŋиŅŅв", + "email_language": "ĐОва Đ´ĐģŅ ĐžŅŅиĐŧаĐŊĐŊŅ ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊиŅ
ĐģиŅŅŅв вŅĐ´ ŅĐĩŅвĐĩŅа", + "account_backup_table_head": "Đ ĐĩСĐĩŅвĐŊĐĩ ĐēĐžĐŋŅŅваĐŊĐŊŅ", + "download_backup": "ĐаваĐŊŅаĐļиŅи", + "backup_not_ready": "Đ ĐĩСĐĩŅвĐŊа ĐēĐžĐŋŅŅ ŅĐĩ ĐŊĐĩ ĐŗĐžŅОва.", + "remove_backup": "ĐидаĐģиŅи", + "list_backups_error": "ĐĐžĐŧиĐģĐēа ĐŋŅĐ´ ŅĐ°Ņ ĐžŅŅиĐŧаĐŊĐŊŅ ŅĐŋиŅĐēŅ ŅĐĩСĐĩŅвĐŊиŅ
ĐēĐžĐŋŅĐš: {error}", + "add_backup": "ĐĄŅвОŅиŅи ĐŊĐžĐ˛Ņ ŅĐĩСĐĩŅвĐŊŅ ĐēĐžĐŋŅŅ", + "account_alias_table_head": "ĐŅĐĩвдОĐŊŅĐŧ", + "list_aliases_error": "ĐĐžĐŧиĐģĐēа ĐŋŅĐ´ ŅĐ°Ņ ĐžŅŅиĐŧаĐŊĐŊŅ ĐŋŅĐĩвдОĐŊŅĐŧŅв: {error}", + "hide_list_aliases_error_action": "ĐаĐēŅиŅи", + "remove_alias": "ĐидаĐģиŅи ŅĐĩĐš ĐŋŅĐĩвдОĐŊŅĐŧ", + "added_alias": "ĐŅĐĩвдОĐŊŅĐŧ дОдаĐŊĐž.", + "add_alias_error": "ĐĐžĐŧиĐģĐēа ĐŋŅĐ´ ŅĐ°Ņ Đ´ĐžĐ´Đ°Đ˛Đ°ĐŊĐŊŅ ĐŋŅĐĩвдОĐŊŅĐŧа: {error}", + "move_account": "ĐĐĩŅĐĩĐŧŅŅŅиŅи ОйĐģŅĐēОвиК СаĐŋиŅ", + "move_account_target": "ĐĻŅĐģŅОвиК ОйĐģŅĐēОвиК СаĐŋĐ¸Ņ (ĐŊаĐŋŅ. {example})", + "moved_account": "ĐĐąĐģŅĐēОвиК СаĐŋĐ¸Ņ ĐŋĐĩŅĐĩĐŧŅŅĐĩĐŊĐž.", + "move_account_error": "ĐĐžĐŧиĐģĐēа ĐŋŅĐ´ ŅĐ°Ņ ĐŋĐĩŅĐĩĐŧŅŅĐĩĐŊĐŊŅ ĐžĐąĐģŅĐēĐžĐ˛ĐžĐŗĐž СаĐŋиŅŅ: {error}" }, "selectable_list": { "select_all": "ĐийŅаŅи вŅĐĩ" @@ -670,7 +714,10 @@ "captcha": "CAPTCHA", "register": "ĐаŅĐĩŅŅŅŅŅваŅиŅŅ", "reason_placeholder": "ĐĻĐĩĐš ŅĐŊŅŅаĐŊŅ ĐžĐąŅОйĐģŅŅ ĐˇĐ°ĐŋиŅи ĐŊа ŅĐĩŅŅŅŅаŅŅŅ Đ˛ŅŅŅĐŊŅ.\nРОСĐēаĐļŅŅŅ Đ°Đ´ĐŧŅĐŊŅŅŅŅаŅŅŅ ŅĐžĐŧŅ Đ˛Đ¸ Ņ
ĐžŅĐĩŅĐĩ СаŅĐĩŅŅŅŅŅваŅиŅŅ.", - "reason": "ĐŅиŅиĐŊа ŅĐĩŅŅŅŅаŅŅŅ" + "reason": "ĐŅиŅиĐŊа ŅĐĩŅŅŅŅаŅŅŅ", + "bio_optional": "ĐŅĐžĐŗŅаŅŅŅ (ĐŊĐĩОйОв'ŅСĐēОвО)", + "email_language": "Đ¯ĐēĐžŅ ĐŧĐžĐ˛ĐžŅ Đ˛Đ¸ йаĐļаŅŅĐĩ ĐžŅŅиĐŧŅваŅи ĐĩĐģĐĩĐēŅŅĐžĐŊĐŊŅ ĐģиŅŅи вŅĐ´ ŅĐĩŅвĐĩŅа?", + "email_optional": "ĐĐģ. ĐŋĐžŅŅа (ĐŊĐĩОйОв'ŅСĐēОвО)" }, "who_to_follow": { "who_to_follow": "Đа ĐēĐžĐŗĐž ĐŋŅĐ´ĐŋиŅаŅиŅŅ", @@ -755,7 +802,6 @@ "deactivate_account": "ĐĐĩаĐēŅивŅваŅи ОйĐģŅĐēОвиК СаĐŋиŅ", "delete_account": "ĐидаĐģиŅи ОйĐģŅĐēОвиК СаĐŋиŅ", "moderation": "ĐОдĐĩŅаŅŅŅ", - "delete_user_confirmation": "Đи айŅĐžĐģŅŅĐŊĐž вĐŋĐĩвĐŊĐĩĐŊŅ? ĐĻŅ Đ´ŅŅ ĐŊĐĩĐŧĐžĐļĐģивО ĐąŅĐ´Đĩ ŅĐēаŅОвŅваŅи.", "delete_user": "ĐидаĐģиŅи ОйĐģŅĐēОвиК СаĐŋиŅ", "strip_media": "ĐиĐģŅŅиŅи ĐŧĐĩĐ´Ņа С Đ´ĐžĐŋиŅŅв ĐēĐžŅиŅŅŅваŅа", "force_nsfw": "ĐОСĐŊаŅиŅи вŅŅ Đ´ĐžĐŋиŅи ŅĐē NSFW", @@ -861,5 +907,12 @@ "profile_loading_error": "ĐийаŅŅĐĩ, ĐŋŅĐ´ ŅĐ°Ņ ĐˇĐ°Đ˛Đ°ĐŊŅаĐļĐĩĐŊĐŊŅ ŅŅĐžĐŗĐž ĐŋŅĐžŅŅĐģŅ Đ˛Đ¸ĐŊиĐēĐģа ĐŋĐžĐŧиĐģĐēа.", "profile_does_not_exist": "ĐийаŅŅĐĩ, ŅĐĩĐš ĐŋŅĐžŅŅĐģŅ ĐąŅĐģŅŅĐĩ ĐŊĐĩ ŅŅĐŊŅŅ.", "timeline_title": "ĐĄŅŅŅŅĐēа ĐēĐžŅиŅŅŅваŅа" + }, + "report": { + "notes": "ĐŅиĐŧŅŅĐēи:", + "state": "ĐĄŅаŅŅŅ:", + "state_open": "вŅĐ´ĐēŅиŅиК", + "state_closed": "СаĐēŅиŅиК", + "state_resolved": "виŅŅŅĐĩĐŊиК" } } diff --git a/src/i18n/vi.json b/src/i18n/vi.json index 088d73cc..fd7ae25c 100644 --- a/src/i18n/vi.json +++ b/src/i18n/vi.json @@ -51,7 +51,7 @@ "scope_options": "Äa dáēĄng kiáģu ÄÄng" }, "finder": { - "error_fetching_user": "Láģi ngưáģi dÚng", + "error_fetching_user": "Láģi khi náēĄp ngưáģi dÚng", "find_user": "TÃŦm ngưáģi dÚng" }, "shoutbox": { @@ -149,7 +149,7 @@ "no_more_notifications": "Không cÃ˛n thông bÃĄo nà o", "migrated_to": "chuyáģn sang", "reacted_with": "cháēĄm táģi {0}", - "error": "Láģi xáģ lÃŊ thông bÃĄo: {0}" + "error": "Láģi khi náēĄp thông bÃĄo {0}" }, "polls": { "add_poll": "TáēĄo bÃŦnh cháģn", @@ -197,7 +197,7 @@ "text/bbcode": "BBCode" }, "content_warning": "TiÃĒu Äáģ (tÚy cháģn)", - "default": "Just landed in L.A.", + "default": "Äáģi ngưáģi con gÃĄi không muáģn yÃĒu ai ÄÆ°áģŖc không?", "direct_warning_to_first_only": "Ngưáģi Äáē§u tiÃĒn ÄÆ°áģŖc nháē¯c Äáēŋn máģi cÃŗ tháģ tháēĨy tÃēt nà y.", "posting": "Äang ÄÄng tÃēt", "post": "ÄÄng", @@ -427,9 +427,445 @@ "no_rich_text_description": "Không hiáģn rich text trong cÃĄc tÃēt", "hide_follows_count_description": "áē¨n sáģ lưáģŖng ngưáģi tôi theo dÃĩi", "nsfw_clickthrough": "Cho phÊp nháēĨn và o xem cÃĄc tÃēt nháēĄy cáēŖm", - "reply_visibility_following": "Cháģ hiáģn nháģ¯ng tráēŖ láģi cÃŗ nháē¯c táģi tôi hoáēˇc táģĢ nháģ¯ng ngưáģi mà tôi theo dÃĩi" + "reply_visibility_following": "Cháģ hiáģn nháģ¯ng tráēŖ láģi cÃŗ nháē¯c táģi tôi hoáēˇc táģĢ nháģ¯ng ngưáģi mà tôi theo dÃĩi", + "autohide_floating_post_button": "áē¨n nÃēt viáēŋt tÃēt khi xem báēŖng tin (di Äáģng)", + "saving_err": "Thiáēŋt láēp láģi lưu", + "saving_ok": "ÄÃŖ lưu cÃĄc thay Äáģi", + "search_user_to_block": "TÃŦm ngưáģi báēĄn muáģn cháēˇn", + "search_user_to_mute": "TÃŦm ngưáģi báēĄn muáģn áēŠn", + "security_tab": "BáēŖo máēt", + "scope_copy": "ChÊp pháēĄm vi khi tráēŖ láģi (tin nháē¯n luôn ÄÆ°áģŖc chÊp sáēĩn)", + "minimal_scopes_mode": "TÚy cháģn thu nháģ pháēĄm vi tÃēt", + "set_new_avatar": "Äáģi áēŖnh ÄáēĄi diáģn", + "set_new_profile_background": "Äáģi áēŖnh náģn", + "set_new_profile_banner": "Äáģi áēŖnh bÃŦa", + "reset_profile_background": "Äáēˇt láēĄi áēŖnh náģn", + "reset_profile_banner": "Äáēˇt láēĄi áēŖnh bÃŦa", + "reset_banner_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģn Äáēˇt láēĄi áēŖnh bÃŦa?", + "reset_background_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģn Äáēˇt láēĄi áēŖnh náģn?", + "settings": "Cà i Äáēˇt", + "subject_input_always_show": "Luôn hiáģn vÚng tiÃĒu Äáģ", + "subject_line_behavior": "ChÊp tiÃĒu Äáģ khi tráēŖ láģi", + "subject_line_email": "Giáģng email: \"re: subject\"", + "subject_line_mastodon": "Giáģng Mastodon: copy as is", + "subject_line_noop": "ÄáģĢng chÊp", + "sensitive_by_default": "Máēˇc Äáģnh tÃēt là nháēĄy cáēŖm", + "stop_gifs": "Cháģ phÃĄt GIF khi cháēĄm và o", + "streaming": "Táģą Äáģng táēŖi tÃēt máģi khi cuáģn lÃĒn trÃĒn", + "user_mutes": "Ngưáģi dÚng", + "useStreamingApiWarning": "(TÃnh nÄng tháģ nghiáģm, không Äáģ xuáēĨt sáģ dáģĨng)", + "text": "VÄn báēŖn", + "theme": "Theme", + "theme_help": "DÚng mÃŖ mà u hex (#rrggbb) Äáģ táģą cháēŋ theme.", + "tooltipRadius": "Tooltips/alerts", + "type_domains_to_mute": "TÃŦm mÃĄy cháģ§ Äáģ áēŠn", + "upload_a_photo": "TáēŖi áēŖnh lÃĒn", + "user_settings": "Thiáēŋt láēp ngưáģi dÚng", + "values": { + "false": "không", + "true": "cÃŗ" + }, + "virtual_scrolling": "Render báēŖng tin", + "fun": "Vui nháģn", + "greentext": "MÅŠi tÃĒn meme", + "notifications": "Thông bÃĄo", + "notification_setting_filters": "Báģ láģc", + "notification_setting_block_from_strangers": "Cháēˇn thông bÃĄo táģĢ nháģ¯ng ngưáģi báēĄn không theo dÃĩi", + "notification_setting_privacy": "RiÃĒng tư", + "notification_setting_hide_notification_contents": "áē¨n ngưáģi gáģi và náģi dung thông bÃĄo ÄáēŠy", + "notification_mutes": "Sáģ dáģĨng áēŠn náēŋu muáģn dáģĢng nháēn thông bÃĄo táģĢ máģt ngưáģi cáģĨ tháģ.", + "notification_blocks": "Cháēˇn máģt ngưáģi ngáģĢng toà n báģ thông bÃĄo cÅŠng giáģng như háģ§y ÄÄng kÃŊ háģ.", + "more_settings": "Cà i Äáēˇt khÃĄc", + "style": { + "switcher": { + "keep_shadows": "Giáģ¯ bÃŗng Äáģ", + "keep_color": "Giáģ¯ mà u", + "keep_opacity": "Giáģ¯ trong suáģt", + "keep_roundness": "Giáģ¯ bo trÃ˛n gÃŗc", + "reset": "Äáēˇt láēĄi", + "clear_all": "XÃŗa háēŋt", + "clear_opacity": "XÃŗa trong suáģt", + "load_theme": "TáēŖi theme", + "keep_as_is": "Giáģ¯ như là ", + "use_snapshot": "BáēŖn cÅŠ", + "use_source": "BáēŖn máģi", + "help": { + "upgraded_from_v2": "PleromaFE ÄÃŖ ÄÆ°áģŖc nÃĸng cáēĨp, theme cÃŗ tháģ khÃĄc hÆĄn máģt chÃēt so váģi báēŖn cÅŠ.", + "v2_imported": "Táēp tin báēĄn nháēp là táģĢ phiÃĒn báēŖn PleromaFE cÅŠ. ChÃēng tôi sáēŊ cáģ là m nÃŗ tÆ°ÆĄng thÃch nhưng cÃŗ tháģ sáēŊ cÃŗ xung Äáģt.", + "older_version_imported": "Táēp tin báēĄn váģĢa nháēp ÄÆ°áģŖc táēĄo ra táģĢ phiÃĒn báēŖn PleromaFE cÅŠ.", + "snapshot_present": "ÄÃŖ táēŖi theme snapshot, máģi giÃĄ tráģ sáēŊ báģ chÊp Äè. Thay và o ÄÃŗ, báēĄn cÃŗ tháģ táēŖi dáģ¯ liáģu cháē¯c cháē¯n cáģ§a theme.", + "fe_upgraded": "Theme cáģ§a PleromaFE ÄÆ°áģŖc nÃĸng cáēĨp sau máģi phiÃĒn báēŖn.", + "fe_downgraded": "Theme cáģ§a phiÃĒn báēŖn PleromaFE ÄÃŖ ÄÆ°áģŖc háēĄ cáēĨp.", + "migration_snapshot_ok": "Theme snapshot ÄÃŖ táēŖi xong. BáēĄn cÃŗ tháģ tháģ táēŖi dáģ¯ liáģu theme.", + "migration_napshot_gone": "Náēŋu thiáēŋu snapshot, máģt sáģ tháģŠ sáēŊ khÃĄc váģi ban Äáē§u.", + "future_version_imported": "Táēp tin báēĄn váģĢa nháēp ÄÆ°áģŖc táēĄo ra táģĢ phiÃĒn báēŖn PleromaFE máģi.", + "snapshot_missing": "Không cÃŗ theme snapshot trong táēp tin cho nÃĒn cÃŗ tháģ nÃŗ sáēŊ khÃĄc váģi báēŖn gáģc Äôi chÃēt.", + "snapshot_source_mismatch": "Xung Äáģt phiÃĒn báēŖn: háē§u háēŋt Pleroma FE ÄÃŖ háēĄ cáēĨp và cáēp nháēt láēĄi, náēŋu báēĄn Äáģi theme sáģ dáģĨng phiÃĒn báēŖn cÅŠ hÆĄn cáģ§a FE, báēĄn gáē§n như muáģn sáģ dáģĨng phiÃĒn báēŖn cÅŠ, thay và o ÄÃŗ sáģ dáģĨng phiÃĒn báēŖn máģi." + }, + "keep_fonts": "Giáģ¯ phông cháģ¯", + "save_load_hint": "GiÃēp giáģ¯ nguyÃĒn cÃĄc tÚy cháģn hiáģn táēĄi khi cháģn hoáēˇc táēŖi theme khÃĄc, nÃŗ cÅŠng lưu tráģ¯ cÃĄc tÚy cháģn ÄÃŖ nÃŗi khi xuáēĨt máģt theme. Khi táēĨt cáēŖ cÃĄc háģp kiáģm báģ báģ tráģng, viáģc xuáēĨt theme sáēŊ lưu máģi tháģŠ." + }, + "common": { + "color": "Mà u sáē¯c", + "opacity": "Trong suáģt", + "contrast": { + "hint": "Táģ láģ tÆ°ÆĄng pháēŖn là {ratio}, nÃŗ {level} {context}", + "level": { + "aa": "ÄáēĄt máģŠc AA (táģi thiáģu)", + "aaa": "ÄáēĄt máģŠc AAA (Äáģ xuáēĨt)", + "bad": "không ÄáēĄt yÃĒu cáē§u" + }, + "context": { + "18pt": "cáģĄ cháģ¯ láģn (18pt+)", + "text": "cho cháģ¯" + } + } + }, + "common_colors": { + "_tab_label": "Chung", + "main": "Mà u sáē¯c chung", + "foreground_hint": "Máģ tab \"NÃĸng cao\" Äáģ cÃŗ nhiáģu tÚy cháģn hÆĄn", + "rgbo": "Icons, accents, badges" + }, + "advanced_colors": { + "_tab_label": "NÃĸng cao", + "alert": "Náģn cáēŖnh bÃĄo", + "alert_error": "Láģi", + "alert_warning": "CáēŖnh bÃĄo", + "alert_neutral": "Neutral", + "post": "TÃēt/Tiáģu sáģ", + "badge": "Náģn huy hiáģu", + "popover": "Tooltips, menus, popovers", + "badge_notification": "Thông bÃĄo", + "panel_header": "TiÃĒu Äáģ panel", + "top_bar": "Thanh trÃĒn cÚng", + "borders": "ÄÆ°áģng biÃĒn", + "buttons": "NÃēt báēĨm", + "faint_text": "Cháģ¯ máģ", + "underlay": "Láģp dưáģi", + "wallpaper": "Wallpaper", + "poll": "Biáģu Äáģ cuáģc bÃŦnh cháģn", + "icons": "Biáģu tưáģŖng", + "highlight": "Nháģ¯ng thà nh pháē§n náģi báēt", + "pressed": "Khi nháēĨn xuáģng", + "selectedPost": "Cháģn tÃēt", + "selectedMenu": "Cháģn menu", + "toggled": "Toggled", + "tabs": "Tab", + "chat": { + "incoming": "Tin nháē¯n Äáēŋn", + "outgoing": "Tin nháē¯n Äi", + "border": "ÄÆ°áģng biÃĒn" + }, + "inputs": "Khung soáēĄn tháēŖo", + "disabled": "Vô hiáģu hÃŗa" + }, + "radii": { + "_tab_label": "GÃŗc bo trÃ˛n" + }, + "shadows": { + "component": "Thà nh pháē§n", + "shadow_id": "Äáģ bÃŗng #{value}", + "blur": "Là m máģ", + "spread": "Máģ ráģng", + "inset": "Thu và o", + "filter_hint": { + "always_drop_shadow": "ChÃē ÃŊ, mà u bÃŗng Äáģ nà y luôn sáģ dáģĨng {0} náēŋu trÃŦnh duyáģt háģ tráģŖ.", + "drop_shadow_syntax": "{0} không háģ tráģŖ {1} pháē§n và táģĢ khÃŗa {2}.", + "spread_zero": "BÃŗng Äáģ > 0 sáēŊ xuáēĨt hiáģn náēŋu cháģn nÃŗ thà nh không", + "inset_classic": "BÃŗng Äáģ inset sáēŊ sáģ dáģĨng {0}", + "avatar_inset": "Náēŋu tráģn láēĢn bÃŗng Äáģ inset và non-inset trÃĒn áēŖnh ÄáēĄi diáģn cÃŗ tháģ khiáēŋn áēŖnh ÄáēĄi diáģn biáēŋn thà nh trong suáģt." + }, + "components": { + "panel": "Panel", + "panelHeader": "Panel áēŖnh bÃŦa", + "topBar": "Thanh trÃĒn cÚng", + "avatar": "áēĸnh ÄáēĄi diáģn (áģ trang cÃĄ nhÃĸn)", + "avatarStatus": "áēĸnh ÄáēĄi diáģn (áģ tÃēt)", + "popup": "Popups và tooltips", + "button": "NÃēt báēĨm", + "buttonHover": "NÃēt báēĨm (khi rÃĒ chuáģt)", + "buttonPressed": "NÃēt báēĨm (khi nháēĨn chuáģt)", + "buttonPressedHover": "NÃēt báēĨm (khi nháēĨn+giáģ¯)", + "input": "Khung soáēĄn tháēŖo" + }, + "_tab_label": "Äáģ bÃŗng và tô sÃĄng", + "override": "ChÊp Äè", + "hintV3": "Váģi bÃŗng Äáģ, báēĄn cÃŗ tháģ sáģ dáģĨng kÃŊ hiáģu {0} Äáģ dÚng slot mà u khÃĄc." + }, + "fonts": { + "_tab_label": "Phông cháģ¯", + "components": { + "interface": "Giao diáģn chung", + "input": "Khung soáēĄn tháēŖo", + "post": "TÃēt", + "postCode": "Cháģ¯ monospaced (rich text)" + }, + "family": "TÃĒn phông", + "size": "KÃch cáģĄ (px)", + "weight": "Äáģ Äáēm", + "custom": "TÚy cháģnh", + "help": "Cháģn phông cháģ¯ hiáģn tháģ. Äáģ \"tÚy cháģn\", báēĄn pháēŖi nháēp chÃnh xÃĄc tÃĒn phông cháģ¯ trÃĒn háģ tháģng." + }, + "preview": { + "header": "Xem trưáģc", + "content": "Náģi dung", + "error": "Láģi máēĢu và dáģĨ", + "button": "NÃēt báēĨm", + "text": "Máģt Äáģng {0} và {1}", + "mono": "náģi dung", + "input": "Äáģi ngưáģi con gÃĄi không muáģn yÃĒu ai ÄÆ°áģŖc không?", + "faint_link": "tà i liáģu hưáģng dáēĢn", + "checkbox": "Tôi ÄÃŖ Äáģc lưáģt qua quy táē¯c và chÃnh sÃĄch báēŖo máēt", + "link": "Link Äáēšp ÄÃŗ em yÃĒu", + "fine_print": "Äáģc {0} Äáģ tÃŦm hiáģu thÃĒm!", + "header_faint": "OK nè" + } + }, + "version": { + "title": "PhiÃĒn báēŖn", + "frontend_version": "Frontend", + "backend_version": "Backend" + }, + "reset_avatar": "Äáēˇt láēĄi áēŖnh ÄáēĄi diáģn", + "reset_avatar_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģn Äáēˇt láēĄi áēŖnh ÄáēĄi diáģn?", + "post_status_content_type": "LoáēĄi tÃēt ÄÄng", + "useStreamingApi": "Nháēn tÃēt và thông bÃĄo theo tháģi gian tháģąc", + "theme_help_v2_1": "BáēĄn cÅŠng cÃŗ tháģ xÃŗa háēŋt mà u thà nh pháē§n và là m theme trong suáģt, cháģn nÃēt \"XÃŗa háēŋt\".", + "theme_help_v2_2": "CÃĄc biáģu tưáģŖng bÃĒn dưáģi cÃĄc máģĨc cÃŗ Äáģ tÆ°ÆĄng pháēŖn náģn/vÄn báēŖn, hÃŖy rÃĒ chuáģt qua Äáģ biáēŋt thông tin chi tiáēŋt. Xin lưu ÃŊ ráēąng, khi sáģ dáģĨng cÃĄc Äáģ tÆ°ÆĄng pháēŖn trong suáģt cÃŗ tháģ khiáēŋn Äáģc cháģ¯ không ra.", + "enable_web_push_notifications": "Cho phÊp thông bÃĄo ÄáēŠy trÃĒn web", + "mentions_new_style": "LưáģŖt nháē¯c mà u mè", + "mentions_new_place": "Äáēˇt lưáģŖt nháē¯c áģ dÃ˛ng riÃĒng", + "always_show_post_button": "Luôn hiáģn nÃēt viáēŋt tÃēt máģi" }, "errors": { "storage_unavailable": "Pleroma không tháģ truy cáēp lưu tráģ¯ trÃŦnh duyáģt. Thông tin ÄÄng nháēp và nháģ¯ng thiáēŋt láēp táēĄm tháģi sáēŊ báģ máēĨt. HÃŖy cho phÊp cookies." + }, + "time": { + "day": "{0} ngà y", + "days": "{0} ngà y", + "day_short": "{0} ngà y", + "days_short": "{0} ngà y", + "hour": "{0} giáģ", + "hours": "{0} giáģ", + "hour_short": "{0} giáģ", + "hours_short": "{0} giáģ", + "in_future": "lÃēc {0}", + "in_past": "{0} trưáģc", + "minute": "{0} phÃēt", + "minutes": "{0} phÃēt", + "minute_short": "{0} phÃēt", + "minutes_short": "{0} phÃēt", + "month": "{0} thÃĄng", + "months": "{0} thÃĄng", + "month_short": "{0} thÃĄng", + "months_short": "{0} thÃĄng", + "now": "váģĢa xong", + "second": "{0} giÃĸy", + "seconds": "{0} giÃĸy", + "second_short": "{0}s", + "seconds_short": "{0}s", + "week": "{0} tuáē§n", + "weeks": "{0} tuáē§n", + "week_short": "{0} tuáē§n", + "weeks_short": "{0} tuáē§n", + "year": "{0} nÄm", + "years": "{0} nÄm", + "year_short": "{0} nÄm", + "years_short": "{0} nÄm", + "now_short": "váģĢa xong" + }, + "timeline": { + "collapse": "Thu gáģn", + "error": "Láģi khi náēĄp báēŖng tin {0}", + "load_older": "Xem tÃēt cÅŠ hÆĄn", + "repeated": "chia sáēģ", + "show_new": "Hiáģn máģi", + "reload": "TáēŖi láēĄi", + "up_to_date": "ÄÃŖ táēŖi nháģ¯ng tÃēt máģi nháēĨt", + "no_more_statuses": "Không cÃ˛n tÃēt nà o", + "no_statuses": "Tráģng trÆĄn!", + "socket_reconnected": "Thiáēŋt láēp káēŋt náģi tháģi gian tháģąc", + "conversation": "TháēŖo luáēn", + "no_retweet_hint": "Không tháģ chia sáēģ tin nháē¯n và nháģ¯ng tÃēt riÃĒng tư", + "socket_broke": "MáēĨt káēŋt náģi tháģi gian tháģąc: CloseEvent {0}" + }, + "status": { + "repeats": "Chia sáēģ", + "delete": "XÃŗa tÃēt", + "unpin": "Báģ ghim trÃĒn trang cÃĄ nhÃĸn", + "pin": "Ghim trÃĒn trang cÃĄ nhÃĸn", + "pinned": "TÃēt ÄÆ°áģŖc ghim", + "bookmark": "Lưu", + "unbookmark": "Báģ lưu", + "reply_to": "TráēŖ láģi", + "replies_list": "Nháģ¯ng tráēŖ láģi:", + "mute_conversation": "Không quan tÃĸm náģ¯a", + "unmute_conversation": "Quan tÃĸm", + "status_unavailable": "Không tÃŦm tháēĨy tÃēt", + "copy_link": "Sao chÊp URL", + "external_source": "Nguáģn bÃĒn ngoà i", + "thread_muted": "ÄÃŖ áēŠn cháģ§ Äáģ", + "thread_muted_and_words": ", cÃŗ táģĢ:", + "hide_full_subject": "áē¨n tiÃĒu Äáģ", + "show_content": "Hiáģn náģi dung", + "hide_content": "áē¨n náģi dung", + "status_deleted": "TÃēt nà y ÄÃŖ báģ xÃŗa", + "nsfw": "NháēĄy cáēŖm", + "expand": "Xem nguyÃĒn vÄn", + "favorites": "ThÃch", + "delete_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģn xÃŗa tÃēt nà y?", + "show_full_subject": "Hiáģn Äáē§y Äáģ§ tiÃĒu Äáģ", + "you": "(BáēĄn)", + "mentions": "LưáģŖt nháē¯c", + "plus_more": "+{number} nhiáģu hÆĄn" + }, + "user_card": { + "approve": "CháēĨp nháēn", + "block": "Cháēˇn", + "blocked": "ÄÃŖ cháēˇn!", + "deny": "TáģĢ cháģi", + "edit_profile": "Cháģnh sáģa trang cÃĄ nhÃĸn", + "favorites": "ThÃch", + "follow": "Theo dÃĩi", + "follow_progress": "Äang yÃĒu cáē§uâĻ", + "follow_again": "Gáģi láēĄi yÃĒu cáē§u?", + "follow_unfollow": "Ngưng theo dÃĩi", + "followees": "Äang theo dÃĩi", + "followers": "Ngưáģi theo dÃĩi", + "following": "Äang theo dÃĩi!", + "follows_you": "Theo dÃĩi báēĄn!", + "hidden": "áē¨n", + "media": "Media", + "mention": "LưáģŖt nháē¯c", + "message": "Tin nháē¯n", + "mute": "áē¨n", + "muted": "ÄÃŖ áēŠn", + "per_day": "tÃēt máģi ngà y", + "remote_follow": "Theo dÃĩi táģĢ xa", + "report": "BÃĄo cÃĄo", + "statuses": "TÃēt", + "subscribe": "ÄÄng kÃŊ", + "unsubscribe": "Háģ§y ÄÄng kÃŊ", + "unblock": "Báģ cháēˇn", + "unblock_progress": "Äang báģ cháēˇnâĻ", + "block_progress": "Äang cháēˇnâĻ", + "unmute": "Báģ áēŠn", + "unmute_progress": "Äang báģ áēŠnâĻ", + "mute_progress": "Äang áēŠnâĻ", + "hide_repeats": "áē¨n lưáģŖt chia sáēģ", + "show_repeats": "Hiáģn lưáģŖt chia sáēģ", + "bot": "Bot", + "admin_menu": { + "moderation": "Kiáģm duyáģt", + "grant_admin": "Cháģ Äáģnh QuáēŖn tráģ viÃĒn", + "revoke_admin": "GáģĄ báģ QuáēŖn tráģ viÃĒn", + "grant_moderator": "Cháģ Äáģnh Kiáģm duyáģt viÃĒn", + "activate_account": "XÃĄc tháģąc ngưáģi dÚng", + "deactivate_account": "Vô hiáģu hÃŗa ngưáģi dÚng", + "delete_account": "XÃŗa ngưáģi dÚng", + "force_nsfw": "ÄÃĄnh dáēĨu táēĨt cáēŖ tÃēt là nháēĄy cáēŖm", + "strip_media": "GáģĄ báģ media trong tÃēt", + "sandbox": "ÄÃĄnh dáēĨu táēĨt cáēŖ tÃēt là riÃĒng tư", + "disable_remote_subscription": "Không cho phÊp theo dÃĩi táģĢ mÃĄy cháģ§ khÃĄc", + "disable_any_subscription": "Không cho phÊp theo dÃĩi báēĨt cáģŠ ai", + "quarantine": "Không cho phÊp tÃēt liÃĒn háģŖp", + "delete_user": "XÃŗa ngưáģi dÚng", + "revoke_moderator": "GáģĄ báģ QuáēŖn tráģ viÃĒn", + "force_unlisted": "ÄÃĄnh dáēĨu táēĨt cáēŖ tÃēt là háēĄn cháēŋ" + }, + "highlight": { + "disabled": "Không náģi báēt", + "solid": "Náģn 1 mà u", + "striped": "Náģn 2 mà u", + "side": "Sáģc bÃĒn" + }, + "follow_sent": "ÄÃŖ gáģi yÃĒu cáē§u!", + "its_you": "ÄÃŗ là báēĄn!" + }, + "user_profile": { + "timeline_title": "BáēŖng tin ngưáģi dÚng", + "profile_does_not_exist": "Xin láģi, tà i khoáēŖn nà y không táģn táēĄi.", + "profile_loading_error": "Xin láģi, cÃŗ láģi xáēŖy ra khi xem trang cÃĄ nhÃĸn nà y." + }, + "user_reporting": { + "title": "BÃĄo cÃĄo {0}", + "additional_comments": "Ghi chÃē", + "forward_description": "Ngưáģi nà y thuáģc mÃĄy cháģ§ khÃĄc. Gáģi máģt bÃĄo cÃĄo áēŠn danh táģi mÃĄy cháģ§ ÄÃŗ?", + "forward_to": "Chuyáģn cho {0}", + "submit": "Gáģi", + "generic_error": "CÃŗ láģi xáēŖy ra khi xáģ lÃŊ yÃĒu cáē§u cáģ§a báēĄn.", + "add_comment_description": "HÃŖy cho quáēŖn tráģ viÃĒn biáēŋt lÃŊ do vÃŦ sao báēĄn bÃĄo cÃĄo ngưáģi nà y:" + }, + "who_to_follow": { + "more": "Nhiáģu hÆĄn náģ¯a", + "who_to_follow": "Nháģ¯ng ngưáģi dÚng náģi báēt" + }, + "tool_tip": { + "media_upload": "TáēŖi lÃĒn media", + "repeat": "Chia sáēģ", + "reply": "TráēŖ láģi", + "favorite": "ThÃch", + "add_reaction": "ThÃĒm tÆ°ÆĄng tÃĄc", + "accept_follow_request": "PhÃĒ duyáģt yÃĒu cáē§u theo dÃĩi", + "reject_follow_request": "TáģĢ cháģi yÃĒu cáē§u theo dÃĩi", + "bookmark": "Lưu", + "user_settings": "Thiáēŋt láēp ngưáģi dÚng" + }, + "upload": { + "error": { + "base": "TáēŖi lÃĒn tháēĨt báēĄi.", + "message": "TáēŖi lÃĒn tháēĨt báēĄi: {0}", + "file_too_big": "Táēp tin quÃĄ láģn [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "HÃŖy tháģ láēĄi sau" + }, + "file_size_units": { + "KiB": "KB", + "MiB": "MB", + "GiB": "GB", + "B": "byte", + "TiB": "TB" + } + }, + "search": { + "people": "Ngưáģi", + "hashtags": "Hashtag", + "person_talking": "{count} ngưáģi Äang trÃ˛ chuyáģn", + "people_talking": "{count} ngưáģi Äang trÃ˛ chuyáģn", + "no_results": "Không tÃŦm tháēĨy" + }, + "password_reset": { + "forgot_password": "QuÃĒn máēt kháēŠu", + "password_reset": "Äáģi máēt kháēŠu", + "placeholder": "Email hoáēˇc tÃĒn ngưáģi dÚng", + "check_email": "Kiáģm tra email cáģ§a báēĄn.", + "return_home": "Quay láēĄi Pleroma", + "too_many_requests": "BáēĄn ÄÃŖ vưáģŖt giáģi háēĄn cho phÊp, hÃŖy tháģ láēĄi sau.", + "password_reset_disabled": "Reset máēt kháēŠu báģ táē¯t. HÃŖy liÃĒn háģ quáēŖn tráģ viÃĒn mÃĄy cháģ§.", + "password_reset_required": "BáēĄn pháēŖi Äáģi máēt kháēŠu Äáģ ÄÄng nháēp.", + "instruction": "Nháēp email hoáēˇc tÃĒn ngưáģi dÚng. ChÃēng tôi sáēŊ gáģi email reset máēt kháēŠu cho báēĄn.", + "password_reset_required_but_mailer_is_disabled": "BáēĄn cáē§n pháēŖi Äáģi máēt kháēŠu, nhưng tÃnh nÄng báģ táē¯t. HÃŖy liÃĒn háģ quáēŖn tráģ viÃĒn mÃĄy cháģ§." + }, + "chats": { + "you": "BáēĄn:", + "message_user": "Nháē¯n tin {nickname}", + "delete": "XÃŗa", + "chats": "Chat", + "new": "Chat máģi", + "empty_message_error": "Không tháģ gáģi tin nháē¯n tráģng", + "more": "Nhiáģu hÆĄn", + "delete_confirm": "BáēĄn cÃŗ cháē¯c cháē¯n muáģn xÃŗa tin nháē¯n nà y?", + "error_loading_chat": "CÃŗ váēĨn Äáģ khi táēŖi giao diáģn chat.", + "error_sending_message": "CÃŗ váēĨn Äáģ khi gáģi tin nháē¯n.", + "empty_chat_list_placeholder": "BáēĄn không cÃŗ tin nháē¯n. HÃŖy báē¯t Äáē§u nháē¯n cho ai ÄÃŗ!" + }, + "file_type": { + "audio": "Ãm thanh", + "video": "Video", + "image": "HÃŦnh áēŖnh", + "file": "Táēp tin" + }, + "display_date": { + "today": "Hôm nay" } } diff --git a/src/i18n/zh.json b/src/i18n/zh.json index abba4be9..cf5f384c 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -15,7 +15,8 @@ "title": "åčŊ", "who_to_follow": "æ¨čå
ŗæŗ¨", "pleroma_chat_messages": "Pleroma č夊", - "upload_limit": "ä¸äŧ éåļ" + "upload_limit": "ä¸äŧ éåļ", + "shout": "į荿ŋ" }, "finder": { "error_fetching_user": "čˇå፿ˇæļåįé蝝", @@ -46,7 +47,13 @@ }, "flash_content": "įšåģäģĨäŊŋ፠Ruffle æžį¤ē Flash å
厚īŧåŽéĒæ§īŧå¯čŊæ æīŧã", "flash_security": "æŗ¨æčŋå¯čŊææŊå¨įåąéŠīŧå ä¸ē Flash å
厚äģį￝äģģæįäģŖį ã", - "flash_fail": "Flash å
厚å čŊŊå¤ąč´Ĩīŧ蝎卿§åļå°æĨįč¯Ļæ
ã" + "flash_fail": "Flash å
厚å čŊŊå¤ąč´Ĩīŧ蝎卿§åļå°æĨįč¯Ļæ
ã", + "scope_in_timeline": { + "public": "å
Ŧåŧ", + "direct": "į§čޝ", + "private": "äģ
å
ŗæŗ¨č
", + "unlisted": "åå¤" + } }, "image_cropper": { "crop_picture": "čŖåĒåžį", @@ -79,7 +86,9 @@ }, "media_modal": { "previous": "åžå", - "next": "åžå" + "next": "åžå", + "hide": "å
ŗéåĒäŊæĨįå¨", + "counter": "{current} / {total}" }, "nav": { "about": "å
ŗäē", @@ -114,7 +123,8 @@ "reacted_with": "äŊåēäē {0} įååē", "migrated_to": "čŋį§ģå°äē", "follow_request": "æŗčĻå
ŗæŗ¨äŊ ", - "error": "ååžéįĨæļåįé蝝īŧ{0}" + "error": "ååžéįĨæļåįé蝝īŧ{0}", + "poll_ended": "æįĨ¨įģæäē" }, "polls": { "add_poll": "åĸå æįĨ¨", @@ -197,7 +207,8 @@ }, "reason_placeholder": "æ¤åŽäžįæŗ¨åéčϿ卿šåã\nč¯ˇčŽŠįŽĄįåįĨ鿍ä¸ēäģäšæŗčĻæŗ¨åã", "reason": "æŗ¨åįįą", - "register": "æŗ¨å" + "register": "æŗ¨å", + "email_language": "äŊ æŗäģæåĄå¨æļå°äģäšč¯č¨įéŽäģļīŧ" }, "selectable_list": { "select_all": "éæŠå
¨é¨" @@ -589,7 +600,38 @@ "backup_restore": "莞įŊŽå¤äģŊ" }, "right_sidebar": "å¨åŗäž§æžį¤ēäž§čžšæ ", - "hide_shoutbox": "éčåŽäžį荿ŋ" + "hide_shoutbox": "éčåŽäžį荿ŋ", + "expert_mode": "æžį¤ēéĢįē§", + "download_backup": "ä¸čŊŊ", + "mention_links": "æåéžæĨ", + "account_backup": "č´Ļåˇå¤äģŊ", + "account_backup_table_head": "å¤äģŊ", + "remove_backup": "į§ģé¤", + "list_backups_error": "čˇåå¤äģŊå襨åēéīŧ{error}", + "add_backup": "ååģēä¸ä¸Ēæ°å¤äģŊ", + "added_backup": "ååģēäēä¸ä¸Ēæ°å¤äģŊã", + "account_alias": "č´ĻåˇåĢå", + "account_alias_table_head": "åĢå", + "list_aliases_error": "čˇååĢåæļåēéīŧ{error}", + "hide_list_aliases_error_action": "å
ŗé", + "remove_alias": "į§ģé¤čŋä¸ĒåĢå", + "new_alias_target": "æˇģå ä¸ä¸Ēæ°åĢåīŧäžåĻ {example}īŧ", + "added_alias": "åĢ忎ģå åĨŊäēã", + "move_account": "į§ģå¨č´Ļåˇ", + "move_account_target": "įŽæ č´ĻåˇīŧäžåĻ {example}īŧ", + "moved_account": "č´Ļåˇį§ģå¨åĨŊäēã", + "move_account_error": "į§ģå¨č´Ļ县ļåēéīŧ{error}", + "setting_server_side": "čŋä¸Ē莞įŊŽæ¯æįģå°äŊ įä¸ĒäēēčĩæįīŧčŊåŊąåææäŧč¯ååŽĸæˇį̝", + "post_look_feel": "æįĢ įæ ˇå莿å", + "email_language": "äģæåĄå¨æļéŽäģļįč¯č¨", + "account_backup_description": "čŋä¸Ēå
莸äŊ ä¸čŊŊä¸äģŊč´ĻåˇäŋĄæ¯åæįĢ įåæĄŖīŧäŊæ¯į°å¨čŋä¸čŊå¯ŧå
Ĩå° Pleroma č´Ļåˇéã", + "backup_not_ready": "å¤äģŊčŋæ˛Ąåå¤åĨŊã", + "add_backup_error": "æˇģå æ°å¤äģŊæļåēéīŧ{error}", + "add_alias_error": "æˇģå åĢåæļåēéīŧ{error}", + "move_account_notes": "åĻæäŊ æŗæč´Ļåˇį§ģå¨å°åĢįå°æšīŧäŊ åŋ
éĄģåģįŽæ č´Ļåˇīŧįļåå ä¸ä¸ĒæåčŋéįåĢåã", + "wordfilter": "č¯č¯čŋæģ¤å¨", + "user_profiles": "፿ˇčĩæ", + "third_column_mode_notifications": "æļæ¯æ " }, "time": { "day": "{0} 夊", @@ -623,7 +665,23 @@ "year": "{0} åš´", "years": "{0} åš´", "year_short": "{0}y", - "years_short": "{0}y" + "years_short": "{0}y", + "unit": { + "days_short": "{0} 夊", + "hours": "{0} å°æļ", + "hours_short": "{0} æļ", + "minutes": "{0} å", + "minutes_short": "{0} å", + "months": "{0} ä¸Ēæ", + "months_short": "{0} æ", + "seconds": "{0} į§", + "seconds_short": "{0} į§", + "weeks_short": "{0} å¨", + "years": "{0} åš´", + "years_short": "{0} åš´", + "weeks": "{0} å¨", + "days": "{0} 夊" + } }, "timeline": { "collapse": "æå ", @@ -666,7 +724,32 @@ "status_deleted": "č¯Ĩįļæåˇ˛čĸĢå é¤", "nsfw": "NSFW", "external_source": "å¤é¨æĨæē", - "expand": "åąåŧ" + "expand": "åąåŧ", + "you": "īŧäŊ īŧ", + "plus_more": "čŋæ {number} ä¸Ē", + "many_attachments": "æįĢ æ {number} ä¸Ēéäģļ", + "collapse_attachments": "æčĩˇéäģļ", + "show_all_attachments": "æžį¤ēææéäģļ", + "show_attachment_description": "éĸč§æčŋ°īŧæåŧéäģļčŊįåŽæ´æčŋ°īŧ", + "hide_attachment": "éčéäģļ", + "remove_attachment": "į§ģé¤éäģļ", + "attachment_stop_flash": "åæĸ Flash ææžå¨", + "move_up": "æéäģļåˇĻį§ģ", + "open_gallery": "æåŧåžåē", + "thread_hide": "éččŋä¸Ēįēŋį´ĸ", + "thread_show": "æžį¤ēčŋä¸Ēįēŋį´ĸ", + "thread_show_full_with_icon": "{icon} {text}", + "thread_follow": "æĨįčŋä¸Ēįēŋį´ĸįåŠäŊé¨åīŧä¸å
ąæ {numStatus} ä¸Ēįļæīŧ", + "thread_follow_with_icon": "{icon} {text}", + "ancestor_follow": "æĨįčŋä¸Ēįļæä¸įåĢį {numReplies} ä¸Ēåå¤", + "ancestor_follow_with_icon": "{icon} {text}", + "show_all_conversation_with_icon": "{icon} {text}", + "show_all_conversation": "æžį¤ēåŽæ´å¯šč¯īŧčŋæ {numStatus} ä¸Ēįļæīŧ", + "mentions": "æå", + "replies_list_with_others": "åå¤īŧåĻå¤ +{numReplies} ä¸Ēīŧīŧ", + "move_down": "æéäģļåŗį§ģ", + "thread_show_full": "æžį¤ēčŋä¸Ēįēŋį´ĸä¸įææä¸čĨŋīŧä¸å
ąæ {numStatus} ä¸Ēįļæīŧæå¤§æˇąåēĻ {depth}īŧ", + "show_only_conversation_under_this": "åĒæžį¤ēčŋä¸Ēįļæįåå¤" }, "user_card": { "approve": "æ ¸å", @@ -714,8 +797,7 @@ "disable_remote_subscription": "įĻæĸäģčŋį¨åŽäžå
ŗæŗ¨į¨æˇ", "disable_any_subscription": "åŽå
¨įĻæĸå
ŗæŗ¨į¨æˇ", "quarantine": "äģčååŽäžä¸įĻæĸ፿ˇå¸å", - "delete_user": "å é¤į¨æˇ", - "delete_user_confirmation": "äŊ įĄŽåŽåīŧæ¤æäŊæ æŗæ¤éã" + "delete_user": "å é¤į¨æˇ" }, "hidden": "厞éč", "show_repeats": "æžį¤ēčŊŦå", @@ -825,7 +907,10 @@ "media_nsfw": "åŧēåļ莞įŊŽåĒäŊä¸ēææå
厚", "media_removal_desc": "æŦåŽäžį§ģ餿ĨčĒäģĨä¸åŽäžįåĒäŊå
厚īŧ", "ftl_removal_desc": "č¯ĨåŽäžå¨äģâ厞įĨįŊįģâæļé´įēŋä¸į§ģé¤äēä¸ååŽäžīŧ", - "ftl_removal": "äģâ厞įĨįŊįģâæļé´įēŋä¸į§ģé¤" + "ftl_removal": "äģâ厞įĨįŊįģâæļé´įēŋä¸į§ģé¤", + "reason": "įįą", + "not_applicable": "æ ", + "instance": "åŽäž" }, "mrf_policies_desc": "MRF įįĨäŧåŊąåæŦåŽäžįäēéčĄä¸ēãäģĨä¸įįĨ厞å¯į¨īŧ", "mrf_policies": "厞å¯į¨į MRF įįĨ", diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json index 2c4dc3fb..6f0f63b5 100644 --- a/src/i18n/zh_Hant.json +++ b/src/i18n/zh_Hant.json @@ -747,7 +747,6 @@ "admin_menu": { "delete_account": "åĒé¤čŗŦč", "delete_user": "åĒé¤į¨æļ", - "delete_user_confirmation": "äŊ įĸēčĒåīŧæ¤æäŊįĄæŗæ¤éˇã", "moderation": "čĒŋå", "grant_admin": "čŗĻäēįŽĄįæŦé", "revoke_admin": "æ¤éˇįŽĄįæŦé", diff --git a/src/lib/notification-i18n-loader.js b/src/lib/notification-i18n-loader.js index 71f9156a..d7a4430d 100644 --- a/src/lib/notification-i18n-loader.js +++ b/src/lib/notification-i18n-loader.js @@ -3,8 +3,8 @@ // meant to be used to load the partial i18n we need for // the service worker. module.exports = function (source) { - var object = JSON.parse(source) - var smol = { + const object = JSON.parse(source) + const smol = { notifications: object.notifications || {} } diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js index 8ecb66a8..6d59c595 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -1,20 +1,23 @@ import merge from 'lodash.merge' import localforage from 'localforage' -import { each, get, set } from 'lodash' +import { each, get, set, cloneDeep } from 'lodash' let loaded = false const defaultReducer = (state, paths) => ( - paths.length === 0 ? state : paths.reduce((substate, path) => { - set(substate, path, get(state, path)) - return substate - }, {}) + paths.length === 0 + ? state + : paths.reduce((substate, path) => { + set(substate, path, get(state, path)) + return substate + }, {}) ) const saveImmedeatelyActions = [ 'markNotificationsAsSeen', 'clearCurrentUser', 'setCurrentUser', + 'setServerSideStorage', 'setHighlight', 'setOption', 'setClientData', @@ -30,7 +33,7 @@ export default function createPersistedState ({ key = 'vuex-lz', paths = [], getState = (key, storage) => { - let value = storage.getItem(key) + const value = storage.getItem(key) return value }, setState = (key, state, storage) => { @@ -69,7 +72,7 @@ export default function createPersistedState ({ subscriber(store)((mutation, state) => { try { if (saveImmedeatelyActions.includes(mutation.type)) { - setState(key, reducer(state, paths), storage) + setState(key, reducer(cloneDeep(state), paths), storage) .then(success => { if (typeof success !== 'undefined') { if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') { diff --git a/src/main.js b/src/main.js index 3895da89..d3e60a0f 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,4 @@ -import Vue from 'vue' -import VueRouter from 'vue-router' -import Vuex from 'vuex' +import { createStore } from 'vuex' import 'custom-event-polyfill' import './lib/event_target_polyfill.js' @@ -8,9 +6,12 @@ import './lib/event_target_polyfill.js' import interfaceModule from './modules/interface.js' import instanceModule from './modules/instance.js' import statusesModule from './modules/statuses.js' +import listsModule from './modules/lists.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' +import serverSideConfigModule from './modules/serverSideConfig.js' +import serverSideStorageModule from './modules/serverSideStorage.js' import shoutModule from './modules/shout.js' import oauthModule from './modules/oauth.js' import authFlowModule from './modules/auth_flow.js' @@ -19,36 +20,24 @@ 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 editStatusModule from './modules/editStatus.js' +import statusHistoryModule from './modules/statusHistory.js' + import chatsModule from './modules/chats.js' +import announcementsModule from './modules/announcements.js' -import VueI18n from 'vue-i18n' +import { createI18n } from 'vue-i18n' import createPersistedState from './lib/persisted_state.js' import pushNotifications from './lib/push_notifications_plugin.js' import messages from './i18n/messages.js' -import VueClickOutside from 'v-click-outside' -import PortalVue from 'portal-vue' -import VBodyScrollLock from './directives/body_scroll_lock' - -import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' - import afterStoreSetup from './boot/after_store.js' const currentLocale = (window.navigator.language || 'en').split('-')[0] -Vue.use(Vuex) -Vue.use(VueRouter) -Vue.use(VueI18n) -Vue.use(VueClickOutside) -Vue.use(PortalVue) -Vue.use(VBodyScrollLock) - -Vue.component('FAIcon', FontAwesomeIcon) -Vue.component('FALayers', FontAwesomeLayers) - -const i18n = new VueI18n({ +const i18n = createI18n({ // By default, use the browser locale, we will update it if neccessary locale: 'en', fallbackLocale: 'en', @@ -59,6 +48,7 @@ messages.setLanguage(i18n, currentLocale) const persistedStateOptions = { paths: [ + 'serverSideStorage.cache', 'config', 'users.lastLoginName', 'oauth' @@ -75,19 +65,23 @@ const persistedStateOptions = { console.error(e) storageError = true } - const store = new Vuex.Store({ + const store = createStore({ modules: { i18n: { getters: { - i18n: () => i18n + i18n: () => i18n.global } }, interface: interfaceModule, instance: instanceModule, - statuses: statusesModule, + // TODO refactor users/statuses modules, they depend on each other users: usersModule, + statuses: statusesModule, + lists: listsModule, api: apiModule, config: configModule, + serverSideConfig: serverSideConfigModule, + serverSideStorage: serverSideStorageModule, shout: shoutModule, oauth: oauthModule, authFlow: authFlowModule, @@ -96,7 +90,10 @@ const persistedStateOptions = { reports: reportsModule, polls: pollsModule, postStatus: postStatusModule, - chats: chatsModule + editStatus: editStatusModule, + statusHistory: statusHistoryModule, + chats: chatsModule, + announcements: announcementsModule }, plugins, strict: false // Socket modifies itself, let's ignore this for now. diff --git a/src/modules/announcements.js b/src/modules/announcements.js new file mode 100644 index 00000000..e4d2d2b0 --- /dev/null +++ b/src/modules/announcements.js @@ -0,0 +1,135 @@ +const FETCH_ANNOUNCEMENT_INTERVAL_MS = 1000 * 60 * 5 + +export const defaultState = { + announcements: [], + supportsAnnouncements: true, + fetchAnnouncementsTimer: undefined +} + +export const mutations = { + setAnnouncements (state, announcements) { + state.announcements = announcements + }, + setAnnouncementRead (state, { id, read }) { + const index = state.announcements.findIndex(a => a.id === id) + + if (index < 0) { + return + } + + state.announcements[index].read = read + }, + setFetchAnnouncementsTimer (state, timer) { + state.fetchAnnouncementsTimer = timer + }, + setSupportsAnnouncements (state, supportsAnnouncements) { + state.supportsAnnouncements = supportsAnnouncements + } +} + +export const getters = { + unreadAnnouncementCount (state, _getters, rootState) { + if (!rootState.users.currentUser) { + return 0 + } + + const unread = state.announcements.filter(announcement => !(announcement.inactive || announcement.read)) + return unread.length + } +} + +const announcements = { + state: defaultState, + mutations, + getters, + actions: { + fetchAnnouncements (store) { + if (!store.state.supportsAnnouncements) { + return Promise.resolve() + } + + const currentUser = store.rootState.users.currentUser + const isAdmin = currentUser && currentUser.role === 'admin' + + const getAnnouncements = async () => { + if (!isAdmin) { + return store.rootState.api.backendInteractor.fetchAnnouncements() + } + + const all = await store.rootState.api.backendInteractor.adminFetchAnnouncements() + const visible = await store.rootState.api.backendInteractor.fetchAnnouncements() + const visibleObject = visible.reduce((a, c) => { + a[c.id] = c + return a + }, {}) + const getWithinVisible = announcement => visibleObject[announcement.id] + + all.forEach(announcement => { + const visibleAnnouncement = getWithinVisible(announcement) + if (!visibleAnnouncement) { + announcement.inactive = true + } else { + announcement.read = visibleAnnouncement.read + } + }) + + return all + } + + return getAnnouncements() + .then(announcements => { + store.commit('setAnnouncements', announcements) + }) + .catch(error => { + // If and only if backend does not support announcements, it would return 404. + // In this case, silently ignores it. + if (error && error.statusCode === 404) { + store.commit('setSupportsAnnouncements', false) + } else { + throw error + } + }) + }, + markAnnouncementAsRead (store, id) { + return store.rootState.api.backendInteractor.dismissAnnouncement({ id }) + .then(() => { + store.commit('setAnnouncementRead', { id, read: true }) + }) + }, + startFetchingAnnouncements (store) { + if (store.state.fetchAnnouncementsTimer) { + return + } + + const interval = setInterval(() => store.dispatch('fetchAnnouncements'), FETCH_ANNOUNCEMENT_INTERVAL_MS) + store.commit('setFetchAnnouncementsTimer', interval) + + return store.dispatch('fetchAnnouncements') + }, + stopFetchingAnnouncements (store) { + const interval = store.state.fetchAnnouncementsTimer + store.commit('setFetchAnnouncementsTimer', undefined) + clearInterval(interval) + }, + postAnnouncement (store, { content, startsAt, endsAt, allDay }) { + return store.rootState.api.backendInteractor.postAnnouncement({ content, startsAt, endsAt, allDay }) + .then(() => { + return store.dispatch('fetchAnnouncements') + }) + }, + editAnnouncement (store, { id, content, startsAt, endsAt, allDay }) { + return store.rootState.api.backendInteractor.editAnnouncement({ id, content, startsAt, endsAt, allDay }) + .then(() => { + return store.dispatch('fetchAnnouncements') + }) + }, + deleteAnnouncement (store, id) { + return store.rootState.api.backendInteractor.deleteAnnouncement({ id }) + .then(() => { + return store.dispatch('fetchAnnouncements') + }) + } + } +} + +export default announcements diff --git a/src/modules/api.js b/src/modules/api.js index 54f94356..fee584e8 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -15,6 +15,9 @@ const api = { mastoUserSocketStatus: null, followRequests: [] }, + getters: { + followRequestCount: state => state.followRequests.length + }, mutations: { setBackendInteractor (state, backendInteractor) { state.backendInteractor = backendInteractor @@ -100,6 +103,13 @@ const api = { showImmediately: timelineData.visibleStatuses.length === 0, timeline: 'friends' }) + } else if (message.event === 'status.update') { + dispatch('addNewStatuses', { + statuses: [message.status], + userId: false, + showImmediately: message.status.id in timelineData.visibleStatusesObject, + timeline: 'friends' + }) } else if (message.event === 'delete') { dispatch('deleteStatusById', message.id) } else if (message.event === 'pleroma:chat_update') { @@ -191,12 +201,13 @@ const api = { startFetchingTimeline (store, { timeline = 'friends', tag = false, - userId = false + userId = false, + listId = false }) { if (store.state.fetchers[timeline]) return const fetcher = store.state.backendInteractor.startFetchingTimeline({ - timeline, store, userId, tag + timeline, store, userId, listId, tag }) store.commit('addFetcher', { fetcherName: timeline, fetcher }) }, @@ -205,7 +216,7 @@ const api = { if (!fetcher) return store.commit('removeFetcher', { fetcherName: timeline, fetcher }) }, - fetchTimeline (store, timeline, { ...rest }) { + fetchTimeline (store, { timeline, ...rest }) { store.state.backendInteractor.fetchTimeline({ store, timeline, @@ -233,7 +244,7 @@ const api = { // Follow requests startFetchingFollowRequests (store) { - if (store.state.fetchers['followRequests']) return + if (store.state.fetchers.followRequests) return const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store }) store.commit('addFetcher', { fetcherName: 'followRequests', fetcher }) @@ -244,10 +255,22 @@ const api = { store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher }) }, removeFollowRequest (store, request) { - let requests = store.state.followRequests.filter((it) => it !== request) + const requests = store.state.followRequests.filter((it) => it !== request) store.commit('setFollowRequests', requests) }, + // Lists + startFetchingLists (store) { + if (store.state.fetchers.lists) return + const fetcher = store.state.backendInteractor.startFetchingLists({ store }) + store.commit('addFetcher', { fetcherName: 'lists', fetcher }) + }, + stopFetchingLists (store) { + const fetcher = store.state.fetchers.lists + if (!fetcher) return + store.commit('removeFetcher', { fetcherName: 'lists', fetcher }) + }, + // Pleroma websocket setWsToken (store, token) { store.commit('setWsToken', token) diff --git a/src/modules/chats.js b/src/modules/chats.js index 69d683bd..f28c2603 100644 --- a/src/modules/chats.js +++ b/src/modules/chats.js @@ -1,4 +1,4 @@ -import Vue from 'vue' +import { reactive } 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' @@ -13,8 +13,8 @@ const emptyChatList = () => ({ const defaultState = { chatList: emptyChatList(), chatListFetcher: null, - openedChats: {}, - openedChatMessageServices: {}, + openedChats: reactive({}), + openedChatMessageServices: reactive({}), fetcher: undefined, currentChatId: null, lastReadMessageId: null @@ -137,10 +137,10 @@ const chats = { }, addOpenedChat (state, { _dispatch, chat }) { state.currentChatId = chat.id - Vue.set(state.openedChats, chat.id, chat) + state.openedChats[chat.id] = chat if (!state.openedChatMessageServices[chat.id]) { - Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id)) + state.openedChatMessageServices[chat.id] = chatService.empty(chat.id) } }, setCurrentChatId (state, { chatId }) { @@ -160,7 +160,7 @@ const chats = { } } else { state.chatList.data.push(updatedChat) - Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + state.chatList.idStore[updatedChat.id] = updatedChat } }) }, @@ -172,7 +172,7 @@ const chats = { chat.updated_at = updatedChat.updated_at } if (!chat) { state.chatList.data.unshift(updatedChat) } - Vue.set(state.chatList.idStore, updatedChat.id, updatedChat) + state.chatList.idStore[updatedChat.id] = updatedChat }, deleteChat (state, { _dispatch, id, _rootGetters }) { state.chats.data = state.chats.data.filter(conversation => @@ -186,8 +186,8 @@ const chats = { 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) + delete state.openedChats[chatId] + delete state.openedChatMessageServices[chatId] } }, setChatsLoading (state, { value }) { @@ -215,8 +215,8 @@ const chats = { for (const chatId in state.openedChats) { if (currentChatId !== chatId) { chatService.clear(state.openedChatMessageServices[chatId]) - Vue.delete(state.openedChats, chatId) - Vue.delete(state.openedChatMessageServices, chatId) + delete state.openedChats[chatId] + delete state.openedChatMessageServices[chatId] } } }, diff --git a/src/modules/config.js b/src/modules/config.js index bc3db11b..3cd6888f 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,6 +1,9 @@ -import { set, delete as del } from 'vue' -import { setPreset, applyTheme } from '../services/style_setter/style_setter.js' +import Cookies from 'js-cookie' +import { setPreset, applyTheme, applyConfig } from '../services/style_setter/style_setter.js' import messages from '../i18n/messages' +import localeService from '../services/locale/locale.service.js' + +const BACKEND_LANGUAGE_COOKIE_NAME = 'userLanguage' const browserLocale = (window.navigator.language || 'en').split('-')[0] @@ -11,10 +14,15 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0] */ export const multiChoiceProperties = [ 'postContentType', - 'subjectLineBehavior' + 'subjectLineBehavior', + 'conversationDisplay', // tree | linear + 'conversationOtherRepliesButton', // below | inside + 'mentionLinkDisplay', // short | full_for_remote | full + 'userPopoverAvatarAction' // close | zoom | open ] export const defaultState = { + expertLevel: 0, // used to track which settings to show and hide colors: {}, theme: undefined, customTheme: undefined, @@ -24,6 +32,9 @@ export const defaultState = { hideShoutbox: false, // bad name: actually hides posts of muted USERS hideMutedPosts: undefined, // instance default + hideMutedThreads: undefined, // instance default + hideWordFilteredPosts: undefined, // instance default + muteBotStatuses: undefined, // instance default collapseMessageWithSubject: undefined, // instance default padEmoji: true, hideAttachments: false, @@ -38,8 +49,9 @@ export const defaultState = { alwaysShowNewPostButton: false, autohideFloatingPostButton: false, pauseOnUnfocused: true, - stopGifs: false, + stopGifs: true, replyVisibility: 'all', + thirdColumnMode: 'notifications', notificationVisibility: { follows: true, mentions: true, @@ -48,7 +60,9 @@ export const defaultState = { moves: true, emojiReactions: true, followRequest: true, - chatMention: true + reports: true, + chatMention: true, + polls: true }, webPushNotifications: false, muteWords: [], @@ -66,12 +80,33 @@ export const defaultState = { hideFilteredStatuses: undefined, // instance default playVideosInModal: false, useOneClickNsfw: false, - useContainFit: false, + useContainFit: true, + disableStickyHeaders: false, + showScrollbars: false, + userPopoverAvatarAction: 'open', + userPopoverOverlay: false, + sidebarColumnWidth: '25rem', + contentColumnWidth: '45rem', + notifsColumnWidth: '25rem', + navbarColumnStretch: false, greentext: undefined, // instance default + useAtIcon: undefined, // instance default + mentionLinkDisplay: undefined, // instance default + mentionLinkShowTooltip: undefined, // instance default + mentionLinkShowAvatar: undefined, // instance default + mentionLinkFadeDomain: undefined, // instance default + mentionLinkShowYous: undefined, // instance default + mentionLinkBoldenYou: undefined, // instance default hidePostStats: undefined, // instance default + hideBotIndication: undefined, // instance default hideUserStats: undefined, // instance default virtualScrolling: undefined, // instance default - sensitiveByDefault: undefined // instance default + sensitiveByDefault: undefined, // instance default + conversationDisplay: undefined, // instance default + conversationTreeAdvanced: undefined, // instance default + conversationOtherRepliesButton: undefined, // instance default + conversationTreeFadeAncestors: undefined, // instance default + maxDepthInThread: undefined // instance default } // caching the instance default properties @@ -102,14 +137,14 @@ const config = { }, mutations: { setOption (state, { name, value }) { - set(state, name, value) + state[name] = value }, setHighlight (state, { user, color, type }) { const data = this.state.config.highlight[user] if (color || type) { - set(state.highlight, user, { color: color || data.color, type: type || data.type }) + state.highlight[user] = { color: color || data.color, type: type || data.type } } else { - del(state.highlight, user) + delete state.highlight[user] } } }, @@ -118,7 +153,7 @@ const config = { const knownKeys = new Set(Object.keys(defaultState)) const presentKeys = new Set(Object.keys(data)) const intersection = new Set() - for (let elem of presentKeys) { + for (const elem of presentKeys) { if (knownKeys.has(elem)) { intersection.add(elem) } @@ -131,18 +166,28 @@ const config = { setHighlight ({ commit, dispatch }, { user, color, type }) { commit('setHighlight', { user, color, type }) }, - setOption ({ commit, dispatch }, { name, value }) { + setOption ({ commit, dispatch, state }, { name, value }) { commit('setOption', { name, value }) switch (name) { case 'theme': setPreset(value) break + case 'sidebarColumnWidth': + case 'contentColumnWidth': + case 'notifsColumnWidth': + applyConfig(state) + break case 'customTheme': case 'customThemeSource': applyTheme(value) break case 'interfaceLanguage': messages.setLanguage(this.getters.i18n, value) + dispatch('loadUnicodeEmojiData', value) + Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value)) + break + case 'thirdColumnMode': + dispatch('setLayoutWidth', undefined) break } } diff --git a/src/modules/editStatus.js b/src/modules/editStatus.js new file mode 100644 index 00000000..fd316519 --- /dev/null +++ b/src/modules/editStatus.js @@ -0,0 +1,25 @@ +const editStatus = { + state: { + params: null, + modalActivated: false + }, + mutations: { + openEditStatusModal (state, params) { + state.params = params + state.modalActivated = true + }, + closeEditStatusModal (state) { + state.modalActivated = false + } + }, + actions: { + openEditStatusModal ({ commit }, params) { + commit('openEditStatusModal', params) + }, + closeEditStatusModal ({ commit }) { + commit('closeEditStatusModal') + } + } +} + +export default editStatus diff --git a/src/modules/errors.js b/src/modules/errors.js index ca89dc0f..d2e24100 100644 --- a/src/modules/errors.js +++ b/src/modules/errors.js @@ -2,8 +2,8 @@ import { capitalize } from 'lodash' export function humanizeErrors (errors) { return Object.entries(errors).reduce((errs, [k, val]) => { - let message = val.reduce((acc, message) => { - let key = capitalize(k.replace(/_/g, ' ')) + const message = val.reduce((acc, message) => { + const key = capitalize(k.replace(/_/g, ' ')) return acc + [key, message].join(' ') + '. ' }, '') return [...errs, message] diff --git a/src/modules/instance.js b/src/modules/instance.js index 539b9c66..3b15e62e 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -1,8 +1,42 @@ -import { set } from 'vue' import { getPreset, applyTheme } from '../services/style_setter/style_setter.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import apiService from '../services/api/api.service.js' import { instanceDefaultProperties } from './config.js' +import { langCodeToCldrName, ensureFinalFallback } from '../i18n/languages.js' + +const SORTED_EMOJI_GROUP_IDS = [ + 'smileys-and-emotion', + 'people-and-body', + 'animals-and-nature', + 'food-and-drink', + 'travel-and-places', + 'activities', + 'objects', + 'symbols', + 'flags' +] + +const REGIONAL_INDICATORS = (() => { + const start = 0x1F1E6 + const end = 0x1F1FF + const A = 'A'.codePointAt(0) + const res = new Array(end - start + 1) + for (let i = start; i <= end; ++i) { + const letter = String.fromCodePoint(A + i - start) + res[i - start] = { + replacement: String.fromCodePoint(i), + imageUrl: false, + displayText: 'regional_indicator_' + letter, + displayTextI18n: { + key: 'emoji.regional_indicator', + args: { letter } + } + } + } + return res +})() + +const REMOTE_INTERACTION_URL = '/main/ostatus' const defaultState = { // Stuff from apiConfig @@ -20,16 +54,29 @@ const defaultState = { background: '/static/aurora_borealis.jpg', collapseMessageWithSubject: false, greentext: false, + useAtIcon: false, + mentionLinkDisplay: 'short', + mentionLinkShowTooltip: true, + mentionLinkShowAvatar: false, + mentionLinkFadeDomain: true, + mentionLinkShowYous: false, + mentionLinkBoldenYou: true, hideFilteredStatuses: false, + // bad name: actually hides posts of muted USERS hideMutedPosts: false, + hideMutedThreads: true, + hideWordFilteredPosts: false, hidePostStats: false, + hideBotIndication: false, hideSitename: false, hideUserStats: false, + muteBotStatuses: false, loginMethod: 'password', logo: '/static/logo.svg', logoMargin: '.2em', logoMask: true, logoLeft: false, + disableUpdateNotification: false, minimalScopesMode: false, nsfwCensorImage: undefined, postContentType: 'text/plain', @@ -43,12 +90,18 @@ const defaultState = { theme: 'pleroma-dark', virtualScrolling: true, sensitiveByDefault: false, + conversationDisplay: 'linear', + conversationTreeAdvanced: false, + conversationOtherRepliesButton: 'below', + conversationTreeFadeAncestors: false, + maxDepthInThread: 6, // Nasty stuff customEmoji: [], customEmojiFetched: false, - emoji: [], + emoji: {}, emojiFetched: false, + unicodeEmojiAnnotations: {}, pleromaBackend: true, postFormats: [], restrictedNicknames: [], @@ -80,16 +133,44 @@ const defaultState = { } } +const loadAnnotations = (lang) => { + return import( + /* webpackChunkName: "emoji-annotations/[request]" */ + `@kazvmoe-infra/unicode-emoji-json/annotations/${langCodeToCldrName(lang)}.json` + ) + .then(k => k.default) +} + +const injectAnnotations = (emoji, annotations) => { + const availableLangs = Object.keys(annotations) + + return { + ...emoji, + annotations: availableLangs.reduce((acc, cur) => { + acc[cur] = annotations[cur][emoji.replacement] + return acc + }, {}) + } +} + +const injectRegionalIndicators = groups => { + groups.symbols.push(...REGIONAL_INDICATORS) + return groups +} + const instance = { state: defaultState, mutations: { setInstanceOption (state, { name, value }) { if (typeof value !== 'undefined') { - set(state, name, value) + state[name] = value } }, setKnownDomains (state, domains) { state.knownDomains = domains + }, + setUnicodeEmojiAnnotations (state, { lang, annotations }) { + state.unicodeEmojiAnnotations[lang] = annotations } }, getters: { @@ -97,6 +178,56 @@ const instance = { return instanceDefaultProperties .map(key => [key, state[key]]) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) + }, + groupedCustomEmojis (state) { + const packsOf = emoji => { + return emoji.tags + .filter(k => k.startsWith('pack:')) + .map(k => k.slice(5)) // remove 'pack:' prefix + } + + return state.customEmoji + .reduce((res, emoji) => { + packsOf(emoji).forEach(packName => { + const packId = `custom-${packName}` + if (!res[packId]) { + res[packId] = ({ + id: packId, + text: packName, + image: emoji.imageUrl, + emojis: [] + }) + } + res[packId].emojis.push(emoji) + }) + return res + }, {}) + }, + standardEmojiList (state) { + return SORTED_EMOJI_GROUP_IDS + .map(groupId => (state.emoji[groupId] || []).map(k => injectAnnotations(k, state.unicodeEmojiAnnotations))) + .reduce((a, b) => a.concat(b), []) + }, + standardEmojiGroupList (state) { + return SORTED_EMOJI_GROUP_IDS.map(groupId => ({ + id: groupId, + emojis: (state.emoji[groupId] || []).map(k => injectAnnotations(k, state.unicodeEmojiAnnotations)) + })) + }, + instanceDomain (state) { + return new URL(state.server).hostname + }, + remoteInteractionLink (state) { + const server = state.server.endsWith('/') ? state.server.slice(0, -1) : state.server + const link = server + REMOTE_INTERACTION_URL + + return ({ statusId, nickname }) => { + if (statusId) { + return `${link}?status_id=${statusId}` + } else { + return `${link}?nickname=${nickname}` + } + } } }, actions: { @@ -118,32 +249,52 @@ const instance = { }, async getStaticEmoji ({ commit }) { try { - const res = await window.fetch('/static/emoji.json') - if (res.ok) { - const values = await res.json() - const emoji = Object.keys(values).map((key) => { - return { - displayText: key, - imageUrl: false, - replacement: values[key] - } - }).sort((a, b) => a.name > b.name ? 1 : -1) - commit('setInstanceOption', { name: 'emoji', value: emoji }) - } else { - throw (res) - } + const values = (await import(/* webpackChunkName: 'emoji' */ '../../static/emoji.json')).default + + const emoji = Object.keys(values).reduce((res, groupId) => { + res[groupId] = values[groupId].map(e => ({ + displayText: e.slug, + imageUrl: false, + replacement: e.emoji + })) + return res + }, {}) + commit('setInstanceOption', { name: 'emoji', value: injectRegionalIndicators(emoji) }) } catch (e) { console.warn("Can't load static emoji") console.warn(e) } }, + loadUnicodeEmojiData ({ commit, state }, language) { + const langList = ensureFinalFallback(language) + + return Promise.all( + langList + .map(async lang => { + if (!state.unicodeEmojiAnnotations[lang]) { + const annotations = await loadAnnotations(lang) + commit('setUnicodeEmojiAnnotations', { lang, annotations }) + } + })) + }, + async getCustomEmoji ({ commit, state }) { try { const res = await window.fetch('/api/pleroma/emoji.json') if (res.ok) { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result + const caseInsensitiveStrCmp = (a, b) => { + const la = a.toLowerCase() + const lb = b.toLowerCase() + return la > lb ? 1 : (la < lb ? -1 : 0) + } + const byPackThenByName = (a, b) => { + const packOf = emoji => (emoji.tags.filter(k => k.startsWith('pack:'))[0] || '').slice(5) + return caseInsensitiveStrCmp(packOf(a), packOf(b)) || caseInsensitiveStrCmp(a.displayText, b.displayText) + } + const emoji = Object.entries(values).map(([key, value]) => { const imageUrl = value.image_url return { @@ -154,7 +305,7 @@ const instance = { } // Technically could use tags but those are kinda useless right now, // should have been "pack" field, that would be more useful - }).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : -1) + }).sort(byPackThenByName) commit('setInstanceOption', { name: 'customEmoji', value: emoji }) } else { throw (res) diff --git a/src/modules/interface.js b/src/modules/interface.js index d6db32fd..a86193ea 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -1,5 +1,3 @@ -import { set, delete as del } from 'vue' - const defaultState = { settingsModalState: 'hidden', settingsModalLoaded: false, @@ -15,7 +13,7 @@ const defaultState = { window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') ) }, - mobileLayout: false, + layoutType: 'normal', globalNotices: [], layoutHeight: 0, lastTimeline: null @@ -29,18 +27,17 @@ const interfaceMod = { if (state.noticeClearTimeout) { clearTimeout(state.noticeClearTimeout) } - set(state.settings, 'currentSaveStateNotice', { error: false, data: success }) - set(state.settings, 'noticeClearTimeout', - setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000)) + state.settings.currentSaveStateNotice = { error: false, data: success } + state.settings.noticeClearTimeout = setTimeout(() => delete state.settings.currentSaveStateNotice, 2000) } else { - set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error }) + state.settings.currentSaveStateNotice = { error: true, errorData: error } } }, setNotificationPermission (state, permission) { state.notificationPermission = permission }, - setMobileLayout (state, value) { - state.mobileLayout = value + setLayoutType (state, value) { + state.layoutType = value }, closeSettingsModal (state) { state.settingsModalState = 'hidden' @@ -75,6 +72,9 @@ const interfaceMod = { setLayoutHeight (state, value) { state.layoutHeight = value }, + setLayoutWidth (state, value) { + state.layoutWidth = value + }, setLastTimeline (state, value) { state.lastTimeline = value } @@ -89,9 +89,6 @@ const interfaceMod = { setNotificationPermission ({ commit }, permission) { commit('setNotificationPermission', permission) }, - setMobileLayout ({ commit }, value) { - commit('setMobileLayout', value) - }, closeSettingsModal ({ commit }) { commit('closeSettingsModal') }, @@ -109,7 +106,7 @@ const interfaceMod = { commit('openSettingsModal') }, pushGlobalNotice ( - { commit, dispatch }, + { commit, dispatch, state }, { messageKey, messageArgs = {}, @@ -121,11 +118,14 @@ const interfaceMod = { messageArgs, level } + commit('pushGlobalNotice', notice) + // Adding a new element to array wraps it in a Proxy, which breaks the comparison + // TODO: Generate UUID or something instead or relying on !== operator? + const newNotice = state.globalNotices[state.globalNotices.length - 1] if (timeout) { - setTimeout(() => dispatch('removeGlobalNotice', notice), timeout) + setTimeout(() => dispatch('removeGlobalNotice', newNotice), timeout) } - commit('pushGlobalNotice', notice) - return notice + return newNotice }, removeGlobalNotice ({ commit }, notice) { commit('removeGlobalNotice', notice) @@ -133,6 +133,24 @@ const interfaceMod = { setLayoutHeight ({ commit }, value) { commit('setLayoutHeight', value) }, + // value is optional, assuming it was cached prior + setLayoutWidth ({ commit, state, rootGetters, rootState }, value) { + let width = value + if (value !== undefined) { + commit('setLayoutWidth', value) + } else { + width = state.layoutWidth + } + const mobileLayout = width <= 800 + const normalOrMobile = mobileLayout ? 'mobile' : 'normal' + const { thirdColumnMode } = rootGetters.mergedConfig + if (thirdColumnMode === 'none' || !rootState.users.currentUser) { + commit('setLayoutType', normalOrMobile) + } else { + const wideLayout = width >= 1300 + commit('setLayoutType', wideLayout ? 'wide' : normalOrMobile) + } + }, setLastTimeline ({ commit }, value) { commit('setLastTimeline', value) } diff --git a/src/modules/lists.js b/src/modules/lists.js new file mode 100644 index 00000000..22fed800 --- /dev/null +++ b/src/modules/lists.js @@ -0,0 +1,130 @@ +import { remove, find } from 'lodash' + +export const defaultState = { + allLists: [], + allListsObject: {} +} + +export const mutations = { + setLists (state, value) { + state.allLists = value + }, + setList (state, { listId, title }) { + if (!state.allListsObject[listId]) { + state.allListsObject[listId] = { accountIds: [] } + } + state.allListsObject[listId].title = title + + const entry = find(state.allLists, { id: listId }) + if (!entry) { + state.allLists.push({ id: listId, title }) + } else { + entry.title = title + } + }, + setListAccounts (state, { listId, accountIds }) { + if (!state.allListsObject[listId]) { + state.allListsObject[listId] = { accountIds: [] } + } + state.allListsObject[listId].accountIds = accountIds + }, + addListAccount (state, { listId, accountId }) { + if (!state.allListsObject[listId]) { + state.allListsObject[listId] = { accountIds: [] } + } + state.allListsObject[listId].accountIds.push(accountId) + }, + removeListAccount (state, { listId, accountId }) { + if (!state.allListsObject[listId]) { + state.allListsObject[listId] = { accountIds: [] } + } + const { accountIds } = state.allListsObject[listId] + const set = new Set(accountIds) + set.delete(accountId) + state.allListsObject[listId].accountIds = [...set] + }, + deleteList (state, { listId }) { + delete state.allListsObject[listId] + remove(state.allLists, list => list.id === listId) + } +} + +const actions = { + setLists ({ commit }, value) { + commit('setLists', value) + }, + createList ({ rootState, commit }, { title }) { + return rootState.api.backendInteractor.createList({ title }) + .then((list) => { + commit('setList', { listId: list.id, title }) + return list + }) + }, + fetchList ({ rootState, commit }, { listId }) { + return rootState.api.backendInteractor.getList({ listId }) + .then((list) => commit('setList', { listId: list.id, title: list.title })) + }, + fetchListAccounts ({ rootState, commit }, { listId }) { + return rootState.api.backendInteractor.getListAccounts({ listId }) + .then((accountIds) => commit('setListAccounts', { listId, accountIds })) + }, + setList ({ rootState, commit }, { listId, title }) { + rootState.api.backendInteractor.updateList({ listId, title }) + commit('setList', { listId, title }) + }, + setListAccounts ({ rootState, commit }, { listId, accountIds }) { + const saved = rootState.lists.allListsObject[listId].accountIds || [] + const added = accountIds.filter(id => !saved.includes(id)) + const removed = saved.filter(id => !accountIds.includes(id)) + commit('setListAccounts', { listId, accountIds }) + if (added.length > 0) { + rootState.api.backendInteractor.addAccountsToList({ listId, accountIds: added }) + } + if (removed.length > 0) { + rootState.api.backendInteractor.removeAccountsFromList({ listId, accountIds: removed }) + } + }, + addListAccount ({ rootState, commit }, { listId, accountId }) { + return rootState + .api + .backendInteractor + .addAccountsToList({ listId, accountIds: [accountId] }) + .then((result) => { + commit('addListAccount', { listId, accountId }) + return result + }) + }, + removeListAccount ({ rootState, commit }, { listId, accountId }) { + return rootState + .api + .backendInteractor + .removeAccountsFromList({ listId, accountIds: [accountId] }) + .then((result) => { + commit('removeListAccount', { listId, accountId }) + return result + }) + }, + deleteList ({ rootState, commit }, { listId }) { + rootState.api.backendInteractor.deleteList({ listId }) + commit('deleteList', { listId }) + } +} + +export const getters = { + findListTitle: state => id => { + if (!state.allListsObject[id]) return + return state.allListsObject[id].title + }, + findListAccounts: state => id => { + return [...state.allListsObject[id].accountIds] + } +} + +const lists = { + state: defaultState, + mutations, + actions, + getters +} + +export default lists diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js index 721c25e6..ebcba01d 100644 --- a/src/modules/media_viewer.js +++ b/src/modules/media_viewer.js @@ -1,4 +1,5 @@ import fileTypeService from '../services/file_type/file_type.service.js' +const supportedTypes = new Set(['image', 'video', 'audio', 'flash']) const mediaViewer = { state: { @@ -10,7 +11,7 @@ const mediaViewer = { setMedia (state, media) { state.media = media }, - setCurrent (state, index) { + setCurrentMedia (state, index) { state.activated = true state.currentIndex = index }, @@ -22,13 +23,13 @@ const mediaViewer = { setMedia ({ commit }, attachments) { const media = attachments.filter(attachment => { const type = fileTypeService.fileType(attachment.mimetype) - return type === 'image' || type === 'video' || type === 'audio' + return supportedTypes.has(type) }) commit('setMedia', media) }, - setCurrent ({ commit, state }, current) { + setCurrentMedia ({ commit, state }, current) { const index = state.media.indexOf(current) - commit('setCurrent', index || 0) + commit('setCurrentMedia', index || 0) }, closeMediaViewer ({ commit }) { commit('close') diff --git a/src/modules/oauth.js b/src/modules/oauth.js index a2a83450..038bc3f3 100644 --- a/src/modules/oauth.js +++ b/src/modules/oauth.js @@ -1,5 +1,3 @@ -import { delete as del } from 'vue' - const oauth = { state: { clientId: false, @@ -29,7 +27,7 @@ const oauth = { state.userToken = false // state.token is userToken with older name, coming from persistent state // let's clear it as well, since it is being used as a fallback of state.userToken - del(state, 'token') + delete state.token } }, getters: { diff --git a/src/modules/polls.js b/src/modules/polls.js index 92b89a06..1c4f98a4 100644 --- a/src/modules/polls.js +++ b/src/modules/polls.js @@ -1,5 +1,4 @@ import { merge } from 'lodash' -import { set } from 'vue' const polls = { state: { @@ -13,25 +12,25 @@ const polls = { // Make expired-state change trigger re-renders properly poll.expired = Date.now() > Date.parse(poll.expires_at) if (existingPoll) { - set(state.pollsObject, poll.id, merge(existingPoll, poll)) + state.pollsObject[poll.id] = merge(existingPoll, poll) } else { - set(state.pollsObject, poll.id, poll) + state.pollsObject[poll.id] = poll } }, trackPoll (state, pollId) { const currentValue = state.trackedPolls[pollId] if (currentValue) { - set(state.trackedPolls, pollId, currentValue + 1) + state.trackedPolls[pollId] = currentValue + 1 } else { - set(state.trackedPolls, pollId, 1) + state.trackedPolls[pollId] = 1 } }, untrackPoll (state, pollId) { const currentValue = state.trackedPolls[pollId] if (currentValue) { - set(state.trackedPolls, pollId, currentValue - 1) + state.trackedPolls[pollId] = currentValue - 1 } else { - set(state.trackedPolls, pollId, 0) + state.trackedPolls[pollId] = 0 } } }, diff --git a/src/modules/reports.js b/src/modules/reports.js index fea83e5f..925792c0 100644 --- a/src/modules/reports.js +++ b/src/modules/reports.js @@ -2,20 +2,29 @@ import filter from 'lodash/filter' const reports = { state: { - userId: null, - statuses: [], - preTickedIds: [], - modalActivated: false + reportModal: { + userId: null, + statuses: [], + preTickedIds: [], + activated: false + }, + reports: {} }, mutations: { openUserReportingModal (state, { userId, statuses, preTickedIds }) { - state.userId = userId - state.statuses = statuses - state.preTickedIds = preTickedIds - state.modalActivated = true + state.reportModal.userId = userId + state.reportModal.statuses = statuses + state.reportModal.preTickedIds = preTickedIds + state.reportModal.activated = true }, closeUserReportingModal (state) { - state.modalActivated = false + state.reportModal.activated = false + }, + setReportState (reportsState, { id, state }) { + reportsState.reports[id].state = state + }, + addReport (state, report) { + state.reports[report.id] = report } }, actions: { @@ -31,6 +40,23 @@ const reports = { }, closeUserReportingModal ({ commit }) { commit('closeUserReportingModal') + }, + setReportState ({ commit, dispatch, rootState }, { id, state }) { + const oldState = rootState.reports.reports[id].state + commit('setReportState', { id, state }) + rootState.api.backendInteractor.setReportState({ id, state }).catch(e => { + console.error('Failed to set report state', e) + dispatch('pushGlobalNotice', { + level: 'error', + messageKey: 'general.generic_error_message', + messageArgs: [e.message], + timeout: 5000 + }) + commit('setReportState', { id, state: oldState }) + }) + }, + addReport ({ commit }, report) { + commit('addReport', report) } } } diff --git a/src/modules/serverSideConfig.js b/src/modules/serverSideConfig.js new file mode 100644 index 00000000..476263bc --- /dev/null +++ b/src/modules/serverSideConfig.js @@ -0,0 +1,140 @@ +import { get, set } from 'lodash' + +const defaultApi = ({ rootState, commit }, { path, value }) => { + const params = {} + set(params, path, value) + return rootState + .api + .backendInteractor + .updateProfile({ params }) + .then(result => { + commit('addNewUsers', [result]) + commit('setCurrentUser', result) + }) +} + +const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => { + const settings = {} + set(settings, path, value) + return rootState + .api + .backendInteractor + .updateNotificationSettings({ settings }) + .then(result => { + if (result.status === 'success') { + commit('confirmServerSideOption', { name, value }) + } else { + commit('confirmServerSideOption', { name, value: oldValue }) + } + }) +} + +/** + * Map that stores relation between path for reading (from user profile), + * for writing (into API) an what API to use. + * + * Shorthand - instead of { get, set, api? } object it's possible to use string + * in case default api is used and get = set + * + * If no api is specified, defaultApi is used (see above) + */ +export const settingsMap = { + defaultScope: 'source.privacy', + defaultNSFW: 'source.sensitive', // BROKEN: pleroma/pleroma#2837 + stripRichContent: { + get: 'source.pleroma.no_rich_text', + set: 'no_rich_text' + }, + // Privacy + locked: 'locked', + acceptChatMessages: { + get: 'pleroma.accepts_chat_messages', + set: 'accepts_chat_messages' + }, + allowFollowingMove: { + get: 'pleroma.allow_following_move', + set: 'allow_following_move' + }, + discoverable: { + get: 'source.pleroma.discoverable', + set: 'discoverable' + }, + hideFavorites: { + get: 'pleroma.hide_favorites', + set: 'hide_favorites' + }, + hideFollowers: { + get: 'pleroma.hide_followers', + set: 'hide_followers' + }, + hideFollows: { + get: 'pleroma.hide_follows', + set: 'hide_follows' + }, + hideFollowersCount: { + get: 'pleroma.hide_followers_count', + set: 'hide_followers_count' + }, + hideFollowsCount: { + get: 'pleroma.hide_follows_count', + set: 'hide_follows_count' + }, + // NotificationSettingsAPIs + webPushHideContents: { + get: 'pleroma.notification_settings.hide_notification_contents', + set: 'hide_notification_contents', + api: notificationsApi + }, + blockNotificationsFromStrangers: { + get: 'pleroma.notification_settings.block_from_strangers', + set: 'block_from_strangers', + api: notificationsApi + } +} + +export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null])) + +const serverSideConfig = { + state: { ...defaultState }, + mutations: { + confirmServerSideOption (state, { name, value }) { + set(state, name, value) + }, + wipeServerSideOption (state, { name }) { + set(state, name, null) + }, + wipeAllServerSideOptions (state) { + Object.keys(settingsMap).forEach(key => { + set(state, key, null) + }) + }, + // Set the settings based on their path location + setCurrentUser (state, user) { + Object.entries(settingsMap).forEach((map) => { + const [name, value] = map + const { get: path = value } = value + set(state, name, get(user._original, path)) + }) + } + }, + actions: { + setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) { + const oldValue = get(state, name) + const map = settingsMap[name] + if (!map) throw new Error('Invalid server-side setting') + const { set: path = map, api = defaultApi } = map + commit('wipeServerSideOption', { name }) + + api({ rootState, commit }, { path, value, oldValue }) + .catch((e) => { + console.warn('Error setting server-side option:', e) + commit('confirmServerSideOption', { name, value: oldValue }) + }) + }, + logout ({ commit }) { + commit('wipeAllServerSideOptions') + } + } +} + +export default serverSideConfig diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js new file mode 100644 index 00000000..c933ce8d --- /dev/null +++ b/src/modules/serverSideStorage.js @@ -0,0 +1,436 @@ +import { toRaw } from 'vue' +import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight, uniqWith } from 'lodash' +import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js' + +export const VERSION = 1 +export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically + +export const COMMAND_TRIM_FLAGS = 1000 +export const COMMAND_TRIM_FLAGS_AND_RESET = 1001 + +export const defaultState = { + // do we need to update data on server? + dirty: false, + // storage of flags - stuff that can only be set and incremented + flagStorage: { + updateCounter: 0, // Counter for most recent update notification seen + reset: 0 // special flag that can be used to force-reset all flags, debug purposes only + // special reset codes: + // 1000: trim keys to those known by currently running FE + // 1001: same as above + reset everything to 0 + }, + prefsStorage: { + _journal: [], + simple: { + dontShowUpdateNotifs: false, + collapseNav: false + }, + collections: { + pinnedNavItems: ['home', 'dms', 'chats'] + } + }, + // raw data + raw: null, + // local cache + cache: null +} + +export const newUserFlags = { + ...defaultState.flagStorage, + updateCounter: CURRENT_UPDATE_COUNTER // new users don't need to see update notification +} + +export const _moveItemInArray = (array, value, movement) => { + const oldIndex = array.indexOf(value) + const newIndex = oldIndex + movement + const newArray = [...array] + // remove old + newArray.splice(oldIndex, 1) + // add new + newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value) + return newArray +} + +const _wrapData = (data, userName) => ({ + ...data, + _user: userName, + _timestamp: Date.now(), + _version: VERSION +}) + +const _checkValidity = (data) => data._timestamp > 0 && data._version > 0 + +const _verifyPrefs = (state) => { + state.prefsStorage = state.prefsStorage || { + simple: {}, + collections: {} + } + Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => { + if (typeof v === 'number' || typeof v === 'boolean') return + console.warn(`Preference simple.${k} as invalid type, reinitializing`) + set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k]) + }) + Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => { + if (Array.isArray(v)) return + console.warn(`Preference collections.${k} as invalid type, reinitializing`) + set(state.prefsStorage.collections, k, defaultState.prefsStorage.collections[k]) + }) +} + +export const _getRecentData = (cache, live) => { + const result = { recent: null, stale: null, needUpload: false } + const cacheValid = _checkValidity(cache || {}) + const liveValid = _checkValidity(live || {}) + if (!liveValid && cacheValid) { + result.needUpload = true + console.debug('Nothing valid stored on server, assuming cache to be source of truth') + result.recent = cache + result.stale = live + } else if (!cacheValid && liveValid) { + console.debug('Valid storage on server found, no local cache found, using live as source of truth') + result.recent = live + result.stale = cache + } else if (cacheValid && liveValid) { + console.debug('Both sources have valid data, figuring things out...') + if (live._timestamp === cache._timestamp && live._version === cache._version) { + console.debug('Same version/timestamp on both source, source of truth irrelevant') + result.recent = cache + result.stale = live + } else { + console.debug('Different timestamp, figuring out which one is more recent') + if (live._timestamp < cache._timestamp) { + result.recent = cache + result.stale = live + } else { + result.recent = live + result.stale = cache + } + } + } else { + console.debug('Both sources are invalid, start from scratch') + result.needUpload = true + } + return result +} + +export const _getAllFlags = (recent, stale) => { + return Array.from(new Set([ + ...Object.keys(toRaw((recent || {}).flagStorage || {})), + ...Object.keys(toRaw((stale || {}).flagStorage || {})) + ])) +} + +export const _mergeFlags = (recent, stale, allFlagKeys) => { + if (!stale.flagStorage) return recent.flagStorage + if (!recent.flagStorage) return stale.flagStorage + return Object.fromEntries(allFlagKeys.map(flag => { + const recentFlag = recent.flagStorage[flag] + const staleFlag = stale.flagStorage[flag] + // use flag that is of higher value + return [flag, Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0)] + })) +} + +const _mergeJournal = (...journals) => { + // Ignore invalid journal entries + const allJournals = flatten( + journals.map(j => Array.isArray(j) ? j : []) + ).filter(entry => + Object.prototype.hasOwnProperty.call(entry, 'path') && + Object.prototype.hasOwnProperty.call(entry, 'operation') && + Object.prototype.hasOwnProperty.call(entry, 'args') && + Object.prototype.hasOwnProperty.call(entry, 'timestamp') + ) + const grouped = groupBy(allJournals, 'path') + const trimmedGrouped = Object.entries(grouped).map(([path, journal]) => { + // side effect + journal.sort((a, b) => a.timestamp > b.timestamp ? 1 : -1) + + if (path.startsWith('collections')) { + const lastRemoveIndex = findLastIndex(journal, ({ operation }) => operation === 'removeFromCollection') + // everything before last remove is unimportant + let remainder + if (lastRemoveIndex > 0) { + remainder = journal.slice(lastRemoveIndex) + } else { + // everything else doesn't need trimming + remainder = journal + } + return uniqWith(remainder, (a, b) => { + if (a.path !== b.path) { return false } + if (a.operation !== b.operation) { return false } + if (a.operation === 'addToCollection') { + return a.args[0] === b.args[0] + } + return false + }) + } else if (path.startsWith('simple')) { + // Only the last record is important + return takeRight(journal) + } else { + return journal + } + }) + return flatten(trimmedGrouped) + .sort((a, b) => a.timestamp > b.timestamp ? 1 : -1) +} + +export const _mergePrefs = (recent, stale, allFlagKeys) => { + if (!stale) return recent + if (!recent) return stale + const { _journal: recentJournal, ...recentData } = recent + const { _journal: staleJournal } = stale + /** Journal entry format: + * path: path to entry in prefsStorage + * timestamp: timestamp of the change + * operation: operation type + * arguments: array of arguments, depends on operation type + * + * currently only supported operation type is "set" which just sets the value + * to requested one. Intended only to be used with simple preferences (boolean, number) + * shouldn't be used with collections! + */ + const resultOutput = { ...recentData } + const totalJournal = _mergeJournal(staleJournal, recentJournal) + totalJournal.forEach(({ path, timestamp, operation, command, args }) => { + if (path.startsWith('_')) { + console.error(`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`) + return + } + switch (operation) { + case 'set': + set(resultOutput, path, args[0]) + break + case 'addToCollection': + set(resultOutput, path, Array.from(new Set(get(resultOutput, path)).add(args[0]))) + break + case 'removeFromCollection': { + const newSet = new Set(get(resultOutput, path)) + newSet.delete(args[0]) + set(resultOutput, path, Array.from(newSet)) + break + } + case 'reorderCollection': { + const [value, movement] = args + set(resultOutput, path, _moveItemInArray(get(resultOutput, path), value, movement)) + break + } + default: + console.error(`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`) + } + }) + return { ...resultOutput, _journal: totalJournal } +} + +export const _resetFlags = (totalFlags, knownKeys = defaultState.flagStorage) => { + let result = { ...totalFlags } + const allFlagKeys = Object.keys(totalFlags) + // flag reset functionality + if (totalFlags.reset >= COMMAND_TRIM_FLAGS && totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET) { + console.debug('Received command to trim the flags') + const knownKeysSet = new Set(Object.keys(knownKeys)) + + // Trim + result = {} + allFlagKeys.forEach(flag => { + if (knownKeysSet.has(flag)) { + result[flag] = totalFlags[flag] + } + }) + + // Reset + if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) { + // 1001 - and reset everything to 0 + console.debug('Received command to reset the flags') + Object.keys(knownKeys).forEach(flag => { result[flag] = 0 }) + } + } else if (totalFlags.reset > 0 && totalFlags.reset < 9000) { + console.debug('Received command to reset the flags') + allFlagKeys.forEach(flag => { result[flag] = 0 }) + } + result.reset = 0 + return result +} + +export const _doMigrations = (cache) => { + if (!cache) return cache + + if (cache._version < VERSION) { + console.debug('Local cached data has older version, seeing if there any migrations that can be applied') + + // no migrations right now since we only have one version + console.debug('No migrations found') + } + + if (cache._version > VERSION) { + console.debug('Local cached data has newer version, seeing if there any reverse migrations that can be applied') + + // no reverse migrations right now but we leave a possibility of loading a hotpatch if need be + if (window._PLEROMA_HOTPATCH) { + if (window._PLEROMA_HOTPATCH.reverseMigrations) { + console.debug('Found hotpatch migration, applying') + return window._PLEROMA_HOTPATCH.reverseMigrations.call({}, 'serverSideStorage', { from: cache._version, to: VERSION }, cache) + } + } + } + + return cache +} + +export const mutations = { + clearServerSideStorage (state, userData) { + state = { ...cloneDeep(defaultState) } + }, + setServerSideStorage (state, userData) { + const live = userData.storage + state.raw = live + let cache = state.cache + if (cache && cache._user !== userData.fqn) { + console.warn('cache belongs to another user! reinitializing local cache!') + cache = null + } + + cache = _doMigrations(cache) + + let { recent, stale, needsUpload } = _getRecentData(cache, live) + + const userNew = userData.created_at > NEW_USER_DATE + const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage + let dirty = false + + if (recent === null) { + console.debug(`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`) + recent = _wrapData({ + flagStorage: { ...flagsTemplate }, + prefsStorage: { ...defaultState.prefsStorage } + }) + } + + if (!needsUpload && recent && stale) { + console.debug('Checking if data needs merging...') + // discarding timestamps and versions + const { _timestamp: _0, _version: _1, ...recentData } = recent + const { _timestamp: _2, _version: _3, ...staleData } = stale + dirty = !isEqual(recentData, staleData) + console.debug(`Data ${dirty ? 'needs' : 'doesn\'t need'} merging`) + } + + const allFlagKeys = _getAllFlags(recent, stale) + let totalFlags + let totalPrefs + if (dirty) { + // Merge the flags + console.debug('Merging the data...') + totalFlags = _mergeFlags(recent, stale, allFlagKeys) + _verifyPrefs(recent) + _verifyPrefs(stale) + totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage) + } else { + totalFlags = recent.flagStorage + totalPrefs = recent.prefsStorage + } + + totalFlags = _resetFlags(totalFlags) + + recent.flagStorage = { ...flagsTemplate, ...totalFlags } + recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs } + + state.dirty = dirty || needsUpload + state.cache = recent + // set local timestamp to smaller one if we don't have any changes + if (stale && recent && !state.dirty) { + state.cache._timestamp = Math.min(stale._timestamp, recent._timestamp) + } + state.flagStorage = state.cache.flagStorage + state.prefsStorage = state.cache.prefsStorage + }, + setFlag (state, { flag, value }) { + state.flagStorage[flag] = value + state.dirty = true + }, + setPreference (state, { path, value }) { + if (path.startsWith('_')) { + console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`) + return + } + set(state.prefsStorage, path, value) + state.prefsStorage._journal = [ + ...state.prefsStorage._journal, + { operation: 'set', path, args: [value], timestamp: Date.now() } + ] + state.dirty = true + }, + addCollectionPreference (state, { path, value }) { + if (path.startsWith('_')) { + console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`) + return + } + const collection = new Set(get(state.prefsStorage, path)) + collection.add(value) + set(state.prefsStorage, path, [...collection]) + state.prefsStorage._journal = [ + ...state.prefsStorage._journal, + { operation: 'addToCollection', path, args: [value], timestamp: Date.now() } + ] + state.dirty = true + }, + removeCollectionPreference (state, { path, value }) { + if (path.startsWith('_')) { + console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`) + return + } + const collection = new Set(get(state.prefsStorage, path)) + collection.delete(value) + set(state.prefsStorage, path, [...collection]) + state.prefsStorage._journal = [ + ...state.prefsStorage._journal, + { operation: 'removeFromCollection', path, args: [value], timestamp: Date.now() } + ] + state.dirty = true + }, + reorderCollectionPreference (state, { path, value, movement }) { + if (path.startsWith('_')) { + console.error(`tried to edit internal (starts with _) field '${path}', ignoring.`) + return + } + const collection = get(state.prefsStorage, path) + const newCollection = _moveItemInArray(collection, value, movement) + set(state.prefsStorage, path, newCollection) + state.prefsStorage._journal = [ + ...state.prefsStorage._journal, + { operation: 'arrangeCollection', path, args: [value], timestamp: Date.now() } + ] + state.dirty = true + }, + updateCache (state, { username }) { + state.prefsStorage._journal = _mergeJournal(state.prefsStorage._journal) + state.cache = _wrapData({ + flagStorage: toRaw(state.flagStorage), + prefsStorage: toRaw(state.prefsStorage) + }, username) + } +} + +const serverSideStorage = { + state: { + ...cloneDeep(defaultState) + }, + mutations, + actions: { + pushServerSideStorage ({ state, rootState, commit }, { force = false } = {}) { + const needPush = state.dirty || force + console.log(needPush) + if (!needPush) return + commit('updateCache', { username: rootState.users.currentUser.fqn }) + const params = { pleroma_settings_store: { 'pleroma-fe': state.cache } } + rootState.api.backendInteractor + .updateProfile({ params }) + .then((user) => { + commit('setServerSideStorage', user) + state.dirty = false + }) + } + } +} + +export default serverSideStorage diff --git a/src/modules/shout.js b/src/modules/shout.js index 507a4d83..88aefbfe 100644 --- a/src/modules/shout.js +++ b/src/modules/shout.js @@ -1,7 +1,8 @@ const shout = { state: { messages: [], - channel: { state: '' } + channel: { state: '' }, + joined: false }, mutations: { setChannel (state, channel) { @@ -13,11 +14,23 @@ const shout = { }, setMessages (state, messages) { state.messages = messages.slice(-19, 20) + }, + setJoined (state, joined) { + state.joined = joined } }, actions: { initializeShout (store, socket) { const channel = socket.channel('chat:public') + channel.joinPush.receive('ok', () => { + store.commit('setJoined', true) + }) + channel.onClose(() => { + store.commit('setJoined', false) + }) + channel.onError(() => { + store.commit('setJoined', false) + }) channel.on('new_msg', (msg) => { store.commit('addMessage', msg) }) diff --git a/src/modules/statusHistory.js b/src/modules/statusHistory.js new file mode 100644 index 00000000..db3d6d4b --- /dev/null +++ b/src/modules/statusHistory.js @@ -0,0 +1,25 @@ +const statusHistory = { + state: { + params: {}, + modalActivated: false + }, + mutations: { + openStatusHistoryModal (state, params) { + state.params = params + state.modalActivated = true + }, + closeStatusHistoryModal (state) { + state.modalActivated = false + } + }, + actions: { + openStatusHistoryModal ({ commit }, params) { + commit('openStatusHistoryModal', params) + }, + closeStatusHistoryModal ({ commit }) { + commit('closeStatusHistoryModal') + } + } +} + +export default statusHistory diff --git a/src/modules/statuses.js b/src/modules/statuses.js index ac5d25c4..5a5c7b1b 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -12,7 +12,6 @@ import { isArray, omitBy } from 'lodash' -import { set } from 'vue' import { isStatusNotification, isValidNotification, @@ -63,7 +62,8 @@ export const defaultState = () => ({ friends: emptyTl(), tag: emptyTl(), dms: emptyTl(), - bookmarks: emptyTl() + bookmarks: emptyTl(), + list: emptyTl() } }) @@ -92,7 +92,7 @@ const mergeOrAdd = (arr, obj, item) => { // This is a new item, prepare it prepareStatus(item) arr.push(item) - set(obj, item.id, item) + obj[item.id] = item return { item, new: true } } } @@ -131,7 +131,7 @@ const addStatusToGlobalStorage = (state, data) => { if (conversationsObject[conversationId]) { conversationsObject[conversationId].push(status) } else { - set(conversationsObject, conversationId, [status]) + conversationsObject[conversationId] = [status] } } return result @@ -246,10 +246,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } const processors = { - 'status': (status) => { + status: (status) => { addStatus(status, showImmediately) }, - 'retweet': (status) => { + edit: (status) => { + addStatus(status, showImmediately) + }, + retweet: (status) => { // RetweetedStatuses are never shown immediately const retweetedStatus = addStatus(status.retweeted_status, false, false) @@ -271,7 +274,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us retweet.retweeted_status = retweetedStatus }, - 'favorite': (favorite) => { + favorite: (favorite) => { // Only update if this is a new favorite. // Ignore our own favorites because we get info about likes as response to like request if (!state.favorites.has(favorite.id)) { @@ -279,7 +282,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us favoriteStatus(favorite) } }, - 'deletion': (deletion) => { + deletion: (deletion) => { const uri = deletion.uri const status = find(allStatuses, { uri }) if (!status) { @@ -293,10 +296,10 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us remove(timelineObject.visibleStatuses, { uri }) } }, - 'follow': (follow) => { + follow: (follow) => { // NOOP, it is known status but we don't do anything about it for now }, - 'default': (unknown) => { + default: (unknown) => { console.log('unknown status type') console.log(unknown) } @@ -304,7 +307,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us each(statuses, (status) => { const type = status.type - const processor = processors[type] || processors['default'] + const processor = processors[type] || processors.default processor(status) }) @@ -337,11 +340,16 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item } + if (notification.type === 'pleroma:report') { + dispatch('addReport', notification.report) + } + if (notification.type === 'pleroma:emoji_reaction') { dispatch('fetchEmojiReactionsBy', notification.status.id) } // Only add a new notification if we don't have one for the same action + // eslint-disable-next-line no-prototype-builtins if (!state.notifications.idStore.hasOwnProperty(notification.id)) { updateNotificationsMinMaxId(state, notification) @@ -523,7 +531,7 @@ export const mutations = { }, addEmojiReactionsBy (state, { id, emojiReactions, currentUser }) { const status = state.allStatusesObject[id] - set(status, 'emoji_reactions', emojiReactions) + status.emoji_reactions = emojiReactions }, addOwnReaction (state, { id, emoji, currentUser }) { const status = state.allStatusesObject[id] @@ -542,9 +550,9 @@ export const mutations = { // Update count of existing reaction if it exists, otherwise append at the end if (reactionIndex >= 0) { - set(status.emoji_reactions, reactionIndex, newReaction) + status.emoji_reactions[reactionIndex] = newReaction } else { - set(status, 'emoji_reactions', [...status.emoji_reactions, newReaction]) + status.emoji_reactions = [...status.emoji_reactions, newReaction] } }, removeOwnReaction (state, { id, emoji, currentUser }) { @@ -563,9 +571,9 @@ export const mutations = { } if (newReaction.count > 0) { - set(status.emoji_reactions, reactionIndex, newReaction) + status.emoji_reactions[reactionIndex] = newReaction } else { - set(status, 'emoji_reactions', status.emoji_reactions.filter(r => r.name !== emoji)) + status.emoji_reactions = status.emoji_reactions.filter(r => r.name !== emoji) } }, updateStatusWithPoll (state, { id, poll }) { @@ -601,6 +609,12 @@ const statuses = { return rootState.api.backendInteractor.fetchStatus({ id }) .then((status) => dispatch('addNewStatuses', { statuses: [status] })) }, + fetchStatusSource ({ rootState, dispatch }, status) { + return apiService.fetchStatusSource({ id: status.id, credentials: rootState.users.currentUser.credentials }) + }, + fetchStatusHistory ({ rootState, dispatch }, status) { + return apiService.fetchStatusHistory({ status }) + }, deleteStatus ({ rootState, commit }, status) { commit('setDeleted', { status }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) @@ -747,8 +761,8 @@ const statuses = { rootState.api.backendInteractor.fetchRebloggedByUsers({ id }) .then(rebloggedByUsers => commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser })) }, - search (store, { q, resolve, limit, offset, following }) { - return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following }) + search (store, { q, resolve, limit, offset, following, type }) { + return store.rootState.api.backendInteractor.search2({ q, resolve, limit, offset, following, type }) .then((data) => { store.commit('addNewUsers', data.accounts) store.commit('addNewStatuses', { statuses: data.statuses }) diff --git a/src/modules/users.js b/src/modules/users.js index fb92cc91..053e44b6 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,7 +1,7 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' +import { windowWidth, windowHeight } from '../services/window_utils/window_utils' import oauthApi from '../services/new_api/oauth.js' import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash' -import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' // TODO: Unify with mergeOrAdd in statuses.js @@ -15,10 +15,7 @@ export const mergeOrAdd = (arr, obj, item) => { } else { // This is a new item, prepare it arr.push(item) - set(obj, item.id, item) - if (item.screen_name && !item.screen_name.includes('@')) { - set(obj, item.screen_name.toLowerCase(), item) - } + obj[item.id] = item return { item, new: true } } } @@ -54,6 +51,16 @@ const unblockUser = (store, id) => { .then((relationship) => store.commit('updateUserRelationship', [relationship])) } +const removeUserFromFollowers = (store, id) => { + return store.rootState.api.backendInteractor.removeUserFromFollowers({ id }) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + +const editUserNote = (store, { id, comment }) => { + return store.rootState.api.backendInteractor.editUserNote({ id, comment }) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + const muteUser = (store, id) => { const predictedRelationship = store.state.relationships[id] || { id } predictedRelationship.muting = true @@ -103,23 +110,23 @@ export const mutations = { const user = state.usersObject[id] const tags = user.tags || [] const newTags = tags.concat([tag]) - set(user, 'tags', newTags) + user.tags = newTags }, untagUser (state, { user: { id }, tag }) { const user = state.usersObject[id] const tags = user.tags || [] const newTags = tags.filter(t => t !== tag) - set(user, 'tags', newTags) + user.tags = newTags }, updateRight (state, { user: { id }, right, value }) { const user = state.usersObject[id] - let newRights = user.rights + const newRights = user.rights newRights[right] = value - set(user, 'rights', newRights) + user.rights = newRights }, updateActivationStatus (state, { user: { id }, deactivated }) { const user = state.usersObject[id] - set(user, 'deactivated', deactivated) + user.deactivated = deactivated }, setCurrentUser (state, user) { state.lastLoginName = user.screen_name @@ -148,28 +155,35 @@ export const mutations = { clearFriends (state, userId) { const user = state.usersObject[userId] if (user) { - set(user, 'friendIds', []) + user.friendIds = [] } }, clearFollowers (state, userId) { const user = state.usersObject[userId] if (user) { - set(user, 'followerIds', []) + user.followerIds = [] } }, addNewUsers (state, users) { each(users, (user) => { if (user.relationship) { - set(state.relationships, user.relationship.id, user.relationship) + state.relationships[user.relationship.id] = user.relationship + } + const res = mergeOrAdd(state.users, state.usersObject, user) + const item = res.item + if (res.new && item.screen_name && !item.screen_name.includes('@')) { + state.usersByNameObject[item.screen_name.toLowerCase()] = item } - mergeOrAdd(state.users, state.usersObject, user) }) }, updateUserRelationship (state, relationships) { relationships.forEach((relationship) => { - set(state.relationships, relationship.id, relationship) + state.relationships[relationship.id] = relationship }) }, + updateUserInLists (state, { id, inLists }) { + state.usersObject[id].inLists = inLists + }, saveBlockIds (state, blockIds) { state.currentUser.blockIds = blockIds }, @@ -222,7 +236,7 @@ export const mutations = { }, setColor (state, { user: { id }, highlighted }) { const user = state.usersObject[id] - set(user, 'highlight', highlighted) + user.highlight = highlighted }, signUpPending (state) { state.signUpPending = true @@ -239,12 +253,10 @@ export const mutations = { export const getters = { findUser: state => query => { - const result = state.usersObject[query] - // In case it's a screen_name, we can try searching case-insensitive - if (!result && typeof query === 'string') { - return state.usersObject[query.toLowerCase()] - } - return result + return state.usersObject[query] + }, + findUserByName: state => query => { + return state.usersByNameObject[query.toLowerCase()] }, findUserByUrl: state => query => { return state.users @@ -263,6 +275,7 @@ export const defaultState = { currentUser: false, users: [], usersObject: {}, + usersByNameObject: {}, signUpPending: false, signUpErrors: [], relationships: {} @@ -285,12 +298,25 @@ const users = { return user }) }, + fetchUserByName (store, name) { + return store.rootState.api.backendInteractor.fetchUserByName({ name }) + .then((user) => { + store.commit('addNewUsers', [user]) + return user + }) + }, fetchUserRelationship (store, id) { if (store.state.currentUser) { store.rootState.api.backendInteractor.fetchUserRelationship({ id }) .then((relationships) => store.commit('updateUserRelationship', relationships)) } }, + fetchUserInLists (store, id) { + if (store.state.currentUser) { + store.rootState.api.backendInteractor.fetchUserInLists({ id }) + .then((inLists) => store.commit('updateUserInLists', { id, inLists })) + } + }, fetchBlocks (store) { return store.rootState.api.backendInteractor.fetchBlocks() .then((blocks) => { @@ -305,12 +331,18 @@ const users = { unblockUser (store, id) { return unblockUser(store, id) }, + removeUserFromFollowers (store, id) { + return removeUserFromFollowers(store, id) + }, blockUsers (store, ids = []) { return Promise.all(ids.map(id => blockUser(store, id))) }, unblockUsers (store, ids = []) { return Promise.all(ids.map(id => unblockUser(store, id))) }, + editUserNote (store, args) { + return editUserNote(store, args) + }, fetchMutes (store) { return store.rootState.api.backendInteractor.fetchMutes() .then((mutes) => { @@ -393,7 +425,7 @@ const users = { toggleActivationStatus ({ rootState, commit }, { user }) { const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser api({ user }) - .then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated })) + .then((user) => { const deactivated = !user.is_active; commit('updateActivationStatus', { user, deactivated }) }) }, registerPushNotifications (store) { const token = store.state.currentUser.credentials @@ -457,17 +489,17 @@ const users = { async signUp (store, userInfo) { store.commit('signUpPending') - let rootState = store.rootState + const rootState = store.rootState try { - let data = await rootState.api.backendInteractor.register( + const data = await rootState.api.backendInteractor.register( { params: { ...userInfo } } ) store.commit('signUpSuccess') store.commit('setToken', data.access_token) store.dispatch('loginUser', data.access_token) } catch (e) { - let errors = e.message + const errors = e.message store.commit('signUpFailure', errors) throw e } @@ -502,11 +534,15 @@ const users = { store.dispatch('stopFetchingTimeline', 'friends') store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) store.dispatch('stopFetchingNotifications') + store.dispatch('stopFetchingLists') store.dispatch('stopFetchingFollowRequests') store.commit('clearNotifications') store.commit('resetStatuses') store.dispatch('resetChats') store.dispatch('setLastTimeline', 'public-timeline') + store.dispatch('setLayoutWidth', windowWidth()) + store.dispatch('setLayoutHeight', windowHeight()) + store.commit('clearServerSideStorage') }) }, loginUser (store, accessToken) { @@ -523,6 +559,7 @@ const users = { user.muteIds = [] user.domainMutes = [] commit('setCurrentUser', user) + commit('setServerSideStorage', user) commit('addNewUsers', [user]) store.dispatch('fetchEmoji') @@ -532,6 +569,7 @@ const users = { // Set our new backend interactor commit('setBackendInteractor', backendInteractorService(accessToken)) + store.dispatch('pushServerSideStorage') if (user.token) { store.dispatch('setWsToken', user.token) @@ -551,8 +589,14 @@ const users = { store.dispatch('startFetchingChats') } + store.dispatch('startFetchingLists') + + if (user.locked) { + store.dispatch('startFetchingFollowRequests') + } + if (store.getters.mergedConfig.useStreamingApi) { - store.dispatch('fetchTimeline', 'friends', { since: null }) + store.dispatch('fetchTimeline', { timeline: 'friends', since: null }) store.dispatch('fetchNotifications', { since: null }) store.dispatch('enableMastoSockets', true).catch((error) => { console.error('Failed initializing MastoAPI Streaming socket', error) @@ -567,6 +611,9 @@ const users = { // Get user mutes store.dispatch('fetchMutes') + store.dispatch('setLayoutWidth', windowWidth()) + store.dispatch('setLayoutHeight', windowHeight()) + // Fetch our friends store.rootState.api.backendInteractor.fetchFriends({ id: user.id }) .then((friends) => commit('addNewUsers', friends)) diff --git a/src/panel.scss b/src/panel.scss new file mode 100644 index 00000000..a53e47c6 --- /dev/null +++ b/src/panel.scss @@ -0,0 +1,240 @@ +.panel { + position: relative; + display: flex; + flex-direction: column; + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + + &::after, + & { + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + } + + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 5; + box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.6); + box-shadow: var(--panelShadow); + pointer-events: none; + } +} + +.panel-body { + padding: var(--panel-body-padding, 0); + + &:empty::before { + content: "¯\\_(ã)_/¯"; // Could use words but it'd require translations + display: block; + margin: 1em; + text-align: center; + } + + > p { + line-height: 1.3; + padding: 1em; + margin: 0; + } +} + +.panel-heading, +.panel-footer { + --panel-heading-height-padding: 0.6em; + --__panel-heading-gap: 0.5em; + --__panel-heading-height: 3.2em; + --__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0)); + + position: relative; + box-sizing: border-box; + display: grid; + grid-auto-flow: column; + grid-template-columns: minmax(50%, 1fr); + grid-auto-columns: auto; + grid-column-gap: var(--__panel-heading-gap); + flex: none; + background-size: cover; + padding: var(--panel-heading-height-padding); + height: var(--__panel-heading-height); + line-height: var(--__panel-heading-height-inner); + z-index: 4; + + &.-flexible-height { + --__panel-heading-height: auto; + + &::after, + &::before { + display: none; + } + } + + &.-stub { + &, + &::after { + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + } + } + + &.-sticky { + position: sticky; + top: var(--navbar-height); + } + + &::after, + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + pointer-events: none; + } + + .title { + font-size: 1.3em; + } + + .alert { + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; + } + + &:not(.-flexible-height) { + > .button-default, + > .alert { + height: var(--__panel-heading-height-inner); + min-height: 0; + box-sizing: border-box; + margin: 0; + min-width: 1px; + padding-top: 0; + padding-bottom: 0; + align-self: stretch; + } + } +} + +// TODO Should refactor panels into separate component and utilize slots + +.panel-heading { + border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; + border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; + border-width: 0 0 1px 0; + align-items: start; + // panel theme + color: var(--panelText); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + + &::after { + background-color: $fallback--fg; + background-color: var(--panel, $fallback--fg); + z-index: -2; + border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; + border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; + box-shadow: var(--panelHeaderShadow); + } + + a, + .-link { + color: $fallback--link; + color: var(--panelLink, $fallback--link); + } + + .button-unstyled:hover, + a:hover { + i[class*=icon-], + .svg-inline--fa, + .iconLetter { + color: var(--panelText); + } + } + + .faint { + background-color: transparent; + color: $fallback--faint; + color: var(--panelFaint, $fallback--faint); + } + + .faint-link { + color: $fallback--faint; + color: var(--faintLink, $fallback--faint); + } + + &:not(.-flexible-height) { + > .button-default { + flex-shrink: 0; + + &, + i[class*=icon-] { + color: $fallback--text; + color: var(--btnPanelText, $fallback--text); + } + + &:active { + background-color: $fallback--fg; + background-color: var(--btnPressedPanel, $fallback--fg); + color: $fallback--text; + color: var(--btnPressedPanelText, $fallback--text); + } + + &:disabled { + color: $fallback--text; + color: var(--btnDisabledPanelText, $fallback--text); + } + + &.toggled { + color: $fallback--text; + color: var(--btnToggledPanelText, $fallback--text); + } + } + } + + .rightside-button { + align-self: stretch; + text-align: center; + width: var(--__panel-heading-height); + height: var(--__panel-heading-height); + margin: calc(-1 * var(--panel-heading-height-padding)) 0; + margin-right: calc(-1 * var(--__panel-heading-gap)); + + > button { + box-sizing: border-box; + padding: calc(1 * var(--panel-heading-height-padding)) 0; + height: 100%; + width: 100%; + text-align: center; + + svg { + font-size: 1.2em; + } + } + } + + .rightside-icon { + align-self: stretch; + text-align: center; + width: var(--__panel-heading-height); + margin-right: calc(-1 * var(--__panel-heading-gap)); + + svg { + font-size: 1.2em; + } + } +} + +.panel-footer { + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; + border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); + align-items: center; + border-width: 1px 0 0 0; + border-style: solid; + border-color: var(--border, $fallback--border); +} diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 436b8b0a..7174cc5d 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, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -9,6 +9,8 @@ const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' const CHANGE_EMAIL_URL = '/api/pleroma/change_email' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' +const MOVE_ACCOUNT_URL = '/api/pleroma/move_account' +const ALIASES_URL = '/api/pleroma/aliases' const TAG_USER_URL = '/api/pleroma/admin/users/tag' const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}` const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate' @@ -47,9 +49,16 @@ const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public' const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home' const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}` const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context` +const MASTODON_STATUS_SOURCE_URL = id => `/api/v1/statuses/${id}/source` +const MASTODON_STATUS_HISTORY_URL = id => `/api/v1/statuses/${id}/history` const MASTODON_USER_URL = '/api/v1/accounts' +const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` +const MASTODON_USER_IN_LISTS = id => `/api/v1/accounts/${id}/lists` +const MASTODON_LIST_URL = id => `/api/v1/lists/${id}` +const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}` +const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts` 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/' @@ -58,8 +67,10 @@ const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block` const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock` const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` +const MASTODON_REMOVE_USER_FROM_FOLLOWERS = id => `/api/v1/accounts/${id}/remove_from_followers` const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe` const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe` +const MASTODON_USER_NOTE_URL = id => `/api/v1/accounts/${id}/note` 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' @@ -74,23 +85,32 @@ const MASTODON_PIN_OWN_STATUS = id => `/api/v1/statuses/${id}/pin` const MASTODON_UNPIN_OWN_STATUS = id => `/api/v1/statuses/${id}/unpin` const MASTODON_MUTE_CONVERSATION = id => `/api/v1/statuses/${id}/mute` const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute` -const MASTODON_SEARCH_2 = `/api/v2/search` +const MASTODON_SEARCH_2 = '/api/v2/search' const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' +const MASTODON_LISTS_URL = '/api/v1/lists' const MASTODON_STREAMING = '/api/v1/streaming' const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' +const MASTODON_ANNOUNCEMENTS_URL = '/api/v1/announcements' +const MASTODON_ANNOUNCEMENTS_DISMISS_URL = id => `/api/v1/announcements/${id}/dismiss` 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_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 PLEROMA_ADMIN_REPORTS = '/api/pleroma/admin/reports' +const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups' +const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements' +const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements' +const PLEROMA_EDIT_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` +const PLEROMA_DELETE_ANNOUNCEMENT_URL = id => `/api/v1/pleroma/admin/announcements/${id}` const oldfetch = window.fetch -let fetch = (url, options) => { +const fetch = (url, options) => { options = options || {} const baseUrl = '' const fullUrl = baseUrl + url @@ -102,7 +122,7 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers = const options = { method, headers: { - 'Accept': 'application/json', + Accept: 'application/json', 'Content-Type': 'application/json', ...headers } @@ -151,9 +171,15 @@ const updateNotificationSettings = ({ credentials, settings }) => { }).then((data) => data.json()) } -const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => { +const updateProfileImages = ({ credentials, avatar = null, avatarName = null, banner = null, background = null }) => { const form = new FormData() - if (avatar !== null) form.append('avatar', avatar) + if (avatar !== null) { + if (avatarName !== null) { + form.append('avatar', avatar, avatarName) + } else { + 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, { @@ -191,6 +217,7 @@ const updateProfile = ({ credentials, params }) => { // homepage // location // token +// language const register = ({ params, credentials }) => { const { nickname, ...rest } = params return fetch(MASTODON_REGISTRATION_URL, { @@ -219,16 +246,16 @@ const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json()) const authHeaders = (accessToken) => { if (accessToken) { - return { 'Authorization': `Bearer ${accessToken}` } + return { Authorization: `Bearer ${accessToken}` } } else { return { } } } const followUser = ({ id, credentials, ...options }) => { - let url = MASTODON_FOLLOW_URL(id) + const url = MASTODON_FOLLOW_URL(id) const form = {} - if (options.reblogs !== undefined) { form['reblogs'] = options.reblogs } + if (options.reblogs !== undefined) { form.reblogs = options.reblogs } return fetch(url, { body: JSON.stringify(form), headers: { @@ -240,13 +267,20 @@ const followUser = ({ id, credentials, ...options }) => { } const unfollowUser = ({ id, credentials }) => { - let url = MASTODON_UNFOLLOW_URL(id) + const url = MASTODON_UNFOLLOW_URL(id) return fetch(url, { headers: authHeaders(credentials), method: 'POST' }).then((data) => data.json()) } +const fetchUserInLists = ({ id, credentials }) => { + const url = MASTODON_USER_IN_LISTS(id) + return fetch(url, { + headers: authHeaders(credentials) + }).then((data) => data.json()) +} + const pinOwnStatus = ({ id, credentials }) => { return promisedRequest({ url: MASTODON_PIN_OWN_STATUS(id), credentials, method: 'POST' }) .then((data) => parseStatus(data)) @@ -281,8 +315,26 @@ const unblockUser = ({ id, credentials }) => { }).then((data) => data.json()) } +const removeUserFromFollowers = ({ id, credentials }) => { + return fetch(MASTODON_REMOVE_USER_FROM_FOLLOWERS(id), { + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + +const editUserNote = ({ id, credentials, comment }) => { + return promisedRequest({ + url: MASTODON_USER_NOTE_URL(id), + credentials, + payload: { + comment + }, + method: 'POST' + }) +} + const approveUser = ({ id, credentials }) => { - let url = MASTODON_APPROVE_USER_URL(id) + const url = MASTODON_APPROVE_USER_URL(id) return fetch(url, { headers: authHeaders(credentials), method: 'POST' @@ -290,7 +342,7 @@ const approveUser = ({ id, credentials }) => { } const denyUser = ({ id, credentials }) => { - let url = MASTODON_DENY_USER_URL(id) + const url = MASTODON_DENY_USER_URL(id) return fetch(url, { headers: authHeaders(credentials), method: 'POST' @@ -298,13 +350,32 @@ const denyUser = ({ id, credentials }) => { } const fetchUser = ({ id, credentials }) => { - let url = `${MASTODON_USER_URL}/${id}` + const url = `${MASTODON_USER_URL}/${id}` return promisedRequest({ url, credentials }) .then((data) => parseUser(data)) } +const fetchUserByName = ({ name, credentials }) => { + return promisedRequest({ + url: MASTODON_USER_LOOKUP_URL, + credentials, + params: { acct: name } + }) + .then(data => data.id) + .catch(error => { + if (error && error.statusCode === 404) { + // Either the backend does not support lookup endpoint, + // or there is no user with such name. Fallback and treat name as id. + return name + } else { + throw error + } + }) + .then(id => fetchUser({ id, credentials })) +} + const fetchUserRelationship = ({ id, credentials }) => { - let url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}` + const url = `${MASTODON_USER_RELATIONSHIPS_URL}/?id=${id}` return fetch(url, { headers: authHeaders(credentials) }) .then((response) => { return new Promise((resolve, reject) => response.json() @@ -323,7 +394,7 @@ const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => { maxId && `max_id=${maxId}`, sinceId && `since_id=${sinceId}`, limit && `limit=${limit}`, - `with_relationships=true` + 'with_relationships=true' ].filter(_ => _).join('&') url = url + (args ? '?' + args : '') @@ -333,6 +404,7 @@ const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => { } const exportFriends = ({ id, credentials }) => { + // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { try { let friends = [] @@ -358,7 +430,7 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => { maxId && `max_id=${maxId}`, sinceId && `since_id=${sinceId}`, limit && `limit=${limit}`, - `with_relationships=true` + 'with_relationships=true' ].filter(_ => _).join('&') url += args ? '?' + args : '' @@ -374,8 +446,83 @@ const fetchFollowRequests = ({ credentials }) => { .then((data) => data.map(parseUser)) } +const fetchLists = ({ credentials }) => { + const url = MASTODON_LISTS_URL + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + +const createList = ({ title, credentials }) => { + const url = MASTODON_LISTS_URL + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'POST', + body: JSON.stringify({ title }) + }).then((data) => data.json()) +} + +const getList = ({ listId, credentials }) => { + const url = MASTODON_LIST_URL(listId) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + +const updateList = ({ listId, title, credentials }) => { + const url = MASTODON_LIST_URL(listId) + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'PUT', + body: JSON.stringify({ title }) + }) +} + +const getListAccounts = ({ listId, credentials }) => { + const url = MASTODON_LIST_ACCOUNTS_URL(listId) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => data.map(({ id }) => id)) +} + +const addAccountsToList = ({ listId, accountIds, credentials }) => { + const url = MASTODON_LIST_ACCOUNTS_URL(listId) + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'POST', + body: JSON.stringify({ account_ids: accountIds }) + }) +} + +const removeAccountsFromList = ({ listId, accountIds, credentials }) => { + const url = MASTODON_LIST_ACCOUNTS_URL(listId) + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + headers, + method: 'DELETE', + body: JSON.stringify({ account_ids: accountIds }) + }) +} + +const deleteList = ({ listId, credentials }) => { + const url = MASTODON_LIST_URL(listId) + return fetch(url, { + method: 'DELETE', + headers: authHeaders(credentials) + }) +} + const fetchConversation = ({ id, credentials }) => { - let urlContext = MASTODON_STATUS_CONTEXT_URL(id) + const urlContext = MASTODON_STATUS_CONTEXT_URL(id) return fetch(urlContext, { headers: authHeaders(credentials) }) .then((data) => { if (data.ok) { @@ -391,7 +538,7 @@ const fetchConversation = ({ id, credentials }) => { } const fetchStatus = ({ id, credentials }) => { - let url = MASTODON_STATUS_URL(id) + const url = MASTODON_STATUS_URL(id) return fetch(url, { headers: authHeaders(credentials) }) .then((data) => { if (data.ok) { @@ -403,6 +550,31 @@ const fetchStatus = ({ id, credentials }) => { .then((data) => parseStatus(data)) } +const fetchStatusSource = ({ id, credentials }) => { + const url = MASTODON_STATUS_SOURCE_URL(id) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => { + if (data.ok) { + return data + } + throw new Error('Error fetching source', data) + }) + .then((data) => data.json()) + .then((data) => parseSource(data)) +} + +const fetchStatusHistory = ({ status, credentials }) => { + const url = MASTODON_STATUS_HISTORY_URL(status.id) + return promisedRequest({ url, credentials }) + .then((data) => { + data.reverse() + return data.map((item) => { + item.originalStatus = status + return parseStatus(item) + }) + }) +} + const tagUser = ({ tag, credentials, user }) => { const screenName = user.screen_name const form = { @@ -415,7 +587,7 @@ const tagUser = ({ tag, credentials, user }) => { return fetch(TAG_USER_URL, { method: 'PUT', - headers: headers, + headers, body: JSON.stringify(form) }) } @@ -432,7 +604,7 @@ const untagUser = ({ tag, credentials, user }) => { return fetch(TAG_USER_URL, { method: 'DELETE', - headers: headers, + headers, body: JSON.stringify(body) }) } @@ -485,7 +657,7 @@ const deleteUser = ({ credentials, user }) => { return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, { method: 'DELETE', - headers: headers + headers }) } @@ -495,18 +667,21 @@ const fetchTimeline = ({ since = false, until = false, userId = false, + listId = false, tag = false, withMuted = false, - replyVisibility = 'all' + replyVisibility = 'all', + includeTypes = [] }) => { const timelineUrls = { public: MASTODON_PUBLIC_TIMELINE, friends: MASTODON_USER_HOME_TIMELINE_URL, dms: MASTODON_DIRECT_MESSAGES_TIMELINE_URL, notifications: MASTODON_USER_NOTIFICATIONS_URL, - 'publicAndExternal': MASTODON_PUBLIC_TIMELINE, + publicAndExternal: MASTODON_PUBLIC_TIMELINE, user: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL, + list: MASTODON_LIST_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, tag: MASTODON_TAG_TIMELINE_URL, bookmarks: MASTODON_BOOKMARK_TIMELINE_URL @@ -520,6 +695,10 @@ const fetchTimeline = ({ url = url(userId) } + if (timeline === 'list') { + url = url(listId) + } + if (since) { params.push(['since_id', since]) } @@ -544,6 +723,11 @@ const fetchTimeline = ({ if (replyVisibility !== 'all') { params.push(['reply_visibility', replyVisibility]) } + if (includeTypes.length > 0) { + includeTypes.forEach(type => { + params.push(['include_types[]', type]) + }) + } params.push(['limit', 20]) @@ -678,7 +862,7 @@ const postStatus = ({ form.append('preview', 'true') } - let postHeaders = authHeaders(credentials) + const postHeaders = authHeaders(credentials) if (idempotencyKey) { postHeaders['idempotency-key'] = idempotencyKey } @@ -694,6 +878,54 @@ const postStatus = ({ .then((data) => data.error ? data : parseStatus(data)) } +const editStatus = ({ + id, + credentials, + status, + spoilerText, + sensitive, + poll, + mediaIds = [], + contentType +}) => { + const form = new FormData() + const pollOptions = poll.options || [] + + form.append('status', status) + if (spoilerText) form.append('spoiler_text', spoilerText) + if (sensitive) form.append('sensitive', sensitive) + if (contentType) form.append('content_type', contentType) + mediaIds.forEach(val => { + form.append('media_ids[]', val) + }) + + if (pollOptions.some(option => option !== '')) { + const normalizedPoll = { + expires_in: poll.expiresIn, + multiple: poll.multiple + } + Object.keys(normalizedPoll).forEach(key => { + form.append(`poll[${key}]`, normalizedPoll[key]) + }) + + pollOptions.forEach(option => { + form.append('poll[options][]', option) + }) + } + + const putHeaders = authHeaders(credentials) + + return fetch(MASTODON_STATUS_URL(id), { + body: form, + method: 'PUT', + headers: putHeaders + }) + .then((response) => { + return response.json() + }) + .then((data) => data.error ? data : parseStatus(data)) +} + const deleteStatus = ({ id, credentials }) => { return fetch(MASTODON_DELETE_URL(id), { headers: authHeaders(credentials), @@ -782,6 +1014,49 @@ const changeEmail = ({ credentials, email, password }) => { .then((response) => response.json()) } +const moveAccount = ({ credentials, password, targetAccount }) => { + const form = new FormData() + + form.append('password', password) + form.append('target_account', targetAccount) + + return fetch(MOVE_ACCOUNT_URL, { + body: form, + method: 'POST', + headers: authHeaders(credentials) + }) + .then((response) => response.json()) +} + +const addAlias = ({ credentials, alias }) => { + return promisedRequest({ + url: ALIASES_URL, + method: 'PUT', + credentials, + payload: { alias } + }) +} + +const deleteAlias = ({ credentials, alias }) => { + return promisedRequest({ + url: ALIASES_URL, + method: 'DELETE', + credentials, + payload: { alias } + }) +} + +const listAliases = ({ credentials }) => { + return promisedRequest({ + url: ALIASES_URL, + method: 'GET', + credentials, + params: { + _cacheBooster: (new Date()).getTime() + } + }) +} + const changePassword = ({ credentials, password, newPassword, newPasswordConfirmation }) => { const form = new FormData() @@ -868,6 +1143,25 @@ const fetchBlocks = ({ credentials }) => { .then((users) => users.map(parseUser)) } +const addBackup = ({ credentials }) => { + return promisedRequest({ + url: PLEROMA_BACKUP_URL, + method: 'POST', + credentials + }) +} + +const listBackups = ({ credentials }) => { + return promisedRequest({ + url: PLEROMA_BACKUP_URL, + method: 'GET', + credentials, + params: { + _cacheBooster: (new Date()).getTime() + } + }) +} + const fetchOAuthTokens = ({ credentials }) => { const url = '/api/oauth_tokens.json' @@ -921,7 +1215,7 @@ const vote = ({ pollId, choices, credentials }) => { method: 'POST', credentials, payload: { - choices: choices + choices } }) } @@ -981,8 +1275,8 @@ const reportUser = ({ credentials, userId, statusIds, comment, forward }) => { url: MASTODON_REPORT_USER_URL, method: 'POST', payload: { - 'account_id': userId, - 'status_ids': statusIds, + account_id: userId, + status_ids: statusIds, comment, forward }, @@ -1002,9 +1296,9 @@ const searchUsers = ({ credentials, query }) => { .then((data) => data.map(parseUser)) } -const search2 = ({ credentials, q, resolve, limit, offset, following }) => { +const search2 = ({ credentials, q, resolve, limit, offset, following, type }) => { let url = MASTODON_SEARCH_2 - let params = [] + const params = [] if (q) { params.push(['q', encodeURIComponent(q)]) @@ -1026,9 +1320,13 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => { params.push(['following', true]) } + if (type) { + params.push(['following', type]) + } + params.push(['with_relationships', true]) - let queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') + const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') url += `?${queryString}` return fetch(url, { headers: authHeaders(credentials) }) @@ -1081,6 +1379,66 @@ const dismissNotification = ({ credentials, id }) => { }) } +const adminFetchAnnouncements = ({ credentials }) => { + return promisedRequest({ url: PLEROMA_ANNOUNCEMENTS_URL, credentials }) +} + +const fetchAnnouncements = ({ credentials }) => { + return promisedRequest({ url: MASTODON_ANNOUNCEMENTS_URL, credentials }) +} + +const dismissAnnouncement = ({ id, credentials }) => { + return promisedRequest({ + url: MASTODON_ANNOUNCEMENTS_DISMISS_URL(id), + credentials, + method: 'POST' + }) +} + +const announcementToPayload = ({ content, startsAt, endsAt, allDay }) => { + const payload = { content } + + if (typeof startsAt !== 'undefined') { + payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null + } + + if (typeof endsAt !== 'undefined') { + payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null + } + + if (typeof allDay !== 'undefined') { + payload.all_day = allDay + } + + return payload +} + +const postAnnouncement = ({ credentials, content, startsAt, endsAt, allDay }) => { + return promisedRequest({ + url: PLEROMA_POST_ANNOUNCEMENT_URL, + credentials, + method: 'POST', + payload: announcementToPayload({ content, startsAt, endsAt, allDay }) + }) +} + +const editAnnouncement = ({ id, credentials, content, startsAt, endsAt, allDay }) => { + return promisedRequest({ + url: PLEROMA_EDIT_ANNOUNCEMENT_URL(id), + credentials, + method: 'PATCH', + payload: announcementToPayload({ content, startsAt, endsAt, allDay }) + }) +} + +const deleteAnnouncement = ({ id, credentials }) => { + return promisedRequest({ + url: PLEROMA_DELETE_ANNOUNCEMENT_URL(id), + credentials, + method: 'DELETE' + }) +} + export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => { return Object.entries({ ...(credentials @@ -1098,7 +1456,8 @@ const MASTODON_STREAMING_EVENTS = new Set([ 'update', 'notification', 'delete', - 'filters_changed' + 'filters_changed', + 'status.update' ]) const PLEROMA_STREAMING_EVENTS = new Set([ @@ -1170,6 +1529,8 @@ export const handleMastoWS = (wsEvent) => { const data = payload ? JSON.parse(payload) : null if (event === 'update') { return { event, status: parseStatus(data) } + } else if (event === 'status.update') { + return { event, status: parseStatus(data) } } else if (event === 'notification') { return { event, notification: parseNotification(data) } } else if (event === 'pleroma:chat_update') { @@ -1182,12 +1543,12 @@ export const handleMastoWS = (wsEvent) => { } export const WSConnectionStatus = Object.freeze({ - 'JOINED': 1, - 'CLOSED': 2, - 'ERROR': 3, - 'DISABLED': 4, - 'STARTING': 5, - 'STARTING_INITIAL': 6 + JOINED: 1, + CLOSED: 2, + ERROR: 3, + DISABLED: 4, + STARTING: 5, + STARTING_INITIAL: 6 }) const chats = ({ credentials }) => { @@ -1225,11 +1586,11 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => { const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => { const payload = { - 'content': content + content } if (mediaId) { - payload['media_id'] = mediaId + payload.media_id = mediaId } const headers = {} @@ -1241,7 +1602,7 @@ const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credenti return promisedRequest({ url: PLEROMA_CHAT_MESSAGES_URL(id), method: 'POST', - payload: payload, + payload, credentials, headers }) @@ -1252,7 +1613,7 @@ const readChat = ({ id, lastReadId, credentials }) => { url: PLEROMA_CHAT_READ_URL(id), method: 'POST', payload: { - 'last_read_id': lastReadId + last_read_id: lastReadId }, credentials }) @@ -1266,12 +1627,46 @@ const deleteChatMessage = ({ chatId, messageId, credentials }) => { }) } +const setReportState = ({ id, state, credentials }) => { + // TODO: Can't use promisedRequest because on OK this does not return json + // See https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1322 + return fetch(PLEROMA_ADMIN_REPORTS, { + headers: { + ...authHeaders(credentials), + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: 'PATCH', + body: JSON.stringify({ + reports: [{ + id, + state + }] + }) + }) + .then(data => { + if (data.status >= 500) { + throw Error(data.statusText) + } else if (data.status >= 400) { + return data.json() + } + return data + }) + .then(data => { + if (data.errors) { + throw Error(data.errors[0].message) + } + }) +} + const apiService = { verifyCredentials, fetchTimeline, fetchPinnedStatuses, fetchConversation, fetchStatus, + fetchStatusSource, + fetchStatusHistory, fetchFriends, exportFriends, fetchFollowers, @@ -1283,7 +1678,10 @@ const apiService = { unmuteConversation, blockUser, unblockUser, + removeUserFromFollowers, + editUserNote, fetchUser, + fetchUserByName, fetchUserRelationship, favorite, unfavorite, @@ -1292,6 +1690,7 @@ const apiService = { bookmarkStatus, unbookmarkStatus, postStatus, + editStatus, deleteStatus, uploadMedia, setMediaDescription, @@ -1319,13 +1718,27 @@ const apiService = { importFollows, deleteAccount, changeEmail, + moveAccount, + addAlias, + deleteAlias, + listAliases, changePassword, settingsMFA, mfaDisableOTP, generateMfaBackupCodes, mfaSetupOTP, mfaConfirmOTP, + addBackup, + listBackups, fetchFollowRequests, + fetchLists, + createList, + getList, + updateList, + getListAccounts, + addAccountsToList, + removeAccountsFromList, + deleteList, approveUser, denyUser, suggestions, @@ -1351,7 +1764,15 @@ const apiService = { chatMessages, sendChatMessage, readChat, - deleteChatMessage + deleteChatMessage, + setReportState, + fetchUserInLists, + fetchAnnouncements, + dismissAnnouncement, + postAnnouncement, + editAnnouncement, + deleteAnnouncement, + adminFetchAnnouncements } 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 4a40f5b5..62ee8549 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -2,10 +2,11 @@ import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.servic import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js' import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service' +import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js' const backendInteractorService = credentials => ({ - startFetchingTimeline ({ timeline, store, userId = false, tag }) { - return timelineFetcher.startFetching({ timeline, store, credentials, userId, tag }) + startFetchingTimeline ({ timeline, store, userId = false, listId = false, tag }) { + return timelineFetcher.startFetching({ timeline, store, credentials, userId, listId, tag }) }, fetchTimeline (args) { @@ -24,6 +25,10 @@ const backendInteractorService = credentials => ({ return followRequestFetcher.startFetching({ store, credentials }) }, + startFetchingLists ({ store }) { + return listsFetcher.startFetching({ store, credentials }) + }, + startUserSocket ({ store }) { const serv = store.rootState.instance.server.replace('http', 'ws') const url = serv + getMastodonSocketURI({ credentials, stream: 'user' }) diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js index 92ff689d..eb26a0ab 100644 --- a/src/services/chat_service/chat_service.js +++ b/src/services/chat_service/chat_service.js @@ -7,7 +7,7 @@ const empty = (chatId) => { messages: [], newMessageCount: 0, lastSeenMessageId: '0', - chatId: chatId, + chatId, minId: undefined, maxId: undefined } @@ -101,7 +101,7 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => { storage.messages = storage.messages.filter(msg => msg.id !== message.id) } Object.assign(fakeMessage, message, { error: false }) - delete fakeMessage['fakeId'] + delete fakeMessage.fakeId storage.idIndex[fakeMessage.id] = fakeMessage delete storage.idIndex[message.fakeId] @@ -178,7 +178,7 @@ const getView = (storage) => { id: date.getTime().toString() }) - previousMessage['isTail'] = true + previousMessage.isTail = true currentMessageChainId = undefined afterDate = true } @@ -193,15 +193,15 @@ const getView = (storage) => { // end a message chian if ((nextMessage && nextMessage.account_id) !== message.account_id) { - object['isTail'] = true + 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 + object.isHead = true + object.messageChainId = currentMessageChainId } result.push(object) diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js index de6e0625..a8da1eed 100644 --- a/src/services/chat_utils/chat_utils.js +++ b/src/services/chat_utils/chat_utils.js @@ -25,7 +25,7 @@ export const buildFakeMessage = ({ content, chatId, attachments, userId, idempot chat_id: chatId, created_at: new Date(), id: `${new Date().getTime()}`, - attachments: attachments, + attachments, account_id: userId, idempotency_key: idempotencyKey, emojis: [], diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js index ec104269..47d6344e 100644 --- a/src/services/color_convert/color_convert.js +++ b/src/services/color_convert/color_convert.js @@ -144,11 +144,13 @@ export const invert = (rgb) => { */ export const hex2rgb = (hex) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } + : null } /** diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js index 8a6eba7e..8fa4f75b 100644 --- a/src/services/completion/completion.js +++ b/src/services/completion/completion.js @@ -35,7 +35,7 @@ export const addPositionToWords = (words) => { } export const splitByWhitespaceBoundary = (str) => { - let result = [] + const result = [] let currentWord = '' for (let i = 0; i < str.length; i++) { const currentChar = str[i] diff --git a/src/services/date_utils/date_utils.js b/src/services/date_utils/date_utils.js index 32e13bca..c93d2176 100644 --- a/src/services/date_utils/date_utils.js +++ b/src/services/date_utils/date_utils.js @@ -10,31 +10,29 @@ export const relativeTime = (date, nowThreshold = 1) => { if (typeof date === 'string') date = Date.parse(date) const round = Date.now() > date ? Math.floor : Math.ceil const d = Math.abs(Date.now() - date) - let r = { num: round(d / YEAR), key: 'time.years' } + const r = { num: round(d / YEAR), key: 'time.unit.years' } if (d < nowThreshold * SECOND) { r.num = 0 r.key = 'time.now' } else if (d < MINUTE) { r.num = round(d / SECOND) - r.key = 'time.seconds' + r.key = 'time.unit.seconds' } else if (d < HOUR) { r.num = round(d / MINUTE) - r.key = 'time.minutes' + r.key = 'time.unit.minutes' } else if (d < DAY) { r.num = round(d / HOUR) - r.key = 'time.hours' + r.key = 'time.unit.hours' } else if (d < WEEK) { r.num = round(d / DAY) - r.key = 'time.days' + r.key = 'time.unit.days' } else if (d < MONTH) { r.num = round(d / WEEK) - r.key = 'time.weeks' + r.key = 'time.unit.weeks' } else if (d < YEAR) { r.num = round(d / MONTH) - r.key = 'time.months' + r.key = 'time.unit.months' } - // Remove plural form when singular - if (r.num === 1) r.key = r.key.slice(0, -1) return r } diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 7025d803..ea138177 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -39,14 +39,17 @@ const qvitterStatusType = (status) => { export const parseUser = (data) => { const output = {} - const masto = data.hasOwnProperty('acct') + const masto = Object.prototype.hasOwnProperty.call(data, 'acct') // case for users in "mentions" property for statuses in MastoAPI - const mastoShort = masto && !data.hasOwnProperty('avatar') + const mastoShort = masto && !Object.prototype.hasOwnProperty.call(data, 'avatar') + output.inLists = null output.id = String(data.id) + output._original = data // used for server-side settings if (masto) { output.screen_name = data.acct + output.fqn = data.fqn output.statusnet_profile_url = data.url // There's nothing else to get @@ -89,6 +92,9 @@ export const parseUser = (data) => { output.bot = data.bot if (data.pleroma) { + if (data.pleroma.settings_store) { + output.storage = data.pleroma.settings_store['pleroma-fe'] + } const relationship = data.pleroma.relationship output.background_image = data.pleroma.background_image @@ -118,6 +124,34 @@ export const parseUser = (data) => { } else { output.role = 'member' } + + if (data.pleroma.privileges) { + output.privileges = data.pleroma.privileges + } else if (data.pleroma.is_admin) { + output.privileges = [ + 'users_read', + 'users_manage_invites', + 'users_manage_activation_state', + 'users_manage_tags', + 'users_manage_credentials', + 'users_delete', + 'messages_read', + 'messages_delete', + 'instances_delete', + 'reports_manage_reports', + 'moderation_log_read', + 'announcements_manage_announcements', + 'emoji_manage_emoji', + 'statistics_read' + ] + } else if (data.pleroma.is_moderator) { + output.privileges = [ + 'messages_delete', + 'reports_manage_reports' + ] + } else { + output.privileges = [] + } } if (data.source) { @@ -210,12 +244,14 @@ export const parseUser = (data) => { output.screen_name_ui = output.screen_name if (output.screen_name && output.screen_name.includes('@')) { const parts = output.screen_name.split('@') - let unicodeDomain = punycode.toUnicode(parts[1]) + const unicodeDomain = punycode.toUnicode(parts[1]) if (unicodeDomain !== parts[1]) { // Add some identifier so users can potentially spot spoofing attempts: // lain.com and xn--lin-6cd.com would appear identical otherwise. - unicodeDomain = 'đ' + unicodeDomain + output.screen_name_ui_contains_non_ascii = true output.screen_name_ui = [parts[0], unicodeDomain].join('@') + } else { + output.screen_name_ui_contains_non_ascii = false } } @@ -224,7 +260,7 @@ export const parseUser = (data) => { export const parseAttachment = (data) => { const output = {} - const masto = !data.hasOwnProperty('oembed') + const masto = !Object.prototype.hasOwnProperty.call(data, 'oembed') if (masto) { // Not exactly same... @@ -243,9 +279,19 @@ export const parseAttachment = (data) => { return output } +export const parseSource = (data) => { + const output = {} + + output.text = data.text + output.spoiler_text = data.spoiler_text + output.content_type = data.content_type + + return output +} + export const parseStatus = (data) => { const output = {} - const masto = data.hasOwnProperty('account') + const masto = Object.prototype.hasOwnProperty.call(data, 'account') if (masto) { output.favorited = data.favourited @@ -264,6 +310,8 @@ export const parseStatus = (data) => { output.tags = data.tags + output.edited_at = data.edited_at + if (data.pleroma) { const { pleroma } = data output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content @@ -365,15 +413,19 @@ export const parseStatus = (data) => { output.favoritedBy = [] output.rebloggedBy = [] + if (Object.prototype.hasOwnProperty.call(data, 'originalStatus')) { + Object.assign(output, data.originalStatus) + } + return output } export const parseNotification = (data) => { const mastoDict = { - 'favourite': 'like', - 'reblog': 'repeat' + favourite: 'like', + reblog: 'repeat' } - const masto = !data.hasOwnProperty('ntype') + const masto = !Object.prototype.hasOwnProperty.call(data, 'ntype') const output = {} if (masto) { @@ -386,6 +438,13 @@ export const parseNotification = (data) => { : parseUser(data.target) output.from_profile = parseUser(data.account) output.emoji = data.emoji + if (data.report) { + output.report = data.report + output.report.content = data.report.content + output.report.acct = parseUser(data.report.account) + output.report.actor = parseUser(data.report.actor) + output.report.statuses = data.report.statuses.map(parseStatus) + } } else { const parsedNotice = parseStatus(data.notice) output.type = data.ntype diff --git a/src/services/errors/errors.js b/src/services/errors/errors.js index d4cf9132..50372e5e 100644 --- a/src/services/errors/errors.js +++ b/src/services/errors/errors.js @@ -26,6 +26,7 @@ export class RegistrationError extends Error { // the error is probably a JSON object with a single key, "errors", whose value is another JSON object containing the real errors if (typeof error === 'string') { error = JSON.parse(error) + // eslint-disable-next-line if (error.hasOwnProperty('error')) { error = JSON.parse(error.error) } diff --git a/src/services/export_import/export_import.js b/src/services/export_import/export_import.js index ac67cf9c..7fee0ad3 100644 --- a/src/services/export_import/export_import.js +++ b/src/services/export_import/export_import.js @@ -1,9 +1,11 @@ +import utf8 from 'utf8' + export const newExporter = ({ filename = 'data', getExportedObject }) => ({ exportData () { - const stringified = JSON.stringify(getExportedObject(), null, 2) // Pretty-print and indent with 2 spaces + const stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces // Create an invisible link with a data url and simulate a click const e = document.createElement('a') diff --git a/src/services/file_size_format/file_size_format.js b/src/services/file_size_format/file_size_format.js index 7e6cd4d7..17deb09b 100644 --- a/src/services/file_size_format/file_size_format.js +++ b/src/services/file_size_format/file_size_format.js @@ -1,15 +1,14 @@ -const fileSizeFormat = (num) => { - var exponent - var unit - var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] +const fileSizeFormat = (numArg) => { + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + let num = numArg if (num < 1) { return num + ' ' + units[0] } - exponent = Math.min(Math.floor(Math.log(num) / Math.log(1024)), units.length - 1) + const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1024)), units.length - 1) num = (num / Math.pow(1024, exponent)).toFixed(2) * 1 - unit = units[exponent] - return { num: num, unit: unit } + const unit = units[exponent] + return { num, unit } } const fileSizeFormatService = { fileSizeFormat diff --git a/src/services/gesture_service/gesture_service.js b/src/services/gesture_service/gesture_service.js index 88a328f3..265a7f25 100644 --- a/src/services/gesture_service/gesture_service.js +++ b/src/services/gesture_service/gesture_service.js @@ -4,9 +4,15 @@ const DIRECTION_RIGHT = [1, 0] const DIRECTION_UP = [0, -1] const DIRECTION_DOWN = [0, 1] +const BUTTON_LEFT = 0 + const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]] -const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY]) +const touchCoord = touch => [touch.screenX, touch.screenY] + +const touchEventCoord = e => touchCoord(e.touches[0]) + +const pointerEventCoord = e => [e.clientX, e.clientY] const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1]) @@ -61,6 +67,132 @@ const updateSwipe = (event, gesture) => { gesture._swiping = false } +class SwipeAndClickGesture { + // swipePreviewCallback(offsets: Array[Number]) + // offsets: the offset vector which the underlying component should move, from the starting position + // swipeEndCallback(sign: 0|-1|1) + // sign: if the swipe does not meet the threshold, 0 + // if the swipe meets the threshold in the positive direction, 1 + // if the swipe meets the threshold in the negative direction, -1 + constructor ({ + direction, + // swipeStartCallback + swipePreviewCallback, + swipeEndCallback, + swipeCancelCallback, + swipelessClickCallback, + threshold = 30, + perpendicularTolerance = 1.0, + disableClickThreshold = 1 + }) { + const nop = () => {} + this.direction = direction + this.swipePreviewCallback = swipePreviewCallback || nop + this.swipeEndCallback = swipeEndCallback || nop + this.swipeCancelCallback = swipeCancelCallback || nop + this.swipelessClickCallback = swipelessClickCallback || nop + this.threshold = typeof threshold === 'function' ? threshold : () => threshold + this.disableClickThreshold = typeof disableClickThreshold === 'function' ? disableClickThreshold : () => disableClickThreshold + this.perpendicularTolerance = perpendicularTolerance + this._reset() + } + + _reset () { + this._startPos = [0, 0] + this._pointerId = -1 + this._swiping = false + this._swiped = false + this._preventNextClick = false + } + + start (event) { + // Only handle left click + if (event.button !== BUTTON_LEFT) { + return + } + + this._startPos = pointerEventCoord(event) + this._pointerId = event.pointerId + this._swiping = true + this._swiped = false + } + + move (event) { + if (this._swiping && this._pointerId === event.pointerId) { + this._swiped = true + + const coord = pointerEventCoord(event) + const delta = deltaCoord(this._startPos, coord) + + this.swipePreviewCallback(delta) + } + } + + cancel (event) { + if (!this._swiping || this._pointerId !== event.pointerId) { + return + } + + this.swipeCancelCallback() + } + + end (event) { + if (!this._swiping) { + return + } + + if (this._pointerId !== event.pointerId) { + return + } + + this._swiping = false + + // movement too small + const coord = pointerEventCoord(event) + const delta = deltaCoord(this._startPos, coord) + + const sign = (() => { + if (vectorLength(delta) < this.threshold()) { + return 0 + } + // movement is opposite from direction + const isPositive = dotProduct(delta, this.direction) > 0 + + // movement perpendicular to direction is too much + const towardsDir = project(delta, this.direction) + const perpendicularDir = perpendicular(this.direction) + const towardsPerpendicular = project(delta, perpendicularDir) + if ( + vectorLength(towardsDir) * this.perpendicularTolerance < + vectorLength(towardsPerpendicular) + ) { + return 0 + } + + return isPositive ? 1 : -1 + })() + + if (this._swiped) { + this.swipeEndCallback(sign) + } + this._reset() + // Only a mouse will fire click event when + // the end point is far from the starting point + // so for other kinds of pointers do not check + // whether we have swiped + if (vectorLength(delta) >= this.disableClickThreshold() && event.pointerType === 'mouse') { + this._preventNextClick = true + } + } + + click (event) { + if (!this._preventNextClick) { + this.swipelessClickCallback() + } + this._reset() + } +} + const GestureService = { DIRECTION_LEFT, DIRECTION_RIGHT, @@ -68,7 +200,8 @@ const GestureService = { DIRECTION_DOWN, swipeGesture, beginSwipe, - updateSwipe + updateSwipe, + SwipeAndClickGesture } export default GestureService diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js index 5eeaa7cb..9c3d1f19 100644 --- a/src/services/html_converter/html_line_converter.service.js +++ b/src/services/html_converter/html_line_converter.service.js @@ -46,7 +46,7 @@ export const convertHtmlToLines = (html = '') => { // All block-level elements that aren't empty elements, i.e. not <hr> const nonEmptyElements = new Set(visualLineElements) // Difference - for (let elem of emptyElements) { + for (const elem of emptyElements) { nonEmptyElements.delete(elem) } @@ -56,7 +56,7 @@ export const convertHtmlToLines = (html = '') => { ...emptyElements.values() ]) - let buffer = [] // Current output buffer + const buffer = [] // Current output buffer const level = [] // How deep we are in tags and which tags were there let textBuffer = '' // Current line content let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js index 6a8796c4..247a8173 100644 --- a/src/services/html_converter/html_tree_converter.service.js +++ b/src/services/html_converter/html_tree_converter.service.js @@ -1,4 +1,5 @@ import { getTagName } from './utility.service.js' +import { unescape } from 'lodash' /** * This is a not-so-tiny purpose-built HTML parser/processor. This parses html @@ -49,7 +50,7 @@ export const convertHtmlToTree = (html = '') => { const handleOpen = (tag) => { const curBuf = getCurrentBuffer() - const newLevel = [tag, []] + const newLevel = [unescape(tag), []] levels.push(newLevel) curBuf.push(newLevel) } diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js index 4d0c36c2..f1042971 100644 --- a/src/services/html_converter/utility.service.js +++ b/src/services/html_converter/utility.service.js @@ -16,7 +16,7 @@ export const getTagName = (tag) => { * @return {Object} - map of attributes key = attribute name, value = attribute value * attributes without values represented as boolean true */ -export const getAttrs = tag => { +export const getAttrs = (tag, filter) => { const innertag = tag .substring(1, tag.length - 1) .replace(new RegExp('^' + getTagName(tag)), '') @@ -28,7 +28,15 @@ export const getAttrs = tag => { if (!v) return [k, true] return [k, v.substring(1, v.length - 1)] }) - return Object.fromEntries(attrs) + const defaultFilter = ([k, v]) => { + const attrKey = k.toLowerCase() + if (attrKey === 'style') return false + if (attrKey === 'class') { + return v === 'greentext' || v === 'cyantext' + } + return true + } + return Object.fromEntries(attrs.filter(filter || defaultFilter)) } /** @@ -50,7 +58,7 @@ export const processTextForEmoji = (text, emojis, processor) => { if (char === ':') { const next = text.slice(i + 1) let found = false - for (let emoji of emojis) { + for (const emoji of emojis) { if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) { found = emoji break diff --git a/src/services/lists_fetcher/lists_fetcher.service.js b/src/services/lists_fetcher/lists_fetcher.service.js new file mode 100644 index 00000000..8d9dae66 --- /dev/null +++ b/src/services/lists_fetcher/lists_fetcher.service.js @@ -0,0 +1,22 @@ +import apiService from '../api/api.service.js' +import { promiseInterval } from '../promise_interval/promise_interval.js' + +const fetchAndUpdate = ({ store, credentials }) => { + return apiService.fetchLists({ credentials }) + .then(lists => { + store.commit('setLists', lists) + }, () => {}) + .catch(() => {}) +} + +const startFetching = ({ credentials, store }) => { + const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) + boundFetchAndUpdate() + return promiseInterval(boundFetchAndUpdate, 240000) +} + +const listsFetcher = { + startFetching +} + +export default listsFetcher diff --git a/src/services/locale/locale.service.js b/src/services/locale/locale.service.js index 5be99d81..d3389785 100644 --- a/src/services/locale/locale.service.js +++ b/src/services/locale/locale.service.js @@ -1,12 +1,35 @@ +import languagesObject from '../../i18n/messages' +import ISO6391 from 'iso-639-1' +import _ from 'lodash' + const specialLanguageCodes = { - 'ja_easy': 'ja', - 'zh_Hant': 'zh-HANT' + ja_easy: 'ja', + zh_Hant: 'zh-HANT', + zh: 'zh-Hans' } const internalToBrowserLocale = code => specialLanguageCodes[code] || code +const internalToBackendLocale = code => internalToBrowserLocale(code).replace('_', '-') + +const getLanguageName = (code) => { + const specialLanguageNames = { + ja_easy: 'ãããããĢãģãã', + zh: 'įŽäŊ䏿', + zh_Hant: 'įšéĢ䏿' + } + const languageName = specialLanguageNames[code] || ISO6391.getNativeName(code) + const browserLocale = internalToBrowserLocale(code) + return languageName.charAt(0).toLocaleUpperCase(browserLocale) + languageName.slice(1) +} + +const languages = _.map(languagesObject.languages, (code) => ({ code, name: getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name)) + const localeService = { - internalToBrowserLocale + internalToBrowserLocale, + internalToBackendLocale, + languages, + getLanguageName } export default localeService diff --git a/src/services/new_api/password_reset.js b/src/services/new_api/password_reset.js index 43199625..9f3c27b5 100644 --- a/src/services/new_api/password_reset.js +++ b/src/services/new_api/password_reset.js @@ -1,6 +1,6 @@ import { reduce } from 'lodash' -const MASTODON_PASSWORD_RESET_URL = `/auth/password` +const MASTODON_PASSWORD_RESET_URL = '/auth/password' const resetPassword = ({ instance, email }) => { const params = { email } diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index 6fef1022..0f8b9b02 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -14,11 +14,13 @@ export const visibleTypes = store => { rootState.config.notificationVisibility.follows && 'follow', rootState.config.notificationVisibility.followRequest && 'follow_request', rootState.config.notificationVisibility.moves && 'move', - rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction' + rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction', + rootState.config.notificationVisibility.reports && 'pleroma:report', + rootState.config.notificationVisibility.polls && 'poll' ].filter(_ => _)) } -const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction'] +const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll'] export const isStatusNotification = (type) => includes(statusNotifications, type) @@ -98,6 +100,12 @@ export const prepareNotificationObject = (notification, i18n) => { case 'follow_request': i18nString = 'follow_request' break + case 'pleroma:report': + i18nString = 'submitted_report' + break + case 'poll': + i18nString = 'poll_ended' + break } if (notification.type === 'pleroma:emoji_reaction') { diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index b66fcd67..6c247210 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -1,6 +1,18 @@ import apiService from '../api/api.service.js' import { promiseInterval } from '../promise_interval/promise_interval.js' +// For using include_types when fetching notifications. +// Note: chat_mention excluded as pleroma-fe polls them separately +const mastoApiNotificationTypes = [ + 'mention', + 'favourite', + 'reblog', + 'follow', + 'move', + 'pleroma:emoji_reaction', + 'pleroma:report' +] + const update = ({ store, notifications, older }) => { store.dispatch('addNewNotifications', { notifications, older }) } @@ -11,24 +23,22 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => { const rootState = store.rootState || store.state const timelineData = rootState.statuses.notifications const hideMutedPosts = getters.mergedConfig.hideMutedPosts - const allowFollowingMove = rootState.users.currentUser.allow_following_move - - args['withMuted'] = !hideMutedPosts - args['withMove'] = !allowFollowingMove + args.includeTypes = mastoApiNotificationTypes + args.withMuted = !hideMutedPosts - args['timeline'] = 'notifications' + args.timeline = 'notifications' if (older) { if (timelineData.minId !== Number.POSITIVE_INFINITY) { - args['until'] = timelineData.minId + args.until = timelineData.minId } return fetchNotifications({ store, args, older }) } else { // fetch new notifications if (since === undefined && timelineData.maxId !== Number.POSITIVE_INFINITY) { - args['since'] = timelineData.maxId + args.since = timelineData.maxId } else if (since !== null) { - args['since'] = since + args.since = since } const result = fetchNotifications({ store, args, older }) @@ -41,7 +51,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => { const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id) const numUnseenNotifs = notifications.length - readNotifsIds.length if (numUnseenNotifs > 0 && readNotifsIds.length > 0) { - args['since'] = Math.max(...readNotifsIds) + args.since = Math.max(...readNotifsIds) fetchNotifications({ store, args, older }) } @@ -66,6 +76,7 @@ const fetchNotifications = ({ store, args, older }) => { messageArgs: [error.message], timeout: 5000 }) + console.error(error) }) } diff --git a/src/services/offset_finder/offset_finder.service.js b/src/services/offset_finder/offset_finder.service.js index 9034f8c8..5a904f08 100644 --- a/src/services/offset_finder/offset_finder.service.js +++ b/src/services/offset_finder/offset_finder.service.js @@ -9,7 +9,7 @@ export const findOffset = (child, parent, { top = 0, left = 0 } = {}, ignorePadd result.left += ignorePadding ? 0 : leftPadding } - if (child.offsetParent && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) { + if (child.offsetParent && window.getComputedStyle(child.offsetParent).position !== 'sticky' && (parent === window || parent.contains(child.offsetParent) || parent === child.offsetParent)) { return findOffset(child.offsetParent, parent, result, false) } else { if (parent !== window) { diff --git a/src/services/push/push.js b/src/services/push/push.js index 5836fc26..1787ac36 100644 --- a/src/services/push/push.js +++ b/src/services/push/push.js @@ -1,4 +1,4 @@ -import runtime from 'serviceworker-webpack-plugin/lib/runtime' +import runtime from 'serviceworker-webpack5-plugin/lib/runtime' function urlBase64ToUint8Array (base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4) @@ -43,7 +43,7 @@ function deleteSubscriptionFromBackEnd (token) { method: 'DELETE', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}` } }).then((response) => { if (!response.ok) throw new Error('Bad status code from server.') @@ -56,7 +56,7 @@ function sendSubscriptionToBackEnd (subscription, token, notificationVisibility) method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}` }, body: JSON.stringify({ subscription, diff --git a/src/services/resettable_async_component.js b/src/services/resettable_async_component.js index 517bbd88..1c046ce7 100644 --- a/src/services/resettable_async_component.js +++ b/src/services/resettable_async_component.js @@ -1,4 +1,4 @@ -import Vue from 'vue' +import { defineAsyncComponent, shallowReactive, h } from 'vue' /* By default async components don't have any way to recover, if component is * failed, it is failed forever. This helper tries to remedy that by recreating @@ -8,23 +8,21 @@ import Vue from 'vue' * actual target component itself if needs to be. */ function getResettableAsyncComponent (asyncComponent, options) { - const asyncComponentFactory = () => () => ({ - component: asyncComponent(), + const asyncComponentFactory = () => () => defineAsyncComponent({ + loader: asyncComponent, ...options }) - const observe = Vue.observable({ c: asyncComponentFactory() }) + const observe = shallowReactive({ c: asyncComponentFactory() }) return { - functional: true, - render (createElement, { data, children }) { + render () { // emit event resetAsyncComponent to reloading - data.on = {} - data.on.resetAsyncComponent = () => { - observe.c = asyncComponentFactory() - // parent.$forceUpdate() - } - return createElement(observe.c, data, children) + return h(observe.c(), { + onResetAsyncComponent () { + observe.c = asyncComponentFactory() + } + }) } } } diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index f09196aa..1eb10bb6 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -47,6 +47,47 @@ const postStatus = ({ }) } +const editStatus = ({ + store, + statusId, + status, + spoilerText, + sensitive, + poll, + media = [], + contentType = 'text/plain' +}) => { + const mediaIds = map(media, 'id') + + return apiService.editStatus({ + id: statusId, + credentials: store.state.users.currentUser.credentials, + status, + spoilerText, + sensitive, + poll, + mediaIds, + contentType + }) + .then((data) => { + if (!data.error) { + store.dispatch('addNewStatuses', { + statuses: [data], + timeline: 'friends', + showImmediately: true, + noIdUpdate: true // To prevent missing notices on next pull. + }) + } + return data + }) + .catch((err) => { + console.error('Error editing status', err) + return { + error: err.message + } + }) +} + const uploadMedia = ({ store, formData }) => { const credentials = store.state.users.currentUser.credentials return apiService.uploadMedia({ credentials, formData }) @@ -59,6 +100,7 @@ const setMediaDescription = ({ store, id, description }) => { const statusPosterService = { postStatus, + editStatus, uploadMedia, setMediaDescription } diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index f75e6916..d6e973a1 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -1,6 +1,7 @@ import { convert } from 'chromatism' import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js' import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js' +import { defaultState } from '../../modules/config.js' export const applyTheme = (input) => { const { rules } = generatePreset(input) @@ -13,10 +14,40 @@ export const applyTheme = (input) => { const styleSheet = styleEl.sheet styleSheet.toString() - styleSheet.insertRule(`body { ${rules.radii} }`, 'index-max') - styleSheet.insertRule(`body { ${rules.colors} }`, 'index-max') - styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max') - styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max') + styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max') + styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max') + styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max') + styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max') + body.classList.remove('hidden') +} + +const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) => + ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth }) + +const defaultConfigColumns = configColumns(defaultState) + +export const applyConfig = (config) => { + const columns = configColumns(config) + + if (columns === defaultConfigColumns) { + return + } + + const head = document.head + const body = document.body + body.classList.add('hidden') + + const rules = Object + .entries(columns) + .filter(([k, v]) => v) + .map(([k, v]) => `--${k}: ${v}`).join(';') + + const styleEl = document.createElement('style') + head.appendChild(styleEl) + const styleSheet = styleEl.sheet + + styleSheet.toString() + styleSheet.insertRule(`:root { ${rules} }`, 'index-max') body.classList.remove('hidden') } diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js index c2983be7..dc7a5d89 100644 --- a/src/services/theme_data/pleromafe.js +++ b/src/services/theme_data/pleromafe.js @@ -709,6 +709,14 @@ export const SLOT_INHERITANCE = { textColor: 'bw' }, + badgeNeutral: '--cGreen', + badgeNeutralText: { + depends: ['text', 'badgeNeutral'], + layer: 'badge', + variant: 'badgeNeutral', + textColor: 'bw' + }, + chatBg: { depends: ['bg'] }, diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js index b619f810..b376ef4d 100644 --- a/src/services/theme_data/theme_data.service.js +++ b/src/services/theme_data/theme_data.service.js @@ -39,7 +39,7 @@ import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js' export const CURRENT_VERSION = 3 export const getLayersArray = (layer, data = LAYERS) => { - let array = [layer] + const array = [layer] let parent = data[layer] while (parent) { array.unshift(parent) @@ -138,6 +138,7 @@ export const topoSort = ( if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi if (depsA === 0 && depsB !== 0) return -1 if (depsB === 0 && depsA !== 0) return 1 + return 0 // failsafe, shouldn't happen? }).map(({ data }) => data) } diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index 46bba41a..8501907e 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -3,12 +3,13 @@ import { camelCase } from 'lodash' import apiService from '../api/api.service.js' import { promiseInterval } from '../promise_interval/promise_interval.js' -const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => { +const update = ({ store, statuses, timeline, showImmediately, userId, listId, pagination }) => { const ccTimeline = camelCase(timeline) store.dispatch('addNewStatuses', { timeline: ccTimeline, userId, + listId, statuses, showImmediately, pagination @@ -22,6 +23,7 @@ const fetchAndUpdate = ({ older = false, showImmediately = false, userId = false, + listId = false, tag = false, until, since @@ -34,20 +36,21 @@ const fetchAndUpdate = ({ const loggedIn = !!rootState.users.currentUser if (older) { - args['until'] = until || timelineData.minId + args.until = until || timelineData.minId } else { if (since === undefined) { - args['since'] = timelineData.maxId + args.since = timelineData.maxId } else if (since !== null) { - args['since'] = since + args.since = since } } - args['userId'] = userId - args['tag'] = tag - args['withMuted'] = !hideMutedPosts + args.userId = userId + args.listId = listId + args.tag = tag + args.withMuted = !hideMutedPosts if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) { - args['replyVisibility'] = replyVisibility + args.replyVisibility = replyVisibility } const numStatusesBeforeFetch = timelineData.statuses.length @@ -60,9 +63,9 @@ const fetchAndUpdate = ({ const { data: statuses, pagination } = response if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { - store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) + store.dispatch('queueFlush', { timeline, id: timelineData.maxId }) } - update({ store, statuses, timeline, showImmediately, userId, pagination }) + update({ store, statuses, timeline, showImmediately, userId, listId, pagination }) return { statuses, pagination } }) .catch((error) => { @@ -75,14 +78,15 @@ const fetchAndUpdate = ({ }) } -const startFetching = ({ timeline = 'friends', credentials, store, userId = false, tag = false }) => { +const startFetching = ({ timeline = 'friends', credentials, store, userId = false, listId = false, tag = false }) => { const rootState = store.rootState || store.state const timelineData = rootState.statuses.timelines[camelCase(timeline)] const showImmediately = timelineData.visibleStatuses.length === 0 timelineData.userId = userId - fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, tag }) + timelineData.listId = listId + fetchAndUpdate({ timeline, credentials, store, showImmediately, userId, listId, tag }) const boundFetchAndUpdate = () => - fetchAndUpdate({ timeline, credentials, store, userId, tag }) + fetchAndUpdate({ timeline, credentials, store, userId, listId, tag }) return promiseInterval(boundFetchAndUpdate, 10000) } const timelineFetcher = { diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js index 3b07592e..b5f58040 100644 --- a/src/services/user_highlighter/user_highlighter.js +++ b/src/services/user_highlighter/user_highlighter.js @@ -36,7 +36,7 @@ const highlightStyle = (prefs) => { 'linear-gradient(to right,', `${solidColor} ,`, `${solidColor} 2px,`, - `transparent 6px` + 'transparent 6px' ].join(' '), backgroundPosition: '0 0', ...customProps @@ -3,12 +3,10 @@ import localForage from 'localforage' import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js' import { prepareNotificationObject } from './services/notification_utils/notification_utils.js' -import Vue from 'vue' -import VueI18n from 'vue-i18n' +import { createI18n } from 'vue-i18n' import messages from './i18n/service_worker_messages.js' -Vue.use(VueI18n) -const i18n = new VueI18n({ +const i18n = createI18n({ // By default, use the browser locale, we will update it if neccessary locale: 'en', fallbackLocale: 'en', @@ -59,8 +57,8 @@ self.addEventListener('notificationclick', (event) => { event.notification.close() event.waitUntil(getWindowClients().then((list) => { - for (var i = 0; i < list.length; i++) { - var client = list[i] + for (let i = 0; i < list.length; i++) { + const client = list[i] if (client.url === '/' && 'focus' in client) { return client.focus() } } |
