Merge remote-tracking branch 'upstream/develop' into shigusegubu

* upstream/develop: (51 commits)
  fix convos
  resolve focus issue in the auto-complete for name field
  resolve issue related to event handler
  resolve issue in prop binding
  this fixes #350 - v-model binding issue
  #341 - fix naming
  #341 - automatic scroll with repeats/reports
  345 - fix long username issue in repeat status
  Update spanish translate - es.json
  Fix return
  Fix clicking link - open new tab unless tag or mention
  Fix max-width of profile banner in setting
  Fix unit test
  Typo
  #332 - add follow/not follow button to follow list
  Add quick css fix for user profile bg img preview
  Switch into Lodash
  Switch into Lodash
  Change the async stuff to not render app before theme is loaded
  fixing conflicts
  ...
This commit is contained in:
Henry Jameson 2019-02-14 20:17:22 +02:00
commit f4e8b781a9
43 changed files with 616 additions and 363 deletions

View file

@ -66,7 +66,7 @@ export default {
}) })
}, },
logo () { return this.$store.state.instance.logo }, logo () { return this.$store.state.instance.logo },
style () { bgStyle () {
return { return {
'--body-background-image': `url(${this.background})`, '--body-background-image': `url(${this.background})`,
'background-image': `url(${this.background})` 'background-image': `url(${this.background})`
@ -82,7 +82,7 @@ export default {
unseenNotificationsCount () { unseenNotificationsCount () {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
showFeaturesPanel () { return this.$store.state.config.showFeaturesPanel } showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
}, },
methods: { methods: {
scrollToTop () { scrollToTop () {

View file

@ -1,15 +1,21 @@
@import './_variables.scss'; @import './_variables.scss';
#app { #app {
background-size: cover;
background-attachment: fixed;
background-repeat: no-repeat;
background-position: 0 50px;
min-height: 100vh; min-height: 100vh;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
} }
.app-bg-wrapper {
position: fixed;
z-index: -1;
height: 100%;
width: 100%;
background-size: cover;
background-repeat: no-repeat;
background-position: 0 50%;
}
i { i {
user-select: none; user-select: none;
} }

View file

@ -1,5 +1,6 @@
<template> <template>
<div id="app" v-bind:style="style"> <div id="app">
<div class="app-bg-wrapper" v-bind:style="bgStyle"></div>
<nav class='nav-bar container' @click="scrollToTop()" id="nav"> <nav class='nav-bar container' @click="scrollToTop()" id="nav">
<div class='logo' :style='logoBgStyle'> <div class='logo' :style='logoBgStyle'>
<div class='mask' :style='logoMaskStyle'></div> <div class='mask' :style='logoMaskStyle'></div>

View file

@ -55,10 +55,10 @@ const afterStoreSetup = ({ store, i18n }) => {
} }
copyInstanceOption('nsfwCensorImage') copyInstanceOption('nsfwCensorImage')
copyInstanceOption('theme')
copyInstanceOption('background') copyInstanceOption('background')
copyInstanceOption('hidePostStats') copyInstanceOption('hidePostStats')
copyInstanceOption('hideUserStats') copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo') copyInstanceOption('logo')
store.dispatch('setInstanceOption', { store.dispatch('setInstanceOption', {
@ -87,6 +87,7 @@ const afterStoreSetup = ({ store, i18n }) => {
copyInstanceOption('postContentType') copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput') copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('noAttachmentLinks') copyInstanceOption('noAttachmentLinks')
copyInstanceOption('showFeaturesPanel')
if ((config.chatDisabled)) { if ((config.chatDisabled)) {
store.dispatch('disableChat') store.dispatch('disableChat')
@ -94,6 +95,9 @@ const afterStoreSetup = ({ store, i18n }) => {
store.dispatch('initializeSocket') store.dispatch('initializeSocket')
} }
return store.dispatch('setTheme', config['theme'])
})
.then(() => {
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', mode: 'history',
routes: routes(store), routes: routes(store),

View file

@ -9,7 +9,7 @@ const About = {
TermsOfServicePanel TermsOfServicePanel
}, },
computed: { computed: {
showFeaturesPanel () { return this.$store.state.config.showFeaturesPanel } showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }
} }
} }

View file

@ -0,0 +1,149 @@
import Completion from '../../services/completion/completion.js'
import { take, filter, map } from 'lodash'
const AutoCompleteInput = {
props: [
'id',
'classObj',
'value',
'placeholder',
'autoResize',
'multiline',
'drop',
'dragoverPrevent',
'paste',
'keydownMetaEnter',
'keyupCtrlEnter'
],
components: {},
mounted () {
this.autoResize && this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
},
data () {
return {
caret: 0,
highlighted: 0
}
},
computed: {
users () {
return this.$store.state.users.users
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
return word
},
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
const query = this.textAtCaret.slice(1).toUpperCase()
const matchedUsers = filter(this.users, (user) => {
return user.screen_name.toUpperCase().startsWith(query) ||
user.name && user.name.toUpperCase().startsWith(query)
})
if (matchedUsers.length <= 0) {
return false
}
// eslint-disable-next-line camelcase
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
// eslint-disable-next-line camelcase
screen_name: `@${screen_name}`,
name: name,
img: profile_image_url_original,
highlighted: index === this.highlighted
}))
} else if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
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) => ({
screen_name: `:${shortcode}:`,
name: '',
utf: utf || '',
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
return false
}
}
},
methods: {
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
},
cycleBackward (e) {
const len = this.candidates.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.candidates.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.candidates.length || 0
if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
} else {
this.highlighted = 0
}
},
replace (replacement) {
this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement))
const el = this.$el.querySelector('textarea') || this.$el.querySelector('input')
el.focus()
this.caret = 0
},
replaceCandidate (e) {
const len = this.candidates.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const candidate = this.candidates[this.highlighted]
const replacement = candidate.utf || (candidate.screen_name + ' ')
this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement))
const el = this.$el.querySelector('textarea') || this.$el.querySelector('input')
el.focus()
this.caret = 0
this.highlighted = 0
}
},
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
if (target.value === '') {
target.style.height = null
}
}
}
}
export default AutoCompleteInput

View file

@ -0,0 +1,104 @@
<template>
<div style="display: flex; flex-direction: column;">
<textarea
v-if="multiline"
ref="textarea"
rows="1"
:value="value" :class="classObj" :id="id" :placeholder="placeholder"
@input="$emit('input', $event.target.value), autoResize && resize($event)"
@click="setCaret"
@keyup="setCaret"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceCandidate"
@drop="drop && drop($event)"
@dragover.prevent="dragoverPrevent && dragoverPrevent($event)"
@paste="paste && paste($event)"
@keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)"
@keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)">
</textarea>
<input
v-else
ref="textarea"
:value="value" :class="classObj" :id="id" :placeholder="placeholder"
@input="$emit('input', $event.target.value), autoResize && resize($event)"
@click="setCaret"
@keyup="setCaret"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceCandidate"
@drop="drop && drop($event)"
@dragover.prevent="dragoverPrevent && dragoverPrevent($event)"
@paste="paste && paste($event)"
@keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)"
@keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)"/>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
</div>
</div>
</div>
</div>
</template>
<script src="./autocomplete_input.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
</style>

View file

@ -9,9 +9,9 @@ const sortById = (a, b) => {
if (isSeqA && isSeqB) { if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1 return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) { } else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1 return -1
} else if (!isSeqA && isSeqB) {
return 1
} else { } else {
return a.id < b.id ? -1 : 1 return a.id < b.id ? -1 : 1
} }
@ -36,6 +36,13 @@ const conversation = {
status () { status () {
return this.statusoid return this.statusoid
}, },
statusId () {
if (this.statusoid.retweeted_status) {
return this.statusoid.retweeted_status.id
} else {
return this.statusoid.id
}
},
conversation () { conversation () {
if (!this.status) { if (!this.status) {
return [] return []
@ -79,7 +86,7 @@ const conversation = {
const conversationId = this.status.statusnet_conversation_id const conversationId = this.status.statusnet_conversation_id
this.$store.state.api.backendInteractor.fetchConversation({id: conversationId}) this.$store.state.api.backendInteractor.fetchConversation({id: conversationId})
.then((statuses) => this.$store.dispatch('addNewStatuses', { statuses })) .then((statuses) => this.$store.dispatch('addNewStatuses', { statuses }))
.then(() => this.setHighlight(this.statusoid.id)) .then(() => this.setHighlight(this.statusId))
} else { } else {
const id = this.$route.params.id const id = this.$route.params.id
this.$store.state.api.backendInteractor.fetchStatus({id}) this.$store.state.api.backendInteractor.fetchStatus({id})
@ -91,11 +98,7 @@ const conversation = {
return this.replies[id] || [] return this.replies[id] || []
}, },
focused (id) { focused (id) {
if (this.statusoid.retweeted_status) { return id === this.statusId
return (id === this.statusoid.retweeted_status.id)
} else {
return (id === this.statusoid.id)
}
}, },
setHighlight (id) { setHighlight (id) {
this.highlight = id this.highlight = id

View file

@ -25,7 +25,8 @@ const FollowList = {
}, },
entries () { entries () {
return this.showFollowers ? this.user.followers : this.user.friends return this.showFollowers ? this.user.followers : this.user.friends
} },
showActions () { return this.$store.state.users.currentUser.id === this.userId }
}, },
methods: { methods: {
fetchEntries () { fetchEntries () {

View file

@ -3,7 +3,8 @@
<user-card <user-card
v-for="entry in entries" v-for="entry in entries"
:key="entry.id" :user="entry" :key="entry.id" :user="entry"
:showFollows="true" :showFollows="!showFollowers"
:showActions="showActions"
/> />
<div class="text-center panel-footer"> <div class="text-center panel-footer">
<a v-if="error" @click="fetchEntries" class="alert error"> <a v-if="error" @click="fetchEntries" class="alert error">

View file

@ -1,8 +1,8 @@
import statusPoster from '../../services/status_poster/status_poster.service.js' import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue' import MediaUpload from '../media_upload/media_upload.vue'
import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import Completion from '../../services/completion/completion.js' import { reject, map, uniqBy } from 'lodash'
import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => { const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions] let allAttentions = [...attentions]
@ -28,13 +28,10 @@ const PostStatusForm = {
'subject' 'subject'
], ],
components: { components: {
MediaUpload MediaUpload,
AutoCompleteInput
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) { if (this.replyTo) {
this.$refs.textarea.focus() this.$refs.textarea.focus()
} }
@ -61,15 +58,13 @@ const PostStatusForm = {
submitDisabled: false, submitDisabled: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0,
newStatus: { newStatus: {
spoilerText: this.subject || '', spoilerText: this.subject || '',
status: statusText, status: statusText,
nsfw: false, nsfw: false,
files: [], files: [],
visibility: scope visibility: scope
}, }
caret: 0
} }
}, },
computed: { computed: {
@ -81,59 +76,6 @@ const PostStatusForm = {
direct: { selected: this.newStatus.visibility === 'direct' } direct: { selected: this.newStatus.visibility === 'direct' }
} }
}, },
candidates () {
const firstchar = this.textAtCaret.charAt(0)
if (firstchar === '@') {
const query = this.textAtCaret.slice(1).toUpperCase()
const matchedUsers = filter(this.users, (user) => {
return user.screen_name.toUpperCase().startsWith(query) ||
user.name && user.name.toUpperCase().startsWith(query)
})
if (matchedUsers.length <= 0) {
return false
}
// eslint-disable-next-line camelcase
return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({
// eslint-disable-next-line camelcase
screen_name: `@${screen_name}`,
name: name,
img: profile_image_url_original,
highlighted: index === this.highlighted
}))
} else if (firstchar === ':') {
if (this.textAtCaret === ':') { return }
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) => ({
screen_name: `:${shortcode}:`,
name: '',
utf: utf || '',
// eslint-disable-next-line camelcase
img: utf ? '' : this.$store.state.instance.server + image_url,
highlighted: index === this.highlighted
}))
} else {
return false
}
},
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret () {
const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
return word
},
users () {
return this.$store.state.users.users
},
emoji () {
return this.$store.state.instance.emoji || []
},
customEmoji () {
return this.$store.state.instance.customEmoji || []
},
statusLength () { statusLength () {
return this.newStatus.status.length return this.newStatus.status.length
}, },
@ -174,53 +116,8 @@ const PostStatusForm = {
} }
}, },
methods: { methods: {
replace (replacement) { postStatusCopy () {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) this.postStatus(this.newStatus)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
},
replaceCandidate (e) {
const len = this.candidates.length || 0
if (this.textAtCaret === ':' || e.ctrlKey) { return }
if (len > 0) {
e.preventDefault()
const candidate = this.candidates[this.highlighted]
const replacement = candidate.utf || (candidate.screen_name + ' ')
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
const el = this.$el.querySelector('textarea')
el.focus()
this.caret = 0
this.highlighted = 0
}
},
cycleBackward (e) {
const len = this.candidates.length || 0
if (len > 0) {
e.preventDefault()
this.highlighted -= 1
if (this.highlighted < 0) {
this.highlighted = this.candidates.length - 1
}
} else {
this.highlighted = 0
}
},
cycleForward (e) {
const len = this.candidates.length || 0
if (len > 0) {
if (e.shiftKey) { return }
e.preventDefault()
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = 0
}
} else {
this.highlighted = 0
}
},
setCaret ({target: {selectionStart}}) {
this.caret = selectionStart
}, },
postStatus (newStatus) { postStatus (newStatus) {
if (this.posting) { return } if (this.posting) { return }
@ -303,18 +200,6 @@ const PostStatusForm = {
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = 'copy'
}, },
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) { return }
const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) +
Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1))
// Auto is needed to make textbox shrink when removing lines
target.style.height = 'auto'
target.style.height = `${target.scrollHeight - vertPadding}px`
if (target.value === '') {
target.style.height = null
}
},
clearError () { clearError () {
this.error = null this.error = null
}, },

View file

@ -16,22 +16,16 @@
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
class="form-cw"> class="form-cw">
<textarea <auto-complete-input v-model="newStatus.status"
ref="textarea" :classObj="{ 'form-control': true }"
@click="setCaret" :placeholder="$t('post_status.default')"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" :autoResize="true"
@keydown.down="cycleForward" :multiline="true"
@keydown.up="cycleBackward" :drop="fileDrop"
@keydown.shift.tab="cycleBackward" :dragoverPrevent="fileDrag"
@keydown.tab="cycleForward" :paste="paste"
@keydown.enter="replaceCandidate" :keydownMetaEnter="postStatusCopy"
@keydown.meta.enter="postStatus(newStatus)" :keyupCtrlEnter="postStatusCopy"/>
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@paste="paste">
</textarea>
<div class="visibility-tray"> <div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled"> <span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select"> <label for="post-content-type" class="select">
@ -52,17 +46,6 @@
</div> </div>
</div> </div>
</div> </div>
<div style="position:relative;" v-if="candidates">
<div class="autocomplete-panel">
<div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
<div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
<span v-if="candidate.img"><img :src="candidate.img"></img></span>
<span v-else>{{candidate.utf}}</span>
<span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
</div>
</div>
</div>
</div>
<div class='form-bottom'> <div class='form-bottom'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
@ -250,52 +233,5 @@
cursor: pointer; cursor: pointer;
z-index: 4; z-index: 4;
} }
.autocomplete-panel {
margin: 0 0.5em 0 0.5em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
position: absolute;
z-index: 1;
box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
// this doesn't match original but i don't care, making it uniform.
box-shadow: var(--popupShadow);
min-width: 75%;
background: $fallback--bg;
background: var(--bg, $fallback--bg);
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.autocomplete {
cursor: pointer;
padding: 0.2em 0.4em 0.2em 0.4em;
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
display: flex;
img {
width: 24px;
height: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
object-fit: contain;
}
span {
line-height: 24px;
margin: 0 0.1em 0 0.2em;
}
small {
margin-left: .5em;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
&.highlighted {
background-color: $fallback--fg;
background-color: var(--lightBg, $fallback--fg);
}
}
} }
</style> </style>

View file

@ -7,7 +7,7 @@ const PublicAndExternalTimeline = {
timeline () { return this.$store.state.statuses.timelines.publicAndExternal } timeline () { return this.$store.state.statuses.timelines.publicAndExternal }
}, },
created () { created () {
this.$store.dispatch('startFetching', 'publicAndExternal') this.$store.dispatch('startFetching', { timeline: 'publicAndExternal' })
}, },
destroyed () { destroyed () {
this.$store.dispatch('stopFetching', 'publicAndExternal') this.$store.dispatch('stopFetching', 'publicAndExternal')

View file

@ -7,7 +7,7 @@ const PublicTimeline = {
timeline () { return this.$store.state.statuses.timelines.public } timeline () { return this.$store.state.statuses.timelines.public }
}, },
created () { created () {
this.$store.dispatch('startFetching', 'public') this.$store.dispatch('startFetching', { timeline: 'public' })
}, },
destroyed () { destroyed () {
this.$store.dispatch('stopFetching', 'public') this.$store.dispatch('stopFetching', 'public')

View file

@ -27,6 +27,11 @@ const settings = {
: user.hideUserStats, : user.hideUserStats,
hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats), hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats),
hideFilteredStatusesLocal: typeof user.hideFilteredStatuses === 'undefined'
? instance.hideFilteredStatuses
: user.hideFilteredStatuses,
hideFilteredStatusesDefault: this.$t('settings.values.' + instance.hideFilteredStatuses),
notificationVisibilityLocal: user.notificationVisibility, notificationVisibilityLocal: user.notificationVisibility,
replyVisibilityLocal: user.replyVisibility, replyVisibilityLocal: user.replyVisibility,
loopVideoLocal: user.loopVideo, loopVideoLocal: user.loopVideo,
@ -101,6 +106,9 @@ const settings = {
hideUserStatsLocal (value) { hideUserStatsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideUserStats', value }) this.$store.dispatch('setOption', { name: 'hideUserStats', value })
}, },
hideFilteredStatusesLocal (value) {
this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
},
hideNsfwLocal (value) { hideNsfwLocal (value) {
this.$store.dispatch('setOption', { name: 'hideNsfw', value }) this.$store.dispatch('setOption', { name: 'hideNsfw', value })
}, },

View file

@ -227,7 +227,6 @@
</label> </label>
</li> </li>
</ul> </ul>
</label>
</div> </div>
<div> <div>
{{$t('settings.replies_in_timeline')}} {{$t('settings.replies_in_timeline')}}
@ -254,11 +253,18 @@
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div>
<p>{{$t('settings.filtering_explanation')}}</p> <p>{{$t('settings.filtering_explanation')}}</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea> <textarea id="muteWords" v-model="muteWordsString"></textarea>
</div> </div>
<div>
<input type="checkbox" id="hideFilteredStatuses" v-model="hideFilteredStatusesLocal">
<label for="hideFilteredStatuses">
{{$t('settings.hide_filtered_statuses')}} {{$t('settings.instance_default', { value: hideFilteredStatusesDefault })}}
</label>
</div>
</div>
</div> </div>
</tab-switcher> </tab-switcher>
</keep-alive> </keep-alive>
</div> </div>

View file

@ -10,8 +10,8 @@ import LinkPreview from '../link-preview/link-preview.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service' import fileType from 'src/services/file_type/file_type.service'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { mentionMatchesUrl } from 'src/services/mention_matcher/mention_matcher.js' import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { filter, find } from 'lodash' import { filter, find, unescape } from 'lodash'
const Status = { const Status = {
name: 'Status', name: 'Status',
@ -110,6 +110,14 @@ const Status = {
return hits return hits
}, },
muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) }, muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) },
hideFilteredStatuses () {
return typeof this.$store.state.config.hideFilteredStatuses === 'undefined'
? this.$store.state.instance.hideFilteredStatuses
: this.$store.state.config.hideFilteredStatuses
},
hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses)
},
isFocused () { isFocused () {
// retweet or root of an expanded conversation // retweet or root of an expanded conversation
if (this.focused) { if (this.focused) {
@ -201,14 +209,15 @@ const Status = {
}, },
replySubject () { replySubject () {
if (!this.status.summary) return '' if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined' const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined'
? this.$store.state.instance.subjectLineBehavior ? this.$store.state.instance.subjectLineBehavior
: this.$store.state.config.subjectLineBehavior : this.$store.state.config.subjectLineBehavior
const startsWithRe = this.status.summary.match(/^re[: ]/i) const startsWithRe = decodedSummary.match(/^re[: ]/i)
if (behavior !== 'noop' && startsWithRe || behavior === 'masto') { if (behavior !== 'noop' && startsWithRe || behavior === 'masto') {
return this.status.summary return decodedSummary
} else if (behavior === 'email') { } else if (behavior === 'email') {
return 're: '.concat(this.status.summary) return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') { } else if (behavior === 'noop') {
return '' return ''
} }
@ -273,7 +282,7 @@ const Status = {
} }
if (target.tagName === 'A') { if (target.tagName === 'A') {
if (target.className.match(/mention/)) { if (target.className.match(/mention/)) {
const href = target.getAttribute('href') const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) { if (attn) {
event.stopPropagation() event.stopPropagation()
@ -283,6 +292,15 @@ const Status = {
return return
} }
} }
if (target.className.match(/hashtag/)) {
// Extract tag name from link url
const tag = extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
return
}
}
window.open(target.href, '_blank') window.open(target.href, '_blank')
} }
}, },
@ -339,6 +357,9 @@ const Status = {
generateUserProfileLink (id, name) { generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
}, },
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () { setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments) return () => this.$store.dispatch('setMedia', attachments)

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="status-el" v-if="!hideReply && !deleted" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
<template v-if="muted && !noReplyLinks"> <template v-if="muted && !noReplyLinks">
<div class="media status container muted"> <div class="media status container muted">
<small> <small>
@ -56,7 +56,7 @@
</div> </div>
<h4 class="replies" v-if="inConversation && !noReplyLinks"> <h4 class="replies" v-if="inConversation && !noReplyLinks">
<small v-if="replies.length">Replies:</small> <small v-if="replies.length">Replies:</small>
<small class="reply-link" v-for="reply in replies"> <small class="reply-link" v-bind:key="reply.id" v-for="reply in replies">
<a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}&nbsp;</a> <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}&nbsp;</a>
</small> </small>
</h4> </h4>
@ -438,6 +438,8 @@
.user-name { .user-name {
font-weight: bold; font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
img { img {
width: 14px; width: 14px;

View file

@ -37,7 +37,7 @@ export default Vue.component('tab-switcher', {
return ( return (
<div class={ classesWrapper.join(' ')}> <div class={ classesWrapper.join(' ')}>
<button onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> <button disabled={slot.data.attrs.disabled} onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button>
</div> </div>
) )
}) })

View file

@ -3,7 +3,7 @@ import Timeline from '../timeline/timeline.vue'
const TagTimeline = { const TagTimeline = {
created () { created () {
this.$store.commit('clearTimeline', { timeline: 'tag' }) this.$store.commit('clearTimeline', { timeline: 'tag' })
this.$store.dispatch('startFetching', { 'tag': this.tag }) this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag })
}, },
components: { components: {
Timeline Timeline
@ -15,7 +15,7 @@ const TagTimeline = {
watch: { watch: {
tag () { tag () {
this.$store.commit('clearTimeline', { timeline: 'tag' }) this.$store.commit('clearTimeline', { timeline: 'tag' })
this.$store.dispatch('startFetching', { 'tag': this.tag }) this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag })
} }
}, },
destroyed () { destroyed () {

View file

@ -1,16 +1,21 @@
import UserCardContent from '../user_card_content/user_card_content.vue' import UserCardContent from '../user_card_content/user_card_content.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
const UserCard = { const UserCard = {
props: [ props: [
'user', 'user',
'showFollows', 'showFollows',
'showApproval' 'showApproval',
'showActions'
], ],
data () { data () {
return { return {
userExpanded: false userExpanded: false,
followRequestInProgress: false,
followRequestSent: false,
updated: false
} }
}, },
components: { components: {
@ -18,7 +23,11 @@ const UserCard = {
UserAvatar UserAvatar
}, },
computed: { computed: {
currentUser () { return this.$store.state.users.currentUser } currentUser () { return this.$store.state.users.currentUser },
following () { return this.updated ? this.updated.following : this.user.following },
showFollow () {
return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following)
}
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {
@ -34,6 +43,21 @@ const UserCard = {
}, },
userProfileLink (user) { userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
},
followUser () {
this.followRequestInProgress = true
requestFollow(this.user, this.$store).then(({ sent, updated }) => {
this.followRequestInProgress = false
this.followRequestSent = sent
this.updated = updated
})
},
unfollowUser () {
this.followRequestInProgress = true
requestUnfollow(this.user, this.$store).then(({ updated }) => {
this.followRequestInProgress = false
this.updated = updated
})
} }
} }
} }

View file

@ -7,22 +7,43 @@
<user-card-content :user="user" :switcher="false"></user-card-content> <user-card-content :user="user" :switcher="false"></user-card-content>
</div> </div>
<div class="name-and-screen-name" v-else> <div class="name-and-screen-name" v-else>
<div :title="user.name" v-if="user.name_html" class="user-name"> <div :title="user.name" class="user-name">
<span v-html="user.name_html"></span> <span v-if="user.name_html" v-html="user.name_html"></span>
<span v-else>{{ user.name }}</span>
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you">
{{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span> </span>
</div> </div>
<div :title="user.name" v-else class="user-name"> <div class="user-link-action">
{{ user.name }}
<span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you">
{{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
</div>
<router-link class='user-screen-name' :to="userProfileLink(user)"> <router-link class='user-screen-name' :to="userProfileLink(user)">
@{{user.screen_name}} @{{user.screen_name}}
</router-link> </router-link>
<button
v-if="showFollow"
class="btn btn-default"
@click="followUser"
:disabled="followRequestInProgress"
:title="followRequestSent ? $t('user_card.follow_again') : ''"
>
<template v-if="followRequestInProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else-if="followRequestSent">
{{ $t('user_card.follow_sent') }}
</template>
<template v-else>
{{ $t('user_card.follow') }}
</template>
</button>
<button v-if="showActions && showFollows && following" class="btn btn-default" @click="unfollowUser" :disabled="followRequestInProgress">
<template v-if="followRequestInProgress">
{{ $t('user_card.follow_progress') }}
</template>
<template v-else>
{{ $t('user_card.follow_unfollow') }}
</template>
</button>
</div>
</div> </div>
<div class="approval" v-if="showApproval"> <div class="approval" v-if="showApproval">
<button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button>
@ -42,6 +63,9 @@
text-align: left; text-align: left;
width: 100%; width: 100%;
.user-name { .user-name {
display: flex;
justify-content: space-between;
img { img {
object-fit: contain; object-fit: contain;
height: 16px; height: 16px;
@ -49,11 +73,20 @@
vertical-align: middle; vertical-align: middle;
} }
} }
.user-link-action {
display: flex;
align-items: flex-start;
justify-content: space-between;
button {
margin-top: 3px;
}
}
} }
.follows-you { .follows-you {
margin-left: 2em; margin-left: 2em;
float: right;
} }
.card { .card {

View file

@ -1,5 +1,6 @@
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js' import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
export default { export default {
@ -92,67 +93,15 @@ export default {
}, },
methods: { methods: {
followUser () { followUser () {
const store = this.$store
this.followRequestInProgress = true this.followRequestInProgress = true
store.state.api.backendInteractor.followUser(this.user.id) requestFollow(this.user, this.$store).then(({sent}) => {
.then((followedUser) => store.commit('addNewUsers', [followedUser]))
.then(() => {
// For locked users we just mark it that we sent the follow request
if (this.user.locked) {
this.followRequestInProgress = false this.followRequestInProgress = false
this.followRequestSent = true this.followRequestSent = sent
return
}
if (this.user.following) {
// If we get result immediately, just stop.
this.followRequestInProgress = false
return
}
// But usually we don't get result immediately, so we ask server
// for updated user profile to confirm if we are following them
// Sometimes it takes several tries. Sometimes we end up not following
// user anyway, probably because they locked themselves and we
// don't know that yet.
// Recursive Promise, it will call itself up to 3 times.
const fetchUser = (attempt) => new Promise((resolve, reject) => {
setTimeout(() => {
store.state.api.backendInteractor.fetchUser({ id: this.user.id })
.then((user) => store.commit('addNewUsers', [user]))
.then(() => resolve([this.user.following, attempt]))
.catch((e) => reject(e))
}, 500)
}).then(([following, attempt]) => {
if (!following && attempt <= 3) {
// If we BE reports that we still not following that user - retry,
// increment attempts by one
return fetchUser(++attempt)
} else {
// If we run out of attempts, just return whatever status is.
return following
}
})
return fetchUser(1)
.then((following) => {
if (following) {
// We confirmed and everything its good.
this.followRequestInProgress = false
} else {
// If after all the tries, just treat it as if user is locked
this.followRequestInProgress = false
this.followRequestSent = true
}
})
}) })
}, },
unfollowUser () { unfollowUser () {
const store = this.$store
this.followRequestInProgress = true this.followRequestInProgress = true
store.state.api.backendInteractor.unfollowUser(this.user.id) requestUnfollow(this.user, this.$store).then(() => {
.then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser]))
.then(() => {
this.followRequestInProgress = false this.followRequestInProgress = false
}) })
}, },

View file

@ -8,8 +8,8 @@ const UserProfile = {
this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'user' })
this.$store.commit('clearTimeline', { timeline: 'favorites' }) this.$store.commit('clearTimeline', { timeline: 'favorites' })
this.$store.commit('clearTimeline', { timeline: 'media' }) this.$store.commit('clearTimeline', { timeline: 'media' })
this.$store.dispatch('startFetching', ['user', this.fetchBy]) this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', ['media', this.fetchBy]) this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites() this.startFetchFavorites()
if (!this.user.id) { if (!this.user.id) {
this.$store.dispatch('fetchUser', this.fetchBy) this.$store.dispatch('fetchUser', this.fetchBy)
@ -69,12 +69,12 @@ const UserProfile = {
methods: { methods: {
startFetchFavorites () { startFetchFavorites () {
if (this.isUs) { if (this.isUs) {
this.$store.dispatch('startFetching', ['favorites', this.fetchBy]) this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy })
} }
}, },
startUp () { startUp () {
this.$store.dispatch('startFetching', ['user', this.fetchBy]) this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy })
this.$store.dispatch('startFetching', ['media', this.fetchBy]) this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy })
this.startFetchFavorites() this.startFetchFavorites()
}, },

View file

@ -9,19 +9,20 @@
<tab-switcher :renderOnlyFocused="true"> <tab-switcher :renderOnlyFocused="true">
<Timeline <Timeline
:label="$t('user_card.statuses')" :label="$t('user_card.statuses')"
:disabled="!user.statuses_count"
:embedded="true" :embedded="true"
:title="$t('user_profile.timeline_title')" :title="$t('user_profile.timeline_title')"
:timeline="timeline" :timeline="timeline"
:timeline-name="'user'" :timeline-name="'user'"
:user-id="fetchBy" :user-id="fetchBy"
/> />
<div :label="$t('user_card.followees')" v-if="followsTabVisible"> <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count">
<FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" /> <FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" />
<div class="userlist-placeholder" v-else> <div class="userlist-placeholder" v-else>
<i class="icon-spin3 animate-spin"></i> <i class="icon-spin3 animate-spin"></i>
</div> </div>
</div> </div>
<div :label="$t('user_card.followers')" v-if="followersTabVisible"> <div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count">
<FollowList v-if="user.followers_count > 0" :userId="userId" :showFollowers="true" /> <FollowList v-if="user.followers_count > 0" :userId="userId" :showFollowers="true" />
<div class="userlist-placeholder" v-else> <div class="userlist-placeholder" v-else>
<i class="icon-spin3 animate-spin"></i> <i class="icon-spin3 animate-spin"></i>
@ -29,6 +30,7 @@
</div> </div>
<Timeline <Timeline
:label="$t('user_card.media')" :label="$t('user_card.media')"
:disabled="!media.visibleStatuses.length"
:embedded="true" :title="$t('user_card.media')" :embedded="true" :title="$t('user_card.media')"
timeline-name="media" timeline-name="media"
:timeline="media" :timeline="media"
@ -37,6 +39,7 @@
<Timeline <Timeline
v-if="isUs" v-if="isUs"
:label="$t('user_card.favorites')" :label="$t('user_card.favorites')"
:disabled="!favorites.visibleStatuses.length"
:embedded="true" :embedded="true"
:title="$t('user_card.favorites')" :title="$t('user_card.favorites')"
timeline-name="favorites" timeline-name="favorites"

View file

@ -2,6 +2,7 @@ import { unescape } from 'lodash'
import TabSwitcher from '../tab_switcher/tab_switcher.js' import TabSwitcher from '../tab_switcher/tab_switcher.js'
import StyleSwitcher from '../style_switcher/style_switcher.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue'
import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue'
import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
const UserSettings = { const UserSettings = {
@ -41,7 +42,8 @@ const UserSettings = {
}, },
components: { components: {
StyleSwitcher, StyleSwitcher,
TabSwitcher TabSwitcher,
AutoCompleteInput
}, },
computed: { computed: {
user () { user () {

View file

@ -9,9 +9,9 @@
<div class="setting-item" > <div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2> <h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p> <p>{{$t('settings.name')}}</p>
<input class='name-changer' id='username' v-model="newName"></input> <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/>
<p>{{$t('settings.bio')}}</p> <p>{{$t('settings.bio')}}</p>
<textarea class="bio" v-model="newBio"></textarea> <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/>
<p> <p>
<input type="checkbox" v-model="newLocked" id="account-locked"> <input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>
@ -178,7 +178,7 @@
} }
.banner { .banner {
max-width: 400px; max-width: 100%;
} }
.uploading { .uploading {
@ -189,5 +189,9 @@
.name-changer { .name-changer {
width: 100%; width: 100%;
} }
.bg {
max-width: 100%;
}
} }
</style> </style>

View file

@ -132,6 +132,7 @@
"preload_images": "Bilder vorausladen", "preload_images": "Bilder vorausladen",
"hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)", "hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)",
"hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)", "hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)",
"hide_filtered_statuses": "Gefilterte Beiträge verbergen",
"import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei", "import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei",
"import_theme": "Farbschema laden", "import_theme": "Farbschema laden",
"inputRadius": "Eingabefelder", "inputRadius": "Eingabefelder",

View file

@ -140,6 +140,7 @@
"use_one_click_nsfw": "Open NSFW attachments with just one click", "use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)", "hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)", "hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide filtered statuses",
"import_followers_from_a_csv_file": "Import follows from a csv file", "import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset", "import_theme": "Load preset",
"inputRadius": "Input fields", "inputRadius": "Input fields",

View file

@ -28,7 +28,8 @@
"password": "Contraseña", "password": "Contraseña",
"placeholder": "p.ej. lain", "placeholder": "p.ej. lain",
"register": "Registrar", "register": "Registrar",
"username": "Usuario" "username": "Usuario",
"hint": "Inicia sesión para unirte a la discusión"
}, },
"nav": { "nav": {
"about": "Sobre", "about": "Sobre",
@ -55,7 +56,7 @@
"no_more_notifications": "No hay más notificaciones" "no_more_notifications": "No hay más notificaciones"
}, },
"post_status": { "post_status": {
"new_status": "Post new status", "new_status": "Publicar un nuevo estado",
"account_not_locked_warning": "Tu cuenta no está {0}. Cualquiera puede seguirte y leer las entradas para Solo-Seguidores.", "account_not_locked_warning": "Tu cuenta no está {0}. Cualquiera puede seguirte y leer las entradas para Solo-Seguidores.",
"account_not_locked_warning_link": "bloqueada", "account_not_locked_warning_link": "bloqueada",
"attachments_sensitive": "Contenido sensible", "attachments_sensitive": "Contenido sensible",
@ -139,6 +140,7 @@
"use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.", "use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.",
"hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)", "hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)",
"hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)", "hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)",
"hide_filtered_statuses": "Ocultar estados filtrados",
"import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv", "import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv",
"import_theme": "Importar tema", "import_theme": "Importar tema",
"inputRadius": "Campos de entrada", "inputRadius": "Campos de entrada",
@ -164,7 +166,10 @@
"notification_visibility_mentions": "Menciones", "notification_visibility_mentions": "Menciones",
"notification_visibility_repeats": "Repeticiones (Repeats)", "notification_visibility_repeats": "Repeticiones (Repeats)",
"no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas", "no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas",
"hide_network_description": "No mostrar a quién sigo, ni quién me sigue", "hide_follows_description": "No mostrar a quién sigo",
"hide_followers_description": "No mostrar quién me sigue",
"show_admin_badge": "Mostrar la placa de administrador en mi perfil",
"show_moderator_badge": "Mostrar la placa de moderador en mi perfil",
"nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW", "nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW",
"panelRadius": "Paneles", "panelRadius": "Paneles",
"pause_on_unfocused": "Parar la transmisión cuando no estés en foco.", "pause_on_unfocused": "Parar la transmisión cuando no estés en foco.",
@ -191,6 +196,8 @@
"subject_line_email": "Tipo email: \"re: tema\"", "subject_line_email": "Tipo email: \"re: tema\"",
"subject_line_mastodon": "Tipo mastodon: copiar como es", "subject_line_mastodon": "Tipo mastodon: copiar como es",
"subject_line_noop": "No copiar", "subject_line_noop": "No copiar",
"post_status_content_type": "Formato de publicación",
"status_content_type_plain": "Texto plano",
"stop_gifs": "Iniciar GIFs al pasar el ratón", "stop_gifs": "Iniciar GIFs al pasar el ratón",
"streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior", "streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior",
"text": "Texto", "text": "Texto",

View file

@ -48,7 +48,7 @@ export default function createPersistedState ({
return getState(key, storage).then((savedState) => { return getState(key, storage).then((savedState) => {
return store => { return store => {
try { try {
if (typeof savedState === 'object') { if (savedState !== null && typeof savedState === 'object') {
// build user cache // build user cache
const usersState = savedState.users || {} const usersState = savedState.users || {}
usersState.usersObject = {} usersState.usersObject = {}

View file

@ -1,5 +1,4 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import {isArray} from 'lodash'
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
const api = { const api = {
@ -34,20 +33,12 @@ const api = {
} }
}, },
actions: { actions: {
startFetching (store, timeline) { startFetching (store, {timeline = 'friends', tag = false, userId = false}) {
let userId = false
// This is for user timelines
if (isArray(timeline)) {
userId = timeline[1]
timeline = timeline[0]
}
// Don't start fetching if we already are. // Don't start fetching if we already are.
if (!store.state.fetchers[timeline]) { if (store.state.fetchers[timeline]) return
const fetcher = store.state.backendInteractor.startFetching({timeline, store, userId})
const fetcher = store.state.backendInteractor.startFetching({ timeline, store, userId, tag })
store.commit('addFetcher', { timeline, fetcher }) store.commit('addFetcher', { timeline, fetcher })
}
}, },
stopFetching (store, timeline) { stopFetching (store, timeline) {
const fetcher = store.state.fetchers[timeline] const fetcher = store.state.fetchers[timeline]

View file

@ -31,7 +31,6 @@ const defaultState = {
scopeCopy: undefined, // instance default scopeCopy: undefined, // instance default
subjectLineBehavior: undefined, // instance default subjectLineBehavior: undefined, // instance default
alwaysShowSubjectInput: undefined, // instance default alwaysShowSubjectInput: undefined, // instance default
showFeaturesPanel: true,
postContentType: undefined // instance default postContentType: undefined // instance default
} }

View file

@ -21,6 +21,7 @@ const defaultState = {
collapseMessageWithSubject: false, collapseMessageWithSubject: false,
hidePostStats: false, hidePostStats: false,
hideUserStats: false, hideUserStats: false,
hideFilteredStatuses: true,
disableChat: false, disableChat: false,
scopeCopy: true, scopeCopy: true,
subjectLineBehavior: 'email', subjectLineBehavior: 'email',
@ -29,6 +30,7 @@ const defaultState = {
nsfwCensorImage: undefined, nsfwCensorImage: undefined,
vapidPublicKey: undefined, vapidPublicKey: undefined,
noAttachmentLinks: false, noAttachmentLinks: false,
showFeaturesPanel: true,
// Nasty stuff // Nasty stuff
pleromaBackend: true, pleromaBackend: true,
@ -64,9 +66,11 @@ const instance = {
case 'name': case 'name':
dispatch('setPageTitle') dispatch('setPageTitle')
break break
case 'theme':
setPreset(value, commit)
} }
},
setTheme ({ commit }, themeName) {
commit('setInstanceOption', { name: 'theme', value: themeName })
return setPreset(themeName, commit)
} }
} }
} }

View file

@ -271,7 +271,7 @@ const users = {
} }
// Start getting fresh posts. // Start getting fresh posts.
store.dispatch('startFetching', 'friends') store.dispatch('startFetching', { timeline: 'friends' })
// Get user mutes and follower info // Get user mutes and follower info
store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => {

View file

@ -0,0 +1,74 @@
const fetchUser = (attempt, user, store) => new Promise((resolve, reject) => {
setTimeout(() => {
store.state.api.backendInteractor.fetchUser({ id: user.id })
.then((user) => store.commit('addNewUsers', [user]))
.then(() => resolve([user.following, attempt]))
.catch((e) => reject(e))
}, 500)
}).then(([following, attempt]) => {
if (!following && attempt <= 3) {
// If we BE reports that we still not following that user - retry,
// increment attempts by one
return fetchUser(++attempt, user, store)
} else {
// If we run out of attempts, just return whatever status is.
return following
}
})
export const requestFollow = (user, store) => new Promise((resolve, reject) => {
store.state.api.backendInteractor.followUser(user.id)
.then((updated) => {
store.commit('addNewUsers', [updated])
// For locked users we just mark it that we sent the follow request
if (updated.locked) {
resolve({
sent: true,
updated
})
}
if (updated.following) {
// If we get result immediately, just stop.
resolve({
sent: false,
updated
})
}
// But usually we don't get result immediately, so we ask server
// for updated user profile to confirm if we are following them
// Sometimes it takes several tries. Sometimes we end up not following
// user anyway, probably because they locked themselves and we
// don't know that yet.
// Recursive Promise, it will call itself up to 3 times.
return fetchUser(1, user, store)
.then((following) => {
if (following) {
// We confirmed and everything's good.
resolve({
sent: false,
updated
})
} else {
// If after all the tries, just treat it as if user is locked
resolve({
sent: false,
updated
})
}
})
})
})
export const requestUnfollow = (user, store) => new Promise((resolve, reject) => {
store.state.api.backendInteractor.unfollowUser(user.id)
.then((updated) => {
store.commit('addNewUsers', [updated])
resolve({
updated
})
})
})

View file

@ -0,0 +1,23 @@
export const mentionMatchesUrl = (attention, url) => {
if (url === attention.statusnet_profile_url) {
return true
}
const [namepart, instancepart] = attention.screen_name.split('@')
const matchstring = new RegExp('://' + instancepart + '/.*' + namepart + '$', 'g')
return !!url.match(matchstring)
}
/**
* Extract tag name from pleroma or mastodon url.
* i.e https://bikeshed.party/tag/photo or https://quey.org/tags/sky
* @param {string} url
*/
export const extractTagFromUrl = (url) => {
const regex = /tag[s]*\/(\w+)$/g
const result = regex.exec(url)
if (!result) {
return false
}
return result[1]
}

View file

@ -1,9 +0,0 @@
export const mentionMatchesUrl = (attention, url) => {
if (url === attention.statusnet_profile_url) {
return true
}
const [namepart, instancepart] = attention.screen_name.split('@')
const matchstring = new RegExp('://' + instancepart + '/.*' + namepart + '$', 'g')
return !!url.match(matchstring)
}

View file

@ -480,7 +480,7 @@ const getThemes = () => {
} }
const setPreset = (val, commit) => { const setPreset = (val, commit) => {
getThemes().then((themes) => { return getThemes().then((themes) => {
const theme = themes[val] ? themes[val] : themes['pleroma-dark'] const theme = themes[val] ? themes[val] : themes['pleroma-dark']
const isV1 = Array.isArray(theme) const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme const data = isV1 ? {} : theme.theme

View file

@ -21,5 +21,6 @@
"loginMethod": "password", "loginMethod": "password",
"webPushNotifications": false, "webPushNotifications": false,
"noAttachmentLinks": false, "noAttachmentLinks": false,
"nsfwCensorImage": "" "nsfwCensorImage": "",
"showFeaturesPanel": true
} }

View file

@ -241,7 +241,7 @@ describe('API Entities normalizer', () => {
notice: makeMockStatusQvitter({ id: 444 }), notice: makeMockStatusQvitter({ id: 444 }),
from_profile: makeMockUserQvitter({ id: 'spurdo' }) from_profile: makeMockUserQvitter({ id: 'spurdo' })
}) })
expect(parseNotification(notif)).to.have.property('id', '123') expect(parseNotification(notif)).to.have.property('id', 123)
expect(parseNotification(notif)).to.have.property('seen', false) expect(parseNotification(notif)).to.have.property('seen', false)
expect(parseNotification(notif)).to.have.deep.property('status.id', '444') expect(parseNotification(notif)).to.have.deep.property('status.id', '444')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444') expect(parseNotification(notif)).to.have.deep.property('action.id', '444')
@ -259,7 +259,7 @@ describe('API Entities normalizer', () => {
is_seen: 1, is_seen: 1,
from_profile: makeMockUserQvitter({ id: 'spurdo' }) from_profile: makeMockUserQvitter({ id: 'spurdo' })
}) })
expect(parseNotification(notif)).to.have.property('id', '123') expect(parseNotification(notif)).to.have.property('id', 123)
expect(parseNotification(notif)).to.have.property('type', 'like') expect(parseNotification(notif)).to.have.property('type', 'like')
expect(parseNotification(notif)).to.have.property('seen', true) expect(parseNotification(notif)).to.have.property('seen', true)
expect(parseNotification(notif)).to.have.deep.property('status.id', '4412') expect(parseNotification(notif)).to.have.deep.property('status.id', '4412')

View file

@ -1,4 +1,4 @@
import * as MentionMatcher from 'src/services/mention_matcher/mention_matcher.js' import * as MatcherService from 'src/services/matcher/matcher.service.js'
const localAttn = () => ({ const localAttn = () => ({
id: 123, id: 123,
@ -16,48 +16,67 @@ const externalAttn = () => ({
statusnet_profile_url: 'https://instance.com/users/person' statusnet_profile_url: 'https://instance.com/users/person'
}) })
describe('MentionMatcher', () => { describe('MatcherService', () => {
describe.only('mentionMatchesUrl', () => { describe('mentionMatchesUrl', () => {
it('should match local mention', () => { it('should match local mention', () => {
const attention = localAttn() const attention = localAttn()
const url = 'https://instance.com/users/person' const url = 'https://instance.com/users/person'
expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(true) expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(true)
}) })
it('should not match a local mention with same name but different instance', () => { it('should not match a local mention with same name but different instance', () => {
const attention = localAttn() const attention = localAttn()
const url = 'https://website.com/users/person' const url = 'https://website.com/users/person'
expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(false) expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(false)
}) })
it('should match external pleroma mention', () => { it('should match external pleroma mention', () => {
const attention = externalAttn() const attention = externalAttn()
const url = 'https://instance.com/users/person' const url = 'https://instance.com/users/person'
expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(true) expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(true)
}) })
it('should not match external pleroma mention with same name but different instance', () => { it('should not match external pleroma mention with same name but different instance', () => {
const attention = externalAttn() const attention = externalAttn()
const url = 'https://website.com/users/person' const url = 'https://website.com/users/person'
expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(false) expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(false)
}) })
it('should match external mastodon mention', () => { it('should match external mastodon mention', () => {
const attention = externalAttn() const attention = externalAttn()
const url = 'https://instance.com/@person' const url = 'https://instance.com/@person'
expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(true) expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(true)
}) })
it('should not match external mastodon mention with same name but different instance', () => { it('should not match external mastodon mention with same name but different instance', () => {
const attention = externalAttn() const attention = externalAttn()
const url = 'https://website.com/@person' const url = 'https://website.com/@person'
expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(false) expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(false)
})
})
describe('extractTagFromUrl', () => {
it('should return tag name from valid pleroma url', () => {
const url = 'https://website.com/tag/photo'
expect(MatcherService.extractTagFromUrl(url)).to.eql('photo')
})
it('should return tag name from valid mastodon url', () => {
const url = 'https://website.com/tags/sky'
expect(MatcherService.extractTagFromUrl(url)).to.eql('sky')
})
it('should not return string but false if invalid url', () => {
const url = 'https://website.com/users/sky'
expect(MatcherService.extractTagFromUrl(url)).to.eql(false)
}) })
}) })
}) })