diff options
Diffstat (limited to 'src')
203 files changed, 3465 insertions, 2241 deletions
@@ -1,6 +1,5 @@ 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' @@ -16,13 +15,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, @@ -46,10 +46,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 +75,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..7e6d0dfc 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,77 +1,332 @@ +// 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; } 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: 1000; + 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 +339,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 +358,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 +396,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 +446,9 @@ a { } } -input, textarea, .input { - +input, +textarea, +.input { &.unstyled { border-radius: 0; background: none; @@ -200,10 +456,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 +469,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 +495,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 +518,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 +558,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 +578,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 +593,7 @@ option { .hide-number-spinner { -moz-appearance: textfield; + &[type=number]::-webkit-inner-spin-button, &[type=number]::-webkit-outer-spin-button { opacity: 0; @@ -331,11 +601,6 @@ option { } } -i[class*=icon-], .svg-inline--fa { - color: $fallback--icon; - color: var(--icon, $fallback--icon); -} - .btn-block { display: block; width: 100%; @@ -362,273 +627,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 +664,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 +718,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 +733,7 @@ nav { position: absolute; top: 0; right: 0; - padding: .5em; + padding: 0.5em; color: inherit; } } @@ -744,72 +750,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 +759,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 +791,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.2s; +} + +.fade-enter-from, +.fade-leave-active { + opacity: 0; +} diff --git a/src/App.vue b/src/App.vue index eb65b548..21f6f686 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,39 +1,32 @@ <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="sidebar-flexer mobile-hidden" - :style="sidebarAlign" - > - <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> + <div class="underlay"/> + <div id="sidebar" class="column -scrollable" :class="{ '-show-scrollbar': showScrollbars }"> + <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,19 +40,20 @@ </div> <router-view /> </div> - <media-modal /> + <div id="notifs-column" class="column -scrollable" :class="{ '-show-scrollbar': showScrollbars }"/> </div> + <media-modal /> <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> </template> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index c4a0a800..f655c38f 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' @@ -326,8 +332,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() @@ -367,25 +373,32 @@ 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) + + app.mount('#app') + + return app } export default afterStoreSetup diff --git a/src/boot/routes.js b/src/boot/routes.js index 1bc1f9f7..726476a8 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -46,7 +46,7 @@ export default (store) => { { 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([^/@]+)', + path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)', component: RemoteUserResolver, beforeEnter: validateAuthenticatedRoute }, @@ -62,14 +62,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..5d5d6479 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 /> 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..c35d01af 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -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.scss b/src/components/attachment/attachment.scss index dfda15bf..b2dea98d 100644 --- a/src/components/attachment/attachment.scss +++ b/src/components/attachment/attachment.scss @@ -173,7 +173,7 @@ margin: 8px; word-break: break-all; h1 { - font-size: 14px; + font-size: 1rem; margin: 0px; } } 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/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 53deb1df..eeca7828 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -4,7 +4,7 @@ <UserAvatar class="avatar" :user="user" - @click.prevent.native="toggleUserExpanded" + @click.prevent="toggleUserExpanded" /> </router-link> <div 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..9f6e64e3 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 }) }, @@ -115,9 +110,6 @@ const Chat = { '$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..58e8d0b3 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> 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..5bac7736 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -27,6 +27,7 @@ const ChatMessage = { 'chatViewItem', 'hoveredMessageChain' ], + emits: ['hover'], components: { Popover, Attachment, 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..f6e299ad 100644 --- a/src/components/chat_title/chat_title.js +++ b/src/components/chat_title/chat_title.js @@ -1,11 +1,12 @@ -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' -export default Vue.component('chat-title', { +export default { name: 'ChatTitle', components: { - UserAvatar + UserAvatar, + RichContent }, props: [ 'user', 'withAvatar' @@ -23,4 +24,4 @@ export default Vue.component('chat-title', { 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 a92028e8..7f6aaaa4 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -4,20 +4,21 @@ :title="title" > <router-link + class="avatar-container" v-if="withAvatar && user" :to="getUserProfileLink(user)" > <UserAvatar + class="titlebar-avatar" :user="user" - width="23px" - height="23px" /> </router-link> <RichContent + v-if="user" class="username" - :title="'@'+user.screen_name_ui" + :title="'@'+(user && user.screen_name_ui)" :html="htmlTitle" - :emoji="user.emoji" + :emoji="user.emoji || []" /> </div> </template> @@ -32,7 +33,6 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - align-items: center; --emoji-size: 14px; @@ -45,11 +45,15 @@ overflow: hidden; } - .Avatar { - width: 23px; - height: 23px; - margin-right: 0.5em; + .avatar-container { + align-self: center; + line-height: 1; + } + .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..83695912 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,12 +22,9 @@ <script> export default { - model: { - prop: 'checked', - event: 'change' - }, + emits: ['update:modelValue'], props: [ - 'checked', + 'modelValue', 'indeterminate', 'disabled' ] diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue index 8fb16113..e84603c3 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" @@ -67,7 +67,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,18 +91,19 @@ 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('--') } } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 7628ceaa..6088e1ca 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 @@ -27,20 +27,24 @@ v-if="shouldShowAllConversationButton" class="conversation-dive-to-top-level-box" > - <i18n - path="status.show_all_conversation_with_icon" + <i18n-t + keypath="status.show_all_conversation_with_icon" tag="button" class="button-unstyled -link" @click.prevent="diveToTopLevel" + scope="global" > - <FAIcon - place="icon" - icon="angle-double-left" - /> - <span place="text"> - {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }} - </span> - </i18n> + <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" @@ -96,20 +100,24 @@ <div class="thread-ancestor-dive-box-inner" > - <i18n + <i18n-t tag="button" - path="status.ancestor_follow_with_icon" + scope="global" + keypath="status.ancestor_follow_with_icon" class="button-unstyled -link thread-tree-show-replies-button" @click.prevent="diveIntoStatus(status.id)" > - <FAIcon - place="icon" - icon="angle-double-right" - /> - <span place="text"> - {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }} - </span> - </i18n> + <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> @@ -193,6 +201,8 @@ @import '../../_variables.scss'; .Conversation { + z-index: 1; + .conversation-dive-to-top-level-box { padding: var(--status-margin, $status-margin); border-bottom-width: 1px; @@ -215,6 +225,7 @@ --text: var(--faint); color: var(--text); } + .thread-ancestor-dive-box { padding-left: var(--status-margin, $status-margin); border-bottom-width: 1px; @@ -242,6 +253,7 @@ .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; } @@ -262,5 +274,9 @@ 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.scss b/src/components/desktop_nav/desktop_nav.scss index 2d468588..eddd9707 100644 --- a/src/components/desktop_nav/desktop_nav.scss +++ b/src/components/desktop_nav/desktop_nav.scss @@ -1,9 +1,11 @@ @import '../../_variables.scss'; .DesktopNav { - height: 50px; width: 100%; - position: fixed; + + input { + color: var(--inputTopbarText, var(--inputText)); + } a { color: var(--topBarLink, $fallback--link); @@ -11,7 +13,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 +22,7 @@ max-width: 980px; } - &.-logoLeft { + &.-logoLeft .inner-nav { grid-template-columns: auto 2fr 2fr; grid-template-areas: "logo sitename actions"; } @@ -77,7 +79,7 @@ img { display: inline-block; - height: 50px; + height: var(--navbar-height); } } @@ -103,8 +105,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 304baf9d..bab3ca81 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -34,7 +34,7 @@ <search-bar v-if="currentUser || !privateMode" @toggled="onSearchBarToggled" - @click.stop.native + @click.stop /> <button class="button-unstyled nav-icon" diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 3241ce3e..06b270c3 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -58,16 +58,7 @@ background-color: var(--bg, $fallback--bg); .dialog-modal-heading { - padding: .5em .5em; - margin-right: auto; - margin-bottom: 0; - white-space: nowrap; - color: var(--panelText); - background-color: $fallback--fg; - background-color: var(--panel, $fallback--fg); - .title { - margin-bottom: 0; text-align: center; } } diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 902ec384..391cc5b5 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 @@ -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..bd5c2e39 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, @@ -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..2055e02e 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -7,7 +7,7 @@ right: 0; left: 0; margin: 0 !important; - z-index: 1; + z-index: 100; background-color: $fallback--bg; background-color: var(--popover, $fallback--bg); color: $fallback--link; @@ -73,12 +73,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 +152,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/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/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..f100c3a9 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" diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js index 094b3e57..4e1bda55 100644 --- a/src/components/gallery/gallery.js +++ b/src/components/gallery/gallery.js @@ -1,5 +1,5 @@ import Attachment from '../attachment/attachment.vue' -import { sumBy } from 'lodash' +import { sumBy, set } from 'lodash' const Gallery = { props: [ @@ -85,7 +85,7 @@ const Gallery = { }, methods: { onNaturalSizeLoad ({ id, width, height }) { - this.$set(this.sizes, id, { width, height }) + set(this.sizes, id, { width, height }) }, rowStyle (row) { if (row.audio) { diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index f2e1b5ce..ccf6e3e2 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -22,7 +22,6 @@ class="gallery-item" :nsfw="nsfw" :attachment="attachment" - :allow-play="false" :size="size" :editable="editable" :remove="removeAttachment" diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue index a45f4586..ddc45b81 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -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/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js index e8d5ec6d..05f6fd4c 100644 --- a/src/components/image_cropper/image_cropper.js +++ b/src/components/image_cropper/image_cropper.js @@ -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/interactions/interactions.js b/src/components/interactions/interactions.js index 53ec7d49..95087eac 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'], @@ -23,7 +24,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..7ad1fe2e 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,43 @@ </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: { 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.vue b/src/components/login_form/login_form.vue index bfabb946..21482977 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -76,11 +76,15 @@ > <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> @@ -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 01a90377..ff993664 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -142,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 708a43c6..8b76aafb 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -121,7 +121,7 @@ $modal-view-button-icon-width: 3em; $modal-view-button-icon-margin: 0.5em; .modal-view.media-modal-view { - z-index: 1001; + z-index: 9000; flex-direction: column; .modal-view-button-arrow, @@ -234,7 +234,7 @@ $modal-view-button-icon-margin: 0.5em; position: absolute; height: $modal-view-button-icon-height; width: $modal-view-button-icon-width; - font-size: 14px; + font-size: 1rem; line-height: $modal-view-button-icon-height; color: #FFF; text-align: center; diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index e955aa72..7cc59f5a 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -17,9 +17,9 @@ /> <input v-if="uploadReady" + class="hidden-input-file" :disabled="disabled" type="file" - style="position: fixed; top: -100em" multiple="true" @change="change" > @@ -32,6 +32,10 @@ @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.vue b/src/components/mention_link/mention_link.vue index 3562f511..022f04c7 100644 --- a/src/components/mention_link/mention_link.vue +++ b/src/components/mention_link/mention_link.vue @@ -41,10 +41,12 @@ class="serverName" :class="{ '-faded': shouldFadeDomain }" v-html="'@' + serverName" - /></span><span + /> + </span> + <span v-if="isYou && shouldShowYous" :class="{ '-you': shouldBoldenYou }" - > {{ $t('status.you') }}</span> + > {{ ' ' + $t('status.you') }}</span> <!-- eslint-enable vue/no-v-html --> </a><span v-if="shouldShowTooltip" diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue index f375e3b0..09b6a1d6 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" @@ -21,7 +20,6 @@ class="mention-link" :content="mention.content" :url="mention.url" - :first-mention="false" /> </span><button v-if="!expanded" diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue index 7c594228..a9cf39aa 100644 --- a/src/components/mfa_form/recovery_form.vue +++ b/src/components/mfa_form/recovery_form.vue @@ -56,11 +56,15 @@ > <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> 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..d2d48a03 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" > @@ -69,12 +68,9 @@ </div> <div class="mobile-notifications" + id="mobile-notifications" @scroll="onScroll" > - <Notifications - ref="notifications" - :no-heading="true" - /> </div> </div> <SideDrawer @@ -92,13 +88,14 @@ .MobileNav { .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 { @@ -153,8 +150,9 @@ z-index: 1001; -webkit-overflow-scrolling: touch; - &.closed { + &.-closed { transform: translateX(100%); + box-shadow: none; } } @@ -182,7 +180,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..9394efff 100644 --- a/src/components/modal/modal.vue +++ b/src/components/modal/modal.vue @@ -35,7 +35,7 @@ export default { <style lang="scss"> .modal-view { - z-index: 1000; + z-index: 2000; 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..96b8c3a3 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -132,7 +132,7 @@ </button> </template> </Popover> - <portal to="modal"> + <teleport to="#modal"> <DialogModal v-if="showDeleteUserDialog" :on-cancel="deleteUserDialog.bind(this, false)" @@ -156,7 +156,7 @@ </button> </template> </DialogModal> - </portal> + </teleport> </div> </template> diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index e10eff00..ebbe5f95 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -33,7 +33,7 @@ > <a class="avatar-container" - :href="notification.from_profile.statusnet_profile_url" + :href="$router.resolve(userProfileLink).href" @click.stop.prevent.capture="toggleUserExpanded" > <UserAvatar @@ -65,12 +65,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 +83,7 @@ icon="retweet" :title="$t('tool_tip.repeat')" /> + {{ ' ' }} <small>{{ $t('notifications.repeated_you') }}</small> </span> <span v-if="notification.type === 'follow'"> @@ -86,6 +91,7 @@ class="type-icon" icon="user-plus" /> + {{ ' ' }} <small>{{ $t('notifications.followed_you') }}</small> </span> <span v-if="notification.type === 'follow_request'"> @@ -93,6 +99,7 @@ class="type-icon" icon="user" /> + {{ ' ' }} <small>{{ $t('notifications.follow_request') }}</small> </span> <span v-if="notification.type === 'move'"> @@ -100,18 +107,30 @@ class="type-icon" icon="suitcase-rolling" /> + {{ ' ' }} <small>{{ $t('notifications.migrated_to') }}</small> </span> <span v-if="notification.type === 'pleroma:emoji_reaction'"> <small> - <i18n path="notifications.reacted_with"> + <i18n-t + scope="global" + keypath="notifications.reacted_with" + > <span class="emoji-reaction-emoji">{{ notification.emoji }}</span> - </i18n> + </i18n-t> </small> </span> <span v-if="notification.type === 'pleroma:report'"> <small>{{ $t('notifications.submitted_report') }}</small> </span> + <span v-if="notification.type === 'poll'"> + <FAIcon + class="type-icon" + icon="poll-h" + /> + {{ ' ' }} + <small>{{ $t('notifications.poll_ended') }}</small> + </span> </div> <div v-if="isStatusNotification" @@ -164,18 +183,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 diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue index ba0e90a0..00a531b3 100644 --- a/src/components/notifications/notification_filters.vue +++ b/src/components/notifications/notification_filters.vue @@ -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"> + <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..82aa1489 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -23,13 +23,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 { @@ -65,6 +65,18 @@ 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' + }, 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 47c035f1..f71f9b76 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; @@ -66,8 +66,6 @@ } .follow-request-accept { - cursor: pointer; - &:hover { color: $fallback--text; color: var(--text, $fallback--text); @@ -75,8 +73,6 @@ } .follow-request-reject { - cursor: pointer; - &:hover { color: $fallback--cRed; color: var(--cRed, $fallback--cRed); @@ -119,13 +115,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%; @@ -148,7 +144,7 @@ } .timeago { - margin-right: .2em; + margin-right: 0.2em; } .status-content { @@ -161,7 +157,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..b46c06aa 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -1,69 +1,71 @@ <template> - <div - :class="{ minimal: minimalMode }" - class="Notifications" - > - <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"> + <teleport :disabled="minimalMode || disableTeleport" :to="teleportTarget"> + <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/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..f269d60e 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" diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 6ccf32f0..a30a37c9 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -178,7 +178,7 @@ const Popover = { created () { document.addEventListener('click', this.onClickOutside) }, - destroyed () { + unmounted () { document.removeEventListener('click', this.onClickOutside) this.hidePopover() } diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index 8588b351..c2a3e801 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -5,7 +5,7 @@ > <button ref="trigger" - class="button-unstyled -fullwidth popover-trigger-button" + class="button-unstyled popover-trigger-button" type="button" @click="onClick" > @@ -37,7 +37,7 @@ } .popover { - z-index: 8; + z-index: 500; position: absolute; min-width: 0; } @@ -45,8 +45,19 @@ .popover-default { transition: opacity 0.3s; - box-shadow: 1px 1px 4px rgba(0,0,0,.6); - box-shadow: var(--panelShadow); + &: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; + } + border-radius: $fallback--btnRadius; border-radius: var(--btnRadius, $fallback--btnRadius); @@ -65,11 +76,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: 200; white-space: nowrap; .dropdown-divider { @@ -82,9 +93,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 +121,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 +154,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 fe07309f..2febf226 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -78,6 +78,12 @@ const PostStatusForm = { 'emojiPickerPlacement', 'optimisticPosting' ], + emits: [ + 'posted', + 'resize', + 'mediaplay', + 'mediapause' + ], components: { MediaUpload, EmojiInput, @@ -480,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 diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 2e0980a2..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,15 +269,28 @@ </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" - /> + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="times" + /> + </button> </div> <gallery v-if="newStatus.files && newStatus.files.length > 0" @@ -331,7 +336,7 @@ display: flex; justify-content: space-between; padding: 0.5em; - height: 32px; + height: 2.5em; button { width: 10em; @@ -389,7 +394,6 @@ border-radius: var(--tooltipRadius, $fallback--tooltipRadius); padding: 0.5em; margin: 0; - line-height: 1.4em; } .text-format { @@ -403,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 @@ -436,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 { @@ -484,10 +487,6 @@ flex-direction: column; } - .btn { - cursor: pointer; - } - .btn[disabled] { cursor: not-allowed; } @@ -503,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; @@ -546,10 +539,6 @@ } } - .btn { - cursor: pointer; - } - .btn[disabled] { cursor: not-allowed; } @@ -566,7 +555,6 @@ .drop-indicator { position: absolute; - z-index: 1; width: 100%; height: 100%; font-size: 5em; 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..e6f9dbff 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,7 +44,7 @@ const ReactButton = { }, emojis () { if (this.filterWord !== '') { - const filterWordLowercase = this.filterWord.toLowerCase() + const filterWordLowercase = trim(this.filterWord.toLowerCase()) let 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..8a4b4d3b 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -12,6 +12,7 @@ <div class="reaction-picker-filter"> <input v-model="filterWord" + @input="$event.target.composing = false" size="1" :placeholder="$t('emoji.search_emoji')" > @@ -101,7 +102,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..cc655c0b 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,18 @@ >{{ $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" :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 +39,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 +47,18 @@ >{{ $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" :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,7 +66,7 @@ <div class="form-group" - :class="{ 'form-group--error': $v.user.email.$error }" + :class="{ 'form-group--error': v$.user.email.$error }" > <label class="form--label" @@ -74,18 +74,18 @@ >{{ $t('registration.email') }}</label> <input id="email" - v-model="$v.user.email.$model" + v-model="v$.user.email.$model" :disabled="isPending" class="form-control" type="email" > </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> @@ -107,7 +107,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" @@ -122,11 +122,11 @@ > </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 +134,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" @@ -149,20 +149,32 @@ > </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 +283,10 @@ $validations-cRed: #f04124; .container { display: flex; flex-direction: row; - //margin-bottom: 1em; + + > * { + min-width: 0; + } } .terms-of-service { @@ -294,8 +309,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 +330,7 @@ $validations-cRed: #f04124; text-align: left; span { - font-size: 12px; + font-size: 0.85em; } } @@ -341,7 +356,7 @@ $validations-cRed: #f04124; .btn { margin-top: 0.6em; - height: 28px; + height: 2em; } .error { 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/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx index 46bc661a..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) => { @@ -222,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> } @@ -235,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 { @@ -266,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/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 8d6528ff..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; } @@ -54,7 +55,7 @@ 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..3c80660e 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> @@ -31,8 +31,8 @@ > <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 diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue index e0d825f2..69584808 100644 --- a/src/components/settings_modal/helpers/boolean_setting.vue +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -4,9 +4,9 @@ class="BooleanSetting" > <Checkbox - :checked="state" + :model-value="state" :disabled="disabled" - @change="update" + @update:modelValue="update" > <span v-if="!!$slots.default" @@ -14,6 +14,7 @@ > <slot /> </span> + {{ ' ' }} <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox> </label> </template> diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue index 54f5d0a7..258c7422 100644 --- a/src/components/settings_modal/helpers/choice_setting.vue +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -4,10 +4,11 @@ class="ChoiceSetting" > <slot /> + {{ ' ' }} <Select - :value="state" + :model-value="state" :disabled="disabled" - @change="update" + @update:modelValue="update" > <option v-for="option in options" diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js index 4a19bd7c..17dc0e7b 100644 --- a/src/components/settings_modal/helpers/integer_setting.js +++ b/src/components/settings_modal/helpers/integer_setting.js @@ -8,7 +8,7 @@ export default { path: String, disabled: Boolean, min: Number, - expert: Number + expert: [Number, String] }, computed: { pathDefault () { diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue index 408b0925..e661a025 100644 --- a/src/components/settings_modal/helpers/integer_setting.vue +++ b/src/components/settings_modal/helpers/integer_setting.vue @@ -16,6 +16,7 @@ :value="state" @change="update" > + {{ ' ' }} <ModifiedIndicator :changed="isChanged" /> </span> </template> diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index 82ea410e..0a72dca1 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -56,8 +56,8 @@ const SettingsModal = { SettingsModalContent: getResettableAsyncComponent( () => import('./settings_modal_content.vue'), { - loading: PanelLoading, - error: AsyncComponentError, + loadingComponent: PanelLoading, + errorComponent: AsyncComponentError, delay: 0 } ) diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss index fb466f2f..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,7 +54,7 @@ overflow-y: hidden; .btn { - min-height: 28px; + min-height: 2em; min-width: 10em; padding: 0 2em; } @@ -54,5 +66,10 @@ >* { 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 1805c77f..d3bed061 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" @@ -68,6 +59,7 @@ :title="$t('general.close')" > <span>{{ $t("settings.file_export_import.backup_restore") }}</span> + {{ ' ' }} <FAIcon icon="chevron-down" /> @@ -109,9 +101,16 @@ </template> </Popover> - <Checkbox v-model="expertLevel"> + <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.vue b/src/components/settings_modal/tabs/filtering_tab.vue index dc48902f..97046ff0 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -72,22 +72,10 @@ <div>{{ $t('settings.filtering_explanation') }}</div> </li> <h3>{{ $t('settings.attachments') }}</h3> - <li v-if="expertLevel > 0"> - <label for="maxThumbnails"> - {{ $t('settings.max_thumbnails') }} - </label> - <input - id="maxThumbnails" - path.number="maxThumbnails" - class="number-input" - type="number" - min="0" - step="1" - > - </li> <li> <IntegerSetting path="maxThumbnails" + expert="1" :min="0" > {{ $t('settings.max_thumbnails') }} diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index 62d86176..1e11b9e0 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -38,6 +38,11 @@ const GeneralTab = { 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') || @@ -72,6 +77,12 @@ 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: { diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index a2c6bffa..1fe51b6d 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"> @@ -61,6 +65,26 @@ </BooleanSetting> </li> <li> + <BooleanSetting path="disableStickyHeaders"> + {{ $t('settings.disable_sticky_headers') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="showScrollbars"> + {{ $t('settings.show_scrollbars') }} + </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" 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/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index 86be6095..dd3806ed 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -41,6 +41,11 @@ {{ $t('settings.notification_visibility_emoji_reactions') }} </BooleanSetting> </li> + <li> + <BooleanSetting path="notificationVisibility.polls"> + {{ $t('settings.notification_visibility_polls') }} + </BooleanSetting> + </li> </ul> </li> </ul> diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index bee8a7bb..8781bb91 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -8,8 +8,10 @@ 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 { @@ -40,7 +42,8 @@ const ProfileTab = { banner: null, bannerPreview: null, background: null, - backgroundPreview: null + backgroundPreview: null, + emailLanguage: this.$store.state.users.currentUser.language || '' } }, components: { @@ -50,7 +53,8 @@ const ProfileTab = { Autosuggest, ProgressButton, Checkbox, - BooleanSetting + BooleanSetting, + InterfaceLanguageSwitcher }, computed: { user () { @@ -111,19 +115,25 @@ const ProfileTab = { }, methods: { updateProfile () { + const params = { + note: this.newBio, + locked: this.newLocked, + // Backend notation. + /* eslint-disable camelcase */ + display_name: this.newName, + fields_attributes: this.newFields.filter(el => el != null), + bot: this.bot, + show_role: this.showRole + /* eslint-enable camelcase */ + } + + if (this.emailLanguage) { + params.language = localeService.internalToBackendLocale(this.emailLanguage) + } + this.$store.state.api.backendInteractor - .updateProfile({ - params: { - note: this.newBio, - locked: this.newLocked, - // Backend notation. - /* eslint-disable camelcase */ - display_name: this.newName, - fields_attributes: this.newFields.filter(el => el != null), - bot: this.bot, - show_role: this.showRole - /* eslint-enable camelcase */ - } }).then((user) => { + .updateProfile({ params }) + .then((user) => { this.newFields.splice(user.fields.length) merge(this.newFields, user.fields) this.$store.commit('addNewUsers', [user]) @@ -193,8 +203,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) @@ -207,9 +217,9 @@ const ProfileTab = { } if (cropper) { - cropper.getCroppedCanvas().toBlob(updateAvatar, file.type) + cropper.getCroppedCanvas().toBlob((data) => updateAvatar(data, file.name), file.type) } else { - updateAvatar(file) + updateAvatar(file, file.name) } }) }, diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss index 111eaed3..201f1a76 100644 --- a/src/components/settings_modal/tabs/profile_tab.scss +++ b/src/components/settings_modal/tabs/profile_tab.scss @@ -54,16 +54,20 @@ border-radius: var(--tooltipRadius, $fallback--tooltipRadius); background-color: rgba(0, 0, 0, 0.6); opacity: 0.7; - color: white; width: 1.5em; height: 1.5em; text-align: center; line-height: 1.5em; font-size: 1.5em; cursor: pointer; + &:hover { opacity: 1; } + + svg { + color: white; + } } .oauth-tokens { @@ -85,7 +89,7 @@ &-bulk-actions { text-align: right; padding: 0 1em; - min-height: 28px; + min-height: 2em; button { width: 10em; diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index e00f6e5b..4cd93772 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -68,8 +68,9 @@ class="delete-field button-unstyled -hover-highlight" @click="deleteField(i)" > + <!-- TODO something is wrong with v-show here --> <FAIcon - v-show="newFields.length > 1" + v-if="newFields.length > 1" icon="times" /> </button> @@ -88,6 +89,13 @@ {{ $t('settings.bot') }} </Checkbox> </p> + <p> + <interface-language-switcher + :prompt-text="$t('settings.email_language')" + :language="emailLanguage" + :set-language="val => emailLanguage = val" + /> + </p> <button :disabled="newName && newName.length === 0" class="btn button-default" @@ -106,14 +114,17 @@ :src="user.profile_image_url_original" class="current-avatar" > - <FAIcon + <button v-if="!isDefaultAvatar && pickAvatarBtnVisible" :title="$t('settings.reset_avatar')" - class="reset-button" - icon="times" - type="button" @click="resetAvatar" - /> + class="button-unstyled reset-button" + > + <FAIcon + icon="times" + type="button" + /> + </button> </div> <p>{{ $t('settings.set_new_avatar') }}</p> <button @@ -135,14 +146,17 @@ <h2>{{ $t('settings.profile_banner') }}</h2> <div class="banner-background-preview"> <img :src="user.cover_photo"> - <FAIcon + <button v-if="!isDefaultBanner" + class="button-unstyled reset-button" :title="$t('settings.reset_profile_banner')" - class="reset-button" - icon="times" - type="button" @click="resetBanner" - /> + > + <FAIcon + icon="times" + type="button" + /> + </button> </div> <p>{{ $t('settings.set_new_profile_banner') }}</p> <img @@ -174,14 +188,17 @@ <h2>{{ $t('settings.profile_background') }}</h2> <div class="banner-background-preview"> <img :src="user.background_image"> - <FAIcon + <button v-if="!isDefaultBackground" + class="button-unstyled reset-button" :title="$t('settings.reset_profile_background')" - class="reset-button" - icon="times" - type="button" @click="resetBackground" - /> + > + <FAIcon + icon="times" + type="button" + /> + </button> </div> <p>{{ $t('settings.set_new_profile_background') }}</p> <img 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..fc732936 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.js +++ b/src/components/settings_modal/tabs/security_tab/security_tab.js @@ -15,11 +15,21 @@ const SecurityTab = { deleteAccountError: false, 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..f266b603 100644 --- a/src/components/settings_modal/tabs/theme_tab/preview.vue +++ b/src/components/settings_modal/tabs/theme_tab/preview.vue @@ -29,14 +29,14 @@ {{ $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 +72,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..7e1da7ab 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' @@ -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..386a5756 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,7 +106,7 @@ export default { !this.usingFallback }, usingFallback () { - return typeof this.value === 'undefined' + return typeof this.modelValue === 'undefined' }, rgb () { return hex2rgb(this.selected.color) diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue index 511e07f3..f2fc7b99 100644 --- a/src/components/shadow_control/shadow_control.vue +++ b/src/components/shadow_control/shadow_control.vue @@ -204,12 +204,13 @@ 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> diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue index c88797d1..1eca88a7 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; + bottom: 0.5em; z-index: 1000; 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..bad1806b 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -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/status/status.js b/src/components/status/status.js index 4c0ef3e0..a925f30b 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -69,7 +69,7 @@ const controlledOrUncontrolledGetters = list => list.reduce((res, name) => { const controlledName = `controlled${camelized}` const uncontrolledName = `uncontrolled${camelized}` res[name] = function () { - return this[toggle] ? this[controlledName] : this[uncontrolledName] + return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName] } return res }, {}) @@ -225,12 +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)) @@ -305,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) || @@ -383,6 +389,9 @@ const Status = { }, threadShowing () { return this.controlledThreadDisplayStatus === 'showing' + }, + visibilityLocalized () { + return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility) } }, methods: { @@ -472,11 +481,6 @@ const Status = { '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 3f647b25..b3ad3818 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -42,6 +42,10 @@ display: flex; padding: var(--status-margin, $status-margin); + > * { + min-width: 0; + } + &.-repeat { padding-top: 0; } @@ -78,7 +82,6 @@ .status-username { white-space: nowrap; - font-size: 14px; overflow: hidden; max-width: 85%; font-weight: bold; @@ -103,7 +106,7 @@ .heading-name-row { display: flex; justify-content: space-between; - line-height: 18px; + line-height: 1.3; a { display: inline-block; @@ -156,7 +159,7 @@ & .heading-reply-row { position: relative; align-content: baseline; - font-size: 12px; + font-size: 0.85em; margin-top: 0.2em; line-height: 130%; max-width: 100%; @@ -224,8 +227,8 @@ .replies { margin-top: 0.25em; - line-height: 18px; - font-size: 12px; + line-height: 1.3; + font-size: 0.85em; display: flex; flex-wrap: wrap; @@ -385,14 +388,14 @@ .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; } @@ -406,13 +409,13 @@ 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 1679834e..67ce999a 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,7 +78,7 @@ <UserAvatar v-if="retweet" class="left-side repeater-avatar" - :bot="botIndicator" + :bot="rtBotIndicator" :better-shadow="betterShadow" :user="statusoid.user" /> @@ -100,6 +101,7 @@ :to="retweeterProfileLink" >{{ retweeter }}</router-link> </span> + {{ ' ' }} <FAIcon icon="retweet" class="repeat-icon" @@ -120,17 +122,18 @@ v-if="!noHeading" class="left-side" > - <router-link - :to="userProfileLink" - @click.stop.prevent.capture.native="toggleUserExpanded" + <a + :href="$router.resolve(userProfileLink).href" + @click.stop.prevent.capture="toggleUserExpanded" > <UserAvatar + class="post-avatar" :bot="botIndicator" :compact="compact" :better-shadow="betterShadow" :user="status.user" /> - </router-link> + </a> </div> <div class="right-side"> <UserCard @@ -190,7 +193,7 @@ <span v-if="status.visibility" class="visibility-icon" - :title="status.visibility | capitalize" + :title="visibilityLocalized" > <FAIcon fixed-width @@ -273,6 +276,7 @@ icon="reply" flip="horizontal" /> + {{ ' ' }} <span class="reply-to-text" > @@ -292,7 +296,6 @@ :url="replyProfileLink" :user-id="status.in_reply_to_user_id" :user-screen-name="status.in_reply_to_screen_name" - :first-mention="false" /> </span> @@ -454,6 +457,7 @@ > <div class="left-side"> <UserAvatar + class="post-avatar" :compact="compact" :bot="botIndicator" /> diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss index f261108e..039d4c7f 100644 --- a/src/components/status_body/status_body.scss +++ b/src/components/status_body/status_body.scss @@ -19,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 { diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue index 24d842c2..976fe98c 100644 --- a/src/components/status_body/status_body.vue +++ b/src/components/status_body/status_body.vue @@ -15,14 +15,14 @@ :emoji="status.emojis" /> <button - v-if="longSubject && showingLongSubject" + v-show="longSubject && showingLongSubject" class="button-unstyled -link tall-subject-hider" @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="toggleShowingLongSubject" > @@ -34,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" @@ -54,7 +54,7 @@ /> <button - v-if="hideSubjectStatus" + v-show="hideSubjectStatus" class="button-unstyled -link cw-status-hider" @click.prevent="toggleShowMore" > @@ -85,7 +85,7 @@ /> </button> <button - v-if="showingMore && !fullContent" + v-show="showingMore && !fullContent" class="button-unstyled -link status-unhider" @click.prevent="toggleShowMore" > diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index cf72ccb8..89f0aa51 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -31,7 +31,7 @@ const controlledOrUncontrolledGetters = list => list.reduce((res, name) => { const controlledName = `controlled${camelized}` const uncontrolledName = `uncontrolled${camelized}` res[name] = function () { - return this[toggle] ? this[controlledName] : this[uncontrolledName] + return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName] } return res }, {}) @@ -59,7 +59,9 @@ const StatusContent = { 'controlledShowingTall', 'controlledExpandingSubject', 'controlledToggleShowingTall', - 'controlledToggleExpandingSubject' + 'controlledToggleExpandingSubject', + 'controlledShowingLongSubject', + 'controlledToggleShowingLongSubject' ], data () { return { diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js index c47f5631..e0962ccd 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 () { diff --git a/src/components/sticker_picker/sticker_picker.js b/src/components/sticker_picker/sticker_picker.js index 8daf3f07..3a2d3914 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: { diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index e939b532..ab3080c8 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -58,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/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/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue index e64455e0..4eaf597d 100644 --- a/src/components/thread_tree/thread_tree.vue +++ b/src/components/thread_tree/thread_tree.vue @@ -1,5 +1,5 @@ <template> - <div class="thread-tree panel-body"> + <div class="thread-tree"> <status :key="status.id" ref="statusComponent" @@ -74,36 +74,44 @@ v-if="currentReplies.length && !threadShowing" class="thread-tree-replies thread-tree-replies-hidden" > - <i18n + <i18n-t v-if="simple" + scope="global" tag="button" - path="status.thread_follow_with_icon" + keypath="status.thread_follow_with_icon" class="button-unstyled -link thread-tree-show-replies-button" @click.prevent="dive(status.id)" > - <FAIcon - place="icon" - icon="angle-double-right" - /> - <span place="text"> - {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }} - </span> - </i18n> - <i18n + <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" - path="status.thread_show_full_with_icon" + keypath="status.thread_show_full_with_icon" class="button-unstyled -link thread-tree-show-replies-button" @click.prevent="showThreadRecursively(status.id)" > - <FAIcon - place="icon" - icon="angle-double-down" - /> - <span place="text"> - {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }} - </span> - </i18n> + <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> 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 04f0e7d6..c575e876 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -22,7 +22,8 @@ const Timeline = { 'embedded', 'count', 'pinnedStatusIds', - 'inProfile' + 'inProfile', + 'footerSlipgate' // reference to an element where we should put our footer ], data () { return { @@ -40,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 }, @@ -54,11 +61,11 @@ 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'] : []) } @@ -70,8 +77,9 @@ const Timeline = { 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 () { @@ -104,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) @@ -135,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 ff16208d..f65881b6 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,62 @@ 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="timelineName !== 'user' || (status.id >= timeline.minId && status.id <= timeline.maxId)" - :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" - > - {{ $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') }} + <teleport :to="footerSlipgate" :disabled="!embedded || !footerSlipgate"> + <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.vue b/src/components/timeline/timeline_quick_settings.vue index 4d67e06b..98fab926 100644 --- a/src/components/timeline/timeline_quick_settings.vue +++ b/src/components/timeline/timeline_quick_settings.vue @@ -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 @@ -93,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.vue b/src/components/timeline_menu/timeline_menu.vue index 8f14093f..61119482 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -43,6 +43,10 @@ min-width: 0; width: 24rem; + .popover-trigger-button { + vertical-align: bottom; + } + .timeline-menu-popover-wrap { overflow: hidden; // Match panel heading padding to line up menu with bottom of heading diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 847d654b..f4d294df 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -1,24 +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" + :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" /> - </StillImage> - <div - v-else - class="Avatar -placeholder" - :class="{ 'avatar-compact': compact }" - /> + </span> </template> <script src="./user_avatar.js"></script> @@ -31,42 +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); - & > .bot-indicator { - position: absolute; - bottom: 0; - right: 0; - } + &.-better-shadow { + box-shadow: var(--_avatarShadowInset); + filter: var(--_avatarShadowFilter); + } - &.better-shadow { - box-shadow: var(--_avatarShadowInset); - filter: var(--_avatarShadowFilter); - } + &.-animated::before { + display: none; + } + + &.-compact { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } - &.animated::before { - display: none; + &.-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.scss b/src/components/user_card/user_card.scss new file mode 100644 index 00000000..2e153120 --- /dev/null +++ b/src/components/user_card/user_card.scss @@ -0,0 +1,323 @@ +@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(--panelRadius) - 1px); + border-top-right-radius: calc(var(--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; + } + } + + // 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 { + min-width: 0; + padding: 16px 0 6px; + display: flex; + align-items: flex-start; + max-height: 56px; + + > * { + min-width: 0; + } + + .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; + 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; + 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; + 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 0708f387..67837845 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -8,7 +8,7 @@ :style="style" class="background-image" /> - <div class="panel-heading"> + <div class="panel-heading -flexible-height"> <div class="user-info"> <div class="container"> <a @@ -141,6 +141,7 @@ class="userHighlightCl" type="color" > + {{ ' ' }} <Select :id="'userHighlightSel'+user.id" v-model="userHighlightType" @@ -283,320 +284,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 8706d0ff..bdc3aa92 100644 --- a/src/components/user_list_popover/user_list_popover.vue +++ b/src/components/user_list_popover/user_list_popover.vue @@ -73,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_profile/user_profile.js b/src/components/user_profile/user_profile.js index 7a475609..f779b823 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -3,7 +3,7 @@ import UserCard from '../user_card/user_card.vue' import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import Conversation from '../conversation/conversation.vue' -import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' @@ -39,7 +39,8 @@ const UserProfile = { return { error: false, userId: null, - tab: defaultTabKey + tab: defaultTabKey, + footerRef: null } }, created () { @@ -47,7 +48,7 @@ const UserProfile = { this.load(routeParams.name || routeParams.id) this.tab = get(this.$route, 'query.tab', defaultTabKey) }, - destroyed () { + unmounted () { this.stopFetching() }, computed: { @@ -78,6 +79,9 @@ const UserProfile = { } }, methods: { + setFooterRef (el) { + this.footerRef = el + }, load (userNameOrId) { const startFetchingTimeline = (timeline, userId) => { // Clear timeline only if load another user's profile diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 726216ff..62792599 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -56,6 +56,7 @@ :user-id="userId" :pinned-status-ids="user.pinnedStatusIds" :in-profile="true" + :footerSlipgate="footerRef" /> <div v-if="followsTabVisible" @@ -94,6 +95,7 @@ :timeline="media" :user-id="userId" :in-profile="true" + :footerSlipgate="footerRef" /> <Timeline v-if="isUs" @@ -105,8 +107,10 @@ timeline-name="favorites" :timeline="favorites" :in-profile="true" + :footerSlipgate="footerRef" /> </tab-switcher> + <div class="panel-footer" :ref="setFooterRef"></div> </div> <div v-else @@ -138,6 +142,9 @@ flex: 2; flex-basis: 500px; + // No sticky header on user profile + --currentPanelStack: 1; + .user-profile-fields { margin: 0 0.5em; @@ -176,7 +183,7 @@ } .user-profile-field-name, .user-profile-field-value { - line-height: 18px; + line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -192,24 +199,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..030ce2c4 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -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/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 d2abac1d..51e1a0c3 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", @@ -86,7 +86,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", @@ -156,7 +162,8 @@ "no_more_notifications": "No more notifications", "migrated_to": "migrated to", "reacted_with": "reacted with {0}", - "submitted_report": "submitted a report" + "submitted_report": "submitted a report", + "poll_ended": "poll has ended" }, "polls": { "add_poll": "Add poll", @@ -252,7 +259,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", @@ -311,6 +319,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", @@ -322,6 +331,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", @@ -347,6 +366,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.", @@ -375,7 +407,7 @@ "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", @@ -435,6 +467,7 @@ "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", @@ -489,6 +522,12 @@ "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", @@ -516,14 +555,14 @@ "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_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_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", "fun": "Fun", "greentext": "Meme arrows", @@ -690,38 +729,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", @@ -847,7 +874,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..fae7c7a2 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -659,8 +659,7 @@ "disable_remote_subscription": "Interdir de s'abonner a l'utilisateur depuis l'instance distante", "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": "Supprimer l'utilisateur" }, "mention": "Mention", "hidden": "Caché", 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..18ed79b7 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) + 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 fd61572c..64c92b68 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 304a0349..efebcc83 100644 --- a/src/i18n/pl.json +++ b/src/i18n/pl.json @@ -762,8 +762,7 @@ "disable_remote_subscription": "Zakaż obserwowania użytkownika ze zdalnych instancji", "disable_any_subscription": "Zakaż całkowicie obserwowania użytkownika", "quarantine": "Zakaż federowania postów od tego użytkownika", - "delete_user": "Usuń użytkownika", - "delete_user_confirmation": "Czy jesteś absolutnie pewny(-a)? Ta operacja nie może być cofnięta." + "delete_user": "Usuń użytkownika" }, "message": "Napisz", "edit_profile": "Edytuj profil", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e32a95e4..b997701c 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -594,7 +594,6 @@ "unmute_progress": "A retirar silêncio…", "mute_progress": "A silenciar…", "admin_menu": { - "delete_user_confirmation": "Tens a certeza? Esta ação não pode ser revertida.", "delete_user": "Eliminar utilizador", "quarantine": "Não permitir publicações de utilizadores de instâncias remotas", "disable_any_subscription": "Não permitir que nenhum utilizador te siga", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index ba0cec28..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..dd0e6827 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -714,8 +714,7 @@ "disable_remote_subscription": "禁止从远程实例关注用户", "disable_any_subscription": "完全禁止关注用户", "quarantine": "从联合实例中禁止用户帖子", - "delete_user": "删除用户", - "delete_user_confirmation": "你确定吗?此操作无法撤销。" + "delete_user": "删除用户" }, "hidden": "已隐藏", "show_repeats": "显示转发", 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/persisted_state.js b/src/lib/persisted_state.js index 8ecb66a8..24b835da 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -1,6 +1,6 @@ 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 @@ -69,7 +69,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 bdf8368b..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' @@ -22,36 +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.config.ignoredElements = ['pinch-zoom'] - -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', @@ -78,17 +58,18 @@ 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, 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 93487b99..c4b08390 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] @@ -47,6 +50,7 @@ export const defaultState = { pauseOnUnfocused: true, stopGifs: true, replyVisibility: 'all', + thirdColumnMode: 'notifications', notificationVisibility: { follows: true, mentions: true, @@ -56,7 +60,8 @@ export const defaultState = { emojiReactions: true, followRequest: true, reports: true, - chatMention: true + chatMention: true, + polls: true }, webPushNotifications: false, muteWords: [], @@ -75,6 +80,8 @@ export const defaultState = { playVideosInModal: false, useOneClickNsfw: false, useContainFit: true, + disableStickyHeaders: false, + showScrollbars: false, greentext: undefined, // instance default useAtIcon: undefined, // instance default mentionLinkDisplay: undefined, // instance default @@ -123,14 +130,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] } } }, @@ -164,6 +171,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/instance.js b/src/modules/instance.js index 79c54096..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' @@ -102,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/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 index 5c1baedb..4b73af26 100644 --- a/src/modules/serverSideConfig.js +++ b/src/modules/serverSideConfig.js @@ -55,7 +55,10 @@ export const settingsMap = { get: 'pleroma.allow_following_move', set: 'allow_following_move' }, - 'discoverable': 'source.discoverable', + 'discoverable': { + get: 'source.pleroma.discoverable', + set: 'discoverable' + }, 'hideFavorites': { get: 'pleroma.hide_favorites', set: 'hide_favorites' 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 cb2a8d46..66cc82bc 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 @@ -527,7 +526,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] @@ -546,9 +545,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 }) { @@ -567,9 +566,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..f951483f 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 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 @@ -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 c17d0476..8f09545c 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' @@ -88,6 +90,7 @@ const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages` const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read` const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}` const PLEROMA_ADMIN_REPORTS = '/api/pleroma/admin/reports' +const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups' const oldfetch = window.fetch @@ -152,9 +155,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, { @@ -192,6 +201,7 @@ const updateProfile = ({ credentials, params }) => { // homepage // location // token +// language const register = ({ params, credentials }) => { const { nickname, ...rest } = params return fetch(MASTODON_REGISTRATION_URL, { @@ -789,6 +799,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() @@ -875,6 +928,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' @@ -1358,12 +1430,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/date_utils/date_utils.js b/src/services/date_utils/date_utils.js index 32e13bca..677c184c 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' } + let 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/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/locale/locale.service.js b/src/services/locale/locale.service.js index 5be99d81..8cef2522 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' + '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: 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/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js index 8c70ea53..0f8b9b02 100644 --- a/src/services/notification_utils/notification_utils.js +++ b/src/services/notification_utils/notification_utils.js @@ -15,11 +15,12 @@ export const visibleTypes = store => { rootState.config.notificationVisibility.followRequest && 'follow_request', rootState.config.notificationVisibility.moves && 'move', rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction', - rootState.config.notificationVisibility.reports && 'pleroma:report' + rootState.config.notificationVisibility.reports && 'pleroma:report', + rootState.config.notificationVisibility.polls && 'poll' ].filter(_ => _)) } -const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction'] +const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll'] export const isStatusNotification = (type) => includes(statusNotifications, type) @@ -102,6 +103,9 @@ export const prepareNotificationObject = (notification, i18n) => { case 'pleroma:report': i18nString = 'submitted_report' break + case 'poll': + i18nString = 'poll_ended' + break } if (notification.type === 'pleroma:emoji_reaction') { 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/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') } @@ -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', |
