diff options
Diffstat (limited to 'src')
289 files changed, 8015 insertions, 3751 deletions
@@ -1,11 +1,9 @@ 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' @@ -16,13 +14,14 @@ import PostStatusModal from './components/post_status_modal/post_status_modal.vu 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,7 +31,7 @@ export default { MobilePostStatusButton, MobileNav, DesktopNav, - SettingsModal, + SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')), UserReportingModal, PostStatusModal, GlobalNoticeList @@ -46,10 +45,20 @@ 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 + ] + }, currentUser () { return this.$store.state.users.currentUser }, userBackground () { return this.currentUser.background_image }, instanceBackground () { @@ -65,38 +74,45 @@ 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' + }, + newPostButtonShown () { + if (this.isChats) return false + return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' + }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, 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..ab025d63 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,77 +1,339 @@ +// 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: 90000; + --ZI_modals_popovers: 85000; + --ZI_modals: 80000; + --ZI_navbar_popovers: 75000; + --ZI_navbar: 70000; + --ZI_popovers: 60000; } 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; +} + +i[class*=icon-], +.svg-inline--fa { + color: $fallback--icon; + color: var(--icon, $fallback--icon); +} + +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; +} + +.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: minmax(var(--miniColumn), 45rem); + --columnGap: 1em; + --status-margin: 0.75em; + + position: relative; + display: grid; + grid-template-columns: var(--miniColumn) var(--maxiColumn); + 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(--maxiColumn) var(--miniColumn); + grid-template-areas: "content sidebar"; + } + + &.-wide { + grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn); + grid-template-areas: "sidebar content notifs"; + + &.-reverse { + 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 +346,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 +365,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 +403,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 +453,9 @@ a { } } -input, textarea, .input { - +input, +textarea, +.input { &.unstyled { border-radius: 0; background: none; @@ -200,10 +463,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 +476,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 +502,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 +525,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 +565,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 +585,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 +600,7 @@ option { .hide-number-spinner { -moz-appearance: textfield; + &[type=number]::-webkit-inner-spin-button, &[type=number]::-webkit-outer-spin-button { opacity: 0; @@ -331,11 +608,6 @@ option { } } -i[class*=icon-], .svg-inline--fa { - color: $fallback--icon; - color: var(--icon, $fallback--icon); -} - .btn-block { display: block; width: 100%; @@ -362,273 +634,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 +671,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 +725,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,7 +740,7 @@ nav { position: absolute; top: 0; right: 0; - padding: .5em; + padding: 0.5em; color: inherit; } } @@ -744,72 +757,6 @@ nav { } } -@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 - } -} - .login-hint { text-align: center; @@ -819,18 +766,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 +798,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..0efadaf0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,39 +1,40 @@ <template> <div - id="app" + id="app-loaded" :style="bgStyle" > <div id="app_bg_wrapper" class="app-bg-wrapper" /> - <MobileNav v-if="isMobileLayout" /> + <MobileNav v-if="layoutType === 'mobile'" /> <DesktopNav v-else /> - <div class="app-bg-wrapper app-container-wrapper" /> + <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"> + <div + id="main-scroller" + class="column main" + :class="{ '-full-height': isChats }" + > <div v-if="!currentUser" class="login-hint panel panel-default" @@ -47,20 +48,26 @@ </div> <router-view /> </div> - <media-modal /> + <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 /> <SettingsModal /> - <portal-target name="modal" /> + <div id="modal" /> <GlobalNoticeList /> + <div id="popovers" /> </div> </template> 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/boot/after_store.js b/src/boot/after_store.js index cc0c7c5e..908d905a 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -1,8 +1,14 @@ -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' @@ -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() } @@ -312,6 +319,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 +333,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() @@ -366,25 +374,35 @@ const afterStoreSetup = async ({ store, i18n }) => { 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..c8194d5f 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -31,7 +31,8 @@ export default (store) => { } let routes = [ - { name: 'root', + { + name: 'root', path: '/', redirect: _to => { return (store.state.users.currentUser @@ -45,12 +46,14 @@ 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 @@ -62,14 +65,14 @@ export default (store) => { { 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: 'user-profile', path: '/:_(users)?/:name', component: UserProfile } ] 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..99762562 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -40,7 +40,7 @@ const AccountActions = { 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..23547f2c 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 @@ -57,7 +57,7 @@ </button> </div> </template> - <template v-slot:trigger> + <template #trigger> <button class="button-unstyled ellipsis-button"> <FAIcon class="icon" @@ -74,10 +74,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/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..d62a4adc 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,33 @@ 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: { + localDescription (newVal) { + this.onEdit(newVal) + } + }, methods: { linkClicked ({ target }) { if (target.tagName === 'A') { @@ -100,12 +141,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 +198,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..8b1a2c38 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -1,4 +1,4 @@ -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 RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -7,20 +7,12 @@ const BasicUserCard = { props: [ 'user' ], - data () { - return { - userExpanded: false - } - }, components: { - UserCard, + UserPopover, UserAvatar, RichContent }, 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..9cca7840 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 @@ -53,6 +51,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..5a5c37b6 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,11 @@ const Chat = { } this.$nextTick(() => { - this.updateScrollableContainerHeight() this.handleResize() }) - this.setChatLayout() }, - destroyed () { + unmounted () { window.removeEventListener('scroll', this.handleScroll) - window.removeEventListener('resize', this.handleLayoutChange) - this.unsetChatLayout() if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.dispatch('clearCurrentChat') }, @@ -96,8 +92,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 +107,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 +124,6 @@ const Chat = { onFilesDropped () { this.$nextTick(() => { this.handleResize() - this.updateScrollableContainerHeight() }) }, handleVisibilityChange () { @@ -142,43 +133,7 @@ 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 @@ -190,29 +145,20 @@ const Chat = { } this.$nextTick(() => { - this.updateScrollableContainerHeight() - - const { offsetHeight = undefined } = this.lastScrollPosition - this.lastScrollPosition = getScrollPosition(this.$refs.scrollable) - + const { offsetHeight = undefined } = getScrollPosition() const diff = this.lastScrollPosition.offsetHeight - offsetHeight - if (diff < 0 || (!this.bottomedOut() && expand)) { + if (diff !== 0 || (!this.bottomedOut() && expand)) { this.$nextTick(() => { - this.updateScrollableContainerHeight() - this.$refs.scrollable.scrollTo({ - top: this.$refs.scrollable.scrollTop - diff, - left: 0 - }) + window.scrollTo({ top: window.scrollY + 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 +174,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(() => { @@ -263,10 +208,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 +229,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 +276,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.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..3b540cac 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,5 +1,19 @@ import { reduce, filter, findIndex, clone, get } from 'lodash' import Status from '../status/status.vue' +import ThreadTree from '../thread_tree/thread_tree.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 +49,10 @@ const conversation = { data () { return { highlight: null, - expanded: false + expanded: false, + threadDisplayStatusObject: {}, // id => 'showing' | 'hidden' + statusContentPropertiesObject: {}, + inlineDivePosition: null } }, props: [ @@ -53,13 +70,51 @@ 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 + }, + 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 +145,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 +271,7 @@ const conversation = { result[irid] = result[irid] || [] result[irid].push({ name: `#${i}`, - id: id + id }) } i++ @@ -109,15 +279,71 @@ 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 } }, components: { - Status + Status, + ThreadTree }, watch: { statusId (newVal, oldVal) { @@ -132,6 +358,8 @@ const conversation = { expanded (value) { if (value) { this.fetchConversation() + } else { + this.resetDisplayState() } }, virtualHidden (value) { @@ -161,8 +389,8 @@ 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 @@ -170,15 +398,139 @@ const conversation = { 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..1adbe250 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 @@ -18,24 +18,176 @@ {{ $t('timeline.collapse') }} </button> </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 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" + > + <div + 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> + </div> + </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" + > + <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" + /> + </div> + </div> </div> <div v-else @@ -49,19 +201,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..71202244 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,7 @@ max-width: 980px; } - &.-logoLeft { + &.-logoLeft .inner-nav { grid-template-columns: auto 2fr 2fr; grid-template-areas: "logo sitename actions"; } @@ -77,7 +80,7 @@ img { display: inline-block; - height: 50px; + height: var(--navbar-height); } } @@ -103,8 +106,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; diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index 762aa610..f352c78c 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 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/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 902ec384..5ba3907f 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -31,6 +31,7 @@ library.add( */ const EmojiInput = { + emits: ['update:modelValue', 'shown'], props: { suggest: { /** @@ -57,8 +58,7 @@ const EmojiInput = { required: true, type: Function }, - // TODO VUE3: change to modelValue, change 'input' event to 'input' - value: { + modelValue: { /** * Used for v-model */ @@ -137,8 +137,8 @@ const EmojiInput = { 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 } } @@ -189,8 +189,11 @@ const EmojiInput = { img: imageUrl || '' })) }, - suggestions (newValue) { - this.$nextTick(this.resize) + suggestions: { + handler (newValue) { + this.$nextTick(this.resize) + }, + deep: true } }, methods: { @@ -225,13 +228,13 @@ const EmojiInput = { } }, 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 @@ -259,7 +262,7 @@ const EmojiInput = { 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 +281,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 +321,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 @@ -455,7 +458,7 @@ const EmojiInput = { this.showPicker = false this.setCaret(e) this.resize() - this.$emit('input', e.target.value) + this.$emit('update:modelValue', e.target.value) }, onClickInput (e) { this.showPicker = false diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index aa2950ce..7d95ab7e 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -78,7 +78,7 @@ top: 0; right: 0; margin: .2em .25em; - font-size: 16px; + font-size: 1.3em; cursor: pointer; line-height: 24px; diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 2716d93f..f6920208 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,3 +1,4 @@ +import { defineAsyncComponent } from 'vue' import Checkbox from '../checkbox/checkbox.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -5,6 +6,7 @@ import { faStickyNote, faSmileBeam } from '@fortawesome/free-solid-svg-icons' +import { trim } from 'lodash' library.add( faBoxOpen, @@ -23,7 +25,7 @@ const filterByKeyword = (list, keyword = '') => { if (keyword === '') return list const keywordLowercase = keyword.toLowerCase() - let orderedEmojiList = [] + const orderedEmojiList = [] for (const emoji of list) { const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase) if (indexOfKeyword > -1) { @@ -57,7 +59,7 @@ const EmojiPicker = { } }, components: { - StickerPicker: () => import('../sticker_picker/sticker_picker.vue'), + StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), Checkbox }, methods: { @@ -79,7 +81,7 @@ const EmojiPicker = { }, highlight (key) { const ref = this.$refs['group-' + key] - const top = ref[0].offsetTop + const top = ref.offsetTop this.setShowStickers(false) this.activeGroup = key this.$nextTick(() => { @@ -96,7 +98,7 @@ const EmojiPicker = { } }, triggerLoadMore (target) { - const ref = this.$refs['group-end-custom'][0] + const ref = this.$refs['group-end-custom'] if (!ref) return const bottom = ref.offsetTop + ref.offsetHeight @@ -119,7 +121,7 @@ const EmojiPicker = { this.$nextTick(() => { this.emojisView.forEach(group => { const ref = this.$refs['group-' + group.id] - if (ref[0].offsetTop <= top) { + if (ref.offsetTop <= top) { this.activeGroup = group.id } }) @@ -175,7 +177,7 @@ const EmojiPicker = { filteredEmoji () { return filterByKeyword( this.$store.state.instance.customEmoji || [], - this.keyword + trim(this.keyword) ) }, customEmojiBuffer () { @@ -196,7 +198,7 @@ const EmojiPicker = { id: 'standard', text: this.$t('emoji.unicode'), icon: 'box-open', - emojis: filterByKeyword(standardEmojis, this.keyword) + emojis: filterByKeyword(standardEmojis, trim(this.keyword)) } ] }, diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index ec711758..a2f17c51 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -7,7 +7,8 @@ right: 0; left: 0; margin: 0 !important; - z-index: 1; + // TODO: actually use popover in emoji picker + z-index: var(--ZI_popovers); background-color: $fallback--bg; background-color: var(--popover, $fallback--bg); color: $fallback--link; @@ -73,12 +74,13 @@ &-item { padding: 0 7px; cursor: pointer; - font-size: 24px; + font-size: 1.85em; &.disabled { opacity: 0.5; pointer-events: none; } + &.active { border-bottom: 4px solid; @@ -151,9 +153,10 @@ justify-content: left; &-title { - font-size: 12px; + font-size: 0.85em; width: 100%; margin: 0; + &.disabled { display: none; } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 3262a3d9..a7269120 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -47,6 +47,7 @@ type="text" class="form-control" :placeholder="$t('emoji.search_emoji')" + @input="$event.target.composing = false" > </div> <div diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index 51d50359..4ea8b6a2 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -26,7 +26,7 @@ </div> </template> -<script src="./emoji_reactions.js" ></script> +<script src="./emoji_reactions.js"></script> <style lang="scss"> @import '../../_variables.scss'; 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..22ffb65a 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -25,7 +25,7 @@ library.add( ) const ExtraButtons = { - props: [ 'status' ], + props: ['status'], components: { Popover }, methods: { deleteStatus () { @@ -89,6 +89,9 @@ 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}` } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index a3c3c767..2c893bf3 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -7,7 +7,7 @@ :bound-to="{ x: 'container' }" remove-padding > - <template v-slot:content="{close}"> + <template #content="{close}"> <div class="dropdown-menu"> <button v-if="canMute && !status.thread_muted" @@ -51,28 +51,30 @@ icon="thumbtack" /><span>{{ $t("status.unpin") }}</span> </button> - <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 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="canDelete" class="button-default dropdown-item dropdown-item-icon" @@ -118,18 +120,18 @@ </button> </div> </template> - <template v-slot:trigger> - <button class="button-unstyled popover-trigger"> + <template #trigger> + <span class="button-unstyled popover-trigger"> <FAIcon class="fa-scale-110 fa-old-padding" icon="ellipsis-h" /> - </button> + </span> </template> </Popover> </template> -<script src="./extra_buttons.js" ></script> +<script src="./extra_buttons.js"></script> <style lang="scss"> @import '../../_variables.scss'; diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index dce25e24..d5c4c61e 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -29,7 +29,7 @@ </div> </template> -<script src="./favorite_button.js" ></script> +<script src="./favorite_button.js"></script> <style lang="scss"> @import '../../_variables.scss'; 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/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..09904761 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -32,7 +32,7 @@ top: 50px; width: 100%; pointer-events: none; - z-index: 1001; + z-index: var(--ZI_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..cc6a15e1 100644 --- a/src/components/interactions/interactions.js +++ b/src/components/interactions/interactions.js @@ -1,4 +1,5 @@ import Notifications from '../notifications/notifications.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' const tabModeDict = { mentions: ['mention'], @@ -11,7 +12,7 @@ const Interactions = { data () { return { allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, - filterMode: tabModeDict['mentions'] + filterMode: tabModeDict.mentions } }, methods: { @@ -20,7 +21,8 @@ const Interactions = { } }, components: { - Notifications + Notifications, + TabSwitcher } } 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/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 5209907d..4a74fbe2 100644 --- a/src/components/mention_link/mention_link.js +++ b/src/components/mention_link/mention_link.js @@ -2,6 +2,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p import { mapGetters, mapState } from 'vuex' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import UserAvatar from '../user_avatar/user_avatar.vue' +import { defineAsyncComponent } from 'vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faAt @@ -14,7 +15,8 @@ library.add( const MentionLink = { name: 'MentionLink', components: { - UserAvatar + UserAvatar, + UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) }, props: { url: { @@ -34,15 +36,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) @@ -87,8 +104,9 @@ const MentionLink = { classnames () { return [ { - '-you': this.isYou, - '-highlighted': this.highlight + '-you': this.isYou && this.shouldBoldenYou, + '-highlighted': this.highlight, + '-has-selection': this.hasSelection }, this.highlightType ] @@ -110,11 +128,17 @@ const MentionLink = { } }, shouldShowTooltip () { - return this.mergedConfig.mentionLinkShowTooltip && this.mergedConfig.mentionLinkDisplay === 'short' && this.isRemote + return this.mergedConfig.mentionLinkShowTooltip }, shouldShowAvatar () { return this.mergedConfig.mentionLinkShowAvatar }, + shouldShowYous () { + return this.mergedConfig.mentionLinkShowYous + }, + shouldBoldenYou () { + return this.mergedConfig.mentionLinkBoldenYou + }, shouldFadeDomain () { return this.mergedConfig.mentionLinkFadeDomain }, diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss index 03306dcc..8b2af926 100644 --- a/src/components/mention_link/mention_link.scss +++ b/src/components/mention_link/mention_link.scss @@ -3,12 +3,13 @@ .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; } @@ -38,8 +39,8 @@ user-select: all; } - .short.-with-tooltip, - .you { + & .short.-with-tooltip, + & .you { user-select: none; } @@ -48,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%; @@ -68,8 +75,7 @@ } &.-striped { - & .shortName, - & .full { + & .shortName { background-image: repeating-linear-gradient( 135deg, @@ -82,30 +88,29 @@ } &.-solid { - & .shortName, - & .full { + .shortName { background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2)); } } &.-side { - & .shortName, - & .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); } +} - .full .-faded { - color: var(--faint, $fallback--faint); - } +.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 ac20eb5a..3af502ef 100644 --- a/src/components/mention_link/mention_link.vue +++ b/src/components/mention_link/mention_link.vue @@ -9,71 +9,64 @@ 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" - :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" - /></span> - <span - v-if="isYou" - class="you" - >{{ $t('status.you') }}</span> - <!-- eslint-enable vue/no-v-html --> - </a> <span - v-if="shouldShowTooltip" - class="full popover-default" - :class="[highlightType]" + v-if="user" + class="new" + :style="style" + :class="classnames" > - <span - class="userNameFull" + <a + class="short button-unstyled" + :class="{ '-with-tooltip': shouldShowTooltip }" + :href="url" + @click.prevent="onClick" > <!-- eslint-disable vue/no-v-html --> - @<span + <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" /> + </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..877d52a9 100644 --- a/src/components/mobile_nav/mobile_nav.js +++ b/src/components/mobile_nav/mobile_nav.js @@ -78,7 +78,8 @@ const MobileNav = { this.$store.dispatch('logout') }, markNotificationsAsSeen () { - this.$refs.notifications.markAsSeen() + // this.$refs.notifications.markAsSeen() + this.$store.dispatch('markNotificationsAsSeen') }, onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { if (scrollTop + clientHeight >= scrollHeight) { diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 0f0ea457..949cf17e 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -5,7 +5,6 @@ <nav id="nav" class="mobile-nav" - :class="{ 'mobile-hidden': isChat }" @click="scrollToTop()" > <div class="item"> @@ -51,7 +50,7 @@ <div v-if="currentUser" class="mobile-notifications-drawer" - :class="{ 'closed': !notificationsOpen }" + :class="{ '-closed': !notificationsOpen }" @touchstart.stop="notificationsTouchStart" @touchmove.stop="notificationsTouchMove" > @@ -68,14 +67,10 @@ </a> </div> <div + id="mobile-notifications" class="mobile-notifications" @scroll="onScroll" - > - <Notifications - ref="notifications" - :no-heading="true" - /> - </div> + /> </div> <SideDrawer ref="sideDrawer" @@ -90,15 +85,18 @@ @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 +148,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 +161,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; @@ -182,7 +181,7 @@ .mobile-notifications { margin-top: 50px; width: 100vw; - height: calc(100vh - 50px); + height: calc(100vh - var(--navbar-height)); overflow-x: hidden; overflow-y: scroll; 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..ecf79b64 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 @@ -29,7 +29,7 @@ const MobilePostStatusButton = { } window.addEventListener('resize', this.handleOSK) }, - destroyed () { + unmounted () { if (this.autohideFloatingPostButton) { this.deactivateFloatingPostButtonAutohide() } @@ -45,7 +45,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..9fcdf6f8 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,12 @@ <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 }" + @click="openPostForm" + > + <FAIcon icon="pen" /> + </button> </template> <script src="./mobile_post_status_button.js"></script> @@ -15,25 +14,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.vue b/src/components/moderation_tools/moderation_tools.vue index 96476abe..34fe2e7c 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -8,7 +8,7 @@ @show="setToggled(true)" @close="setToggled(false)" > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> <span v-if="user.is_local"> <button @@ -122,7 +122,7 @@ </span> </div> </template> - <template v-slot:trigger> + <template #trigger> <button class="btn button-default btn-block moderation-tools-button" :class="{ toggled }" @@ -132,16 +132,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 +156,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.vue b/src/components/nav_panel/nav_panel.vue index 7ae7b1d6..3fd27d89 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -90,7 +90,7 @@ </div> </template> -<script src="./nav_panel.js" ></script> +<script src="./nav_panel.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -113,7 +113,9 @@ 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); diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 398bb7a9..882b68f9 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' import Timeago from '../timeago/timeago.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' @@ -39,14 +40,15 @@ const Notification = { unmuted: false } }, - props: [ 'notification' ], + props: ['notification'], components: { StatusContent, UserAvatar, UserCard, Timeago, Status, - RichContent + RichContent, + UserPopover }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss index 14ec6213..38978137 100644 --- a/src/components/notification/notification.scss +++ b/src/components/notification/notification.scss @@ -9,6 +9,12 @@ 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; height: 1.2em; diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 44cd3d8a..d2b903f6 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -33,22 +33,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 --> @@ -65,12 +66,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'"> @@ -79,6 +84,7 @@ icon="retweet" :title="$t('tool_tip.repeat')" /> + {{ ' ' }} <small>{{ $t('notifications.repeated_you') }}</small> </span> <span v-if="notification.type === 'follow'"> @@ -86,6 +92,7 @@ class="type-icon" icon="user-plus" /> + {{ ' ' }} <small>{{ $t('notifications.followed_you') }}</small> </span> <span v-if="notification.type === 'follow_request'"> @@ -93,6 +100,7 @@ class="type-icon" icon="user" /> + {{ ' ' }} <small>{{ $t('notifications.follow_request') }}</small> </span> <span v-if="notification.type === 'move'"> @@ -100,15 +108,27 @@ 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 === 'poll'"> + <FAIcon + class="type-icon" + icon="poll-h" + /> + {{ ' ' }} + <small>{{ $t('notifications.poll_ended') }}</small> + </span> </div> <div v-if="isStatusNotification" @@ -161,18 +181,26 @@ 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 @@ -184,8 +212,9 @@ </router-link> </div> <template v-else> - <status-content + <StatusContent class="faint" + :compact="true" :status="notification.action" /> </template> diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue index ba0e90a0..b0213167 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> @@ -107,15 +116,14 @@ export default { align-self: stretch; > button { - font-size: 1.2em; - padding-left: 0.7em; - padding-right: 0.2em; line-height: 100%; height: 100%; - } + width: var(--__panel-heading-height-inner); + text-align: center; - .dropdown-item { - margin: 0; + svg { + font-size: 1.2em; + } } } diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index c8f1ebcb..0851f407 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' @@ -23,13 +24,13 @@ 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 { @@ -40,6 +41,11 @@ const Notifications = { seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT } }, + provide () { + return { + popoversZLayer: computed(() => this.popoversZLayer) + } + }, computed: { mainClass () { return this.minimalMode ? '' : 'panel panel-default' @@ -65,6 +71,22 @@ const Notifications = { 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) }, diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 9e5663ed..3d3408f7 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; @@ -47,6 +43,10 @@ } } + &:last-child .Notification { + border-bottom: none; + } + .non-mention { display: flex; flex: 1; @@ -64,8 +64,6 @@ } .follow-request-accept { - cursor: pointer; - &:hover { color: $fallback--text; color: var(--text, $fallback--text); @@ -73,8 +71,6 @@ } .follow-request-reject { - cursor: pointer; - &:hover { color: $fallback--cRed; color: var(--cRed, $fallback--cRed); @@ -117,13 +113,13 @@ } .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%; @@ -146,7 +142,7 @@ } .timeago { - margin-right: .2em; + margin-right: 0.2em; } .status-content { @@ -159,7 +155,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..e778e27b 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -1,69 +1,74 @@ <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"> + <div + :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> + <button + v-if="unseenCount" + class="button-default read-button" + @click.prevent="markAsSeen" + > + {{ $t('notifications.read') }} + </button> + <NotificationFilters /> </div> - </div> - <div class="panel-footer notifications-footer"> - <div - v-if="bottomedOut" - class="new-status-notification text-center faint" - > - {{ $t('notifications.no_more_notifications') }} + <div class="panel-body"> + <div + v-for="notification in notificationsToDisplay" + :key="notification.id" + 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> + </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/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..d2af59fe 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -31,13 +31,35 @@ 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 }, + 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 + 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, + childrenShown: new Set() } }, methods: { @@ -47,9 +69,7 @@ const Popover = { }, updateStyles () { if (this.hidden) { - this.styles = { - opacity: 0 - } + this.styles = {} return } @@ -57,14 +77,26 @@ const Popover = { // 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 // 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) + + // 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 +104,156 @@ 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 { + // Default to whatever user wished with placement prop + let usingTop = this.placement !== 'bottom' - const xOffset = (this.offset && this.offset.x) || 0 - const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset + // 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 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 + } - // 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 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') { @@ -160,8 +266,24 @@ const Popover = { }, onClickOutside (e) { 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 +297,18 @@ const Popover = { this.oldSize = { width: content.offsetWidth, height: content.offsetHeight } } }, - created () { - document.addEventListener('click', this.onClickOutside) + mounted () { + 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..bd59cade 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -1,30 +1,37 @@ <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" @click="onClick" > <slot name="trigger" /> </button> - <div - v-if="!hidden" - ref="content" - :style="styles" - class="popover" - :class="popoverClass || 'popover-default'" - > - <slot - name="content" - class="popover-inner" - :close="hidePopover" - /> - </div> - </div> + <teleport to="#popovers"> + <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 +40,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 +84,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 +101,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; @@ -110,14 +129,15 @@ &: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 +162,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..c0d80b20 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}` }) @@ -77,6 +78,12 @@ const PostStatusForm = { 'emojiPickerPlacement', 'optimisticPosting' ], + emits: [ + 'posted', + 'resize', + 'mediaplay', + 'mediapause' + ], components: { MediaUpload, EmojiInput, @@ -85,7 +92,8 @@ const PostStatusForm = { Checkbox, Select, Attachment, - StatusContent + StatusContent, + Gallery }, mounted () { this.updateIdempotencyKey() @@ -234,7 +242,7 @@ const PostStatusForm = { }) }, watch: { - 'newStatus': { + newStatus: { deep: true, handler () { this.statusChanged() @@ -265,7 +273,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 +392,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 +462,7 @@ const PostStatusForm = { }, onEmojiInputInput (e) { this.$nextTick(() => { - this.resize(this.$refs['textarea']) + this.resize(this.$refs.textarea) }) }, resize (e) { @@ -454,8 +477,8 @@ const PostStatusForm = { 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 +486,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 @@ -541,7 +564,7 @@ const PostStatusForm = { 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..62613bd1 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" @@ -277,42 +269,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 +325,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 +394,6 @@ border-radius: var(--tooltipRadius, $fallback--tooltipRadius); padding: 0.5em; margin: 0; - line-height: 1.4em; } .text-format { @@ -426,13 +407,16 @@ display: flex; justify-content: space-between; padding-top: 5px; + align-items: baseline; } .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 +443,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 +487,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 +502,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 +539,6 @@ } } - .btn { - cursor: pointer; - } - .btn[disabled] { cursor: not-allowed; } @@ -598,7 +555,6 @@ .drop-indicator { position: absolute; - z-index: 1; width: 100%; height: 100%; font-size: 5em; @@ -616,11 +572,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/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..37d6e7d0 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -1,6 +1,7 @@ import Popover from '../popover/popover.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' +import { trim } from 'lodash' library.add(faSmileBeam) @@ -43,8 +44,8 @@ const ReactButton = { }, emojis () { if (this.filterWord !== '') { - const filterWordLowercase = this.filterWord.toLowerCase() - let orderedEmojiList = [] + const filterWordLowercase = trim(this.filterWord.toLowerCase()) + const orderedEmojiList = [] for (const emoji of this.$store.state.instance.emoji) { if (emoji.replacement === this.filterWord) return [emoji] diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index c69c315b..5a809847 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -6,14 +6,16 @@ :offset="{ y: 5 }" :bound-to="{ x: 'container' }" remove-padding + popover-class="ReactButton popover-default" @show="focusInput" > - <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"> @@ -39,8 +41,8 @@ <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')" > @@ -48,12 +50,12 @@ class="fa-scale-110 fa-old-padding" :icon="['far', 'smile-beam']" /> - </button> + </span> </template> </Popover> </template> -<script src="./react_button.js" ></script> +<script src="./react_button.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -101,7 +103,7 @@ cursor: pointer; flex-basis: 20%; - line-height: 1.5em; + line-height: 1.5; align-content: center; &:hover { 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..d78d8da9 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"> <span>{{ $t('registration.validations.password_confirmation_required') }}</span> </li> - <li v-if="!$v.user.confirm.sameAsPassword"> + <li v-if="!v$.user.confirm.sameAsPassword"> <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/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 859ce499..5a15d387 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -36,7 +36,7 @@ </div> </template> -<script src="./retweet_button.js" ></script> +<script src="./retweet_button.js"></script> <style lang="scss"> @import '../../_variables.scss'; diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index c0d20c5e..ca075270 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 @@ -221,7 +226,7 @@ export default Vue.component('RichContent', { attrs.target = '_blank' const newChildren = [...children].reverse().map(processItemReverse).reverse() - return <a {...{ attrs }}> + return <a {...attrs}> { newChildren } </a> } @@ -234,7 +239,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 +270,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..76ac30ef 100644 --- a/src/components/search/search.js +++ b/src/components/search/search.js @@ -1,6 +1,7 @@ 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 { @@ -17,7 +18,8 @@ const Search = { components: { FollowCard, Conversation, - Status + Status, + TabSwitcher }, props: [ 'query' 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/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..353e551c 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,8 +29,14 @@ 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: { diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index c3ee6583..69584808 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,8 +14,8 @@ > <slot /> </span> - <ModifiedIndicator :changed="isChanged" /> - </Checkbox> + {{ ' ' }} + <ModifiedIndicator :changed="isChanged" /><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..4677d4c1 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,8 +30,14 @@ 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: { diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index fa17661b..258c7422 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" @@ -18,6 +20,7 @@ </option> </Select> <ModifiedIndicator :changed="isChanged" /> + <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..17dc0e7b --- /dev/null +++ b/src/components/settings_modal/helpers/integer_setting.js @@ -0,0 +1,41 @@ +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)) + } + } +} 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..e661a025 --- /dev/null +++ b/src/components/settings_modal/helpers/integer_setting.vue @@ -0,0 +1,24 @@ +<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" /> + </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/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..73413b48 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(), diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 50ee20e0..97046ff0 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -21,6 +21,7 @@ </li> <li> <BooleanSetting + v-if="user" :disabled="hideFilteredStatuses" path="hideMutedThreads" > @@ -29,6 +30,7 @@ </li> <li> <BooleanSetting + v-if="user" :disabled="hideFilteredStatuses" path="hideMutedPosts" > @@ -38,11 +40,22 @@ </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" @@ -60,17 +73,13 @@ </li> <h3>{{ $t('settings.attachments') }}</h3> <li> - <label for="maxThumbnails"> - {{ $t('settings.max_thumbnails') }} - </label> - <input - id="maxThumbnails" - path.number="maxThumbnails" - class="number-input" - type="number" - min="0" - step="1" + <IntegerSetting + path="maxThumbnails" + expert="1" + :min="0" > + {{ $t('settings.max_thumbnails') }} + </IntegerSetting> </li> <li> <BooleanSetting path="hideAttachments"> @@ -84,7 +93,10 @@ </li> </ul> </div> - <div class="setting-item"> + <div + v-if="expertLevel > 0" + class="setting-item" + > <h2>{{ $t('settings.user_profiles') }}</h2> <ul class="setting-list"> <li> @@ -94,46 +106,6 @@ </li> </ul> </div> - <div class="setting-item"> - <h2>{{ $t('settings.notifications') }}</h2> - <ul class="setting-list"> - <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> - </ul> - </li> - </ul> - </div> </div> </template> <script src="./filtering_tab.js"></script> diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index 952c328d..1e11b9e0 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -1,8 +1,11 @@ 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 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,11 +23,26 @@ 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}`) + })), loopSilentAvailable: // Firefox Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || @@ -37,7 +55,10 @@ const GeneralTab = { components: { BooleanSetting, ChoiceSetting, - InterfaceLanguageSwitcher + IntegerSetting, + InterfaceLanguageSwitcher, + ScopeSelector, + ServerSideIndicator }, computed: { postFormats () { @@ -56,7 +77,18 @@ const GeneralTab = { !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 44b1ac92..a2609200 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -4,7 +4,11 @@ <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"> @@ -45,26 +49,78 @@ </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="virtualScrolling"> + <BooleanSetting + path="virtualScrolling" + expert="1" + > {{ $t('settings.virtual_scrolling') }} </BooleanSetting> </li> <li> - <BooleanSetting path="autohideFloatingPostButton"> + <BooleanSetting path="disableStickyHeaders"> + {{ $t('settings.disable_sticky_headers') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="showScrollbars"> + {{ $t('settings.show_scrollbars') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="userPopoverZoom" + expert="1" + > + {{ $t('settings.user_popover_avatar_zoom') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="userPopoverOverlay" + expert="1" + > + {{ $t('settings.user_popover_avatar_overlay') }} + </BooleanSetting> + </li> + <li> + <ChoiceSetting + v-if="user" + id="thirdColumnMode" + path="thirdColumnMode" + :options="thirdColumnModeOptions" + > + {{ $t('settings.third_column_mode') }} + </ChoiceSetting> + </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 v-if="instanceShoutboxPresent"> - <BooleanSetting path="hideShoutbox"> + <BooleanSetting + path="hideShoutbox" + expert="1" + > {{ $t('settings.hide_shoutbox') }} </BooleanSetting> </li> @@ -74,18 +130,79 @@ <h2>{{ $t('settings.post_look_feel') }}</h2> <ul class="setting-list"> <li> + <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="emojiReactionsOnTimeline"> + <BooleanSetting + path="emojiReactionsOnTimeline" + expert="1" + > {{ $t('settings.emoji_reactions_on_timeline') }} </BooleanSetting> </li> + <li> + <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"> + <BooleanSetting + path="useContainFit" + expert="1" + > {{ $t('settings.use_contain_fit') }} </BooleanSetting> </li> @@ -98,6 +215,7 @@ <li> <BooleanSetting path="preloadImage" + expert="1" :disabled="!hideNsfw" > {{ $t('settings.preload_images') }} @@ -106,6 +224,7 @@ <li> <BooleanSetting path="useOneClickNsfw" + expert="1" :disabled="!hideNsfw" > {{ $t('settings.use_one_click_nsfw') }} @@ -113,7 +232,10 @@ </li> </ul> <li> - <BooleanSetting path="loopVideo"> + <BooleanSetting + path="loopVideo" + expert="1" + > {{ $t('settings.loop_video') }} </BooleanSetting> <ul @@ -123,6 +245,7 @@ <li> <BooleanSetting path="loopVideoSilentOnly" + expert="1" :disabled="!loopVideo || !loopSilentAvailable" > {{ $t('settings.loop_video_silent_only') }} @@ -137,16 +260,14 @@ </ul> </li> <li> - <BooleanSetting path="playVideosInModal"> + <BooleanSetting + path="playVideosInModal" + expert="1" + > {{ $t('settings.play_videos_in_modal') }} </BooleanSetting> </li> - <h3>{{ $t('settings.fun') }}</h3> - <li> - <BooleanSetting path="greentext"> - {{ $t('settings.greentext') }} - </BooleanSetting> - </li> + <h3>{{ $t('settings.mention_links') }}</h3> <li> <ChoiceSetting id="mentionLinkDisplay" @@ -156,45 +277,102 @@ {{ $t('settings.mention_link_display') }} </ChoiceSetting> </li> - <ul - class="setting-list suboptions" - > - <li - v-if="mentionLinkDisplay === 'short'" + <li> + <BooleanSetting + path="mentionLinkShowTooltip" + expert="1" > - <BooleanSetting path="mentionLinkShowTooltip"> - {{ $t('settings.mention_link_show_tooltip') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="useAtIcon"> - {{ $t('settings.use_at_icon') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="mentionLinkShowAvatar"> - {{ $t('settings.mention_link_show_avatar') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="mentionLinkFadeDomain"> - {{ $t('settings.mention_link_fade_domain') }} - </BooleanSetting> - </li> - </ul> + {{ $t('settings.mention_link_use_tooltip') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + path="useAtIcon" + expert="1" + > + {{ $t('settings.use_at_icon') }} + </BooleanSetting> + </li> + <li> + <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"> + <div + v-if="user" + class="setting-item" + > <h2>{{ $t('settings.composing') }}</h2> <ul class="setting-list"> <li> - <BooleanSetting path="scopeCopy"> + <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"> + <BooleanSetting + path="alwaysShowSubjectInput" + expert="1" + > {{ $t('settings.subject_input_always_show') }} </BooleanSetting> </li> @@ -203,6 +381,7 @@ id="subjectLineBehavior" path="subjectLineBehavior" :options="subjectLineOptions" + expert="1" > {{ $t('settings.subject_line_behavior') }} </ChoiceSetting> @@ -217,43 +396,39 @@ </ChoiceSetting> </li> <li> - <BooleanSetting path="minimalScopesMode"> + <BooleanSetting + path="minimalScopesMode" + expert="1" + > {{ $t('settings.minimal_scopes_mode') }} </BooleanSetting> </li> <li> - <BooleanSetting path="sensitiveByDefault"> - {{ $t('settings.sensitive_by_default') }} - </BooleanSetting> - </li> - <li> - <BooleanSetting path="alwaysShowNewPostButton"> + <BooleanSetting + path="alwaysShowNewPostButton" + expert="1" + > {{ $t('settings.always_show_post_button') }} </BooleanSetting> </li> <li> - <BooleanSetting path="autohideFloatingPostButton"> + <BooleanSetting + path="autohideFloatingPostButton" + expert="1" + > {{ $t('settings.autohide_floating_post_button') }} </BooleanSetting> </li> <li> - <BooleanSetting path="padEmoji"> + <BooleanSetting + path="padEmoji" + expert="1" + > {{ $t('settings.pad_emoji') }} </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> - </li> - </ul> - </div> </div> </template> 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..c515d542 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> @@ -63,7 +63,7 @@ :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 0ae95dbd..af7206a7 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,27 +32,20 @@ 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, newBirthday: this.$store.state.users.currentUser.birthday, showBirthday: this.$store.state.users.currentUser.show_birthday, 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: { @@ -56,12 +54,15 @@ const ProfileTab = { EmojiInput, Autosuggest, ProgressButton, - Checkbox + Checkbox, + BooleanSetting, + InterfaceLanguageSwitcher }, computed: { user () { return this.$store.state.users.currentUser }, + ...SharedComputedObject(), emojiUserSuggestor () { return suggestor({ emoji: [ @@ -72,10 +73,12 @@ const ProfileTab = { }) }, emojiSuggestor () { - return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, - ...this.$store.state.instance.customEmoji - ] }) + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ] + }) }, userSuggestor () { return suggestor({ store: this.$store }) @@ -116,29 +119,27 @@ 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, + birthday: this.newBirthday || '', + show_birthday: this.showBirthday + /* 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, - birthday: this.newBirthday || null, - show_birthday: this.showBirthday - /* 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]) @@ -208,8 +209,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) @@ -222,9 +223,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 24287511..2303f5e8 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 9b92056d..faa05bcc 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> <p>{{ $t('settings.birthday.label') }}</p> <input @@ -140,8 +80,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> @@ -160,6 +101,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" @@ -178,14 +126,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 @@ -207,14 +158,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 @@ -246,14 +200,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 @@ -281,6 +238,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..c74a0c67 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"> 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..282cb384 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 { @@ -320,9 +319,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 +333,7 @@ export default { return this.shadowsLocal[this.shadowSelected] }, set (v) { - set(this.shadowsLocal, this.shadowSelected, v) + this.shadowsLocal[this.shadowSelected] = v } }, themeValid () { @@ -378,6 +377,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 +560,7 @@ export default { .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal')) .filter(_ => !v1OnlyNames.includes(_)) .forEach(key => { - set(this.$data, key, undefined) + this.$data[key] = undefined }) }, @@ -565,7 +568,7 @@ export default { Object.keys(this.$data) .filter(_ => _.endsWith('RadiusLocal')) .forEach(key => { - set(this.$data, key, undefined) + this.$data[key] = undefined }) }, @@ -573,7 +576,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..f45f8def 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -32,7 +32,7 @@ library.add( ) const SideDrawer = { - props: [ 'logout' ], + props: ['logout'], data: () => ({ closed: true, closeGesture: undefined @@ -49,7 +49,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) }, diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index dd88de7d..7547fb08 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -204,14 +204,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..a7fbc718 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -17,8 +17,8 @@ const StaffPanel = { 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([ 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 d8f94926..384063a7 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -4,13 +4,13 @@ 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 MentionsLine from 'src/components/mentions_line/mentions_line.vue' @@ -35,7 +35,10 @@ import { faStar, faEyeSlash, faEye, - faThumbtack + faThumbtack, + faChevronUp, + faChevronDown, + faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons' library.add( @@ -52,9 +55,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 +105,6 @@ const Status = { RetweetButton, ExtraButtons, PostStatusForm, - UserCard, UserAvatar, AvatarList, Timeago, @@ -74,7 +114,8 @@ const Status = { StatusContent, RichContent, MentionLink, - MentionsLine + MentionsLine, + UserPopover }, props: [ 'statusoid', @@ -89,20 +130,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 +225,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)) @@ -191,7 +262,9 @@ const Status = { // Thread is muted status.thread_muted || // Wordfiltered - this.muteWordHits.length > 0 + this.muteWordHits.length > 0 || + // bot status + (this.muteBotStatuses && this.botStatus && !this.compact) return !this.unmuted && !this.shouldNotMute && reasonsToMute }, userIsMuted () { @@ -238,7 +311,7 @@ const Status = { return this.mergedConfig.hideWordFilteredPosts }, hideStatus () { - return (this.virtualHidden || !this.shouldNotMute) && ( + return (!this.shouldNotMute) && ( (this.muted && this.hideFilteredStatuses) || (this.userIsMuted && this.hideMutedUsers) || (this.status.thread_muted && this.hideMutedThreads) || @@ -288,11 +361,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 }, @@ -304,6 +384,15 @@ 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) } }, methods: { @@ -326,7 +415,7 @@ const Status = { this.error = undefined }, toggleReplying () { - this.replying = !this.replying + controlledOrUncontrolledToggle(this, 'replying') }, gotoOriginal (id) { if (this.inConversation) { @@ -346,19 +435,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) @@ -370,6 +461,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 @@ -383,14 +479,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..b3ad3818 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; @@ -163,19 +159,25 @@ $status-margin: 0.75em; & .heading-reply-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 +211,6 @@ $status-margin: 0.75em; & .reply-to { white-space: nowrap; position: relative; - padding-right: 0.25em; } & .mentions-text, @@ -226,8 +227,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 +242,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 +288,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 +356,7 @@ $status-margin: 0.75em; } .favs-repeated-users { - margin-top: $status-margin; + margin-top: var(--status-margin, $status-margin); } .stats { @@ -382,19 +383,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 +409,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..967a966c 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 }]" > @@ -77,6 +78,7 @@ <UserAvatar v-if="retweet" class="left-side repeater-avatar" + :bot="rtBotIndicator" :better-shadow="betterShadow" :user="statusoid.user" /> @@ -99,6 +101,7 @@ :to="retweeterProfileLink" >{{ retweeter }}</router-link> </span> + {{ ' ' }} <FAIcon icon="retweet" class="repeat-icon" @@ -119,25 +122,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" @@ -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,6 +320,7 @@ class="mentions-line-first" /> </span> + {{ ' ' }} <MentionsLine v-if="hasMentionsLine" :mentions="mentionsLine.slice(1)" @@ -306,6 +335,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 +350,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 +455,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 +491,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_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..d7abbcb5 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -5,7 +5,9 @@ const StillImage = { 'mimetype', 'imageLoadError', 'imageLoadHandler', - 'alt' + 'alt', + 'height', + 'width' ], data () { return { @@ -15,6 +17,13 @@ const StillImage = { computed: { animated () { return this.stopGifs && (this.mimetype === 'image/gif' || this.src.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: { diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index 0623b42e..ab3080c8 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" @@ -18,6 +19,7 @@ @load="onLoad" @error="onError" > + <slot /> </div> </template> @@ -56,10 +58,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..7a086b26 100644 --- a/src/components/tab_switcher/tab_switcher.scss +++ b/src/components/tab_switcher/tab_switcher.scss @@ -25,8 +25,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 +167,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..4eaf597d --- /dev/null +++ b/src/components/thread_tree/thread_tree.vue @@ -0,0 +1,135 @@ +<template> + <div 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> + </div> +</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..2b487dfd 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]) }} + {{ $tc(relativeTime.key, relativeTime.num, [relativeTime.num]) }} </time> </template> @@ -31,7 +31,7 @@ export default { 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..c575e876 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -12,19 +12,6 @@ library.add( faCog ) -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', @@ -35,7 +22,8 @@ const Timeline = { 'embedded', 'count', 'pinnedStatusIds', - 'inProfile' + 'inProfile', + 'footerSlipgate' // reference to an element where we should put our footer ], data () { return { @@ -53,6 +41,12 @@ const Timeline = { TimelineQuickSettings }, 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 }, @@ -67,29 +61,25 @@ const Timeline = { } }, 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 () { @@ -122,7 +112,7 @@ 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) @@ -153,6 +143,7 @@ const Timeline = { this.$store.commit('showNewStatuses', { timeline: this.timelineName }) this.paused = false } + window.scrollTo({ top: 0 }) }, fetchOlderStatuses: throttle(function () { const store = this.$store diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss index 2c5a67e2..9e009fd3 100644 --- a/src/components/timeline/timeline.scss +++ b/src/components/timeline/timeline.scss @@ -9,23 +9,23 @@ 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..266c1d9a 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -1,5 +1,5 @@ <template> - <div :class="[classes.root, 'Timeline']"> + <div :class="['Timeline', classes.root]"> <div :class="classes.header"> <TimelineMenu v-if="!embedded" /> <button @@ -10,7 +10,7 @@ {{ loadButtonString }} </button> <div - v-else + v-else-if="!embedded" class="loadmore-text faint" @click.prevent > @@ -23,64 +23,65 @@ ref="timeline" class="timeline" > - <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'" + 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" + 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" + <teleport + :to="footerSlipgate" + :disabled="!embedded || !footerSlipgate" > - {{ $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()" - > - <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/timeline_quick_settings.js b/src/components/timeline/timeline_quick_settings.js index 7b4931ce..92d5ac14 100644 --- a/src/components/timeline/timeline_quick_settings.js +++ b/src/components/timeline/timeline_quick_settings.js @@ -53,6 +53,13 @@ const TimelineQuickSettings = { const value = !this.hideMutedPosts 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 }) + } } } } diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/timeline/timeline_quick_settings.vue index 98996ebd..297bc72a 100644 --- a/src/components/timeline/timeline_quick_settings.vue +++ b/src/components/timeline/timeline_quick_settings.vue @@ -4,7 +4,7 @@ class="TimelineQuickSettings" :bound-to="{ x: 'container' }" > - <template v-slot:content> + <template #content> <div class="dropdown-menu"> <div v-if="loggedIn"> <button @@ -12,8 +12,8 @@ @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 @@ -21,8 +21,8 @@ @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 @@ -30,8 +30,8 @@ @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 @@ -41,6 +41,15 @@ </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 @@ -71,7 +80,7 @@ </button> </div> </template> - <template v-slot:trigger> + <template #trigger> <button class="button-unstyled"> <FAIcon icon="filter" /> </button> @@ -84,18 +93,16 @@ <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%; - } + width: var(--__panel-heading-height-inner); + text-align: center; - .dropdown-item { - margin: 0; + svg { + font-size: 1.2em; + } } } diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index bab51e75..a11e7b7e 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -11,9 +11,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' } diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue index 8f14093f..c24b9d72 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -3,19 +3,17 @@ 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> + <TimelineMenuContent /> </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 +25,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 +48,6 @@ border-top-left-radius: 0; } - &.open .timeline-menu-popover { - transform: translateY(0); - } - .timeline-menu-title { margin: 0; cursor: pointer; @@ -104,6 +82,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 +118,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; diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue index bed1b679..59e9e43c 100644 --- a/src/components/timeline_menu/timeline_menu_content.vue +++ b/src/components/timeline_menu/timeline_menu_content.vue @@ -63,4 +63,4 @@ </ul> </template> -<script src="./timeline_menu_content.js" ></script> +<script src="./timeline_menu_content.js"></script> 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..4c81e2c7 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -14,7 +14,9 @@ import { faRss, faSearchPlus, faExternalLinkAlt, - faEdit + faEdit, + faTimes, + faExpandAlt } from '@fortawesome/free-solid-svg-icons' library.add( @@ -22,12 +24,21 @@ 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' ], data () { return { @@ -47,15 +58,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(', ') } @@ -166,10 +178,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..a0bbc6a6 --- /dev/null +++ b/src/components/user_card/user_card.scss @@ -0,0 +1,348 @@ +@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; + } + } +} + +.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 5f957003..ace89c51 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,6 +83,27 @@ :user="user" :relationship="relationship" /> + <router-link + v-if="onClose" + :to="userProfileLink(user)" + class="button-unstyled external-link-button" + @click="onClose" + > + <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"> <router-link @@ -141,6 +173,7 @@ class="userHighlightCl" type="color" > + {{ ' ' }} <Select :id="'userHighlightSel'+user.id" v-model="userHighlightType" @@ -275,6 +308,7 @@ class="user-card-bio" :html="user.description_html" :emoji="user.emoji" + :handle-links="true" /> </div> </div> @@ -282,320 +316,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_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js index 32ca2b8d..e24eb9f7 100644 --- a/src/components/user_list_popover/user_list_popover.js +++ b/src/components/user_list_popover/user_list_popover.js @@ -1,3 +1,6 @@ +import { defineAsyncComponent } from 'vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' + import { library } from '@fortawesome/fontawesome-svg-core' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' @@ -11,8 +14,9 @@ const UserListPopover = { 'users' ], components: { - Popover: () => import('../popover/popover.vue'), - UserAvatar: () => import('../user_avatar/user_avatar.vue') + RichContent, + 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..a3ce54c3 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,7 +22,12 @@ /> <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> </div> @@ -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_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 5685916a..243de387 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -2,7 +2,7 @@ <div class="user-panel"> <div v-if="signedIn" - key="user-panel" + key="user-panel-signed" class="panel panel-default signed-in" > <UserCard @@ -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..69b25383 --- /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: { + userPopoverZoom () { + return this.$store.getters.mergedConfig.userPopoverZoom + }, + 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..4e999672 --- /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="userPopoverZoom ? 'zoom' : close" + :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 05d5f0a6..16551624 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' @@ -42,7 +42,8 @@ const UserProfile = { return { error: false, userId: null, - tab: defaultTabKey + tab: defaultTabKey, + footerRef: null } }, created () { @@ -50,7 +51,7 @@ const UserProfile = { this.load(routeParams.name || routeParams.id) this.tab = get(this.$route, 'query.tab', defaultTabKey) }, - destroyed () { + unmounted () { this.stopFetching() }, computed: { @@ -85,6 +86,9 @@ const UserProfile = { } }, methods: { + setFooterRef (el) { + this.footerRef = el + }, load (userNameOrId) { const startFetchingTimeline = (timeline, userId) => { // Clear timeline only if load another user's profile diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 5e083198..6032abeb 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -8,7 +8,7 @@ :user-id="userId" :switcher="true" :selected="timeline.viewing" - :allow-zooming-avatar="true" + avatar-action="zoom" rounded="top" /> <span @@ -66,6 +66,7 @@ :user-id="userId" :pinned-status-ids="user.pinnedStatusIds" :in-profile="true" + :footer-slipgate="footerRef" /> <div v-if="followsTabVisible" @@ -74,7 +75,7 @@ :disabled="!user.friends_count" > <FriendList :user-id="userId"> - <template v-slot:item="{item}"> + <template #item="{item}"> <FollowCard :user="item" /> </template> </FriendList> @@ -86,7 +87,7 @@ :disabled="!user.followers_count" > <FollowerList :user-id="userId"> - <template v-slot:item="{item}"> + <template #item="{item}"> <FollowCard :user="item" :no-follows-you="isUs" @@ -104,6 +105,7 @@ :timeline="media" :user-id="userId" :in-profile="true" + :footer-slipgate="footerRef" /> <Timeline v-if="isUs" @@ -115,8 +117,13 @@ timeline-name="favorites" :timeline="favorites" :in-profile="true" + :footer-slipgate="footerRef" /> </tab-switcher> + <div + :ref="setFooterRef" + class="panel-footer" + /> </div> <div v-else @@ -148,6 +155,9 @@ flex: 2; flex-basis: 500px; + // No sticky header on user profile + --currentPanelStack: 1; + .user-birthday { margin: 0 0.75em 0.5em; } @@ -190,7 +200,7 @@ } .user-profile-field-name, .user-profile-field-value { - line-height: 18px; + line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -206,24 +216,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.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index 1f67a5cc..429a66e2 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -45,7 +45,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 +53,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 +76,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 +87,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/ca.json b/src/i18n/ca.json index 74260143..5f2795a8 100644 --- a/src/i18n/ca.json +++ b/src/i18n/ca.json @@ -621,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", 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 abdd57b5..a5936613 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -46,7 +46,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", @@ -85,7 +85,13 @@ }, "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 +124,9 @@ }, "media_modal": { "previous": "Previous", - "next": "Next" + "next": "Next", + "counter": "{current} / {total}", + "hide": "Close media viewer" }, "nav": { "about": "About", @@ -152,7 +160,8 @@ "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}", + "poll_ended": "poll has ended" }, "polls": { "add_poll": "Add poll", @@ -225,8 +234,9 @@ } }, "registration": { - "bio": "Bio", + "bio_optional": "Bio (optional)", "email": "Email", + "email_optional": "Email (optional)", "fullname": "Display name", "password_confirm": "Password confirmation", "registration": "Registration", @@ -246,7 +256,8 @@ "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", @@ -258,11 +269,14 @@ }, "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", "post_look_feel": "Posts Look & Feel", + "mention_links": "Mention links", "mfa": { "otp": "OTP", "setup_otp": "Setup OTP", @@ -292,6 +306,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", @@ -303,6 +318,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", @@ -328,6 +353,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.", @@ -350,11 +388,13 @@ "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 (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", "always_show_post_button": "Always show floating New Post button", "hide_wallpaper": "Hide instance wallpaper", "preload_images": "Preload images", @@ -403,6 +443,7 @@ "label": "Birthday", "show_birthday": "Show my birthday" }, + "account_privacy": "Privacy", "use_contain_fit": "Don't crop the attachment in thumbnails", "name": "Name", "name_bio": "Name & bio", @@ -417,9 +458,11 @@ "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", @@ -433,7 +476,7 @@ "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", @@ -468,13 +511,27 @@ "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", + "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", + "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_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": "Pause animated images until you hover on them", - "streaming": "Enable automatic streaming of new posts when scrolled to the top", + "streaming": "Automatically show new posts when scrolled to the top", "user_mutes": "Users", "useStreamingApi": "Receive posts and notifications real-time", - "useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)", "text": "Text", "theme": "Theme", "theme_help": "Use hex color codes (#rrggbb) to customize your color theme.", @@ -489,16 +546,20 @@ "true": "yes" }, "virtual_scrolling": "Optimize timeline rendering", - "use_at_icon": "Display @ symbol as an icon instead of text", + "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_show_tooltip": "Show full user names as tooltip for remote users", + "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_fade_domain": "Fade domains (e.g. @example.org in @foo@example.org)", + "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_zoom": "Clicking on user avatar in popover zooms it instead of closing the popover", + "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", @@ -661,38 +722,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", @@ -722,6 +771,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", @@ -737,7 +787,29 @@ "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" }, "user_card": { "approve": "Approve", @@ -796,7 +868,7 @@ "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", diff --git a/src/i18n/eo.json b/src/i18n/eo.json index 659b5960..3c401b30 100644 --- a/src/i18n/eo.json +++ b/src/i18n/eo.json @@ -606,7 +606,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", diff --git a/src/i18n/es.json b/src/i18n/es.json index eb9fc0a5..9887f007 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -731,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..306ed184 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,15 @@ "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é" + } }, "image_cropper": { "crop_picture": "Rogner l'image", @@ -79,7 +86,9 @@ }, "media_modal": { "previous": "Précédent", - "next": "Suivant" + "next": "Suivant", + "counter": "{current} / {total}", + "hide": "Fermer le visualiseur multimédia" }, "nav": { "about": "À propos", @@ -114,7 +123,8 @@ "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é" }, "interactions": { "favs_repeats": "Partages et favoris", @@ -178,7 +188,8 @@ }, "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 ?" }, "selectable_list": { "select_all": "Tout selectionner" @@ -267,8 +278,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 +581,71 @@ "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" }, "timeline": { "collapse": "Fermer", @@ -613,7 +688,33 @@ "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, {depth} niveaux maximum) | Montrer le reste du fil ({numStatus} messages, {depth} niveaux maximum)", + "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)" }, "user_card": { "approve": "Accepter", @@ -644,11 +745,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 +761,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 +781,9 @@ "striped": "Fond rayé" }, "bot": "Robot", - "edit_profile": "Éditer le profil" + "edit_profile": "Éditer le profil", + "deactivated": "Désactivé", + "follow_cancel": "Annuler la requête" }, "user_profile": { "timeline_title": "Flux du compte", @@ -748,13 +851,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é", @@ -826,7 +932,23 @@ "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", 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 fd64b7ae..73cc2a71 100644 --- a/src/i18n/id.json +++ b/src/i18n/id.json @@ -327,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", diff --git a/src/i18n/it.json b/src/i18n/it.json index a1ec37a2..c8c74b70 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -485,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", 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 be334651..fddf24db 100644 --- a/src/i18n/ja_pedantic.json +++ b/src/i18n/ja_pedantic.json @@ -729,8 +729,7 @@ "disable_remote_subscription": "他のインスタンスからフォローされないようにする", "disable_any_subscription": "フォローされないようにする", "quarantine": "他のインスタンスからの投稿を止める", - "delete_user": "ユーザーを削除", - "delete_user_confirmation": "あなたの精神状態に何か問題はございませんか? この操作を取り消すことはできません。" + "delete_user": "ユーザーを削除" }, "roles": { "moderator": "モデレーター", diff --git a/src/i18n/messages.js b/src/i18n/messages.js index 2a1161be..eae75c80 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -32,6 +32,7 @@ const loaders = { pt: () => import('./pt.json'), ro: () => import('./ro.json'), ru: () => import('./ru.json'), + sk: () => import('./sk.json'), te: () => import('./te.json'), uk: () => import('./uk.json'), zh: () => import('./zh.json'), @@ -41,12 +42,12 @@ const loaders = { const messages = { languages: ['en', ...Object.keys(loaders)], 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) + const messages = await loaders[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..5c00efc4 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -580,7 +580,6 @@ "remote_follow": "Volg vanop afstand", "statuses": "Statussen", "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", "disable_any_subscription": "Volgen van gebruiker in zijn geheel verbieden", 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 7cd31a06..efebcc83 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -633,11 +633,7 @@ "reply_visibility_following_short": "Pokazuj odpowiedzi obserwującym", "reply_visibility_self_short": "Pokazuj odpowiedzi tylko do mnie", "sensitive_by_default": "Domyślnie oznaczaj wpisy jako wrażliwe", - "hide_shoutbox": "Ukryj shoutbox instancji", - "birthday": { - "label": "Urodziny", - "show_birthday": "Pokazuj moją datę urodzenia" - } + "hide_shoutbox": "Ukryj shoutbox instancji" }, "time": { "day": "{0} dzień", @@ -766,16 +762,14 @@ "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", "highlight": { "disabled": "Bez wyróżnienia" }, - "bot": "Bot", - "birthday": "Urodzony(-a) {birthday}" + "bot": "Bot" }, "user_profile": { "timeline_title": "Oś czasu użytkownika", 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..7e6ff3f5 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -576,8 +576,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..4e62b4a9 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -755,7 +755,6 @@ "deactivate_account": "Деактивувати обліковий запис", "delete_account": "Видалити обліковий запис", "moderation": "Модерація", - "delete_user_confirmation": "Ви абсолютно впевнені? Цю дію неможливо буде скасовувати.", "delete_user": "Видалити обліковий запис", "strip_media": "Вилучити медіа з дописів користувача", "force_nsfw": "Позначити всі дописи як NSFW", diff --git a/src/i18n/vi.json b/src/i18n/vi.json index c77ad4ca..fd7ae25c 100644 --- a/src/i18n/vi.json +++ b/src/i18n/vi.json @@ -772,8 +772,7 @@ "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ế", - "delete_user_confirmation": "Bạn chắc chắn chưa? Hành động này không thể phục hồi." + "force_unlisted": "Đánh dấu tất cả tút là hạn chế" }, "highlight": { "disabled": "Không nổi bật", 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..c73a38ec 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -1,14 +1,16 @@ 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 = [ @@ -30,7 +32,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 +71,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..eacd554c 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' @@ -11,6 +9,7 @@ import statusesModule from './modules/statuses.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 shoutModule from './modules/shout.js' import oauthModule from './modules/oauth.js' import authFlowModule from './modules/auth_flow.js' @@ -21,34 +20,18 @@ import pollsModule from './modules/polls.js' import postStatusModule from './modules/postStatus.js' import chatsModule from './modules/chats.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', @@ -75,19 +58,21 @@ 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, api: apiModule, config: configModule, + serverSideConfig: serverSideConfigModule, shout: shoutModule, oauth: oauthModule, authFlow: authFlowModule, diff --git a/src/modules/api.js b/src/modules/api.js index 54f94356..28f2076e 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -233,7 +233,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,7 +244,7 @@ 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) }, 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 9f2d4ef3..eaf67a91 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,6 +1,9 @@ -import { set, delete as del } from 'vue' +import Cookies from 'js-cookie' import { setPreset, applyTheme } 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] @@ -12,10 +15,13 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0] export const multiChoiceProperties = [ 'postContentType', 'subjectLineBehavior', + 'conversationDisplay', // tree | linear + 'conversationOtherRepliesButton', // below | inside 'mentionLinkDisplay' // short | full_for_remote | full ] export const defaultState = { + expertLevel: 0, // used to track which settings to show and hide colors: {}, theme: undefined, customTheme: undefined, @@ -27,6 +33,7 @@ export const defaultState = { hideMutedPosts: undefined, // instance default hideMutedThreads: undefined, // instance default hideWordFilteredPosts: undefined, // instance default + muteBotStatuses: undefined, // instance default collapseMessageWithSubject: undefined, // instance default padEmoji: true, hideAttachments: false, @@ -41,8 +48,9 @@ export const defaultState = { alwaysShowNewPostButton: false, autohideFloatingPostButton: false, pauseOnUnfocused: true, - stopGifs: false, + stopGifs: true, replyVisibility: 'all', + thirdColumnMode: 'notifications', notificationVisibility: { follows: true, mentions: true, @@ -51,7 +59,8 @@ export const defaultState = { moves: true, emojiReactions: true, followRequest: true, - chatMention: true + chatMention: true, + polls: true }, webPushNotifications: false, muteWords: [], @@ -69,17 +78,29 @@ export const defaultState = { hideFilteredStatuses: undefined, // instance default playVideosInModal: false, useOneClickNsfw: false, - useContainFit: false, + useContainFit: true, + disableStickyHeaders: false, + showScrollbars: false, + userPopoverZoom: false, + userPopoverOverlay: true, 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 @@ -110,14 +131,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] } } }, @@ -126,7 +147,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) } @@ -151,6 +172,10 @@ const config = { break case 'interfaceLanguage': messages.setLanguage(this.getters.i18n, value) + Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value)) + break + case 'thirdColumnMode': + dispatch('setLayoutWidth', undefined) break } } 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 d686f258..220463ca 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -1,4 +1,3 @@ -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' @@ -25,14 +24,18 @@ const defaultState = { 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', @@ -51,6 +54,11 @@ const defaultState = { theme: 'pleroma-dark', virtualScrolling: true, sensitiveByDefault: false, + conversationDisplay: 'linear', + conversationTreeAdvanced: false, + conversationOtherRepliesButton: 'below', + conversationTreeFadeAncestors: false, + maxDepthInThread: 6, // Nasty stuff customEmoji: [], @@ -93,7 +101,7 @@ const instance = { mutations: { setInstanceOption (state, { name, value }) { if (typeof value !== 'undefined') { - set(state, name, value) + state[name] = value } }, setKnownDomains (state, domains) { 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/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/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/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/statuses.js b/src/modules/statuses.js index ac5d25c4..62251b0b 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, @@ -92,7 +91,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 +130,7 @@ const addStatusToGlobalStorage = (state, data) => { if (conversationsObject[conversationId]) { conversationsObject[conversationId].push(status) } else { - set(conversationsObject, conversationId, [status]) + conversationsObject[conversationId] = [status] } } return result @@ -246,10 +245,10 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } const processors = { - 'status': (status) => { + status: (status) => { addStatus(status, showImmediately) }, - 'retweet': (status) => { + retweet: (status) => { // RetweetedStatuses are never shown immediately const retweetedStatus = addStatus(status.retweeted_status, false, false) @@ -271,7 +270,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 +278,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 +292,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 +303,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) }) @@ -342,6 +341,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot } // 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 +523,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 +542,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 +563,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 }) { diff --git a/src/modules/users.js b/src/modules/users.js index 05ff44d5..13d4e318 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,9 +15,9 @@ export const mergeOrAdd = (arr, obj, item) => { } else { // This is a new item, prepare it arr.push(item) - set(obj, item.id, item) + obj[item.id] = item if (item.screen_name && !item.screen_name.includes('@')) { - set(obj, item.screen_name.toLowerCase(), item) + obj[item.screen_name.toLowerCase()] = item } return { item, new: true } } @@ -103,23 +103,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,26 +148,26 @@ 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 } mergeOrAdd(state.users, state.usersObject, user) }) }, updateUserRelationship (state, relationships) { relationships.forEach((relationship) => { - set(state.relationships, relationship.id, relationship) + state.relationships[relationship.id] = relationship }) }, saveBlockIds (state, blockIds) { @@ -222,7 +222,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 @@ -393,7 +393,7 @@ const users = { toggleActivationStatus ({ rootState, commit }, { user }) { const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser api({ user }) - .then((user) => { let deactivated = !user.is_active; 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 +457,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 } @@ -507,6 +507,8 @@ const users = { store.commit('resetStatuses') store.dispatch('resetChats') store.dispatch('setLastTimeline', 'public-timeline') + store.dispatch('setLayoutWidth', windowWidth()) + store.dispatch('setLayoutHeight', windowHeight()) }) }, loginUser (store, accessToken) { @@ -567,6 +569,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..3a814269 --- /dev/null +++ b/src/panel.scss @@ -0,0 +1,198 @@ +.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-height: 3.2em; + --__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding)); + + 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: 0.5em; + flex: none; + background-size: cover; + padding: 0.6em; + 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); + } + + .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); + } + } + } +} + +.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..7b7fbefd 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -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' @@ -74,7 +76,7 @@ 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_STREAMING = '/api/v1/streaming' @@ -82,15 +84,16 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers' const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` -const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats` +const PLEROMA_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_BACKUP_URL = '/api/v1/pleroma/backups' const oldfetch = window.fetch -let fetch = (url, options) => { +const fetch = (url, options) => { options = options || {} const baseUrl = '' const fullUrl = baseUrl + url @@ -102,7 +105,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 +154,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 +200,7 @@ const updateProfile = ({ credentials, params }) => { // homepage // location // token +// language const register = ({ params, credentials }) => { const { nickname, ...rest } = params return fetch(MASTODON_REGISTRATION_URL, { @@ -219,16 +229,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,7 +250,7 @@ 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' @@ -282,7 +292,7 @@ const unblockUser = ({ id, credentials }) => { } 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 +300,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 +308,13 @@ 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 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 +333,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 +343,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 +369,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 : '' @@ -375,7 +386,7 @@ const fetchFollowRequests = ({ 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 +402,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) { @@ -415,7 +426,7 @@ const tagUser = ({ tag, credentials, user }) => { return fetch(TAG_USER_URL, { method: 'PUT', - headers: headers, + headers, body: JSON.stringify(form) }) } @@ -432,7 +443,7 @@ const untagUser = ({ tag, credentials, user }) => { return fetch(TAG_USER_URL, { method: 'DELETE', - headers: headers, + headers, body: JSON.stringify(body) }) } @@ -485,7 +496,7 @@ const deleteUser = ({ credentials, user }) => { return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, { method: 'DELETE', - headers: headers + headers }) } @@ -504,7 +515,7 @@ const fetchTimeline = ({ 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, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, @@ -678,7 +689,7 @@ const postStatus = ({ form.append('preview', 'true') } - let postHeaders = authHeaders(credentials) + const postHeaders = authHeaders(credentials) if (idempotencyKey) { postHeaders['idempotency-key'] = idempotencyKey } @@ -782,6 +793,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 +922,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 +994,7 @@ const vote = ({ pollId, choices, credentials }) => { method: 'POST', credentials, payload: { - choices: choices + choices } }) } @@ -981,8 +1054,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 }, @@ -1004,7 +1077,7 @@ const searchUsers = ({ credentials, query }) => { const search2 = ({ credentials, q, resolve, limit, offset, following }) => { let url = MASTODON_SEARCH_2 - let params = [] + const params = [] if (q) { params.push(['q', encodeURIComponent(q)]) @@ -1028,7 +1101,7 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => { 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) }) @@ -1182,12 +1255,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 +1298,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 +1314,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 +1325,7 @@ const readChat = ({ id, lastReadId, credentials }) => { url: PLEROMA_CHAT_READ_URL(id), method: 'POST', payload: { - 'last_read_id': lastReadId + last_read_id: lastReadId }, credentials }) @@ -1319,12 +1392,18 @@ const apiService = { importFollows, deleteAccount, changeEmail, + moveAccount, + addAlias, + deleteAlias, + listAliases, changePassword, settingsMFA, mfaDisableOTP, generateMfaBackupCodes, mfaSetupOTP, mfaConfirmOTP, + addBackup, + listBackups, fetchFollowRequests, approveUser, denyUser, 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 86eb4c80..8bf28512 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -39,11 +39,12 @@ 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.id = String(data.id) + output._original = data // used for server-side settings if (masto) { output.screen_name = data.acct @@ -227,7 +228,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... @@ -248,7 +249,7 @@ export const parseAttachment = (data) => { 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 @@ -373,10 +374,10 @@ export const parseStatus = (data) => { 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) { 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..583ccca5 100644 --- a/src/services/html_converter/utility.service.js +++ b/src/services/html_converter/utility.service.js @@ -50,7 +50,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/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..a221b022 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -14,11 +14,12 @@ 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.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 +99,9 @@ export const prepareNotificationObject = (notification, i18n) => { case 'follow_request': i18nString = 'follow_request' 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 f83f871e..c0e299e4 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -12,20 +12,20 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => { const timelineData = rootState.statuses.notifications const hideMutedPosts = getters.mergedConfig.hideMutedPosts - args['withMuted'] = !hideMutedPosts + 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 }) @@ -38,7 +38,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 }) } 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..40d7b0fd 100644 --- a/src/services/push/push.js +++ b/src/services/push/push.js @@ -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/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index f75e6916..543aa874 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -13,10 +13,10 @@ 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') } 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..7ba138e0 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -34,20 +34,20 @@ 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.tag = tag + args.withMuted = !hideMutedPosts if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) { - args['replyVisibility'] = replyVisibility + args.replyVisibility = replyVisibility } const numStatusesBeforeFetch = timelineData.statuses.length @@ -60,7 +60,7 @@ 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 }) return { statuses, pagination } 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() } } |
