diff options
Diffstat (limited to 'src')
60 files changed, 2436 insertions, 753 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 f830a33b..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: '✔'; @@ -168,6 +186,13 @@ input, textarea, .select { } } +option { + color: $fallback--fg; + color: var(--fg, $fallback--fg); + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); +} + i[class*=icon-] { color: $fallback--icon; color: var(--icon, $fallback--icon) @@ -211,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; @@ -219,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; @@ -267,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 { @@ -426,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/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js new file mode 100644 index 00000000..11a228aa --- /dev/null +++ b/src/components/follow_requests/follow_requests.js @@ -0,0 +1,23 @@ +import UserCard from '../user_card/user_card.vue' + +const FollowRequests = { + components: { + UserCard + }, + created () { + this.updateRequests() + }, + computed: { + requests () { + return this.$store.state.api.followRequests + } + }, + methods: { + updateRequests () { + this.$store.state.api.backendInteractor.fetchFollowRequests() + .then((requests) => { this.$store.commit('setFollowRequests', requests) }) + } + } +} + +export default FollowRequests diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue new file mode 100644 index 00000000..87dc4194 --- /dev/null +++ b/src/components/follow_requests/follow_requests.vue @@ -0,0 +1,12 @@ +<template> + <div class="settings panel panel-default"> + <div class="panel-heading"> + {{$t('nav.friend_requests')}} + </div> + <div class="panel-body"> + <user-card v-for="request in requests" :key="request.id" :user="request" :showFollows="false" :showApproval="true"></user-card> + </div> + </div> +</template> + +<script src="./follow_requests.js"></script> 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/login_form/login_form.vue b/src/components/login_form/login_form.vue index 67fa95a8..b7fed48a 100644 --- a/src/components/login_form/login_form.vue +++ b/src/components/login_form/login_form.vue @@ -8,7 +8,7 @@ <form v-on:submit.prevent='submit(user)' class='login-form'> <div class='form-group'> <label for='username'>{{$t('login.username')}}</label> - <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' placeholder='e.g. lain'> + <input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')"> </div> <div class='form-group'> <label for='password'>{{$t('login.password')}}</label> 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/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 2e1a6c7a..0b188f9a 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -12,6 +12,11 @@ {{ $t("nav.mentions") }} </router-link> </li> + <li v-if='currentUser && currentUser.locked'> + <router-link to='/friend-requests'> + {{ $t("nav.friend_requests") }} + </router-link> + </li> <li> <router-link to='/main/public'> {{ $t("nav.public_tl") }} diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 3a274374..c786f2cc 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,6 +1,7 @@ import Status from '../status/status.vue' import StillImage from '../still-image/still-image.vue' import UserCardContent from '../user_card_content/user_card_content.vue' +import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' const Notification = { data () { @@ -18,6 +19,16 @@ const Notification = { toggleUserExpanded () { this.userExpanded = !this.userExpanded } + }, + computed: { + userClass () { + return highlightClass(this.notification.action.user) + }, + userStyle () { + const highlight = this.$store.state.config.highlight + const user = this.notification.action.user + return highlightStyle(highlight[user.screen_name]) + } } } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index eed598a8..72c1ca69 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,6 +1,6 @@ <template> <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> - <div class="non-mention" v-else> + <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else> <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> <StillImage class='avatar-compact' :src="notification.action.user.profile_image_url_original"/> </a> @@ -10,8 +10,9 @@ </div> <span class="notification-details"> <div class="name-and-action"> - <span class="username" :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> - <span v-if="notification.type === 'favorite'"> + <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 === 'like'"> <i class="fa icon-star lit"></i> <small>{{$t('notifications.favorited_you')}}</small> </span> @@ -24,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 008530b4..a137ccd5 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -4,49 +4,30 @@ // 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 { - border-left: 4px solid $fallback--cRed; - border-left: 4px solid var(--cRed, $fallback--cRed); + box-shadow: inset 4px 0 0 var(--cRed, $fallback--cRed); padding-left: 0; } } @@ -55,8 +36,18 @@ box-sizing: border-box; display: flex; border-bottom: 1px solid; - border-bottom-color: inherit; - padding-left: 4px; + 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; @@ -71,7 +62,7 @@ } } - &:hover .animated.avatar { + &:hover .animated.avatar-compact { canvas { display: none; } @@ -147,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; @@ -196,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 0597d652..d7f1ffb2 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -23,13 +23,19 @@ const PostStatusForm = { props: [ 'replyTo', 'repliedUser', - 'attentions' + 'attentions', + 'messageScope', + 'subject' ], components: { MediaUpload }, mounted () { this.resize(this.$refs.textarea) + + if (this.replyTo) { + this.$refs.textarea.focus() + } }, data () { const preset = this.$route.query.message @@ -47,9 +53,12 @@ const PostStatusForm = { posting: false, highlighted: 0, newStatus: { + spoilerText: this.subject, status: statusText, + contentType: 'text/plain', + nsfw: false, files: [], - visibility: 'public' + visibility: this.messageScope || this.$store.state.users.currentUser.default_scope }, caret: 0 } @@ -67,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 } @@ -81,16 +90,16 @@ 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 } return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - // eslint-disable-next-line camelcase screen_name: `:${shortcode}:`, name: '', utf: utf || '', - img: image_url, + // eslint-disable-next-line camelcase + img: utf ? '' : this.$store.state.config.server + image_url, highlighted: index === this.highlighted })) } else { @@ -130,6 +139,9 @@ const PostStatusForm = { }, scopeOptionsEnabled () { return this.$store.state.config.scopeOptionsEnabled + }, + formattingOptionsEnabled () { + return this.$store.state.config.formattingOptionsEnabled } }, methods: { @@ -199,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 802d51ed..42e9c65c 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -2,6 +2,14 @@ <div class="post-status-form"> <form @submit.prevent="postStatus(newStatus)"> <div class="form-group" > + <i18n + v-if="!this.$store.state.users.currentUser.locked && this.newStatus.visibility == 'private'" + path="post_status.account_not_locked_warning" + tag="p" + class="visibility-notice"> + <router-link to="/user-settings">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> + </i18n> + <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p> <input v-if="scopeOptionsEnabled" type="text" @@ -24,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"></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 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"> @@ -57,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> @@ -91,17 +118,6 @@ } } -.post-status-form .visibility-tray { - font-size: 1.2em; - padding: 3px; - cursor: pointer; - - .selected { - color: $fallback--lightFg; - color: var(--lightFg, $fallback--lightFg); - } -} - .post-status-form, .login { .form-bottom { display: flex; @@ -123,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/registration/registration.js b/src/components/registration/registration.js index 771b3b27..73840608 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -5,17 +5,23 @@ const registration = { registering: false }), created () { - if (!this.$store.state.config.registrationOpen || !!this.$store.state.users.currentUser) { + if ((!this.$store.state.config.registrationOpen && !this.token) || !!this.$store.state.users.currentUser) { this.$router.push('/main/all') } + // Seems like this doesn't work at first page open for some reason + if (this.$store.state.config.registrationOpen && this.token) { + this.$router.push('/registration') + } }, computed: { - termsofservice () { return this.$store.state.config.tos } + termsofservice () { return this.$store.state.config.tos }, + token () { return this.$route.params.token } }, methods: { submit () { this.registering = true this.user.nickname = this.user.username + this.user.token = this.token this.$store.state.api.backendInteractor.register(this.user).then( (response) => { if (response.ok) { diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 00f665af..087cab6b 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -38,6 +38,10 @@ <input :disabled="registering" v-model='user.captcha' placeholder='Enter captcha' type='test' class='form-control' id='captcha'> </div> --> + <div class='form-group' v-if='token' > + <label for='token'>{{$t('registration.token')}}</label> + <input disabled='true' v-model='token' class='form-control' id='token' type='text'> + </div> <div class='form-group'> <button :disabled="registering" type='submit' class='btn btn-default'>{{$t('general.submit')}}</button> </div> diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index 4a43542d..cafa9cbc 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -1,5 +1,5 @@ const RetweetButton = { - props: ['status', 'loggedIn'], + props: ['status', 'loggedIn', 'visibility'], data () { return { animated: false @@ -9,6 +9,8 @@ const RetweetButton = { retweet () { if (!this.status.repeated) { this.$store.dispatch('retweet', {id: this.status.id}) + } else { + this.$store.dispatch('unretweet', {id: this.status.id}) } this.animated = true setTimeout(() => { @@ -20,6 +22,7 @@ const RetweetButton = { classes () { return { 'retweeted': this.status.repeated, + 'retweeted-empty': !this.status.repeated, 'animate-spin': this.animated } } diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 1bee3d08..ee5722bd 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -1,9 +1,14 @@ <template> <div v-if="loggedIn"> - <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 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> + <div v-else-if="!loggedIn"> <i :class='classes' class='icon-retweet'></i> <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> </div> 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 b4514ba1..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"> @@ -57,13 +141,39 @@ @import '../../_variables.scss'; .setting-item { + border-bottom: 2px solid var(--btn, $fallback--btn); 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; @@ -79,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 87ef90d8..45f5ccac 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -6,6 +6,7 @@ import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCardContent from '../user_card_content/user_card_content.vue' import StillImage from '../still-image/still-image.vue' import { filter, find } from 'lodash' +import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' const Status = { name: 'Status', @@ -21,25 +22,48 @@ 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 }, + repeaterClass () { + const user = this.statusoid.user + return highlightClass(user) + }, + userClass () { + const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user + return highlightClass(user) + }, + repeaterStyle () { + const user = this.statusoid.user + const highlight = this.$store.state.config.highlight + return highlightStyle(highlight[user.screen_name]) + }, + userStyle () { + if (this.noHeading) return + const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user + const highlight = this.$store.state.config.highlight + return highlightStyle(highlight[user.screen_name]) + }, hideAttachments () { return (this.$store.state.config.hideAttachments && !this.inConversation) || (this.$store.state.config.hideAttachmentsInConv && this.inConversation) }, retweet () { return !!this.statusoid.retweeted_status }, retweeter () { return this.statusoid.user.name }, + retweeterHtml () { return this.statusoid.user.name_html }, status () { if (this.retweet) { return this.statusoid.retweeted_status @@ -59,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) { @@ -77,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) || @@ -142,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 @@ -179,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 ace141cd..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> @@ -8,16 +8,17 @@ </div> </template> <template v-else> - <div v-if="retweet && !noHeading" class="media container retweet-info"> + <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 :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> </div> - <div class="media status"> + <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status"> <div v-if="!noHeading" class="media-left"> <a :href="status.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> <StillImage class='avatar' :class="{'avatar-compact': compact}" :src="status.user.profile_image_url_original"/> @@ -30,7 +31,8 @@ <div v-if="!noHeading" class="media-body container media-heading"> <div class="media-heading-left"> <div class="name-and-links"> - <h4 class="user-name">{{status.user.name}}</h4> + <h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4> + <h4 class="user-name" v-else>{{status.user.name}}</h4> <span class="links"> <router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link> <span v-if="status.in_reply_to_screen_name" class="faint reply-info"> @@ -55,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> @@ -72,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> @@ -88,7 +98,7 @@ <i class="icon-reply" :class="{'icon-reply-active': replying}"></i> </a> </div> - <retweet-button :loggedIn='loggedIn' :status='status'></retweet-button> + <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button> <favorite-button :loggedIn='loggedIn' :status='status'></favorite-button> <delete-button :status='status'></delete-button> </div> @@ -96,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" 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> @@ -139,6 +149,7 @@ margin-top: 0.25em; margin-left: 0.5em; z-index: 50; + .status { flex: 1; border: 0; @@ -153,6 +164,7 @@ text-align: center; border-width: 1px; border-style: solid; + i { font-size: 2em; } @@ -194,6 +206,7 @@ .media-heading { flex-wrap: nowrap; + line-height: 18px; } .media-heading-left { @@ -216,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); @@ -245,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 { @@ -287,7 +316,7 @@ } } - .tall-status-unhider { + .status-unhider, .cw-status-hider { width: 100%; text-align: center; } @@ -306,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 0 -0.5em 0; + margin: 0; + .avatar { border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); @@ -331,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; @@ -427,6 +494,9 @@ .status { display: flex; padding: 0.6em; + &.is-retweet { + padding-top: 0.1em; + } } .status-conversation:last-child { 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 7acba1dc..72a338bd 100644 --- a/src/components/style_switcher/style_switcher.vue +++ b/src/components/style_switcher/style_switcher.vue @@ -1,94 +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[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', @@ -119,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> @@ -131,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; } @@ -149,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; @@ -216,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 f24626f9..a651f619 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -13,7 +13,8 @@ const Timeline = { ], data () { return { - paused: false + paused: false, + unfocused: false } }, computed: { @@ -65,8 +66,15 @@ const Timeline = { this.fetchFollowers() } }, + mounted () { + if (typeof document.hidden !== 'undefined') { + document.addEventListener('visibilitychange', this.handleVisibilityChange, false) + this.unfocused = document.hidden + } + }, destroyed () { window.removeEventListener('scroll', this.scrollLoad) + if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { @@ -113,6 +121,9 @@ const Timeline = { (window.innerHeight + window.pageYOffset) >= (height - 750)) { this.fetchOlderStatuses() } + }, + handleVisibilityChange () { + this.unfocused = document.hidden } }, watch: { @@ -122,7 +133,10 @@ const Timeline = { } if (count > 0) { // only 'stream' them when you're scrolled to the top - if (window.pageYOffset < 15 && !this.paused) { + 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.js b/src/components/user_card/user_card.js index a7a871c3..a019627a 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -3,7 +3,8 @@ import UserCardContent from '../user_card_content/user_card_content.vue' const UserCard = { props: [ 'user', - 'showFollows' + 'showFollows', + 'showApproval' ], data () { return { @@ -16,6 +17,14 @@ const UserCard = { methods: { toggleUserExpanded () { this.userExpanded = !this.userExpanded + }, + approveUser () { + this.$store.state.api.backendInteractor.approveUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) + }, + denyUser () { + this.$store.state.api.backendInteractor.denyUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 51d6965f..48f272ca 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -7,14 +7,24 @@ <user-card-content :user="user" :switcher="false"></user-card-content> </div> <div class="name-and-screen-name" v-else> - <div :title="user.name" class="user-name"> + <div :title="user.name" v-if="user.name_html" class="user-name"> + <span v-html="user.name_html"></span> + <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> + {{ $t('user_card.follows_you') }} + </span> + </div> + <div :title="user.name" v-else class="user-name"> {{ user.name }} <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ $t('user_card.follows_you') }} + {{ $t('user_card.follows_you') }} </span> </div> <a :href="user.statusnet_profile_url" target="blank"><div class="user-screen-name">@{{ user.screen_name }}</div></a> </div> + <div class="approval" v-if="showApproval"> + <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> + <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> + </div> </div> </template> @@ -63,16 +73,25 @@ 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 { margin-bottom: 0; } } + +.approval { + button { + width: 100%; + margin-bottom: 0.5em; + } +} </style> diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js index 4d4266cb..76a5577e 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card_content/user_card_content.js @@ -9,11 +9,6 @@ export default { if (color) { const rgb = hex2rgb(color) const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .5)` - console.log(rgb) - console.log([ - `url(${this.user.cover_photo})`, - `linear-gradient(to bottom, ${tintColor}, ${tintColor})` - ].join(', ')) return { backgroundColor: `rgb(${Math.floor(rgb.r * 0.53)}, ${Math.floor(rgb.g * 0.56)}, ${Math.floor(rgb.b * 0.59)})`, backgroundImage: [ @@ -37,6 +32,29 @@ export default { dailyAvg () { const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000)) return Math.round(this.user.statuses_count / days) + }, + userHighlightType: { + get () { + const data = this.$store.state.config.highlight[this.user.screen_name] + return data && data.type || 'disabled' + }, + set (type) { + const data = this.$store.state.config.highlight[this.user.screen_name] + if (type !== 'disabled') { + this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: data && data.color || '#FFFFFF', type }) + } else { + this.$store.dispatch('setHighlight', { user: this.user.screen_name, color: undefined }) + } + } + }, + userHighlightColor: { + get () { + const data = this.$store.state.config.highlight[this.user.screen_name] + return data && data.color + }, + set (color) { + this.$store.dispatch('setHighlight', { user: this.user.screen_name, color }) + } } }, components: { diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index c120df9a..59358040 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -1,29 +1,46 @@ <template> - <div id="heading" class="profile-panel-background" :style="headingStyle"> - <div class="panel-heading text-center"> - <div class='user-info'> - <router-link to='/user-settings' style="float: right; margin-top:16px;" v-if="!isOtherUser"> - <i class="icon-cog usersettings"></i> +<div id="heading" class="profile-panel-background" :style="headingStyle"> + <div class="panel-heading text-center"> + <div class='user-info'> + <router-link to='/user-settings' style="float: right; margin-top:16px;" v-if="!isOtherUser"> + <i class="icon-cog usersettings"></i> + </router-link> + <a :href="user.statusnet_profile_url" target="_blank" class="floater" v-if="isOtherUser"> + <i class="icon-link-ext usersettings"></i> + </a> + <div class='container'> + <router-link :to="{ name: 'user-profile', params: { id: user.id } }"> + <StillImage class="avatar" :src="user.profile_image_url_original"/> </router-link> - <a :href="user.statusnet_profile_url" target="_blank" style="float: right; margin-top:16px;" v-if="isOtherUser"> - <i class="icon-link-ext usersettings"></i> - </a> - <div class='container'> - <router-link :to="{ name: 'user-profile', params: { id: user.id } }"> - <StillImage class="avatar" :src="user.profile_image_url_original"/> + <div class="name-and-screen-name"> + <div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div> + <div :title="user.name" class='user-name' v-else>{{user.name}}</div> + <router-link class='user-screen-name':to="{ name: 'user-profile', params: { id: user.id } }"> + <span>@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span> + <span class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> </router-link> - <div class="name-and-screen-name"> - <div :title="user.name" class='user-name'>{{user.name}}</div> - <router-link class='user-screen-name':to="{ name: 'user-profile', params: { id: user.id } }"> - <span>@{{user.screen_name}}</span> - <span class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> - </router-link> - </div> </div> - <div v-if="isOtherUser" class="user-interactions"> - <div v-if="user.follows_you && loggedIn" class="following"> - {{ $t('user_card.follows_you') }} - </div> + </div> + <div class="user-meta"> + <div v-if="user.follows_you && loggedIn && isOtherUser" class="following"> + {{ $t('user_card.follows_you') }} + </div> + <div class="floater" v-if="switcher || isOtherUser"> + <!-- id's need to be unique, otherwise vue confuses which user-card checkbox belongs to --> + <input class="userHighlightText" type="text" :id="'userHighlightColorTx'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/> + <input class="userHighlightCl" type="color" :id="'userHighlightColor'+user.id" v-if="userHighlightType !== 'disabled'" v-model="userHighlightColor"/> + <label for="style-switcher" class='userHighlightSel select'> + <select class="userHighlightSel" :id="'userHighlightSel'+user.id" v-model="userHighlightType"> + <option value="disabled">No highlight</option> + <option value="solid">Solid bg</option> + <option value="striped">Striped bg</option> + <option value="side">Side stripe</option> + </select> + <i class="icon-down-open"/> + </label> + </div> + </div> + <div v-if="isOtherUser" class="user-interactions"> <div class="follow" v-if="loggedIn"> <span v-if="user.following"> <!--Following them!--> @@ -88,7 +105,8 @@ <span>{{user.followers_count}}</span> </div> </div> - <p v-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> @@ -112,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 { @@ -179,6 +201,27 @@ padding-right: 0.1em; } + .user-meta { + margin-bottom: .4em; + + .following { + font-size: 14px; + flex: 0 0 100%; + margin: 0; + padding-left: 16px; + text-align: left; + float: left; + } + .floater { + margin: 0; + } + + &::after { + display: block; + content: ''; + clear: both; + } + } .user-interactions { display: flex; flex-flow: row wrap; @@ -188,14 +231,6 @@ flex: 1; } - .following { - font-size: 14px; - flex: 0 0 100%; - margin: 0 0 .4em 0; - padding-left: 16px; - text-align: left; - } - .mute { max-width: 220px; min-height: 28px; @@ -278,4 +313,33 @@ font-size: 0.7em; color: #CCC; } +.floater { + float: right; + margin-top: 16px; + + .userHighlightCl { + padding: 2px 10px; + } + .userHighlightSel, + .userHighlightSel.select { + padding-top: 0; + padding-bottom: 0; + } + .userHighlightSel.select i { + line-height: 22px; + } + + .userHighlightText { + width: 70px; + } + + .userHighlightCl, + .userHighlightText, + .userHighlightSel, + .userHighlightSel.select { + height: 22px; + vertical-align: top; + margin-right: 0 + } +} </style> 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 b6026e18..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 = { @@ -5,6 +6,8 @@ const UserSettings = { return { 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, @@ -16,11 +19,13 @@ const UserSettings = { deleteAccountError: false, changePasswordInputs: [ '', '', '' ], changedPassword: false, - changePasswordError: false + changePasswordError: false, + activeTab: 'profile' } }, components: { - StyleSwitcher + StyleSwitcher, + TabSwitcher }, computed: { user () { @@ -28,18 +33,36 @@ 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: { updateProfile () { const name = this.newname const description = this.newbio - this.$store.state.api.backendInteractor.updateProfile({params: {name, description}}).then((user) => { + const locked = this.newlocked + /* 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] @@ -215,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 fbf3f651..9daafdce 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -4,108 +4,131 @@ {{$t('settings.user_settings')}} </div> <div class="panel-body profile-edit"> - <div class="setting-item"> - <h3>{{$t('settings.name_bio')}}</h3> - <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> - <button :disabled='newname.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> - </div> - <div class="setting-item"> - <h3>{{$t('settings.avatar')}}</h3> - <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> + <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> - <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"> - <h3>{{$t('settings.profile_banner')}}</h3> - <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"> - <h3>{{$t('settings.profile_background')}}</h3> - <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"> - <h3>{{$t('settings.change_password')}}</h3> - <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"> - <h3>{{$t('settings.follow_import')}}</h3> - <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"> - <h3>{{$t('settings.follow_export')}}</h3> - <button class="btn btn-default" @click="exportFollows">{{$t('settings.follow_export_button')}}</button> - </div> - <div class="setting-item" v-else> - <h3>{{$t('settings.follow_export_processing')}}</h3> - </div> - <hr> - <div class="setting-item"> - <h3>{{$t('settings.delete_account')}}</h3> - <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> @@ -121,6 +144,7 @@ input[type=file] { padding: 5px; + height: auto; } .banner { 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 ecf86771..5c5ed447 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -48,7 +48,11 @@ const de = { settings: 'Einstellungen', theme: 'Farbschema', presets: 'Voreinstellungen', - theme_help: 'Benutze HTML Farbcodes (#rrggbb) um dein Farbschema anzupassen.', + 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', foreground: 'Vordergrund', text: 'Text', @@ -58,6 +62,7 @@ const de = { cOrange: 'Orange (Favorisieren)', cGreen: 'Grün (Retweet)', btnRadius: 'Buttons', + inputRadius: 'Eingabefelder', panelRadius: 'Panel', avatarRadius: 'Avatare', avatarAltRadius: 'Avatare (Benachrichtigungen)', @@ -76,7 +81,20 @@ const de = { follow_import: 'Folgeliste importieren', import_followers_from_a_csv_file: 'Importiere Kontakte, denen du folgen möchtest, aus einer CSV-Datei', follows_imported: 'Folgeliste importiert! Die Bearbeitung kann eine Zeit lang dauern.', - follow_import_error: 'Fehler beim importieren der Folgeliste' + follow_import_error: 'Fehler beim importieren der Folgeliste', + delete_account: 'Account löschen', + delete_account_description: 'Lösche deinen Account und alle deine Nachrichten dauerhaft.', + delete_account_instructions: 'Tippe dein Passwort unten in das Feld ein um die Löschung deines Accounts zu bestätigen.', + delete_account_error: 'Es ist ein Fehler beim löschen deines Accounts aufgetreten. Tritt dies weiterhin auf, wende dich an den Administrator der Instanz.', + follow_export: 'Folgeliste exportieren', + follow_export_processing: 'In Bearbeitung. Die Liste steht gleich zum herunterladen bereit.', + follow_export_button: 'Liste (.csv) erstellen', + change_password: 'Passwort ändern', + current_password: 'Aktuelles Passwort', + new_password: 'Neues Passwort', + confirm_new_password: 'Neues Passwort bestätigen', + changed_password: 'Passwort erfolgreich geändert!', + change_password_error: 'Es gab ein Problem bei der Änderung des Passworts.' }, notifications: { notifications: 'Benachrichtigungen', @@ -88,6 +106,7 @@ const de = { login: { login: 'Anmelden', username: 'Benutzername', + placeholder: 'z.B. lain', password: 'Passwort', register: 'Registrieren', logout: 'Abmelden' @@ -101,7 +120,16 @@ const de = { }, post_status: { posting: 'Veröffentlichen', - default: 'Sitze gerade im Hofbräuhaus.' + default: 'Sitze gerade im Hofbräuhaus.', + account_not_locked_warning: 'Dein Profil ist nicht {0}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.', + account_not_locked_warning_link: 'gesperrt', + direct_warning: 'Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.', + scope: { + public: 'Öffentlich - Beitrag an öffentliche Zeitleisten', + unlisted: 'Nicht gelistet - Nicht in öffentlichen Zeitleisten anzeigen', + private: 'Nur Folgende - Beitrag nur an Folgende', + direct: 'Direkt - Beitrag nur an erwähnte Profile' + } }, finder: { find_user: 'Finde Benutzer', @@ -184,6 +212,7 @@ const fi = { login: { login: 'Kirjaudu sisään', username: 'Käyttäjänimi', + placeholder: 'esim. lain', password: 'Salasana', register: 'Rekisteröidy', logout: 'Kirjaudu ulos' @@ -218,7 +247,8 @@ const en = { timeline: 'Timeline', mentions: 'Mentions', public_tl: 'Public Timeline', - twkn: 'The Whole Known Network' + twkn: 'The Whole Known Network', + friend_requests: 'Follow Requests' }, user_card: { follows_you: 'Follows you!', @@ -232,7 +262,9 @@ const en = { followers: 'Followers', followees: 'Following', per_day: 'per day', - remote_follow: 'Remote follow' + remote_follow: 'Remote follow', + approve: 'Approve', + deny: 'Deny' }, timeline: { show_new: 'Show new', @@ -241,9 +273,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', @@ -259,7 +293,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', @@ -282,10 +319,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.', @@ -302,18 +352,28 @@ const en = { new_password: 'New password', confirm_new_password: 'Confirm new password', changed_password: 'Password changed successfully!', - change_password_error: 'There was an issue changing your password.' + change_password_error: 'There was an issue changing your password.', + 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', username: 'Username', + placeholder: 'e.g. lain', password: 'Password', register: 'Register', logout: 'Log out' @@ -323,12 +383,26 @@ const en = { fullname: 'Display name', email: 'Email', bio: 'Bio', - password_confirm: 'Password confirmation' + password_confirm: 'Password confirmation', + token: 'Invite token' }, post_status: { posting: 'Posting', - content_warning: 'Content warning (optional)', - default: 'Just landed in L.A.' + content_warning: 'Subject (optional)', + default: 'Just landed in L.A.', + 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: { find_user: 'Find user', @@ -340,6 +414,19 @@ 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' } } @@ -434,6 +521,7 @@ const eo = { login: { login: 'Ensaluti', username: 'Salutnomo', + placeholder: 'ekz. lain', password: 'Pasvorto', register: 'Registriĝi', logout: 'Elsaluti' @@ -521,6 +609,7 @@ const et = { login: { login: 'Logi sisse', username: 'Kasutajanimi', + placeholder: 'nt lain', password: 'Parool', register: 'Registreeru', logout: 'Logi välja' @@ -604,6 +693,7 @@ const hu = { login: { login: 'Bejelentkezés', username: 'Felhasználó név', + placeholder: 'e.g. lain', password: 'Jelszó', register: 'Feliratkozás', logout: 'Kijelentkezés' @@ -687,6 +777,7 @@ const ro = { login: { login: 'Loghează', username: 'Nume utilizator', + placeholder: 'd.e. lain', password: 'Parolă', register: 'Înregistrare', logout: 'Deloghează' @@ -719,114 +810,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: 'ユーザー名', + 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: 'もじのかず' } } @@ -877,7 +1010,7 @@ const fr = { settings: 'Paramètres', theme: 'Thème', filtering: 'Filtre', - filtering_explanation: 'Tout les statuts contenant ces mots seront masqués. Un mot par ligne.', + filtering_explanation: 'Tous les statuts contenant ces mots seront masqués. Un mot par ligne.', attachments: 'Pièces jointes', hide_attachments_in_tl: 'Masquer les pièces jointes dans le journal', hide_attachments_in_convo: 'Masquer les pièces jointes dans les conversations', @@ -886,15 +1019,18 @@ const fr = { reply_link_preview: 'Afficher un aperçu lors du survol de liens vers une réponse', presets: 'Thèmes prédéfinis', theme_help: 'Spécifiez des codes couleur hexadécimaux (#aabbcc) pour personnaliser les couleurs du thème', - background: 'Arrière plan', + background: 'Arrière-plan', foreground: 'Premier plan', text: 'Texte', links: 'Liens', streaming: 'Charger automatiquement les nouveaux statuts lorsque vous êtes au haut de la page', - follow_import: 'Importer ses abonnements', - import_followers_from_a_csv_file: 'Importer ses abonnements depuis un fichier csv', + follow_import: 'Importer des abonnements', + import_followers_from_a_csv_file: 'Importer des abonnements depuis un fichier csv', follows_imported: 'Abonnements importés ! Le traitement peut prendre un moment.', follow_import_error: 'Erreur lors de l\'importation des abonnements.', + follow_export: 'Exporter les abonnements', + follow_export_button: 'Exporter les abonnements en csv', + follow_export_processing: 'Exportation en cours…', cBlue: 'Bleu (Répondre, suivre)', cRed: 'Rouge (Annuler)', cOrange: 'Orange (Aimer)', @@ -907,7 +1043,15 @@ const fr = { tooltipRadius: 'Info-bulles/alertes ', attachmentRadius: 'Pièces jointes', radii_help: 'Vous pouvez ici choisir le niveau d\'arrondi des angles de l\'interface (en pixels)', - stop_gifs: 'N\'animer les GIFS que lors du survol du curseur de la souris' + stop_gifs: 'N\'animer les GIFS que lors du survol du curseur de la souris', + change_password: 'Modifier son mot de passe', + current_password: 'Mot de passe actuel', + new_password: 'Nouveau mot de passe', + confirm_new_password: 'Confirmation du nouveau mot de passe', + delete_account: 'Supprimer le compte', + delete_account_description: 'Supprimer définitivement votre compte et tous vos statuts.', + delete_account_instructions: 'Indiquez votre mot de passe ci-dessous pour confirmer la suppression de votre compte.', + delete_account_error: 'Il y a eu un problème lors de la tentative de suppression de votre compte. Si le problème persiste, contactez l\'administrateur de cette instance.' }, notifications: { notifications: 'Notifications', @@ -919,6 +1063,7 @@ const fr = { login: { login: 'Connexion', username: 'Identifiant', + placeholder: 'p.e. lain', password: 'Mot de passe', register: 'S\'inscrire', logout: 'Déconnexion' @@ -932,7 +1077,16 @@ const fr = { }, post_status: { posting: 'Envoi en cours', - default: 'Écrivez ici votre prochain statut.' + default: 'Écrivez ici votre prochain statut.', + account_not_locked_warning: 'Votre compte n’est pas {0}. N’importe qui peut vous suivre pour voir vos billets en Abonné·e·s uniquement.', + account_not_locked_warning_link: 'verrouillé', + direct_warning: 'Ce message sera visible à toutes les personnes mentionnées.', + scope: { + public: 'Publique - Afficher dans les fils publics', + unlisted: 'Non-Listé - Ne pas afficher dans les fils publics', + private: 'Abonné·e·s uniquement - Seul·e·s vos abonné·e·s verront vos billets', + direct: 'Direct - N’envoyer qu’aux personnes mentionnées' + } }, finder: { find_user: 'Chercher un utilisateur', @@ -1063,10 +1217,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)', @@ -1085,7 +1239,20 @@ 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', @@ -1097,6 +1264,7 @@ const oc = { login: { login: 'Connexion', username: 'Nom d’utilizaire', + placeholder: 'e.g. lain', password: 'Senhal', register: 'Se marcar', logout: 'Desconnexion' @@ -1110,6 +1278,7 @@ const oc = { }, post_status: { posting: 'Mandadís', + content_warning: 'Avís de contengut (opcional)', default: 'Escrivètz aquí vòstre estatut.' }, finder: { @@ -1230,6 +1399,7 @@ const pl = { login: { login: 'Zaloguj', username: 'Użytkownik', + placeholder: 'n.p. lain', password: 'Hasło', register: 'Zarejestruj', logout: 'Wyloguj' @@ -1333,6 +1503,7 @@ const es = { login: { login: 'Identificación', username: 'Usuario', + placeholder: 'p.ej. lain', password: 'Contraseña', register: 'Registrar', logout: 'Salir' @@ -1447,6 +1618,7 @@ const pt = { login: { login: 'Entrar', username: 'Usuário', + placeholder: 'p.e. lain', password: 'Senha', register: 'Registrar', logout: 'Sair' @@ -1507,9 +1679,11 @@ const ru = { load_older: 'Загрузить старые статусы', conversation: 'Разговор', collapse: 'Свернуть', - repeated: 'повторил(а)' + repeated: 'повторил(а)', + no_retweet_hint: 'Пост помечен как "только для подписчиков" или "личное" и поэтому не может быть повторён' }, settings: { + general: 'Общие', user_settings: 'Настройки пользователя', name_bio: 'Имя и описание', name: 'Имя', @@ -1524,9 +1698,11 @@ const ru = { set_new_profile_background: 'Загрузить новый фон профиля', settings: 'Настройки', theme: 'Тема', + export_theme: 'Сохранить Тему', + import_theme: 'Загрузить Тему', presets: 'Пресеты', theme_help: 'Используйте шестнадцатеричные коды цветов (#rrggbb) для настройки темы.', - radii_help: 'Округление краёв элементов интерфейса (в пикселях)', + radii_help: 'Скругление углов элементов интерфейса (в пикселях)', background: 'Фон', foreground: 'Передний план', text: 'Текст', @@ -1551,22 +1727,57 @@ 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: 'Список читаемых импортирован. Обработка займёт некоторое время..', - follow_import_error: 'Ошибка при импортировании читаемых.' + follow_import_error: 'Ошибка при импортировании читаемых.', + delete_account: 'Удалить аккаунт', + delete_account_description: 'Удалить ваш аккаунт и все ваши сообщения.', + delete_account_instructions: 'Введите ваш пароль в поле ниже для подтверждения удаления.', + delete_account_error: 'Возникла ошибка в процессе удаления вашего аккаунта. Если это повторяется, свяжитесь с администратором вашего сервера.', + follow_export: 'Экспортировать читаемых', + follow_export_processing: 'Ведётся обработка, скоро вам будет предложено загрузить файл', + follow_export_button: 'Экспортировать читаемых в файл .csv', + change_password: 'Сменить пароль', + current_password: 'Текущий пароль', + new_password: 'Новый пароль', + confirm_new_password: 'Подтверждение нового пароля', + changed_password: 'Пароль изменён успешно.', + 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: 'Войти', username: 'Имя пользователя', + placeholder: 'e.c. lain', password: 'Пароль', register: 'Зарегистрироваться', logout: 'Выйти' @@ -1576,11 +1787,23 @@ const ru = { fullname: 'Отображаемое имя', email: 'Email', bio: 'Описание', - password_confirm: 'Подтверждение пароля' + password_confirm: 'Подтверждение пароля', + token: 'Код приглашения' }, 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: 'Найти пользователя', @@ -1685,6 +1908,7 @@ const nb = { login: { login: 'Logg inn', username: 'Brukernavn', + placeholder: 'f. eks lain', password: 'Passord', register: 'Registrer', logout: 'Logg ut' @@ -1818,6 +2042,7 @@ const he = { login: { login: 'התחבר', username: 'שם המשתמש', + placeholder: 'למשל lain', password: 'סיסמה', register: 'הירשם', logout: 'התנתק' diff --git a/src/main.js b/src/main.js index 0c964dcc..75c2bab2 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ import UserProfile from './components/user_profile/user_profile.vue' import Settings from './components/settings/settings.vue' import Registration from './components/registration/registration.vue' import UserSettings from './components/user_settings/user_settings.vue' +import FollowRequests from './components/follow_requests/follow_requests.vue' import statusesModule from './modules/statuses.js' import usersModule from './modules/users.js' @@ -44,15 +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', - 'users.lastLoginName' + 'config.highlight', + 'config.loopVideo', + 'config.loopVideoSilentOnly', + 'config.pauseOnUnfocused', + 'config.stopGifs', + 'config.interfaceLanguage', + 'users.lastLoginName', + 'statuses.notifications.maxSavedId' ] } @@ -70,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 @@ -78,67 +90,87 @@ const i18n = new VueI18n({ window.fetch('/api/statusnet/config.json') .then((res) => res.json()) .then((data) => { - const {name, closed: registrationClosed, textlimit} = data.site + const {name, closed: registrationClosed, textlimit, server} = data.site store.dispatch('setOption', { name: 'name', value: name }) store.dispatch('setOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) store.dispatch('setOption', { name: 'textlimit', value: parseInt(textlimit) }) - }) - -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: '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 } + store.dispatch('setOption', { name: 'server', value: server }) + + 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) + }) }) }) @@ -181,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 c91fb97b..2f07a91e 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -7,7 +7,8 @@ const api = { backendInteractor: backendInteractorService(), fetchers: {}, socket: null, - chatDisabled: false + chatDisabled: false, + followRequests: [] }, mutations: { setBackendInteractor (state, backendInteractor) { @@ -24,6 +25,9 @@ const api = { }, setChatDisabled (state, value) { state.chatDisabled = value + }, + setFollowRequests (state, value) { + state.followRequests = value } }, actions: { @@ -42,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) @@ -57,6 +64,10 @@ const api = { }, disableChat (store) { store.commit('setChatDisabled', true) + }, + removeFollowRequest (store, request) { + let requests = store.state.followRequests.filter((it) => it !== request) + store.commit('setFollowRequests', requests) } } } diff --git a/src/modules/config.js b/src/modules/config.js index 9a62905e..60a34bc1 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,16 +1,32 @@ -import { set } from 'vue' +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, - muteWords: [] + pauseOnUnfocused: true, + stopGifs: false, + replyVisibility: 'all', + notificationVisibility: { + follows: true, + mentions: true, + likes: true, + repeats: true + }, + muteWords: [], + highlight: {}, + interfaceLanguage: browserLocale } const config = { @@ -18,12 +34,23 @@ const config = { mutations: { setOption (state, { name, value }) { set(state, name, value) + }, + setHighlight (state, { user, color, type }) { + const data = this.state.config.highlight[user] + if (color || type) { + set(state.highlight, user, { color: color || data.color, type: type || data.type }) + } else { + del(state.highlight, user) + } } }, actions: { setPageTitle ({state}, option = '') { document.title = `${option} ${state.name}` }, + setHighlight ({ commit, dispatch }, { user, color, type }) { + commit('setHighlight', {user, color, type}) + }, setOption ({ commit, dispatch }, { name, value }) { commit('setOption', {name, value}) switch (name) { diff --git a/src/modules/statuses.js b/src/modules/statuses.js index b493c212..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,17 +438,41 @@ 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... commit('setRetweeted', { status, value: true }) apiService.retweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) }, + unretweet ({ rootState, commit }, status) { + commit('setRetweeted', { status, value: false }) + apiService.unretweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + }, queueFlush ({ rootState, commit }, { timeline, id }) { commit('queueFlush', { timeline, id }) } diff --git a/src/modules/users.js b/src/modules/users.js index 8303ecc1..e90d6bb9 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -42,6 +42,10 @@ export const mutations = { }, setUserForStatus (state, status) { status.user = state.usersObject[status.user.id] + }, + setColor (state, { user: {id}, highlighted }) { + const user = state.usersObject[id] + set(user, 'highlight', highlighted) } } @@ -103,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) => { @@ -115,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 0d91851b..87315657 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -8,6 +8,7 @@ const TAG_TIMELINE_URL = '/api/statusnet/tags/timeline' const FAVORITE_URL = '/api/favorites/create' const UNFAVORITE_URL = '/api/favorites/destroy' const RETWEET_URL = '/api/statuses/retweet' +const UNRETWEET_URL = '/api/statuses/unretweet' const STATUS_UPDATE_URL = '/api/statuses/update.json' const STATUS_DELETE_URL = '/api/statuses/destroy' const STATUS_URL = '/api/statuses/show' @@ -26,12 +27,17 @@ 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' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' 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' @@ -127,11 +133,13 @@ const updateBanner = ({credentials, params}) => { const updateProfile = ({credentials, params}) => { let url = PROFILE_UPDATE_URL + console.log(params) + const form = new FormData() each(params, (value, key) => { - if (key === 'description' || /* Always include description, because it might be empty */ - value) { + /* Always include description and locked, because it might be empty or false */ + if (key === 'description' || key === 'locked' || value) { form.append(key, value) } }) @@ -153,6 +161,7 @@ const updateProfile = ({credentials, params}) => { // bio // homepage // location +// token const register = (params) => { const form = new FormData() @@ -216,6 +225,22 @@ const unblockUser = ({id, credentials}) => { }).then((data) => data.json()) } +const approveUser = ({id, credentials}) => { + let url = `${APPROVE_USER_URL}?user_id=${id}` + return fetch(url, { + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + +const denyUser = ({id, credentials}) => { + let url = `${DENY_USER_URL}?user_id=${id}` + return fetch(url, { + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + const fetchUser = ({id, credentials}) => { let url = `${USER_URL}?user_id=${id}` return fetch(url, { headers: authHeaders(credentials) }) @@ -240,6 +265,12 @@ const fetchAllFollowing = ({username, credentials}) => { .then((data) => data.json()) } +const fetchFollowRequests = ({credentials}) => { + const url = FOLLOW_REQUESTS_URL + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + const fetchConversation = ({id, credentials}) => { let url = `${CONVERSATION_URL}/${id}.json?count=100` return fetch(url, { headers: authHeaders(credentials) }) @@ -273,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 } @@ -331,7 +366,14 @@ const retweet = ({ id, credentials }) => { }) } -const postStatus = ({credentials, status, spoilerText, visibility, mediaIds, inReplyToStatusId}) => { +const unretweet = ({ id, credentials }) => { + return fetch(`${UNRETWEET_URL}/${id}.json`, { + headers: authHeaders(credentials), + method: 'POST' + }) +} + +const postStatus = ({credentials, status, spoilerText, visibility, sensitive, mediaIds, inReplyToStatusId, contentType}) => { const idsText = mediaIds.join(',') const form = new FormData() @@ -339,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) @@ -413,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, @@ -428,6 +478,7 @@ const apiService = { favorite, unfavorite, retweet, + unretweet, postStatus, deleteStatus, uploadMedia, @@ -442,7 +493,11 @@ const apiService = { externalProfile, followImport, deleteAccount, - changePassword + changePassword, + fetchFollowRequests, + approveUser, + 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 14173558..c84373ac 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -42,15 +42,34 @@ const backendInteractorService = (credentials) => { return apiService.unblockUser({credentials, id}) } + const approveUser = (id) => { + return apiService.approveUser({credentials, id}) + } + + const denyUser = (id) => { + return apiService.denyUser({credentials, id}) + } + const startFetching = ({timeline, store, userId = false}) => { 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}) } const fetchMutes = () => apiService.fetchMutes({credentials}) + const fetchFollowRequests = () => apiService.fetchFollowRequests({credentials}) const register = (params) => apiService.register(params) const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params}) @@ -77,6 +96,7 @@ const backendInteractorService = (credentials) => { fetchAllFollowing, verifyCredentials: apiService.verifyCredentials, startFetching, + fetchOldPost, setUserMute, fetchMutes, register, @@ -87,7 +107,10 @@ const backendInteractorService = (credentials) => { externalProfile, followImport, deleteAccount, - changePassword + changePassword, + fetchFollowRequests, + approveUser, + denyUser } return backendInteractorServiceInstance 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 } diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js new file mode 100644 index 00000000..ebb25eca --- /dev/null +++ b/src/services/user_highlighter/user_highlighter.js @@ -0,0 +1,48 @@ +import { hex2rgb } from '../color_convert/color_convert.js' +const highlightStyle = (prefs) => { + if (prefs === undefined) return + const {color, type} = prefs + if (typeof color !== 'string') return + const rgb = hex2rgb(color) + if (rgb == null) return + const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})` + const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)` + const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)` + if (type === 'striped') { + return { + backgroundImage: [ + 'repeating-linear-gradient(-45deg,', + `${tintColor} ,`, + `${tintColor} 20px,`, + `${tintColor2} 20px,`, + `${tintColor2} 40px` + ].join(' '), + backgroundPosition: '0 0' + } + } else if (type === 'solid') { + return { + backgroundColor: tintColor2 + } + } else if (type === 'side') { + return { + backgroundImage: [ + 'linear-gradient(to right,', + `${solidColor} ,`, + `${solidColor} 2px,`, + `transparent 6px` + ].join(' '), + backgroundPosition: '0 0' + } + } +} + +const highlightClass = (user) => { + return 'USER____' + user.screen_name + .replace(/\./g, '_') + .replace(/@/g, '_AT_') +} + +export { + highlightClass, + highlightStyle +} |
