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

View file

@ -1,15 +1,21 @@
@import './_variables.scss';
#app {
background-size: cover;
background-attachment: fixed;
background-repeat: no-repeat;
background-position: 0 50px;
min-height: 100vh;
max-width: 100%;
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 {
user-select: none;
}

View file

@ -1,5 +1,6 @@
<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">
<div class='logo' :style='logoBgStyle'>
<div class='mask' :style='logoMaskStyle'></div>

View file

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

View file

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

View file

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

View file

@ -3,7 +3,8 @@
<user-card
v-for="entry in entries"
:key="entry.id" :user="entry"
:showFollows="true"
:showFollows="!showFollowers"
:showActions="showActions"
/>
<div class="text-center panel-footer">
<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 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 Completion from '../../services/completion/completion.js'
import { take, filter, reject, map, uniqBy } from 'lodash'
import { reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions]
@ -28,13 +28,10 @@ const PostStatusForm = {
'subject'
],
components: {
MediaUpload
MediaUpload,
AutoCompleteInput
},
mounted () {
this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) {
this.$refs.textarea.focus()
}
@ -61,15 +58,13 @@ const PostStatusForm = {
submitDisabled: false,
error: null,
posting: false,
highlighted: 0,
newStatus: {
spoilerText: this.subject || '',
status: statusText,
nsfw: false,
files: [],
visibility: scope
},
caret: 0
}
}
},
computed: {
@ -81,59 +76,6 @@ const PostStatusForm = {
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 () {
return this.newStatus.status.length
},
@ -174,53 +116,8 @@ const PostStatusForm = {
}
},
methods: {
replace (replacement) {
this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
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
postStatusCopy () {
this.postStatus(this.newStatus)
},
postStatus (newStatus) {
if (this.posting) { return }
@ -303,18 +200,6 @@ const PostStatusForm = {
fileDrag (e) {
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 () {
this.error = null
},

View file

@ -16,22 +16,16 @@
:placeholder="$t('post_status.content_warning')"
v-model="newStatus.spoilerText"
class="form-cw">
<textarea
ref="textarea"
@click="setCaret"
@keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control"
@keydown.down="cycleForward"
@keydown.up="cycleBackward"
@keydown.shift.tab="cycleBackward"
@keydown.tab="cycleForward"
@keydown.enter="replaceCandidate"
@keydown.meta.enter="postStatus(newStatus)"
@keyup.ctrl.enter="postStatus(newStatus)"
@drop="fileDrop"
@dragover.prevent="fileDrag"
@input="resize"
@paste="paste">
</textarea>
<auto-complete-input v-model="newStatus.status"
:classObj="{ 'form-control': true }"
:placeholder="$t('post_status.default')"
:autoResize="true"
:multiline="true"
:drop="fileDrop"
:dragoverPrevent="fileDrag"
:paste="paste"
:keydownMetaEnter="postStatusCopy"
:keyupCtrlEnter="postStatusCopy"/>
<div class="visibility-tray">
<span class="text-format" v-if="formattingOptionsEnabled">
<label for="post-content-type" class="select">
@ -52,17 +46,6 @@
</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'>
<media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload>
@ -250,52 +233,5 @@
cursor: pointer;
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>

View file

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

View file

@ -1,5 +1,5 @@
<template>
<Timeline :title="$t('nav.twkn')"v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/>
<Timeline :title="$t('nav.twkn')" v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/>
</template>
<script src="./public_and_external_timeline.js"></script>

View file

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

View file

@ -27,6 +27,11 @@ const settings = {
: user.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,
replyVisibilityLocal: user.replyVisibility,
loopVideoLocal: user.loopVideo,
@ -101,6 +106,9 @@ const settings = {
hideUserStatsLocal (value) {
this.$store.dispatch('setOption', { name: 'hideUserStats', value })
},
hideFilteredStatusesLocal (value) {
this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
},
hideNsfwLocal (value) {
this.$store.dispatch('setOption', { name: 'hideNsfw', value })
},

View file

@ -227,7 +227,6 @@
</label>
</li>
</ul>
</label>
</div>
<div>
{{$t('settings.replies_in_timeline')}}
@ -254,11 +253,18 @@
</div>
</div>
<div class="setting-item">
<p>{{$t('settings.filtering_explanation')}}</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea>
<div>
<p>{{$t('settings.filtering_explanation')}}</p>
<textarea id="muteWords" v-model="muteWordsString"></textarea>
</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>
</tab-switcher>
</keep-alive>
</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 fileType from 'src/services/file_type/file_type.service'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { mentionMatchesUrl } from 'src/services/mention_matcher/mention_matcher.js'
import { filter, find } from 'lodash'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { filter, find, unescape } from 'lodash'
const Status = {
name: 'Status',
@ -110,6 +110,14 @@ const Status = {
return hits
},
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 () {
// retweet or root of an expanded conversation
if (this.focused) {
@ -201,14 +209,15 @@ const Status = {
},
replySubject () {
if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary)
const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined'
? this.$store.state.instance.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') {
return this.status.summary
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(this.status.summary)
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
@ -273,7 +282,7 @@ const Status = {
}
if (target.tagName === 'A') {
if (target.className.match(/mention/)) {
const href = target.getAttribute('href')
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
event.stopPropagation()
@ -283,6 +292,15 @@ const Status = {
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')
}
},
@ -339,6 +357,9 @@ const Status = {
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)

View file

@ -1,5 +1,5 @@
<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">
<div class="media status container muted">
<small>
@ -56,7 +56,7 @@
</div>
<h4 class="replies" v-if="inConversation && !noReplyLinks">
<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>
</small>
</h4>
@ -438,6 +438,8 @@
.user-name {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
img {
width: 14px;

View file

@ -37,7 +37,7 @@ export default Vue.component('tab-switcher', {
return (
<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>
)
})

View file

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

View file

@ -1,16 +1,21 @@
import UserCardContent from '../user_card_content/user_card_content.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
const UserCard = {
props: [
'user',
'showFollows',
'showApproval'
'showApproval',
'showActions'
],
data () {
return {
userExpanded: false
userExpanded: false,
followRequestInProgress: false,
followRequestSent: false,
updated: false
}
},
components: {
@ -18,7 +23,11 @@ const UserCard = {
UserAvatar
},
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: {
toggleUserExpanded () {
@ -34,6 +43,21 @@ const UserCard = {
},
userProfileLink (user) {
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>
</div>
<div class="name-and-screen-name" v-else>
<div :title="user.name" v-if="user.name_html" class="user-name">
<span v-html="user.name_html"></span>
<div :title="user.name" class="user-name">
<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">
{{ currentUser.id == user.id ? $t('user_card.its_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">
{{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }}
</span>
<div class="user-link-action">
<router-link class='user-screen-name' :to="userProfileLink(user)">
@{{user.screen_name}}
</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>
<router-link class='user-screen-name' :to="userProfileLink(user)">
@{{user.screen_name}}
</router-link>
</div>
<div class="approval" v-if="showApproval">
<button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button>
@ -42,6 +63,9 @@
text-align: left;
width: 100%;
.user-name {
display: flex;
justify-content: space-between;
img {
object-fit: contain;
height: 16px;
@ -49,11 +73,20 @@
vertical-align: middle;
}
}
.user-link-action {
display: flex;
align-items: flex-start;
justify-content: space-between;
button {
margin-top: 3px;
}
}
}
.follows-you {
margin-left: 2em;
float: right;
}
.card {

View file

@ -1,5 +1,6 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
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'
export default {
@ -92,69 +93,17 @@ export default {
},
methods: {
followUser () {
const store = this.$store
this.followRequestInProgress = true
store.state.api.backendInteractor.followUser(this.user.id)
.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.followRequestSent = true
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
}
})
})
requestFollow(this.user, this.$store).then(({sent}) => {
this.followRequestInProgress = false
this.followRequestSent = sent
})
},
unfollowUser () {
const store = this.$store
this.followRequestInProgress = true
store.state.api.backendInteractor.unfollowUser(this.user.id)
.then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser]))
.then(() => {
this.followRequestInProgress = false
})
requestUnfollow(this.user, this.$store).then(() => {
this.followRequestInProgress = false
})
},
blockUser () {
const store = this.$store

View file

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

View file

@ -9,19 +9,20 @@
<tab-switcher :renderOnlyFocused="true">
<Timeline
:label="$t('user_card.statuses')"
:disabled="!user.statuses_count"
:embedded="true"
:title="$t('user_profile.timeline_title')"
:timeline="timeline"
:timeline-name="'user'"
: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" />
<div class="userlist-placeholder" v-else>
<i class="icon-spin3 animate-spin"></i>
</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" />
<div class="userlist-placeholder" v-else>
<i class="icon-spin3 animate-spin"></i>
@ -29,6 +30,7 @@
</div>
<Timeline
:label="$t('user_card.media')"
:disabled="!media.visibleStatuses.length"
:embedded="true" :title="$t('user_card.media')"
timeline-name="media"
:timeline="media"
@ -37,6 +39,7 @@
<Timeline
v-if="isUs"
:label="$t('user_card.favorites')"
:disabled="!favorites.visibleStatuses.length"
:embedded="true"
:title="$t('user_card.favorites')"
timeline-name="favorites"

View file

@ -2,6 +2,7 @@ import { unescape } from 'lodash'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
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'
const UserSettings = {
@ -41,7 +42,8 @@ const UserSettings = {
},
components: {
StyleSwitcher,
TabSwitcher
TabSwitcher,
AutoCompleteInput
},
computed: {
user () {

View file

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

View file

@ -132,6 +132,7 @@
"preload_images": "Bilder vorausladen",
"hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)",
"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_theme": "Farbschema laden",
"inputRadius": "Eingabefelder",

View file

@ -140,6 +140,7 @@
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"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_filtered_statuses": "Hide filtered statuses",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",

View file

@ -28,7 +28,8 @@
"password": "Contraseña",
"placeholder": "p.ej. lain",
"register": "Registrar",
"username": "Usuario"
"username": "Usuario",
"hint": "Inicia sesión para unirte a la discusión"
},
"nav": {
"about": "Sobre",
@ -55,7 +56,7 @@
"no_more_notifications": "No hay más notificaciones"
},
"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_link": "bloqueada",
"attachments_sensitive": "Contenido sensible",
@ -139,7 +140,8 @@
"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_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)",
"import_followers_from_a_csv_file": "Importar personas que tú sigues apartir de un archivo csv",
"hide_filtered_statuses": "Ocultar estados filtrados",
"import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv",
"import_theme": "Importar tema",
"inputRadius": "Campos de entrada",
"checkboxRadius": "Casillas de verificación",
@ -164,7 +166,10 @@
"notification_visibility_mentions": "Menciones",
"notification_visibility_repeats": "Repeticiones (Repeats)",
"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",
"panelRadius": "Paneles",
"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_mastodon": "Tipo mastodon: copiar como es",
"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",
"streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior",
"text": "Texto",

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ const defaultState = {
collapseMessageWithSubject: false,
hidePostStats: false,
hideUserStats: false,
hideFilteredStatuses: true,
disableChat: false,
scopeCopy: true,
subjectLineBehavior: 'email',
@ -29,6 +30,7 @@ const defaultState = {
nsfwCensorImage: undefined,
vapidPublicKey: undefined,
noAttachmentLinks: false,
showFeaturesPanel: true,
// Nasty stuff
pleromaBackend: true,
@ -64,9 +66,11 @@ const instance = {
case 'name':
dispatch('setPageTitle')
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.
store.dispatch('startFetching', 'friends')
store.dispatch('startFetching', { timeline: 'friends' })
// Get user mutes and follower info
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) => {
getThemes().then((themes) => {
return getThemes().then((themes) => {
const theme = themes[val] ? themes[val] : themes['pleroma-dark']
const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme

View file

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

View file

@ -241,7 +241,7 @@ describe('API Entities normalizer', () => {
notice: makeMockStatusQvitter({ id: 444 }),
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.deep.property('status.id', '444')
expect(parseNotification(notif)).to.have.deep.property('action.id', '444')
@ -259,7 +259,7 @@ describe('API Entities normalizer', () => {
is_seen: 1,
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('seen', true)
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 = () => ({
id: 123,
@ -16,48 +16,67 @@ const externalAttn = () => ({
statusnet_profile_url: 'https://instance.com/users/person'
})
describe('MentionMatcher', () => {
describe.only('mentionMatchesUrl', () => {
describe('MatcherService', () => {
describe('mentionMatchesUrl', () => {
it('should match local mention', () => {
const attention = localAttn()
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', () => {
const attention = localAttn()
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', () => {
const attention = externalAttn()
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', () => {
const attention = externalAttn()
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', () => {
const attention = externalAttn()
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', () => {
const attention = externalAttn()
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)
})
})
})