diff options
Diffstat (limited to 'src')
49 files changed, 1968 insertions, 734 deletions
@@ -2,8 +2,9 @@ import UserPanel from './components/user_panel/user_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue' import Notifications from './components/notifications/notifications.vue' import UserFinder from './components/user_finder/user_finder.vue' -import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' +import FeaturesPanel from './components/features_panel/features_panel.vue' +import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue' export default { @@ -13,23 +14,55 @@ export default { NavPanel, Notifications, UserFinder, - WhoToFollowPanel, InstanceSpecificPanel, + FeaturesPanel, + WhoToFollowPanel, ChatPanel }, data: () => ({ - mobileActivePanel: 'timeline' + mobileActivePanel: 'timeline', + supportsMask: window.CSS && window.CSS.supports && ( + window.CSS.supports('mask-size', 'contain') || + window.CSS.supports('-webkit-mask-size', 'contain') || + window.CSS.supports('-moz-mask-size', 'contain') || + window.CSS.supports('-ms-mask-size', 'contain') || + window.CSS.supports('-o-mask-size', 'contain') + ) }), + created () { + // Load the locale from the storage + this.$i18n.locale = this.$store.state.config.interfaceLanguage + }, computed: { currentUser () { return this.$store.state.users.currentUser }, background () { return this.currentUser.background_image || this.$store.state.config.background }, - logoStyle () { return { 'background-image': `url(${this.$store.state.config.logo})` } }, + enableMask () { return this.supportsMask && this.$store.state.config.logoMask }, + logoStyle () { + return { + 'visibility': this.enableMask ? 'hidden' : 'visible' + } + }, + logoMaskStyle () { + return this.enableMask ? { + 'mask-image': `url(${this.$store.state.config.logo})` + } : { + 'background-color': this.enableMask ? '' : 'transparent' + } + }, + logoBgStyle () { + return Object.assign({ + 'margin': `${this.$store.state.config.logoMargin} 0` + }, this.enableMask ? {} : { + 'background-color': this.enableMask ? '' : 'transparent' + }) + }, + logo () { return this.$store.state.config.logo }, style () { return { 'background-image': `url(${this.background})` } }, sitename () { return this.$store.state.config.name }, chat () { return this.$store.state.chat.channel.state === 'joined' }, - showWhoToFollowPanel () { return this.$store.state.config.showWhoToFollowPanel }, + suggestionsEnabled () { return this.$store.state.config.suggestionsEnabled }, showInstanceSpecificPanel () { return this.$store.state.config.showInstanceSpecificPanel } }, methods: { diff --git a/src/App.scss b/src/App.scss index 2426b998..056a235e 100644 --- a/src/App.scss +++ b/src/App.scss @@ -48,7 +48,7 @@ a { color: var(--link, $fallback--link); } -button{ +button { user-select: none; color: $fallback--fg; color: var(--fg, $fallback--fg); @@ -64,10 +64,19 @@ button{ font-size: 14px; font-family: sans-serif; + &::-moz-focus-inner { + border: none; + } + &:hover { box-shadow: 0px 0px 4px rgba(255, 255, 255, 0.3); } + &:active { + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + border-top: 1px solid rgba(0, 0, 0, 0.2); + } + &:disabled { cursor: not-allowed; opacity: 0.5; @@ -105,6 +114,7 @@ input, textarea, .select { position: relative; height: 29px; line-height: 16px; + hyphens: none; .icon-down-open { position: absolute; @@ -142,6 +152,14 @@ input, textarea, .select { color: $fallback--fg; color: var(--fg, $fallback--fg); } + &:disabled, + { + &, + & + label, + & + label::before { + opacity: .5; + } + } + label::before { display: inline-block; content: '✔'; @@ -218,6 +236,40 @@ nav { position: fixed; height: 50px; + .logo { + display: flex; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + align-items: stretch; + justify-content: center; + flex: 0 0 auto; + z-index: -1; + + .mask { + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: $fallback--fg; + background-color: var(--fg, $fallback--fg); + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + + img { + height: 100%; + object-fit: contain; + display: block; + flex: 0; + } + } + .inner-nav { padding-left: 20px; padding-right: 20px; @@ -226,9 +278,6 @@ nav { flex-basis: 970px; margin: auto; height: 50px; - background-repeat: no-repeat; - background-position: center; - background-size: auto 80%; a i { color: $fallback--link; @@ -274,15 +323,42 @@ main-router { } .panel-heading { + display: flex; border-radius: $fallback--panelRadius $fallback--panelRadius 0 0; border-radius: var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius) 0 0; background-size: cover; - padding: 0.6em 1.0em; + padding: .6em .6em; text-align: left; - font-size: 1.3em; - line-height: 24px; + line-height: 28px; background-color: $fallback--btn; background-color: var(--btn, $fallback--btn); + align-items: baseline; + + .title { + flex: 1 0 auto; + font-size: 1.3em; + } + + .alert { + white-space: nowrap; + text-overflow: ellipsis; + overflow-x: hidden; + } + + button { + flex-shrink: 0; + } + + button, .alert { + // height: 100%; + line-height: 21px; + min-height: 0; + box-sizing: border-box; + margin: 0; + margin-left: .25em; + min-width: 1px; + align-self: stretch; + } } .panel-heading.stub { @@ -433,3 +509,30 @@ nav { text-align: right; padding-right: 20px; } + +.visibility-tray { + font-size: 1.2em; + padding: 3px; + cursor: pointer; + + .selected { + color: $fallback--lightFg; + color: var(--lightFg, $fallback--lightFg); + } + + .text-format { + float: right; + } + + div { + padding-top: 5px; + } +} + +.visibility-notice { + padding: .5em; + border: 1px solid $fallback--faint; + border: 1px solid var(--faint, $fallback--faint); + border-radius: $fallback--inputRadius; + border-radius: var(--inputRadius, $fallback--inputRadius); +} diff --git a/src/App.vue b/src/App.vue index 923d411b..059460f9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,11 @@ <template> <div id="app" v-bind:style="style"> <nav class='container' @click="scrollToTop()" id="nav"> - <div class='inner-nav' :style="logoStyle"> + <div class='logo' :style='logoBgStyle'> + <div class='mask' :style='logoMaskStyle'></div> + <img :src='logo' :style='logoStyle'> + </div> + <div class='inner-nav'> <div class='item'> <router-link :to="{ name: 'root'}">{{sitename}}</router-link> </div> @@ -24,7 +28,8 @@ <user-panel></user-panel> <nav-panel></nav-panel> <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> - <who-to-follow-panel v-if="currentUser && showWhoToFollowPanel"></who-to-follow-panel> + <features-panel v-if="!currentUser"></features-panel> + <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> <notifications v-if="currentUser"></notifications> </div> </div> diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index d9bc4477..41730720 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -13,9 +13,10 @@ const Attachment = { return { nsfwImage, hideNsfwLocal: this.$store.state.config.hideNsfw, + loopVideo: this.$store.state.config.loopVideo, showHidden: false, loading: false, - img: document.createElement('img') + img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img') } }, components: { @@ -45,14 +46,35 @@ const Attachment = { } }, toggleHidden () { - if (this.img.onload) { - this.img.onload() + if (this.img) { + if (this.img.onload) { + this.img.onload() + } else { + this.loading = true + this.img.src = this.attachment.url + this.img.onload = () => { + this.loading = false + this.showHidden = !this.showHidden + } + } } else { - this.loading = true - this.img.src = this.attachment.url - this.img.onload = () => { - this.loading = false - this.showHidden = !this.showHidden + this.showHidden = !this.showHidden + } + }, + onVideoDataLoad (e) { + if (typeof e.srcElement.webkitAudioDecodedByteCount !== 'undefined') { + // non-zero if video has audio track + if (e.srcElement.webkitAudioDecodedByteCount > 0) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } else if (typeof e.srcElement.mozHasAudio !== 'undefined') { + // true if video has audio track + if (e.srcElement.mozHasAudio) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly + } + } else if (typeof e.srcElement.audioTracks !== 'undefined') { + if (e.srcElement.audioTracks.length > 0) { + this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly } } } diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index c48fb16b..8795b131 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -2,7 +2,7 @@ <div v-if="size==='hide'"> <a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a> </div> - <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth}" v-show="!isEmpty"> + <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" v-show="!isEmpty"> <a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()"> <img :key="nsfwImage" :src="nsfwImage"/> </a> @@ -10,11 +10,11 @@ <a href="#" @click.prevent="toggleHidden()">Hide</a> </div> - <a v-if="type === 'image' && !hidden" class="image-attachment" :href="attachment.url" target="_blank"> + <a v-if="type === 'image' && !hidden" class="image-attachment" :href="attachment.url" target="_blank" :title="attachment.description"> <StillImage :class="{'small': isSmall}" referrerpolicy="no-referrer" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/> </a> - <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" :src="attachment.url" controls loop></video> + <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" @loadeddata="onVideoDataLoad" :src="attachment.url" controls :loop="loopVideo"></video> <audio v-if="type === 'audio'" :src="attachment.url" controls></audio> @@ -38,7 +38,6 @@ .attachments { display: flex; flex-wrap: wrap; - margin-right: -0.7em; .attachment.media-upload-container { flex: 0 0 auto; @@ -50,6 +49,14 @@ margin-right: 0.5em; } + .nsfw-placeholder { + cursor: pointer; + + &.loading { + cursor: progress; + } + } + .small-attachment { &.image, &.video { max-width: 35%; @@ -58,6 +65,7 @@ } .attachment { + position: relative; flex: 1 0 30%; margin: 0.5em 0.7em 0.6em 0.0em; align-self: flex-start; @@ -85,10 +93,6 @@ display: flex; } - &.loading { - cursor: progress; - } - .hider { position: absolute; margin: 10px; diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index bfcd3fe7..5528fef6 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,9 +1,9 @@ <template> <div class="timeline panel panel-default"> <div class="panel-heading conversation-heading"> - {{ $t('timeline.conversation') }} - <span v-if="collapsable" style="float:right;"> - <small><a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a></small> + <span class="title"> {{ $t('timeline.conversation') }} </span> + <span v-if="collapsable"> + <a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a> </span> </div> <div class="panel-body"> diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js new file mode 100644 index 00000000..80f5c966 --- /dev/null +++ b/src/components/features_panel/features_panel.js @@ -0,0 +1,14 @@ +const FeaturesPanel = { + computed: { + chat: function () { + return this.$store.state.config.chatAvailable && (!this.$store.state.chatDisabled) + }, + gopher: function () { return this.$store.state.config.gopherAvailable }, + whoToFollow: function () { return this.$store.state.config.suggestionsEnabled }, + mediaProxy: function () { return this.$store.state.config.mediaProxyAvailable }, + scopeOptions: function () { return this.$store.state.config.scopeOptionsEnabled }, + textlimit: function () { return this.$store.state.config.textlimit } + } +} + +export default FeaturesPanel diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue new file mode 100644 index 00000000..445143e9 --- /dev/null +++ b/src/components/features_panel/features_panel.vue @@ -0,0 +1,29 @@ +<template> + <div class="features-panel"> + <div class="panel panel-default base01-background"> + <div class="panel-heading timeline-heading base02-background base04"> + <div class="title"> + {{$t('features_panel.title')}} + </div> + </div> + <div class="panel-body features-panel"> + <ul> + <li v-if="chat">{{$t('features_panel.chat')}}</li> + <li v-if="gopher">{{$t('features_panel.gopher')}}</li> + <li v-if="whoToFollow">{{$t('features_panel.who_to_follow')}}</li> + <li v-if="mediaProxy">{{$t('features_panel.media_proxy')}}</li> + <li v-if="scopeOptions">{{$t('features_panel.scope_options')}}</li> + <li>{{$t('features_panel.text_limit')}} = {{textlimit}}</li> + </ul> + </div> + </div> + </div> +</template> + +<script src="./features_panel.js" ></script> + +<style lang="scss"> + .features-panel li { + line-height: 24px; + } +</style> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue new file mode 100644 index 00000000..4b541888 --- /dev/null +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -0,0 +1,38 @@ +<template> + <div> + <label for="interface-language-switcher" class='select'> + <select id="interface-language-switcher" v-model="language"> + <option v-for="(langCode, i) in languageCodes" :value="langCode"> + {{ languageNames[i] }} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> +</template> + +<script> + import languagesObject from '../../i18n/messages' + import ISO6391 from 'iso-639-1' + import _ from 'lodash' + + export default { + computed: { + languageCodes () { + return Object.keys(languagesObject) + }, + + languageNames () { + return _.map(this.languageCodes, ISO6391.getName) + }, + + language: { + get: function () { return this.$store.state.config.interfaceLanguage }, + set: function (val) { + this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) + this.$i18n.locale = val + } + } + } + } +</script> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 8b4e7ad4..66337c3f 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -6,8 +6,10 @@ const mediaUpload = { const input = this.$el.querySelector('input') input.addEventListener('change', ({target}) => { - const file = target.files[0] - this.uploadFile(file) + for (var i = 0; i < target.files.length; i++) { + let file = target.files[i] + this.uploadFile(file) + } }) }, data () { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 8b931d2d..88094ebb 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -3,7 +3,7 @@ <label class="btn btn-default"> <i class="icon-spin4 animate-spin" v-if="uploading"></i> <i class="icon-upload" v-if="!uploading"></i> - <input type=file style="position: fixed; top: -100em"></input> + <input type="file" style="position: fixed; top: -100em" multiple="true"></input> </label> </div> </template> diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index bb76ddf8..72c1ca69 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -12,7 +12,7 @@ <div class="name-and-action"> <span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span> <span class="username" v-else :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> - <span v-if="notification.type === 'favorite'"> + <span v-if="notification.type === 'like'"> <i class="fa icon-star lit"></i> <small>{{$t('notifications.favorited_you')}}</small> </span> @@ -25,12 +25,17 @@ <small>{{$t('notifications.followed_you')}}</small> </span> </div> - <small class="timeago"><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + <small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> </span> <div class="follow-text" v-if="notification.type === 'follow'"> <router-link :to="{ name: 'user-profile', params: { id: notification.action.user.id } }">@{{notification.action.user.screen_name}}</router-link> </div> - <status v-else class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status> + <template v-else> + <status v-if="notification.status" class="faint" :compact="true" :statusoid="notification.status" :noHeading="true"></status> + <div class="broken-favorite" v-else> + {{$t('notifications.broken_favorite')}} + </div> + </template> </div> </div> </template> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index f8314bfc..58956f98 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,25 +1,38 @@ import Notification from '../notification/notification.vue' +import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' -import { sortBy, take, filter } from 'lodash' +import { sortBy, filter } from 'lodash' const Notifications = { - data () { - return { - visibleNotificationCount: 20 - } + created () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + + notificationsFetcher.startFetching({ store, credentials }) }, computed: { + visibleTypes () { + return [ + this.$store.state.config.notificationVisibility.likes && 'like', + this.$store.state.config.notificationVisibility.mentions && 'mention', + this.$store.state.config.notificationVisibility.repeats && 'repeat', + this.$store.state.config.notificationVisibility.follows && 'follow' + ].filter(_ => _) + }, notifications () { - return this.$store.state.statuses.notifications + return this.$store.state.statuses.notifications.data + }, + error () { + return this.$store.state.statuses.notifications.error }, unseenNotifications () { - return filter(this.notifications, ({seen}) => !seen) + return filter(this.visibleNotifications, ({seen}) => !seen) }, visibleNotifications () { // Don't know why, but sortBy([seen, -action.id]) doesn't work. let sortedNotifications = sortBy(this.notifications, ({action}) => -action.id) sortedNotifications = sortBy(sortedNotifications, 'seen') - return take(sortedNotifications, this.visibleNotificationCount) + return sortedNotifications.filter((notification) => this.visibleTypes.includes(notification.type)) }, unseenCount () { return this.unseenNotifications.length @@ -40,6 +53,15 @@ const Notifications = { methods: { markAsSeen () { this.$store.commit('markNotificationsAsSeen', this.visibleNotifications) + }, + fetchOlderNotifications () { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + notificationsFetcher.fetchAndUpdate({ + store, + credentials, + older: true + }) } } } diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 5853c68e..a137ccd5 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -4,44 +4,26 @@ // a bit of a hack to allow scrolling below notifications padding-bottom: 15em; - .panel { - background: $fallback--bg; - background: var(--bg, $fallback--bg) - } - - .panel-body { - border-color: $fallback--border; - border-color: var(--border, $fallback--border) - } - - .panel-heading { - // force the text to stay centered, while keeping - // the button in the right side of the panel heading - position: relative; - background: $fallback--btn; - background: var(--btn, $fallback--btn); - color: $fallback--fg; - color: var(--fg, $fallback--fg); - .read-button { - position: absolute; - right: 0.7em; - height: 1.8em; - line-height: 100%; - } - } - .unseen-count { display: inline-block; background-color: $fallback--cRed; background-color: var(--cRed, $fallback--cRed); text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5); - min-width: 1.3em; - border-radius: 1.3em; - margin: 0 0.2em 0 -0.4em; + border-radius: 99px; + min-width: 22px; + max-width: 22px; + min-height: 22px; + max-height: 22px; color: white; - font-size: 0.9em; + font-size: 15px; + line-height: 22px; text-align: center; - line-height: 1.3em; + vertical-align: middle + } + + .loadmore-error { + color: $fallback--fg; + color: var(--fg, $fallback--fg); } .unseen { @@ -54,7 +36,18 @@ box-sizing: border-box; display: flex; border-bottom: 1px solid; - border-bottom-color: inherit; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + + .broken-favorite { + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + color: $fallback--faint; + color: var(--faint, $fallback--faint); + background-color: $fallback--cAlertRed; + background-color: var(--cAlertRed, $fallback--cAlertRed); + padding: 2px .5em + } .avatar-compact { width: 32px; @@ -69,7 +62,7 @@ } } - &:hover .animated.avatar { + &:hover .animated.avatar-compact { canvas { display: none; } @@ -145,6 +138,13 @@ max-width: 100%; text-overflow: ellipsis; white-space: nowrap; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } } .timeago { float: right; @@ -194,15 +194,4 @@ margin-bottom: 0.3em; } } - - // ugly as heck - &:last-child { - border-bottom: none; - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - .status-el { - border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; - border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); - } - } } diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 4fa6e925..7a4322f9 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -2,8 +2,13 @@ <div class="notifications"> <div class="panel panel-default"> <div class="panel-heading"> - <span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span> - {{$t('notifications.notifications')}} + <div class="title"> + {{$t('notifications.notifications')}} + <span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span> + </div> + <div @click.prevent class="loadmore-error alert error" v-if="error"> + {{$t('timeline.error_fetching')}} + </div> <button v-if="unseenCount" @click.prevent="markAsSeen" class="read-button">{{$t('notifications.read')}}</button> </div> <div class="panel-body"> @@ -11,6 +16,12 @@ <notification :notification="notification"></notification> </div> </div> + <div class="panel-footer"> + <a href="#" v-on:click.prevent='fetchOlderNotifications()' v-if="!notifications.loading"> + <div class="new-status-notification text-center panel-footer">{{$t('notifications.load_older')}}</div> + </a> + <div class="new-status-notification text-center panel-footer" v-else>...</div> + </div> </div> </div> </template> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 61f2ac0a..d7f1ffb2 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -24,7 +24,8 @@ const PostStatusForm = { 'replyTo', 'repliedUser', 'attentions', - 'messageScope' + 'messageScope', + 'subject' ], components: { MediaUpload @@ -52,9 +53,12 @@ const PostStatusForm = { posting: false, highlighted: 0, newStatus: { + spoilerText: this.subject, status: statusText, + contentType: 'text/plain', + nsfw: false, files: [], - visibility: this.messageScope || 'public' + visibility: this.messageScope || this.$store.state.users.currentUser.default_scope }, caret: 0 } @@ -72,7 +76,7 @@ const PostStatusForm = { const firstchar = this.textAtCaret.charAt(0) if (firstchar === '@') { const matchedUsers = filter(this.users, (user) => (String(user.name + user.screen_name)).toUpperCase() - .match(this.textAtCaret.slice(1).toUpperCase())) + .startsWith(this.textAtCaret.slice(1).toUpperCase())) if (matchedUsers.length <= 0) { return false } @@ -86,7 +90,7 @@ const PostStatusForm = { })) } else if (firstchar === ':') { if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.match(this.textAtCaret.slice(1))) + const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) if (matchedEmoji.length <= 0) { return false } @@ -135,6 +139,9 @@ const PostStatusForm = { }, scopeOptionsEnabled () { return this.$store.state.config.scopeOptionsEnabled + }, + formattingOptionsEnabled () { + return this.$store.state.config.formattingOptionsEnabled } }, methods: { @@ -204,15 +211,18 @@ const PostStatusForm = { status: newStatus.status, spoilerText: newStatus.spoilerText || null, visibility: newStatus.visibility, + sensitive: newStatus.nsfw, media: newStatus.files, store: this.$store, - inReplyToStatusId: this.replyTo + inReplyToStatusId: this.replyTo, + contentType: newStatus.contentType }).then((data) => { if (!data.error) { this.newStatus = { status: '', files: [], - visibility: newStatus.visibility + visibility: newStatus.visibility, + contentType: newStatus.contentType } this.$emit('posted') let el = this.$el.querySelector('textarea') diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 7aa0e7c4..42e9c65c 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -32,11 +32,24 @@ @input="resize" @paste="paste"> </textarea> - <div v-if="scopeOptionsEnabled" class="visibility-tray"> - <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i> - <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i> - <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i> - <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i> + <div class="visibility-tray"> + <span class="text-format" v-if="formattingOptionsEnabled"> + <label for="post-content-type" class="select"> + <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> + <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> + <option value="text/html">HTML</option> + <option value="text/markdown">Markdown</option> + </select> + <i class="icon-down-open"></i> + </label> + </span> + + <div v-if="scopeOptionsEnabled"> + <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct" :title="$t('post_status.scope.direct')"></i> + <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private" :title="$t('post_status.scope.private')"></i> + <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted" :title="$t('post_status.scope.unlisted')"></i> + <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public" :title="$t('post_status.scope.public')"></i> + </div> </div> </div> <div style="position:relative;" v-if="candidates"> @@ -65,14 +78,20 @@ <i class="icon-cancel" @click="clearError"></i> </div> <div class="attachments"> - <div class="media-upload-container attachment" v-for="file in newStatus.files"> + <div class="media-upload-wrapper" v-for="file in newStatus.files"> <i class="fa icon-cancel" @click="removeMediaFile(file)"></i> - <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> - <video v-if="type(file) === 'video'" :src="file.image" controls></video> - <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio> - <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> + <div class="media-upload-container attachment"> + <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> + <video v-if="type(file) === 'video'" :src="file.image" controls></video> + <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio> + <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> + </div> </div> </div> + <div class="upload_settings" v-if="newStatus.files.length > 0"> + <input type="checkbox" id="filesSensitive" v-model="newStatus.nsfw"> + <label for="filesSensitive">{{$t('post_status.attachments_sensitive')}}</label> + </div> </form> </div> </template> @@ -99,25 +118,6 @@ } } -.post-status-form .visibility-tray { - font-size: 1.2em; - padding: 3px; - cursor: pointer; - - .selected { - color: $fallback--lightFg; - color: var(--lightFg, $fallback--lightFg); - } -} - -.visibility-notice { - padding: .5em; - border: 1px solid $fallback--faint; - border: 1px solid var(--faint, $fallback--faint); - border-radius: $fallback--inputRadius; - border-radius: var(--inputRadius, $fallback--inputRadius); -} - .post-status-form, .login { .form-bottom { display: flex; @@ -139,14 +139,49 @@ text-align: center; } + .media-upload-wrapper { + flex: 0 0 auto; + max-width: 100%; + min-width: 50px; + margin-right: .2em; + margin-bottom: .5em; + + .icon-cancel { + display: inline-block; + position: static; + margin: 0; + padding-bottom: 0; + margin-left: $fallback--attachmentRadius; + margin-left: var(--attachmentRadius, $fallback--attachmentRadius); + background-color: $fallback--btn; + background-color: var(--btn, $fallback--btn); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + .attachments { padding: 0 0.5em; .attachment { + margin: 0; position: relative; + flex: 0 0 auto; border: 1px solid $fallback--border; border: 1px solid var(--border, $fallback--border); - margin: 0.5em 0.8em 0.2em 0; + text-align: center; + + audio { + min-width: 300px; + flex: 1 0 auto; + } + + a { + display: block; + text-align: left; + line-height: 1.2; + padding: .5em; + } } i { diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index f5b00599..ee5722bd 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -1,7 +1,12 @@ <template> - <div v-if="loggedIn && visibility !== 'private' && visibility !== 'direct'"> - <i :class='classes' class='icon-retweet rt-active' v-on:click.prevent='retweet()'></i> - <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> + <div v-if="loggedIn"> + <template v-if="visibility !== 'private' && visibility !== 'direct'"> + <i :class='classes' class='icon-retweet rt-active' v-on:click.prevent='retweet()'></i> + <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> + </template> + <template v-else> + <i :class='classes' class='icon-lock' :title="$t('timeline.no_retweet_hint')"></i> + </template> </div> <div v-else-if="!loggedIn"> <i :class='classes' class='icon-retweet'></i> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index a26111d6..8ef84b2a 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -1,21 +1,43 @@ +/* eslint-env browser */ +import TabSwitcher from '../tab_switcher/tab_switcher.jsx' import StyleSwitcher from '../style_switcher/style_switcher.vue' +import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue' import { filter, trim } from 'lodash' const settings = { data () { + const config = this.$store.state.config + return { - hideAttachmentsLocal: this.$store.state.config.hideAttachments, - hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv, - hideNsfwLocal: this.$store.state.config.hideNsfw, - muteWordsString: this.$store.state.config.muteWords.join('\n'), - autoLoadLocal: this.$store.state.config.autoLoad, - streamingLocal: this.$store.state.config.streaming, - hoverPreviewLocal: this.$store.state.config.hoverPreview, - stopGifs: this.$store.state.config.stopGifs + hideAttachmentsLocal: config.hideAttachments, + hideAttachmentsInConvLocal: config.hideAttachmentsInConv, + hideNsfwLocal: config.hideNsfw, + notificationVisibilityLocal: config.notificationVisibility, + replyVisibilityLocal: config.replyVisibility, + loopVideoLocal: config.loopVideo, + loopVideoSilentOnlyLocal: config.loopVideoSilentOnly, + muteWordsString: config.muteWords.join('\n'), + autoLoadLocal: config.autoLoad, + streamingLocal: config.streaming, + pauseOnUnfocusedLocal: config.pauseOnUnfocused, + hoverPreviewLocal: config.hoverPreview, + collapseMessageWithSubjectLocal: typeof config.collapseMessageWithSubject === 'undefined' + ? config.defaultCollapseMessageWithSubject + : config.collapseMessageWithSubject, + stopGifs: config.stopGifs, + loopSilentAvailable: + // Firefox + Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || + // Chrome-likes + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'webkitAudioDecodedByteCount') || + // Future spec, still not supported in Nightly 63 as of 08/2018 + Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks') } }, components: { - StyleSwitcher + TabSwitcher, + StyleSwitcher, + InterfaceLanguageSwitcher }, computed: { user () { @@ -32,12 +54,36 @@ const settings = { hideNsfwLocal (value) { this.$store.dispatch('setOption', { name: 'hideNsfw', value }) }, + 'notificationVisibilityLocal.likes' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + 'notificationVisibilityLocal.follows' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + 'notificationVisibilityLocal.repeats' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + 'notificationVisibilityLocal.mentions' (value) { + this.$store.dispatch('setOption', { name: 'notificationVisibility', value: this.$store.state.config.notificationVisibility }) + }, + replyVisibilityLocal (value) { + this.$store.dispatch('setOption', { name: 'replyVisibility', value }) + }, + loopVideoLocal (value) { + this.$store.dispatch('setOption', { name: 'loopVideo', value }) + }, + loopVideoSilentOnlyLocal (value) { + this.$store.dispatch('setOption', { name: 'loopVideoSilentOnly', value }) + }, autoLoadLocal (value) { this.$store.dispatch('setOption', { name: 'autoLoad', value }) }, streamingLocal (value) { this.$store.dispatch('setOption', { name: 'streaming', value }) }, + pauseOnUnfocusedLocal (value) { + this.$store.dispatch('setOption', { name: 'pauseOnUnfocused', value }) + }, hoverPreviewLocal (value) { this.$store.dispatch('setOption', { name: 'hoverPreview', value }) }, @@ -45,6 +91,9 @@ const settings = { value = filter(value.split('\n'), (word) => trim(word).length > 0) this.$store.dispatch('setOption', { name: 'muteWords', value }) }, + collapseMessageWithSubjectLocal (value) { + this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value }) + }, stopGifs (value) { this.$store.dispatch('setOption', { name: 'stopGifs', value }) } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 6245e758..c106b79c 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -1,53 +1,137 @@ <template> - <div class="settings panel panel-default"> - <div class="panel-heading"> - {{$t('settings.settings')}} - </div> - <div class="panel-body"> - <div class="setting-item"> - <h2>{{$t('settings.theme')}}</h2> - <style-switcher></style-switcher> - </div> - <div class="setting-item"> - <h2>{{$t('settings.filtering')}}</h2> - <p>{{$t('settings.filtering_explanation')}}</p> - <textarea id="muteWords" v-model="muteWordsString"></textarea> - </div> - <div class="setting-item"> - <h2>{{$t('settings.attachments')}}</h2> - <ul class="setting-list"> +<div class="settings panel panel-default"> + <div class="panel-heading"> + {{$t('settings.settings')}} + </div> + <div class="panel-body"> + <tab-switcher> + <div :label="$t('settings.general')" > + <div class="setting-item"> + <h2>{{ $t('settings.interfaceLanguage') }}</h2> + <interface-language-switcher /> + </div> + <div class="setting-item"> + <h2>{{$t('nav.timeline')}}</h2> + <ul class="setting-list"> + <li> + <input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal"> + <label for="collapseMessageWithSubject">{{$t('settings.collapse_subject')}}</label> + </li> + <li> + <input type="checkbox" id="streaming" v-model="streamingLocal"> + <label for="streaming">{{$t('settings.streaming')}}</label> + <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]"> + <li> + <input :disabled="!streamingLocal" type="checkbox" id="pauseOnUnfocused" v-model="pauseOnUnfocusedLocal"> + <label for="pauseOnUnfocused">{{$t('settings.pause_on_unfocused')}}</label> + </li> + </ul> + </li> <li> - <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal"> - <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label> + <input type="checkbox" id="autoload" v-model="autoLoadLocal"> + <label for="autoload">{{$t('settings.autoload')}}</label> </li> <li> - <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal"> - <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> + <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal"> + <label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label> </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{$t('settings.attachments')}}</h2> + <ul class="setting-list"> <li> - <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> - <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> + <input type="checkbox" id="hideAttachments" v-model="hideAttachmentsLocal"> + <label for="hideAttachments">{{$t('settings.hide_attachments_in_tl')}}</label> </li> <li> - <input type="checkbox" id="autoload" v-model="autoLoadLocal"> - <label for="autoload">{{$t('settings.autoload')}}</label> + <input type="checkbox" id="hideAttachmentsInConv" v-model="hideAttachmentsInConvLocal"> + <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> </li> <li> - <input type="checkbox" id="streaming" v-model="streamingLocal"> - <label for="streaming">{{$t('settings.streaming')}}</label> + <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> + <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> </li> <li> - <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal"> - <label for="hoverPreview">{{$t('settings.reply_link_preview')}}</label> + <input type="checkbox" id="stopGifs" v-model="stopGifs"> + <label for="stopGifs">{{$t('settings.stop_gifs')}}</label> </li> <li> - <input type="checkbox" id="stopGifs" v-model="stopGifs"> - <label for="stopGifs">{{$t('settings.stop_gifs')}}</label> + <input type="checkbox" id="loopVideo" v-model="loopVideoLocal"> + <label for="loopVideo">{{$t('settings.loop_video')}}</label> + <ul class="setting-list suboptions" :class="[{disabled: !streamingLocal}]"> + <li> + <input :disabled="!loopVideoLocal || !loopSilentAvailable" type="checkbox" id="loopVideoSilentOnly" v-model="loopVideoSilentOnlyLocal"> + <label for="loopVideoSilentOnly">{{$t('settings.loop_video_silent_only')}}</label> + <div v-if="!loopSilentAvailable" class="unavailable"> + <i class="icon-globe"/>! {{$t('settings.limited_availability')}} + </div> + </li> + </ul> </li> - </ul> + </ul> + </div> </div> - </div> + + <div :label="$t('settings.theme')" > + <div class="setting-item"> + <style-switcher></style-switcher> + </div> + </div> + + <div :label="$t('settings.filtering')" > + <div class="setting-item"> + <div class="select-multiple"> + <span class="label">{{$t('settings.notification_visibility')}}</span> + <ul class="option-list"> + <li> + <input type="checkbox" id="notification-visibility-likes" v-model="notificationVisibilityLocal.likes"> + <label for="notification-visibility-likes"> + {{$t('settings.notification_visibility_likes')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-visibility-repeats" v-model="notificationVisibilityLocal.repeats"> + <label for="notification-visibility-repeats"> + {{$t('settings.notification_visibility_repeats')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-visibility-follows" v-model="notificationVisibilityLocal.follows"> + <label for="notification-visibility-follows"> + {{$t('settings.notification_visibility_follows')}} + </label> + </li> + <li> + <input type="checkbox" id="notification-visibility-mentions" v-model="notificationVisibilityLocal.mentions"> + <label for="notification-visibility-mentions"> + {{$t('settings.notification_visibility_mentions')}} + </label> + </li> + </ul> + </label> + </div> + <div> + {{$t('settings.replies_in_timeline')}} + <label for="replyVisibility" class="select"> + <select id="replyVisibility" v-model="replyVisibilityLocal"> + <option value="all" selected>{{$t('settings.reply_visibility_all')}}</option> + <option value="following">{{$t('settings.reply_visibility_following')}}</option> + <option value="self">{{$t('settings.reply_visibility_self')}}</option> + </select> + <i class="icon-down-open"/> + </label> + </div> + </div> + <div class="setting-item"> + <p>{{$t('settings.filtering_explanation')}}</p> + <textarea id="muteWords" v-model="muteWordsString"></textarea> + </div> + </div> + + </tab-switcher> </div> +</div> </template> <script src="./settings.js"> @@ -61,12 +145,35 @@ margin: 1em 1em 1.4em; padding-bottom: 1.4em; + > div { + margin-bottom: .5em; + &:last-child { + margin-bottom: 0; + } + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 1em; + } + + select { + min-width: 10em; + } + textarea { width: 100%; height: 100px; } + .unavailable, + .unavailable i { + color: var(--cRed, $fallback--cRed); + color: $fallback--cRed; + } + .old-avatar { width: 128px; border-radius: $fallback--avatarRadius; @@ -82,15 +189,31 @@ } .btn { - margin-top: 1em; min-height: 28px; + } + + .submit { + margin-top: 1em; + min-height: 30px; width: 10em; } } -.setting-list { +.select-multiple { + display: flex; + .option-list { + margin: 0; + padding-left: .5em; + } +} +.setting-list, +.option-list{ list-style-type: none; + padding-left: 2em; li { margin-bottom: 0.5em; } + .suboptions { + margin-top: 0.3em + } } </style> diff --git a/src/components/status/status.js b/src/components/status/status.js index a2d6f41f..45f5ccac 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -22,15 +22,18 @@ const Status = { 'noHeading', 'inlineExpanded' ], - data: () => ({ - replying: false, - expanded: false, - unmuted: false, - userExpanded: false, - preview: null, - showPreview: false, - showingTall: false - }), + data () { + return { + replying: false, + expanded: false, + unmuted: false, + userExpanded: false, + preview: null, + showPreview: false, + showingTall: false, + expandingSubject: !this.$store.state.config.collapseMessageWithSubject + } + }, computed: { muteWords () { return this.$store.state.config.muteWords @@ -80,7 +83,6 @@ const Status = { return hits }, muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) }, - isReply () { return !!this.status.in_reply_to_status_id }, isFocused () { // retweet or root of an expanded conversation if (this.focused) { @@ -98,12 +100,84 @@ const Status = { // // Using max-height + overflow: auto for status components resulted in false positives // very often with japanese characters, and it was very annoying. + tallStatus () { + const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 + return lengthScore > 20 + }, + isReply () { + if (this.status.in_reply_to_status_id) { + return true + } + // For private replies where we can't see the OP, in_reply_to_status_id will be null. + // So instead, check that the post starts with a @mention. + if (this.status.visibility === 'private') { + var textBody = this.status.text + if (this.status.summary !== null) { + textBody = textBody.substring(this.status.summary.length, textBody.length) + } + return textBody.startsWith('@') + } + return false + }, + hideReply () { + if (this.$store.state.config.replyVisibility === 'all') { + return false + } + if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) { + return false + } + if (this.status.user.id === this.$store.state.users.currentUser.id) { + return false + } + if (this.status.activity_type === 'repeat') { + return false + } + var checkFollowing = this.$store.state.config.replyVisibility === 'following' + for (var i = 0; i < this.status.attentions.length; ++i) { + if (this.status.user.id === this.status.attentions[i].id) { + continue + } + if (checkFollowing && this.status.attentions[i].following) { + return false + } + if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) { + return false + } + } + return this.status.attentions.length > 0 + }, + hideSubjectStatus () { + if (this.tallStatus && !this.$store.state.config.collapseMessageWithSubject) { + return false + } + return !this.expandingSubject && this.status.summary + }, hideTallStatus () { + if (this.status.summary && this.$store.state.config.collapseMessageWithSubject) { + return false + } if (this.showingTall) { return false } - const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 - return lengthScore > 20 + return this.tallStatus + }, + showingMore () { + return this.showingTall || (this.status.summary && this.expandingSubject) + }, + nsfwClickthrough () { + if (!this.status.nsfw) { + return false + } + if (this.status.summary && this.$store.state.config.collapseMessageWithSubject) { + return false + } + return true + }, + replySubject () { + if (this.status.summary && !this.status.summary.match(/^re[: ]/i)) { + return 're: '.concat(this.status.summary) + } + return this.status.summary }, attachmentSize () { if ((this.$store.state.config.hideAttachments && !this.inConversation) || @@ -163,8 +237,16 @@ const Status = { toggleUserExpanded () { this.userExpanded = !this.userExpanded }, - toggleShowTall () { - this.showingTall = !this.showingTall + toggleShowMore () { + if (this.showingTall) { + this.showingTall = false + } else if (this.expandingSubject) { + this.expandingSubject = false + } else if (this.hideTallStatus) { + this.showingTall = true + } else if (this.hideSubjectStatus) { + this.expandingSubject = true + } }, replyEnter (id, event) { this.showPreview = true @@ -200,6 +282,11 @@ const Status = { } } } + }, + filters: { + capitalize: function (str) { + return str.charAt(0).toUpperCase() + str.slice(1) + } } } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index e2fb5d36..eb521280 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,5 +1,5 @@ <template> - <div class="status-el" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> + <div class="status-el" v-if="!hideReply" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> <template v-if="muted && !noReplyLinks"> <div class="media status container muted"> <small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small> @@ -11,8 +11,8 @@ <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> <StillImage v-if="retweet" class='avatar' :src="statusoid.user.profile_image_url_original"/> <div class="media-body faint"> - <a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" style="font-weight: bold;" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a> - <a v-else :href="statusoid.user.statusnet_profile_url" style="font-weight: bold;" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> + <a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a> + <a v-else :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> <i class='fa icon-retweet retweeted'></i> {{$t('timeline.repeated')}} </div> @@ -57,10 +57,16 @@ <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }"> <timeago :since="status.created_at" :auto-update="60"></timeago> </router-link> - <span v-if="status.visibility"><i :class="visibilityIcon(status.visibility)"></i> </span> - <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url"><i class="icon-link-ext"></i></a> + <div class="visibility-icon" v-if="status.visibility"> + <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + </div> + <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source"> + <i class="icon-link-ext-alt"></i> + </a> <template v-if="expandable"> - <a href="#" @click.prevent="toggleExpanded"><i class="icon-plus-squared"></i></a> + <a href="#" @click.prevent="toggleExpanded" title="Expand"> + <i class="icon-plus-squared"></i> + </a> </template> <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="icon-eye-off"></i></a> </div> @@ -74,13 +80,15 @@ </div> <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper"> - <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowTall">Show more</a> - <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div> - <a v-if="showingTall" href="#" class="tall-status-unhider" @click.prevent="toggleShowTall">Show less</a> + <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary" v-else></div> + <a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a> + <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a> </div> - <div v-if='status.attachments' class='attachments media-body'> - <attachment :size="attachmentSize" :status-id="status.id" :nsfw="status.nsfw" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id"> + <div v-if='status.attachments && !hideSubjectStatus' class='attachments media-body'> + <attachment :size="attachmentSize" :status-id="status.id" :nsfw="nsfwClickthrough" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id"> </attachment> </div> @@ -98,7 +106,7 @@ </div> <div class="container" v-if="replying"> <div class="reply-left"/> - <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :message-scope="status.visibility" v-on:posted="toggleReplying"/> + <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/> </div> </template> </div> @@ -141,6 +149,7 @@ margin-top: 0.25em; margin-left: 0.5em; z-index: 50; + .status { flex: 1; border: 0; @@ -155,6 +164,7 @@ text-align: center; border-width: 1px; border-style: solid; + i { font-size: 2em; } @@ -196,6 +206,7 @@ .media-heading { flex-wrap: nowrap; + line-height: 18px; } .media-heading-left { @@ -218,12 +229,22 @@ flex: 1 0; display: flex; flex-wrap: wrap; - align-content: center; + align-items: baseline; + + .user-name { + margin-right: .45em; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } + } } + .links { display: flex; - padding-top: 1px; - margin-left: 0.2em; font-size: 12px; color: $fallback--link; color: var(--link, $fallback--link); @@ -247,19 +268,25 @@ } .media-heading-right { + display: inline-flex; flex-shrink: 0; - display: flex; flex-wrap: nowrap; - max-height: 1.5em; - margin-left: 0.25em; + margin-left: .25em; + align-self: baseline; + .timeago { margin-right: 0.2em; font-size: 12px; - padding-top: 1px; + align-self: last baseline; } - i { + + > * { margin-left: 0.2em; } + a:hover i { + color: $fallback--fg; + color: var(--fg, $fallback--fg); + } } a { @@ -289,7 +316,7 @@ } } - .tall-status-unhider { + .status-unhider, .cw-status-hider { width: 100%; text-align: center; } @@ -308,16 +335,41 @@ font-style: italic; } + pre { + overflow: auto; + } + p { margin: 0; margin-top: 0.2em; margin-bottom: 0.5em; } + + h1 { + font-size: 1.1em; + line-height: 1.2em; + margin: 1.4em 0; + } + + h2 { + font-size: 1.1em; + margin: 1.0em 0; + } + + h3 { + font-size: 1em; + margin: 1.2em 0; + } + + h4 { + margin: 1.1em 0; + } } .retweet-info { padding: 0.4em 0.6em 0 0.6em; margin: 0; + .avatar { border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); @@ -333,9 +385,22 @@ display: flex; align-content: center; flex-wrap: wrap; + + .user-name { + font-weight: bold; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } + } + i { padding: 0 0.2em; } + a { max-width: 100%; overflow: hidden; diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 0839aca5..5ad06dc2 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -18,7 +18,11 @@ const StillImage = { onLoad () { const canvas = this.$refs.canvas if (!canvas) return - canvas.getContext('2d').drawImage(this.$refs.src, 1, 1, canvas.width, canvas.height) + const width = this.$refs.src.naturalWidth + const height = this.$refs.src.naturalHeight + canvas.width = width + canvas.height = height + canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height) } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index a37c678d..1dcb7ce6 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -23,6 +23,7 @@ img { width: 100%; height: 100%; + object-fit: contain; } &.animated { @@ -60,6 +61,7 @@ right: 0; width: 100%; height: 100%; + object-fit: contain; } } </style> diff --git a/src/components/style_switcher/style_switcher.js b/src/components/style_switcher/style_switcher.js index 6f4845c4..95c15b49 100644 --- a/src/components/style_switcher/style_switcher.js +++ b/src/components/style_switcher/style_switcher.js @@ -5,6 +5,7 @@ export default { return { availableStyles: [], selected: this.$store.state.config.theme, + invalidThemeImported: false, bgColorLocal: '', btnColorLocal: '', textColorLocal: '', @@ -32,25 +33,61 @@ export default { }) }, mounted () { - this.bgColorLocal = rgbstr2hex(this.$store.state.config.colors.bg) - this.btnColorLocal = rgbstr2hex(this.$store.state.config.colors.btn) - this.textColorLocal = rgbstr2hex(this.$store.state.config.colors.fg) - this.linkColorLocal = rgbstr2hex(this.$store.state.config.colors.link) - - this.redColorLocal = rgbstr2hex(this.$store.state.config.colors.cRed) - this.blueColorLocal = rgbstr2hex(this.$store.state.config.colors.cBlue) - this.greenColorLocal = rgbstr2hex(this.$store.state.config.colors.cGreen) - this.orangeColorLocal = rgbstr2hex(this.$store.state.config.colors.cOrange) - - this.btnRadiusLocal = this.$store.state.config.radii.btnRadius || 4 - this.inputRadiusLocal = this.$store.state.config.radii.inputRadius || 4 - this.panelRadiusLocal = this.$store.state.config.radii.panelRadius || 10 - this.avatarRadiusLocal = this.$store.state.config.radii.avatarRadius || 5 - this.avatarAltRadiusLocal = this.$store.state.config.radii.avatarAltRadius || 50 - this.tooltipRadiusLocal = this.$store.state.config.radii.tooltipRadius || 2 - this.attachmentRadiusLocal = this.$store.state.config.radii.attachmentRadius || 5 + this.normalizeLocalState(this.$store.state.config.colors, this.$store.state.config.radii) }, methods: { + exportCurrentTheme () { + const stringified = JSON.stringify({ + // To separate from other random JSON files and possible future theme formats + _pleroma_theme_version: 1, + colors: this.$store.state.config.colors, + radii: this.$store.state.config.radii + }, 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') + e.setAttribute('download', 'pleroma_theme.json') + e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified)) + e.style.display = 'none' + + document.body.appendChild(e) + e.click() + document.body.removeChild(e) + }, + + importTheme () { + this.invalidThemeImported = false + const filePicker = document.createElement('input') + filePicker.setAttribute('type', 'file') + filePicker.setAttribute('accept', '.json') + + filePicker.addEventListener('change', event => { + if (event.target.files[0]) { + // eslint-disable-next-line no-undef + const reader = new FileReader() + reader.onload = ({target}) => { + try { + const parsed = JSON.parse(target.result) + if (parsed._pleroma_theme_version === 1) { + this.normalizeLocalState(parsed.colors, parsed.radii) + } else { + // A theme from the future, spooky + this.invalidThemeImported = true + } + } catch (e) { + // This will happen both if there is a JSON syntax error or the theme is missing components + this.invalidThemeImported = true + } + } + reader.readAsText(event.target.files[0]) + } + }) + + document.body.appendChild(filePicker) + filePicker.click() + document.body.removeChild(filePicker) + }, + setCustomTheme () { if (!this.bgColorLocal && !this.btnColorLocal && !this.linkColorLocal) { // reset to picked themes @@ -95,6 +132,26 @@ export default { attachmentRadius: this.attachmentRadiusLocal }}) } + }, + + normalizeLocalState (colors, radii) { + this.bgColorLocal = rgbstr2hex(colors.bg) + this.btnColorLocal = rgbstr2hex(colors.btn) + this.textColorLocal = rgbstr2hex(colors.fg) + this.linkColorLocal = rgbstr2hex(colors.link) + + this.redColorLocal = rgbstr2hex(colors.cRed) + this.blueColorLocal = rgbstr2hex(colors.cBlue) + this.greenColorLocal = rgbstr2hex(colors.cGreen) + this.orangeColorLocal = rgbstr2hex(colors.cOrange) + + this.btnRadiusLocal = radii.btnRadius || 4 + this.inputRadiusLocal = radii.inputRadius || 4 + this.panelRadiusLocal = radii.panelRadius || 10 + this.avatarRadiusLocal = radii.avatarRadius || 5 + this.avatarAltRadiusLocal = radii.avatarAltRadius || 50 + this.tooltipRadiusLocal = radii.tooltipRadius || 2 + this.attachmentRadiusLocal = radii.attachmentRadius || 5 } }, watch: { diff --git a/src/components/style_switcher/style_switcher.vue b/src/components/style_switcher/style_switcher.vue index 112bbc1e..72a338bd 100644 --- a/src/components/style_switcher/style_switcher.vue +++ b/src/components/style_switcher/style_switcher.vue @@ -1,97 +1,30 @@ <template> - <div> - <div>{{$t('settings.presets')}} +<div> + <div class="presets-container"> + <div> + {{$t('settings.presets')}} <label for="style-switcher" class='select'> <select id="style-switcher" v-model="selected" class="style-switcher"> - <option v-for="style in availableStyles" :value="style" :style="{ - backgroundColor: style[1], - color: style[3] - }">{{style[0]}}</option> + <option v-for="style in availableStyles" + :value="style" + :style="{ + backgroundColor: style[1], + color: style[3] + }"> + {{style[0]}} + </option> </select> <i class="icon-down-open"/> </label> </div> - <div class="color-container"> - <p>{{$t('settings.theme_help')}}</p> - <div class="color-item"> - <label for="bgcolor" class="theme-color-lb">{{$t('settings.background')}}</label> - <input id="bgcolor" class="theme-color-cl" type="color" v-model="bgColorLocal"> - <input id="bgcolor-t" class="theme-color-in" type="text" v-model="bgColorLocal"> - </div> - <div class="color-item"> - <label for="fgcolor" class="theme-color-lb">{{$t('settings.foreground')}}</label> - <input id="fgcolor" class="theme-color-cl" type="color" v-model="btnColorLocal"> - <input id="fgcolor-t" class="theme-color-in" type="text" v-model="btnColorLocal"> - </div> - <div class="color-item"> - <label for="textcolor" class="theme-color-lb">{{$t('settings.text')}}</label> - <input id="textcolor" class="theme-color-cl" type="color" v-model="textColorLocal"> - <input id="textcolor-t" class="theme-color-in" type="text" v-model="textColorLocal"> - </div> - <div class="color-item"> - <label for="linkcolor" class="theme-color-lb">{{$t('settings.links')}}</label> - <input id="linkcolor" class="theme-color-cl" type="color" v-model="linkColorLocal"> - <input id="linkcolor-t" class="theme-color-in" type="text" v-model="linkColorLocal"> - </div> - <div class="color-item"> - <label for="redcolor" class="theme-color-lb">{{$t('settings.cRed')}}</label> - <input id="redcolor" class="theme-color-cl" type="color" v-model="redColorLocal"> - <input id="redcolor-t" class="theme-color-in" type="text" v-model="redColorLocal"> - </div> - <div class="color-item"> - <label for="bluecolor" class="theme-color-lb">{{$t('settings.cBlue')}}</label> - <input id="bluecolor" class="theme-color-cl" type="color" v-model="blueColorLocal"> - <input id="bluecolor-t" class="theme-color-in" type="text" v-model="blueColorLocal"> - </div> - <div class="color-item"> - <label for="greencolor" class="theme-color-lb">{{$t('settings.cGreen')}}</label> - <input id="greencolor" class="theme-color-cl" type="color" v-model="greenColorLocal"> - <input id="greencolor-t" class="theme-color-in" type="green" v-model="greenColorLocal"> - </div> - <div class="color-item"> - <label for="orangecolor" class="theme-color-lb">{{$t('settings.cOrange')}}</label> - <input id="orangecolor" class="theme-color-cl" type="color" v-model="orangeColorLocal"> - <input id="orangecolor-t" class="theme-color-in" type="text" v-model="orangeColorLocal"> - </div> - </div> - <div class="radius-container"> - <p>{{$t('settings.radii_help')}}</p> - <div class="radius-item"> - <label for="btnradius" class="theme-radius-lb">{{$t('settings.btnRadius')}}</label> - <input id="btnradius" class="theme-radius-rn" type="range" v-model="btnRadiusLocal" max="16"> - <input id="btnradius-t" class="theme-radius-in" type="text" v-model="btnRadiusLocal"> - </div> - <div class="radius-item"> - <label for="inputradius" class="theme-radius-lb">{{$t('settings.inputRadius')}}</label> - <input id="inputradius" class="theme-radius-rn" type="range" v-model="inputRadiusLocal" max="16"> - <input id="inputradius-t" class="theme-radius-in" type="text" v-model="inputRadiusLocal"> - </div> - <div class="radius-item"> - <label for="panelradius" class="theme-radius-lb">{{$t('settings.panelRadius')}}</label> - <input id="panelradius" class="theme-radius-rn" type="range" v-model="panelRadiusLocal" max="50"> - <input id="panelradius-t" class="theme-radius-in" type="text" v-model="panelRadiusLocal"> - </div> - <div class="radius-item"> - <label for="avatarradius" class="theme-radius-lb">{{$t('settings.avatarRadius')}}</label> - <input id="avatarradius" class="theme-radius-rn" type="range" v-model="avatarRadiusLocal" max="28"> - <input id="avatarradius-t" class="theme-radius-in" type="green" v-model="avatarRadiusLocal"> - </div> - <div class="radius-item"> - <label for="avataraltradius" class="theme-radius-lb">{{$t('settings.avatarAltRadius')}}</label> - <input id="avataraltradius" class="theme-radius-rn" type="range" v-model="avatarAltRadiusLocal" max="28"> - <input id="avataraltradius-t" class="theme-radius-in" type="text" v-model="avatarAltRadiusLocal"> - </div> - <div class="radius-item"> - <label for="attachmentradius" class="theme-radius-lb">{{$t('settings.attachmentRadius')}}</label> - <input id="attachmentrradius" class="theme-radius-rn" type="range" v-model="attachmentRadiusLocal" max="50"> - <input id="attachmentradius-t" class="theme-radius-in" type="text" v-model="attachmentRadiusLocal"> - </div> - <div class="radius-item"> - <label for="tooltipradius" class="theme-radius-lb">{{$t('settings.tooltipRadius')}}</label> - <input id="tooltipradius" class="theme-radius-rn" type="range" v-model="tooltipRadiusLocal" max="20"> - <input id="tooltipradius-t" class="theme-radius-in" type="text" v-model="tooltipRadiusLocal"> - </div> + <div class="import-export"> + <button class="btn" @click="exportCurrentTheme">{{ $t('settings.export_theme') }}</button> + <button class="btn" @click="importTheme">{{ $t('settings.import_theme') }}</button> + <p v-if="invalidThemeImported" class="import-warning">{{ $t('settings.invalid_theme_imported') }}</p> </div> + </div> + + <div class="preview-container"> <div :style="{ '--btnRadius': btnRadiusLocal + 'px', '--inputRadius': inputRadiusLocal + 'px', @@ -122,8 +55,95 @@ </div> </div> </div> - <button class="btn" @click="setCustomTheme">{{$t('general.apply')}}</button> </div> + + <div class="color-container"> + <p>{{$t('settings.theme_help')}}</p> + <div class="color-item"> + <label for="bgcolor" class="theme-color-lb">{{$t('settings.background')}}</label> + <input id="bgcolor" class="theme-color-cl" type="color" v-model="bgColorLocal"> + <input id="bgcolor-t" class="theme-color-in" type="text" v-model="bgColorLocal"> + </div> + <div class="color-item"> + <label for="fgcolor" class="theme-color-lb">{{$t('settings.foreground')}}</label> + <input id="fgcolor" class="theme-color-cl" type="color" v-model="btnColorLocal"> + <input id="fgcolor-t" class="theme-color-in" type="text" v-model="btnColorLocal"> + </div> + <div class="color-item"> + <label for="textcolor" class="theme-color-lb">{{$t('settings.text')}}</label> + <input id="textcolor" class="theme-color-cl" type="color" v-model="textColorLocal"> + <input id="textcolor-t" class="theme-color-in" type="text" v-model="textColorLocal"> + </div> + <div class="color-item"> + <label for="linkcolor" class="theme-color-lb">{{$t('settings.links')}}</label> + <input id="linkcolor" class="theme-color-cl" type="color" v-model="linkColorLocal"> + <input id="linkcolor-t" class="theme-color-in" type="text" v-model="linkColorLocal"> + </div> + <div class="color-item"> + <label for="redcolor" class="theme-color-lb">{{$t('settings.cRed')}}</label> + <input id="redcolor" class="theme-color-cl" type="color" v-model="redColorLocal"> + <input id="redcolor-t" class="theme-color-in" type="text" v-model="redColorLocal"> + </div> + <div class="color-item"> + <label for="bluecolor" class="theme-color-lb">{{$t('settings.cBlue')}}</label> + <input id="bluecolor" class="theme-color-cl" type="color" v-model="blueColorLocal"> + <input id="bluecolor-t" class="theme-color-in" type="text" v-model="blueColorLocal"> + </div> + <div class="color-item"> + <label for="greencolor" class="theme-color-lb">{{$t('settings.cGreen')}}</label> + <input id="greencolor" class="theme-color-cl" type="color" v-model="greenColorLocal"> + <input id="greencolor-t" class="theme-color-in" type="green" v-model="greenColorLocal"> + </div> + <div class="color-item"> + <label for="orangecolor" class="theme-color-lb">{{$t('settings.cOrange')}}</label> + <input id="orangecolor" class="theme-color-cl" type="color" v-model="orangeColorLocal"> + <input id="orangecolor-t" class="theme-color-in" type="text" v-model="orangeColorLocal"> + </div> + </div> + + <div class="radius-container"> + <p>{{$t('settings.radii_help')}}</p> + <div class="radius-item"> + <label for="btnradius" class="theme-radius-lb">{{$t('settings.btnRadius')}}</label> + <input id="btnradius" class="theme-radius-rn" type="range" v-model="btnRadiusLocal" max="16"> + <input id="btnradius-t" class="theme-radius-in" type="text" v-model="btnRadiusLocal"> + </div> + <div class="radius-item"> + <label for="inputradius" class="theme-radius-lb">{{$t('settings.inputRadius')}}</label> + <input id="inputradius" class="theme-radius-rn" type="range" v-model="inputRadiusLocal" max="16"> + <input id="inputradius-t" class="theme-radius-in" type="text" v-model="inputRadiusLocal"> + </div> + <div class="radius-item"> + <label for="panelradius" class="theme-radius-lb">{{$t('settings.panelRadius')}}</label> + <input id="panelradius" class="theme-radius-rn" type="range" v-model="panelRadiusLocal" max="50"> + <input id="panelradius-t" class="theme-radius-in" type="text" v-model="panelRadiusLocal"> + </div> + <div class="radius-item"> + <label for="avatarradius" class="theme-radius-lb">{{$t('settings.avatarRadius')}}</label> + <input id="avatarradius" class="theme-radius-rn" type="range" v-model="avatarRadiusLocal" max="28"> + <input id="avatarradius-t" class="theme-radius-in" type="green" v-model="avatarRadiusLocal"> + </div> + <div class="radius-item"> + <label for="avataraltradius" class="theme-radius-lb">{{$t('settings.avatarAltRadius')}}</label> + <input id="avataraltradius" class="theme-radius-rn" type="range" v-model="avatarAltRadiusLocal" max="28"> + <input id="avataraltradius-t" class="theme-radius-in" type="text" v-model="avatarAltRadiusLocal"> + </div> + <div class="radius-item"> + <label for="attachmentradius" class="theme-radius-lb">{{$t('settings.attachmentRadius')}}</label> + <input id="attachmentrradius" class="theme-radius-rn" type="range" v-model="attachmentRadiusLocal" max="50"> + <input id="attachmentradius-t" class="theme-radius-in" type="text" v-model="attachmentRadiusLocal"> + </div> + <div class="radius-item"> + <label for="tooltipradius" class="theme-radius-lb">{{$t('settings.tooltipRadius')}}</label> + <input id="tooltipradius" class="theme-radius-rn" type="range" v-model="tooltipRadiusLocal" max="20"> + <input id="tooltipradius-t" class="theme-radius-in" type="text" v-model="tooltipRadiusLocal"> + </div> + </div> + + <div class="apply-container"> + <button class="btn submit" @click="setCustomTheme">{{$t('general.apply')}}</button> + </div> +</div> </template> <script src="./style_switcher.js"></script> @@ -134,15 +154,24 @@ margin-right: 1em; } +.import-warning { + color: $fallback--cRed; + color: var(--cRed, $fallback--cRed); +} + +.apply-container, .radius-container, -.color-container { +.color-container, +.presets-container { display: flex; p { + flex: 2 0 100%; margin-top: 2em; margin-bottom: .5em; } } + .radius-container { flex-direction: column; } @@ -152,6 +181,36 @@ justify-content: space-between; } +.presets-container { + justify-content: center; + .import-export { + display: flex; + + .btn { + margin-left: .5em; + } + } +} + +.preview-container { + border-top: 1px dashed; + border-bottom: 1px dashed; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + margin: 1em -1em 0; + padding: 1em; + + .btn { + margin-top: 1em; + min-height: 30px; + width: 10em; + } +} + +.apply-container { + justify-content: center; +} + .radius-item, .color-item { min-width: 20em; @@ -219,6 +278,7 @@ flex: 0; min-width: 2em; cursor: pointer; + max-height: 29px; } .theme-preview-content { diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx new file mode 100644 index 00000000..3fff38f6 --- /dev/null +++ b/src/components/tab_switcher/tab_switcher.jsx @@ -0,0 +1,44 @@ +import Vue from 'vue' + +import './tab_switcher.scss' + +export default Vue.component('tab-switcher', { + name: 'TabSwitcher', + data () { + return { + active: 0 + } + }, + methods: { + activateTab(index) { + return () => this.active = index; + } + }, + render(h) { + const tabs = this.$slots.default + .filter(slot => slot.data) + .map((slot, index) => { + const classes = ['tab'] + + if (index === this.active) { + classes.push('active') + } + return (<button onClick={this.activateTab(index)} class={ classes.join(' ') }>{slot.data.attrs.label}</button>) + }); + const contents = ( + <div> + {this.$slots.default.filter(slot => slot.data)[this.active]} + </div> + ); + return ( + <div class="tab-switcher"> + <div class="tabs"> + {tabs} + </div> + <div class="contents"> + {contents} + </div> + </div> + ) + } +}) diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss new file mode 100644 index 00000000..374a19c5 --- /dev/null +++ b/src/components/tab_switcher/tab_switcher.scss @@ -0,0 +1,43 @@ +@import '../../_variables.scss'; + +.tab-switcher { + .tabs { + display: flex; + position: relative; + justify-content: center; + width: 100%; + overflow: hidden; + padding-top: 5px; + + &::after, &::before { + display: block; + content: ''; + flex: 1 1 auto; + } + + .tab, &::after, &::before { + border-bottom: 1px solid; + border-bottom-color: $fallback--btn; + border-bottom-color: var(--btn, $fallback--btn); + } + + .tab { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding: .3em 1em; + + &:not(.active) { + border-bottom: 1px solid; + border-bottom-color: $fallback--btn; + border-bottom-color: var(--btn, $fallback--btn); + z-index: 4; + } + + &.active { + background: transparent; + border-bottom: none; + z-index: 5; + } + } + } +} diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 5c179567..a651f619 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -133,7 +133,10 @@ const Timeline = { } if (count > 0) { // only 'stream' them when you're scrolled to the top - if (window.pageYOffset < 15 && !this.paused && !this.unfocused) { + if (window.pageYOffset < 15 && + !this.paused && + !(this.unfocused && this.$store.state.config.pauseOnUnfocused) + ) { this.showNewStatuses() } else { this.paused = true diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index c4e0fbce..2dd4376a 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -4,12 +4,12 @@ <div class="title"> {{title}} </div> - <button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError"> - {{$t('timeline.show_new')}}{{newStatusCountStr}} - </button> <div @click.prevent class="loadmore-error alert error" v-if="timelineError"> {{$t('timeline.error_fetching')}} </div> + <button @click.prevent="showNewStatuses" class="loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError"> + {{$t('timeline.show_new')}}{{newStatusCountStr}} + </button> <div @click.prevent class="loadmore-text" v-if="!timeline.newStatusCount > 0 && !timelineError"> {{$t('timeline.up_to_date')}} </div> @@ -57,36 +57,7 @@ @import '../../_variables.scss'; .timeline { - .timeline-heading { - position: relative; - display: flex; - } - - .title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 70%; - } - - .loadmore-button { - position: absolute; - right: 0.6em; - font-size: 14px; - - min-width: 6em; - height: 1.8em; - line-height: 100%; - } - .loadmore-text { - position: absolute; - right: 0.6em; - font-size: 14px; - min-width: 6em; - font-family: sans-serif; - text-align: center; - padding: 0 0.5em 0 0.5em; opacity: 0.8; background-color: transparent; color: $fallback--faint; @@ -94,14 +65,6 @@ } .loadmore-error { - position: absolute; - right: 0.6em; - font-size: 14px; - min-width: 6em; - font-family: sans-serif; - text-align: center; - padding: 0 0.25em 0 0.25em; - margin: 0; color: $fallback--fg; color: var(--fg, $fallback--fg); } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 7e3e0afe..48f272ca 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -73,12 +73,14 @@ border-radius: var(--panelRadius, $fallback--panelRadius); border-style: solid; border-color: $fallback--border; - border-color: var(--border, $fallback--border); + border-color: var(--border, $fallback--border); border-width: 1px; overflow: hidden; .panel-heading { background: transparent; + flex-direction: column; + align-items: stretch; } p { diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 71222d15..59358040 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -105,8 +105,8 @@ <span>{{user.followers_count}}</span> </div> </div> - <p v-if="!hideBio && user.description_html" v-html="user.description_html"></p> - <p v-else-if="!hideBio">{{ user.description }}</p> + <p v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p> + <p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p> </div> </div> </template> @@ -130,7 +130,11 @@ .profile-panel-body { word-wrap: break-word; background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%) + background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%); + + .profile-bio { + text-align: center; + } } .user-info { diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 3d4f873d..2d5cb500 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -14,8 +14,10 @@ <style lang="scss"> .user-panel { - .profile-panel-background .panel-heading { - background: transparent; - } + .profile-panel-background .panel-heading { + background: transparent; + flex-direction: column; + align-items: stretch; + } } </style> diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index f8502907..91d4acd2 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -17,6 +17,8 @@ padding-bottom: 10px; .panel-heading { background: transparent; + flex-direction: column; + align-items: stretch; } } </style> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 443e63dd..0b13a668 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,3 +1,4 @@ +import TabSwitcher from '../tab_switcher/tab_switcher.jsx' import StyleSwitcher from '../style_switcher/style_switcher.vue' const UserSettings = { @@ -6,6 +7,7 @@ const UserSettings = { newname: this.$store.state.users.currentUser.name, newbio: this.$store.state.users.currentUser.description, newlocked: this.$store.state.users.currentUser.locked, + newdefaultScope: this.$store.state.users.currentUser.default_scope, followList: null, followImportError: false, followsImported: false, @@ -17,11 +19,13 @@ const UserSettings = { deleteAccountError: false, changePasswordInputs: [ '', '', '' ], changedPassword: false, - changePasswordError: false + changePasswordError: false, + activeTab: 'profile' } }, components: { - StyleSwitcher + StyleSwitcher, + TabSwitcher }, computed: { user () { @@ -29,6 +33,17 @@ const UserSettings = { }, pleromaBackend () { return this.$store.state.config.pleromaBackend + }, + scopeOptionsEnabled () { + return this.$store.state.config.scopeOptionsEnabled + }, + vis () { + return { + public: { selected: this.newdefaultScope === 'public' }, + unlisted: { selected: this.newdefaultScope === 'unlisted' }, + private: { selected: this.newdefaultScope === 'private' }, + direct: { selected: this.newdefaultScope === 'direct' } + } } }, methods: { @@ -36,12 +51,18 @@ const UserSettings = { const name = this.newname const description = this.newbio const locked = this.newlocked - this.$store.state.api.backendInteractor.updateProfile({params: {name, description, locked}}).then((user) => { + /* eslint-disable camelcase */ + const default_scope = this.newdefaultScope + this.$store.state.api.backendInteractor.updateProfile({params: {name, description, locked, default_scope}}).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) } }) + /* eslint-enable camelcase */ + }, + changeVis (visibility) { + this.newdefaultScope = visibility }, uploadFile (slot, e) { const file = e.target.files[0] @@ -217,6 +238,9 @@ const UserSettings = { this.changePasswordError = res.error } }) + }, + activateTab (tabName) { + this.activeTab = tabName } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 881b0fa1..9daafdce 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -4,112 +4,131 @@ {{$t('settings.user_settings')}} </div> <div class="panel-body profile-edit"> - <div class="setting-item"> - <h2>{{$t('settings.name_bio')}}</h2> - <p>{{$t('settings.name')}}</p> - <input class='name-changer' id='username' v-model="newname"></input> - <p>{{$t('settings.bio')}}</p> - <textarea class="bio" v-model="newbio"></textarea> - <div class="setting-item"> - <input type="checkbox" v-model="newlocked" id="account-locked"> - <label for="account-locked">{{$t('settings.lock_account_description')}}</label> + <tab-switcher> + <div :label="$t('settings.profile_tab')"> + <div class="setting-item" > + <h2>{{$t('settings.name_bio')}}</h2> + <p>{{$t('settings.name')}}</p> + <input class='name-changer' id='username' v-model="newname"></input> + <p>{{$t('settings.bio')}}</p> + <textarea class="bio" v-model="newbio"></textarea> + <p> + <input type="checkbox" v-model="newlocked" id="account-locked"> + <label for="account-locked">{{$t('settings.lock_account_description')}}</label> + </p> + <div v-if="scopeOptionsEnabled"> + <label for="default-vis">{{$t('settings.default_vis')}}</label> + <div id="default-vis" class="visibility-tray"> + <i v-on:click="changeVis('direct')" class="icon-mail-alt" :class="vis.direct"></i> + <i v-on:click="changeVis('private')" class="icon-lock" :class="vis.private"></i> + <i v-on:click="changeVis('unlisted')" class="icon-lock-open-alt" :class="vis.unlisted"></i> + <i v-on:click="changeVis('public')" class="icon-globe" :class="vis.public"></i> + </div> + </div> + <button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> + </div> + <div class="setting-item"> + <h2>{{$t('settings.avatar')}}</h2> + <p>{{$t('settings.current_avatar')}}</p> + <img :src="user.profile_image_url_original" class="old-avatar"></img> + <p>{{$t('settings.set_new_avatar')}}</p> + <img class="new-avatar" v-bind:src="previews[0]" v-if="previews[0]"> + </img> + <div> + <input type="file" @change="uploadFile(0, $event)" ></input> + </div> + <i class="icon-spin4 animate-spin" v-if="uploading[0]"></i> + <button class="btn btn-default" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button> + </div> + <div class="setting-item"> + <h2>{{$t('settings.profile_banner')}}</h2> + <p>{{$t('settings.current_profile_banner')}}</p> + <img :src="user.cover_photo" class="banner"></img> + <p>{{$t('settings.set_new_profile_banner')}}</p> + <img class="banner" v-bind:src="previews[1]" v-if="previews[1]"> + </img> + <div> + <input type="file" @change="uploadFile(1, $event)" ></input> + </div> + <i class=" icon-spin4 animate-spin uploading" v-if="uploading[1]"></i> + <button class="btn btn-default" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button> + </div> + <div class="setting-item"> + <h2>{{$t('settings.profile_background')}}</h2> + <p>{{$t('settings.set_new_profile_background')}}</p> + <img class="bg" v-bind:src="previews[2]" v-if="previews[2]"> + </img> + <div> + <input type="file" @change="uploadFile(2, $event)" ></input> + </div> + <i class=" icon-spin4 animate-spin uploading" v-if="uploading[2]"></i> + <button class="btn btn-default" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button> + </div> </div> - <button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h2>{{$t('settings.avatar')}}</h2> - <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="old-avatar"></img> - <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="previews[0]" v-if="previews[0]"> - </img> - <div> - <input type="file" @change="uploadFile(0, $event)" ></input> - </div> - <i class="icon-spin4 animate-spin" v-if="uploading[0]"></i> - <button class="btn btn-default" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h2>{{$t('settings.profile_banner')}}</h2> - <p>{{$t('settings.current_profile_banner')}}</p> - <img :src="user.cover_photo" class="banner"></img> - <p>{{$t('settings.set_new_profile_banner')}}</p> - <img class="banner" v-bind:src="previews[1]" v-if="previews[1]"> - </img> - <div> - <input type="file" @change="uploadFile(1, $event)" ></input> - </div> - <i class=" icon-spin4 animate-spin uploading" v-if="uploading[1]"></i> - <button class="btn btn-default" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h2>{{$t('settings.profile_background')}}</h2> - <p>{{$t('settings.set_new_profile_background')}}</p> - <img class="bg" v-bind:src="previews[2]" v-if="previews[2]"> - </img> - <div> - <input type="file" @change="uploadFile(2, $event)" ></input> - </div> - <i class=" icon-spin4 animate-spin uploading" v-if="uploading[2]"></i> - <button class="btn btn-default" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h2>{{$t('settings.change_password')}}</h2> - <div> - <p>{{$t('settings.current_password')}}</p> - <input type="password" v-model="changePasswordInputs[0]"> - </div> - <div> - <p>{{$t('settings.new_password')}}</p> - <input type="password" v-model="changePasswordInputs[1]"> - </div> - <div> - <p>{{$t('settings.confirm_new_password')}}</p> - <input type="password" v-model="changePasswordInputs[2]"> - </div> - <button class="btn btn-default" @click="changePassword">{{$t('general.submit')}}</button> - <p v-if="changedPassword">{{$t('settings.changed_password')}}</p> - <p v-else-if="changePasswordError !== false">{{$t('settings.change_password_error')}}</p> - <p v-if="changePasswordError">{{changePasswordError}}</p> - </div> - <div class="setting-item" v-if="pleromaBackend"> - <h2>{{$t('settings.follow_import')}}</h2> - <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> - <form v-model="followImportForm"> - <input type="file" ref="followlist" v-on:change="followListChange"></input> - </form> - <i class=" icon-spin4 animate-spin uploading" v-if="uploading[3]"></i> - <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> - <div v-if="followsImported"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follows_imported')}}</p> - </div> - <div v-else-if="followImportError"> - <i class="icon-cross" @click="dismissImported"></i> - <p>{{$t('settings.follow_import_error')}}</p> + + <div :label="$t('settings.security_tab')"> + <div class="setting-item"> + <h2>{{$t('settings.change_password')}}</h2> + <div> + <p>{{$t('settings.current_password')}}</p> + <input type="password" v-model="changePasswordInputs[0]"> + </div> + <div> + <p>{{$t('settings.new_password')}}</p> + <input type="password" v-model="changePasswordInputs[1]"> + </div> + <div> + <p>{{$t('settings.confirm_new_password')}}</p> + <input type="password" v-model="changePasswordInputs[2]"> + </div> + <button class="btn btn-default" @click="changePassword">{{$t('general.submit')}}</button> + <p v-if="changedPassword">{{$t('settings.changed_password')}}</p> + <p v-else-if="changePasswordError !== false">{{$t('settings.change_password_error')}}</p> + <p v-if="changePasswordError">{{changePasswordError}}</p> + </div> + + <div class="setting-item"> + <h2>{{$t('settings.delete_account')}}</h2> + <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> + <div v-if="deletingAccount"> + <p>{{$t('settings.delete_account_instructions')}}</p> + <p>{{$t('login.password')}}</p> + <input type="password" v-model="deleteAccountConfirmPasswordInput"> + <button class="btn btn-default" @click="deleteAccount">{{$t('settings.delete_account')}}</button> + </div> + <p v-if="deleteAccountError !== false">{{$t('settings.delete_account_error')}}</p> + <p v-if="deleteAccountError">{{deleteAccountError}}</p> + <button class="btn btn-default" v-if="!deletingAccount" @click="confirmDelete">{{$t('general.submit')}}</button> + </div> </div> - </div> - <div class="setting-item" v-if="enableFollowsExport"> - <h2>{{$t('settings.follow_export')}}</h2> - <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> - </div> - <div class="setting-item" v-else> - <h2>{{$t('settings.follow_export_processing')}}</h2> - </div> - <hr> - <div class="setting-item"> - <h2>{{$t('settings.delete_account')}}</h2> - <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> - <div v-if="deletingAccount"> - <p>{{$t('settings.delete_account_instructions')}}</p> - <p>{{$t('login.password')}}</p> - <input type="password" v-model="deleteAccountConfirmPasswordInput"> - <button class="btn btn-default" @click="deleteAccount">{{$t('settings.delete_account')}}</button> + + <div :label="$t('settings.data_import_export_tab')" v-if="pleromaBackend"> + <div class="setting-item"> + <h2>{{$t('settings.follow_import')}}</h2> + <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> + <form v-model="followImportForm"> + <input type="file" ref="followlist" v-on:change="followListChange"></input> + </form> + <i class=" icon-spin4 animate-spin uploading" v-if="uploading[3]"></i> + <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> + <div v-if="followsImported"> + <i class="icon-cross" @click="dismissImported"></i> + <p>{{$t('settings.follows_imported')}}</p> + </div> + <div v-else-if="followImportError"> + <i class="icon-cross" @click="dismissImported"></i> + <p>{{$t('settings.follow_import_error')}}</p> + </div> + </div> + <div class="setting-item" v-if="enableFollowsExport"> + <h2>{{$t('settings.follow_export')}}</h2> + <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> + </div> + <div class="setting-item" v-else> + <h2>{{$t('settings.follow_export_processing')}}</h2> + </div> </div> - <p v-if="deleteAccountError !== false">{{$t('settings.delete_account_error')}}</p> - <p v-if="deleteAccountError">{{deleteAccountError}}</p> - <button class="btn btn-default" v-if="!deletingAccount" @click="confirmDelete">{{$t('general.submit')}}</button> - </div> + </tab-switcher> </div> </div> </template> diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js index 51b9f469..ce60308f 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.js +++ b/src/components/who_to_follow_panel/who_to_follow_panel.js @@ -1,18 +1,21 @@ -function showWhoToFollow (panel, reply, aHost, aUser) { - var users = reply.ids +import apiService from '../../services/api/api.service.js' + +function showWhoToFollow (panel, reply) { + var users = reply var cn - var index = 0 - var random = Math.floor(Math.random() * 10) - for (cn = random; cn < users.length; cn = cn + 10) { + var index + var step = 7 + cn = Math.floor(Math.random() * step) + for (index = 0; index < 3; index++) { var user user = users[cn] var img - if (user.icon) { - img = user.icon + if (user.avatar) { + img = user.avatar } else { img = '/images/avi.png' } - var name = user.to_id + var name = user.acct if (index === 0) { panel.img1 = img panel.name1 = name @@ -44,35 +47,20 @@ function showWhoToFollow (panel, reply, aHost, aUser) { } }) } - index = index + 1 - if (index > 2) { - break - } } + cn = (cn + step) % users.length } function getWhoToFollow (panel) { - var user = panel.$store.state.users.currentUser.screen_name - if (user) { + var credentials = panel.$store.state.users.currentUser.credentials + if (credentials) { panel.name1 = 'Loading...' panel.name2 = 'Loading...' panel.name3 = 'Loading...' - var host = window.location.hostname - var whoToFollowProvider = panel.$store.state.config.whoToFollowProvider - var url - url = whoToFollowProvider.replace(/{{host}}/g, encodeURIComponent(host)) - url = url.replace(/{{user}}/g, encodeURIComponent(user)) - window.fetch(url, {mode: 'cors'}).then(function (response) { - if (response.ok) { - return response.json() - } else { - panel.name1 = '' - panel.name2 = '' - panel.name3 = '' - } - }).then(function (reply) { - showWhoToFollow(panel, reply, host, user) - }) + apiService.suggestions({credentials: credentials}) + .then((reply) => { + showWhoToFollow(panel, reply) + }) } } @@ -95,26 +83,26 @@ const WhoToFollowPanel = { moreUrl: function () { var host = window.location.hostname var user = this.user - var whoToFollowLink = this.$store.state.config.whoToFollowLink + var suggestionsWeb = this.$store.state.config.suggestionsWeb var url - url = whoToFollowLink.replace(/{{host}}/g, encodeURIComponent(host)) + url = suggestionsWeb.replace(/{{host}}/g, encodeURIComponent(host)) url = url.replace(/{{user}}/g, encodeURIComponent(user)) return url }, - showWhoToFollowPanel () { - return this.$store.state.config.showWhoToFollowPanel + suggestionsEnabled () { + return this.$store.state.config.suggestionsEnabled } }, watch: { user: function (user, oldUser) { - if (this.showWhoToFollowPanel) { + if (this.suggestionsEnabled) { getWhoToFollow(this) } } }, mounted: function () { - if (this.showWhoToFollowPanel) { + if (this.suggestionsEnabled) { getWhoToFollow(this) } } diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue index 5af6d0d5..8b3abe70 100644 --- a/src/components/who_to_follow_panel/who_to_follow_panel.vue +++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue @@ -3,7 +3,7 @@ <div class="panel panel-default base01-background"> <div class="panel-heading timeline-heading base02-background base04"> <div class="title"> - Who to follow + {{$t('who_to_follow.who_to_follow')}} </div> </div> <div class="panel-body who-to-follow"> @@ -11,7 +11,7 @@ <img v-bind:src="img1"/> <router-link :to="{ name: 'user-profile', params: { id: id1 } }">{{ name1 }}</router-link><br> <img v-bind:src="img2"/> <router-link :to="{ name: 'user-profile', params: { id: id2 } }">{{ name2 }}</router-link><br> <img v-bind:src="img3"/> <router-link :to="{ name: 'user-profile', params: { id: id3 } }">{{ name3 }}</router-link><br> - <img v-bind:src="$store.state.config.logo"> <a v-bind:href="moreUrl" target="_blank">More</a> + <img v-bind:src="$store.state.config.logo"> <a v-bind:href="moreUrl" target="_blank">{{$t('who_to_follow.more')}}</a> </p> </div> </div> diff --git a/src/i18n/messages.js b/src/i18n/messages.js index e0b961a2..d0fc46a2 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -51,6 +51,9 @@ const de = { settings: 'Einstellungen', theme: 'Farbschema', presets: 'Voreinstellungen', + export_theme: 'Farbschema speichern', + import_theme: 'Farbschema laden', + invalid_theme_imported: 'Die ausgewählte Datei ist kein unterstütztes Pleroma-Theme. Keine Änderungen wurden vorgenommen.', theme_help: 'Benutze HTML Farbcodes (#rrggbb) um dein Farbschema anzupassen', radii_help: 'Kantenrundung (in Pixel) der Oberfläche anpassen', background: 'Hintergrund', @@ -275,9 +278,11 @@ const en = { load_older: 'Load older statuses', conversation: 'Conversation', collapse: 'Collapse', - repeated: 'repeated' + repeated: 'repeated', + no_retweet_hint: 'Post is marked as followers-only or direct and cannot be repeated' }, settings: { + general: 'General', user_settings: 'User Settings', name_bio: 'Name & Bio', name: 'Name', @@ -293,7 +298,10 @@ const en = { settings: 'Settings', theme: 'Theme', presets: 'Presets', + export_theme: 'Save preset', + import_theme: 'Load preset', theme_help: 'Use hex color codes (#rrggbb) to customize your color theme.', + invalid_theme_imported: 'The selected file is not a supported Pleroma theme. No changes to your theme were made.', radii_help: 'Set up interface edge rounding (in pixels)', background: 'Background', foreground: 'Foreground', @@ -316,10 +324,23 @@ const en = { hide_attachments_in_tl: 'Hide attachments in timeline', hide_attachments_in_convo: 'Hide attachments in conversations', nsfw_clickthrough: 'Enable clickthrough NSFW attachment hiding', + collapse_subject: 'Collapse posts with subjects', stop_gifs: 'Play-on-hover GIFs', autoload: 'Enable automatic loading when scrolled to the bottom', streaming: 'Enable automatic streaming of new posts when scrolled to the top', + pause_on_unfocused: 'Pause streaming when tab is not focused', + loop_video: 'Loop videos', + loop_video_silent_only: 'Loop only videos without sound (i.e. Mastodon\'s "gifs")', reply_link_preview: 'Enable reply-link preview on mouse hover', + replies_in_timeline: 'Replies in timeline', + reply_visibility_all: 'Show all replies', + reply_visibility_following: 'Only show replies directed at me or users I\'m following', + reply_visibility_self: 'Only show replies directed at me', + notification_visibility: 'Types of notifications to show', + notification_visibility_likes: 'Likes', + notification_visibility_mentions: 'Mentions', + notification_visibility_repeats: 'Repeats', + notification_visibility_follows: 'Follows', follow_import: 'Follow import', import_followers_from_a_csv_file: 'Import follows from a csv file', follows_imported: 'Follows imported! Processing them will take a while.', @@ -337,14 +358,22 @@ const en = { confirm_new_password: 'Confirm new password', changed_password: 'Password changed successfully!', change_password_error: 'There was an issue changing your password.', - lock_account_description: 'Restrict your account to approved followers only' + lock_account_description: 'Restrict your account to approved followers only', + limited_availability: 'Unavailable in your browser', + default_vis: 'Default visibility scope', + profile_tab: 'Profile', + security_tab: 'Security', + data_import_export_tab: 'Data Import / Export', + interfaceLanguage: 'Interface language' }, notifications: { notifications: 'Notifications', read: 'Read!', followed_you: 'followed you', favorited_you: 'favorited your status', - repeated_you: 'repeated your status' + repeated_you: 'repeated your status', + broken_favorite: 'Unknown status, searching for it...', + load_older: 'Load older notifications' }, login: { login: 'Log in', @@ -369,11 +398,15 @@ const en = { account_not_locked_warning: 'Your account is not {0}. Anyone can follow you to view your follower-only posts.', account_not_locked_warning_link: 'locked', direct_warning: 'This post will only be visible to all the mentioned users.', + attachments_sensitive: 'Mark attachments as sensitive', scope: { public: 'Public - Post to public timelines', unlisted: 'Unlisted - Do not post to public timelines', private: 'Followers-only - Post to followers only', direct: 'Direct - Post to mentioned users only' + }, + content_type: { + plain_text: 'Plain text' } }, finder: { @@ -386,19 +419,32 @@ const en = { }, user_profile: { timeline_title: 'User Timeline' + }, + who_to_follow: { + who_to_follow: 'Who to follow', + more: 'More' + }, + features_panel: { + title: 'Features', + chat: 'Chat', + gopher: 'Gopher', + who_to_follow: 'Who to follow', + media_proxy: 'Media proxy', + scope_options: 'Scope options', + text_limit: 'Text limit' } } const eo = { chat: { - title: 'Babilo' + title: 'Babilejo' }, nav: { - chat: 'Loka babilo', - timeline: 'Tempovido', + chat: 'Loka babilejo', + timeline: 'Tempolinio', mentions: 'Mencioj', - public_tl: 'Publika tempovido', - twkn: 'Tuta konata reto' + public_tl: 'Publika tempolinio', + twkn: 'La tuta konata reto' }, user_card: { follows_you: 'Abonas vin!', @@ -408,26 +454,26 @@ const eo = { block: 'Bari', statuses: 'Statoj', mute: 'Silentigi', - muted: 'Silentigita', + muted: 'Silentigitaj', followers: 'Abonantoj', followees: 'Abonatoj', per_day: 'tage', - remote_follow: 'Fora abono' + remote_follow: 'Fore aboni' }, timeline: { show_new: 'Montri novajn', - error_fetching: 'Eraro ĝisdatigante', + error_fetching: 'Eraro dum ĝisdatigo', up_to_date: 'Ĝisdata', - load_older: 'Enlegi pli malnovajn statojn', + load_older: 'Montri pli malnovajn statojn', conversation: 'Interparolo', collapse: 'Maletendi', repeated: 'ripetata' }, settings: { - user_settings: 'Uzulaj agordoj', - name_bio: 'Nomo kaj prio', + user_settings: 'Uzantaj agordoj', + name_bio: 'Nomo kaj priskribo', name: 'Nomo', - bio: 'Prio', + bio: 'Priskribo', avatar: 'Profilbildo', current_avatar: 'Via nuna profilbildo', set_new_avatar: 'Agordi novan profilbildon', @@ -437,9 +483,9 @@ const eo = { profile_background: 'Profila fono', set_new_profile_background: 'Agordi novan profilan fonon', settings: 'Agordoj', - theme: 'Haŭto', - presets: 'Antaŭmetaĵoj', - theme_help: 'Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran haŭton.', + theme: 'Etoso', + presets: 'Antaŭagordoj', + theme_help: 'Uzu deksesumajn kolorkodojn (#rrvvbb) por adapti vian koloran etoson.', radii_help: 'Agordi fasadan rondigon de randoj (rastrumere)', background: 'Fono', foreground: 'Malfono', @@ -447,65 +493,65 @@ const eo = { links: 'Ligiloj', cBlue: 'Blua (Respondo, abono)', cRed: 'Ruĝa (Nuligo)', - cOrange: 'Orange (Ŝato)', + cOrange: 'Oranĝa (Ŝato)', cGreen: 'Verda (Kunhavigo)', btnRadius: 'Butonoj', panelRadius: 'Paneloj', avatarRadius: 'Profilbildoj', - avatarAltRadius: 'Profilbildoj (Sciigoj)', + avatarAltRadius: 'Profilbildoj (sciigoj)', tooltipRadius: 'Ŝpruchelpiloj/avertoj', attachmentRadius: 'Kunsendaĵoj', filtering: 'Filtrado', filtering_explanation: 'Ĉiuj statoj kun tiuj ĉi vortoj silentiĝos, po unu linie', attachments: 'Kunsendaĵoj', - hide_attachments_in_tl: 'Kaŝi kunsendaĵojn en tempovido', + hide_attachments_in_tl: 'Kaŝi kunsendaĵojn en tempolinio', hide_attachments_in_convo: 'Kaŝi kunsendaĵojn en interparoloj', nsfw_clickthrough: 'Ŝalti traklakan kaŝon de konsternaj kunsendaĵoj', stop_gifs: 'Movi GIF-bildojn dum ŝvebo', - autoload: 'Ŝalti memfaran enlegadon ĉe subo de paĝo', - streaming: 'Ŝalti memfaran fluigon de novaj afiŝoj ĉe supro de paĝo', + autoload: 'Ŝalti memfaran ŝarĝadon ĉe subo de paĝo', + streaming: 'Ŝalti memfaran fluigon de novaj afiŝoj ĉe la supro de la paĝo', reply_link_preview: 'Ŝalti respond-ligilan antaŭvidon dum ŝvebo', follow_import: 'Abona enporto', - import_followers_from_a_csv_file: 'Enporti abonojn de CSV-dosiero', + import_followers_from_a_csv_file: 'Enporti abonojn el CSV-dosiero', follows_imported: 'Abonoj enportiĝis! Traktado daŭros iom.', follow_import_error: 'Eraro enportante abonojn' }, notifications: { notifications: 'Sciigoj', - read: 'Legita!', + read: 'Legite!', followed_you: 'ekabonis vin', favorited_you: 'ŝatis vian staton', repeated_you: 'ripetis vian staton' }, login: { - login: 'Saluti', + login: 'Ensaluti', username: 'Salutnomo', placeholder: 'ekz. lain', password: 'Pasvorto', register: 'Registriĝi', - logout: 'Adiaŭi' + logout: 'Elsaluti' }, registration: { registration: 'Registriĝo', fullname: 'Vidiga nomo', email: 'Retpoŝtadreso', - bio: 'Prio', + bio: 'Priskribo', password_confirm: 'Konfirmo de pasvorto' }, post_status: { - posting: 'Afiŝanta', - default: 'Ĵus alvenis la universalan kongreson!' + posting: 'Afiŝante', + default: 'Ĵus alvenis al la Universala Kongreso!' }, finder: { - find_user: 'Trovi uzulon', - error_fetching_user: 'Eraro alportante uzulon' + find_user: 'Trovi uzanton', + error_fetching_user: 'Eraro alportante uzanton' }, general: { submit: 'Sendi', apply: 'Apliki' }, user_profile: { - timeline_title: 'Uzula tempovido' + timeline_title: 'Uzanta tempolinio' } } @@ -769,115 +815,156 @@ const ja = { chat: 'ローカルチャット', timeline: 'タイムライン', mentions: 'メンション', - public_tl: '公開タイムライン', - twkn: '接続しているすべてのネットワーク' + public_tl: 'パブリックタイムライン', + twkn: 'つながっているすべてのネットワーク', + friend_requests: 'Follow Requests' }, user_card: { follows_you: 'フォローされました!', - following: 'フォロー中!', + following: 'フォローしています!', follow: 'フォロー', - blocked: 'ブロック済み!', + blocked: 'ブロックしています!', block: 'ブロック', - statuses: '投稿', + statuses: 'ステータス', mute: 'ミュート', - muted: 'ミュート済み', + muted: 'ミュートしています!', followers: 'フォロワー', followees: 'フォロー', per_day: '/日', - remote_follow: 'リモートフォロー' + remote_follow: 'リモートフォロー', + approve: 'Approve', + deny: 'Deny' }, timeline: { - show_new: '更新', - error_fetching: '更新の取得中にエラーが発生しました。', - up_to_date: '最新', - load_older: '古い投稿を読み込む', - conversation: '会話', - collapse: '折り畳む', + show_new: 'よみこみ', + error_fetching: 'よみこみがエラーになりました。', + up_to_date: 'さいしん', + load_older: 'ふるいステータス', + conversation: 'スレッド', + collapse: 'たたむ', repeated: 'リピート' }, settings: { - user_settings: 'ユーザー設定', - name_bio: '名前とプロフィール', - name: '名前', + user_settings: 'ユーザーせってい', + name_bio: 'なまえとプロフィール', + name: 'なまえ', bio: 'プロフィール', avatar: 'アバター', - current_avatar: 'あなたの現在のアバター', - set_new_avatar: '新しいアバターを設定する', + current_avatar: 'いまのアバター', + set_new_avatar: 'あたらしいアバターをせっていする', profile_banner: 'プロフィールバナー', - current_profile_banner: '現在のプロフィールバナー', - set_new_profile_banner: '新しいプロフィールバナーを設定する', - profile_background: 'プロフィールの背景', - set_new_profile_background: '新しいプロフィールの背景を設定する', - settings: '設定', + current_profile_banner: 'いまのプロフィールバナー', + set_new_profile_banner: 'あたらしいプロフィールバナーを設定する', + profile_background: 'プロフィールのバックグラウンド', + set_new_profile_background: 'あたらしいプロフィールのバックグラウンドをせっていする', + settings: 'せってい', theme: 'テーマ', presets: 'プリセット', - theme_help: '16進数カラーコード (#aabbcc) を使用してカラーテーマをカスタマイズ出来ます。', - radii_help: 'インターフェースの縁の丸さを設定する。', - background: '背景', - foreground: '前景', - text: '文字', + theme_help: 'カラーテーマをカスタマイズできます。', + radii_help: 'インターフェースのまるさをせっていする。', + background: 'バックグラウンド', + foreground: 'フォアグラウンド', + text: 'もじ', links: 'リンク', - cBlue: '青 (返信, フォロー)', - cRed: '赤 (キャンセル)', - cOrange: 'オレンジ (お気に入り)', - cGreen: '緑 (リツイート)', + cBlue: 'あお (リプライ, フォロー)', + cRed: 'あか (キャンセル)', + cOrange: 'オレンジ (おきにいり)', + cGreen: 'みどり (リピート)', btnRadius: 'ボタン', + inputRadius: 'Input fields', panelRadius: 'パネル', avatarRadius: 'アバター', - avatarAltRadius: 'アバター (通知)', + avatarAltRadius: 'アバター (つうち)', tooltipRadius: 'ツールチップ/アラート', attachmentRadius: 'ファイル', filtering: 'フィルタリング', - filtering_explanation: 'これらの単語を含むすべてのものがミュートされます。1行に1つの単語を入力してください。', + filtering_explanation: 'これらのことばをふくむすべてのものがミュートされます。1行に1つのことばをかいてください。', attachments: 'ファイル', - hide_attachments_in_tl: 'タイムラインのファイルを隠す。', - hide_attachments_in_convo: '会話の中のファイルを隠す。', - nsfw_clickthrough: 'NSFWファイルの非表示を有効にする。', - stop_gifs: 'カーソルを重ねた時にGIFを再生する。', - autoload: '下にスクロールした時に自動で読み込むようにする。', - streaming: '上までスクロールした時に自動でストリーミングされるようにする。', - reply_link_preview: 'マウスカーソルを重ねた時に返信のプレビューを表示するようにする。', + hide_attachments_in_tl: 'タイムラインのファイルをかくす。', + hide_attachments_in_convo: 'スレッドのファイルをかくす。', + nsfw_clickthrough: 'NSFWなファイルをかくす。', + stop_gifs: 'カーソルをかさねたとき、GIFをうごかす。', + autoload: 'したにスクロールしたとき、じどうてきによみこむ。', + streaming: 'うえまでスクロールしたとき、じどうてきにストリーミングする。', + reply_link_preview: 'カーソルをかさねたとき、リプライのプレビューをみる。', follow_import: 'フォローインポート', import_followers_from_a_csv_file: 'CSVファイルからフォローをインポートする。', - follows_imported: 'フォローがインポートされました!処理に少し時間がかかるかもしれません。', - follow_import_error: 'フォロワーのインポート中にエラーが発生しました。' + follows_imported: 'フォローがインポートされました! すこしじかんがかかるかもしれません。', + follow_import_error: 'フォローのインポートがエラーになりました。', + delete_account: 'アカウントをけす', + delete_account_description: 'あなたのアカウントとメッセージが、きえます。', + delete_account_instructions: 'ほんとうにアカウントをけしてもいいなら、パスワードをかいてください。', + delete_account_error: 'アカウントをけすことが、できなかったかもしれません。インスタンスのかんりしゃに、れんらくしてください。', + follow_export: 'フォローのエクスポート', + follow_export_processing: 'おまちください。まもなくファイルをダウンロードできます。', + follow_export_button: 'エクスポート', + change_password: 'パスワードをかえる', + current_password: 'いまのパスワード', + new_password: 'あたらしいパスワード', + confirm_new_password: 'あたらしいパスワードのかくにん', + changed_password: 'パスワードが、かわりました!', + change_password_error: 'パスワードをかえることが、できなかったかもしれません。', + lock_account_description: 'あなたがみとめたひとだけ、あなたのアカウントをフォローできます。' }, notifications: { - notifications: '通知', - read: '読んだ!', + notifications: 'つうち', + read: 'よんだ!', followed_you: 'フォローされました', - favorited_you: 'あなたの投稿がお気に入りされました', - repeated_you: 'あなたの投稿がリピートされました' + favorited_you: 'あなたのステータスがおきにいりされました', + repeated_you: 'あなたのステータスがリピートされました' }, login: { login: 'ログイン', - username: 'ユーザー名', - placeholder: '例えば lain', + username: 'ユーザーめい', + placeholder: 'れい: lain', password: 'パスワード', - register: '登録', + register: 'はじめる', logout: 'ログアウト' }, registration: { - registration: '登録', - fullname: '表示名', + registration: 'はじめる', + fullname: 'スクリーンネーム', email: 'Eメール', bio: 'プロフィール', - password_confirm: 'パスワードの確認' + password_confirm: 'パスワードのかくにん' }, post_status: { - posting: '投稿', - default: 'ちょうどL.A.に着陸しました。' + posting: 'とうこう', + content_warning: 'せつめい (かかなくてもよい)', + default: 'はねだくうこうに、つきました。', + account_not_locked_warning: 'あなたのアカウントは {0} ではありません。あなたをフォローすれば、だれでも、フォロワーげんていのステータスをよむことができます。', + account_not_locked_warning_link: 'ロックされたアカウント', + direct_warning: 'このステータスは、メンションされたユーザーだけが、よむことができます。', + scope: { + public: 'パブリック - パブリックタイムラインにとどきます。', + unlisted: 'アンリステッド - パブリックタイムラインにとどきません。', + private: 'フォロワーげんてい - フォロワーのみにとどきます。', + direct: 'ダイレクト - メンションされたユーザーのみにとどきます。' + } }, finder: { - find_user: 'ユーザー検索', - error_fetching_user: 'ユーザー検索でエラーが発生しました' + find_user: 'ユーザーをさがす', + error_fetching_user: 'ユーザーけんさくがエラーになりました。' }, general: { - submit: '送信', - apply: '適用' + submit: 'そうしん', + apply: 'てきよう' }, user_profile: { timeline_title: 'ユーザータイムライン' + }, + who_to_follow: { + who_to_follow: 'おすすめユーザー', + more: 'くわしく' + }, + features_panel: { + title: 'ゆうこうなきのう', + chat: 'チャット', + gopher: 'Gopher', + who_to_follow: 'おすすめユーザー', + media_proxy: 'メディアプロクシ', + scope_options: 'こうかいはんい', + text_limit: 'もじのかず' } } @@ -1089,8 +1176,8 @@ const oc = { twkn: 'Lo malhum conegut' }, user_card: { - follows_you: 'Vos sèc !', - following: 'Seguit !', + follows_you: 'Vos sèc!', + following: 'Seguit!', follow: 'Seguir', blocked: 'Blocat', block: 'Blocar', @@ -1135,10 +1222,10 @@ const oc = { links: 'Ligams', cBlue: 'Blau (Respondre, seguir)', cRed: 'Roge (Anullar)', - cOrange: 'Irange (Metre en favorit)', + cOrange: 'Irange (Aimar)', cGreen: 'Verd (Repartajar)', - inputRadius: 'Camps tèxte', btnRadius: 'Botons', + inputRadius: 'Camps tèxte', panelRadius: 'Panèls', avatarRadius: 'Avatars', avatarAltRadius: 'Avatars (Notificacions)', @@ -1157,12 +1244,25 @@ const oc = { follow_import: 'Importar los abonaments', import_followers_from_a_csv_file: 'Importar los seguidors d’un fichièr csv', follows_imported: 'Seguidors importats. Lo tractament pòt trigar una estona.', - follow_import_error: 'Error en important los seguidors' + follow_import_error: 'Error en important los seguidors', + delete_account: 'Suprimir lo compte', + delete_account_description: 'Suprimir vòstre compte e los messatges per sempre.', + delete_account_instructions: 'Picatz vòstre senhal dins lo camp tèxte çai-jos per confirmar la supression del compte.', + delete_account_error: 'Una error s’es producha en suprimir lo compte. S’aquò ten d’arribar mercés de contactar vòstre administrador d’instància.', + follow_export: 'Exportar los abonaments', + follow_export_processing: 'Tractament, vos demandarem lèu de telecargar lo fichièr', + follow_export_button: 'Exportar vòstres abonaments dins un fichièr csv', + change_password: 'Cambiar lo senhal', + current_password: 'Senhal actual', + new_password: 'Nòu senhal', + confirm_new_password: 'Confirmatz lo nòu senhal', + changed_password: 'Senhal corrèctament cambiat', + change_password_error: 'Una error s’es producha en cambiant lo senhal.' }, notifications: { notifications: 'Notficacions', - read: 'Legit !', - followed_you: 'vos sèc', + read: 'Legit!', + followed_you: 'vos a seguit', favorited_you: 'a aimat vòstre estatut', repeated_you: 'a repetit your vòstre estatut' }, @@ -1183,6 +1283,7 @@ const oc = { }, post_status: { posting: 'Mandadís', + content_warning: 'Avís de contengut (opcional)', default: 'Escrivètz aquí vòstre estatut.' }, finder: { @@ -1438,7 +1539,7 @@ const pt = { title: 'Chat' }, nav: { - chat: 'Chat Local', + chat: 'Chat local', timeline: 'Linha do tempo', mentions: 'Menções', public_tl: 'Linha do tempo pública', @@ -1482,16 +1583,28 @@ const pt = { theme: 'Tema', presets: 'Predefinições', theme_help: 'Use cores em código hexadecimal (#rrggbb) para personalizar seu esquema de cores.', + radii_help: 'Arredondar arestas da interface (em píxeis)', background: 'Plano de Fundo', foreground: 'Primeiro Plano', text: 'Texto', links: 'Links', + cBlue: 'Azul (Responder, seguir)', + cRed: 'Vermelho (Cancelar)', + cOrange: 'Laranja (Favoritar)', + cGreen: 'Verde (Repetir)', + btnRadius: 'Botões', + panelRadius: 'Paineis', + avatarRadius: 'Avatares', + avatarAltRadius: 'Avatares (Notificações)', + tooltipRadius: 'Dicass/alertas', + attachmentRadius: 'Anexos', filtering: 'Filtragem', filtering_explanation: 'Todas as postagens contendo estas palavras serão silenciadas, uma por linha.', attachments: 'Anexos', hide_attachments_in_tl: 'Ocultar anexos na linha do tempo.', hide_attachments_in_convo: 'Ocultar anexos em conversas', nsfw_clickthrough: 'Habilitar clique para ocultar anexos NSFW', + stop_gifs: 'Reproduzir GIFs ao passar o cursor em cima', autoload: 'Habilitar carregamento automático quando a rolagem chegar ao fim.', streaming: 'Habilitar o fluxo automático de postagens quando ao topo da página', reply_link_preview: 'Habilitar a pré-visualização de link de respostas ao passar o mouse.', @@ -1502,8 +1615,10 @@ const pt = { }, notifications: { notifications: 'Notificações', - read: 'Ler!', - followed_you: 'seguiu você' + read: 'Lido!', + followed_you: 'seguiu você', + favorited_you: 'favoritou sua postagem', + repeated_you: 'repetiu sua postagem' }, login: { login: 'Entrar', @@ -1522,7 +1637,7 @@ const pt = { }, post_status: { posting: 'Publicando', - default: 'Acabo de aterrizar em L.A.' + default: 'Acabei de chegar no Rio!' }, finder: { find_user: 'Buscar usuário', @@ -1531,6 +1646,9 @@ const pt = { general: { submit: 'Enviar', apply: 'Aplicar' + }, + user_profile: { + timeline_title: 'Linha do tempo do usuário' } } @@ -1566,9 +1684,11 @@ const ru = { load_older: 'Загрузить старые статусы', conversation: 'Разговор', collapse: 'Свернуть', - repeated: 'повторил(а)' + repeated: 'повторил(а)', + no_retweet_hint: 'Пост помечен как "только для подписчиков" или "личное" и поэтому не может быть повторён' }, settings: { + general: 'Общие', user_settings: 'Настройки пользователя', name_bio: 'Имя и описание', name: 'Имя', @@ -1583,9 +1703,11 @@ const ru = { set_new_profile_background: 'Загрузить новый фон профиля', settings: 'Настройки', theme: 'Тема', + export_theme: 'Сохранить Тему', + import_theme: 'Загрузить Тему', presets: 'Пресеты', theme_help: 'Используйте шестнадцатеричные коды цветов (#rrggbb) для настройки темы.', - radii_help: 'Округление краёв элементов интерфейса (в пикселях)', + radii_help: 'Скругление углов элементов интерфейса (в пикселях)', background: 'Фон', foreground: 'Передний план', text: 'Текст', @@ -1610,7 +1732,19 @@ const ru = { nsfw_clickthrough: 'Включить скрытие NSFW вложений', autoload: 'Включить автоматическую загрузку при прокрутке вниз', streaming: 'Включить автоматическую загрузку новых сообщений при прокрутке вверх', + pause_on_unfocused: 'Приостановить загрузку когда вкладка не в фокусе', + loop_video: 'Зациливать видео', + loop_video_silent_only: 'Зацикливать только беззвучные видео (т.е. "гифки" с Mastodon)', reply_link_preview: 'Включить предварительный просмотр ответа при наведении мыши', + replies_in_timeline: 'Ответы в ленте', + reply_visibility_all: 'Показывать все ответы', + reply_visibility_following: 'Показывать только ответы мне и тех на кого я подписан', + reply_visibility_self: 'Показывать только ответы мне', + notification_visibility: 'Показывать уведомления', + notification_visibility_likes: 'Лайки', + notification_visibility_mentions: 'Упоминания', + notification_visibility_repeats: 'Повторы', + notification_visibility_follows: 'Подписки', follow_import: 'Импортировать читаемых', import_followers_from_a_csv_file: 'Импортировать читаемых из файла .csv', follows_imported: 'Список читаемых импортирован. Обработка займёт некоторое время..', @@ -1627,14 +1761,23 @@ const ru = { new_password: 'Новый пароль', confirm_new_password: 'Подтверждение нового пароля', changed_password: 'Пароль изменён успешно.', - change_password_error: 'Произошла ошибка при попытке изменить пароль.' + change_password_error: 'Произошла ошибка при попытке изменить пароль.', + lock_account_description: 'Аккаунт доступен только подтверждённым подписчикам', + limited_availability: 'Не доступно в вашем браузере', + profile_tab: 'Профиль', + security_tab: 'Безопасность', + data_import_export_tab: 'Импорт / Экспорт данных', + collapse_subject: 'Сворачивать посты с темой', + interfaceLanguage: 'Язык интерфейса' }, notifications: { notifications: 'Уведомления', read: 'Прочесть', followed_you: 'начал(а) читать вас', favorited_you: 'нравится ваш статус', - repeated_you: 'повторил(а) ваш статус' + repeated_you: 'повторил(а) ваш статус', + broken_favorite: 'Неизвестный статус, ищем...', + load_older: 'Загрузить старые уведомления' }, login: { login: 'Войти', @@ -1654,7 +1797,18 @@ const ru = { }, post_status: { posting: 'Отправляется', - default: 'Что нового?' + content_warning: 'Тема (не обязательно)', + default: 'Что нового?', + account_not_locked_warning: 'Ваш аккаунт не {0}. Кто угодно может зафоловить вас чтобы прочитать посты только для подписчиков', + account_not_locked_warning_link: 'залочен', + direct_warning: 'Этот пост будет видет только упомянутым пользователям', + attachments_sensitive: 'Вложения содержат чувствительный контент', + scope: { + public: 'Публичный - этот пост виден всем', + unlisted: 'Непубличный - этот пост не виден на публичных лентах', + private: 'Для подписчиков - этот пост видят только подписчики', + direct: 'Личное - этот пост видят только те кто в нём упомянут' + } }, finder: { find_user: 'Найти пользователя', diff --git a/src/main.js b/src/main.js index cb53edd3..75c2bab2 100644 --- a/src/main.js +++ b/src/main.js @@ -45,16 +45,25 @@ Vue.use(VueChatScroll) const persistedStateOptions = { paths: [ + 'config.collapseMessageWithSubject', 'config.hideAttachments', 'config.hideAttachmentsInConv', 'config.hideNsfw', + 'config.replyVisibility', + 'config.notificationVisibility', 'config.autoLoad', 'config.hoverPreview', 'config.streaming', 'config.muteWords', 'config.customTheme', 'config.highlight', - 'users.lastLoginName' + 'config.loopVideo', + 'config.loopVideoSilentOnly', + 'config.pauseOnUnfocused', + 'config.stopGifs', + 'config.interfaceLanguage', + 'users.lastLoginName', + 'statuses.notifications.maxSavedId' ] } @@ -72,6 +81,7 @@ const store = new Vuex.Store({ }) const i18n = new VueI18n({ + // By default, use the browser locale, we will update it if neccessary locale: currentLocale, fallbackLocale: 'en', messages @@ -86,64 +96,81 @@ window.fetch('/api/statusnet/config.json') store.dispatch('setOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) store.dispatch('setOption', { name: 'textlimit', value: parseInt(textlimit) }) store.dispatch('setOption', { name: 'server', value: server }) - }) -window.fetch('/static/config.json') - .then((res) => res.json()) - .then((data) => { - const {theme, background, logo, showWhoToFollowPanel, whoToFollowProvider, whoToFollowLink, showInstanceSpecificPanel, scopeOptionsEnabled} = data - store.dispatch('setOption', { name: 'theme', value: theme }) - store.dispatch('setOption', { name: 'background', value: background }) - store.dispatch('setOption', { name: 'logo', value: logo }) - store.dispatch('setOption', { name: 'showWhoToFollowPanel', value: showWhoToFollowPanel }) - store.dispatch('setOption', { name: 'whoToFollowProvider', value: whoToFollowProvider }) - store.dispatch('setOption', { name: 'whoToFollowLink', value: whoToFollowLink }) - store.dispatch('setOption', { name: 'showInstanceSpecificPanel', value: showInstanceSpecificPanel }) - store.dispatch('setOption', { name: 'scopeOptionsEnabled', value: scopeOptionsEnabled }) - if (data['chatDisabled']) { - store.dispatch('disableChat') - } - - const routes = [ - { name: 'root', - path: '/', - redirect: to => { - var redirectRootLogin = data['redirectRootLogin'] - var redirectRootNoLogin = data['redirectRootNoLogin'] - return (store.state.users.currentUser ? redirectRootLogin : redirectRootNoLogin) || '/main/all' - }}, - { path: '/main/all', component: PublicAndExternalTimeline }, - { path: '/main/public', component: PublicTimeline }, - { path: '/main/friends', component: FriendsTimeline }, - { path: '/tag/:tag', component: TagTimeline }, - { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, - { name: 'user-profile', path: '/users/:id', component: UserProfile }, - { name: 'mentions', path: '/:username/mentions', component: Mentions }, - { name: 'settings', path: '/settings', component: Settings }, - { name: 'registration', path: '/registration', component: Registration }, - { name: 'registration', path: '/registration/:token', component: Registration }, - { name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, - { name: 'user-settings', path: '/user-settings', component: UserSettings } - ] - - const router = new VueRouter({ - mode: 'history', - routes, - scrollBehavior: (to, from, savedPosition) => { - if (to.matched.some(m => m.meta.dontScroll)) { - return false - } - return savedPosition || { x: 0, y: 0 } + var apiConfig = data.site.pleromafe + + window.fetch('/static/config.json') + .then((res) => res.json()) + .then((data) => { + var staticConfig = data + // This takes static config and overrides properties that are present in apiConfig + var config = Object.assign({}, staticConfig, apiConfig) + + var theme = (config.theme) + var background = (config.background) + var logo = (config.logo) + var logoMask = (typeof config.logoMask === 'undefined' ? true : config.logoMask) + var logoMargin = (typeof config.logoMargin === 'undefined' ? 0 : config.logoMargin) + var redirectRootNoLogin = (config.redirectRootNoLogin) + var redirectRootLogin = (config.redirectRootLogin) + var chatDisabled = (config.chatDisabled) + var showInstanceSpecificPanel = (config.showInstanceSpecificPanel) + var scopeOptionsEnabled = (config.scopeOptionsEnabled) + var formattingOptionsEnabled = (config.formattingOptionsEnabled) + var defaultCollapseMessageWithSubject = (config.collapseMessageWithSubject) + + store.dispatch('setOption', { name: 'theme', value: theme }) + store.dispatch('setOption', { name: 'background', value: background }) + store.dispatch('setOption', { name: 'logo', value: logo }) + store.dispatch('setOption', { name: 'logoMask', value: logoMask }) + store.dispatch('setOption', { name: 'logoMargin', value: logoMargin }) + store.dispatch('setOption', { name: 'showInstanceSpecificPanel', value: showInstanceSpecificPanel }) + store.dispatch('setOption', { name: 'scopeOptionsEnabled', value: scopeOptionsEnabled }) + store.dispatch('setOption', { name: 'formattingOptionsEnabled', value: formattingOptionsEnabled }) + store.dispatch('setOption', { name: 'defaultCollapseMessageWithSubject', value: defaultCollapseMessageWithSubject }) + if (chatDisabled) { + store.dispatch('disableChat') } - }) - /* eslint-disable no-new */ - new Vue({ - router, - store, - i18n, - el: '#app', - render: h => h(App) + const routes = [ + { name: 'root', + path: '/', + redirect: to => { + return (store.state.users.currentUser ? redirectRootLogin : redirectRootNoLogin) || '/main/all' + }}, + { path: '/main/all', component: PublicAndExternalTimeline }, + { path: '/main/public', component: PublicTimeline }, + { path: '/main/friends', component: FriendsTimeline }, + { path: '/tag/:tag', component: TagTimeline }, + { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, + { name: 'user-profile', path: '/users/:id', component: UserProfile }, + { name: 'mentions', path: '/:username/mentions', component: Mentions }, + { name: 'settings', path: '/settings', component: Settings }, + { name: 'registration', path: '/registration', component: Registration }, + { name: 'registration', path: '/registration/:token', component: Registration }, + { name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, + { name: 'user-settings', path: '/user-settings', component: UserSettings } + ] + + const router = new VueRouter({ + mode: 'history', + routes, + scrollBehavior: (to, from, savedPosition) => { + if (to.matched.some(m => m.meta.dontScroll)) { + return false + } + return savedPosition || { x: 0, y: 0 } + } + }) + + /* eslint-disable no-new */ + new Vue({ + router, + store, + i18n, + el: '#app', + render: h => h(App) + }) }) }) @@ -186,3 +213,15 @@ window.fetch('/instance/panel.html') store.dispatch('setOption', { name: 'instanceSpecificPanelContent', value: html }) }) +window.fetch('/nodeinfo/2.0.json') + .then((res) => res.json()) + .then((data) => { + const metadata = data.metadata + store.dispatch('setOption', { name: 'mediaProxyAvailable', value: data.metadata.mediaProxy }) + store.dispatch('setOption', { name: 'chatAvailable', value: data.metadata.chat }) + store.dispatch('setOption', { name: 'gopherAvailable', value: data.metadata.gopher }) + + const suggestions = metadata.suggestions + store.dispatch('setOption', { name: 'suggestionsEnabled', value: suggestions.enabled }) + store.dispatch('setOption', { name: 'suggestionsWeb', value: suggestions.web }) + }) diff --git a/src/modules/api.js b/src/modules/api.js index a61340c2..2f07a91e 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -46,6 +46,9 @@ const api = { store.commit('addFetcher', {timeline, fetcher}) } }, + fetchOldPost (store, { postId }) { + store.state.backendInteractor.fetchOldPost({ store, postId }) + }, stopFetching (store, timeline) { const fetcher = store.state.fetchers[timeline] window.clearInterval(fetcher) diff --git a/src/modules/config.js b/src/modules/config.js index 2b50655b..60a34bc1 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,17 +1,32 @@ import { set, delete as del } from 'vue' import StyleSetter from '../services/style_setter/style_setter.js' +const browserLocale = (window.navigator.language || 'en').split('-')[0] + const defaultState = { name: 'Pleroma FE', colors: {}, + collapseMessageWithSubject: false, hideAttachments: false, hideAttachmentsInConv: false, hideNsfw: true, + loopVideo: true, + loopVideoSilentOnly: true, autoLoad: true, streaming: false, hoverPreview: true, + pauseOnUnfocused: true, + stopGifs: false, + replyVisibility: 'all', + notificationVisibility: { + follows: true, + mentions: true, + likes: true, + repeats: true + }, muteWords: [], - highlight: {} + highlight: {}, + interfaceLanguage: browserLocale } const config = { diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 291ab53c..f980f53d 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -1,4 +1,5 @@ import { includes, remove, slice, sortBy, toInteger, each, find, flatten, maxBy, minBy, merge, last, isArray } from 'lodash' +import { set } from 'vue' import apiService from '../services/api/api.service.js' // import parse from '../services/status_parser/status_parser.js' @@ -22,13 +23,22 @@ export const defaultState = { allStatuses: [], allStatusesObject: {}, maxId: 0, - notifications: [], + notifications: { + desktopNotificationSilence: true, + maxId: 0, + maxSavedId: 0, + minId: Number.POSITIVE_INFINITY, + data: [], + error: false, + brokenFavorites: {} + }, favorites: new Set(), error: false, timelines: { mentions: emptyTl(), public: emptyTl(), user: emptyTl(), + own: emptyTl(), publicAndExternal: emptyTl(), friends: emptyTl(), tag: emptyTl() @@ -58,6 +68,15 @@ export const prepareStatus = (status) => { return status } +const visibleNotificationTypes = (rootState) => { + return [ + rootState.config.notificationVisibility.likes && 'like', + rootState.config.notificationVisibility.mentions && 'mention', + rootState.config.notificationVisibility.repeats && 'repeat', + rootState.config.notificationVisibility.follows && 'follow' + ].filter(_ => _) +} + export const statusType = (status) => { if (status.is_post_verb) { return 'status' @@ -76,8 +95,7 @@ export const statusType = (status) => { return 'deletion' } - // TODO change to status.activity_type === 'follow' when gs supports it - if (status.text.match(/started following/)) { + if (status.text.match(/started following/) || status.activity_type === 'follow') { return 'follow' } @@ -134,11 +152,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us const result = mergeOrAdd(allStatuses, allStatusesObject, status) status = result.item - if (result.new) { - if (statusType(status) === 'retweet' && status.retweeted_status.user.id === user.id) { - addNotification({ type: 'repeat', status: status, action: status }) - } + const brokenFavorites = state.notifications.brokenFavorites[status.id] || [] + brokenFavorites.forEach((fav) => { + fav.status = status + }) + delete state.notifications.brokenFavorites[status.id] + if (result.new) { // We are mentioned in a post if (statusType(status) === 'status' && find(status.attentions, { id: user.id })) { const mentions = state.timelines.mentions @@ -150,10 +170,6 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us sortTimeline(mentions) } - // Don't add notification for self-mention - if (status.user.id !== user.id) { - addNotification({ type: 'mention', status, action: status }) - } } } @@ -176,45 +192,14 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us return status } - const addNotification = ({type, status, action}) => { - // Only add a new notification if we don't have one for the same action - if (!find(state.notifications, (oldNotification) => oldNotification.action.id === action.id)) { - state.notifications.push({ type, status, action, seen: false }) - - if ('Notification' in window && window.Notification.permission === 'granted') { - const title = action.user.name - const result = {} - result.icon = action.user.profile_image_url - result.body = action.text // there's a problem that it doesn't put a space before links tho - - // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... - if (action.attachments && action.attachments.length > 0 && !action.nsfw && - action.attachments[0].mimetype.startsWith('image/')) { - result.image = action.attachments[0].url - } - - let notification = new window.Notification(title, result) - - // Chrome is known for not closing notifications automatically - // according to MDN, anyway. - setTimeout(notification.close.bind(notification), 5000) - } - } - } - - const favoriteStatus = (favorite) => { + const favoriteStatus = (favorite, counter) => { const status = find(allStatuses, { id: toInteger(favorite.in_reply_to_status_id) }) if (status) { - status.fave_num += 1 - // This is our favorite, so the relevant bit. if (favorite.user.id === user.id) { status.favorited = true - } - - // Add a notification if the user's status is favorited - if (status.user.id === user.id) { - addNotification({type: 'favorite', status, action: favorite}) + } else { + status.fave_num += 1 } } return status @@ -248,18 +233,12 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us }, 'favorite': (favorite) => { // Only update if this is a new favorite. + // Ignore our own favorites because we get info about likes as response to like request if (!state.favorites.has(favorite.id)) { state.favorites.add(favorite.id) favoriteStatus(favorite) } }, - 'follow': (status) => { - let re = new RegExp(`started following ${user.name} \\(${user.statusnet_profile_url}\\)`) - let repleroma = new RegExp(`started following ${user.screen_name}$`) - if (status.text.match(re) || status.text.match(repleroma)) { - addNotification({ type: 'follow', status: status, action: status }) - } - }, 'deletion': (deletion) => { const uri = deletion.uri @@ -269,7 +248,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us return } - remove(state.notifications, ({action: {id}}) => id === status.id) + remove(state.notifications.data, ({action: {id}}) => id === status.id) remove(allStatuses, { uri }) if (timeline) { @@ -298,8 +277,69 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us } } +const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes }) => { + const allStatuses = state.allStatuses + const allStatusesObject = state.allStatusesObject + each(notifications, (notification) => { + const result = mergeOrAdd(allStatuses, allStatusesObject, notification.notice) + const action = result.item + // Only add a new notification if we don't have one for the same action + if (!find(state.notifications.data, (oldNotification) => oldNotification.action.id === action.id)) { + state.notifications.maxId = Math.max(notification.id, state.notifications.maxId) + state.notifications.minId = Math.min(notification.id, state.notifications.minId) + + const fresh = !older && !notification.is_seen && notification.id > state.notifications.maxSavedId + const status = notification.ntype === 'like' + ? find(allStatuses, { id: action.in_reply_to_status_id }) + : action + + const result = { + type: notification.ntype, + status, + action, + // Always assume older notifications as seen + seen: !fresh + } + + if (notification.ntype === 'like' && !status) { + let broken = state.notifications.brokenFavorites[action.in_reply_to_status_id] + if (broken) { + broken.push(result) + } else { + dispatch('fetchOldPost', { postId: action.in_reply_to_status_id }) + broken = [ result ] + state.notifications.brokenFavorites[action.in_reply_to_status_id] = broken + } + } + + state.notifications.data.push(result) + + if ('Notification' in window && window.Notification.permission === 'granted') { + const title = action.user.name + const result = {} + result.icon = action.user.profile_image_url + result.body = action.text // there's a problem that it doesn't put a space before links tho + + // Shows first attached non-nsfw image, if any. Should add configuration for this somehow... + if (action.attachments && action.attachments.length > 0 && !action.nsfw && + action.attachments[0].mimetype.startsWith('image/')) { + result.image = action.attachments[0].url + } + + if (fresh && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.ntype)) { + let notification = new window.Notification(title, result) + // Chrome is known for not closing notifications automatically + // according to MDN, anyway. + setTimeout(notification.close.bind(notification), 5000) + } + } + } + }) +} + export const mutations = { addNewStatuses, + addNewNotifications, showNewStatuses (state, { timeline }) { const oldTimeline = (state.timelines[timeline]) @@ -316,6 +356,11 @@ export const mutations = { const newStatus = state.allStatusesObject[status.id] newStatus.favorited = value }, + setFavoritedConfirm (state, { status }) { + const newStatus = state.allStatusesObject[status.id] + newStatus.favorited = status.favorited + newStatus.fave_num = status.fave_num + }, setRetweeted (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] newStatus.repeated = value @@ -334,6 +379,12 @@ export const mutations = { setError (state, { value }) { state.error = value }, + setNotificationsError (state, { value }) { + state.notifications.error = value + }, + setNotificationsSilence (state, { value }) { + state.notifications.desktopNotificationSilence = value + }, setProfileView (state, { v }) { // load followers / friends only when needed state.timelines['user'].viewing = v @@ -345,6 +396,7 @@ export const mutations = { state.timelines['user'].followers = followers }, markNotificationsAsSeen (state, notifications) { + set(state.notifications, 'maxSavedId', state.notifications.maxId) each(notifications, (notification) => { notification.seen = true }) @@ -360,9 +412,18 @@ const statuses = { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false }) { commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser }) }, + addNewNotifications ({ rootState, commit, dispatch }, { notifications, older }) { + commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older }) + }, setError ({ rootState, commit }, { value }) { commit('setError', { value }) }, + setNotificationsError ({ rootState, commit }, { value }) { + commit('setNotificationsError', { value }) + }, + setNotificationsSilence ({ rootState, commit }, { value }) { + commit('setNotificationsSilence', { value }) + }, addFriends ({ rootState, commit }, { friends }) { commit('addFriends', { friends }) }, @@ -377,11 +438,31 @@ const statuses = { // Optimistic favoriting... commit('setFavorited', { status, value: true }) apiService.favorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) + .then(response => { + if (response.ok) { + return response.json() + } else { + return {} + } + }) + .then(status => { + commit('setFavoritedConfirm', { status }) + }) }, unfavorite ({ rootState, commit }, status) { // Optimistic favoriting... commit('setFavorited', { status, value: false }) apiService.unfavorite({ id: status.id, credentials: rootState.users.currentUser.credentials }) + .then(response => { + if (response.ok) { + return response.json() + } else { + return {} + } + }) + .then(status => { + commit('setFavoritedConfirm', { status }) + }) }, retweet ({ rootState, commit }, status) { // Optimistic retweeting... diff --git a/src/modules/users.js b/src/modules/users.js index ba548765..e90d6bb9 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -107,6 +107,8 @@ const users = { // Start getting fresh tweets. store.dispatch('startFetching', 'friends') + // Start getting our own posts, only really needed for mitigating broken favorites + store.dispatch('startFetching', ['own', user.id]) // Get user mutes and follower info store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { @@ -119,7 +121,7 @@ const users = { } // Fetch our friends - store.rootState.api.backendInteractor.fetchFriends() + store.rootState.api.backendInteractor.fetchFriends({id: user.id}) .then((friends) => commit('addNewUsers', friends)) }) } else { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 13cc4796..87315657 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -27,6 +27,7 @@ const BANNER_UPDATE_URL = '/api/account/update_profile_banner.json' const PROFILE_UPDATE_URL = '/api/account/update_profile.json' const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json' +const QVITTER_USER_NOTIFICATIONS_URL = '/api/qvitter/statuses/notifications.json' const BLOCKING_URL = '/api/blocks/create.json' const UNBLOCKING_URL = '/api/blocks/destroy.json' const USER_URL = '/api/users/show.json' @@ -36,6 +37,7 @@ const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const FOLLOW_REQUESTS_URL = '/api/pleroma/friend_requests' const APPROVE_USER_URL = '/api/pleroma/friendships/approve' const DENY_USER_URL = '/api/pleroma/friendships/deny' +const SUGGESTIONS_URL = '/api/v1/suggestions' import { each, map } from 'lodash' import 'whatwg-fetch' @@ -302,8 +304,12 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use public: PUBLIC_TIMELINE_URL, friends: FRIENDS_TIMELINE_URL, mentions: MENTIONS_URL, + notifications: QVITTER_USER_NOTIFICATIONS_URL, 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, user: QVITTER_USER_TIMELINE_URL, + // separate timeline for own posts, so it won't break due to user timeline bugs + // really needed only for broken favorites + own: QVITTER_USER_TIMELINE_URL, tag: TAG_TIMELINE_URL } @@ -367,7 +373,7 @@ const unretweet = ({ id, credentials }) => { }) } -const postStatus = ({credentials, status, spoilerText, visibility, mediaIds, inReplyToStatusId}) => { +const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType}) => { const idsText = mediaIds.join(',') const form = new FormData() @@ -375,6 +381,8 @@ const postStatus = ({credentials, status, spoilerText, visibility, mediaIds, inR form.append('source', 'Pleroma FE') if (spoilerText) form.append('spoiler_text', spoilerText) if (visibility) form.append('visibility', visibility) + if (sensitive) form.append('sensitive', sensitive) + if (contentType) form.append('content_type', contentType) form.append('media_ids', idsText) if (inReplyToStatusId) { form.append('in_reply_to_status_id', inReplyToStatusId) @@ -449,6 +457,12 @@ const fetchMutes = ({credentials}) => { }).then((data) => data.json()) } +const suggestions = ({credentials}) => { + return fetch(SUGGESTIONS_URL, { + headers: authHeaders(credentials) + }).then((data) => data.json()) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -482,7 +496,8 @@ const apiService = { changePassword, fetchFollowRequests, approveUser, - denyUser + denyUser, + suggestions } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index dbfb54f9..c84373ac 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -54,6 +54,16 @@ const backendInteractorService = (credentials) => { return timelineFetcherService.startFetching({timeline, store, credentials, userId}) } + const fetchOldPost = ({store, postId}) => { + return timelineFetcherService.fetchAndUpdate({ + store, + credentials, + timeline: 'own', + older: true, + until: postId + 1 + }) + } + const setUserMute = ({id, muted = true}) => { return apiService.setUserMute({id, muted, credentials}) } @@ -86,6 +96,7 @@ const backendInteractorService = (credentials) => { fetchAllFollowing, verifyCredentials: apiService.verifyCredentials, startFetching, + fetchOldPost, setUserMute, fetchMutes, register, diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js new file mode 100644 index 00000000..1480cded --- /dev/null +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -0,0 +1,46 @@ +import apiService from '../api/api.service.js' + +const update = ({store, notifications, older}) => { + store.dispatch('setNotificationsError', { value: false }) + + store.dispatch('addNewNotifications', { notifications, older }) +} + +const fetchAndUpdate = ({store, credentials, older = false}) => { + const args = { credentials } + const rootState = store.rootState || store.state + const timelineData = rootState.statuses.notifications + + if (older) { + if (timelineData.minId !== Number.POSITIVE_INFINITY) { + args['until'] = timelineData.minId + } + } else { + args['since'] = timelineData.maxId + } + + args['timeline'] = 'notifications' + + return apiService.fetchTimeline(args) + .then((notifications) => { + update({store, notifications, older}) + }, () => store.dispatch('setNotificationsError', { value: true })) + .catch(() => store.dispatch('setNotificationsError', { value: true })) +} + +const startFetching = ({credentials, store}) => { + fetchAndUpdate({ credentials, store }) + const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) + // Initially there's set flag to silence all desktop notifications so + // that there won't spam of them when user just opened up the FE we + // reset that flag after a while to show new notifications once again. + setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) + return setInterval(boundFetchAndUpdate, 10000) +} + +const notificationsFetcher = { + fetchAndUpdate, + startFetching +} + +export default notificationsFetcher diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index 3381e9e2..7f8b0fc0 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -1,10 +1,10 @@ import { map } from 'lodash' import apiService from '../api/api.service.js' -const postStatus = ({ store, status, spoilerText, visibility, media = [], inReplyToStatusId = undefined }) => { +const postStatus = ({ store, status, spoilerText, visibility, sensitive, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => { const mediaIds = map(media, 'id') - return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, mediaIds, inReplyToStatusId}) + return apiService.postStatus({credentials: store.state.users.currentUser.credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType}) .then((data) => data.json()) .then((data) => { if (!data.error) { diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index bb5fdc2e..0e3e32d2 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -14,13 +14,13 @@ const update = ({store, statuses, timeline, showImmediately}) => { }) } -const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, tag = false}) => { +const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, tag = false, until}) => { const args = { timeline, credentials } const rootState = store.rootState || store.state const timelineData = rootState.statuses.timelines[camelCase(timeline)] if (older) { - args['until'] = timelineData.minVisibleId + args['until'] = until || timelineData.minVisibleId } else { args['since'] = timelineData.maxId } |
