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

* upstream/develop:
  review
  review
  Update emoji-input.js
  Misc fixes: Fix uploads stretching on chrome, fix warnings in console
  apply font smoothing in webkit and firefox
  fix user search
  Apply suggestion to src/services/backend_interactor_service/backend_interactor_service.js
  properly position the caret after replacement
  Apply suggestion to src/services/api/api.service.js
  fix MFA crashing on user-settings page
  fixup! Removed formattingOptionsEnabled in favor of relying on BE-provided list of accepted formatting options
  getting and setting user background via MastoAPI
  Removed formattingOptionsEnabled in favor of relying on BE-provided list of accepted formatting options
  fix small annoyance
  fixed some bugs i found, also cleaned up some stuff + documentation
  self-review
  Linting
This commit is contained in:
Henry Jameson 2019-06-18 22:27:15 +03:00
commit cda2d6cb20
23 changed files with 214 additions and 162 deletions

View file

@ -1,5 +1,8 @@
# v1.0
## Removed features/radically changed behavior
### formattingOptionsEnabled
as of !833 `formattingOptionsEnabled` is no longer available and instead FE check for available post formatting options and enables formatting control if there's more than one option.
### minimalScopesMode
As of !633, `scopeOptions` is no longer available and instead is changed for `minimalScopesMode` (default: `false`)

View file

@ -47,6 +47,8 @@ body {
color: var(--text, $fallback--text);
max-width: 100vw;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {

View file

@ -100,7 +100,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')

View file

@ -68,6 +68,7 @@
max-height: 200px;
max-width: 100%;
display: flex;
align-items: center;
video {
max-width: 100%;
}

View file

@ -1,6 +1,11 @@
<template>
<div class="avatars">
<router-link :to="userProfileLink(user)" class="avatars-item" v-for="user in slicedUsers">
<router-link
:to="userProfileLink(user)"
class="avatars-item"
v-for="user in slicedUsers"
:key="user.id"
>
<UserAvatar :user="user" class="avatar-small" />
</router-link>
</div>

View file

@ -139,6 +139,7 @@ const conversation = {
return (this.isExpanded) && id === this.status.id
},
setHighlight (id) {
if (!id) return
this.highlight = id
this.$store.dispatch('fetchFavsAndRepeats', id)
},

View file

@ -1,31 +1,65 @@
import Completion from '../../services/completion/completion.js'
import { take } from 'lodash'
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = {
props: [
'placeholder',
'suggest',
'value',
'type',
'classname'
],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function
},
value: {
/**
* Used for v-model
*/
required: true,
type: String
}
},
data () {
return {
input: undefined,
highlighted: 0,
caret: 0,
focused: false,
popupOptions: {
placement: 'bottom-start',
trigger: 'hover',
// See: https://github.com/RobinCK/vue-popper/issues/63
'delay-on-mouse-over': 9999999,
'delay-on-mouse-out': 9999999,
modifiers: {
arrow: { enabled: true },
offset: { offset: '0, 5px' }
}
}
focused: false
}
},
computed: {
@ -64,22 +98,22 @@ const EmojiInput = {
if (!input) return
this.input = input
this.resize()
input.elm.addEventListener('transitionend', this.onTransition)
input.elm.addEventListener('blur', this.onBlur)
input.elm.addEventListener('focus', this.onFocus)
input.elm.addEventListener('paste', this.onPaste)
input.elm.addEventListener('keyup', this.onKeyUp)
input.elm.addEventListener('keydown', this.onKeyDown)
input.elm.addEventListener('transitionend', this.onTransition)
},
unmounted () {
const { input } = this
if (input) {
input.elm.removeEventListener('transitionend', this.onTransition)
input.elm.removeEventListener('blur', this.onBlur)
input.elm.removeEventListener('focus', this.onFocus)
input.elm.removeEventListener('paste', this.onPaste)
input.elm.removeEventListener('keyup', this.onKeyUp)
input.elm.removeEventListener('keydown', this.onKeyDown)
input.elm.removeEventListener('transitionend', this.onTransition)
}
},
methods: {
@ -96,8 +130,16 @@ const EmojiInput = {
const replacement = suggestion.replacement
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
this.caret = 0
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.elm.focus()
// Set selection right after the replacement instead of the very end
this.input.elm.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
}
},
@ -126,29 +168,33 @@ const EmojiInput = {
}
},
onTransition (e) {
this.resize(e)
this.resize()
},
onBlur (e) {
this.focused = false
this.setCaret(e)
this.resize(e)
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
setTimeout(() => {
this.focused = false
this.setCaret(e)
this.resize()
}, 200)
},
onFocus (e) {
this.focused = true
this.setCaret(e)
this.resize(e)
this.resize()
},
onKeyUp (e) {
this.setCaret(e)
this.resize(e)
this.resize()
},
onPaste (e) {
this.setCaret(e)
this.resize(e)
this.resize()
},
onKeyDown (e) {
this.setCaret(e)
this.resize(e)
this.resize()
const { ctrlKey, shiftKey, key } = e
if (key === 'Tab') {
@ -172,7 +218,7 @@ const EmojiInput = {
onInput (e) {
this.$emit('input', e.target.value)
},
setCaret ({ target: { selectionStart, value } }) {
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
},
resize () {

View file

@ -6,7 +6,7 @@
<div
v-for="(suggestion, index) in suggestions"
:key="index"
@click.stop.prevent="replace(suggestion.replacement)"
@click.stop.prevent="replaceText"
class="autocomplete-item"
:class="{ highlighted: suggestion.highlighted }"
>

View file

@ -1,70 +1,79 @@
export default function suggest (data) {
return input => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return suggestEmoji(data.emoji)(input)
}
if (firstChar === '@' && data.users) {
return suggestUsers(data.users)(input)
}
return []
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (state.instance.emoji + state.instance.customEmoji)
* data.users - optional, an array of all known users
*
* Depending on data present one or both (or none) can be present, so if field
* doesn't support user linking you can just provide only emoji.
*/
export default data => input => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return suggestEmoji(data.emoji)(input)
}
if (firstChar === '@' && data.users) {
return suggestUsers(data.users)(input)
}
return []
}
function suggestEmoji (emojis) {
return input => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Make custom emojis a priority
aScore += Number(!!a.imageUrl) * 10
bScore += Number(!!b.imageUrl) * 10
// Sort alphabetically
const alphabetically = a.displayText > b.displayText ? 1 : -1
return bScore - aScore + alphabetically
})
}
}
function suggestUsers (users) {
return input => {
const noPrefix = input.toLowerCase().substr(1)
return users.filter(
user =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)
/* eslint-disable camelcase */
).slice(0, 20).sort((a, b) => {
export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) * 2
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) * 2
// Make custom emojis a priority
aScore += a.imageUrl ? 10 : 0
bScore += b.imageUrl ? 10 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix)
bScore += b.name.toLowerCase().startsWith(noPrefix)
// Sort alphabetically
const alphabetically = a.displayText > b.displayText ? 1 : -1
const diff = bScore * 10 - aScore * 10
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically
}).map(({ screen_name, name, profile_image_url_original }) => ({
displayText: screen_name,
detailText: name,
imageUrl: profile_image_url_original,
replacement: '@' + screen_name
}))
/* eslint-enable camelcase */
}
return bScore - aScore + alphabetically
})
}
export const suggestUsers = users => input => {
const noPrefix = input.toLowerCase().substr(1)
return users.filter(
user =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)
/* taking only 20 results so that sorting is a bit cheaper, we display
* only 5 anyway. could be inaccurate, but we ideally we should query
* backend anyway
*/
).slice(0, 20).sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */
}).map(({ screen_name, name, profile_image_url_original }) => ({
displayText: screen_name,
detailText: name,
imageUrl: profile_image_url_original,
replacement: '@' + screen_name + ' '
}))
/* eslint-enable camelcase */
}

View file

@ -147,9 +147,6 @@ const PostStatusForm = {
return true
}
},
formattingOptionsEnabled () {
return this.$store.state.instance.formattingOptionsEnabled
},
postFormats () {
return this.$store.state.instance.postFormats || []
},

View file

@ -31,7 +31,7 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<emoji-input
<EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject"
:suggest="emojiSuggestor"
v-model="newStatus.spoilerText"
@ -44,8 +44,8 @@
v-model="newStatus.spoilerText"
class="form-post-subject"
/>
</emoji-input>
<emoji-input
</EmojiInput>
<EmojiInput
:suggest="emojiUserSuggestor"
v-model="newStatus.status"
class="form-control"
@ -65,9 +65,9 @@
class="form-post-body"
>
</textarea>
</emoji-input>
</EmojiInput>
<div class="visibility-tray">
<div class="text-format" v-if="formattingOptionsEnabled">
<div class="text-format" v-if="postFormats.length > 1">
<label for="post-content-type" class="select">
<select id="post-content-type" v-model="newStatus.contentType" class="form-control">
<option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat">
@ -77,6 +77,11 @@
<i class="icon-down-open"></i>
</label>
</div>
<div class="text-format" v-if="postFormats.length === 1">
<span class="only-format">
{{$t(`post_status.content_type["${postFormats[0]}"]`)}}
</span>
</div>
<scope-selector
:showAll="showAllScopes"
@ -167,6 +172,14 @@
}
}
.text-format {
.only-format {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
.error {
text-align: center;
}

View file

@ -102,7 +102,7 @@
</label>
</div>
</li>
<li>
<li v-if="postFormats.length > 0">
<div>
{{$t('settings.post_status_content_type')}}
<label for="postContentType" class="select">

View file

@ -78,6 +78,8 @@ const Timeline = {
},
methods: {
handleShortKey (e) {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses () {

View file

@ -7,6 +7,7 @@ import { mapState } from 'vuex'
const Mfa = {
data: () => ({
settings: { // current settings of MFA
available: false,
enabled: false,
totp: false
},
@ -139,7 +140,9 @@ const Mfa = {
// fetch settings from server
async fetchSettings () {
let result = await this.backendInteractor.fetchSettingsMFA()
if (result.error) return
this.settings = result.settings
this.settings.available = true
return result
}
},

View file

@ -1,5 +1,5 @@
<template>
<div class="setting-item mfa-settings" v-if="readyInit">
<div class="setting-item mfa-settings" v-if="readyInit && settings.available">
<div class="mfa-heading">
<h2>{{$t('settings.mfa.title')}}</h2>

View file

@ -47,7 +47,9 @@ const UserSettings = {
pickAvatarBtnVisible: true,
bannerUploading: false,
backgroundUploading: false,
banner: null,
bannerPreview: null,
background: null,
backgroundPreview: null,
bannerUploadError: null,
backgroundUploadError: null,
@ -214,22 +216,12 @@ const UserSettings = {
},
submitBg () {
if (!this.backgroundPreview) { return }
let img = this.backgroundPreview
// eslint-disable-next-line no-undef
let imginfo = new Image()
let cropX, cropY, cropW, cropH
imginfo.src = img
cropX = 0
cropY = 0
cropW = imginfo.width
cropH = imginfo.width
let background = this.background
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({params: {img, cropX, cropY, cropW, cropH}}).then((data) => {
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => {
if (!data.error) {
let clone = JSON.parse(JSON.stringify(this.$store.state.users.currentUser))
clone.background_image = data.url
this.$store.commit('addNewUsers', [clone])
this.$store.commit('setCurrentUser', clone)
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
} else {
this.backgroundUploadError = this.$t('upload.error.base') + data.error

View file

@ -22,20 +22,20 @@
<div class="setting-item" >
<h2>{{$t('settings.name_bio')}}</h2>
<p>{{$t('settings.name')}}</p>
<emoji-input :suggest="emojiSuggestor" v-model="newName">
<EmojiInput :suggest="emojiSuggestor" v-model="newName">
<input
v-model="newName"
id="username"
classname="name-changer"
/>
</emoji-input>
</EmojiInput>
<p>{{$t('settings.bio')}}</p>
<emoji-input :suggest="emojiUserSuggestor" v-model="newBio">
<EmojiInput :suggest="emojiUserSuggestor" v-model="newBio">
<textarea
v-model="newBio"
classname="bio"
/>
</emoji-input>
</EmojiInput>
<p>
<input type="checkbox" v-model="newLocked" id="account-locked">
<label for="account-locked">{{$t('settings.lock_account_description')}}</label>

View file

@ -16,7 +16,6 @@ const defaultState = {
redirectRootNoLogin: '/main/all',
redirectRootLogin: '/main/friends',
showInstanceSpecificPanel: false,
formattingOptionsEnabled: false,
alwaysShowSubjectInput: true,
hideMutedPosts: false,
collapseMessageWithSubject: false,

View file

@ -356,7 +356,13 @@ const users = {
},
searchUsers (store, query) {
// TODO: Move userSearch api into api.service
return userSearchApi.search({query, store: { state: store.rootState }})
return userSearchApi.search({
query,
store: {
state: store.rootState,
getters: store.rootGetters
}
})
.then((users) => {
store.commit('addNewUsers', users)
return users

View file

@ -1,5 +1,4 @@
/* eslint-env browser */
const BG_UPDATE_URL = '/api/qvitter/update_background_image.json'
const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json'
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
@ -25,7 +24,6 @@ const MFA_DISABLE_OTP_URL = '/api/pleroma/profile/mfa/totp'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const GET_BACKGROUND_HACK = '/api/account/verify_credentials.json'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite`
@ -133,22 +131,16 @@ const updateAvatar = ({credentials, avatar}) => {
.then((data) => parseUser(data))
}
const updateBg = ({credentials, params}) => {
let url = BG_UPDATE_URL
const updateBg = ({ credentials, background }) => {
const form = new FormData()
each(params, (value, key) => {
if (value) {
form.append(key, value)
}
})
return fetch(url, {
form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'POST',
method: 'PATCH',
body: form
}).then((data) => data.json())
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBanner = ({credentials, banner}) => {
@ -544,26 +536,6 @@ const verifyCredentials = (user) => {
}
})
.then((data) => data.error ? data : parseUser(data))
.then((mastoUser) => {
// REMOVE WHEN BE SUPPORTS background_image
return fetch(GET_BACKGROUND_HACK, {
method: 'POST',
headers: authHeaders(user)
})
.then((response) => {
if (response.ok) {
return response.json()
} else {
return {}
}
})
/* eslint-disable camelcase */
.then(({ background_image }) => ({
...mastoUser,
background_image
}))
/* eslint-enable camelcase */
})
}
const favorite = ({ id, credentials }) => {

View file

@ -105,7 +105,7 @@ const backendInteractorService = (credentials) => {
const getCaptcha = () => apiService.getCaptcha()
const register = (params) => apiService.register({ credentials, params })
const updateAvatar = ({avatar}) => apiService.updateAvatar({credentials, avatar})
const updateBg = ({params}) => apiService.updateBg({credentials, params})
const updateBg = ({ background }) => apiService.updateBg({ credentials, background })
const updateBanner = ({banner}) => apiService.updateBanner({credentials, banner})
const updateProfile = ({params}) => apiService.updateProfile({credentials, params})

View file

@ -60,6 +60,9 @@ export const parseUser = (data) => {
if (data.pleroma) {
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.token = data.pleroma.chat_token
if (relationship) {
output.follows_you = relationship.followed_by
output.following = relationship.following

View file

@ -9,7 +9,6 @@
"redirectRootLogin": "/main/friends",
"chatDisabled": false,
"showInstanceSpecificPanel": false,
"formattingOptionsEnabled": false,
"collapseMessageWithSubject": false,
"scopeCopy": true,
"subjectLineBehavior": "noop",