diff options
Diffstat (limited to 'src/components')
36 files changed, 802 insertions, 489 deletions
diff --git a/src/components/about/about.js b/src/components/about/about.js index b4433b4e..ae1cb182 100644 --- a/src/components/about/about.js +++ b/src/components/about/about.js @@ -7,6 +7,9 @@ const About = { InstanceSpecificPanel, FeaturesPanel, TermsOfServicePanel + }, + computed: { + showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel } } } diff --git a/src/components/about/about.vue b/src/components/about/about.vue index bf87e0b8..13dec87c 100644 --- a/src/components/about/about.vue +++ b/src/components/about/about.vue @@ -1,7 +1,7 @@ <template> <div class="sidebar"> <instance-specific-panel></instance-specific-panel> - <features-panel></features-panel> + <features-panel v-if="showFeaturesPanel"></features-panel> <terms-of-service-panel></terms-of-service-panel> </div> </template> diff --git a/src/components/autocomplete_input/autocomplete_input.js b/src/components/autocomplete_input/autocomplete_input.js new file mode 100644 index 00000000..2a959fd1 --- /dev/null +++ b/src/components/autocomplete_input/autocomplete_input.js @@ -0,0 +1,150 @@ +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, + text: this.value + } + }, + 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.text, 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.text = Completion.replaceWord(this.text, 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.text = Completion.replaceWord(this.text, 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 new file mode 100644 index 00000000..56233535 --- /dev/null +++ b/src/components/autocomplete_input/autocomplete_input.vue @@ -0,0 +1,104 @@ +<template> + <div style="display: flex; flex-direction: column;"> + <textarea + v-if="multiline" + ref="textarea" + rows="1" + :value="text" :class="classObj" :id="id" :placeholder="placeholder" + @input="text = $event.target.value, $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()" + @dragover.prevent="dragoverPrevent && dragoverPrevent()" + @paste="paste && paste()" + @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter()" + @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter()"> + </textarea> + <input + v-else + ref="textarea" + :value="text" :class="classObj" :id="id" :placeholder="placeholder" + @input="text = $event.target.value, $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()" + @dragover.prevent="dragoverPrevent && dragoverPrevent()" + @paste="paste && paste()" + @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter()" + @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter()"/> + <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/follow_list/follow_list.js b/src/components/follow_list/follow_list.js new file mode 100644 index 00000000..acdb216d --- /dev/null +++ b/src/components/follow_list/follow_list.js @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000..7be2e7b7 --- /dev/null +++ b/src/components/follow_list/follow_list.vue @@ -0,0 +1,34 @@ +<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/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 42d900d3..1c874faa 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -3,19 +3,10 @@ import statusPosterService from '../../services/status_poster/status_poster.serv import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' const mediaUpload = { - mounted () { - const input = this.$el.querySelector('input') - - input.addEventListener('change', ({target}) => { - for (var i = 0; i < target.files.length; i++) { - let file = target.files[i] - this.uploadFile(file) - } - }) - }, data () { return { - uploading: false + uploading: false, + uploadReady: true } }, methods: { @@ -56,6 +47,18 @@ const mediaUpload = { } else { e.dataTransfer.dropEffect = 'none' } + }, + clearFile () { + this.uploadReady = false + this.$nextTick(() => { + this.uploadReady = true + }) + }, + change ({target}) { + for (var i = 0; i < target.files.length; i++) { + let file = target.files[i] + this.uploadFile(file) + } } }, props: [ diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 768d3565..fcdc3471 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -3,7 +3,7 @@ <label class="btn btn-default" :title="$t('tool_tip.media_upload')"> <i class="icon-spin4 animate-spin" v-if="uploading"></i> <i class="icon-upload" v-if="!uploading"></i> - <input type="file" style="position: fixed; top: -100em" multiple="true"></input> + <input type="file" v-if="uploadReady" @change="change" style="position: fixed; top: -100em" multiple="true"></input> </label> </div> </template> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index f95a329f..7d9807de 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,5 +1,5 @@ import Status from '../status/status.vue' -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import UserCardContent from '../user_card_content/user_card_content.vue' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -13,7 +13,7 @@ const Notification = { }, props: [ 'notification' ], components: { - Status, StillImage, UserCardContent + Status, UserAvatar, UserCardContent }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index f91c90cc..a0a55cba 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -2,7 +2,7 @@ <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else> <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <StillImage class='avatar-compact' :class="{'better-shadow': betterShadow}" :src="notification.action.user.profile_image_url_original"/> + <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/> </a> <div class='notification-right'> <div class="usercard notification-usercard" v-if="userExpanded"> diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 5c4ca1b9..bc81d45c 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -36,26 +36,7 @@ border-color: $fallback--border; border-color: var(--border, $fallback--border); - .avatar-compact { - width: 32px; - height: 32px; - box-shadow: var(--avatarStatusShadow); - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - overflow: hidden; - line-height: 0; - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } - - &.animated::before { - display: none; - } - } - - &:hover .animated.avatar-compact { + &:hover .animated.avatar { canvas { display: none; } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 88bc736f..8e30264d 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 Completion from '../../services/completion/completion.js' -import { take, filter, reject, map, uniqBy } from 'lodash' +import { reject, map, uniqBy } from 'lodash' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -28,13 +28,10 @@ const PostStatusForm = { 'subject' ], components: { - MediaUpload + MediaUpload, + AutoCompleteInput }, mounted () { - this.resize(this.$refs.textarea) - const textLength = this.$refs.textarea.value.length - this.$refs.textarea.setSelectionRange(textLength, textLength) - if (this.replyTo) { this.$refs.textarea.focus() } @@ -61,16 +58,13 @@ const PostStatusForm = { submitDisabled: false, error: null, posting: false, - highlighted: 0, newStatus: { spoilerText: this.subject || '', status: statusText, - contentType: 'text/plain', nsfw: false, files: [], visibility: scope - }, - caret: 0 + } } }, computed: { @@ -82,59 +76,6 @@ const PostStatusForm = { direct: { selected: this.newStatus.visibility === 'direct' } } }, - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - const query = this.textAtCaret.slice(1).toUpperCase() - const matchedUsers = filter(this.users, (user) => { - return user.screen_name.toUpperCase().startsWith(query) || - user.name && user.name.toUpperCase().startsWith(query) - }) - if (matchedUsers.length <= 0) { - return false - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) - if (matchedEmoji.length <= 0) { - return false - } - return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - return false - } - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} - return word - }, - users () { - return this.$store.state.users.users - }, - emoji () { - return this.$store.state.instance.emoji || [] - }, - customEmoji () { - return this.$store.state.instance.customEmoji || [] - }, statusLength () { return this.newStatus.status.length }, @@ -167,56 +108,16 @@ 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 } }, methods: { - replace (replacement) { - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart + postStatusCopy () { + this.postStatus(this.newStatus) }, postStatus (newStatus) { if (this.posting) { return } @@ -250,6 +151,7 @@ const PostStatusForm = { visibility: newStatus.visibility, contentType: newStatus.contentType } + this.$refs.mediaUpload.clearFile() this.$emit('posted') let el = this.$el.querySelector('textarea') el.style.height = 'auto' @@ -300,18 +202,6 @@ const PostStatusForm = { fileDrag (e) { e.dataTransfer.dropEffect = 'copy' }, - resize (e) { - const target = e.target || e - if (!(target instanceof window.Element)) { return } - const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + - Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) - // Auto is needed to make textbox shrink when removing lines - target.style.height = 'auto' - target.style.height = `${target.scrollHeight - vertPadding}px` - if (target.value === '') { - target.style.height = null - } - }, clearError () { this.error = null }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 398f1871..ef3a7901 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -16,26 +16,20 @@ :placeholder="$t('post_status.content_warning')" v-model="newStatus.spoilerText" class="form-cw"> - <textarea - ref="textarea" - @click="setCaret" - @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @keydown.meta.enter="postStatus(newStatus)" - @keyup.ctrl.enter="postStatus(newStatus)" - @drop="fileDrop" - @dragover.prevent="fileDrag" - @input="resize" - @paste="paste"> - </textarea> + <auto-complete-input v-model="newStatus.status" + :classObj="{ 'form-control': true }" + :placeholder="$t('post_status.default')" + :autoResize="true" + :multiline="true" + :drop="fileDrop" + :dragoverPrevent="fileDrag" + :paste="paste" + :keydownMetaEnter="postStatusCopy" + :keyupCtrlEnter="postStatusCopy"/> <div class="visibility-tray"> <span class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> - <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> + <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> @@ -52,19 +46,8 @@ </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 @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> + <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> @@ -250,52 +233,5 @@ cursor: pointer; z-index: 4; } - - .autocomplete-panel { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - position: absolute; - z-index: 1; - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - // this doesn't match original but i don't care, making it uniform. - box-shadow: var(--popupShadow); - min-width: 75%; - background: $fallback--bg; - background: var(--bg, $fallback--bg); - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - - .autocomplete { - cursor: pointer; - padding: 0.2em 0.4em 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); - display: flex; - - img { - width: 24px; - height: 24px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - object-fit: contain; - } - - span { - line-height: 24px; - margin: 0 0.1em 0 0.2em; - } - - small { - margin-left: .5em; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - &.highlighted { - background-color: $fallback--fg; - background-color: var(--lightBg, $fallback--fg); - } - } } </style> diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.js b/src/components/public_and_external_timeline/public_and_external_timeline.js index 0db6efae..d45677e0 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.js +++ b/src/components/public_and_external_timeline/public_and_external_timeline.js @@ -7,7 +7,7 @@ const PublicAndExternalTimeline = { timeline () { return this.$store.state.statuses.timelines.publicAndExternal } }, created () { - this.$store.dispatch('startFetching', 'publicAndExternal') + this.$store.dispatch('startFetching', { timeline: 'publicAndExternal' }) }, destroyed () { this.$store.dispatch('stopFetching', 'publicAndExternal') diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.vue b/src/components/public_and_external_timeline/public_and_external_timeline.vue index aded2ead..6be9f955 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.vue +++ b/src/components/public_and_external_timeline/public_and_external_timeline.vue @@ -1,5 +1,5 @@ <template> - <Timeline :title="$t('nav.twkn')"v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/> + <Timeline :title="$t('nav.twkn')" v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/> </template> <script src="./public_and_external_timeline.js"></script> diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js index 9b866be8..64c951ac 100644 --- a/src/components/public_timeline/public_timeline.js +++ b/src/components/public_timeline/public_timeline.js @@ -7,7 +7,7 @@ const PublicTimeline = { timeline () { return this.$store.state.statuses.timelines.public } }, created () { - this.$store.dispatch('startFetching', 'public') + this.$store.dispatch('startFetching', { timeline: 'public' }) }, destroyed () { this.$store.dispatch('stopFetching', 'public') diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 8d138485..534a9839 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -27,6 +27,11 @@ const settings = { : user.hideUserStats, hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats), + hideFilteredStatusesLocal: typeof user.hideFilteredStatuses === 'undefined' + ? instance.hideFilteredStatuses + : user.hideFilteredStatuses, + hideFilteredStatusesDefault: this.$t('settings.values.' + instance.hideFilteredStatuses), + notificationVisibilityLocal: user.notificationVisibility, replyVisibilityLocal: user.replyVisibility, loopVideoLocal: user.loopVideo, @@ -46,6 +51,11 @@ const settings = { : user.subjectLineBehavior, subjectLineBehaviorDefault: instance.subjectLineBehavior, + postContentTypeLocal: typeof user.postContentType === 'undefined' + ? instance.postContentType + : user.postContentType, + postContentTypeDefault: instance.postContentType, + alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined' ? instance.alwaysShowSubjectInput : user.alwaysShowSubjectInput, @@ -96,6 +106,9 @@ const settings = { hideUserStatsLocal (value) { this.$store.dispatch('setOption', { name: 'hideUserStats', value }) }, + hideFilteredStatusesLocal (value) { + this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value }) + }, hideNsfwLocal (value) { this.$store.dispatch('setOption', { name: 'hideNsfw', value }) }, @@ -157,6 +170,9 @@ const settings = { subjectLineBehaviorLocal (value) { this.$store.dispatch('setOption', { name: 'subjectLineBehavior', value }) }, + postContentTypeLocal (value) { + this.$store.dispatch('setOption', { name: 'postContentType', value }) + }, stopGifs (value) { this.$store.dispatch('setOption', { name: 'stopGifs', value }) }, diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 9953780f..dfb2e49d 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -100,6 +100,28 @@ </label> </div> </li> + <li> + <div> + {{$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> + </select> + <i class="icon-down-open"/> + </label> + </div> + </li> </ul> </div> @@ -205,7 +227,6 @@ </label> </li> </ul> - </label> </div> <div> {{$t('settings.replies_in_timeline')}} @@ -232,11 +253,18 @@ </div> </div> <div class="setting-item"> - <p>{{$t('settings.filtering_explanation')}}</p> - <textarea id="muteWords" v-model="muteWordsString"></textarea> + <div> + <p>{{$t('settings.filtering_explanation')}}</p> + <textarea id="muteWords" v-model="muteWordsString"></textarea> + </div> + <div> + <input type="checkbox" id="hideFilteredStatuses" v-model="hideFilteredStatusesLocal"> + <label for="hideFilteredStatuses"> + {{$t('settings.hide_filtered_statuses')}} {{$t('settings.instance_default', { value: hideFilteredStatusesDefault })}} + </label> + </div> </div> </div> - </tab-switcher> </keep-alive> </div> diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 754a57e0..40ffa1dd 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -26,6 +26,12 @@ const SideDrawer = { }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled + }, + logo () { + return this.$store.state.instance.logo + }, + sitename () { + return this.$store.state.instance.name } }, methods: { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index fc90977b..a6c6f237 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -8,8 +8,11 @@ @touchmove="touchMove" > <div class="side-drawer-heading" @click="toggleDrawer"> - <user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"> - </user-card-content> + <user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"/> + <div class="side-drawer-logo-wrapper" v-else> + <img :src="logo"/> + <span>{{sitename}}</span> + </div> </div> <ul> <li v-if="currentUser" @click="toggleDrawer"> @@ -141,6 +144,24 @@ background-color: var(--bg, $fallback--bg); } +.side-drawer-logo-wrapper { + display: flex; + align-items: center; + padding: 0.85em; + + img { + flex: none; + height: 50px; + margin-right: 0.85em; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + .side-drawer-click-outside-closed { flex: 0 0 0; } @@ -154,7 +175,6 @@ flex-direction: column; align-items: stretch; display: flex; - min-height: 7em; padding: 0; margin: 0; diff --git a/src/components/status/status.js b/src/components/status/status.js index c718fe9f..0273a5be 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -4,14 +4,14 @@ import RetweetButton from '../retweet_button/retweet_button.vue' import DeleteButton from '../delete_button/delete_button.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCardContent from '../user_card_content/user_card_content.vue' -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' -import { mentionMatchesUrl } from 'src/services/mention_matcher/mention_matcher.js' -import { filter, find } from 'lodash' +import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' +import { filter, find, unescape } from 'lodash' const Status = { name: 'Status', @@ -36,6 +36,7 @@ const Status = { preview: null, showPreview: false, showingTall: this.inConversation && this.focused, + showingLongSubject: false, expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, @@ -89,6 +90,7 @@ const Status = { retweet () { return !!this.statusoid.retweeted_status }, retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name }, retweeterHtml () { return this.statusoid.user.name_html }, + retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) }, status () { if (this.retweet) { return this.statusoid.retweeted_status @@ -108,6 +110,14 @@ const Status = { return hits }, muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) }, + hideFilteredStatuses () { + return typeof this.$store.state.config.hideFilteredStatuses === 'undefined' + ? this.$store.state.instance.hideFilteredStatuses + : this.$store.state.config.hideFilteredStatuses + }, + hideStatus () { + return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) + }, isFocused () { // retweet or root of an expanded conversation if (this.focused) { @@ -129,6 +139,9 @@ const Status = { const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 return lengthScore > 20 }, + longSubject () { + return this.status.summary.length > 900 + }, isReply () { return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id) }, @@ -196,14 +209,15 @@ const Status = { }, replySubject () { if (!this.status.summary) return '' + const decodedSummary = unescape(this.status.summary) const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined' ? this.$store.state.instance.subjectLineBehavior : this.$store.state.config.subjectLineBehavior - const startsWithRe = this.status.summary.match(/^re[: ]/i) + const startsWithRe = decodedSummary.match(/^re[: ]/i) if (behavior !== 'noop' && startsWithRe || behavior === 'masto') { - return this.status.summary + return decodedSummary } else if (behavior === 'email') { - return 're: '.concat(this.status.summary) + return 're: '.concat(decodedSummary) } else if (behavior === 'noop') { return '' } @@ -244,7 +258,7 @@ const Status = { DeleteButton, PostStatusForm, UserCardContent, - StillImage, + UserAvatar, Gallery, LinkPreview }, @@ -268,7 +282,7 @@ const Status = { } if (target.tagName === 'A') { if (target.className.match(/mention/)) { - const href = target.getAttribute('href') + const href = target.href const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) if (attn) { event.stopPropagation() @@ -278,6 +292,15 @@ const Status = { return } } + if (target.className.match(/hashtag/)) { + // Extract tag name from link url + const tag = extractTagFromUrl(target.href) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + return + } + } window.open(target.href, '_blank') } }, @@ -334,6 +357,9 @@ const Status = { generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) }, + generateTagLink (tag) { + return `/tag/${tag}` + }, setMedia () { const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments return () => this.$store.dispatch('setMedia', attachments) diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 3e3e82bf..aae365d1 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,5 +1,5 @@ <template> - <div class="status-el" v-if="!hideReply && !deleted" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> + <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> <template v-if="muted && !noReplyLinks"> <div class="media status container muted"> <small> @@ -13,10 +13,12 @@ </template> <template v-else> <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <StillImage v-if="retweet" class='avatar' :class='{ "better-shadow": betterShadow }' :src="statusoid.user.profile_image_url_original"/> + <UserAvatar v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> <div class="media-body faint"> - <a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a> - <a v-else :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> + <span class="user-name"> + <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> + <router-link v-else :to="retweeterProfileLink">{{retweeter}}</router-link> + </span> <i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i> {{$t('timeline.repeated')}} </div> @@ -25,7 +27,7 @@ <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status"> <div v-if="!noHeading" class="media-left"> <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> - <StillImage class='avatar' :class="{'avatar-compact': compact, 'better-shadow': betterShadow}" :src="status.user.profile_image_url_original"/> + <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/> </router-link> </div> <div class="status-body"> @@ -54,7 +56,7 @@ </div> <h4 class="replies" v-if="inConversation && !noReplyLinks"> <small v-if="replies.length">Replies:</small> - <small class="reply-link" v-for="reply in replies"> + <small class="reply-link" v-bind:key="reply.id" v-for="reply in replies"> <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}} </a> </small> </h4> @@ -85,7 +87,12 @@ </div> </div> - <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper"> + <div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject"> + <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">Show more</a> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div> + <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">Show less</a> + </div> + <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else> <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a> <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div> <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div> @@ -93,7 +100,7 @@ <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a> </div> - <div v-if="status.attachments && !hideSubjectStatus" class="attachments media-body"> + <div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body"> <attachment class="non-gallery" v-for="attachment in nonGalleryAttachments" @@ -413,7 +420,7 @@ padding: 0.4em 0.6em 0 0.6em; margin: 0; - .avatar { + .avatar.still-image { border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); margin-left: 28px; @@ -431,6 +438,8 @@ .user-name { font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; img { width: 14px; @@ -497,46 +506,6 @@ color: var(--cBlue, $fallback--cBlue); } -.status .avatar-compact { - width: 32px; - height: 32px; - box-shadow: var(--avatarStatusShadow); - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } -} - -.avatar.still-image { - width: 48px; - height: 48px; - box-shadow: var(--avatarStatusShadow); - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - overflow: hidden; - position: relative; - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } - - img { - width: 100%; - height: 100%; - } - - &.animated::before { - display: none; - } - - &.retweeted { - } -} - .status:hover .animated.avatar { canvas { display: none; @@ -594,7 +563,7 @@ a.unmute { @media all and (max-width: 800px) { .status-el { .retweet-info { - .avatar { + .avatar.still-image { margin-left: 20px; } } @@ -603,14 +572,14 @@ a.unmute { max-width: 100%; } - .status .avatar { + .status .avatar.still-image { width: 40px; height: 40px; - } - .status .avatar-compact { - width: 32px; - height: 32px; + &.avatar-compact { + width: 32px; + height: 32px; + } } } diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 5ad06dc2..02e98f19 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -2,7 +2,8 @@ const StillImage = { props: [ 'src', 'referrerpolicy', - 'mimetype' + 'mimetype', + 'imageLoadError' ], data () { return { @@ -23,6 +24,9 @@ const StillImage = { canvas.width = width canvas.height = height canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height) + }, + onError () { + this.imageLoadError && this.imageLoadError() } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index 1dcb7ce6..af824fa2 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -1,7 +1,7 @@ <template> <div class='still-image' :class='{ animated: animated }' > <canvas ref="canvas" v-if="animated"></canvas> - <img ref="src" :src="src" :referrerpolicy="referrerpolicy" v-on:load="onLoad"/> + <img ref="src" :src="src" :referrerpolicy="referrerpolicy" v-on:load="onLoad" @error="onError"/> </div> </template> diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index f9c3f927..423df258 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -37,7 +37,7 @@ export default Vue.component('tab-switcher', { return ( <div class={ classesWrapper.join(' ')}> - <button onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> + <button disabled={slot.data.attrs.disabled} onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> </div> ) }) diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js index 43de4f49..41b09706 100644 --- a/src/components/tag_timeline/tag_timeline.js +++ b/src/components/tag_timeline/tag_timeline.js @@ -3,7 +3,7 @@ import Timeline from '../timeline/timeline.vue' const TagTimeline = { created () { this.$store.commit('clearTimeline', { timeline: 'tag' }) - this.$store.dispatch('startFetching', { 'tag': this.tag }) + this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag }) }, components: { Timeline @@ -15,7 +15,7 @@ const TagTimeline = { watch: { tag () { this.$store.commit('clearTimeline', { timeline: 'tag' }) - this.$store.dispatch('startFetching', { 'tag': this.tag }) + this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag }) } }, destroyed () { diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js new file mode 100644 index 00000000..e513b993 --- /dev/null +++ b/src/components/user_avatar/user_avatar.js @@ -0,0 +1,29 @@ +import StillImage from '../still-image/still-image.vue' + +const UserAvatar = { + props: [ + 'src', + 'betterShadow', + 'compact' + ], + data () { + return { + showPlaceholder: false + } + }, + components: { + StillImage + }, + computed: { + imgSrc () { + return this.showPlaceholder ? '/images/avi.png' : this.src + } + }, + methods: { + imageLoadError () { + this.showPlaceholder = true + } + } +} + +export default UserAvatar diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue new file mode 100644 index 00000000..6bf7123d --- /dev/null +++ b/src/components/user_avatar/user_avatar.vue @@ -0,0 +1,42 @@ +<template> + <StillImage + class="avatar" + :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" + :src="imgSrc" + :imageLoadError="imageLoadError" + /> +</template> + +<script src="./user_avatar.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.avatar.still-image { + width: 48px; + height: 48px; + box-shadow: var(--avatarStatusShadow); + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + + img { + width: 100%; + height: 100%; + } + + &.better-shadow { + box-shadow: var(--avatarStatusShadowInset); + filter: var(--avatarStatusShadowFilter) + } + + &.animated::before { + display: none; + } + + &.avatar-compact { + width: 32px; + height: 32px; + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } +} +</style> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 615e6487..a4c84716 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -1,24 +1,33 @@ import UserCardContent from '../user_card_content/user_card_content.vue' -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' const UserCard = { props: [ 'user', 'showFollows', - 'showApproval' + 'showApproval', + 'showActions' ], data () { return { - userExpanded: false + userExpanded: false, + followRequestInProgress: false, + followRequestSent: false, + updated: false } }, components: { UserCardContent, - StillImage + UserAvatar }, computed: { - currentUser () { return this.$store.state.users.currentUser } + currentUser () { return this.$store.state.users.currentUser }, + following () { return this.updated ? this.updated.following : this.user.following }, + showFollow () { + return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following) + } }, methods: { toggleUserExpanded () { @@ -34,6 +43,21 @@ const UserCard = { }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + followUser () { + this.followRequestInProgress = true + requestFollow(this.user, this.$store).then(({ sent, updated }) => { + this.followRequestInProgress = false + this.followRequestSent = sent + this.updated = updated + }) + }, + unfollowUser () { + this.followRequestInProgress = true + requestUnfollow(this.user, this.$store).then(({ updated }) => { + this.followRequestInProgress = false + this.updated = updated + }) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index cf69606d..12960c02 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -1,28 +1,49 @@ <template> <div class="card"> - <a href="#"> - <StillImage @click.prevent="toggleUserExpanded" class="avatar" :src="user.profile_image_url"/> - </a> + <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" v-if="user.name_html" class="user-name"> - <span v-html="user.name_html"></span> + <div :title="user.name" class="user-name"> + <span v-if="user.name_html" v-html="user.name_html"></span> + <span v-else>{{ user.name }}</span> <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> </div> - <div :title="user.name" v-else class="user-name"> - {{ user.name }} - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} - </span> + <div class="user-link-action"> + <router-link class='user-screen-name' :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + <button + v-if="showFollow" + class="btn btn-default" + @click="followUser" + :disabled="followRequestInProgress" + :title="followRequestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="followRequestInProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="followRequestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-if="showActions && showFollows && following" class="btn btn-default" @click="unfollowUser" :disabled="followRequestInProgress"> + <template v-if="followRequestInProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> </div> - - <router-link class='user-screen-name' :to="userProfileLink(user)"> - @{{user.screen_name}} - </router-link> </div> <div class="approval" v-if="showApproval"> <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> @@ -42,6 +63,9 @@ text-align: left; width: 100%; .user-name { + display: flex; + justify-content: space-between; + img { object-fit: contain; height: 16px; @@ -49,11 +73,20 @@ vertical-align: middle; } } + + .user-link-action { + display: flex; + align-items: flex-start; + justify-content: space-between; + + button { + margin-top: 3px; + } + } } .follows-you { margin-left: 2em; - float: right; } .card { @@ -69,17 +102,13 @@ border-bottom-color: var(--border, $fallback--border); .avatar { - margin-top: 0.2em; - width:32px; - height: 32px; - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + padding: 0; } } .usercard { width: fill-available; - margin: 0.2em 0 0.7em 0; + margin: 0.2em 0 0 0.7em; border-radius: $fallback--panelRadius; border-radius: var(--panelRadius, $fallback--panelRadius); border-style: solid; diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js index 541c73b4..7a7b89d4 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card_content/user_card_content.js @@ -1,5 +1,6 @@ -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' export default { @@ -79,76 +80,30 @@ export default { set (color) { this.$store.dispatch('setHighlight', { user: this.user.screen_name, color }) } + }, + visibleRole () { + const validRole = (this.user.role === 'admin' || this.user.role === 'moderator') + const showRole = this.isOtherUser || this.user.show_role + + return validRole && showRole && this.user.role } }, components: { - StillImage + UserAvatar }, methods: { followUser () { - const store = this.$store this.followRequestInProgress = true - store.state.api.backendInteractor.followUser(this.user.id) - .then((followedUser) => store.commit('addNewUsers', [followedUser])) - .then(() => { - // For locked users we just mark it that we sent the follow request - if (this.user.locked) { - this.followRequestInProgress = false - this.followRequestSent = true - return - } - - if (this.user.following) { - // If we get result immediately, just stop. - this.followRequestInProgress = false - return - } - - // But usually we don't get result immediately, so we ask server - // for updated user profile to confirm if we are following them - // Sometimes it takes several tries. Sometimes we end up not following - // user anyway, probably because they locked themselves and we - // don't know that yet. - // Recursive Promise, it will call itself up to 3 times. - const fetchUser = (attempt) => new Promise((resolve, reject) => { - setTimeout(() => { - store.state.api.backendInteractor.fetchUser({ id: this.user.id }) - .then((user) => store.commit('addNewUsers', [user])) - .then(() => resolve([this.user.following, attempt])) - .catch((e) => reject(e)) - }, 500) - }).then(([following, attempt]) => { - if (!following && attempt <= 3) { - // If we BE reports that we still not following that user - retry, - // increment attempts by one - return fetchUser(++attempt) - } else { - // If we run out of attempts, just return whatever status is. - return following - } - }) - - return fetchUser(1) - .then((following) => { - if (following) { - // We confirmed and everything its good. - this.followRequestInProgress = false - } else { - // If after all the tries, just treat it as if user is locked - this.followRequestInProgress = false - this.followRequestSent = true - } - }) - }) + requestFollow(this.user, this.$store).then(({sent}) => { + this.followRequestInProgress = false + this.followRequestSent = sent + }) }, unfollowUser () { - const store = this.$store this.followRequestInProgress = true - store.state.api.backendInteractor.unfollowUser(this.user.id) - .then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser])) - .then(() => { - this.followRequestInProgress = false - }) + requestUnfollow(this.user, this.$store).then(() => { + this.followRequestInProgress = false + }) }, blockUser () { const store = this.$store diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index d1034d68..7f9909c4 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -4,7 +4,7 @@ <div class='user-info'> <div class='container'> <router-link :to="userProfileLink(user)"> - <StillImage class="avatar" :class='{ "better-shadow": betterShadow }' :src="user.profile_image_url_original"/> + <UserAvatar :betterShadow="betterShadow" :src="user.profile_image_url_original"/> </router-link> <div class="name-and-screen-name"> <div class="top-line"> @@ -19,7 +19,9 @@ </div> <router-link class='user-screen-name' :to="userProfileLink(user)"> - <span class="handle">@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span> + <span class="handle">@{{user.screen_name}} + <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span> + </span><span v-if="user.locked"><i class="icon icon-lock"></i></span> <span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> </router-link> </div> @@ -169,23 +171,12 @@ max-height: 56px; .avatar { - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); flex: 1 0 100%; width: 56px; height: 56px; box-shadow: 0px 1px 8px rgba(0,0,0,0.75); box-shadow: var(--avatarShadow); object-fit: cover; - - &.better-shadow { - box-shadow: var(--avatarShadowInset); - filter: var(--avatarShadowFilter) - } - - &.animated::before { - display: none; - } } } @@ -258,6 +249,15 @@ text-overflow: ellipsis; overflow: hidden; } + + // TODO use proper colors + .staff { + text-transform: capitalize; + color: $fallback--text; + color: var(--btnText, $fallback--text); + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + } } .user-meta { diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 27e138b0..a22b8722 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,23 +1,22 @@ import UserCardContent from '../user_card_content/user_card_content.vue' import UserCard from '../user_card/user_card.vue' import Timeline from '../timeline/timeline.vue' +import FollowList from '../follow_list/follow_list.vue' const UserProfile = { created () { this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'favorites' }) this.$store.commit('clearTimeline', { timeline: 'media' }) - this.$store.dispatch('startFetching', ['user', this.fetchBy]) - this.$store.dispatch('startFetching', ['media', this.fetchBy]) + this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy }) + this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy }) this.startFetchFavorites() if (!this.user.id) { this.$store.dispatch('fetchUser', this.fetchBy) } }, destroyed () { - this.$store.dispatch('stopFetching', 'user') - this.$store.dispatch('stopFetching', 'favorites') - this.$store.dispatch('stopFetching', 'media') + this.cleanUp(this.userId) }, computed: { timeline () { @@ -39,12 +38,6 @@ const UserProfile = { return this.userId && this.$store.state.users.currentUser.id && this.userId === this.$store.state.users.currentUser.id }, - friends () { - return this.user.friends - }, - followers () { - return this.user.followers - }, userInStore () { if (this.isExternal) { return this.$store.getters.userById(this.userId) @@ -65,64 +58,56 @@ const UserProfile = { }, isExternal () { return this.$route.name === 'external-user-profile' + }, + followsTabVisible () { + return this.isUs || !this.user.hide_follows + }, + followersTabVisible () { + return this.isUs || !this.user.hide_followers } }, methods: { - fetchFollowers () { - const id = this.userId - this.$store.dispatch('addFollowers', { id }) - }, - fetchFriends () { - const id = this.userId - this.$store.dispatch('addFriends', { id }) - }, startFetchFavorites () { if (this.isUs) { - this.$store.dispatch('startFetching', ['favorites', this.fetchBy]) + this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy }) } + }, + startUp () { + this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy }) + this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy }) + + this.startFetchFavorites() + }, + cleanUp () { + this.$store.dispatch('stopFetching', 'user') + this.$store.dispatch('stopFetching', 'favorites') + this.$store.dispatch('stopFetching', 'media') + this.$store.commit('clearTimeline', { timeline: 'user' }) + this.$store.commit('clearTimeline', { timeline: 'favorites' }) + this.$store.commit('clearTimeline', { timeline: 'media' }) } }, watch: { - // TODO get rid of this copypasta userName () { if (this.isExternal) { return } - this.$store.dispatch('stopFetching', 'user') - this.$store.dispatch('stopFetching', 'favorites') - this.$store.dispatch('stopFetching', 'media') - this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.commit('clearTimeline', { timeline: 'favorites' }) - this.$store.commit('clearTimeline', { timeline: 'media' }) - this.$store.dispatch('startFetching', ['user', this.fetchBy]) - this.$store.dispatch('startFetching', ['media', this.fetchBy]) - this.startFetchFavorites() + this.cleanUp() + this.startUp() }, userId () { if (!this.isExternal) { return } - this.$store.dispatch('stopFetching', 'user') - this.$store.dispatch('stopFetching', 'favorites') - this.$store.dispatch('stopFetching', 'media') - this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.commit('clearTimeline', { timeline: 'favorites' }) - this.$store.commit('clearTimeline', { timeline: 'media' }) - this.$store.dispatch('startFetching', ['user', this.fetchBy]) - this.$store.dispatch('startFetching', ['media', this.fetchBy]) - this.startFetchFavorites() - }, - user () { - if (this.user.id && !this.user.followers) { - this.fetchFollowers() - this.fetchFriends() - } + this.cleanUp() + this.startUp() } }, components: { UserCardContent, UserCard, - Timeline + Timeline, + FollowList } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index e53ce4cc..79461291 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -9,40 +9,28 @@ <tab-switcher :renderOnlyFocused="true"> <Timeline :label="$t('user_card.statuses')" + :disabled="!user.statuses_count" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="fetchBy" /> - <div :label="$t('user_card.followees')"> - <div v-if="friends"> - <user-card - v-for="friend in friends" - :key="friend.id" - :user="friend" - :showFollows="true" - /> - </div> + <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> + <FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" /> <div class="userlist-placeholder" v-else> <i class="icon-spin3 animate-spin"></i> </div> </div> - <div :label="$t('user_card.followers')"> - <div v-if="followers"> - <user-card - v-for="follower in followers" - :key="follower.id" - :user="follower" - :showFollows="false" - /> - </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> </div> <Timeline :label="$t('user_card.media')" + :disabled="!media.visibleStatuses.length" :embedded="true" :title="$t('user_card.media')" timeline-name="media" :timeline="media" @@ -51,6 +39,7 @@ <Timeline v-if="isUs" :label="$t('user_card.favorites')" + :disabled="!favorites.visibleStatuses.length" :embedded="true" :title="$t('user_card.favorites')" timeline-name="favorites" diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 9bd8aa00..fa389c3b 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,16 +1,22 @@ +import { unescape } from 'lodash' + import TabSwitcher from '../tab_switcher/tab_switcher.js' import StyleSwitcher from '../style_switcher/style_switcher.vue' +import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' const UserSettings = { data () { return { newName: this.$store.state.users.currentUser.name, - newBio: this.$store.state.users.currentUser.description, + newBio: unescape(this.$store.state.users.currentUser.description), newLocked: this.$store.state.users.currentUser.locked, newNoRichText: this.$store.state.users.currentUser.no_rich_text, newDefaultScope: this.$store.state.users.currentUser.default_scope, - newHideNetwork: this.$store.state.users.currentUser.hide_network, + hideFollows: this.$store.state.users.currentUser.hide_follows, + hideFollowers: this.$store.state.users.currentUser.hide_followers, + showRole: this.$store.state.users.currentUser.show_role, + role: this.$store.state.users.currentUser.role, followList: null, followImportError: false, followsImported: false, @@ -36,7 +42,8 @@ const UserSettings = { }, components: { StyleSwitcher, - TabSwitcher + TabSwitcher, + AutoCompleteInput }, computed: { user () { @@ -66,7 +73,10 @@ const UserSettings = { /* eslint-disable camelcase */ const default_scope = this.newDefaultScope const no_rich_text = this.newNoRichText - const hide_network = this.newHideNetwork + const hide_follows = this.hideFollows + const hide_followers = this.hideFollowers + const show_role = this.showRole + /* eslint-enable camelcase */ this.$store.state.api.backendInteractor .updateProfile({ @@ -78,7 +88,9 @@ const UserSettings = { /* eslint-disable camelcase */ default_scope, no_rich_text, - hide_network + hide_follows, + hide_followers, + show_role /* eslint-enable camelcase */ }}).then((user) => { if (!user.error) { @@ -233,7 +245,9 @@ const UserSettings = { exportFollows () { this.enableFollowsExport = false this.$store.state.api.backendInteractor - .fetchFriends({id: this.$store.state.users.currentUser.id}) + .exportFriends({ + id: this.$store.state.users.currentUser.id + }) .then((friendList) => { this.exportPeople(friendList, 'friends.csv') setTimeout(() => { this.enableFollowsExport = true }, 2000) diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index be42cc4a..ad7c17bd 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -9,9 +9,9 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <input class='name-changer' id='username' v-model="newName"></input> + <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/> <p>{{$t('settings.bio')}}</p> - <textarea class="bio" v-model="newBio"></textarea> + <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label> @@ -30,10 +30,19 @@ <label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label> </p> <p> - <input type="checkbox" v-model="newHideNetwork" id="account-hide-network"> - <label for="account-hide-network">{{$t('settings.hide_network_description')}}</label> + <input type="checkbox" v-model="hideFollows" id="account-hide-follows"> + <label for="account-hide-follows">{{$t('settings.hide_follows_description')}}</label> </p> - <button :disabled='newName.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> + <p> + <input type="checkbox" v-model="hideFollowers" id="account-hide-followers"> + <label for="account-hide-followers">{{$t('settings.hide_followers_description')}}</label> + </p> + <p> + <input type="checkbox" v-model="showRole" id="account-show-role"> + <label for="account-show-role" v-if="role === 'admin'">{{$t('settings.show_admin_badge')}}</label> + <label for="account-show-role" v-if="role === 'moderator'">{{$t('settings.show_moderator_badge')}}</label> + </p> + <button :disabled='newName && newName.length === 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> </div> <div class="setting-item"> <h2>{{$t('settings.avatar')}}</h2> @@ -169,7 +178,7 @@ } .banner { - max-width: 400px; + max-width: 100%; } .uploading { @@ -180,5 +189,9 @@ .name-changer { width: 100%; } + + .bg { + max-width: 100%; + } } </style> |
