aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.js43
-rw-r--r--src/App.scss124
-rw-r--r--src/App.vue9
-rw-r--r--src/components/attachment/attachment.js38
-rw-r--r--src/components/attachment/attachment.vue20
-rw-r--r--src/components/conversation/conversation.vue6
-rw-r--r--src/components/features_panel/features_panel.js14
-rw-r--r--src/components/features_panel/features_panel.vue29
-rw-r--r--src/components/follow_requests/follow_requests.js23
-rw-r--r--src/components/follow_requests/follow_requests.vue12
-rw-r--r--src/components/interface_language_switcher/interface_language_switcher.vue38
-rw-r--r--src/components/login_form/login_form.vue2
-rw-r--r--src/components/media_upload/media_upload.js6
-rw-r--r--src/components/media_upload/media_upload.vue2
-rw-r--r--src/components/nav_panel/nav_panel.vue5
-rw-r--r--src/components/notification/notification.js11
-rw-r--r--src/components/notification/notification.vue16
-rw-r--r--src/components/notifications/notifications.js38
-rw-r--r--src/components/notifications/notifications.scss81
-rw-r--r--src/components/notifications/notifications.vue15
-rw-r--r--src/components/post_status_form/post_status_form.js31
-rw-r--r--src/components/post_status_form/post_status_form.vue95
-rw-r--r--src/components/registration/registration.js10
-rw-r--r--src/components/registration/registration.vue4
-rw-r--r--src/components/retweet_button/retweet_button.js5
-rw-r--r--src/components/retweet_button/retweet_button.vue11
-rw-r--r--src/components/settings/settings.js67
-rw-r--r--src/components/settings/settings.vue196
-rw-r--r--src/components/status/status.js136
-rw-r--r--src/components/status/status.vue120
-rw-r--r--src/components/still-image/still-image.js6
-rw-r--r--src/components/still-image/still-image.vue2
-rw-r--r--src/components/style_switcher/style_switcher.js91
-rw-r--r--src/components/style_switcher/style_switcher.vue233
-rw-r--r--src/components/tab_switcher/tab_switcher.jsx44
-rw-r--r--src/components/tab_switcher/tab_switcher.scss43
-rw-r--r--src/components/timeline/timeline.js18
-rw-r--r--src/components/timeline/timeline.vue43
-rw-r--r--src/components/user_card/user_card.js11
-rw-r--r--src/components/user_card/user_card.vue25
-rw-r--r--src/components/user_card_content/user_card_content.js28
-rw-r--r--src/components/user_card_content/user_card_content.vue128
-rw-r--r--src/components/user_panel/user_panel.vue8
-rw-r--r--src/components/user_profile/user_profile.vue2
-rw-r--r--src/components/user_settings/user_settings.js32
-rw-r--r--src/components/user_settings/user_settings.vue222
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.js60
-rw-r--r--src/components/who_to_follow_panel/who_to_follow_panel.vue4
-rw-r--r--src/i18n/messages.js393
-rw-r--r--src/main.js156
-rw-r--r--src/modules/api.js13
-rw-r--r--src/modules/config.js31
-rw-r--r--src/modules/statuses.js191
-rw-r--r--src/modules/users.js8
-rw-r--r--src/services/api/api.service.js63
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js25
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js46
-rw-r--r--src/services/status_poster/status_poster.service.js4
-rw-r--r--src/services/timeline_fetcher/timeline_fetcher.service.js4
-rw-r--r--src/services/user_highlighter/user_highlighter.js48
60 files changed, 2436 insertions, 753 deletions
diff --git a/src/App.js b/src/App.js
index a052e058..be6548f3 100644
--- a/src/App.js
+++ b/src/App.js
@@ -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
+}