diff options
Diffstat (limited to 'src/components')
52 files changed, 1395 insertions, 835 deletions
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index a93c9014..c58bebd3 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -88,7 +88,7 @@ .attachment { position: relative; - margin: 0.5em 0.5em 0em 0em; + margin-top: 0.5em; align-self: flex-start; line-height: 0; diff --git a/src/components/autocomplete_input/autocomplete_input.js b/src/components/autocomplete_input/autocomplete_input.js deleted file mode 100644 index 1544e7bb..00000000 --- a/src/components/autocomplete_input/autocomplete_input.js +++ /dev/null @@ -1,149 +0,0 @@ -import Completion from '../../services/completion/completion.js' -import { take, filter, map } from 'lodash' - -const AutoCompleteInput = { - props: [ - 'id', - 'classObj', - 'value', - 'placeholder', - 'autoResize', - 'multiline', - 'drop', - 'dragoverPrevent', - 'paste', - 'keydownMetaEnter', - 'keyupCtrlEnter' - ], - components: {}, - mounted () { - this.autoResize && this.resize(this.$refs.textarea) - const textLength = this.$refs.textarea.value.length - this.$refs.textarea.setSelectionRange(textLength, textLength) - }, - data () { - return { - caret: 0, - highlighted: 0 - } - }, - computed: { - users () { - return this.$store.state.users.users - }, - emoji () { - return this.$store.state.instance.emoji || [] - }, - customEmoji () { - return this.$store.state.instance.customEmoji || [] - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} - return word - }, - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - const query = this.textAtCaret.slice(1).toUpperCase() - const matchedUsers = filter(this.users, (user) => { - return user.screen_name.toUpperCase().startsWith(query) || - user.name && user.name.toUpperCase().startsWith(query) - }) - if (matchedUsers.length <= 0) { - return false - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) - if (matchedEmoji.length <= 0) { - return false - } - return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - return false - } - } - }, - methods: { - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - replace (replacement) { - this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement)) - const el = this.$el.querySelector('textarea') || this.$el.querySelector('input') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.$emit('input', Completion.replaceWord(this.value, this.wordAtCaret, replacement)) - const el = this.$el.querySelector('textarea') || this.$el.querySelector('input') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - resize (e) { - const target = e.target || e - if (!(target instanceof window.Element)) { return } - const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + - Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) - // Auto is needed to make textbox shrink when removing lines - target.style.height = 'auto' - target.style.height = `${target.scrollHeight - vertPadding}px` - if (target.value === '') { - target.style.height = null - } - } - } -} - -export default AutoCompleteInput diff --git a/src/components/autocomplete_input/autocomplete_input.vue b/src/components/autocomplete_input/autocomplete_input.vue deleted file mode 100644 index 1e26b76b..00000000 --- a/src/components/autocomplete_input/autocomplete_input.vue +++ /dev/null @@ -1,104 +0,0 @@ -<template> - <div style="display: flex; flex-direction: column;"> - <textarea - v-if="multiline" - ref="textarea" - rows="1" - :value="value" :class="classObj" :id="id" :placeholder="placeholder" - @input="$emit('input', $event.target.value), autoResize && resize($event)" - @click="setCaret" - @keyup="setCaret" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @drop="drop && drop($event)" - @dragover.prevent="dragoverPrevent && dragoverPrevent($event)" - @paste="paste && paste($event)" - @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)" - @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)"> - </textarea> - <input - v-else - ref="textarea" - :value="value" :class="classObj" :id="id" :placeholder="placeholder" - @input="$emit('input', $event.target.value), autoResize && resize($event)" - @click="setCaret" - @keyup="setCaret" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @drop="drop && drop($event)" - @dragover.prevent="dragoverPrevent && dragoverPrevent($event)" - @paste="paste && paste($event)" - @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter($event)" - @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter($event)"/> - <div style="position:relative;" v-if="candidates"> - <div class="autocomplete-panel"> - <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> - <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> - <span v-if="candidate.img"><img :src="candidate.img"></img></span> - <span v-else>{{candidate.utf}}</span> - <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> - </div> - </div> - </div> - </div> - </div> -</template> - -<script src="./autocomplete_input.js"></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.autocomplete-panel { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - position: absolute; - z-index: 1; - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - // this doesn't match original but i don't care, making it uniform. - box-shadow: var(--popupShadow); - min-width: 75%; - background: $fallback--bg; - background: var(--bg, $fallback--bg); - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); -} - -.autocomplete { - cursor: pointer; - padding: 0.2em 0.4em 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); - display: flex; - - img { - width: 24px; - height: 24px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - object-fit: contain; - } - - span { - line-height: 24px; - margin: 0 0.1em 0 0.2em; - } - - small { - margin-left: .5em; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - &.highlighted { - background-color: $fallback--fg; - background-color: var(--lightBg, $fallback--fg); - } -} -</style> diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js new file mode 100644 index 00000000..a8441446 --- /dev/null +++ b/src/components/basic_user_card/basic_user_card.js @@ -0,0 +1,28 @@ +import UserCardContent from '../user_card_content/user_card_content.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + +const BasicUserCard = { + props: [ + 'user' + ], + data () { + return { + userExpanded: false + } + }, + components: { + UserCardContent, + UserAvatar + }, + methods: { + toggleUserExpanded () { + this.userExpanded = !this.userExpanded + }, + userProfileLink (user) { + return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + } + } +} + +export default BasicUserCard diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue new file mode 100644 index 00000000..77fb0aa0 --- /dev/null +++ b/src/components/basic_user_card/basic_user_card.vue @@ -0,0 +1,79 @@ +<template> + <div class="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> + <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> + <div> + <router-link class="user-card-screen-name" :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + </div> + <slot></slot> + </div> + </div> +</template> + +<script src="./basic_user_card.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.user-card { + display: flex; + flex: 1 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); + + &-collapsed-content { + margin-left: 0.7em; + text-align: left; + flex: 1; + min-width: 0; + } + + &-user-name { + img { + object-fit: contain; + height: 16px; + width: 16px; + vertical-align: middle; + } + } + + &-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 new file mode 100644 index 00000000..11fa27b4 --- /dev/null +++ b/src/components/block_card/block_card.js @@ -0,0 +1,37 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const BlockCard = { + props: ['userId'], + data () { + return { + progress: false + } + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + blocked () { + return this.user.statusnet_blocking + } + }, + components: { + BasicUserCard + }, + methods: { + unblockUser () { + this.progress = true + this.$store.dispatch('unblockUser', this.user.id).then(() => { + this.progress = false + }) + }, + blockUser () { + this.progress = true + this.$store.dispatch('blockUser', this.user.id).then(() => { + this.progress = false + }) + } + } +} + +export default BlockCard diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue new file mode 100644 index 00000000..8eb56e25 --- /dev/null +++ b/src/components/block_card/block_card.vue @@ -0,0 +1,34 @@ +<template> + <basic-user-card :user="user"> + <div class="block-card-content-container"> + <button class="btn btn-default" @click="unblockUser" :disabled="progress" v-if="blocked"> + <template v-if="progress"> + {{ $t('user_card.unblock_progress') }} + </template> + <template v-else> + {{ $t('user_card.unblock') }} + </template> + </button> + <button class="btn btn-default" @click="blockUser" :disabled="progress" v-else> + <template v-if="progress"> + {{ $t('user_card.block_progress') }} + </template> + <template v-else> + {{ $t('user_card.block') }} + </template> + </button> + </div> + </basic-user-card> +</template> + +<script src="./block_card.js"></script> + +<style lang="scss"> +.block-card-content-container { + margin-top: 0.5em; + text-align: right; + button { + width: 10em; + } +} +</style> diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue index bf65efc5..b37469ac 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/chat_panel/chat_panel.vue @@ -3,8 +3,8 @@ <div class="panel panel-default"> <div class="panel-heading timeline-heading" :class="{ 'chat-heading': floating }" @click.stop.prevent="togglePanel"> <div class="title"> - {{$t('chat.title')}} - <i class="icon-cancel" style="float: right;" v-if="floating"></i> + <span>{{$t('chat.title')}}</span> + <i class="icon-cancel" v-if="floating"></i> </div> </div> <div class="chat-window" v-chat-scroll> @@ -98,4 +98,11 @@ resize: none; } } + +.chat-panel { + .title { + display: flex; + justify-content: space-between; + } +} </style> diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js new file mode 100644 index 00000000..425c9c3e --- /dev/null +++ b/src/components/follow_card/follow_card.js @@ -0,0 +1,45 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' + +const FollowCard = { + props: [ + 'user', + 'noFollowsYou' + ], + data () { + return { + inProgress: false, + requestSent: false, + updated: false + } + }, + components: { + BasicUserCard + }, + 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 + } + }, + methods: { + followUser () { + this.inProgress = true + requestFollow(this.user, this.$store).then(({ sent, updated }) => { + this.inProgress = false + this.requestSent = sent + this.updated = updated + }) + }, + unfollowUser () { + this.inProgress = true + requestUnfollow(this.user, this.$store).then(({ updated }) => { + this.inProgress = false + this.updated = updated + }) + } + } +} + +export default FollowCard diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue new file mode 100644 index 00000000..6cb064eb --- /dev/null +++ b/src/components/follow_card/follow_card.vue @@ -0,0 +1,53 @@ +<template> + <basic-user-card :user="user"> + <div class="follow-card-content-container"> + <span class="faint" v-if="!noFollowsYou && user.follows_you"> + {{ isMe ? $t('user_card.its_you') : $t('user_card.follows_you') }} + </span> + <button + v-if="showFollow" + class="btn btn-default" + @click="followUser" + :disabled="inProgress" + :title="requestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="requestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-if="following" class="btn btn-default pressed" @click="unfollowUser" :disabled="inProgress"> + <template v-if="inProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> + </div> + </basic-user-card> +</template> + +<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; + + .btn { + margin-top: 0.5em; + margin-left: auto; + width: 10em; + } +} +</style> diff --git a/src/components/follow_list/follow_list.js b/src/components/follow_list/follow_list.js deleted file mode 100644 index acdb216d..00000000 --- a/src/components/follow_list/follow_list.js +++ /dev/null @@ -1,63 +0,0 @@ -import UserCard from '../user_card/user_card.vue' - -const FollowList = { - data () { - return { - loading: false, - bottomedOut: false, - error: false - } - }, - props: ['userId', 'showFollowers'], - created () { - window.addEventListener('scroll', this.scrollLoad) - if (this.entries.length === 0) { - this.fetchEntries() - } - }, - destroyed () { - window.removeEventListener('scroll', this.scrollLoad) - this.$store.dispatch('clearFriendsAndFollowers', this.userId) - }, - computed: { - user () { - return this.$store.getters.userById(this.userId) - }, - entries () { - return this.showFollowers ? this.user.followers : this.user.friends - }, - showActions () { return this.$store.state.users.currentUser.id === this.userId } - }, - methods: { - fetchEntries () { - if (!this.loading) { - const command = this.showFollowers ? 'addFollowers' : 'addFriends' - this.loading = true - this.$store.dispatch(command, this.userId).then(entries => { - this.error = false - this.loading = false - this.bottomedOut = entries.length === 0 - }).catch(() => { - this.error = true - this.loading = false - }) - } - }, - scrollLoad (e) { - const bodyBRect = document.body.getBoundingClientRect() - const height = Math.max(bodyBRect.height, -(bodyBRect.y)) - if (this.loading === false && - this.bottomedOut === false && - this.$el.offsetHeight > 0 && - (window.innerHeight + window.pageYOffset) >= (height - 750) - ) { - this.fetchEntries() - } - } - }, - components: { - UserCard - } -} - -export default FollowList diff --git a/src/components/follow_list/follow_list.vue b/src/components/follow_list/follow_list.vue deleted file mode 100644 index 7be2e7b7..00000000 --- a/src/components/follow_list/follow_list.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <div class="follow-list"> - <user-card - v-for="entry in entries" - :key="entry.id" :user="entry" - :showFollows="!showFollowers" - :showActions="showActions" - /> - <div class="text-center panel-footer"> - <a v-if="error" @click="fetchEntries" class="alert error"> - {{$t('general.generic_error')}} - </a> - <i v-else-if="loading" class="icon-spin3 animate-spin"/> - <span v-else-if="bottomedOut"></span> - <a v-else @click="fetchEntries">{{$t('general.more')}}</a> - </div> - </div> -</template> - -<script src="./follow_list.js"></script> - -<style lang="scss"> - -.follow-list { - .panel-footer { - padding: 10px; - } - - .error { - font-size: 14px; - } -} - -</style> diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js new file mode 100644 index 00000000..1a00a1c1 --- /dev/null +++ b/src/components/follow_request_card/follow_request_card.js @@ -0,0 +1,20 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const FollowRequestCard = { + props: ['user'], + components: { + BasicUserCard + }, + methods: { + approveUser () { + this.$store.state.api.backendInteractor.approveUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) + }, + denyUser () { + this.$store.state.api.backendInteractor.denyUser(this.user.id) + this.$store.dispatch('removeFollowRequest', this.user) + } + } +} + +export default FollowRequestCard diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue new file mode 100644 index 00000000..4a3bbba4 --- /dev/null +++ b/src/components/follow_request_card/follow_request_card.vue @@ -0,0 +1,29 @@ +<template> + <basic-user-card :user="user"> + <div class="follow-request-card-content-container"> + <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> + <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> + </div> + </basic-user-card> +</template> + +<script src="./follow_request_card.js"></script> + +<style lang="scss"> +.follow-request-card-content-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + button { + margin-top: 0.5em; + margin-right: 0.5em; + flex: 1 1; + max-width: 12em; + min-width: 8em; + + &:last-child { + margin-right: 0; + } + } +} +</style> diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js index 11a228aa..704a76c6 100644 --- a/src/components/follow_requests/follow_requests.js +++ b/src/components/follow_requests/follow_requests.js @@ -1,22 +1,13 @@ -import UserCard from '../user_card/user_card.vue' +import FollowRequestCard from '../follow_request_card/follow_request_card.vue' const FollowRequests = { components: { - UserCard - }, - created () { - this.updateRequests() + FollowRequestCard }, computed: { requests () { return this.$store.state.api.followRequests } - }, - methods: { - updateRequests () { - this.$store.state.api.backendInteractor.fetchFollowRequests() - .then((requests) => { this.$store.commit('setFollowRequests', requests) }) - } } } diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue index 87dc4194..b83c2d68 100644 --- a/src/components/follow_requests/follow_requests.vue +++ b/src/components/follow_requests/follow_requests.vue @@ -4,7 +4,7 @@ {{$t('nav.friend_requests')}} </div> <div class="panel-body"> - <user-card v-for="request in requests" :key="request.id" :user="request" :showFollows="false" :showApproval="true"></user-card> + <FollowRequestCard v-for="request in requests" :key="request.id" :user="request"/> </div> </div> </template> diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index 3f90caa9..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; @@ -36,6 +35,9 @@ box-sizing: border-box; // to make failed images a bit more noticeable on chromium min-width: 2em; + &:last-child { + margin: 0; + } } .image-attachment { diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js new file mode 100644 index 00000000..49d51846 --- /dev/null +++ b/src/components/image_cropper/image_cropper.js @@ -0,0 +1,128 @@ +import Cropper from 'cropperjs' +import 'cropperjs/dist/cropper.css' + +const ImageCropper = { + props: { + trigger: { + type: [String, window.Element], + required: true + }, + submitHandler: { + type: Function, + required: true + }, + cropperOptions: { + type: Object, + default () { + return { + aspectRatio: 1, + autoCropArea: 1, + viewMode: 1, + movable: false, + zoomable: false, + guides: false + } + } + }, + mimes: { + type: String, + default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' + }, + saveButtonLabel: { + type: String + }, + cancelButtonLabel: { + type: String + } + }, + data () { + return { + cropper: undefined, + dataUrl: undefined, + filename: undefined, + submitting: false, + submitError: null + } + }, + computed: { + saveText () { + return this.saveButtonLabel || this.$t('image_cropper.save') + }, + cancelText () { + return this.cancelButtonLabel || this.$t('image_cropper.cancel') + }, + submitErrorMsg () { + return this.submitError && this.submitError instanceof Error ? this.submitError.toString() : this.submitError + } + }, + methods: { + destroy () { + if (this.cropper) { + this.cropper.destroy() + } + this.$refs.input.value = '' + this.dataUrl = undefined + this.$emit('close') + }, + submit () { + this.submitting = true + this.avatarUploadError = null + this.submitHandler(this.cropper, this.file) + .then(() => this.destroy()) + .catch((err) => { + this.submitError = err + }) + .finally(() => { + this.submitting = false + }) + }, + pickImage () { + this.$refs.input.click() + }, + createCropper () { + this.cropper = new Cropper(this.$refs.img, this.cropperOptions) + }, + getTriggerDOM () { + return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger) + }, + 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(this.file) + this.$emit('changed', this.file, reader) + } + }, + clearError () { + this.submitError = null + } + }, + mounted () { + // listen for click event on trigger + const trigger = this.getTriggerDOM() + if (!trigger) { + this.$emit('error', 'No image make trigger found.', 'user') + } else { + trigger.addEventListener('click', this.pickImage) + } + // listen for input file changes + const fileInput = this.$refs.input + fileInput.addEventListener('change', this.readFile) + }, + beforeDestroy: function () { + // remove the event listeners + const trigger = this.getTriggerDOM() + if (trigger) { + trigger.removeEventListener('click', this.pickImage) + } + const fileInput = this.$refs.input + fileInput.removeEventListener('change', this.readFile) + } +} + +export default ImageCropper diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue new file mode 100644 index 00000000..24a6f3bd --- /dev/null +++ b/src/components/image_cropper/image_cropper.vue @@ -0,0 +1,42 @@ +<template> + <div class="image-cropper"> + <div v-if="dataUrl"> + <div class="image-cropper-image-container"> + <img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> + </div> + <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> + <i class="icon-spin4 animate-spin" v-if="submitting"></i> + </div> + <div class="alert error" v-if="submitError"> + {{submitErrorMsg}} + <i class="button-icon icon-cancel" @click="clearError"></i> + </div> + </div> + <input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> + </div> +</template> + +<script src="./image_cropper.js"></script> + +<style lang="scss"> +.image-cropper { + &-img-input { + display: none; + } + + &-image-container { + position: relative; + + img { + display: block; + max-width: 100%; + } + } + + &-buttons-wrapper { + margin-top: 15px; + } +} +</style> diff --git a/src/components/link-preview/link-preview.vue b/src/components/link-preview/link-preview.vue index e4a247c5..64b1a58b 100644 --- a/src/components/link-preview/link-preview.vue +++ b/src/components/link-preview/link-preview.vue @@ -23,10 +23,7 @@ flex-direction: row; cursor: pointer; overflow: hidden; - - // TODO: clean up the random margins in attachments, this makes preview line - // up with attachments... - margin-right: 0.5em; + margin-top: 0.5em; .card-image { flex-shrink: 0; diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index 14ae19d4..992d7129 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -11,27 +11,62 @@ const MediaModal = { showing () { return this.$store.state.mediaViewer.activated }, + media () { + return this.$store.state.mediaViewer.media + }, currentIndex () { return this.$store.state.mediaViewer.currentIndex }, currentMedia () { - return this.$store.state.mediaViewer.media[this.currentIndex] + return this.media[this.currentIndex] + }, + canNavigate () { + return this.media.length > 1 }, type () { return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null } }, - created () { - document.addEventListener('keyup', e => { - if (e.keyCode === 27 && this.showing) { // escape - this.hide() - } - }) - }, methods: { hide () { this.$store.dispatch('closeMediaViewer') + }, + goPrev () { + if (this.canNavigate) { + const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) + this.$store.dispatch('setCurrent', this.media[prevIndex]) + } + }, + goNext () { + if (this.canNavigate) { + const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) + this.$store.dispatch('setCurrent', this.media[nextIndex]) + } + }, + handleKeyupEvent (e) { + if (this.showing && e.keyCode === 27) { // escape + this.hide() + } + }, + handleKeydownEvent (e) { + if (!this.showing) { + return + } + + if (e.keyCode === 39) { // arrow right + this.goNext() + } else if (e.keyCode === 37) { // arrow left + this.goPrev() + } } + }, + mounted () { + document.addEventListener('keyup', this.handleKeyupEvent) + document.addEventListener('keydown', this.handleKeydownEvent) + }, + destroyed () { + document.removeEventListener('keyup', this.handleKeyupEvent) + document.removeEventListener('keydown', this.handleKeydownEvent) } } diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 796d4e40..427bf12b 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -8,6 +8,22 @@ :controls="true" @click.stop.native=""> </VideoAttachment> + <button + :title="$t('media_modal.previous')" + class="modal-view-button-arrow modal-view-button-arrow--prev" + v-if="canNavigate" + @click.stop.prevent="goPrev" + > + <i class="icon-left-open arrow-icon" /> + </button> + <button + :title="$t('media_modal.next')" + class="modal-view-button-arrow modal-view-button-arrow--next" + v-if="canNavigate" + @click.stop.prevent="goNext" + > + <i class="icon-right-open arrow-icon" /> + </button> </div> </template> @@ -19,15 +35,29 @@ .modal-view { z-index: 1000; position: fixed; - width: 100vw; - height: 100vh; top: 0; left: 0; + right: 0; + bottom: 0; display: flex; justify-content: center; align-items: center; background-color: rgba(0, 0, 0, 0.5); - cursor: pointer; + + &:hover { + .modal-view-button-arrow { + opacity: 0.75; + + &:focus, + &:hover { + outline: none; + box-shadow: none; + } + &:hover { + opacity: 1; + } + } + } } .modal-image { @@ -35,4 +65,49 @@ max-height: 90%; box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); } + +.modal-view-button-arrow { + position: absolute; + display: block; + top: 50%; + margin-top: -50px; + width: 70px; + height: 100px; + border: 0; + padding: 0; + opacity: 0; + box-shadow: none; + background: none; + appearance: none; + overflow: visible; + cursor: pointer; + transition: opacity 333ms cubic-bezier(.4,0,.22,1); + + .arrow-icon { + position: absolute; + top: 35px; + height: 30px; + width: 32px; + font-size: 14px; + line-height: 30px; + color: #FFF; + text-align: center; + background-color: rgba(0,0,0,.3); + } + + &--prev { + left: 0; + .arrow-icon { + left: 6px; + } + } + + &--next { + right: 0; + .arrow-icon { + right: 6px; + } + } +} + </style> diff --git a/src/components/mute_card/mute_card.js b/src/components/mute_card/mute_card.js new file mode 100644 index 00000000..5dd0a9e5 --- /dev/null +++ b/src/components/mute_card/mute_card.js @@ -0,0 +1,37 @@ +import BasicUserCard from '../basic_user_card/basic_user_card.vue' + +const MuteCard = { + props: ['userId'], + data () { + return { + progress: false + } + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + muted () { + return this.user.muted + } + }, + components: { + BasicUserCard + }, + methods: { + unmuteUser () { + this.progress = true + this.$store.dispatch('unmuteUser', this.user.id).then(() => { + this.progress = false + }) + }, + muteUser () { + this.progress = true + this.$store.dispatch('muteUser', this.user.id).then(() => { + this.progress = false + }) + } + } +} + +export default MuteCard diff --git a/src/components/mute_card/mute_card.vue b/src/components/mute_card/mute_card.vue new file mode 100644 index 00000000..e1bfe20b --- /dev/null +++ b/src/components/mute_card/mute_card.vue @@ -0,0 +1,24 @@ +<template> + <basic-user-card :user="user"> + <template slot="secondary-area"> + <button class="btn btn-default" @click="unmuteUser" :disabled="progress" v-if="muted"> + <template v-if="progress"> + {{ $t('user_card.unmute_progress') }} + </template> + <template v-else> + {{ $t('user_card.unmute') }} + </template> + </button> + <button class="btn btn-default" @click="muteUser" :disabled="progress" v-else> + <template v-if="progress"> + {{ $t('user_card.mute_progress') }} + </template> + <template v-else> + {{ $t('user_card.mute') }} + </template> + </button> + </template> + </basic-user-card> +</template> + +<script src="./mute_card.js"></script>
\ No newline at end of file diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index ea5d7ea4..aa3f7605 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,10 +1,23 @@ +import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service' + const NavPanel = { + created () { + if (this.currentUser && this.currentUser.locked) { + const store = this.$store + const credentials = store.state.users.currentUser.credentials + + followRequestFetcher.startFetching({ store, credentials }) + } + }, computed: { currentUser () { return this.$store.state.users.currentUser }, chat () { return this.$store.state.chat.channel + }, + followRequestCount () { + return this.$store.state.api.followRequests.length } } } diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 3aa0a793..7a7212fb 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -19,7 +19,10 @@ </li> <li v-if='currentUser && currentUser.locked'> <router-link :to="{ name: 'friend-requests' }"> - {{ $t("nav.friend_requests") }} + {{ $t("nav.friend_requests")}} + <span v-if='followRequestCount > 0' class="badge follow-request-count"> + {{followRequestCount}} + </span> </router-link> </li> <li> @@ -52,6 +55,12 @@ padding: 0; } +.follow-request-count { + margin: -6px 10px; + background-color: $fallback--bg; + background-color: var(--input, $fallback--faint); +} + .nav-panel li { border-bottom: 1px solid; border-color: $fallback--border; diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index a0a55cba..87925cfc 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -25,7 +25,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.scss b/src/components/notifications/notifications.scss index bc81d45c..2240c10a 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -103,6 +103,7 @@ flex: 1 1 0; display: flex; flex-wrap: nowrap; + justify-content: space-between; .name-and-action { flex: 1; @@ -123,9 +124,9 @@ object-fit: contain } } + .timeago { - float: right; - 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 8e30264d..23a2c7e2 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,8 +1,8 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' -import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import { reject, map, uniqBy } from 'lodash' +import Completion from '../../services/completion/completion.js' +import { take, filter, reject, map, uniqBy } from 'lodash' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -28,10 +28,13 @@ const PostStatusForm = { 'subject' ], components: { - MediaUpload, - AutoCompleteInput + MediaUpload }, mounted () { + this.resize(this.$refs.textarea) + const textLength = this.$refs.textarea.value.length + this.$refs.textarea.setSelectionRange(textLength, textLength) + if (this.replyTo) { this.$refs.textarea.focus() } @@ -53,18 +56,25 @@ const PostStatusForm = { ? this.copyMessageScope : this.$store.state.users.currentUser.default_scope + const contentType = typeof this.$store.state.config.postContentType === 'undefined' + ? this.$store.state.instance.postContentType + : this.$store.state.config.postContentType + return { dropFiles: [], submitDisabled: false, error: null, posting: false, + highlighted: 0, newStatus: { spoilerText: this.subject || '', status: statusText, nsfw: false, files: [], - visibility: scope - } + visibility: scope, + contentType + }, + caret: 0 } }, computed: { @@ -76,6 +86,59 @@ const PostStatusForm = { direct: { selected: this.newStatus.visibility === 'direct' } } }, + candidates () { + const firstchar = this.textAtCaret.charAt(0) + if (firstchar === '@') { + const query = this.textAtCaret.slice(1).toUpperCase() + const matchedUsers = filter(this.users, (user) => { + return user.screen_name.toUpperCase().startsWith(query) || + user.name && user.name.toUpperCase().startsWith(query) + }) + if (matchedUsers.length <= 0) { + return false + } + // eslint-disable-next-line camelcase + return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ + // eslint-disable-next-line camelcase + screen_name: `@${screen_name}`, + name: name, + img: profile_image_url_original, + highlighted: index === this.highlighted + })) + } else if (firstchar === ':') { + if (this.textAtCaret === ':') { return } + const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) + if (matchedEmoji.length <= 0) { + return false + } + return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ + screen_name: `:${shortcode}:`, + name: '', + utf: utf || '', + // eslint-disable-next-line camelcase + img: utf ? '' : this.$store.state.instance.server + image_url, + highlighted: index === this.highlighted + })) + } else { + return false + } + }, + textAtCaret () { + return (this.wordAtCaret || {}).word || '' + }, + wordAtCaret () { + const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} + return word + }, + users () { + return this.$store.state.users.users + }, + emoji () { + return this.$store.state.instance.emoji || [] + }, + customEmoji () { + return this.$store.state.instance.customEmoji || [] + }, statusLength () { return this.newStatus.status.length }, @@ -109,15 +172,58 @@ const PostStatusForm = { formattingOptionsEnabled () { return this.$store.state.instance.formattingOptionsEnabled }, - defaultPostContentType () { - return typeof this.$store.state.config.postContentType === 'undefined' - ? this.$store.state.instance.postContentType - : this.$store.state.config.postContentType + postFormats () { + return this.$store.state.instance.postFormats || [] } }, methods: { - postStatusCopy () { - this.postStatus(this.newStatus) + replace (replacement) { + this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') + el.focus() + this.caret = 0 + }, + replaceCandidate (e) { + const len = this.candidates.length || 0 + if (this.textAtCaret === ':' || e.ctrlKey) { return } + if (len > 0) { + e.preventDefault() + const candidate = this.candidates[this.highlighted] + const replacement = candidate.utf || (candidate.screen_name + ' ') + this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') + el.focus() + this.caret = 0 + this.highlighted = 0 + } + }, + cycleBackward (e) { + const len = this.candidates.length || 0 + if (len > 0) { + e.preventDefault() + this.highlighted -= 1 + if (this.highlighted < 0) { + this.highlighted = this.candidates.length - 1 + } + } else { + this.highlighted = 0 + } + }, + cycleForward (e) { + const len = this.candidates.length || 0 + if (len > 0) { + if (e.shiftKey) { return } + e.preventDefault() + this.highlighted += 1 + if (this.highlighted >= len) { + this.highlighted = 0 + } + } else { + this.highlighted = 0 + } + }, + setCaret ({target: {selectionStart}}) { + this.caret = selectionStart }, postStatus (newStatus) { if (this.posting) { return } @@ -202,6 +308,18 @@ const PostStatusForm = { fileDrag (e) { e.dataTransfer.dropEffect = 'copy' }, + resize (e) { + const target = e.target || e + if (!(target instanceof window.Element)) { return } + const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + + Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) + // Auto is needed to make textbox shrink when removing lines + target.style.height = 'auto' + target.style.height = `${target.scrollHeight - vertPadding}px` + if (target.value === '') { + target.style.height = null + } + }, clearError () { this.error = null }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index ef3a7901..0ddde4ea 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -16,23 +16,31 @@ :placeholder="$t('post_status.content_warning')" v-model="newStatus.spoilerText" class="form-cw"> - <auto-complete-input v-model="newStatus.status" - :classObj="{ 'form-control': true }" - :placeholder="$t('post_status.default')" - :autoResize="true" - :multiline="true" - :drop="fileDrop" - :dragoverPrevent="fileDrag" - :paste="paste" - :keydownMetaEnter="postStatusCopy" - :keyupCtrlEnter="postStatusCopy"/> + <textarea + ref="textarea" + @click="setCaret" + @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" + @keydown.down="cycleForward" + @keydown.up="cycleBackward" + @keydown.shift.tab="cycleBackward" + @keydown.tab="cycleForward" + @keydown.enter="replaceCandidate" + @keydown.meta.enter="postStatus(newStatus)" + @keyup.ctrl.enter="postStatus(newStatus)" + @drop="fileDrop" + @dragover.prevent="fileDrag" + @input="resize" + @paste="paste" + :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="defaultPostContentType" class="form-control"> - <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> - <option value="text/html">HTML</option> - <option value="text/markdown">Markdown</option> + <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> + <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> @@ -46,6 +54,17 @@ </div> </div> </div> + <div style="position:relative;" v-if="candidates"> + <div class="autocomplete-panel"> + <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> + <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> + <span v-if="candidate.img"><img :src="candidate.img"></img></span> + <span v-else>{{candidate.utf}}</span> + <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> + </div> + </div> + </div> + </div> <div class='form-bottom'> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> @@ -101,6 +120,14 @@ } } +.post-status-form { + .visibility-tray { + display: flex; + justify-content: space-between; + flex-direction: row-reverse; + } +} + .post-status-form, .login { .form-bottom { display: flex; @@ -233,5 +260,52 @@ 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.vue b/src/components/registration/registration.vue index f428ead3..e22b308d 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -9,7 +9,7 @@ <div class='text-fields'> <div class='form-group' :class="{ 'form-group--error': $v.user.username.$error }"> <label class='form--label' for='sign-up-username'>{{$t('login.username')}}</label> - <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' placeholder='e.g. lain'> + <input :disabled="isPending" v-model.trim='$v.user.username.$model' class='form-control' id='sign-up-username' :placeholder="$t('registration.username_placeholder')"> </div> <div class="form-error" v-if="$v.user.username.$dirty"> <ul> @@ -21,7 +21,7 @@ <div class='form-group' :class="{ 'form-group--error': $v.user.fullname.$error }"> <label class='form--label' for='sign-up-fullname'>{{$t('registration.fullname')}}</label> - <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' placeholder='e.g. Lain Iwakura'> + <input :disabled="isPending" v-model.trim='$v.user.fullname.$model' class='form-control' id='sign-up-fullname' :placeholder="$t('registration.fullname_placeholder')"> </div> <div class="form-error" v-if="$v.user.fullname.$dirty"> <ul> @@ -44,8 +44,8 @@ </div> <div class='form-group'> - <label class='form--label' for='bio'>{{$t('registration.bio')}}</label> - <input :disabled="isPending" v-model='user.bio' class='form-control' id='bio'> + <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> </div> <div class='form-group' :class="{ 'form-group--error': $v.user.password.$error }"> @@ -139,6 +139,10 @@ $validations-cRed: #f04124; flex-direction: column; } + textarea { + min-height: 100px; + } + .form-group { display: flex; flex-direction: column; diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 534a9839..979457a5 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -12,6 +12,7 @@ const settings = { return { hideAttachmentsLocal: user.hideAttachments, hideAttachmentsInConvLocal: user.hideAttachmentsInConv, + maxThumbnails: user.maxThumbnails, hideNsfwLocal: user.hideNsfw, useOneClickNsfw: user.useOneClickNsfw, hideISPLocal: user.hideISP, @@ -91,7 +92,11 @@ const settings = { }, currentSaveStateNotice () { return this.$store.state.interface.settings.currentSaveStateNotice - } + }, + postFormats () { + return this.$store.state.instance.postFormats || [] + }, + instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel } }, watch: { hideAttachmentsLocal (value) { @@ -185,6 +190,10 @@ const settings = { }, useContainFit (value) { this.$store.dispatch('setOption', { name: 'useContainFit', value }) + }, + maxThumbnails (value) { + value = this.maxThumbnails = Math.floor(Math.max(value, 0)) + this.$store.dispatch('setOption', { name: 'maxThumbnails', value }) } } } diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index dfb2e49d..d2346747 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -27,7 +27,7 @@ <li> <interface-language-switcher /> </li> - <li> + <li v-if="instanceSpecificPanelPresent"> <input type="checkbox" id="hideISP" v-model="hideISPLocal"> <label for="hideISP">{{$t('settings.hide_isp')}}</label> </li> @@ -105,17 +105,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"/> @@ -137,6 +129,10 @@ <label for="hideAttachmentsInConv">{{$t('settings.hide_attachments_in_convo')}}</label> </li> <li> + <label for="maxThumbnails">{{$t('settings.max_thumbnails')}}</label> + <input class="number-input" type="number" id="maxThumbnails" v-model.number="maxThumbnails" min="0" step="1"> + </li> + <li> <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> <label for="hideNsfw">{{$t('settings.nsfw_clickthrough')}}</label> </li> @@ -146,7 +142,7 @@ <label for="preloadImage">{{$t('settings.preload_images')}}</label> </li> <li> - <input type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw"> + <input :disabled="!hideNsfwLocal" type="checkbox" id="useOneClickNsfw" v-model="useOneClickNsfw"> <label for="useOneClickNsfw">{{$t('settings.use_one_click_nsfw')}}</label> </li> </ul> @@ -311,25 +307,15 @@ color: $fallback--cRed; } - .old-avatar { - width: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - - .new-avatar { - object-fit: cover; - width: 128px; - height: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - .btn { min-height: 28px; min-width: 10em; padding: 0 2em; } + + .number-input { + max-width: 6em; + } } .select-multiple { display: flex; diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 40ffa1dd..b5c49059 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -32,6 +32,9 @@ const SideDrawer = { }, sitename () { return this.$store.state.instance.name + }, + followRequestCount () { + return this.$store.state.api.followRequests.length } }, methods: { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index a6c6f237..6996380d 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -45,6 +45,10 @@ <li v-if="currentUser && currentUser.locked" @click="toggleDrawer"> <router-link to='/friend-requests'> {{ $t("nav.friend_requests") }} + <span v-if='followRequestCount > 0' class="badge follow-request-count"> + {{followRequestCount}} + </span> + </router-link> </li> <li @click="toggleDrawer"> diff --git a/src/components/status/status.js b/src/components/status/status.js index 0273a5be..fbbca6c4 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -23,7 +23,7 @@ const Status = { 'highlight', 'compact', 'replies', - 'noReplyLinks', + 'isPreview', 'noHeading', 'inlineExpanded' ], @@ -40,8 +40,7 @@ const Status = { expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, - betterShadow: this.$store.state.interface.browserSupport.cssFilter, - maxAttachments: 9 + betterShadow: this.$store.state.interface.browserSupport.cssFilter } }, computed: { @@ -225,7 +224,7 @@ const Status = { attachmentSize () { if ((this.$store.state.config.hideAttachments && !this.inConversation) || (this.$store.state.config.hideAttachmentsInConv && this.inConversation) || - (this.status.attachments.length > this.maxAttachments)) { + (this.status.attachments.length > this.maxThumbnails)) { return 'hide' } else if (this.compact) { return 'small' @@ -249,6 +248,9 @@ const Status = { return this.status.attachments.filter( file => !fileType.fileMatchesSomeType(this.galleryTypes, file) ) + }, + maxThumbnails () { + return this.$store.state.config.maxThumbnails } }, components: { diff --git a/src/components/status/status.vue b/src/components/status/status.vue index aae365d1..4dd20362 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,6 +1,6 @@ <template> <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> - <template v-if="muted && !noReplyLinks"> + <template v-if="muted && !isPreview"> <div class="media status container muted"> <small> <router-link :to="userProfileLink"> @@ -13,7 +13,7 @@ </template> <template v-else> <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <UserAvatar v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> + <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"> <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> @@ -31,57 +31,69 @@ </router-link> </div> <div class="status-body"> - <div class="usercard media-body" v-if="userExpanded"> + <div class="usercard" v-if="userExpanded"> <user-card-content :user="status.user" :switcher="false"></user-card-content> </div> - <div v-if="!noHeading" class="media-body container media-heading"> - <div class="media-heading-left"> - <div class="name-and-links"> + <div v-if="!noHeading" class="media-heading"> + <div class="heading-name-row"> + <div class="name-and-account-name"> <h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4> <h4 class="user-name" v-else>{{status.user.name}}</h4> - <span class="links"> - <router-link :to="userProfileLink"> - {{status.user.screen_name}} - </router-link> - <span v-if="isReply" class="faint reply-info"> - <i class="icon-right-open"></i> - <router-link :to="replyProfileLink"> - {{replyToName}} - </router-link> - </span> - <a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :aria-label="$t('tool_tip.reply')"> - <i class="button-icon icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i> + <router-link class="account-name" :to="userProfileLink"> + {{status.user.screen_name}} + </router-link> + </div> + + <span class="heading-right"> + <router-link class="timeago faint-link" :to="{ name: 'conversation', params: { id: status.id } }"> + <timeago :since="status.created_at" :auto-update="60"></timeago> + </router-link> + <div class="button-icon visibility-icon" v-if="status.visibility"> + <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + </div> + <a :href="status.external_url" target="_blank" v-if="!status.is_local && !isPreview" class="source_url" title="Source"> + <i class="button-icon icon-link-ext-alt"></i> + </a> + <template v-if="expandable && !isPreview"> + <a href="#" @click.prevent="toggleExpanded" title="Expand"> + <i class="button-icon icon-plus-squared"></i> </a> + </template> + <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a> + </span> + </div> + + <div class="heading-reply-row"> + <div v-if="isReply" class="reply-to-and-accountname"> + <a class="reply-to" + href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" + :aria-label="$t('tool_tip.reply')" + @mouseenter.prevent.stop="replyEnter(status.in_reply_to_status_id, $event)" + @mouseleave.prevent.stop="replyLeave()" + > + <i class="button-icon icon-reply" v-if="!isPreview"></i> + <span class="faint-link reply-to-text">{{$t('status.reply_to')}}</span> + </a> + <router-link :to="replyProfileLink"> + {{replyToName}} + </router-link> + <span class="faint replies-separator" v-if="replies && replies.length"> + - </span> </div> - <h4 class="replies" v-if="inConversation && !noReplyLinks"> - <small v-if="replies.length">Replies:</small> - <small class="reply-link" v-bind:key="reply.id" v-for="reply in replies"> - <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}} </a> - </small> - </h4> - </div> - <div class="media-heading-right"> - <router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }"> - <timeago :since="status.created_at" :auto-update="60"></timeago> - </router-link> - <div class="button-icon visibility-icon" v-if="status.visibility"> - <i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i> + <div class="replies" v-if="inConversation && !isPreview"> + <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> - <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source"> - <i class="button-icon icon-link-ext-alt"></i> - </a> - <template v-if="expandable"> - <a href="#" @click.prevent="toggleExpanded" title="Expand"> - <i class="button-icon icon-plus-squared"></i> - </a> - </template> - <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a> </div> + + </div> <div v-if="showPreview" class="status-preview-container"> - <status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status> + <status class="status-preview" v-if="preview" :isPreview="true" :statusoid="preview" :compact=true></status> <div class="status-preview status-preview-loading" v-else> <i class="icon-spin4 animate-spin"></i> </div> @@ -123,7 +135,7 @@ <link-preview :card="status.card" :size="attachmentSize" :nsfw="nsfwClickthrough" /> </div> - <div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'> + <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> @@ -147,6 +159,8 @@ <style lang="scss"> @import '../../_variables.scss'; +$status-margin: 0.75em; + .status-body { flex: 1; min-width: 0; @@ -202,13 +216,16 @@ } } +.media-left { + margin-right: $status-margin; +} + .status-el { hyphens: auto; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; border-left-width: 0px; - line-height: 18px; min-width: 0; border-color: $fallback--border; border-color: var(--border, $fallback--border); @@ -229,22 +246,34 @@ .media-body { flex: 1; padding: 0; - margin: 0 0 0.25em 0.8em; } .usercard { - margin-bottom: .7em + margin: 0; + margin-bottom: $status-margin; } - .media-heading { - flex-wrap: nowrap; - line-height: 18px; + .user-name { + white-space: nowrap; + font-size: 14px; + overflow: hidden; + flex-shrink: 0; + max-width: 85%; + font-weight: bold; + + img { + width: 14px; + height: 14px; + vertical-align: middle; + object-fit: contain + } } - .media-heading-left { + .media-heading { padding: 0; vertical-align: bottom; flex-basis: 100%; + margin-bottom: 0.5em; a { display: inline-block; @@ -254,83 +283,102 @@ small { font-weight: lighter; } - h4 { - white-space: nowrap; - font-size: 14px; - margin-right: 0.25em; - overflow: hidden; - text-overflow: ellipsis; - } - .name-and-links { + + .heading-name-row { padding: 0; - flex: 1 0; display: flex; - flex-wrap: wrap; - align-items: baseline; + justify-content: space-between; + line-height: 18px; + + .name-and-account-name { + display: flex; + min-width: 0; + } .user-name { - margin-right: .45em; + flex-shrink: 1; + margin-right: 0.4em; + overflow: hidden; + text-overflow: ellipsis; + } - img { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } + .account-name { + min-width: 1.6em; + margin-right: 0.4em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; } } - .links { + .heading-right { display: flex; + flex-shrink: 0; + } + + .timeago { + margin-right: 0.2em; + } + + .heading-reply-row { + align-content: baseline; font-size: 12px; - color: $fallback--link; - color: var(--link, $fallback--link); + line-height: 18px; max-width: 100%; + display: flex; + flex-wrap: wrap; + align-items: stretch; + a { max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } - & > span { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - & > a:last-child { - flex-shrink: 0; + } + + .reply-to-and-accountname { + display: flex; + height: 18px; + margin-right: 0.5em; + overflow: hidden; + max-width: 100%; + .icon-reply { + transform: scaleX(-1); } } + .reply-info { display: flex; } - .replies { - line-height: 16px; + + .reply-to { + display: flex; } - .reply-link { - margin-right: 0.2em; + + .reply-to-text { + overflow: hidden; + text-overflow: ellipsis; + margin: 0 0.4em 0 0.2em; } - } - .media-heading-right { - display: inline-flex; - flex-shrink: 0; - flex-wrap: nowrap; - margin-left: .25em; - align-self: baseline; + .replies-separator { + margin-left: 0.4em; + } - .timeago { - margin-right: 0.2em; + .replies { + line-height: 18px; font-size: 12px; - align-self: last baseline; + display: flex; + flex-wrap: wrap; + & > * { + margin-right: 0.4em; + } } - > * { - margin-left: 0.2em; - } - a:hover i { - color: $fallback--text; - color: var(--text, $fallback--text); + .reply-link { + height: 17px; } } @@ -366,14 +414,19 @@ } .status-content { - margin-right: 0.5em; font-family: var(--postFont, sans-serif); + line-height: 1.4em; img, video { max-width: 100%; max-height: 400px; vertical-align: middle; object-fit: contain; + + &.emoji { + width: 32px; + height: 32px; + } } blockquote { @@ -390,9 +443,11 @@ } p { - margin: 0; - margin-top: 0.2em; - margin-bottom: 0.5em; + margin: 0 0 1em 0; + } + + p:last-child { + margin: 0 0 0 0; } h1 { @@ -417,7 +472,7 @@ } .retweet-info { - padding: 0.4em 0.6em 0 0.6em; + padding: 0.4em $status-margin; margin: 0; .avatar.still-image { @@ -488,10 +543,10 @@ .status-actions { width: 100%; display: flex; + margin-top: $status-margin; div, favorite-button { - padding-top: 0.25em; - max-width: 6em; + max-width: 4em; flex: 1; } } @@ -517,9 +572,9 @@ .status { display: flex; - padding: 0.6em; + padding: $status-margin; &.is-retweet { - padding-top: 0.1em; + padding-top: 0; } } @@ -554,7 +609,7 @@ a.unmute { .timeline > { .status-el:last-child { - border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;; + border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius; border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius); border-bottom: none; } diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 85e0a055..655bfb3f 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -1,7 +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 UserCard from '../user_card/user_card.vue' import { throttle } from 'lodash' const Timeline = { @@ -11,7 +10,8 @@ const Timeline = { 'title', 'userId', 'tag', - 'embedded' + 'embedded', + 'count' ], data () { return { @@ -43,8 +43,7 @@ const Timeline = { }, components: { Status, - StatusOrConversation, - UserCard + StatusOrConversation }, created () { const store = this.$store @@ -53,6 +52,8 @@ const Timeline = { window.addEventListener('scroll', this.scrollLoad) + if (this.timelineName === 'friends' && !credentials) { return false } + timelineFetcher.fetchAndUpdate({ store, credentials, @@ -67,14 +68,21 @@ const Timeline = { document.addEventListener('visibilitychange', this.handleVisibilityChange, false) this.unfocused = document.hidden } + window.addEventListener('keydown', this.handleShortKey) }, destroyed () { window.removeEventListener('scroll', this.scrollLoad) + window.removeEventListener('keydown', this.handleShortKey) if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) }, methods: { + handleShortKey (e) { + if (e.key === '.') this.showNewStatuses() + }, showNewStatuses () { + if (this.newStatusCount === 0) return + if (this.timeline.flushMarker !== 0) { this.$store.commit('clearTimeline', { timeline: this.timelineName }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) @@ -98,7 +106,7 @@ const Timeline = { tag: this.tag }).then(statuses => { store.commit('setLoading', { timeline: this.timelineName, value: false }) - if (statuses.length === 0) { + if (statuses && statuses.length === 0) { this.bottomedOut = true } }) diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index e3eea3bd..8f28d65c 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -20,7 +20,10 @@ </div> </div> <div :class="classes.footer"> - <div v-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> + <div v-if="count===0" class="new-status-notification text-center panel-footer faint"> + {{$t('timeline.no_statuses')}} + </div> + <div v-else-if="bottomedOut" class="new-status-notification text-center panel-footer faint"> {{$t('timeline.no_more_statuses')}} </div> <a v-else-if="!timeline.loading" href="#" v-on:click.prevent='fetchOlderStatuses()'> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js deleted file mode 100644 index a4c84716..00000000 --- a/src/components/user_card/user_card.js +++ /dev/null @@ -1,65 +0,0 @@ -import UserCardContent from '../user_card_content/user_card_content.vue' -import UserAvatar from '../user_avatar/user_avatar.vue' -import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' -import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' - -const UserCard = { - props: [ - 'user', - 'showFollows', - 'showApproval', - 'showActions' - ], - data () { - return { - userExpanded: false, - followRequestInProgress: false, - followRequestSent: false, - updated: false - } - }, - components: { - UserCardContent, - UserAvatar - }, - computed: { - currentUser () { return this.$store.state.users.currentUser }, - following () { return this.updated ? this.updated.following : this.user.following }, - showFollow () { - return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following) - } - }, - methods: { - toggleUserExpanded () { - this.userExpanded = !this.userExpanded - }, - approveUser () { - this.$store.state.api.backendInteractor.approveUser(this.user.id) - this.$store.dispatch('removeFollowRequest', this.user) - }, - denyUser () { - this.$store.state.api.backendInteractor.denyUser(this.user.id) - this.$store.dispatch('removeFollowRequest', this.user) - }, - userProfileLink (user) { - return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) - }, - followUser () { - this.followRequestInProgress = true - requestFollow(this.user, this.$store).then(({ sent, updated }) => { - this.followRequestInProgress = false - this.followRequestSent = sent - this.updated = updated - }) - }, - unfollowUser () { - this.followRequestInProgress = true - requestUnfollow(this.user, this.$store).then(({ updated }) => { - this.followRequestInProgress = false - this.updated = updated - }) - } - } -} - -export default UserCard diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue deleted file mode 100644 index 12960c02..00000000 --- a/src/components/user_card/user_card.vue +++ /dev/null @@ -1,137 +0,0 @@ -<template> - <div class="card"> - <router-link :to="userProfileLink(user)"> - <UserAvatar class="avatar" :compact="true" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> - </router-link> - <div class="usercard" v-if="userExpanded"> - <user-card-content :user="user" :switcher="false"></user-card-content> - </div> - <div class="name-and-screen-name" v-else> - <div :title="user.name" class="user-name"> - <span v-if="user.name_html" v-html="user.name_html"></span> - <span v-else>{{ user.name }}</span> - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} - </span> - </div> - <div class="user-link-action"> - <router-link class='user-screen-name' :to="userProfileLink(user)"> - @{{user.screen_name}} - </router-link> - <button - v-if="showFollow" - class="btn btn-default" - @click="followUser" - :disabled="followRequestInProgress" - :title="followRequestSent ? $t('user_card.follow_again') : ''" - > - <template v-if="followRequestInProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else-if="followRequestSent"> - {{ $t('user_card.follow_sent') }} - </template> - <template v-else> - {{ $t('user_card.follow') }} - </template> - </button> - <button v-if="showActions && showFollows && following" class="btn btn-default" @click="unfollowUser" :disabled="followRequestInProgress"> - <template v-if="followRequestInProgress"> - {{ $t('user_card.follow_progress') }} - </template> - <template v-else> - {{ $t('user_card.follow_unfollow') }} - </template> - </button> - </div> - </div> - <div class="approval" v-if="showApproval"> - <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> - <button class="btn btn-default" @click="denyUser">{{ $t('user_card.deny') }}</button> - </div> - </div> -</template> - -<script src="./user_card.js"></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.name-and-screen-name { - margin-left: 0.7em; - margin-top:0.0em; - text-align: left; - width: 100%; - .user-name { - display: flex; - justify-content: space-between; - - img { - object-fit: contain; - height: 16px; - width: 16px; - vertical-align: middle; - } - } - - .user-link-action { - display: flex; - align-items: flex-start; - justify-content: space-between; - - button { - margin-top: 3px; - } - } -} - -.follows-you { - margin-left: 2em; -} - -.card { - display: flex; - flex: 1 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); - - .avatar { - padding: 0; - } -} - -.usercard { - width: fill-available; - margin: 0.2em 0 0 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; - } -} - -.approval { - button { - width: 100%; - margin-bottom: 0.5em; - } -} -</style> diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 7f9909c4..702c3385 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -13,7 +13,7 @@ <router-link :to="{ name: 'user-settings' }" v-if="!isOtherUser"> <i class="button-icon icon-cog usersettings" :title="$t('tool_tip.user_settings')"></i> </router-link> - <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser"> + <a :href="user.statusnet_profile_url" target="_blank" v-if="isOtherUser && !user.is_local"> <i class="icon-link-ext usersettings"></i> </a> </div> @@ -222,6 +222,14 @@ overflow: hidden; flex: 1 1 auto; margin-right: 1em; + font-size: 15px; + + img { + object-fit: contain; + height: 16px; + width: 16px; + vertical-align: middle; + } } .user-screen-name { @@ -386,6 +394,24 @@ } } -.floater { +.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_finder/user_finder.js b/src/components/user_finder/user_finder.js index 55c6c402..27153f45 100644 --- a/src/components/user_finder/user_finder.js +++ b/src/components/user_finder/user_finder.js @@ -8,6 +8,7 @@ const UserFinder = { methods: { findUser (username) { this.$router.push({ name: 'user-search', query: { query: username } }) + this.$refs.userSearchInput.focus() }, toggleHidden () { this.hidden = !this.hidden diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue index 37d628fa..a118ffe2 100644 --- a/src/components/user_finder/user_finder.vue +++ b/src/components/user_finder/user_finder.vue @@ -4,7 +4,7 @@ <i class="icon-spin4 user-finder-icon animate-spin-slow" v-if="loading" /> <a href="#" v-if="hidden" :title="$t('finder.find_user')"><i class="icon-user-plus user-finder-icon" @click.prevent.stop="toggleHidden" /></a> <template v-else> - <input class="user-finder-input" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> + <input class="user-finder-input" ref="userSearchInput" @keyup.enter="findUser(username)" v-model="username" :placeholder="$t('finder.find_user')" id="user-finder-input" type="text"/> <button class="btn search-button" @click="findUser(username)"> <i class="icon-search"/> </button> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index a22b8722..cdf1cee9 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,9 +1,39 @@ +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 FollowList from '../follow_list/follow_list.vue' +import withLoadMore from '../../hocs/with_load_more/with_load_more' +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', []), + destory: (props, $store) => $store.dispatch('clearFollowers', props.userId), + childPropName: 'entries', + additionalPropNames: ['userId'] + }), + withList({ getEntryProps: user => ({ user }) }) +)(FollowCard) + +const FriendList = compose( + withLoadMore({ + fetch: (props, $store) => $store.dispatch('addFriends', props.userId), + select: (props, $store) => get($store.getters.userById(props.userId), 'friends', []), + destory: (props, $store) => $store.dispatch('clearFriends', props.userId), + childPropName: 'entries', + additionalPropNames: ['userId'] + }), + withList({ getEntryProps: user => ({ user }) }) +)(FollowCard) const UserProfile = { + data () { + return { + error: false + } + }, created () { this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'favorites' }) @@ -13,10 +43,20 @@ const UserProfile = { 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') + } + }) } }, destroyed () { - this.cleanUp(this.userId) + this.cleanUp() }, computed: { timeline () { @@ -101,13 +141,16 @@ const UserProfile = { } this.cleanUp() this.startUp() + }, + $route () { + this.$refs.tabSwitcher.activateTab(0)() } }, components: { UserCardContent, - UserCard, Timeline, - FollowList + FollowerList, + FriendList } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 79461291..8090efa5 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -6,10 +6,11 @@ :switcher="true" :selected="timeline.viewing" /> - <tab-switcher :renderOnlyFocused="true"> + <tab-switcher :renderOnlyFocused="true" ref="tabSwitcher"> <Timeline :label="$t('user_card.statuses')" :disabled="!user.statuses_count" + :count="user.statuses_count" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" @@ -17,16 +18,10 @@ :user-id="fetchBy" /> <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> - <FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" /> - <div class="userlist-placeholder" v-else> - <i class="icon-spin3 animate-spin"></i> - </div> + <FriendList :userId="userId" /> </div> <div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count"> - <FollowList v-if="user.followers_count > 0" :userId="userId" :showFollowers="true" /> - <div class="userlist-placeholder" v-else> - <i class="icon-spin3 animate-spin"></i> - </div> + <FollowerList :userId="userId" :entryProps="{noFollowsYou: isUs}" /> </div> <Timeline :label="$t('user_card.media')" @@ -54,7 +49,8 @@ </div> </div> <div class="panel-body"> - <i class="icon-spin3 animate-spin"></i> + <span v-if="error">{{ error }}</span> + <i class="icon-spin3 animate-spin" v-else></i> </div> </div> </div> diff --git a/src/components/user_search/user_search.js b/src/components/user_search/user_search.js index 9c026276..55040826 100644 --- a/src/components/user_search/user_search.js +++ b/src/components/user_search/user_search.js @@ -1,8 +1,8 @@ -import UserCard from '../user_card/user_card.vue' +import FollowCard from '../follow_card/follow_card.vue' import userSearchApi from '../../services/new_api/user_search.js' const userSearch = { components: { - UserCard + FollowCard }, props: [ 'query' @@ -10,7 +10,8 @@ const userSearch = { data () { return { username: '', - users: [] + users: [], + loading: false } }, mounted () { @@ -24,14 +25,17 @@ const userSearch = { methods: { newQuery (query) { this.$router.push({ name: 'user-search', query: { query } }) + this.$refs.userSearchInput.focus() }, search (query) { if (!query) { this.users = [] return } + this.loading = true userSearchApi.search({query, store: this.$store}) .then((res) => { + this.loading = false this.users = res }) } diff --git a/src/components/user_search/user_search.vue b/src/components/user_search/user_search.vue index 3c2bd3fb..1269eea6 100644 --- a/src/components/user_search/user_search.vue +++ b/src/components/user_search/user_search.vue @@ -4,13 +4,16 @@ {{$t('nav.user_search')}} </div> <div class="user-search-input-container"> - <input class="user-finder-input" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> + <input class="user-finder-input" ref="userSearchInput" @keyup.enter="newQuery(username)" v-model="username" :placeholder="$t('finder.find_user')"/> <button class="btn search-button" @click="newQuery(username)"> <i class="icon-search"/> </button> </div> - <div class="panel-body"> - <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> + <div v-if="loading" class="text-center loading-icon"> + <i class="icon-spin3 animate-spin"/> + </div> + <div v-else class="panel-body"> + <FollowCard v-for="user in users" :key="user.id" :user="user"/> </div> </div> </template> @@ -27,4 +30,8 @@ margin-left: 0.5em; } } + +.loading-icon { + padding: 1em; +} </style> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index fa389c3b..c0ab759c 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,9 +1,32 @@ -import { unescape } from 'lodash' - +import { compose } from 'vue-compose' +import unescape from 'lodash/unescape' +import get from 'lodash/get' import TabSwitcher from '../tab_switcher/tab_switcher.js' +import ImageCropper from '../image_cropper/image_cropper.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue' -import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' +import BlockCard from '../block_card/block_card.vue' +import MuteCard from '../mute_card/mute_card.vue' +import withSubscription from '../../hocs/with_subscription/with_subscription' +import withList from '../../hocs/with_list/with_list' + +const BlockList = compose( + withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchBlocks'), + select: (props, $store) => get($store.state.users.currentUser, 'blockIds', []), + childPropName: 'entries' + }), + withList({ getEntryProps: userId => ({ userId }) }) +)(BlockCard) + +const MuteList = compose( + withSubscription({ + fetch: (props, $store) => $store.dispatch('fetchMutes'), + select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []), + childPropName: 'entries' + }), + withList({ getEntryProps: userId => ({ userId }) }) +)(MuteCard) const UserSettings = { data () { @@ -21,14 +44,12 @@ const UserSettings = { followImportError: false, followsImported: false, enableFollowsExport: true, - avatarUploading: false, + pickAvatarBtnVisible: true, bannerUploading: false, backgroundUploading: false, followListUploading: false, - avatarPreview: null, bannerPreview: null, backgroundPreview: null, - avatarUploadError: null, bannerUploadError: null, backgroundUploadError: null, deletingAccount: false, @@ -40,10 +61,15 @@ const UserSettings = { activeTab: 'profile' } }, + created () { + this.$store.dispatch('fetchTokens') + }, components: { StyleSwitcher, TabSwitcher, - AutoCompleteInput + ImageCropper, + BlockList, + MuteList }, computed: { user () { @@ -62,6 +88,18 @@ const UserSettings = { private: { selected: this.newDefaultScope === 'private' }, direct: { selected: this.newDefaultScope === 'direct' } } + }, + currentSaveStateNotice () { + return this.$store.state.interface.settings.currentSaveStateNotice + }, + oauthTokens () { + return this.$store.state.oauthTokens.tokens.map(oauthToken => { + return { + id: oauthToken.id, + appName: oauthToken.app_name, + validUntil: new Date(oauthToken.valid_until).toLocaleDateString() + } + }) } }, methods: { @@ -119,35 +157,15 @@ const UserSettings = { } reader.readAsDataURL(file) }, - submitAvatar () { - if (!this.avatarPreview) { return } - - let img = this.avatarPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - if (imginfo.height > imginfo.width) { - cropX = 0 - cropW = imginfo.width - cropY = Math.floor((imginfo.height - imginfo.width) / 2) - cropH = imginfo.width - } else { - cropY = 0 - cropH = imginfo.height - cropX = Math.floor((imginfo.width - imginfo.height) / 2) - cropW = imginfo.height - } - this.avatarUploading = true - this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => { + submitAvatar (cropper, file) { + const img = cropper.getCroppedCanvas().toDataURL(file.type) + return this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) - this.avatarPreview = null } else { - this.avatarUploadError = this.$t('upload.error.base') + user.error + throw new Error(this.$t('upload.error.base') + user.error) } - this.avatarUploading = false }) }, clearUploadError (slot) { @@ -301,6 +319,11 @@ const UserSettings = { logout () { this.$store.dispatch('logout') this.$router.replace('/') + }, + revokeToken (id) { + if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) { + this.$store.dispatch('revokeToken', id) + } } } } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index ad7c17bd..a1123638 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -1,7 +1,20 @@ <template> <div class="settings panel panel-default"> <div class="panel-heading"> - {{$t('settings.user_settings')}} + <div class="title"> + {{$t('settings.user_settings')}} + </div> + <transition name="fade"> + <template v-if="currentSaveStateNotice"> + <div @click.prevent class="alert error" v-if="currentSaveStateNotice.error"> + {{ $t('settings.saving_err') }} + </div> + + <div @click.prevent class="alert transparent" v-if="!currentSaveStateNotice.error"> + {{ $t('settings.saving_ok') }} + </div> + </template> + </transition> </div> <div class="panel-body profile-edit"> <tab-switcher> @@ -9,9 +22,9 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/> + <input class='name-changer' id='username' v-model="newName"></input> <p>{{$t('settings.bio')}}</p> - <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/> + <textarea class="bio" v-model="newBio"></textarea> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label> @@ -48,19 +61,10 @@ <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="old-avatar"></img> + <img :src="user.profile_image_url_original" class="current-avatar"></img> <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview"> - </img> - <div> - <input type="file" @change="uploadFile('avatar', $event)" ></input> - </div> - <i class="icon-spin4 animate-spin" v-if="avatarUploading"></i> - <button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button> - <div class='alert error' v-if="avatarUploadError"> - Error: {{ avatarUploadError }} - <i class="button-icon icon-cancel" @click="clearUploadError('avatar')"></i> - </div> + <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" /> </div> <div class="setting-item"> <h2>{{$t('settings.profile_banner')}}</h2> @@ -118,6 +122,30 @@ </div> <div class="setting-item"> + <h2>{{$t('settings.oauth_tokens')}}</h2> + <table class="oauth-tokens"> + <thead> + <tr> + <th>{{$t('settings.app_name')}}</th> + <th>{{$t('settings.valid_until')}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="oauthToken in oauthTokens" :key="oauthToken.id"> + <td>{{oauthToken.appName}}</td> + <td>{{oauthToken.validUntil}}</td> + <td class="actions"> + <button class="btn btn-default" @click="revokeToken(oauthToken.id)"> + {{$t('settings.revoke_token')}} + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <div class="setting-item"> <h2>{{$t('settings.delete_account')}}</h2> <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> <div v-if="deletingAccount"> @@ -158,6 +186,12 @@ <h2>{{$t('settings.follow_export_processing')}}</h2> </div> </div> + + <div :label="$t('settings.blocks_tab')"> + <block-list :refresh="true"> + <template slot="empty">{{$t('settings.no_blocks')}}</template> + </block-list> + </div> </tab-switcher> </div> </div> @@ -167,6 +201,8 @@ </script> <style lang="scss"> +@import '../../_variables.scss'; + .profile-edit { .bio { margin: 0; @@ -193,5 +229,25 @@ .bg { max-width: 100%; } + + .current-avatar { + display: block; + width: 150px; + height: 150px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + } + + .oauth-tokens { + width: 100%; + + th { + text-align: left; + } + + .actions { + text-align: right; + } + } } </style> diff --git a/src/components/who_to_follow/who_to_follow.js b/src/components/who_to_follow/who_to_follow.js index 82098fc2..be0b8827 100644 --- a/src/components/who_to_follow/who_to_follow.js +++ b/src/components/who_to_follow/who_to_follow.js @@ -1,9 +1,9 @@ import apiService from '../../services/api/api.service.js' -import UserCard from '../user_card/user_card.vue' +import FollowCard from '../follow_card/follow_card.vue' const WhoToFollow = { components: { - UserCard + FollowCard }, data () { return { diff --git a/src/components/who_to_follow/who_to_follow.vue b/src/components/who_to_follow/who_to_follow.vue index df2e03c8..1630f5ac 100644 --- a/src/components/who_to_follow/who_to_follow.vue +++ b/src/components/who_to_follow/who_to_follow.vue @@ -4,7 +4,7 @@ {{$t('who_to_follow.who_to_follow')}} </div> <div class="panel-body"> - <user-card v-for="user in users" :key="user.id" :user="user" :showFollows="true"></user-card> + <FollowCard v-for="user in users" :key="user.id" :user="user"/> </div> </div> </template> |
