diff options
Diffstat (limited to 'src/components')
51 files changed, 946 insertions, 450 deletions
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 76affe2d..c58bebd3 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -160,6 +160,7 @@ .hider { position: absolute; + right: 0; white-space: nowrap; margin: 10px; padding: 5px; diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js index a8441446..87085a28 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -1,4 +1,4 @@ -import UserCardContent from '../user_card_content/user_card_content.vue' +import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -12,7 +12,7 @@ const BasicUserCard = { } }, components: { - UserCardContent, + UserCard, UserAvatar }, methods: { diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 77fb0aa0..8afe8b44 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -1,18 +1,18 @@ <template> - <div class="user-card"> + <div class="basic-user-card"> <router-link :to="userProfileLink(user)"> <UserAvatar class="avatar" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> </router-link> - <div class="user-card-expanded-content" v-if="userExpanded"> - <user-card-content :user="user" :switcher="false"></user-card-content> + <div class="basic-user-card-expanded-content" v-if="userExpanded"> + <UserCard :user="user" :rounded="true" :bordered="true"/> </div> - <div class="user-card-collapsed-content" v-else> - <div :title="user.name" class="user-card-user-name"> - <span v-if="user.name_html" v-html="user.name_html"></span> - <span v-else>{{ user.name }}</span> + <div class="basic-user-card-collapsed-content" v-else> + <div :title="user.name" class="basic-user-card-user-name"> + <span v-if="user.name_html" class="basic-user-card-user-name-value" v-html="user.name_html"></span> + <span v-else class="basic-user-card-user-name-value">{{ user.name }}</span> </div> <div> - <router-link class="user-card-screen-name" :to="userProfileLink(user)"> + <router-link class="basic-user-card-screen-name" :to="userProfileLink(user)"> @{{user.screen_name}} </router-link> </div> @@ -26,15 +26,15 @@ <style lang="scss"> @import '../../_variables.scss'; -.user-card { +.basic-user-card { display: flex; flex: 1 0; + margin: 0; padding-top: 0.6em; padding-right: 1em; padding-bottom: 0.6em; padding-left: 1em; border-bottom: 1px solid; - margin: 0; border-bottom-color: $fallback--border; border-bottom-color: var(--border, $fallback--border); @@ -52,28 +52,19 @@ width: 16px; vertical-align: middle; } + + &-value { + display: inline-block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } &-expanded-content { flex: 1; margin-left: 0.7em; - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); - border-style: solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - border-width: 1px; - overflow: hidden; - - .panel-heading { - background: transparent; - flex-direction: column; - align-items: stretch; - } - - p { - margin-bottom: 0; - } } } </style> diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js index 11fa27b4..c459ff1b 100644 --- a/src/components/block_card/block_card.js +++ b/src/components/block_card/block_card.js @@ -9,7 +9,7 @@ const BlockCard = { }, computed: { user () { - return this.$store.getters.userById(this.userId) + return this.$store.getters.findUser(this.userId) }, blocked () { return this.user.statusnet_blocking diff --git a/src/components/conversation-page/conversation-page.js b/src/components/conversation-page/conversation-page.js index 8f1ac3d9..1da70ce9 100644 --- a/src/components/conversation-page/conversation-page.js +++ b/src/components/conversation-page/conversation-page.js @@ -1,5 +1,4 @@ import Conversation from '../conversation/conversation.vue' -import { find } from 'lodash' const conversationPage = { components: { @@ -8,8 +7,8 @@ const conversationPage = { computed: { statusoid () { const id = this.$route.params.id - const statuses = this.$store.state.statuses.allStatuses - const status = find(statuses, {id}) + const statuses = this.$store.state.statuses.allStatusesObject + const status = statuses[id] return status } diff --git a/src/components/conversation-page/conversation-page.vue b/src/components/conversation-page/conversation-page.vue index b03eea28..9e322cf5 100644 --- a/src/components/conversation-page/conversation-page.vue +++ b/src/components/conversation-page/conversation-page.vue @@ -1,5 +1,9 @@ <template> - <conversation :collapsable="false" :statusoid="statusoid"></conversation> + <conversation + :collapsable="false" + isPage="true" + :statusoid="statusoid" + ></conversation> </template> <script src="./conversation-page.js"></script> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index 48b8aaaa..69058bf6 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,9 +1,12 @@ -import { reduce, filter } from 'lodash' +import { reduce, filter, findIndex } from 'lodash' +import { set } from 'vue' import Status from '../status/status.vue' const sortById = (a, b) => { - const seqA = Number(a.id) - const seqB = Number(b.id) + const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id + const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id + const seqA = Number(idA) + const seqB = Number(idB) const isSeqA = !Number.isNaN(seqA) const isSeqB = !Number.isNaN(seqB) if (isSeqA && isSeqB) { @@ -13,29 +16,53 @@ const sortById = (a, b) => { } else if (!isSeqA && isSeqB) { return 1 } else { - return a.id < b.id ? -1 : 1 + return idA < idB ? -1 : 1 } } -const sortAndFilterConversation = (conversation) => { - conversation = filter(conversation, (status) => status.type !== 'retweet') +const sortAndFilterConversation = (conversation, statusoid) => { + if (statusoid.type === 'retweet') { + conversation = filter( + conversation, + (status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id) + ) + } else { + conversation = filter(conversation, (status) => status.type !== 'retweet') + } return conversation.filter(_ => _).sort(sortById) } const conversation = { data () { return { - highlight: null + highlight: null, + expanded: false, + converationStatusIds: [] } }, props: [ 'statusoid', - 'collapsable' + 'collapsable', + 'isPage' ], + created () { + if (this.isPage) { + this.fetchConversation() + } + }, computed: { status () { return this.statusoid }, + idsToShow () { + if (this.converationStatusIds.length > 0) { + return this.converationStatusIds + } else if (this.statusId) { + return [this.statusId] + } else { + return [] + } + }, statusId () { if (this.statusoid.retweeted_status) { return this.statusoid.retweeted_status.id @@ -48,10 +75,22 @@ const conversation = { return [] } - const conversationId = this.status.statusnet_conversation_id - const statuses = this.$store.state.statuses.allStatuses - const conversation = filter(statuses, { statusnet_conversation_id: conversationId }) - return sortAndFilterConversation(conversation) + if (!this.isExpanded) { + return [this.status] + } + + const statusesObject = this.$store.state.statuses.allStatusesObject + const conversation = this.idsToShow.reduce((acc, id) => { + acc.push(statusesObject[id]) + return acc + }, []) + + const statusIndex = findIndex(conversation, { id: this.statusId }) + if (statusIndex !== -1) { + conversation[statusIndex] = this.status + } + + return sortAndFilterConversation(conversation, this.status) }, replies () { let i = 1 @@ -69,23 +108,34 @@ const conversation = { i++ return result }, {}) + }, + isExpanded () { + return this.expanded || this.isPage } }, components: { Status }, - created () { - this.fetchConversation() - }, watch: { - '$route': 'fetchConversation' + '$route': 'fetchConversation', + expanded (value) { + if (value) { + this.fetchConversation() + } + } }, methods: { fetchConversation () { if (this.status) { - const conversationId = this.status.statusnet_conversation_id - this.$store.state.api.backendInteractor.fetchConversation({id: conversationId}) - .then((statuses) => this.$store.dispatch('addNewStatuses', { statuses })) + this.$store.state.api.backendInteractor.fetchConversation({id: this.status.id}) + .then(({ancestors, descendants}) => { + this.$store.dispatch('addNewStatuses', { statuses: ancestors }) + this.$store.dispatch('addNewStatuses', { statuses: descendants }) + set(this, 'converationStatusIds', [].concat( + ancestors.map(_ => _.id).filter(_ => _ !== this.statusId), + this.statusId, + descendants.map(_ => _.id).filter(_ => _ !== this.statusId))) + }) .then(() => this.setHighlight(this.statusId)) } else { const id = this.$route.params.id @@ -98,10 +148,19 @@ const conversation = { return this.replies[id] || [] }, focused (id) { - return id === this.statusId + return (this.isExpanded) && id === this.status.id }, setHighlight (id) { this.highlight = id + }, + getHighlight () { + return this.isExpanded ? this.highlight : null + }, + toggleExpanded () { + this.expanded = !this.expanded + if (!this.expanded) { + this.setHighlight(null) + } } } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 5528fef6..c39a3ed9 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -1,26 +1,42 @@ <template> - <div class="timeline panel panel-default"> - <div class="panel-heading conversation-heading"> + <div class="timeline panel-default" :class="[isExpanded ? 'panel' : 'panel-disabled']"> + <div v-if="isExpanded" class="panel-heading conversation-heading"> <span class="title"> {{ $t('timeline.conversation') }} </span> <span v-if="collapsable"> - <a href="#" @click.prevent="$emit('toggleExpanded')">{{ $t('timeline.collapse') }}</a> + <a href="#" @click.prevent="toggleExpanded">{{ $t('timeline.collapse') }}</a> </span> </div> - <div class="panel-body"> - <div class="timeline"> - <status - v-for="status in conversation" - @goto="setHighlight" :key="status.id" - :inlineExpanded="collapsable" :statusoid="status" - :expandable='false' :focused="focused(status.id)" - :inConversation='true' - :highlight="highlight" - :replies="getReplies(status.id)" - class="status-fadein"> - </status> - </div> - </div> + <status + v-for="status in conversation" + @goto="setHighlight" + @toggleExpanded="toggleExpanded" + :key="status.id" + :inlineExpanded="collapsable" + :statusoid="status" + :expandable='!expanded' + :focused="focused(status.id)" + :inConversation="isExpanded" + :highlight="getHighlight()" + :replies="getReplies(status.id)" + class="status-fadein panel-body" + /> </div> </template> <script src="./conversation.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.timeline { + .panel-disabled { + .status-el { + border-left: none; + border-bottom-width: 1px; + border-bottom-style: solid; + border-color: var(--border, $fallback--border); + border-radius: 0; + } + } +} +</style> diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js new file mode 100644 index 00000000..a5bb6eaf --- /dev/null +++ b/src/components/emoji-input/emoji-input.js @@ -0,0 +1,107 @@ +import Completion from '../../services/completion/completion.js' +import { take, filter, map } from 'lodash' + +const EmojiInput = { + props: [ + 'value', + 'placeholder', + 'type', + 'classname' + ], + data () { + return { + highlighted: 0, + caret: 0 + } + }, + computed: { + suggestions () { + const firstchar = this.textAtCaret.charAt(0) + 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) => ({ + shortcode: `:${shortcode}:`, + 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.value, this.caret - 1) || {} + return word + }, + emoji () { + return this.$store.state.instance.emoji || [] + }, + customEmoji () { + return this.$store.state.instance.customEmoji || [] + } + }, + methods: { + replace (replacement) { + const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) + this.$emit('input', newValue) + this.caret = 0 + }, + replaceEmoji (e) { + const len = this.suggestions.length || 0 + if (this.textAtCaret === ':' || e.ctrlKey) { return } + if (len > 0) { + e.preventDefault() + const emoji = this.suggestions[this.highlighted] + const replacement = emoji.utf || (emoji.shortcode + ' ') + const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) + this.$emit('input', newValue) + this.caret = 0 + this.highlighted = 0 + } + }, + cycleBackward (e) { + const len = this.suggestions.length || 0 + if (len > 0) { + e.preventDefault() + this.highlighted -= 1 + if (this.highlighted < 0) { + this.highlighted = this.suggestions.length - 1 + } + } else { + this.highlighted = 0 + } + }, + cycleForward (e) { + const len = this.suggestions.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 + } + }, + onKeydown (e) { + e.stopPropagation() + }, + onInput (e) { + this.$emit('input', e.target.value) + }, + setCaret ({target: {selectionStart}}) { + this.caret = selectionStart + } + } +} + +export default EmojiInput diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue new file mode 100644 index 00000000..338b77cd --- /dev/null +++ b/src/components/emoji-input/emoji-input.vue @@ -0,0 +1,64 @@ +<template> + <div class="emoji-input"> + <input + v-if="type !== 'textarea'" + :class="classname" + :type="type" + :value="value" + :placeholder="placeholder" + @input="onInput" + @click="setCaret" + @keyup="setCaret" + @keydown="onKeydown" + @keydown.down="cycleForward" + @keydown.up="cycleBackward" + @keydown.shift.tab="cycleBackward" + @keydown.tab="cycleForward" + @keydown.enter="replaceEmoji" + /> + <textarea + v-else + :class="classname" + :value="value" + :placeholder="placeholder" + @input="onInput" + @click="setCaret" + @keyup="setCaret" + @keydown="onKeydown" + @keydown.down="cycleForward" + @keydown.up="cycleBackward" + @keydown.shift.tab="cycleBackward" + @keydown.tab="cycleForward" + @keydown.enter="replaceEmoji" + ></textarea> + <div class="autocomplete-panel" v-if="suggestions"> + <div class="autocomplete-panel-body"> + <div + v-for="(emoji, index) in suggestions" + :key="index" + @click="replace(emoji.utf || (emoji.shortcode + ' '))" + class="autocomplete-item" + :class="{ highlighted: emoji.highlighted }" + > + <span v-if="emoji.img"> + <img :src="emoji.img" /> + </span> + <span v-else>{{emoji.utf}}</span> + <span>{{emoji.shortcode}}</span> + </div> + </div> + </div> + </div> +</template> + +<script src="./emoji-input.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.emoji-input { + .form-control { + width: 100%; + } +} +</style> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js index 425c9c3e..ac4e265a 100644 --- a/src/components/follow_card/follow_card.js +++ b/src/components/follow_card/follow_card.js @@ -1,4 +1,5 @@ import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import RemoteFollow from '../remote_follow/remote_follow.vue' import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' const FollowCard = { @@ -14,13 +15,17 @@ const FollowCard = { } }, components: { - BasicUserCard + BasicUserCard, + RemoteFollow }, computed: { isMe () { return this.$store.state.users.currentUser.id === this.user.id }, following () { return this.updated ? this.updated.following : this.user.following }, showFollow () { return !this.following || this.updated && !this.updated.following + }, + loggedIn () { + return this.$store.state.users.currentUser } }, methods: { diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index 6cb064eb..9f314fd3 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -4,9 +4,12 @@ <span class="faint" v-if="!noFollowsYou && user.follows_you"> {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> + <div class="follow-card-follow-button" v-if="showFollow && !loggedIn"> + <RemoteFollow :user="user" /> + </div> <button - v-if="showFollow" - class="btn btn-default" + v-if="showFollow && loggedIn" + class="btn btn-default follow-card-follow-button" @click="followUser" :disabled="inProgress" :title="requestSent ? $t('user_card.follow_again') : ''" @@ -21,7 +24,7 @@ {{ $t('user_card.follow') }} </template> </button> - <button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="inProgress"> + <button v-if="following" class="btn btn-default follow-card-follow-button pressed" @click="unfollowUser" :disabled="inProgress"> <template v-if="inProgress"> {{ $t('user_card.follow_progress') }} </template> @@ -36,15 +39,17 @@ <script src="./follow_card.js"></script> <style lang="scss"> -.follow-card-content-container { - flex-shrink: 0; - display: flex; - flex-direction: row; - justify-content: space-between; - flex-wrap: wrap; - line-height: 1.5em; +.follow-card { + &-content-container { + flex-shrink: 0; + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + line-height: 1.5em; + } - .btn { + &-follow-button { margin-top: 0.5em; margin-left: auto; width: 10em; diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index 2366ddf7..ea525c95 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -27,7 +27,6 @@ align-content: stretch; flex-grow: 1; margin-top: 0.5em; - margin-bottom: 0.25em; .attachments, .attachment { margin: 0 0.5em 0 0; diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js index 990c0370..5ba8f04e 100644 --- a/src/components/image_cropper/image_cropper.js +++ b/src/components/image_cropper/image_cropper.js @@ -31,6 +31,9 @@ const ImageCropper = { saveButtonLabel: { type: String }, + saveWithoutCroppingButtonlabel: { + type: String + }, cancelButtonLabel: { type: String } @@ -48,6 +51,9 @@ const ImageCropper = { saveText () { return this.saveButtonLabel || this.$t('image_cropper.save') }, + saveWithoutCroppingText () { + return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping') + }, cancelText () { return this.cancelButtonLabel || this.$t('image_cropper.cancel') }, @@ -67,7 +73,19 @@ const ImageCropper = { submit () { this.submitting = true this.avatarUploadError = null - this.submitHandler(this.cropper, this.filename) + this.submitHandler(this.cropper, this.file) + .then(() => this.destroy()) + .catch((err) => { + this.submitError = err + }) + .finally(() => { + this.submitting = false + }) + }, + submitWithoutCropping () { + this.submitting = true + this.avatarUploadError = null + this.submitHandler(false, this.dataUrl) .then(() => this.destroy()) .catch((err) => { this.submitError = err @@ -88,14 +106,14 @@ const ImageCropper = { readFile () { const fileInput = this.$refs.input if (fileInput.files != null && fileInput.files[0] != null) { + this.file = fileInput.files[0] let reader = new window.FileReader() reader.onload = (e) => { this.dataUrl = e.target.result this.$emit('open') } - reader.readAsDataURL(fileInput.files[0]) - this.filename = fileInput.files[0].name || 'unknown' - this.$emit('changed', fileInput.files[0], reader) + reader.readAsDataURL(this.file) + this.$emit('changed', this.file, reader) } }, clearError () { diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue index 24a6f3bd..129e6f46 100644 --- a/src/components/image_cropper/image_cropper.vue +++ b/src/components/image_cropper/image_cropper.vue @@ -7,6 +7,7 @@ <div class="image-cropper-buttons-wrapper"> <button class="btn" type="button" :disabled="submitting" @click="submit" v-text="saveText"></button> <button class="btn" type="button" :disabled="submitting" @click="destroy" v-text="cancelText"></button> + <button class="btn" type="button" :disabled="submitting" @click="submitWithoutCropping" v-text="saveWithoutCroppingText"></button> <i class="icon-spin4 animate-spin" v-if="submitting"></i> </div> <div class="alert error" v-if="submitError"> @@ -36,7 +37,11 @@ } &-buttons-wrapper { - margin-top: 15px; + margin-top: 10px; + + button { + margin-top: 5px; + } } } </style> diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue index 7394668c..64b1a58b 100644 --- a/src/components/link-preview/link-preview.vue +++ b/src/components/link-preview/link-preview.vue @@ -23,6 +23,7 @@ flex-direction: row; cursor: pointer; overflow: hidden; + margin-top: 0.5em; .card-image { flex-shrink: 0; diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 427bf12b..7f666603 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -1,5 +1,5 @@ <template> - <div class="modal-view" v-if="showing" @click.prevent="hide"> + <div class="modal-view media-modal-view" v-if="showing" @click.prevent="hide"> <img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img> <VideoAttachment class="modal-image" @@ -32,18 +32,7 @@ <style lang="scss"> @import '../../_variables.scss'; -.modal-view { - z-index: 1000; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - background-color: rgba(0, 0, 0, 0.5); - +.media-modal-view { &:hover { .modal-view-button-arrow { opacity: 0.75; diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 1c874faa..e4b3d460 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -20,7 +20,7 @@ const mediaUpload = { return } const formData = new FormData() - formData.append('media', file) + formData.append('file', file) self.$emit('uploading') self.uploading = true diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.js b/src/components/mobile_post_status_modal/mobile_post_status_modal.js new file mode 100644 index 00000000..2f24dd08 --- /dev/null +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.js @@ -0,0 +1,91 @@ +import PostStatusForm from '../post_status_form/post_status_form.vue' +import { throttle } from 'lodash' + +const MobilePostStatusModal = { + components: { + PostStatusForm + }, + data () { + return { + hidden: false, + postFormOpen: false, + scrollingDown: false, + inputActive: false, + oldScrollPos: 0, + amountScrolled: 0 + } + }, + created () { + window.addEventListener('scroll', this.handleScroll) + window.addEventListener('resize', this.handleOSK) + }, + destroyed () { + window.removeEventListener('scroll', this.handleScroll) + window.removeEventListener('resize', this.handleOSK) + }, + computed: { + currentUser () { + return this.$store.state.users.currentUser + }, + isHidden () { + return this.hidden || this.inputActive + } + }, + methods: { + openPostForm () { + this.postFormOpen = true + this.hidden = true + + const el = this.$el.querySelector('textarea') + this.$nextTick(function () { + el.focus() + }) + }, + closePostForm () { + this.postFormOpen = false + this.hidden = false + }, + handleOSK () { + // This is a big hack: we're guessing from changed window sizes if the + // on-screen keyboard is active or not. This is only really important + // for phones in portrait mode and it's more important to show the button + // in normal scenarios on all phones, than it is to hide it when the + // keyboard is active. + // Guesswork based on https://www.mydevice.io/#compare-devices + + // for example, iphone 4 and android phones from the same time period + const smallPhone = window.innerWidth < 350 + const smallPhoneKbOpen = smallPhone && window.innerHeight < 345 + + const biggerPhone = !smallPhone && window.innerWidth < 450 + const biggerPhoneKbOpen = biggerPhone && window.innerHeight < 560 + if (smallPhoneKbOpen || biggerPhoneKbOpen) { + this.inputActive = true + } else { + this.inputActive = false + } + }, + handleScroll: throttle(function () { + const scrollAmount = window.scrollY - this.oldScrollPos + const scrollingDown = scrollAmount > 0 + + if (scrollingDown !== this.scrollingDown) { + this.amountScrolled = 0 + this.scrollingDown = scrollingDown + if (!scrollingDown) { + this.hidden = false + } + } else if (scrollingDown) { + this.amountScrolled += scrollAmount + if (this.amountScrolled > 100 && !this.hidden) { + this.hidden = true + } + } + + this.oldScrollPos = window.scrollY + this.scrollingDown = scrollingDown + }, 100) + } +} + +export default MobilePostStatusModal diff --git a/src/components/mobile_post_status_modal/mobile_post_status_modal.vue b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue new file mode 100644 index 00000000..0a451c28 --- /dev/null +++ b/src/components/mobile_post_status_modal/mobile_post_status_modal.vue @@ -0,0 +1,76 @@ +<template> +<div v-if="currentUser"> + <div + class="post-form-modal-view modal-view" + v-show="postFormOpen" + @click="closePostForm" + > + <div class="post-form-modal-panel panel" @click.stop=""> + <div class="panel-heading">{{$t('post_status.new_status')}}</div> + <PostStatusForm class="panel-body" @posted="closePostForm"/> + </div> + </div> + <button + class="new-status-button" + :class="{ 'hidden': isHidden }" + @click="openPostForm" + > + <i class="icon-edit" /> + </button> +</div> +</template> + +<script src="./mobile_post_status_modal.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.post-form-modal-view { + max-height: 100%; + display: block; +} + +.post-form-modal-panel { + flex-shrink: 0; + margin: 25% 0 4em 0; + width: 100%; +} + +.new-status-button { + width: 5em; + height: 5em; + border-radius: 100%; + position: fixed; + bottom: 1.5em; + right: 1.5em; + // TODO: this needs its own color, it has to stand out enough and link color + // is not very optimal for this particular use. + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3); + z-index: 10; + + transition: 0.35s transform; + transition-timing-function: cubic-bezier(0, 1, 0.5, 1); + + &.hidden { + transform: translateY(150%); + } + + i { + font-size: 1.5em; + color: $fallback--text; + color: var(--text, $fallback--text); + } +} + +@media all and (min-width: 801px) { + .new-status-button { + display: none; + } +} + +</style> diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js index 5dd0a9e5..65c9cfb5 100644 --- a/src/components/mute_card/mute_card.js +++ b/src/components/mute_card/mute_card.js @@ -9,7 +9,7 @@ const MuteCard = { }, computed: { user () { - return this.$store.getters.userById(this.userId) + return this.$store.getters.findUser(this.userId) }, muted () { return this.user.muted diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue index e1bfe20b..a4edff03 100644 --- a/src/components/mute_card/mute_card.vue +++ b/src/components/mute_card/mute_card.vue @@ -1,6 +1,6 @@ <template> <basic-user-card :user="user"> - <template slot="secondary-area"> + <div class="mute-card-content-container"> <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> <template v-if="progress"> {{ $t('user_card.unmute_progress') }} @@ -17,8 +17,18 @@ {{ $t('user_card.mute') }} </template> </button> - </template> + </div> </basic-user-card> </template> -<script src="./mute_card.js"></script>
\ No newline at end of file +<script src="./mute_card.js"></script> + +<style lang="scss"> +.mute-card-content-container { + margin-top: 0.5em; + text-align: right; + button { + width: 10em; + } +} +</style> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 7d9807de..fe5b7018 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,6 +1,6 @@ import Status from '../status/status.vue' import UserAvatar from '../user_avatar/user_avatar.vue' -import UserCardContent from '../user_card_content/user_card_content.vue' +import UserCard from '../user_card/user_card.vue' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -13,7 +13,7 @@ const Notification = { }, props: [ 'notification' ], components: { - Status, UserAvatar, UserCardContent + Status, UserAvatar, UserCard }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index a0a55cba..5e9cef97 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -5,9 +5,7 @@ <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/> </a> <div class='notification-right'> - <div class="usercard notification-usercard" v-if="userExpanded"> - <user-card-content :user="notification.action.user" :switcher="false"></user-card-content> - </div> + <UserCard :user="notification.action.user" :rounded="true" :bordered="true" v-if="userExpanded"/> <span class="notification-details"> <div class="name-and-action"> <span class="username" v-if="!!notification.action.user.name_html" :title="'@'+notification.action.user.screen_name" v-html="notification.action.user.name_html"></span> @@ -25,7 +23,11 @@ <small>{{$t('notifications.followed_you')}}</small> </span> </div> - <small class="timeago"><router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + <div class="timeago"> + <router-link v-if="notification.status" :to="{ name: 'conversation', params: { id: notification.status.id } }" class="faint-link"> + <timeago :since="notification.action.created_at" :auto-update="240"></timeago> + </router-link> + </div> </span> <div class="follow-text" v-if="notification.type === 'follow'"> <router-link :to="userProfileLink(notification.action.user)"> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 5e95631a..9fc5e38a 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -11,7 +11,8 @@ const Notifications = { const store = this.$store const credentials = store.state.users.currentUser.credentials - notificationsFetcher.startFetching({ store, credentials }) + const fetcherId = notificationsFetcher.startFetching({ store, credentials }) + this.$store.commit('setNotificationFetcher', { fetcherId }) }, data () { return { diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index b3364afc..c0b458cc 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -45,10 +45,6 @@ } } - .notification-usercard { - margin: 0; - } - .non-mention { display: flex; flex: 1; @@ -126,7 +122,7 @@ } .timeago { - font-size: 12px; + margin-right: .2em; } .icon-retweet.lit { diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index b0882f70..499cbbfb 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,6 +1,7 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' import ScopeSelector from '../scope_selector/scope_selector.vue' +import EmojiInput from '../emoji-input/emoji-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' @@ -30,7 +31,8 @@ const PostStatusForm = { ], components: { MediaUpload, - ScopeSelector + ScopeSelector, + EmojiInput }, mounted () { this.resize(this.$refs.textarea) @@ -174,6 +176,9 @@ const PostStatusForm = { }, formattingOptionsEnabled () { return this.$store.state.instance.formattingOptionsEnabled + }, + postFormats () { + return this.$store.state.instance.postFormats || [] } }, methods: { @@ -222,6 +227,9 @@ const PostStatusForm = { this.highlighted = 0 } }, + onKeydown (e) { + e.stopPropagation() + }, setCaret ({target: {selectionStart}}) { this.caret = selectionStart }, @@ -293,6 +301,8 @@ const PostStatusForm = { }, paste (e) { if (e.clipboardData.files.length > 0) { + // prevent pasting of file as text + e.preventDefault() // Strangely, files property gets emptied after event propagation // Trying to wrap it in array doesn't work. Plus I doubt it's possible // to hold more than one file in clipboard. diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 8beb73a9..3d3a1082 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -10,16 +10,18 @@ <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link> </i18n> <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p> - <input + <EmojiInput v-if="newStatus.spoilerText || alwaysShowSubject" type="text" :placeholder="$t('post_status.content_warning')" v-model="newStatus.spoilerText" - class="form-cw"> + classname="form-control" + /> <textarea ref="textarea" @click="setCaret" @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" + @keydown="onKeydown" @keydown.down="cycleForward" @keydown.up="cycleBackward" @keydown.shift.tab="cycleBackward" @@ -30,15 +32,17 @@ @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize" - @paste="paste"> + @paste="paste" + :disabled="posting" + > </textarea> <div class="visibility-tray"> <span class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> - <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> - <option value="text/html">HTML</option> - <option value="text/markdown">Markdown</option> + <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> + {{$t(`post_status.content_type["${postFormat}"]`)}} + </option> </select> <i class="icon-down-open"></i> </label> @@ -52,14 +56,18 @@ :onScopeChange="changeVis"/> </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 class="autocomplete-panel" v-if="candidates"> + <div class="autocomplete-panel-body"> + <div + v-for="(candidate, index) in candidates" + :key="index" + @click="replace(candidate.utf || (candidate.screen_name + ' '))" + class="autocomplete-item" + :class="{ highlighted: candidate.highlighted }" + > + <span v-if="candidate.img"><img :src="candidate.img" /></span> + <span v-else>{{candidate.utf}}</span> + <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> </div> </div> </div> @@ -81,10 +89,10 @@ <div class="media-upload-wrapper" v-for="file in newStatus.files"> <i class="fa button-icon icon-cancel" @click="removeMediaFile(file)"></i> <div class="media-upload-container attachment"> - <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> - <video v-if="type(file) === 'video'" :src="file.image" controls></video> - <audio v-if="type(file) === 'audio'" :src="file.image" controls></audio> - <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> + <img class="thumbnail media-upload" :src="file.url" v-if="type(file) === 'image'"></img> + <video v-if="type(file) === 'video'" :src="file.url" controls></video> + <audio v-if="type(file) === 'audio'" :src="file.url" controls></audio> + <a v-if="type(file) === 'unknown'" :href="file.url">{{file.url}}</a> </div> </div> </div> @@ -258,52 +266,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> diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index ab6cd64d..8dc00420 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -35,6 +35,9 @@ const registration = { }, computed: { token () { return this.$route.params.token }, + bioPlaceholder () { + return this.$t('registration.bio_placeholder').replace(/\s*\n\s*/g, ' \n') + }, ...mapState({ registrationOpen: (state) => state.instance.registrationOpen, signedIn: (state) => !!state.users.currentUser, diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index e22b308d..110b27bf 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -45,7 +45,7 @@ <div class='form-group'> <label class='form--label' for='bio'>{{$t('registration.bio')}} ({{$t('general.optional')}})</label> - <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="$t('registration.bio_placeholder')"></textarea> + <textarea :disabled="isPending" v-model='user.bio' class='form-control' id='bio' :placeholder="bioPlaceholder"></textarea> </div> <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> diff --git a/src/components/remote_follow/remote_follow.js b/src/components/remote_follow/remote_follow.js new file mode 100644 index 00000000..461d58c9 --- /dev/null +++ b/src/components/remote_follow/remote_follow.js @@ -0,0 +1,10 @@ +export default { + props: [ 'user' ], + computed: { + subscribeUrl () { + // eslint-disable-next-line no-undef + const serverUrl = new URL(this.user.statusnet_profile_url) + return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus` + } + } +} diff --git a/src/components/remote_follow/remote_follow.vue b/src/components/remote_follow/remote_follow.vue new file mode 100644 index 00000000..fb2147bd --- /dev/null +++ b/src/components/remote_follow/remote_follow.vue @@ -0,0 +1,24 @@ +<template> + <div class="remote-follow"> + <form method="POST" :action='subscribeUrl'> + <input type="hidden" name="nickname" :value="user.screen_name"> + <input type="hidden" name="profile" value=""> + <button click="submit" class="remote-button"> + {{ $t('user_card.remote_follow') }} + </button> + </form> + </div> +</template> + +<script src="./remote_follow.js"></script> + +<style lang="scss"> +.remote-follow { + max-width: 220px; + + .remote-button { + width: 100%; + min-height: 28px; + } +} +</style> diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 104be1a8..a85ab674 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -1,8 +1,13 @@ /* eslint-env browser */ +import { filter, trim } from 'lodash' + import TabSwitcher from '../tab_switcher/tab_switcher.js' import StyleSwitcher from '../style_switcher/style_switcher.vue' import InterfaceLanguageSwitcher from '../interface_language_switcher/interface_language_switcher.vue' -import { filter, trim } from 'lodash' +import { extractCommit } from '../../services/version/version.service' + +const pleromaFeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma-fe/commit/' +const pleromaBeCommitUrl = 'https://git.pleroma.social/pleroma/pleroma/commit/' const settings = { data () { @@ -42,6 +47,11 @@ const settings = { pauseOnUnfocusedLocal: user.pauseOnUnfocused, hoverPreviewLocal: user.hoverPreview, + hideMutedPostsLocal: typeof user.hideMutedPosts === 'undefined' + ? instance.hideMutedPosts + : user.hideMutedPosts, + hideMutedPostsDefault: this.$t('settings.values.' + instance.hideMutedPosts), + collapseMessageWithSubjectLocal: typeof user.collapseMessageWithSubject === 'undefined' ? instance.collapseMessageWithSubject : user.collapseMessageWithSubject, @@ -83,7 +93,10 @@ const settings = { // Future spec, still not supported in Nightly 63 as of 08/2018 Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'audioTracks'), playVideosInModal: user.playVideosInModal, - useContainFit: user.useContainFit + useContainFit: user.useContainFit, + + backendVersion: instance.backendVersion, + frontendVersion: instance.frontendVersion } }, components: { @@ -98,7 +111,16 @@ const settings = { currentSaveStateNotice () { return this.$store.state.interface.settings.currentSaveStateNotice }, - instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } + postFormats () { + return this.$store.state.instance.postFormats || [] + }, + instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, + frontendVersionLink () { + return pleromaFeCommitUrl + this.frontendVersion + }, + backendVersionLink () { + return pleromaBeCommitUrl + extractCommit(this.backendVersion) + } }, watch: { hideAttachmentsLocal (value) { @@ -165,6 +187,9 @@ const settings = { value = filter(value.split('\n'), (word) => trim(word).length > 0) this.$store.dispatch('setOption', { name: 'muteWords', value }) }, + hideMutedPostsLocal (value) { + this.$store.dispatch('setOption', { name: 'hideMutedPosts', value }) + }, collapseMessageWithSubjectLocal (value) { this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value }) }, diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index d9b72ee0..6ee103c7 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -37,6 +37,10 @@ <h2>{{$t('nav.timeline')}}</h2> <ul class="setting-list"> <li> + <input type="checkbox" id="hideMutedPosts" v-model="hideMutedPostsLocal"> + <label for="hideMutedPosts">{{$t('settings.hide_muted_posts')}} {{$t('settings.instance_default', { value: hideMutedPostsDefault })}}</label> + </li> + <li> <input type="checkbox" id="collapseMessageWithSubject" v-model="collapseMessageWithSubjectLocal"> <label for="collapseMessageWithSubject"> {{$t('settings.collapse_subject')}} {{$t('settings.instance_default', { value: collapseMessageWithSubjectDefault })}} @@ -105,17 +109,9 @@ {{$t('settings.post_status_content_type')}} <label for="postContentType" class="select"> <select id="postContentType" v-model="postContentTypeLocal"> - <option value="text/plain"> - {{$t('settings.status_content_type_plain')}} - {{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}} - </option> - <option value="text/html"> - HTML - {{postContentTypeDefault == 'text/html' ? $t('settings.instance_default_simple') : ''}} - </option> - <option value="text/markdown"> - Markdown - {{postContentTypeDefault == 'text/markdown' ? $t('settings.instance_default_simple') : ''}} + <option v-for="postFormat in postFormats" :key="postFormat" :value="postFormat"> + {{$t(`post_status.content_type["${postFormat}"]`)}} + {{postContentTypeDefault === postFormat ? $t('settings.instance_default_simple') : ''}} </option> </select> <i class="icon-down-open"/> @@ -275,6 +271,28 @@ </div> </div> </div> + <div :label="$t('settings.version.title')" > + <div class="setting-item"> + <ul class="setting-list"> + <li> + <p>{{$t('settings.version.backend_version')}}</p> + <ul class="option-list"> + <li> + <a :href="backendVersionLink" target="_blank">{{backendVersion}}</a> + </li> + </ul> + </li> + <li> + <p>{{$t('settings.version.frontend_version')}}</p> + <ul class="option-list"> + <li> + <a :href="frontendVersionLink" target="_blank">{{frontendVersion}}</a> + </li> + </ul> + </li> + </ul> + </div> + </div> </tab-switcher> </keep-alive> </div> diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index b5c49059..567d2e5e 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -1,18 +1,17 @@ -import UserCardContent from '../user_card_content/user_card_content.vue' +import UserCard from '../user_card/user_card.vue' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' - -// TODO: separate touch gesture stuff into their own utils if more components want them -const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]] - -const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY]) +import GestureService from '../../services/gesture_service/gesture_service' const SideDrawer = { props: [ 'logout' ], data: () => ({ closed: true, - touchCoord: [0, 0] + closeGesture: undefined }), - components: { UserCardContent }, + created () { + this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer) + }, + components: { UserCard }, computed: { currentUser () { return this.$store.state.users.currentUser @@ -46,13 +45,10 @@ const SideDrawer = { this.toggleDrawer() }, touchStart (e) { - this.touchCoord = touchEventCoord(e) + GestureService.beginSwipe(e, this.closeGesture) }, touchMove (e) { - const delta = deltaCoord(this.touchCoord, touchEventCoord(e)) - if (delta[0] < -30 && Math.abs(delta[1]) < Math.abs(delta[0]) && !this.closed) { - this.toggleDrawer() - } + GestureService.updateSwipe(e, this.closeGesture) } } } diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 6996380d..e5046496 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -2,25 +2,21 @@ <div class="side-drawer-container" :class="{ 'side-drawer-container-closed': closed, 'side-drawer-container-open': !closed }" > + <div class="side-drawer-darken" :class="{ 'side-drawer-darken-closed': closed}" /> <div class="side-drawer" :class="{'side-drawer-closed': closed}" @touchstart="touchStart" @touchmove="touchMove" > <div class="side-drawer-heading" @click="toggleDrawer"> - <user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"/> + <UserCard :user="currentUser" :hideBio="true" v-if="currentUser"/> <div class="side-drawer-logo-wrapper" v-else> <img :src="logo"/> <span>{{sitename}}</span> </div> </div> <ul> - <li v-if="currentUser" @click="toggleDrawer"> - <router-link :to="{ name: 'new-status', params: { username: currentUser.screen_name } }"> - {{ $t("post_status.new_status") }} - </router-link> - </li> - <li v-else @click="toggleDrawer"> + <li v-if="!currentUser" @click="toggleDrawer"> <router-link :to="{ name: 'login' }"> {{ $t("login.login") }} </router-link> @@ -116,17 +112,33 @@ height: 100%; display: flex; align-items: stretch; + transition-duration: 0s; + transition-property: transform; } .side-drawer-container-open { - transition-delay: 0.0s; - transition-property: left; + transform: translate(0%); } .side-drawer-container-closed { - left: -100%; - transition-delay: 0.5s; - transition-property: left; + transition-delay: 0.35s; + transform: translate(-100%); +} + +.side-drawer-darken { + top: 0; + left: 0; + width: 100vw; + height: 100vh; + position: fixed; + z-index: -1; + transition: 0.35s; + transition-property: background-color; + background-color: rgba(0, 0, 0, 0.5); +} + +.side-drawer-darken-closed { + background-color: rgba(0, 0, 0, 0); } .side-drawer-click-outside { @@ -135,8 +147,9 @@ .side-drawer { overflow-x: hidden; - transition: 0.35s; transition-timing-function: cubic-bezier(0, 1, 0.5, 1); + transition: 0.35s; + transition-property: transform; margin: 0 0 0 -100px; padding: 0 0 1em 100px; width: 80%; @@ -181,15 +194,6 @@ display: flex; padding: 0; margin: 0; - - .profile-panel-background { - border-radius: 0; - .panel-heading { - background: transparent; - flex-direction: column; - align-items: stretch; - } - } } .side-drawer ul { diff --git a/src/components/status/status.js b/src/components/status/status.js index fbbca6c4..550fe76f 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -3,7 +3,7 @@ import FavoriteButton from '../favorite_button/favorite_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' import DeleteButton from '../delete_button/delete_button.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' -import UserCardContent from '../user_card_content/user_card_content.vue' +import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' @@ -145,11 +145,11 @@ const Status = { return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id) }, replyToName () { - const user = this.$store.state.users.usersObject[this.status.in_reply_to_user_id] - if (user) { - return user.screen_name - } else { + if (this.status.in_reply_to_screen_name) { return this.status.in_reply_to_screen_name + } else { + const user = this.$store.getters.findUser(this.status.in_reply_to_user_id) + return user && user.screen_name } }, hideReply () { @@ -259,7 +259,7 @@ const Status = { RetweetButton, DeleteButton, PostStatusForm, - UserCardContent, + UserCard, UserAvatar, Gallery, LinkPreview @@ -310,7 +310,6 @@ const Status = { this.replying = !this.replying }, gotoOriginal (id) { - // only handled by conversation, not status_or_conversation if (this.inConversation) { this.$emit('goto', id) } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 46919d7c..1f415534 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -12,7 +12,7 @@ </div> </template> <template v-else> - <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> + <div v-if="retweet && !noHeading && !inConversation" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> <UserAvatar class="media-left" v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> <div class="media-body faint"> <span class="user-name"> @@ -24,16 +24,14 @@ </div> </div> - <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status"> + <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :style="[ userStyle ]" class="media status"> <div v-if="!noHeading" class="media-left"> <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/> </router-link> </div> <div class="status-body"> - <div class="usercard" v-if="userExpanded"> - <user-card-content :user="status.user" :switcher="false"></user-card-content> - </div> + <UserCard :user="status.user" :rounded="true" :bordered="true" class="status-usercard" v-if="userExpanded"/> <div v-if="!noHeading" class="media-heading"> <div class="heading-name-row"> <div class="name-and-account-name"> @@ -77,13 +75,13 @@ <router-link :to="replyProfileLink"> {{replyToName}} </router-link> - <span class="faint replies-separator" v-if="replies.length"> + <span class="faint replies-separator" v-if="replies && replies.length"> - </span> </div> <div class="replies" v-if="inConversation && !isPreview"> - <span class="faint" v-if="replies.length">{{$t('status.replies_list')}}</span> - <span class="reply-link faint" v-for="reply in replies"> + <span class="faint" v-if="replies && replies.length">{{$t('status.replies_list')}}</span> + <span class="reply-link faint" v-if="replies" v-for="reply in replies"> <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}</a> </span> </div> @@ -137,9 +135,8 @@ <div v-if="!noHeading && !isPreview" class='status-actions media-body'> <div v-if="loggedIn"> - <a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')"> - <i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i> - </a> + <i class="button-icon icon-reply" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')" :class="{'icon-reply-active': replying}"></i> + <span v-if="status.replies_count > 0">{{status.replies_count}}</span> </div> <retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button> <favorite-button :loggedIn='loggedIn' :status='status'></favorite-button> @@ -248,8 +245,7 @@ $status-margin: 0.75em; padding: 0; } - .usercard { - margin: 0; + .status-usercard { margin-bottom: $status-margin; } @@ -422,6 +418,11 @@ $status-margin: 0.75em; max-height: 400px; vertical-align: middle; object-fit: contain; + + &.emoji { + width: 32px; + height: 32px; + } } blockquote { @@ -549,6 +550,7 @@ $status-margin: 0.75em; .icon-reply:hover { color: $fallback--cBlue; color: var(--cBlue, $fallback--cBlue); + cursor: pointer; } .icon-reply.icon-reply-active { diff --git a/src/components/status_or_conversation/status_or_conversation.js b/src/components/status_or_conversation/status_or_conversation.js deleted file mode 100644 index 441552ca..00000000 --- a/src/components/status_or_conversation/status_or_conversation.js +++ /dev/null @@ -1,22 +0,0 @@ -import Status from '../status/status.vue' -import Conversation from '../conversation/conversation.vue' - -const statusOrConversation = { - props: ['statusoid'], - data () { - return { - expanded: false - } - }, - components: { - Status, - Conversation - }, - methods: { - toggleExpanded () { - this.expanded = !this.expanded - } - } -} - -export default statusOrConversation diff --git a/src/components/status_or_conversation/status_or_conversation.vue b/src/components/status_or_conversation/status_or_conversation.vue deleted file mode 100644 index 9647d5eb..00000000 --- a/src/components/status_or_conversation/status_or_conversation.vue +++ /dev/null @@ -1,14 +0,0 @@ -<template> - <div> - <conversation v-if="expanded" @toggleExpanded="toggleExpanded" :collapsable="true" :statusoid="statusoid"></conversation> - <status v-if="!expanded" @toggleExpanded="toggleExpanded" :expandable="true" :inConversation="false" :focused="false" :statusoid="statusoid"></status> - </div> -</template> - -<script src="./status_or_conversation.js"></script> - -<style lang="scss"> - .spacer { - height: 1em - } -</style> diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 655bfb3f..1da7d5cc 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,6 +1,6 @@ import Status from '../status/status.vue' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' -import StatusOrConversation from '../status_or_conversation/status_or_conversation.vue' +import Conversation from '../conversation/conversation.vue' import { throttle } from 'lodash' const Timeline = { @@ -43,7 +43,7 @@ const Timeline = { }, components: { Status, - StatusOrConversation + Conversation }, created () { const store = this.$store @@ -132,7 +132,9 @@ const Timeline = { } if (count > 0) { // only 'stream' them when you're scrolled to the top - if (window.pageYOffset < 15 && + const doc = document.documentElement + const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0) + if (top < 15 && !this.paused && !(this.unfocused && this.$store.state.config.pauseOnUnfocused) ) { diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 8f28d65c..e0a34bd1 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -16,7 +16,13 @@ </div> <div :class="classes.body"> <div class="timeline"> - <status-or-conversation v-for="status in timeline.visibleStatuses" :key="status.id" v-bind:statusoid="status" class="status-fadein"></status-or-conversation> + <conversation + v-for="status in timeline.visibleStatuses" + class="status-fadein" + :key="status.id" + :statusoid="status" + :collapsable="true" + /> </div> </div> <div :class="classes.footer"> diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js index e513b993..e6fed3b5 100644 --- a/src/components/user_avatar/user_avatar.js +++ b/src/components/user_avatar/user_avatar.js @@ -23,6 +23,11 @@ const UserAvatar = { imageLoadError () { this.showPlaceholder = true } + }, + watch: { + src () { + this.showPlaceholder = false + } } } diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card/user_card.js index 7a7b89d4..197c61d5 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card/user_card.js @@ -1,10 +1,11 @@ import UserAvatar from '../user_avatar/user_avatar.vue' +import RemoteFollow from '../remote_follow/remote_follow.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 { - props: [ 'user', 'switcher', 'selected', 'hideBio' ], + props: [ 'user', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered' ], data () { return { followRequestInProgress: false, @@ -15,8 +16,18 @@ export default { betterShadow: this.$store.state.interface.browserSupport.cssFilter } }, + created () { + this.$store.dispatch('fetchUserRelationship', this.user.id) + }, computed: { - headingStyle () { + classes () { + return [{ + 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius + 'user-card-rounded': this.rounded === true, // set border-radius for all sides + 'user-card-bordered': this.bordered === true // set border for all sides + }] + }, + style () { const color = this.$store.state.config.customTheme.colors ? this.$store.state.config.customTheme.colors.bg // v2 : this.$store.state.config.colors.bg // v1 @@ -89,36 +100,37 @@ export default { } }, components: { - UserAvatar + UserAvatar, + RemoteFollow }, methods: { followUser () { + const store = this.$store this.followRequestInProgress = true - requestFollow(this.user, this.$store).then(({sent}) => { + requestFollow(this.user, store).then(({sent}) => { this.followRequestInProgress = false this.followRequestSent = sent }) }, unfollowUser () { + const store = this.$store this.followRequestInProgress = true - requestUnfollow(this.user, this.$store).then(() => { + requestUnfollow(this.user, store).then(() => { this.followRequestInProgress = false + store.commit('removeStatus', { timeline: 'friends', userId: this.user.id }) }) }, blockUser () { - const store = this.$store - store.state.api.backendInteractor.blockUser(this.user.id) - .then((blockedUser) => store.commit('addNewUsers', [blockedUser])) + this.$store.dispatch('blockUser', this.user.id) }, unblockUser () { - const store = this.$store - store.state.api.backendInteractor.unblockUser(this.user.id) - .then((unblockedUser) => store.commit('addNewUsers', [unblockedUser])) + this.$store.dispatch('unblockUser', this.user.id) }, - toggleMute () { - const store = this.$store - store.commit('setMuted', {user: this.user, muted: !this.user.muted}) - store.state.api.backendInteractor.setUserMute(this.user) + muteUser () { + this.$store.dispatch('muteUser', this.user.id) + }, + unmuteUser () { + this.$store.dispatch('unmuteUser', this.user.id) }, setProfileView (v) { if (this.switcher) { diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card/user_card.vue index 702c3385..3259d1c5 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card/user_card.vue @@ -1,6 +1,6 @@ <template> -<div id="heading" class="profile-panel-background" :style="headingStyle"> - <div class="panel-heading text-center"> +<div class="user-card" :class="classes" :style="style"> + <div class="panel-heading"> <div class='user-info'> <div class='container'> <router-link :to="userProfileLink(user)"> @@ -11,7 +11,7 @@ <div :title="user.name" class='user-name' v-if="user.name_html" v-html="user.name_html"></div> <div :title="user.name" class='user-name' v-else>{{user.name}}</div> <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser"> - <i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i> + <i class="button-icon icon-pencil usersettings" :title="$t('tool_tip.user_settings')"></i> </router-link> <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> <i class="icon-link-ext usersettings"></i> @@ -74,24 +74,18 @@ </div> <div class='mute' v-if='isOtherUser && loggedIn'> <span v-if='user.muted'> - <button @click="toggleMute" class="pressed"> + <button @click="unmuteUser" class="pressed"> {{ $t('user_card.muted') }} </button> </span> <span v-if='!user.muted'> - <button @click="toggleMute"> + <button @click="muteUser"> {{ $t('user_card.mute') }} </button> </span> </div> - <div class="remote-follow" v-if='!loggedIn && user.is_local'> - <form method="POST" :action='subscribeUrl'> - <input type="hidden" name="nickname" :value="user.screen_name"> - <input type="hidden" name="profile" value=""> - <button click="submit" class="remote-button"> - {{ $t('user_card.remote_follow') }} - </button> - </form> + <div v-if='!loggedIn && user.is_local'> + <RemoteFollow :user="user" /> </div> <div class='block' v-if='isOtherUser && loggedIn'> <span v-if='user.statusnet_blocking'> @@ -108,7 +102,7 @@ </div> </div> </div> - <div class="panel-body profile-panel-body" v-if="!hideBio"> + <div class="panel-body" v-if="!hideBio"> <div v-if="!hideUserStatsLocal && switcher" class="user-counts"> <div class="user-count" v-on:click.prevent="setProfileView('statuses')"> <h5>{{ $t('user_card.statuses') }}</h5> @@ -123,40 +117,75 @@ <span>{{user.followers_count}}</span> </div> </div> - <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="profile-bio" v-html="user.description_html"></p> - <p v-else-if="!hideBio" class="profile-bio">{{ user.description }}</p> + <p @click.prevent="linkClicked" v-if="!hideBio && user.description_html" class="user-card-bio" v-html="user.description_html"></p> + <p v-else-if="!hideBio" class="user-card-bio">{{ user.description }}</p> </div> </div> </template> -<script src="./user_card_content.js"></script> +<script src="./user_card.js"></script> <style lang="scss"> @import '../../_variables.scss'; -.profile-panel-background { +.user-card { background-size: cover; - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); overflow: hidden; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - .panel-heading { padding: .5em 0; text-align: center; box-shadow: none; + background: transparent; + flex-direction: column; + align-items: stretch; } -} -.profile-panel-body { - word-wrap: break-word; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%); + .panel-body { + word-wrap: break-word; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%); + background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%); + } + + p { + margin-bottom: 0; + } - .profile-bio { + &-bio { text-align: center; + + img { + object-fit: contain; + vertical-align: middle; + max-width: 100%; + max-height: 400px; + + .emoji { + width: 32px; + height: 32px; + } + } + } + + // Modifiers + + &-rounded-t { + border-top-left-radius: $fallback--panelRadius; + border-top-left-radius: var(--panelRadius, $fallback--panelRadius); + border-top-right-radius: $fallback--panelRadius; + border-top-right-radius: var(--panelRadius, $fallback--panelRadius); + } + + &-rounded { + border-radius: $fallback--panelRadius; + border-radius: var(--panelRadius, $fallback--panelRadius); + } + + &-bordered { + border-width: 1px; + border-style: solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); } } @@ -340,11 +369,6 @@ min-height: 28px; } - .remote-follow { - max-width: 220px; - min-height: 28px; - } - .follow { max-width: 220px; min-height: 28px; @@ -393,25 +417,4 @@ text-decoration: none; } } - -.usercard { - width: fill-available; - border-radius: $fallback--panelRadius; - border-radius: var(--panelRadius, $fallback--panelRadius); - border-style: solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - border-width: 1px; - overflow: hidden; - - .panel-heading { - background: transparent; - flex-direction: column; - align-items: stretch; - } - - p { - margin-bottom: 0; - } -} </style> diff --git a/src/components/user_panel/user_panel.js b/src/components/user_panel/user_panel.js index 15804b88..d4478290 100644 --- a/src/components/user_panel/user_panel.js +++ b/src/components/user_panel/user_panel.js @@ -1,6 +1,6 @@ import LoginForm from '../login_form/login_form.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' -import UserCardContent from '../user_card_content/user_card_content.vue' +import UserCard from '../user_card/user_card.vue' const UserPanel = { computed: { @@ -9,7 +9,7 @@ const UserPanel = { components: { LoginForm, PostStatusForm, - UserCardContent + UserCard } } diff --git a/src/components/user_panel/user_panel.vue b/src/components/user_panel/user_panel.vue index 2d5cb500..8310f30e 100644 --- a/src/components/user_panel/user_panel.vue +++ b/src/components/user_panel/user_panel.vue @@ -1,7 +1,7 @@ <template> <div class="user-panel"> <div v-if='user' class="panel panel-default" style="overflow: visible;"> - <user-card-content :user="user" :switcher="false" :hideBio="true"></user-card-content> + <UserCard :user="user" :hideBio="true" rounded="top"/> <div class="panel-footer"> <post-status-form v-if='user'></post-status-form> </div> @@ -11,13 +11,3 @@ </template> <script src="./user_panel.js"></script> - -<style lang="scss"> -.user-panel { - .profile-panel-background .panel-heading { - background: transparent; - flex-direction: column; - align-items: stretch; - } -} -</style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 7708141c..82df4510 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,6 +1,6 @@ import { compose } from 'vue-compose' import get from 'lodash/get' -import UserCardContent from '../user_card_content/user_card_content.vue' +import UserCard from '../user_card/user_card.vue' import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' @@ -9,7 +9,7 @@ import withList from '../../hocs/with_list/with_list' const FollowerList = compose( withLoadMore({ fetch: (props, $store) => $store.dispatch('addFollowers', props.userId), - select: (props, $store) => get($store.getters.userById(props.userId), 'followers', []), + select: (props, $store) => get($store.getters.findUser(props.userId), 'followers', []), destory: (props, $store) => $store.dispatch('clearFollowers', props.userId), childPropName: 'entries', additionalPropNames: ['userId'] @@ -20,7 +20,7 @@ const FollowerList = compose( const FriendList = compose( withLoadMore({ fetch: (props, $store) => $store.dispatch('addFriends', props.userId), - select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []), + select: (props, $store) => get($store.getters.findUser(props.userId), 'friends', []), destory: (props, $store) => $store.dispatch('clearFriends', props.userId), childPropName: 'entries', additionalPropNames: ['userId'] @@ -31,28 +31,16 @@ const FriendList = compose( const UserProfile = { data () { return { - error: false + error: false, + fetchedUserId: null } }, created () { - this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.commit('clearTimeline', { timeline: 'favorites' }) - this.$store.commit('clearTimeline', { timeline: 'media' }) - 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) - .catch((reason) => { - const errorMessage = get(reason, 'error.error') - if (errorMessage === 'No user with such user_id') { // Known error - this.error = this.$t('user_profile.profile_does_not_exist') - } else if (errorMessage) { - this.error = errorMessage - } else { - this.error = this.$t('user_profile.profile_loading_error') - } - }) + this.fetchUserId() + .then(() => this.startUp()) + } else { + this.startUp() } }, destroyed () { @@ -69,7 +57,7 @@ const UserProfile = { return this.$store.state.statuses.timelines.media }, userId () { - return this.$route.params.id || this.user.id + return this.$route.params.id || this.user.id || this.fetchedUserId }, userName () { return this.$route.params.name || this.user.screen_name @@ -79,10 +67,9 @@ const UserProfile = { this.userId === this.$store.state.users.currentUser.id }, userInStore () { - if (this.isExternal) { - return this.$store.getters.userById(this.userId) - } - return this.$store.getters.userByName(this.userName) + const routeParams = this.$route.params + // This needs fetchedUserId so that computed will be refreshed when user is fetched + return this.$store.getters.findUser(this.fetchedUserId || routeParams.name || routeParams.id) }, user () { if (this.timeline.statuses[0]) { @@ -93,9 +80,6 @@ const UserProfile = { } return {} }, - fetchBy () { - return this.isExternal ? this.userId : this.userName - }, isExternal () { return this.$route.name === 'external-user-profile' }, @@ -109,14 +93,38 @@ const UserProfile = { methods: { startFetchFavorites () { if (this.isUs) { - this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy }) + this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.userId }) } }, + fetchUserId () { + let fetchPromise + if (this.userId && !this.$route.params.name) { + fetchPromise = this.$store.dispatch('fetchUser', this.userId) + } else { + fetchPromise = this.$store.dispatch('fetchUser', this.userName) + .then(({ id }) => { + this.fetchedUserId = id + }) + } + return fetchPromise + .catch((reason) => { + const errorMessage = get(reason, 'error.error') + if (errorMessage === 'No user with such user_id') { // Known error + this.error = this.$t('user_profile.profile_does_not_exist') + } else if (errorMessage) { + this.error = errorMessage + } else { + this.error = this.$t('user_profile.profile_loading_error') + } + }) + .then(() => this.startUp()) + }, startUp () { - this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy }) - this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy }) - - this.startFetchFavorites() + if (this.userId) { + this.$store.dispatch('startFetching', { timeline: 'user', userId: this.userId }) + this.$store.dispatch('startFetching', { timeline: 'media', userId: this.userId }) + this.startFetchFavorites() + } }, cleanUp () { this.$store.dispatch('stopFetching', 'user') @@ -128,23 +136,26 @@ const UserProfile = { } }, watch: { - userName () { - if (this.isExternal) { - return + // userId can be undefined if we don't know it yet + userId (newVal) { + if (newVal) { + this.cleanUp() + this.startUp() } - this.cleanUp() - this.startUp() }, - userId () { - if (!this.isExternal) { - return + userName () { + if (this.$route.params.name) { + this.fetchUserId() + this.cleanUp() + this.startUp() } - this.cleanUp() - this.startUp() + }, + $route () { + this.$refs.tabSwitcher.activateTab(0)() } }, components: { - UserCardContent, + UserCard, Timeline, FollowerList, FriendList diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index a3d2825f..d449eb85 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -1,12 +1,8 @@ <template> <div> <div v-if="user.id" class="user-profile panel panel-default"> - <user-card-content - :user="user" - :switcher="true" - :selected="timeline.viewing" - /> - <tab-switcher :renderOnlyFocused="true"> + <UserCard :user="user" :switcher="true" :selected="timeline.viewing" rounded="top"/> + <tab-switcher :renderOnlyFocused="true" ref="tabSwitcher"> <Timeline :label="$t('user_card.statuses')" :disabled="!user.statuses_count" @@ -15,7 +11,7 @@ :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" - :user-id="fetchBy" + :user-id="userId" /> <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> <FriendList :userId="userId" /> @@ -29,7 +25,7 @@ :embedded="true" :title="$t('user_card.media')" timeline-name="media" :timeline="media" - :user-id="fetchBy" + :user-id="userId" /> <Timeline v-if="isUs" @@ -64,11 +60,6 @@ flex: 2; flex-basis: 500px; - .profile-panel-background .panel-heading { - background: transparent; - flex-direction: column; - align-items: stretch; - } .userlist-placeholder { display: flex; justify-content: center; diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 1911ab23..4b277b6c 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -8,6 +8,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' import BlockCard from '../block_card/block_card.vue' import MuteCard from '../mute_card/mute_card.vue' +import EmojiInput from '../emoji-input/emoji-input.vue' import withSubscription from '../../hocs/with_subscription/with_subscription' import withList from '../../hocs/with_list/with_list' @@ -71,7 +72,8 @@ const UserSettings = { TabSwitcher, ImageCropper, BlockList, - MuteList + MuteList, + EmojiInput }, computed: { user () { @@ -159,8 +161,14 @@ const UserSettings = { } reader.readAsDataURL(file) }, - submitAvatar (cropper) { - const img = cropper.getCroppedCanvas().toDataURL('image/jpeg') + submitAvatar (cropper, file) { + let img + if (cropper) { + img = cropper.getCroppedCanvas().toDataURL(file.type) + } else { + img = file + } + return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 7bd391ba..c08698dc 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -22,9 +22,18 @@ <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> + <EmojiInput + type="text" + v-model="newName" + id="username" + classname="name-changer" + /> <p>{{$t('settings.bio')}}</p> - <textarea class="bio" v-model="newBio"></textarea> + <EmojiInput + type="textarea" + v-model="newBio" + classname="bio" + /> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label> @@ -61,7 +70,7 @@ <h2>{{$t('settings.avatar')}}</h2> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="current-avatar"></img> + <img :src="user.profile_image_url_original" class="current-avatar" /> <p>{{$t('settings.set_new_avatar')}}</p> <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button> <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" /> @@ -69,12 +78,11 @@ <div class="setting-item"> <h2>{{$t('settings.profile_banner')}}</h2> <p>{{$t('settings.current_profile_banner')}}</p> - <img :src="user.cover_photo" class="banner"></img> + <img :src="user.cover_photo" class="banner" /> <p>{{$t('settings.set_new_profile_banner')}}</p> - <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview"> - </img> + <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" /> <div> - <input type="file" @change="uploadFile('banner', $event)" ></input> + <input type="file" @change="uploadFile('banner', $event)" /> </div> <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i> <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button> @@ -86,10 +94,9 @@ <div class="setting-item"> <h2>{{$t('settings.profile_background')}}</h2> <p>{{$t('settings.set_new_profile_background')}}</p> - <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview"> - </img> + <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" /> <div> - <input type="file" @change="uploadFile('background', $event)" ></input> + <input type="file" @change="uploadFile('background', $event)" /> </div> <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i> <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button> @@ -165,7 +172,7 @@ <h2>{{$t('settings.follow_import')}}</h2> <p>{{$t('settings.import_followers_from_a_csv_file')}}</p> <form> - <input type="file" ref="followlist" v-on:change="followListChange"></input> + <input type="file" ref="followlist" v-on:change="followListChange" /> </form> <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i> <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button> @@ -192,6 +199,12 @@ <template slot="empty">{{$t('settings.no_blocks')}}</template> </block-list> </div> + + <div :label="$t('settings.mutes_tab')"> + <mute-list :refresh="true"> + <template slot="empty">{{$t('settings.no_mutes')}}</template> + </mute-list> + </div> </tab-switcher> </div> </div> |
