diff options
Diffstat (limited to 'src/components')
148 files changed, 5063 insertions, 2388 deletions
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index ab5d1d29..1e31151c 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -6,10 +6,7 @@ :bound-to="{ x: 'container' }" remove-padding > - <div - slot="content" - class="account-tools-popover" - > + <template v-slot:content> <div class="dropdown-menu"> <template v-if="relationship.following"> <button @@ -59,16 +56,15 @@ {{ $t('user_card.message') }} </button> </div> - </div> - <div - slot="trigger" - class="ellipsis-button" - > - <FAIcon - class="icon" - icon="ellipsis-v" - /> - </div> + </template> + <template v-slot:trigger> + <button class="button-unstyled ellipsis-button"> + <FAIcon + class="icon" + icon="ellipsis-v" + /> + </button> + </template> </Popover> </div> </template> @@ -83,7 +79,6 @@ } .ellipsis-button { - cursor: pointer; width: 2.5em; margin: -0.5em 0; padding: 0.5em 0; diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 5f5779a0..d62a4adc 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -1,4 +1,5 @@ import StillImage from '../still-image/still-image.vue' +import Flash from '../flash/flash.vue' import VideoAttachment from '../video_attachment/video_attachment.vue' import nsfwImage from '../../assets/nsfw.png' import fileTypeService from '../../services/file_type/file_type.service.js' @@ -10,7 +11,12 @@ import { faImage, faVideo, faPlayCircle, - faTimes + faTimes, + faStop, + faSearchPlus, + faTrashAlt, + faPencilAlt, + faAlignRight } from '@fortawesome/free-solid-svg-icons' library.add( @@ -19,36 +25,64 @@ library.add( faImage, faVideo, faPlayCircle, - faTimes + faTimes, + faStop, + faSearchPlus, + faTrashAlt, + faPencilAlt, + faAlignRight ) const Attachment = { props: [ 'attachment', + 'description', + 'hideDescription', 'nsfw', 'size', - 'allowPlay', 'setMedia', - 'naturalSizeLoad' + 'remove', + 'shiftUp', + 'shiftDn', + 'edit' ], data () { return { + localDescription: this.description || this.attachment.description, nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, preloadImage: this.$store.getters.mergedConfig.preloadImage, loading: false, img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), modalOpen: false, - showHidden: false + showHidden: false, + flashLoaded: false, + showDescription: false } }, components: { + Flash, StillImage, VideoAttachment }, computed: { + classNames () { + return [ + { + '-loading': this.loading, + '-nsfw-placeholder': this.hidden, + '-editable': this.edit !== undefined + }, + '-type-' + this.type, + this.size && '-size-' + this.size, + `-${this.useContainFit ? 'contain' : 'cover'}-fit` + ] + }, usePlaceholder () { - return this.size === 'hide' || this.type === 'unknown' + return this.size === 'hide' + }, + useContainFit () { + return this.$store.getters.mergedConfig.useContainFit }, placeholderName () { if (this.attachment.description === '' || !this.attachment.description) { @@ -72,24 +106,33 @@ const Attachment = { return this.nsfw && this.hideNsfwLocal && !this.showHidden }, isEmpty () { - return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown' - }, - isSmall () { - return this.size === 'small' - }, - fullwidth () { - if (this.size === 'hide') return false - return this.type === 'html' || this.type === 'audio' || this.type === 'unknown' + return (this.type === 'html' && !this.attachment.oembed) }, useModal () { - const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio'] - : this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] + let modalTypes = [] + switch (this.size) { + case 'hide': + case 'small': + modalTypes = ['image', 'video', 'audio', 'flash'] + break + default: + modalTypes = this.mergedConfig.playVideosInModal + ? ['image', 'video', 'flash'] + : ['image'] + break + } return modalTypes.includes(this.type) }, + videoTag () { + return this.useModal ? 'button' : 'span' + }, ...mapGetters(['mergedConfig']) }, + watch: { + localDescription (newVal) { + this.onEdit(newVal) + } + }, methods: { linkClicked ({ target }) { if (target.tagName === 'A') { @@ -98,12 +141,37 @@ const Attachment = { }, openModal (event) { if (this.useModal) { - event.stopPropagation() - event.preventDefault() - this.setMedia() - this.$store.dispatch('setCurrent', this.attachment) + this.$emit('setMedia') + this.$store.dispatch('setCurrentMedia', this.attachment) + } else if (this.type === 'unknown') { + window.open(this.attachment.url) } }, + openModalForce (event) { + this.$emit('setMedia') + this.$store.dispatch('setCurrentMedia', this.attachment) + }, + onEdit (event) { + this.edit && this.edit(this.attachment, event) + }, + onRemove () { + this.remove && this.remove(this.attachment) + }, + onShiftUp () { + this.shiftUp && this.shiftUp(this.attachment) + }, + onShiftDn () { + this.shiftDn && this.shiftDn(this.attachment) + }, + stopFlash () { + this.$refs.flash.closePlayer() + }, + setFlashLoaded (event) { + this.flashLoaded = event + }, + toggleDescription () { + this.showDescription = !this.showDescription + }, toggleHidden (event) { if ( (this.mergedConfig.useOneClickNsfw && !this.showHidden) && @@ -130,7 +198,7 @@ const Attachment = { onImageLoad (image) { const width = image.naturalWidth const height = image.naturalHeight - this.naturalSizeLoad && this.naturalSizeLoad({ width, height }) + this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height }) } } } diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss new file mode 100644 index 00000000..dfda15bf --- /dev/null +++ b/src/components/attachment/attachment.scss @@ -0,0 +1,268 @@ +@import '../../_variables.scss'; + +.Attachment { + display: inline-flex; + flex-direction: column; + position: relative; + align-self: flex-start; + line-height: 0; + height: 100%; + border-style: solid; + border-width: 1px; + border-radius: $fallback--attachmentRadius; + border-radius: var(--attachmentRadius, $fallback--attachmentRadius); + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + + .attachment-wrapper { + flex: 1 1 auto; + height: 100%; + position: relative; + overflow: hidden; + } + + .description-container { + flex: 0 1 0; + display: flex; + padding-top: 0.5em; + z-index: 1; + + p { + flex: 1; + text-align: center; + line-height: 1.5; + padding: 0.5em; + margin: 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + &.-static { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding-top: 0; + background: var(--popover); + box-shadow: var(--popupShadow); + } + } + + .description-field { + flex: 1; + min-width: 0; + } + + & .placeholder-container, + & .image-container, + & .audio-container, + & .video-container, + & .flash-container, + & .oembed-container { + display: flex; + justify-content: center; + width: 100%; + height: 100%; + } + + .image-container { + .image { + width: 100%; + height: 100%; + } + } + + & .flash-container, + & .video-container { + & .flash, + & video { + width: 100%; + height: 100%; + object-fit: contain; + align-self: center; + } + } + + .audio-container { + display: flex; + align-items: flex-end; + + audio { + width: 100%; + height: 100%; + } + } + + .placeholder-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 0.5em; + } + + + .play-icon { + position: absolute; + font-size: 64px; + top: calc(50% - 32px); + left: calc(50% - 32px); + color: rgba(255, 255, 255, 0.75); + text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); + + &::before { + margin: 0; + } + } + + .attachment-buttons { + display: flex; + position: absolute; + right: 0; + top: 0; + margin-top: 0.5em; + margin-right: 0.5em; + z-index: 1; + + .attachment-button { + padding: 0; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + text-align: center; + width: 2em; + height: 2em; + margin-left: 0.5em; + font-size: 1.25em; + // TODO: theming? hard to theme with unknown background image color + background: rgba(230, 230, 230, 0.7); + + .svg-inline--fa { + color: rgba(0, 0, 0, 0.6); + } + + &:hover .svg-inline--fa { + color: rgba(0, 0, 0, 0.9); + } + } + } + + .oembed-container { + line-height: 1.2em; + flex: 1 0 100%; + width: 100%; + margin-right: 15px; + display: flex; + + img { + width: 100%; + } + + .image { + flex: 1; + img { + border: 0px; + border-radius: 5px; + height: 100%; + object-fit: cover; + } + } + + .text { + flex: 2; + margin: 8px; + word-break: break-all; + h1 { + font-size: 14px; + margin: 0px; + } + } + } + + &.-size-small { + .play-icon { + zoom: 0.5; + opacity: 0.7; + } + + .attachment-buttons { + zoom: 0.7; + opacity: 0.5; + } + } + + &.-editable { + padding: 0.5em; + + & .description-container, + & .attachment-buttons { + margin: 0; + } + } + + &.-placeholder { + display: inline-block; + color: $fallback--link; + color: var(--postLink, $fallback--link); + overflow: hidden; + white-space: nowrap; + height: auto; + line-height: 1.5; + + &:not(.-editable) { + border: none; + } + + &.-editable { + display: flex; + flex-direction: row; + align-items: baseline; + + & .description-container, + & .attachment-buttons { + margin: 0; + padding: 0; + position: relative; + } + + .description-container { + flex: 1; + padding-left: 0.5em; + } + + .attachment-buttons { + order: 99; + align-self: center; + } + } + + a { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + svg { + color: inherit; + } + } + + &.-loading { + cursor: progress; + } + + &.-contain-fit { + img, + canvas { + object-fit: contain; + } + } + + &.-cover-fit { + img, + canvas { + object-fit: cover; + } + } +} diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 2c1c1682..59173759 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -1,7 +1,8 @@ <template> - <div + <button v-if="usePlaceholder" - :class="{ 'fullwidth': fullwidth }" + class="Attachment -placeholder button-unstyled" + :class="classNames" @click="openModal" > <a @@ -13,310 +14,251 @@ :title="attachment.description" > <FAIcon :icon="placeholderIconClass" /> - <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }} + <b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }} </a> - </div> - <div - v-else - v-show="!isEmpty" - class="attachment" - :class="{[type]: true, loading, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" - > - <a - v-if="hidden" - class="image-attachment" - :href="attachment.url" - :alt="attachment.description" - :title="attachment.description" - @click.prevent.stop="toggleHidden" + <div + v-if="edit || remove" + class="attachment-buttons" > - <img - :key="nsfwImage" - class="nsfw" - :src="nsfwImage" - :class="{'small': isSmall}" + <button + v-if="remove" + class="button-unstyled attachment-button" + @click.prevent="onRemove" > - <FAIcon - v-if="type === 'video'" - class="play-icon" - icon="play-circle" - /> - </a> - <button - v-if="nsfw && hideNsfwLocal && !hidden" - class="button-unstyled hider" - @click.prevent="toggleHidden" + <FAIcon icon="trash-alt" /> + </button> + </div> + <div + v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)" + class="description-container" + :class="{ '-static': !edit }" > - <FAIcon icon="times" /> - </button> - - <a - v-if="type === 'image' && (!hidden || preloadImage)" - class="image-attachment" - :class="{'hidden': hidden && preloadImage }" - :href="attachment.url" - target="_blank" - @click="openModal" + <input + v-if="edit" + v-model="localDescription" + type="text" + class="description-field" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" + > + <p v-else> + {{ localDescription }} + </p> + </div> + </button> + <div + v-else + class="Attachment" + :class="classNames" + > + <div + v-show="!isEmpty" + class="attachment-wrapper" > - <StillImage - class="image" - :referrerpolicy="referrerpolicy" - :mimetype="attachment.mimetype" - :src="attachment.large_thumb_url || attachment.url" - :image-load-handler="onImageLoad" + <a + v-if="hidden" + class="image-container" + :href="attachment.url" :alt="attachment.description" - /> - </a> + :title="attachment.description" + @click.prevent.stop="toggleHidden" + > + <img + :key="nsfwImage" + class="nsfw" + :src="nsfwImage" + > + <FAIcon + v-if="type === 'video'" + class="play-icon" + icon="play-circle" + /> + </a> + <div + v-if="!hidden" + class="attachment-buttons" + > + <button + v-if="type === 'flash' && flashLoaded" + class="button-unstyled attachment-button" + @click.prevent="stopFlash" + :title="$t('status.attachment_stop_flash')" + > + <FAIcon icon="stop" /> + </button> + <button + v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'" + class="button-unstyled attachment-button" + @click.prevent="toggleDescription" + :title="$t('status.show_attachment_description')" + > + <FAIcon icon="align-right" /> + </button> + <button + v-if="!useModal && type !== 'unknown'" + class="button-unstyled attachment-button" + @click.prevent="openModalForce" + :title="$t('status.show_attachment_in_modal')" + > + <FAIcon icon="search-plus" /> + </button> + <button + v-if="nsfw && hideNsfwLocal" + class="button-unstyled attachment-button" + @click.prevent="toggleHidden" + :title="$t('status.hide_attachment')" + > + <FAIcon icon="times" /> + </button> + <button + v-if="shiftUp" + class="button-unstyled attachment-button" + @click.prevent="onShiftUp" + :title="$t('status.move_up')" + > + <FAIcon icon="chevron-left" /> + </button> + <button + v-if="shiftDn" + class="button-unstyled attachment-button" + @click.prevent="onShiftDn" + :title="$t('status.move_down')" + > + <FAIcon icon="chevron-right" /> + </button> + <button + v-if="remove" + class="button-unstyled attachment-button" + @click.prevent="onRemove" + :title="$t('status.remove_attachment')" + > + <FAIcon icon="trash-alt" /> + </button> + </div> - <a - v-if="type === 'video' && !hidden" - class="video-container" - :class="{'small': isSmall}" - :href="allowPlay ? undefined : attachment.url" - @click="openModal" - > - <VideoAttachment - class="video" - :attachment="attachment" - :controls="allowPlay" - @play="$emit('play')" - @pause="$emit('pause')" - /> - <FAIcon - v-if="!allowPlay" - class="play-icon" - icon="play-circle" - /> - </a> + <a + v-if="type === 'image' && (!hidden || preloadImage)" + class="image-container" + :class="{'-hidden': hidden && preloadImage }" + :href="attachment.url" + target="_blank" + @click.stop.prevent="openModal" + > + <StillImage + class="image" + :referrerpolicy="referrerpolicy" + :mimetype="attachment.mimetype" + :src="attachment.large_thumb_url || attachment.url" + :image-load-handler="onImageLoad" + :alt="attachment.description" + /> + </a> + + <a + v-if="type === 'unknown' && !hidden" + class="placeholder-container" + :href="attachment.url" + target="_blank" + > + <FAIcon size="5x" :icon="placeholderIconClass" /> + <p> + {{ localDescription }} + </p> + </a> + + <component + :is="videoTag" + v-if="type === 'video' && !hidden" + class="video-container" + :class="{ 'button-unstyled': 'isModal' }" + :href="attachment.url" + @click.stop.prevent="openModal" + > + <VideoAttachment + class="video" + :attachment="attachment" + :controls="!useModal" + @play="$emit('play')" + @pause="$emit('pause')" + /> + <FAIcon + v-if="useModal" + class="play-icon" + icon="play-circle" + /> + </component> + + <span + v-if="type === 'audio' && !hidden" + class="audio-container" + :href="attachment.url" + @click.stop.prevent="openModal" + > + <audio + v-if="type === 'audio'" + :src="attachment.url" + :alt="attachment.description" + :title="attachment.description" + controls + @play="$emit('play')" + @pause="$emit('pause')" + /> + </span> - <audio - v-if="type === 'audio'" - :src="attachment.url" - :alt="attachment.description" - :title="attachment.description" - controls - @play="$emit('play')" - @pause="$emit('pause')" - /> + <div + v-if="type === 'html' && attachment.oembed" + class="oembed-container" + @click.prevent="linkClicked" + > + <div + v-if="attachment.thumb_url" + class="image" + > + <img :src="attachment.thumb_url"> + </div> + <div class="text"> + <!-- eslint-disable vue/no-v-html --> + <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> + <div v-html="attachment.oembed.oembedHTML" /> + <!-- eslint-enable vue/no-v-html --> + </div> + </div> + <span + v-if="type === 'flash' && !hidden" + class="flash-container" + :href="attachment.url" + @click.stop.prevent="openModal" + > + <Flash + ref="flash" + class="flash" + :src="attachment.large_thumb_url || attachment.url" + @playerOpened="setFlashLoaded(true)" + @playerClosed="setFlashLoaded(false)" + /> + </span> + </div> <div - v-if="type === 'html' && attachment.oembed" - class="oembed" - @click.prevent="linkClicked" + v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))" + class="description-container" + :class="{ '-static': !edit }" > - <div - v-if="attachment.thumb_url" - class="image" + <input + v-if="edit" + v-model="localDescription" + type="text" + class="description-field" + :placeholder="$t('post_status.media_description')" + @keydown.enter.prevent="" > - <img :src="attachment.thumb_url"> - </div> - <div class="text"> - <!-- eslint-disable vue/no-v-html --> - <h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1> - <div v-html="attachment.oembed.oembedHTML" /> - <!-- eslint-enable vue/no-v-html --> - </div> + <p v-else> + {{ localDescription }} + </p> </div> </div> </template> <script src="./attachment.js"></script> -<style lang="scss"> -@import '../../_variables.scss'; - -.attachments { - display: flex; - flex-wrap: wrap; - - .non-gallery { - max-width: 100%; - } - - .placeholder { - display: inline-block; - padding: 0.3em 1em 0.3em 0; - color: $fallback--link; - color: var(--postLink, $fallback--link); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 100%; - - svg { - color: inherit; - } - } - - .nsfw-placeholder { - cursor: pointer; - - &.loading { - cursor: progress; - } - } - - .attachment { - position: relative; - margin-top: 0.5em; - align-self: flex-start; - line-height: 0; - - border-style: solid; - border-width: 1px; - border-radius: $fallback--attachmentRadius; - border-radius: var(--attachmentRadius, $fallback--attachmentRadius); - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - overflow: hidden; - } - - .non-gallery.attachment { - &.video { - flex: 1 0 40%; - } - .nsfw { - height: 260px; - } - .small { - height: 120px; - flex-grow: 0; - } - .video { - height: 260px; - display: flex; - } - video { - max-height: 100%; - object-fit: contain; - } - } - - .fullwidth { - flex-basis: 100%; - } - // fixes small gap below video - &.video { - line-height: 0; - } - - .video-container { - display: flex; - max-height: 100%; - } - - .video { - width: 100%; - height: 100%; - } - - .play-icon { - position: absolute; - font-size: 64px; - top: calc(50% - 32px); - left: calc(50% - 32px); - color: rgba(255, 255, 255, 0.75); - text-shadow: 0 0 2px rgba(0, 0, 0, 0.4); - } - - .play-icon::before { - margin: 0; - } - - &.html { - flex-basis: 90%; - width: 100%; - display: flex; - } - - .hider { - position: absolute; - right: 0; - margin: 10px; - padding: 0; - z-index: 4; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - text-align: center; - width: 2em; - height: 2em; - font-size: 1.25em; - // TODO: theming? hard to theme with unknown background image color - background: rgba(230, 230, 230, 0.7); - .svg-inline--fa { - color: rgba(0, 0, 0, 0.6); - } - &:hover .svg-inline--fa { - color: rgba(0, 0, 0, 0.9); - } - } - - video { - z-index: 0; - } - - audio { - width: 100%; - } - - img.media-upload { - line-height: 0; - max-height: 200px; - max-width: 100%; - } - - .oembed { - line-height: 1.2em; - flex: 1 0 100%; - width: 100%; - margin-right: 15px; - display: flex; - - img { - width: 100%; - } - - .image { - flex: 1; - img { - border: 0px; - border-radius: 5px; - height: 100%; - object-fit: cover; - } - } - - .text { - flex: 2; - margin: 8px; - word-break: break-all; - h1 { - font-size: 14px; - margin: 0px; - } - } - } - - .image-attachment { - &, - & .image { - width: 100%; - height: 100%; - } - - &.hidden { - display: none; - } - - .nsfw { - object-fit: cover; - width: 100%; - height: 100%; - } - - img { - image-orientation: from-image; // NOTE: only FF supports this - } - } -} -</style> +<style src="./attachment.scss" lang="scss"></style> diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js index 87085a28..8f41e2fb 100644 --- a/src/components/basic_user_card/basic_user_card.js +++ b/src/components/basic_user_card/basic_user_card.js @@ -1,5 +1,6 @@ import UserCard from '../user_card/user_card.vue' import UserAvatar from '../user_avatar/user_avatar.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' const BasicUserCard = { @@ -13,7 +14,8 @@ const BasicUserCard = { }, components: { UserCard, - UserAvatar + UserAvatar, + RichContent }, methods: { toggleUserExpanded () { diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue index 9e410610..53deb1df 100644 --- a/src/components/basic_user_card/basic_user_card.vue +++ b/src/components/basic_user_card/basic_user_card.vue @@ -25,24 +25,18 @@ :title="user.name" class="basic-user-card-user-name" > - <!-- eslint-disable vue/no-v-html --> - <span - v-if="user.name_html" + <RichContent class="basic-user-card-user-name-value" - v-html="user.name_html" + :html="user.name" + :emoji="user.emoji" /> - <!-- eslint-enable vue/no-v-html --> - <span - v-else - class="basic-user-card-user-name-value" - >{{ user.name }}</span> </div> <div> <router-link class="basic-user-card-screen-name" :to="userProfileLink(user)" > - @{{ user.screen_name }} + @{{ user.screen_name_ui }} </router-link> </div> <slot /> diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index e57fcb91..b54f5fb2 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -73,7 +73,7 @@ const Chat = { }, formPlaceholder () { if (this.recipient) { - return this.$t('chats.message_user', { nickname: this.recipient.screen_name }) + return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui }) } else { return '' } @@ -234,6 +234,13 @@ const Chat = { const scrollable = this.$refs.scrollable return scrollable && scrollable.scrollTop <= 0 }, + cullOlderCheck () { + window.setTimeout(() => { + if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { + this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId) + } + }, 5000) + }, handleScroll: _.throttle(function () { if (!this.currentChat) { return } @@ -241,6 +248,7 @@ const Chat = { this.fetchChat({ maxId: this.currentChatMessageService.minId }) } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { this.jumpToBottomButtonVisible = false + this.cullOlderCheck() if (this.newMessageCount > 0) { // Use a delay before marking as read to prevent situation where new messages // arrive just as you're leaving the view and messages that you didn't actually diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index aef58495..3a26686c 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -98,10 +98,10 @@ .unread-message-count { font-size: 0.8em; left: 50%; - transform: translate(-50%, 0); - border-radius: 100%; margin-top: -1rem; - padding: 0; + padding: 0.1em; + border-radius: 50px; + position: absolute; } .chat-loading-error { diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue index e23eec13..f98b7ed2 100644 --- a/src/components/chat_list/chat_list.vue +++ b/src/components/chat_list/chat_list.vue @@ -23,10 +23,7 @@ class="timeline" > <List :items="sortedChatList"> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <ChatListItem :key="item.id" :compact="false" diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js index bee1ad53..e5032176 100644 --- a/src/components/chat_list_item/chat_list_item.js +++ b/src/components/chat_list_item/chat_list_item.js @@ -1,5 +1,5 @@ import { mapState } from 'vuex' -import StatusContent from '../status_content/status_content.vue' +import StatusBody from '../status_content/status_content.vue' import fileType from 'src/services/file_type/file_type.service' import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' @@ -16,7 +16,7 @@ const ChatListItem = { AvatarList, Timeago, ChatTitle, - StatusContent + StatusBody }, computed: { ...mapState({ @@ -38,12 +38,14 @@ const ChatListItem = { }, messageForStatusContent () { const message = this.chat.lastMessage + const messageEmojis = message ? message.emojis : [] const isYou = message && message.account_id === this.currentUser.id const content = message ? (this.attachmentInfo || message.content) : '' const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content return { summary: '', - statusnet_html: messagePreview, + emojis: messageEmojis, + raw_html: messagePreview, text: messagePreview, attachments: [] } diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 9e97b28e..57332bed 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -77,18 +77,15 @@ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); } - .StatusContent { - img.emoji { - width: 1.4em; - height: 1.4em; - } + .chat-preview-body { + --emoji-size: 1.4em; } .time-wrapper { line-height: 1.4em; } - .single-line { + .chat-preview-body { padding-right: 1em; } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index cd3f436e..c7c0e878 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -29,7 +29,8 @@ </div> </div> <div class="chat-preview"> - <StatusContent + <StatusBody + class="chat-preview-body" :status="messageForStatusContent" :single-line="true" /> diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index bb380f87..eb195bc1 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -57,8 +57,9 @@ const ChatMessage = { messageForStatusContent () { return { summary: '', - statusnet_html: this.message.content, - text: this.message.content, + emojis: this.message.emojis, + raw_html: this.message.content || '', + text: this.message.content || '', attachments: this.message.attachments } }, diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss index e4351d3b..1913479f 100644 --- a/src/components/chat_message/chat_message.scss +++ b/src/components/chat_message/chat_message.scss @@ -1,6 +1,7 @@ @import '../../_variables.scss'; .chat-message-wrapper { + &.hovered-message-chain { .animated.Avatar { canvas { @@ -40,6 +41,12 @@ .chat-message { display: flex; padding-bottom: 0.5em; + + .status-body:hover { + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; + } } .avatar-wrapper { @@ -62,10 +69,6 @@ &.with-media { width: 100%; - .gallery-row { - overflow: hidden; - } - .status { width: 100%; } @@ -89,8 +92,9 @@ } .without-attachment { - .status-content { - &::after { + .message-content { + // TODO figure out how to do it properly + .RichContent::after { margin-right: 5.4em; content: " "; display: inline-block; @@ -162,6 +166,7 @@ .visible { opacity: 1; } + } .chat-message-date-separator { diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue index 0777f880..d62b831d 100644 --- a/src/components/chat_message/chat_message.vue +++ b/src/components/chat_message/chat_message.vue @@ -50,7 +50,7 @@ @show="menuOpened = true" @close="menuOpened = false" > - <div slot="content"> + <template v-slot:content> <div class="dropdown-menu"> <button class="button-default dropdown-item dropdown-item-icon" @@ -59,26 +59,29 @@ <FAIcon icon="times" /> {{ $t("chats.delete") }} </button> </div> - </div> - <button - slot="trigger" - class="button-default menu-icon" - :title="$t('chats.more')" - > - <FAIcon icon="ellipsis-h" /> - </button> + </template> + <template v-slot:trigger> + <button + class="button-default menu-icon" + :title="$t('chats.more')" + > + <FAIcon icon="ellipsis-h" /> + </button> + </template> </Popover> </div> <StatusContent + class="message-content" :status="messageForStatusContent" :full-content="true" > - <span - slot="footer" - class="created-at" - > - {{ createdAt }} - </span> + <template v-slot:footer> + <span + class="created-at" + > + {{ createdAt }} + </span> + </template> </StatusContent> </div> </div> diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue index 79c346b6..98349b75 100644 --- a/src/components/chat_message_date/chat_message_date.vue +++ b/src/components/chat_message_date/chat_message_date.vue @@ -5,6 +5,8 @@ </template> <script> +import localeService from 'src/services/locale/locale.service.js' + export default { name: 'Timeago', props: ['date'], @@ -16,7 +18,7 @@ export default { if (this.date.getTime() === today.getTime()) { return this.$t('display_date.today') } else { - return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' }) + return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' }) } } } diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js index e424bb1f..edfbe7a4 100644 --- a/src/components/chat_title/chat_title.js +++ b/src/components/chat_title/chat_title.js @@ -12,7 +12,7 @@ export default Vue.component('chat-title', { ], computed: { title () { - return this.user ? this.user.screen_name : '' + return this.user ? this.user.screen_name_ui : '' }, htmlTitle () { return this.user ? this.user.name_html : '' diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue index b16ed39d..a92028e8 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -1,5 +1,4 @@ <template> - <!-- eslint-disable vue/no-v-html --> <div class="chat-title" :title="title" @@ -14,12 +13,13 @@ height="23px" /> </router-link> - <span + <RichContent class="username" - v-html="htmlTitle" + :title="'@'+user.screen_name_ui" + :html="htmlTitle" + :emoji="user.emoji" /> </div> - <!-- eslint-enable vue/no-v-html --> </template> <script src="./chat_title.js"></script> @@ -34,6 +34,8 @@ white-space: nowrap; align-items: center; + --emoji-size: 14px; + .username { max-width: 100%; text-overflow: ellipsis; @@ -41,14 +43,6 @@ display: inline; word-wrap: break-word; overflow: hidden; - text-overflow: ellipsis; - - .emoji { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } } .Avatar { diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 353859b8..3fb26d92 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -50,7 +50,6 @@ .Conversation { .conversation-status { - border-left: none; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: var(--border, $fallback--border); diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index 762aa610..304baf9d 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -52,6 +52,7 @@ href="/pleroma/admin/#/login-pleroma" class="nav-icon" target="_blank" + @click.stop > <FAIcon fixed-width diff --git a/src/components/domain_mute_card/domain_mute_card.vue b/src/components/domain_mute_card/domain_mute_card.vue index 3b5aec14..836688aa 100644 --- a/src/components/domain_mute_card/domain_mute_card.vue +++ b/src/components/domain_mute_card/domain_mute_card.vue @@ -9,7 +9,7 @@ class="btn button-default" > {{ $t('domain_mute_card.unmute') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('domain_mute_card.unmute_progress') }} </template> </ProgressButton> @@ -19,7 +19,7 @@ class="btn button-default" > {{ $t('domain_mute_card.mute') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('domain_mute_card.mute_progress') }} </template> </ProgressButton> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 2068a598..902ec384 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -57,6 +57,7 @@ const EmojiInput = { required: true, type: Function }, + // TODO VUE3: change to modelValue, change 'input' event to 'input' value: { /** * Used for v-model @@ -143,32 +144,31 @@ const EmojiInput = { } }, mounted () { - const slots = this.$slots.default - if (!slots || slots.length === 0) return - const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag)) + const { root } = this.$refs + const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') if (!input) return this.input = input this.resize() - input.elm.addEventListener('blur', this.onBlur) - input.elm.addEventListener('focus', this.onFocus) - input.elm.addEventListener('paste', this.onPaste) - input.elm.addEventListener('keyup', this.onKeyUp) - input.elm.addEventListener('keydown', this.onKeyDown) - input.elm.addEventListener('click', this.onClickInput) - input.elm.addEventListener('transitionend', this.onTransition) - input.elm.addEventListener('input', this.onInput) + input.addEventListener('blur', this.onBlur) + input.addEventListener('focus', this.onFocus) + input.addEventListener('paste', this.onPaste) + input.addEventListener('keyup', this.onKeyUp) + input.addEventListener('keydown', this.onKeyDown) + input.addEventListener('click', this.onClickInput) + input.addEventListener('transitionend', this.onTransition) + input.addEventListener('input', this.onInput) }, unmounted () { const { input } = this if (input) { - input.elm.removeEventListener('blur', this.onBlur) - input.elm.removeEventListener('focus', this.onFocus) - input.elm.removeEventListener('paste', this.onPaste) - input.elm.removeEventListener('keyup', this.onKeyUp) - input.elm.removeEventListener('keydown', this.onKeyDown) - input.elm.removeEventListener('click', this.onClickInput) - input.elm.removeEventListener('transitionend', this.onTransition) - input.elm.removeEventListener('input', this.onInput) + input.removeEventListener('blur', this.onBlur) + input.removeEventListener('focus', this.onFocus) + input.removeEventListener('paste', this.onPaste) + input.removeEventListener('keyup', this.onKeyUp) + input.removeEventListener('keydown', this.onKeyDown) + input.removeEventListener('click', this.onClickInput) + input.removeEventListener('transitionend', this.onTransition) + input.removeEventListener('input', this.onInput) } }, watch: { @@ -194,11 +194,18 @@ const EmojiInput = { } }, methods: { + focusPickerInput () { + const pickerEl = this.$refs.picker.$el + if (!pickerEl) return + const pickerInput = pickerEl.querySelector('input') + if (pickerInput) pickerInput.focus() + }, triggerShowPicker () { this.showPicker = true this.$refs.picker.startEmojiLoad() this.$nextTick(() => { this.scrollIntoView() + this.focusPickerInput() }) // This temporarily disables "click outside" handler // since external trigger also means click originates @@ -209,11 +216,12 @@ const EmojiInput = { }, 0) }, togglePicker () { - this.input.elm.focus() + this.input.focus() this.showPicker = !this.showPicker if (this.showPicker) { this.scrollIntoView() this.$refs.picker.startEmojiLoad() + this.$nextTick(this.focusPickerInput) } }, replace (replacement) { @@ -254,13 +262,13 @@ const EmojiInput = { this.$emit('input', newValue) const position = this.caret + (insertion + spaceAfter + spaceBefore).length if (!keepOpen) { - this.input.elm.focus() + this.input.focus() } this.$nextTick(function () { // Re-focus inputbox after clicking suggestion // Set selection right after the replacement instead of the very end - this.input.elm.setSelectionRange(position, position) + this.input.setSelectionRange(position, position) this.caret = position }) }, @@ -277,9 +285,9 @@ const EmojiInput = { this.$nextTick(function () { // Re-focus inputbox after clicking suggestion - this.input.elm.focus() + this.input.focus() // Set selection right after the replacement instead of the very end - this.input.elm.setSelectionRange(position, position) + this.input.setSelectionRange(position, position) this.caret = position }) e.preventDefault() @@ -341,7 +349,7 @@ const EmojiInput = { } this.$nextTick(() => { - const { offsetHeight } = this.input.elm + const { offsetHeight } = this.input const { picker } = this.$refs const pickerBottom = picker.$el.getBoundingClientRect().bottom if (pickerBottom > window.innerHeight) { @@ -406,8 +414,8 @@ const EmojiInput = { // Scroll the input element to the position of the cursor this.$nextTick(() => { - this.input.elm.blur() - this.input.elm.focus() + this.input.blur() + this.input.focus() }) } // Disable suggestions hotkeys if suggestions are hidden @@ -436,7 +444,7 @@ const EmojiInput = { // de-focuses the element (i.e. default browser behavior) if (key === 'Escape') { if (!this.temporarilyHideSuggestions) { - this.input.elm.focus() + this.input.focus() } } @@ -472,7 +480,7 @@ const EmojiInput = { if (!panel) return const picker = this.$refs.picker.$el const panelBody = this.$refs['panel-body'] - const { offsetHeight, offsetTop } = this.input.elm + const { offsetHeight, offsetTop } = this.input const offsetBottom = offsetTop + offsetHeight this.setPlacement(panelBody, panel, offsetBottom) @@ -486,7 +494,7 @@ const EmojiInput = { if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) { target.style.top = 'auto' - target.style.bottom = this.input.elm.offsetHeight + 'px' + target.style.bottom = this.input.offsetHeight + 'px' } }, overflowsBottom (el) { diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index 4becdc41..aa2950ce 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -1,5 +1,6 @@ <template> <div + ref="root" v-click-outside="onClickOutside" class="emoji-input" :class="{ 'with-picker': !hideEmojiButton }" @@ -9,6 +10,7 @@ <button v-if="!hideEmojiButton" class="button-unstyled emoji-picker-icon" + type="button" @click.prevent="togglePicker" > <FAIcon :icon="['far', 'smile-beam']" /> diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js index 14a2b41e..e8efbd1e 100644 --- a/src/components/emoji_input/suggestor.js +++ b/src/components/emoji_input/suggestor.js @@ -116,8 +116,8 @@ export const suggestUsers = ({ dispatch, state }) => { return diff + nameAlphabetically + screenNameAlphabetically /* eslint-disable camelcase */ - }).map(({ screen_name, name, profile_image_url_original }) => ({ - displayText: screen_name, + }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ + displayText: screen_name_ui, detailText: name, imageUrl: profile_image_url_original, replacement: '@' + screen_name + ' ' diff --git a/src/components/export_import/export_import.vue b/src/components/export_import/export_import.vue deleted file mode 100644 index 8ffe34f8..00000000 --- a/src/components/export_import/export_import.vue +++ /dev/null @@ -1,102 +0,0 @@ -<template> - <div class="import-export-container"> - <slot name="before" /> - <button - class="btn button-default" - @click="exportData" - > - {{ exportLabel }} - </button> - <button - class="btn button-default" - @click="importData" - > - {{ importLabel }} - </button> - <slot name="afterButtons" /> - <p - v-if="importFailed" - class="alert error" - > - {{ importFailedText }} - </p> - <slot name="afterError" /> - </div> -</template> - -<script> -export default { - props: [ - 'exportObject', - 'importLabel', - 'exportLabel', - 'importFailedText', - 'validator', - 'onImport', - 'onImportFailure' - ], - data () { - return { - importFailed: false - } - }, - methods: { - exportData () { - const stringified = JSON.stringify(this.exportObject, null, 2) // Pretty-print and indent with 2 spaces - - // Create an invisible link with a data url and simulate a click - const e = document.createElement('a') - e.setAttribute('download', 'pleroma_theme.json') - e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified)) - e.style.display = 'none' - - document.body.appendChild(e) - e.click() - document.body.removeChild(e) - }, - importData () { - this.importFailed = false - const filePicker = document.createElement('input') - filePicker.setAttribute('type', 'file') - filePicker.setAttribute('accept', '.json') - - filePicker.addEventListener('change', event => { - if (event.target.files[0]) { - // eslint-disable-next-line no-undef - const reader = new FileReader() - reader.onload = ({ target }) => { - try { - const parsed = JSON.parse(target.result) - const valid = this.validator(parsed) - if (valid) { - this.onImport(parsed) - } else { - this.importFailed = true - // this.onImportFailure(valid) - } - } catch (e) { - // This will happen both if there is a JSON syntax error or the theme is missing components - this.importFailed = true - // this.onImportFailure(e) - } - } - reader.readAsText(event.target.files[0]) - } - }) - - document.body.appendChild(filePicker) - filePicker.click() - document.body.removeChild(filePicker) - } - } -} -</script> - -<style lang="scss"> -.import-export-container { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: center; -} -</style> diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue index e845d8fc..a3c3c767 100644 --- a/src/components/extra_buttons/extra_buttons.vue +++ b/src/components/extra_buttons/extra_buttons.vue @@ -7,10 +7,7 @@ :bound-to="{ x: 'container' }" remove-padding > - <div - slot="content" - slot-scope="{close}" - > + <template v-slot:content="{close}"> <div class="dropdown-menu"> <button v-if="canMute && !status.thread_muted" @@ -120,16 +117,15 @@ /><span>{{ $t("user_card.report") }}</span> </button> </div> - </div> - <span - slot="trigger" - class="popover-trigger" - > - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="ellipsis-h" - /> - </span> + </template> + <template v-slot:trigger> + <button class="button-unstyled popover-trigger"> + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="ellipsis-h" + /> + </button> + </template> </Popover> </template> @@ -139,6 +135,11 @@ @import '../../_variables.scss'; .ExtraButtons { + /* override of popover internal stuff */ + .popover-trigger-button { + width: auto; + } + .popover-trigger { position: static; padding: 10px; diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js index 8b142d08..d177efeb 100644 --- a/src/components/features_panel/features_panel.js +++ b/src/components/features_panel/features_panel.js @@ -2,7 +2,7 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for const FeaturesPanel = { computed: { - chat: function () { return this.$store.state.instance.chatAvailable }, + shout: function () { return this.$store.state.instance.shoutAvailable }, pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue index 9605d09d..a58a99af 100644 --- a/src/components/features_panel/features_panel.vue +++ b/src/components/features_panel/features_panel.vue @@ -8,8 +8,8 @@ </div> <div class="panel-body features-panel"> <ul> - <li v-if="chat"> - {{ $t('features_panel.chat') }} + <li v-if="shout"> + {{ $t('features_panel.shout') }} </li> <li v-if="pleromaChatMessages"> {{ $t('features_panel.pleroma_chat_messages') }} diff --git a/src/components/flash/flash.js b/src/components/flash/flash.js new file mode 100644 index 00000000..87f940a7 --- /dev/null +++ b/src/components/flash/flash.js @@ -0,0 +1,53 @@ +import RuffleService from '../../services/ruffle_service/ruffle_service.js' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faStop, + faExclamationTriangle +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faStop, + faExclamationTriangle +) + +const Flash = { + props: [ 'src' ], + data () { + return { + player: false, // can be true, "hidden", false. hidden = element exists + loaded: false, + ruffleInstance: null + } + }, + methods: { + openPlayer () { + if (this.player) return // prevent double-loading, or re-loading on failure + this.player = 'hidden' + RuffleService.getRuffle().then((ruffle) => { + const player = ruffle.newest().createPlayer() + player.config = { + letterbox: 'on' + } + const container = this.$refs.container + container.appendChild(player) + player.style.width = '100%' + player.style.height = '100%' + player.load(this.src).then(() => { + this.player = true + }).catch((e) => { + console.error('Error loading ruffle', e) + this.player = 'error' + }) + this.ruffleInstance = player + this.$emit('playerOpened') + }) + }, + closePlayer () { + this.ruffleInstance && this.ruffleInstance.remove() + this.player = false + this.$emit('playerClosed') + } + } +} + +export default Flash diff --git a/src/components/flash/flash.vue b/src/components/flash/flash.vue new file mode 100644 index 00000000..95f71950 --- /dev/null +++ b/src/components/flash/flash.vue @@ -0,0 +1,84 @@ +<template> + <div class="Flash"> + <div + v-if="player === true || player === 'hidden'" + ref="container" + class="player" + :class="{ hidden: player === 'hidden' }" + /> + <button + v-if="player !== true" + class="button-unstyled placeholder" + @click="openPlayer" + > + <span + v-if="player === 'hidden'" + class="label" + > + {{ $t('general.loading') }} + </span> + <span + v-if="player === 'error'" + class="label" + > + {{ $t('general.flash_fail') }} + </span> + <span + v-else + class="label" + > + <p> + {{ $t('general.flash_content') }} + </p> + <p> + <FAIcon icon="exclamation-triangle" /> + {{ $t('general.flash_security') }} + </p> + </span> + </button> + </div> +</template> + +<script src="./flash.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; +.Flash { + display: inline-block; + width: 100%; + height: 100%; + position: relative; + + .player { + height: 100%; + width: 100%; + } + + .placeholder { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + color: var(--link); + } + + .hider { + top: 0; + } + + .label { + text-align: center; + flex: 1 1 0; + line-height: 1.2; + white-space: normal; + word-wrap: normal; + } + + .hidden { + display: none; + visibility: 'hidden'; + } +} +</style> diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js index 95e7cb6b..3edbcb86 100644 --- a/src/components/follow_button/follow_button.js +++ b/src/components/follow_button/follow_button.js @@ -1,6 +1,6 @@ import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' export default { - props: ['relationship', 'labelFollowing', 'buttonClass'], + props: ['relationship', 'user', 'labelFollowing', 'buttonClass'], data () { return { inProgress: false @@ -14,7 +14,7 @@ export default { if (this.inProgress || this.relationship.following) { return this.$t('user_card.follow_unfollow') } else if (this.relationship.requested) { - return this.$t('user_card.follow_again') + return this.$t('user_card.follow_cancel') } else { return this.$t('user_card.follow') } @@ -29,11 +29,14 @@ export default { } else { return this.$t('user_card.follow') } + }, + disabled () { + return this.inProgress || this.user.deactivated } }, methods: { onClick () { - this.relationship.following ? this.unfollow() : this.follow() + this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow() }, follow () { this.inProgress = true diff --git a/src/components/follow_button/follow_button.vue b/src/components/follow_button/follow_button.vue index 7f85f1d7..965d5256 100644 --- a/src/components/follow_button/follow_button.vue +++ b/src/components/follow_button/follow_button.vue @@ -2,7 +2,7 @@ <button class="btn button-default follow-button" :class="{ toggled: isPressed }" - :disabled="inProgress" + :disabled="disabled" :title="title" @click="onClick" > diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue index b503783f..895a8fa3 100644 --- a/src/components/follow_card/follow_card.vue +++ b/src/components/follow_card/follow_card.vue @@ -20,6 +20,7 @@ :relationship="relationship" :label-following="$t('user_card.follow_unfollow')" class="follow-card-follow-button" + :user="user" /> </template> </div> diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js index 6274780b..137ef9c0 100644 --- a/src/components/font_control/font_control.js +++ b/src/components/font_control/font_control.js @@ -1,14 +1,10 @@ import { set } from 'vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faChevronDown -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faChevronDown -) +import Select from '../select/select.vue' export default { + components: { + Select + }, props: [ 'name', 'label', 'value', 'fallback', 'options', 'no-inherit' ], diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue index dd117ec0..29605084 100644 --- a/src/components/font_control/font_control.vue +++ b/src/components/font_control/font_control.vue @@ -22,30 +22,20 @@ class="opt-l" :for="name + '-o'" /> - <label - :for="name + '-font-switcher'" - class="select" + <Select + :id="name + '-font-switcher'" + v-model="preset" :disabled="!present" + class="font-switcher" > - <select - :id="name + '-font-switcher'" - v-model="preset" - :disabled="!present" - class="font-switcher" + <option + v-for="option in availableOptions" + :key="option" + :value="option" > - <option - v-for="option in availableOptions" - :key="option" - :value="option" - > - {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + {{ option === 'custom' ? $t('settings.style.fonts.custom') : option }} + </option> + </Select> <input v-if="isCustom" :id="name" @@ -65,7 +55,8 @@ min-width: 10em; } &.custom { - .select { + /* TODO Should make proper joiners... */ + .font-switcher { border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js index f856fd0a..094b3e57 100644 --- a/src/components/gallery/gallery.js +++ b/src/components/gallery/gallery.js @@ -1,15 +1,26 @@ import Attachment from '../attachment/attachment.vue' -import { chunk, last, dropRight, sumBy } from 'lodash' +import { sumBy } from 'lodash' const Gallery = { props: [ 'attachments', + 'limitRows', + 'descriptions', + 'limit', 'nsfw', - 'setMedia' + 'setMedia', + 'size', + 'editable', + 'removeAttachment', + 'shiftUpAttachment', + 'shiftDnAttachment', + 'editAttachment', + 'grid' ], data () { return { - sizes: {} + sizes: {}, + hidingLong: true } }, components: { Attachment }, @@ -18,26 +29,70 @@ const Gallery = { if (!this.attachments) { return [] } - const rows = chunk(this.attachments, 3) - if (last(rows).length === 1 && rows.length > 1) { - // if 1 attachment on last row -> add it to the previous row instead - const lastAttachment = last(rows)[0] - const allButLastRow = dropRight(rows) - last(allButLastRow).push(lastAttachment) - return allButLastRow + const attachments = this.limit > 0 + ? this.attachments.slice(0, this.limit) + : this.attachments + if (this.size === 'hide') { + return attachments.map(item => ({ minimal: true, items: [item] })) } + const rows = this.grid + ? [{ grid: true, items: attachments }] + : attachments.reduce((acc, attachment, i) => { + if (attachment.mimetype.includes('audio')) { + return [...acc, { audio: true, items: [attachment] }, { items: [] }] + } + if (!( + attachment.mimetype.includes('image') || + attachment.mimetype.includes('video') || + attachment.mimetype.includes('flash') + )) { + return [...acc, { minimal: true, items: [attachment] }, { items: [] }] + } + const maxPerRow = 3 + const attachmentsRemaining = this.attachments.length - i + 1 + const currentRow = acc[acc.length - 1].items + currentRow.push(attachment) + if (currentRow.length >= maxPerRow && attachmentsRemaining > maxPerRow) { + return [...acc, { items: [] }] + } else { + return acc + } + }, [{ items: [] }]).filter(_ => _.items.length > 0) return rows }, - useContainFit () { - return this.$store.getters.mergedConfig.useContainFit + attachmentsDimensionalScore () { + return this.rows.reduce((acc, row) => { + let size = 0 + if (row.minimal) { + size += 1 / 8 + } else if (row.audio) { + size += 1 / 4 + } else { + size += 1 / (row.items.length + 0.6) + } + return acc + size + }, 0) + }, + tooManyAttachments () { + if (this.editable || this.size === 'small') { + return false + } else if (this.size === 'hide') { + return this.attachments.length > 8 + } else { + return this.attachmentsDimensionalScore > 1 + } } }, methods: { - onNaturalSizeLoad (id, size) { - this.$set(this.sizes, id, size) + onNaturalSizeLoad ({ id, width, height }) { + this.$set(this.sizes, id, { width, height }) }, - rowStyle (itemsPerRow) { - return { 'padding-bottom': `${(100 / (itemsPerRow + 0.6))}%` } + rowStyle (row) { + if (row.audio) { + return { 'padding-bottom': '25%' } // fixed reduced height for audio + } else if (!row.minimal && !row.grid) { + return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` } + } }, itemStyle (id, row) { const total = sumBy(row, item => this.getAspectRatio(item.id)) @@ -46,6 +101,16 @@ const Gallery = { getAspectRatio (id) { const size = this.sizes[id] return size ? size.width / size.height : 1 + }, + toggleHidingLong (event) { + this.hidingLong = event + }, + openGallery () { + this.$store.dispatch('setMedia', this.attachments) + this.$store.dispatch('setCurrentMedia', this.attachments[0]) + }, + onMedia () { + this.$store.dispatch('setMedia', this.attachments) } } } diff --git a/src/components/gallery/gallery.vue b/src/components/gallery/gallery.vue index ca91c9c1..f9cad8a9 100644 --- a/src/components/gallery/gallery.vue +++ b/src/components/gallery/gallery.vue @@ -1,26 +1,84 @@ <template> <div ref="galleryContainer" - style="width: 100%;" + class="Gallery" + :class="{ '-long': tooManyAttachments && hidingLong }" > + <div class="gallery-rows"> + <div + v-for="(row, rowIndex) in rows" + :key="rowIndex" + class="gallery-row" + :style="rowStyle(row)" + :class="{ '-audio': row.audio, '-minimal': row.minimal, '-grid': grid }" + > + <div + class="gallery-row-inner" + :class="{ '-grid': grid }" + > + <Attachment + v-for="(attachment, attachmentIndex) in row.items" + :key="attachment.id" + class="gallery-item" + :nsfw="nsfw" + :attachment="attachment" + :allow-play="false" + :size="size" + :editable="editable" + :remove="removeAttachment" + :shiftUp="!(attachmentIndex === 0 && rowIndex === 0) && shiftUpAttachment" + :shiftDn="!(attachmentIndex === row.items.length - 1 && rowIndex === rows.length - 1) && shiftDnAttachment" + :edit="editAttachment" + :description="descriptions && descriptions[attachment.id]" + :hide-description="size === 'small' || tooManyAttachments && hidingLong" + :style="itemStyle(attachment.id, row.items)" + @setMedia="onMedia" + @naturalSizeLoad="onNaturalSizeLoad" + /> + </div> + </div> + </div> <div - v-for="(row, index) in rows" - :key="index" - class="gallery-row" - :style="rowStyle(row.length)" - :class="{ 'contain-fit': useContainFit, 'cover-fit': !useContainFit }" + v-if="tooManyAttachments" + class="many-attachments" > - <div class="gallery-row-inner"> - <attachment - v-for="attachment in row" - :key="attachment.id" - :set-media="setMedia" - :nsfw="nsfw" - :attachment="attachment" - :allow-play="false" - :natural-size-load="onNaturalSizeLoad.bind(null, attachment.id)" - :style="itemStyle(attachment.id, row)" - /> + <div class="many-attachments-text"> + {{ $t("status.many_attachments", { number: attachments.length }) }} + </div> + <div class="many-attachments-buttons"> + <span + v-if="!hidingLong" + class="many-attachments-button" + > + <button + class="button-unstyled -link" + @click="toggleHidingLong(true)" + > + {{ $t("status.collapse_attachments") }} + </button> + </span> + <span + v-if="hidingLong" + class="many-attachments-button" + > + <button + class="button-unstyled -link" + @click="toggleHidingLong(false)" + > + {{ $t("status.show_all_attachments") }} + </button> + </span> + <span + v-if="hidingLong" + class="many-attachments-button" + > + <button + class="button-unstyled -link" + @click="openGallery" + > + {{ $t("status.open_gallery") }} + </button> + </span> </div> </div> </div> @@ -31,12 +89,66 @@ <style lang="scss"> @import '../../_variables.scss'; -.gallery-row { - position: relative; - height: 0; - width: 100%; - flex-grow: 1; - margin-top: 0.5em; +.Gallery { + .gallery-rows { + display: flex; + flex-direction: column; + } + + .gallery-row { + position: relative; + height: 0; + width: 100%; + flex-grow: 1; + + &:not(:first-child) { + margin-top: 0.5em; + } + } + + &.-long { + .gallery-rows { + max-height: 25em; + overflow: hidden; + mask: + linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, + linear-gradient(to top, white, white); + + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + } + + .many-attachments-text { + text-align: center; + line-height: 2; + } + + .many-attachments-buttons { + display: flex; + } + + .many-attachments-button { + display: flex; + flex: 1; + justify-content: center; + line-height: 2; + + button { + padding: 0 2em; + } + } + + .gallery-row { + &.-grid, + &.-minimal { + height: auto; + .gallery-row-inner { + position: relative; + } + } + } .gallery-row-inner { position: absolute; @@ -48,9 +160,24 @@ flex-direction: row; flex-wrap: nowrap; align-content: stretch; + + &.-grid { + width: 100%; + height: auto; + position: relative; + display: grid; + grid-column-gap: 0.5em; + grid-row-gap: 0.5em; + grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); + + .gallery-item { + margin: 0; + height: 200px; + } + } } - .gallery-row-inner .attachment { + .gallery-item { margin: 0 0.5em 0 0; flex-grow: 1; height: 100%; @@ -61,32 +188,5 @@ margin: 0; } } - - .image-attachment { - width: 100%; - height: 100%; - } - - .video-container { - height: 100%; - } - - &.contain-fit { - img, - video, - canvas { - object-fit: contain; - height: 100%; - } - } - - &.cover-fit { - img, - video, - canvas { - object-fit: cover; - } - } } - </style> diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue index 049e23db..a45f4586 100644 --- a/src/components/global_notice_list/global_notice_list.vue +++ b/src/components/global_notice_list/global_notice_list.vue @@ -71,6 +71,14 @@ } } + .global-success { + background-color: var(--alertPopupSuccess, $fallback--cGreen); + color: var(--alertPopupSuccessText, $fallback--text); + .svg-inline--fa { + color: var(--alertPopupSuccessText, $fallback--text); + } + } + .global-info { background-color: var(--alertPopupNeutral, $fallback--fg); color: var(--alertPopupNeutralText, $fallback--text); diff --git a/src/components/hashtag_link/hashtag_link.js b/src/components/hashtag_link/hashtag_link.js new file mode 100644 index 00000000..a2433c2a --- /dev/null +++ b/src/components/hashtag_link/hashtag_link.js @@ -0,0 +1,36 @@ +import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js' + +const HashtagLink = { + name: 'HashtagLink', + props: { + url: { + required: true, + type: String + }, + content: { + required: true, + type: String + }, + tag: { + required: false, + type: String, + default: '' + } + }, + methods: { + onClick () { + const tag = this.tag || extractTagFromUrl(this.url) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + } else { + window.open(this.url, '_blank') + } + }, + generateTagLink (tag) { + return `/tag/${tag}` + } + } +} + +export default HashtagLink diff --git a/src/components/hashtag_link/hashtag_link.scss b/src/components/hashtag_link/hashtag_link.scss new file mode 100644 index 00000000..78e8fb99 --- /dev/null +++ b/src/components/hashtag_link/hashtag_link.scss @@ -0,0 +1,6 @@ +.HashtagLink { + position: relative; + white-space: normal; + display: inline-block; + color: var(--link); +} diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue new file mode 100644 index 00000000..918ed26b --- /dev/null +++ b/src/components/hashtag_link/hashtag_link.vue @@ -0,0 +1,19 @@ +<template> + <span + class="HashtagLink" + > + <!-- eslint-disable vue/no-v-html --> + <a + :href="url" + class="original" + target="_blank" + @click.prevent="onClick" + v-html="content" + /> + <!-- eslint-enable vue/no-v-html --> + </span> +</template> + +<script src="./hashtag_link.js"/> + +<style lang="scss" src="./hashtag_link.scss"/> diff --git a/src/components/interface_language_switcher/interface_language_switcher.vue b/src/components/interface_language_switcher/interface_language_switcher.vue index dc3bd408..cf307a24 100644 --- a/src/components/interface_language_switcher/interface_language_switcher.vue +++ b/src/components/interface_language_switcher/interface_language_switcher.vue @@ -3,27 +3,18 @@ <label for="interface-language-switcher"> {{ $t('settings.interfaceLanguage') }} </label> - <label - for="interface-language-switcher" - class="select" + <Select + id="interface-language-switcher" + v-model="language" > - <select - id="interface-language-switcher" - v-model="language" + <option + v-for="lang in languages" + :key="lang.code" + :value="lang.code" > - <option - v-for="lang in languages" - :key="lang.code" - :value="lang.code" - > - {{ lang.name }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + {{ lang.name }} + </option> + </Select> </div> </template> @@ -32,16 +23,12 @@ import languagesObject from '../../i18n/messages' import localeService from '../../services/locale/locale.service.js' import ISO6391 from 'iso-639-1' import _ from 'lodash' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faChevronDown -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faChevronDown -) +import Select from '../select/select.vue' export default { + components: { + Select + }, computed: { languages () { return _.map(languagesObject.languages, (code) => ({ code: code, name: this.getLanguageName(code) })).sort((a, b) => a.name.localeCompare(b.name)) diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index e7384c93..b8bce730 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -3,22 +3,31 @@ import VideoAttachment from '../video_attachment/video_attachment.vue' import Modal from '../modal/modal.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import GestureService from '../../services/gesture_service/gesture_service' +import Flash from 'src/components/flash/flash.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faChevronLeft, - faChevronRight + faChevronRight, + faCircleNotch } from '@fortawesome/free-solid-svg-icons' library.add( faChevronLeft, - faChevronRight + faChevronRight, + faCircleNotch ) const MediaModal = { components: { StillImage, VideoAttachment, - Modal + Modal, + Flash + }, + data () { + return { + loading: false + } }, computed: { showing () { @@ -27,6 +36,9 @@ const MediaModal = { media () { return this.$store.state.mediaViewer.media }, + description () { + return this.currentMedia.description + }, currentIndex () { return this.$store.state.mediaViewer.currentIndex }, @@ -37,7 +49,7 @@ const MediaModal = { return this.media.length > 1 }, type () { - return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null + return this.currentMedia ? this.getType(this.currentMedia) : null } }, created () { @@ -53,6 +65,9 @@ const MediaModal = { ) }, methods: { + getType (media) { + return fileTypeService.fileType(media.mimetype) + }, mediaTouchStart (e) { GestureService.beginSwipe(e, this.mediaSwipeGestureRight) GestureService.beginSwipe(e, this.mediaSwipeGestureLeft) @@ -67,15 +82,26 @@ const MediaModal = { goPrev () { if (this.canNavigate) { const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) - this.$store.dispatch('setCurrent', this.media[prevIndex]) + const newMedia = this.media[prevIndex] + if (this.getType(newMedia) === 'image') { + this.loading = true + } + this.$store.dispatch('setCurrentMedia', newMedia) } }, goNext () { if (this.canNavigate) { const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) - this.$store.dispatch('setCurrent', this.media[nextIndex]) + const newMedia = this.media[nextIndex] + if (this.getType(newMedia) === 'image') { + this.loading = true + } + this.$store.dispatch('setCurrentMedia', newMedia) } }, + onImageLoaded () { + this.loading = false + }, handleKeyupEvent (e) { if (this.showing && e.keyCode === 27) { // escape this.hide() diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index ea7f7a7f..8680267b 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -6,6 +6,7 @@ > <img v-if="type === 'image'" + :class="{ loading }" class="modal-image" :src="currentMedia.url" :alt="currentMedia.description" @@ -13,6 +14,7 @@ @touchstart.stop="mediaTouchStart" @touchmove.stop="mediaTouchMove" @click="hide" + @load="onImageLoaded" > <VideoAttachment v-if="type === 'video'" @@ -28,6 +30,13 @@ :title="currentMedia.description" controls /> + <Flash + v-if="type === 'flash'" + class="modal-image" + :src="currentMedia.url" + :alt="currentMedia.description" + :title="currentMedia.description" + /> <button v-if="canNavigate" :title="$t('media_modal.previous')" @@ -50,6 +59,27 @@ icon="chevron-right" /> </button> + <span + v-if="description" + class="description" + > + {{ description }} + </span> + <span + class="counter" + > + {{ $tc('media_modal.counter', currentIndex + 1, { current: currentIndex + 1, total: media.length }) }} + </span> + <span + v-if="loading" + class="loading-spinner" + > + <FAIcon + spin + icon="circle-notch" + size="5x" + /> + </span> </Modal> </template> @@ -58,6 +88,7 @@ <style lang="scss"> .modal-view.media-modal-view { z-index: 1001; + flex-direction: column; .modal-view-button-arrow { opacity: 0.75; @@ -67,59 +98,108 @@ outline: none; box-shadow: none; } + &:hover { opacity: 1; } } } -.modal-image { - max-width: 90%; - max-height: 90%; - box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); - image-orientation: from-image; // NOTE: only FF supports this -} +.media-modal-view { + @keyframes media-fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } + } -.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); + .description, + .counter { + /* Hardcoded since background is also hardcoded */ + color: white; + margin-top: 1em; + text-shadow: 0 0 10px black, 0 0 10px black; + padding: 0.2em 2em; } - &--prev { - left: 0; - .arrow-icon { - left: 6px; + .description { + flex: 0 0 auto; + overflow-y: auto; + min-height: 1em; + max-width: 500px; + max-height: 9.5em; + word-break: break-all; + } + + .modal-image { + max-width: 90%; + max-height: 90%; + box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5); + image-orientation: from-image; // NOTE: only FF supports this + animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein; + + &.loading { + opacity: 0.5; + } + } + + .loading-spinner { + width: 100%; + height: 100%; + position: absolute; + pointer-events: none; + display: flex; + justify-content: center; + align-items: center; + + svg { + color: white; } } - &--next { - right: 0; + .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 { - right: 6px; + 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; + } } } } diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js new file mode 100644 index 00000000..55eea195 --- /dev/null +++ b/src/components/mention_link/mention_link.js @@ -0,0 +1,134 @@ +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { mapGetters, mapState } from 'vuex' +import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' +import UserAvatar from '../user_avatar/user_avatar.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faAt +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faAt +) + +const MentionLink = { + name: 'MentionLink', + components: { + UserAvatar + }, + props: { + url: { + required: true, + type: String + }, + content: { + required: true, + type: String + }, + userId: { + required: false, + type: String + }, + userScreenName: { + required: false, + type: String + } + }, + methods: { + onClick () { + const link = generateProfileLink( + this.userId || this.user.id, + this.userScreenName || this.user.screen_name + ) + this.$router.push(link) + } + }, + computed: { + user () { + return this.url && this.$store && this.$store.getters.findUserByUrl(this.url) + }, + isYou () { + // FIXME why user !== currentUser??? + return this.user && this.user.id === this.currentUser.id + }, + userName () { + return this.user && this.userNameFullUi.split('@')[0] + }, + serverName () { + // XXX assumed that domain does not contain @ + return this.user && (this.userNameFullUi.split('@')[1] || this.$store.getters.instanceDomain) + }, + userNameFull () { + return this.user && this.user.screen_name + }, + userNameFullUi () { + return this.user && this.user.screen_name_ui + }, + highlight () { + return this.user && this.mergedConfig.highlight[this.user.screen_name] + }, + highlightType () { + return this.highlight && ('-' + this.highlight.type) + }, + highlightClass () { + if (this.highlight) return highlightClass(this.user) + }, + style () { + if (this.highlight) { + const { + backgroundColor, + backgroundPosition, + backgroundImage, + ...rest + } = highlightStyle(this.highlight) + return rest + } + }, + classnames () { + return [ + { + '-you': this.isYou && this.shouldBoldenYou, + '-highlighted': this.highlight + }, + this.highlightType + ] + }, + useAtIcon () { + return this.mergedConfig.useAtIcon + }, + isRemote () { + return this.userName !== this.userNameFull + }, + shouldShowFullUserName () { + const conf = this.mergedConfig.mentionLinkDisplay + if (conf === 'short') { + return false + } else if (conf === 'full') { + return true + } else { // full_for_remote + return this.isRemote + } + }, + shouldShowTooltip () { + return this.mergedConfig.mentionLinkShowTooltip && this.mergedConfig.mentionLinkDisplay === 'short' && this.isRemote + }, + shouldShowAvatar () { + return this.mergedConfig.mentionLinkShowAvatar + }, + shouldShowYous () { + return this.mergedConfig.mentionLinkShowYous + }, + shouldBoldenYou () { + return this.mergedConfig.mentionLinkBoldenYou + }, + shouldFadeDomain () { + return this.mergedConfig.mentionLinkFadeDomain + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + currentUser: state => state.users.currentUser + }) + } +} + +export default MentionLink diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss new file mode 100644 index 00000000..a4326296 --- /dev/null +++ b/src/components/mention_link/mention_link.scss @@ -0,0 +1,116 @@ +@import '../../_variables.scss'; + +.MentionLink { + position: relative; + white-space: normal; + display: inline; + color: var(--link); + word-break: normal; + + & .new, + & .original { + display: inline; + border-radius: 2px; + } + + .mention-avatar { + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + width: 1.5em; + height: 1.5em; + vertical-align: middle; + user-select: none; + margin-right: 0.2em; + } + + .full { + position: absolute; + display: inline-block; + pointer-events: none; + opacity: 0; + top: 100%; + left: 0; + height: 100%; + word-wrap: normal; + white-space: nowrap; + transition: opacity 0.2s ease; + z-index: 1; + margin-top: 0.25em; + padding: 0.5em; + user-select: all; + } + + & .short.-with-tooltip, + & .you { + user-select: none; + } + + & .short, + & .full { + white-space: nowrap; + } + + .shortName { + white-space: normal; + } + + .new { + &.-you { + & .shortName, + & .full { + font-weight: 600; + } + } + + .at { + color: var(--link); + opacity: 0.8; + display: inline-block; + height: 50%; + line-height: 1; + padding: 0 0.1em; + vertical-align: -25%; + margin: 0; + } + + &.-striped { + & .shortName, + & .full { + background-image: + repeating-linear-gradient( + 135deg, + var(--____highlight-tintColor), + var(--____highlight-tintColor) 5px, + var(--____highlight-tintColor2) 5px, + var(--____highlight-tintColor2) 10px + ); + } + } + + &.-solid { + & .shortName, + & .full { + background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2)); + } + } + + &.-side { + & .shortName, + & .userNameFull { + box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor); + } + } + } + + &:hover .new .full { + opacity: 1; + pointer-events: initial; + } + + .serverName.-faded { + color: var(--faintLink, $fallback--link); + } + + .full .-faded { + color: var(--faint, $fallback--faint); + } +} diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue new file mode 100644 index 00000000..3562f511 --- /dev/null +++ b/src/components/mention_link/mention_link.vue @@ -0,0 +1,75 @@ +<template> + <span + class="MentionLink" + > + <!-- eslint-disable vue/no-v-html --> + <a + v-if="!user" + :href="url" + class="original" + target="_blank" + v-html="content" + /><!-- eslint-enable vue/no-v-html --><span + v-if="user" + class="new" + :style="style" + :class="classnames" + > + <a + class="short button-unstyled" + :class="{ '-with-tooltip': shouldShowTooltip }" + :href="url" + @click.prevent="onClick" + > + <!-- eslint-disable vue/no-v-html --> + <UserAvatar + v-if="shouldShowAvatar" + class="mention-avatar" + :user="user" + /><span + class="shortName" + ><FAIcon + v-if="useAtIcon" + size="sm" + icon="at" + class="at" + />{{ !useAtIcon ? '@' : '' }}<span + class="userName" + v-html="userName" + /><span + v-if="shouldShowFullUserName" + class="serverName" + :class="{ '-faded': shouldFadeDomain }" + v-html="'@' + serverName" + /></span><span + v-if="isYou && shouldShowYous" + :class="{ '-you': shouldBoldenYou }" + > {{ $t('status.you') }}</span> + <!-- eslint-enable vue/no-v-html --> + </a><span + v-if="shouldShowTooltip" + class="full popover-default" + :class="[highlightType]" + > + <span + class="userNameFull" + > + <!-- eslint-disable vue/no-v-html --> + @<span + class="userName" + v-html="userName" + /><span + class="serverName" + :class="{ '-faded': shouldFadeDomain }" + v-html="'@' + serverName" + /> + <!-- eslint-enable vue/no-v-html --> + </span> + </span> + </span> + </span> +</template> + +<script src="./mention_link.js"/> + +<style lang="scss" src="./mention_link.scss"/> diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js new file mode 100644 index 00000000..a4a0c724 --- /dev/null +++ b/src/components/mentions_line/mentions_line.js @@ -0,0 +1,37 @@ +import MentionLink from 'src/components/mention_link/mention_link.vue' +import { mapGetters } from 'vuex' + +export const MENTIONS_LIMIT = 5 + +const MentionsLine = { + name: 'MentionsLine', + props: { + mentions: { + required: true, + type: Array + } + }, + data: () => ({ expanded: false }), + components: { + MentionLink + }, + computed: { + mentionsComputed () { + return this.mentions.slice(0, MENTIONS_LIMIT) + }, + extraMentions () { + return this.mentions.slice(MENTIONS_LIMIT) + }, + manyMentions () { + return this.extraMentions.length > 0 + }, + ...mapGetters(['mergedConfig']) + }, + methods: { + toggleShowMore () { + this.expanded = !this.expanded + } + } +} + +export default MentionsLine diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss new file mode 100644 index 00000000..9a622e75 --- /dev/null +++ b/src/components/mentions_line/mentions_line.scss @@ -0,0 +1,13 @@ +.MentionsLine { + word-break: break-all; + + .mention-link:not(:first-child)::before { + content: ' '; + } + + .showMoreLess { + margin-left: 0.5em; + white-space: normal; + color: var(--link); + } +} diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue new file mode 100644 index 00000000..f375e3b0 --- /dev/null +++ b/src/components/mentions_line/mentions_line.vue @@ -0,0 +1,43 @@ +<template> + <span class="MentionsLine"> + <MentionLink + v-for="mention in mentionsComputed" + :key="mention.index" + class="mention-link" + :content="mention.content" + :url="mention.url" + :first-mention="false" + /><span + v-if="manyMentions" + class="extraMentions" + > + <span + v-if="expanded" + class="fullExtraMentions" + > + <MentionLink + v-for="mention in extraMentions" + :key="mention.index" + class="mention-link" + :content="mention.content" + :url="mention.url" + :first-mention="false" + /> + </span><button + v-if="!expanded" + class="button-unstyled showMoreLess" + @click="toggleShowMore" + > + {{ $t('status.plus_more', { number: extraMentions.length }) }} + </button><button + v-if="expanded" + class="button-unstyled showMoreLess" + @click="toggleShowMore" + > + {{ $t('general.show_less') }} + </button> + </span> + </span> +</template> +<script src="./mentions_line.js" ></script> +<style lang="scss" src="./mentions_line.scss" /> diff --git a/src/components/mfa_form/recovery_form.vue b/src/components/mfa_form/recovery_form.vue index 0bf68e27..7c594228 100644 --- a/src/components/mfa_form/recovery_form.vue +++ b/src/components/mfa_form/recovery_form.vue @@ -25,6 +25,7 @@ <div> <button class="button-unstyled -link" + type="button" @click.prevent="requireTOTP" > {{ $t('login.enter_two_factor_code') }} @@ -32,6 +33,7 @@ <br> <button class="button-unstyled -link" + type="button" @click.prevent="abortMFA" > {{ $t('general.cancel') }} diff --git a/src/components/mfa_form/totp_form.vue b/src/components/mfa_form/totp_form.vue index 79230148..4ee13992 100644 --- a/src/components/mfa_form/totp_form.vue +++ b/src/components/mfa_form/totp_form.vue @@ -27,6 +27,7 @@ <div> <button class="button-unstyled -link" + type="button" @click.prevent="requireRecovery" > {{ $t('login.enter_recovery_code') }} @@ -34,6 +35,7 @@ <br> <button class="button-unstyled -link" + type="button" @click.prevent="abortMFA" > {{ $t('general.cancel') }} diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js index 366ea89c..d27fb3b8 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.js +++ b/src/components/mobile_post_status_button/mobile_post_status_button.js @@ -44,6 +44,9 @@ const MobilePostStatusButton = { return this.autohideFloatingPostButton && (this.hidden || this.inputActive) }, + isPersistent () { + return !!this.$store.getters.mergedConfig.showNewPostButton + }, autohideFloatingPostButton () { return !!this.$store.getters.mergedConfig.autohideFloatingPostButton } diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue index 767f8244..37becf4c 100644 --- a/src/components/mobile_post_status_button/mobile_post_status_button.vue +++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue @@ -2,7 +2,7 @@ <div v-if="isLoggedIn"> <button class="button-default new-status-button" - :class="{ 'hidden': isHidden }" + :class="{ 'hidden': isHidden, 'always-show': isPersistent }" @click="openPostForm" > <FAIcon icon="pen" /> @@ -47,7 +47,7 @@ } @media all and (min-width: 801px) { - .new-status-button { + .new-status-button:not(.always-show) { display: none; } } diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js index d4fdc53e..2469327a 100644 --- a/src/components/moderation_tools/moderation_tools.js +++ b/src/components/moderation_tools/moderation_tools.js @@ -1,6 +1,11 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronDown } from '@fortawesome/free-solid-svg-icons' + import DialogModal from '../dialog_modal/dialog_modal.vue' import Popover from '../popover/popover.vue' +library.add(faChevronDown) + const FORCE_NSFW = 'mrf_tag:media-force-nsfw' const STRIP_MEDIA = 'mrf_tag:media-strip' const FORCE_UNLISTED = 'mrf_tag:force-unlisted' diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index 5c7b82ec..96476abe 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -8,7 +8,7 @@ @show="setToggled(true)" @close="setToggled(false)" > - <div slot="content"> + <template v-slot:content> <div class="dropdown-menu"> <span v-if="user.is_local"> <button @@ -50,96 +50,98 @@ class="button-default dropdown-item" @click="toggleTag(tags.FORCE_NSFW)" > - {{ $t('user_card.admin_menu.force_nsfw') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }" /> + {{ $t('user_card.admin_menu.force_nsfw') }} </button> <button class="button-default dropdown-item" @click="toggleTag(tags.STRIP_MEDIA)" > - {{ $t('user_card.admin_menu.strip_media') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }" /> + {{ $t('user_card.admin_menu.strip_media') }} </button> <button class="button-default dropdown-item" @click="toggleTag(tags.FORCE_UNLISTED)" > - {{ $t('user_card.admin_menu.force_unlisted') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }" /> + {{ $t('user_card.admin_menu.force_unlisted') }} </button> <button class="button-default dropdown-item" @click="toggleTag(tags.SANDBOX)" > - {{ $t('user_card.admin_menu.sandbox') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }" /> + {{ $t('user_card.admin_menu.sandbox') }} </button> <button v-if="user.is_local" class="button-default dropdown-item" @click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)" > - {{ $t('user_card.admin_menu.disable_remote_subscription') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }" /> + {{ $t('user_card.admin_menu.disable_remote_subscription') }} </button> <button v-if="user.is_local" class="button-default dropdown-item" @click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)" > - {{ $t('user_card.admin_menu.disable_any_subscription') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }" /> + {{ $t('user_card.admin_menu.disable_any_subscription') }} </button> <button v-if="user.is_local" class="button-default dropdown-item" @click="toggleTag(tags.QUARANTINE)" > - {{ $t('user_card.admin_menu.quarantine') }} <span class="menu-checkbox" :class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }" /> + {{ $t('user_card.admin_menu.quarantine') }} </button> </span> </div> - </div> - <button - slot="trigger" - class="btn button-default btn-block" - :class="{ toggled }" - > - {{ $t('user_card.admin_menu.moderation') }} - </button> + </template> + <template v-slot:trigger> + <button + class="btn button-default btn-block moderation-tools-button" + :class="{ toggled }" + > + {{ $t('user_card.admin_menu.moderation') }} + <FAIcon icon="chevron-down" /> + </button> + </template> </Popover> <portal to="modal"> <DialogModal v-if="showDeleteUserDialog" :on-cancel="deleteUserDialog.bind(this, false)" > - <template slot="header"> + <template v-slot:header> {{ $t('user_card.admin_menu.delete_user') }} </template> <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> - <template slot="footer"> + <template v-slot:footer> <button class="btn button-default" @click="deleteUserDialog(false)" @@ -163,25 +165,6 @@ <style lang="scss"> @import '../../_variables.scss'; -.menu-checkbox { - float: right; - min-width: 22px; - max-width: 22px; - min-height: 22px; - max-height: 22px; - line-height: 22px; - text-align: center; - border-radius: 0px; - background-color: $fallback--fg; - background-color: var(--input, $fallback--fg); - box-shadow: 0px 0px 2px black inset; - box-shadow: var(--inputShadow); - - &.menu-checkbox-checked::after { - content: '✓'; - } -} - .moderation-tools-popover { height: 100%; .trigger { @@ -189,4 +172,10 @@ height: 100%; } } + +.moderation-tools-button { + svg,i { + font-size: 0.8em; + } +} </style> diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js index a0b600d2..3fde8106 100644 --- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js +++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js @@ -1,17 +1,56 @@ import { mapState } from 'vuex' import { get } from 'lodash' +/** + * This is for backwards compatibility. We originally didn't recieve + * extra info like a reason why an instance was rejected/quarantined/etc. + * Because we didn't want to break backwards compatibility it was decided + * to add an extra "info" key. + */ +const toInstanceReasonObject = (instances, info, key) => { + return instances.map(instance => { + if (info[key] && info[key][instance] && info[key][instance]['reason']) { + return { instance: instance, reason: info[key][instance]['reason'] } + } + return { instance: instance, reason: '' } + }) +} + const MRFTransparencyPanel = { computed: { ...mapState({ federationPolicy: state => get(state, 'instance.federationPolicy'), mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []), - quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []), - acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []), - rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []), - ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []), - mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []), - mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []), + quarantineInstances: state => toInstanceReasonObject( + get(state, 'instance.federationPolicy.quarantined_instances', []), + get(state, 'instance.federationPolicy.quarantined_instances_info', []), + 'quarantined_instances' + ), + acceptInstances: state => toInstanceReasonObject( + get(state, 'instance.federationPolicy.mrf_simple.accept', []), + get(state, 'instance.federationPolicy.mrf_simple_info', []), + 'accept' + ), + rejectInstances: state => toInstanceReasonObject( + get(state, 'instance.federationPolicy.mrf_simple.reject', []), + get(state, 'instance.federationPolicy.mrf_simple_info', []), + 'reject' + ), + ftlRemovalInstances: state => toInstanceReasonObject( + get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []), + get(state, 'instance.federationPolicy.mrf_simple_info', []), + 'federated_timeline_removal' + ), + mediaNsfwInstances: state => toInstanceReasonObject( + get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []), + get(state, 'instance.federationPolicy.mrf_simple_info', []), + 'media_nsfw' + ), + mediaRemovalInstances: state => toInstanceReasonObject( + get(state, 'instance.federationPolicy.mrf_simple.media_removal', []), + get(state, 'instance.federationPolicy.mrf_simple_info', []), + 'media_removal' + ), keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []), keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []), keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', []) diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss new file mode 100644 index 00000000..80ea01d4 --- /dev/null +++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss @@ -0,0 +1,21 @@ +.mrf-section { + margin: 1em; + + table { + width:100%; + text-align: left; + padding-left:10px; + padding-bottom:20px; + + th, td { + width: 180px; + max-width: 360px; + overflow: hidden; + vertical-align: text-top; + } + + th+th, td+td { + width: auto; + } + } +} diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue index acdf822e..1787fa07 100644 --- a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue +++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue @@ -31,13 +31,24 @@ <p>{{ $t("about.mrf.simple.accept_desc") }}</p> - <ul> - <li - v-for="instance in acceptInstances" - :key="instance" - v-text="instance" - /> - </ul> + <table> + <tr> + <th>{{ $t("about.mrf.simple.instance") }}</th> + <th>{{ $t("about.mrf.simple.reason") }}</th> + </tr> + <tr + v-for="entry in acceptInstances" + :key="entry.instance + '_accept'" + > + <td>{{ entry.instance }}</td> + <td v-if="entry.reason === ''"> + {{ $t("about.mrf.simple.not_applicable") }} + </td> + <td v-else> + {{ entry.reason }} + </td> + </tr> + </table> </div> <div v-if="rejectInstances.length"> @@ -45,13 +56,24 @@ <p>{{ $t("about.mrf.simple.reject_desc") }}</p> - <ul> - <li - v-for="instance in rejectInstances" - :key="instance" - v-text="instance" - /> - </ul> + <table> + <tr> + <th>{{ $t("about.mrf.simple.instance") }}</th> + <th>{{ $t("about.mrf.simple.reason") }}</th> + </tr> + <tr + v-for="entry in rejectInstances" + :key="entry.instance + '_reject'" + > + <td>{{ entry.instance }}</td> + <td v-if="entry.reason === ''"> + {{ $t("about.mrf.simple.not_applicable") }} + </td> + <td v-else> + {{ entry.reason }} + </td> + </tr> + </table> </div> <div v-if="quarantineInstances.length"> @@ -59,13 +81,24 @@ <p>{{ $t("about.mrf.simple.quarantine_desc") }}</p> - <ul> - <li - v-for="instance in quarantineInstances" - :key="instance" - v-text="instance" - /> - </ul> + <table> + <tr> + <th>{{ $t("about.mrf.simple.instance") }}</th> + <th>{{ $t("about.mrf.simple.reason") }}</th> + </tr> + <tr + v-for="entry in quarantineInstances" + :key="entry.instance + '_quarantine'" + > + <td>{{ entry.instance }}</td> + <td v-if="entry.reason === ''"> + {{ $t("about.mrf.simple.not_applicable") }} + </td> + <td v-else> + {{ entry.reason }} + </td> + </tr> + </table> </div> <div v-if="ftlRemovalInstances.length"> @@ -73,13 +106,24 @@ <p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p> - <ul> - <li - v-for="instance in ftlRemovalInstances" - :key="instance" - v-text="instance" - /> - </ul> + <table> + <tr> + <th>{{ $t("about.mrf.simple.instance") }}</th> + <th>{{ $t("about.mrf.simple.reason") }}</th> + </tr> + <tr + v-for="entry in ftlRemovalInstances" + :key="entry.instance + '_ftl_removal'" + > + <td>{{ entry.instance }}</td> + <td v-if="entry.reason === ''"> + {{ $t("about.mrf.simple.not_applicable") }} + </td> + <td v-else> + {{ entry.reason }} + </td> + </tr> + </table> </div> <div v-if="mediaNsfwInstances.length"> @@ -87,13 +131,24 @@ <p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p> - <ul> - <li - v-for="instance in mediaNsfwInstances" - :key="instance" - v-text="instance" - /> - </ul> + <table> + <tr> + <th>{{ $t("about.mrf.simple.instance") }}</th> + <th>{{ $t("about.mrf.simple.reason") }}</th> + </tr> + <tr + v-for="entry in mediaNsfwInstances" + :key="entry.instance + '_media_nsfw'" + > + <td>{{ entry.instance }}</td> + <td v-if="entry.reason === ''"> + {{ $t("about.mrf.simple.not_applicable") }} + </td> + <td v-else> + {{ entry.reason }} + </td> + </tr> + </table> </div> <div v-if="mediaRemovalInstances.length"> @@ -101,13 +156,24 @@ <p>{{ $t("about.mrf.simple.media_removal_desc") }}</p> - <ul> - <li - v-for="instance in mediaRemovalInstances" - :key="instance" - v-text="instance" - /> - </ul> + <table> + <tr> + <th>{{ $t("about.mrf.simple.instance") }}</th> + <th>{{ $t("about.mrf.simple.reason") }}</th> + </tr> + <tr + v-for="entry in mediaRemovalInstances" + :key="entry.instance + '_media_removal'" + > + <td>{{ entry.instance }}</td> + <td v-if="entry.reason === ''"> + {{ $t("about.mrf.simple.not_applicable") }} + </td> + <td v-else> + {{ entry.reason }} + </td> + </tr> + </table> </div> <h2 v-if="hasKeywordPolicies"> @@ -161,7 +227,6 @@ <script src="./mrf_transparency_panel.js"></script> <style lang="scss"> -.mrf-section { - margin: 1em; -} +@import '../../_variables.scss'; +@import './mrf_transparency_panel.scss'; </style> diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 81d49cc2..37bcb409 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -1,4 +1,4 @@ -import { timelineNames } from '../timeline_menu/timeline_menu.js' +import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue' import { mapState, mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' @@ -7,10 +7,12 @@ import { faGlobe, faBookmark, faEnvelope, - faHome, + faChevronDown, + faChevronUp, faComments, faBell, - faInfoCircle + faInfoCircle, + faStream } from '@fortawesome/free-solid-svg-icons' library.add( @@ -18,10 +20,12 @@ library.add( faGlobe, faBookmark, faEnvelope, - faHome, + faChevronDown, + faChevronUp, faComments, faBell, - faInfoCircle + faInfoCircle, + faStream ) const NavPanel = { @@ -30,16 +34,20 @@ const NavPanel = { this.$store.dispatch('startFetchingFollowRequests') } }, + components: { + TimelineMenuContent + }, + data () { + return { + showTimelines: false + } + }, + methods: { + toggleTimelines () { + this.showTimelines = !this.showTimelines + } + }, computed: { - onTimelineRoute () { - return !!timelineNames()[this.$route.name] - }, - timelinesRoute () { - if (this.$store.state.interface.lastTimeline) { - return this.$store.state.interface.lastTimeline - } - return this.currentUser ? 'friends' : 'public-timeline' - }, ...mapState({ currentUser: state => state.users.currentUser, followRequestCount: state => state.api.followRequests.length, diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index 0c83d0fe..7ae7b1d6 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -3,19 +3,33 @@ <div class="panel panel-default"> <ul> <li v-if="currentUser || !privateMode"> - <router-link - :to="{ name: timelinesRoute }" - :class="onTimelineRoute && 'router-link-active'" + <button + class="button-unstyled menu-item" + @click="toggleTimelines" > <FAIcon fixed-width class="fa-scale-110" - icon="home" + icon="stream" />{{ $t("nav.timelines") }} - </router-link> + <FAIcon + class="timelines-chevron" + fixed-width + :icon="showTimelines ? 'chevron-up' : 'chevron-down'" + /> + </button> + <div + v-show="showTimelines" + class="timelines-background" + > + <TimelineMenuContent class="timelines" /> + </div> </li> <li v-if="currentUser"> - <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> + <router-link + class="menu-item" + :to="{ name: 'interactions', params: { username: currentUser.screen_name } }" + > <FAIcon fixed-width class="fa-scale-110" @@ -24,7 +38,10 @@ </router-link> </li> <li v-if="currentUser && pleromaChatMessagesAvailable"> - <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }"> + <router-link + class="menu-item" + :to="{ name: 'chats', params: { username: currentUser.screen_name } }" + > <div v-if="unreadChatCount" class="badge badge-notification" @@ -39,7 +56,10 @@ </router-link> </li> <li v-if="currentUser && currentUser.locked"> - <router-link :to="{ name: 'friend-requests' }"> + <router-link + class="menu-item" + :to="{ name: 'friend-requests' }" + > <FAIcon fixed-width class="fa-scale-110" @@ -54,7 +74,10 @@ </router-link> </li> <li> - <router-link :to="{ name: 'about' }"> + <router-link + class="menu-item" + :to="{ name: 'about' }" + > <FAIcon fixed-width class="fa-scale-110" @@ -91,14 +114,14 @@ border-color: var(--border, $fallback--border); padding: 0; - &:first-child a { + &:first-child .menu-item { border-top-right-radius: $fallback--panelRadius; border-top-right-radius: var(--panelRadius, $fallback--panelRadius); border-top-left-radius: $fallback--panelRadius; border-top-left-radius: var(--panelRadius, $fallback--panelRadius); } - &:last-child a { + &:last-child .menu-item { border-bottom-right-radius: $fallback--panelRadius; border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius); border-bottom-left-radius: $fallback--panelRadius; @@ -110,13 +133,15 @@ border: none; } - a { + .menu-item { display: block; box-sizing: border-box; - align-items: stretch; height: 3.5em; line-height: 3.5em; padding: 0 1em; + width: 100%; + color: $fallback--link; + color: var(--link, $fallback--link); &:hover { background-color: $fallback--lightBg; @@ -146,6 +171,25 @@ } } + .timelines-chevron { + margin-left: 0.8em; + font-size: 1.1em; + } + + .timelines-background { + padding: 0 0 0 0.6em; + background-color: $fallback--lightBg; + background-color: var(--selectedMenu, $fallback--lightBg); + border-top: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + } + + .timelines { + background-color: $fallback--bg; + background-color: var(--bg, $fallback--bg); + } + .fa-scale-110 { margin-right: 0.8em; } diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 1634c035..8f74c0e6 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' import Timeago from '../timeago/timeago.vue' import Report from '../report/report.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -46,7 +47,8 @@ const Notification = { UserCard, Timeago, Status, - Report + Report, + RichContent }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss index f5905560..38978137 100644 --- a/src/components/notification/notification.scss +++ b/src/components/notification/notification.scss @@ -2,6 +2,19 @@ // TODO Copypaste from Status, should unify it somehow .Notification { + border-bottom: 1px solid; + border-color: $fallback--border; + border-color: var(--border, $fallback--border); + word-wrap: break-word; + word-break: break-word; + --emoji-size: 14px; + + &:hover { + --_still-image-img-visibility: visible; + --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; + } + &.-muted { padding: 0.25em 0.6em; height: 1.2em; diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 40ea0431..e10eff00 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,6 +1,7 @@ <template> <Status v-if="notification.type === 'mention'" + class="Notification" :compact="true" :statusoid="notification.status" /> @@ -11,7 +12,7 @@ > <small> <router-link :to="userProfileLink"> - {{ notification.from_profile.screen_name }} + {{ notification.from_profile.screen_name_ui }} </router-link> </small> <button @@ -51,17 +52,19 @@ <span class="notification-details"> <div class="name-and-action"> <!-- eslint-disable vue/no-v-html --> - <bdi - v-if="!!notification.from_profile.name_html" - class="username" - :title="'@'+notification.from_profile.screen_name" - v-html="notification.from_profile.name_html" - /> + <bdi v-if="!!notification.from_profile.name_html"> + <RichContent + class="username" + :title="'@'+notification.from_profile.screen_name_ui" + :html="notification.from_profile.name_html" + :emoji="notification.from_profile.emoji" + /> + </bdi> <!-- eslint-enable vue/no-v-html --> <span v-else class="username" - :title="'@'+notification.from_profile.screen_name" + :title="'@'+notification.from_profile.screen_name_ui" >{{ notification.from_profile.name }}</span> <span v-if="notification.type === 'like'"> <FAIcon @@ -155,7 +158,7 @@ :to="userProfileLink" class="follow-name" > - @{{ notification.from_profile.screen_name }} + @{{ notification.from_profile.screen_name_ui }} </router-link> <div v-if="notification.type === 'follow_request'" @@ -180,7 +183,7 @@ class="move-text" > <router-link :to="targetUserProfileLink"> - @{{ notification.target.screen_name }} + @{{ notification.target.screen_name_ui }} </router-link> </div> <Report @@ -188,8 +191,9 @@ :report-id="notification.report.id" /> <template v-else> - <status-content + <StatusContent class="faint" + :compact="true" :status="notification.action" /> </template> diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue new file mode 100644 index 00000000..ba0e90a0 --- /dev/null +++ b/src/components/notifications/notification_filters.vue @@ -0,0 +1,122 @@ +<template> + <Popover + trigger="click" + class="NotificationFilters" + placement="bottom" + :bound-to="{ x: 'container' }" + > + <template v-slot:content> + <div class="dropdown-menu"> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('likes')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.likes }" + />{{ $t('settings.notification_visibility_likes') }} + </button> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('repeats')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.repeats }" + />{{ $t('settings.notification_visibility_repeats') }} + </button> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('follows')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.follows }" + />{{ $t('settings.notification_visibility_follows') }} + </button> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('mentions')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.mentions }" + />{{ $t('settings.notification_visibility_mentions') }} + </button> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('emojiReactions')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.emojiReactions }" + />{{ $t('settings.notification_visibility_emoji_reactions') }} + </button> + <button + class="button-default dropdown-item" + @click="toggleNotificationFilter('moves')" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.moves }" + />{{ $t('settings.notification_visibility_moves') }} + </button> + </div> + </template> + <template v-slot:trigger> + <button class="button-unstyled"> + <FAIcon icon="filter" /> + </button> + </template> + </Popover> +</template> + +<script> +import Popover from '../popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faFilter } from '@fortawesome/free-solid-svg-icons' + +library.add( + faFilter +) + +export default { + components: { Popover }, + computed: { + filters () { + return this.$store.getters.mergedConfig.notificationVisibility + } + }, + methods: { + toggleNotificationFilter (type) { + this.$store.dispatch('setOption', { + name: 'notificationVisibility', + value: { + ...this.filters, + [type]: !this.filters[type] + } + }) + } + } +} +</script> + +<style lang="scss"> + +.NotificationFilters { + align-self: stretch; + + > button { + font-size: 1.2em; + padding-left: 0.7em; + padding-right: 0.2em; + line-height: 100%; + height: 100%; + } + + .dropdown-item { + margin: 0; + } +} + +</style> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index 49258563..c8f1ebcb 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,5 +1,6 @@ import { mapGetters } from 'vuex' import Notification from '../notification/notification.vue' +import NotificationFilters from './notification_filters.vue' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import { notificationsFromStore, @@ -17,6 +18,10 @@ library.add( const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30 const Notifications = { + components: { + Notification, + NotificationFilters + }, props: { // Disables display of panel header noHeading: Boolean, @@ -35,11 +40,6 @@ const Notifications = { seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT } }, - created () { - const store = this.$store - const credentials = store.state.users.currentUser.credentials - notificationsFetcher.fetchAndUpdate({ store, credentials }) - }, computed: { mainClass () { return this.minimalMode ? '' : 'panel panel-default' @@ -70,9 +70,6 @@ const Notifications = { }, ...mapGetters(['unreadChatCount']) }, - components: { - Notification - }, watch: { unseenCountTitle (count) { if (count > 0) { diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 58a86fb0..47c035f1 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -1,6 +1,6 @@ @import '../../_variables.scss'; -.notifications { +.Notifications { &:not(.minimal) { // a bit of a hack to allow scrolling below notifications padding-bottom: 15em; @@ -11,6 +11,10 @@ color: var(--text, $fallback--text); } + .notifications-footer { + border: none; + } + .notification { position: relative; @@ -33,11 +37,6 @@ .notification { box-sizing: border-box; - border-bottom: 1px solid; - border-color: $fallback--border; - border-color: var(--border, $fallback--border); - word-wrap: break-word; - word-break: break-word; &:hover .animated.Avatar { canvas { @@ -84,7 +83,6 @@ } } - .follow-text, .move-text { padding: 0.5em 0; overflow-wrap: break-word; @@ -147,13 +145,6 @@ max-width: 100%; text-overflow: ellipsis; white-space: nowrap; - - img { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain - } } .timeago { diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 725d1ad4..2ce5d56f 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -1,7 +1,7 @@ <template> <div :class="{ minimal: minimalMode }" - class="notifications" + class="Notifications" > <div :class="mainClass"> <div @@ -22,6 +22,7 @@ > {{ $t('notifications.read') }} </button> + <NotificationFilters /> </div> <div class="panel-body"> <div @@ -34,10 +35,10 @@ <notification :notification="notification" /> </div> </div> - <div class="panel-footer"> + <div class="panel-footer notifications-footer"> <div v-if="bottomedOut" - class="new-status-notification text-center panel-footer faint" + class="new-status-notification text-center faint" > {{ $t('notifications.no_more_notifications') }} </div> @@ -46,13 +47,13 @@ class="button-unstyled -link -fullwidth" @click.prevent="fetchOlderNotifications()" > - <div class="new-status-notification text-center panel-footer"> + <div class="new-status-notification text-center"> {{ minimalMode ? $t('interactions.load_older') : $t('notifications.load_older') }} </div> </button> <div v-else - class="new-status-notification text-center panel-footer" + class="new-status-notification text-center" > <FAIcon icon="circle-notch" diff --git a/src/components/password_reset/password_reset.vue b/src/components/password_reset/password_reset.vue index a931cb5a..3ffa5425 100644 --- a/src/components/password_reset/password_reset.vue +++ b/src/components/password_reset/password_reset.vue @@ -53,7 +53,7 @@ type="submit" class="btn button-default btn-block" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> </div> </div> diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js index 98db5582..a69b7886 100644 --- a/src/components/poll/poll.js +++ b/src/components/poll/poll.js @@ -1,10 +1,14 @@ -import Timeago from '../timeago/timeago.vue' +import Timeago from 'components/timeago/timeago.vue' +import RichContent from 'components/rich_content/rich_content.jsx' import { forEach, map } from 'lodash' export default { name: 'Poll', - props: ['basePoll'], - components: { Timeago }, + props: ['basePoll', 'emoji'], + components: { + Timeago, + RichContent + }, data () { return { loading: false, diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue index 42819c19..63b44e4f 100644 --- a/src/components/poll/poll.vue +++ b/src/components/poll/poll.vue @@ -17,8 +17,11 @@ <span class="result-percentage"> {{ percentageForOption(option.votes_count) }}% </span> - <!-- eslint-disable-next-line vue/no-v-html --> - <span v-html="option.title_html" /> + <RichContent + :html="option.title_html" + :handle-links="false" + :emoji="emoji" + /> </div> <div class="result-fill" @@ -42,8 +45,11 @@ :value="index" > <label class="option-vote"> - <!-- eslint-disable-next-line vue/no-v-html --> - <div v-html="option.title_html" /> + <RichContent + :html="option.title_html" + :handle-links="false" + :emoji="emoji" + /> </label> </div> </div> @@ -58,7 +64,12 @@ {{ $t('polls.vote') }} </button> <div class="total"> - {{ totalVotesCount }} {{ $t("polls.votes") }} · + <template v-if="typeof poll.voters_count === 'number'"> + {{ $tc("polls.people_voted_count", poll.voters_count, { count: poll.voters_count }) }} · + </template> + <template v-else> + {{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }} · + </template> </div> <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'"> <Timeago diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js index 1f8df3f9..e30645c3 100644 --- a/src/components/poll/poll_form.js +++ b/src/components/poll/poll_form.js @@ -1,19 +1,21 @@ import * as DateUtils from 'src/services/date_utils/date_utils.js' import { uniq } from 'lodash' import { library } from '@fortawesome/fontawesome-svg-core' +import Select from '../select/select.vue' import { faTimes, - faChevronDown, faPlus } from '@fortawesome/free-solid-svg-icons' library.add( faTimes, - faChevronDown, faPlus ) export default { + components: { + Select + }, name: 'PollForm', props: ['visible'], data: () => ({ diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue index 09496105..3620075a 100644 --- a/src/components/poll/poll_form.vue +++ b/src/components/poll/poll_form.vue @@ -46,23 +46,19 @@ class="poll-type" :title="$t('polls.type')" > - <label - for="poll-type-selector" - class="select" + <Select + v-model="pollType" + class="poll-type-select" + unstyled="true" + @change="updatePollToParent" > - <select - v-model="pollType" - class="select" - @change="updatePollToParent" - > - <option value="single">{{ $t('polls.single_choice') }}</option> - <option value="multiple">{{ $t('polls.multiple_choices') }}</option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + <option value="single"> + {{ $t('polls.single_choice') }} + </option> + <option value="multiple"> + {{ $t('polls.multiple_choices') }} + </option> + </Select> </div> <div class="poll-expiry" @@ -76,24 +72,20 @@ :max="maxExpirationInCurrentUnit" @change="expiryAmountChange" > - <label class="expiry-unit select"> - <select - v-model="expiryUnit" - @change="expiryAmountChange" + <Select + v-model="expiryUnit" + unstyled="true" + class="expiry-unit" + @change="expiryAmountChange" + > + <option + v-for="unit in expiryUnits" + :key="unit" + :value="unit" > - <option - v-for="unit in expiryUnits" - :key="unit" - :value="unit" - > - {{ $t(`time.${unit}_short`, ['']) }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + {{ $t(`time.${unit}_short`, ['']) }} + </option> + </Select> </div> </div> </div> @@ -147,10 +139,9 @@ .poll-type { margin-right: 0.75em; flex: 1 1 60%; - .select { - border: none; - box-shadow: none; - background-color: transparent; + + .poll-type-select { + padding-right: 0.75em; } } @@ -161,12 +152,6 @@ width: 3em; text-align: right; } - - .expiry-unit { - border: none; - box-shadow: none; - background-color: transparent; - } } } </style> diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 5e417fa0..6ccf32f0 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -3,25 +3,32 @@ const Popover = { props: { // Action to trigger popover: either 'hover' or 'click' trigger: String, + // Either 'top' or 'bottom' placement: String, + // Takes object with properties 'x' and 'y', values of these can be // 'container' for using offsetParent as boundaries for either axis // or 'viewport' boundTo: Object, + // Takes a selector to use as a replacement for the parent container // for getting boundaries for x an y axis boundToSelector: String, + // Takes a top/bottom/left/right object, how much space to leave // between boundary and popover element margin: Object, + // Takes a x/y object and tells how many pixels to offset from // anchor point on either axis offset: Object, + // Replaces the classes you may want for the popover container. // Use 'popover-default' in addition to get the default popover // styles with your custom class. popoverClass: String, + // If true, subtract padding when calculating position for the popover, // use it when popover offset looks to be different on top vs bottom. removePadding: Boolean @@ -47,8 +54,11 @@ const Popover = { } // Popover will be anchored around this element, trigger ref is the container, so - // its children are what are inside the slot. Expect only one slot="trigger". + // its children are what are inside the slot. Expect only one v-slot:trigger. const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el + // SVGs don't have offsetWidth/Height, use fallback + const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth + const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight const screenBox = anchorEl.getBoundingClientRect() // Screen position of the origin point for popover const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top } @@ -107,11 +117,11 @@ const Popover = { const yOffset = (this.offset && this.offset.y) || 0 const translateY = usingTop - ? -anchorEl.offsetHeight + vPadding - yOffset - content.offsetHeight + ? -anchorHeight + vPadding - yOffset - content.offsetHeight : yOffset const xOffset = (this.offset && this.offset.x) || 0 - const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset + const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset // Note, separate translateX and translateY avoids blurry text on chromium, // single translate or translate3d resulted in blurry text. @@ -121,9 +131,12 @@ const Popover = { } }, showPopover () { - if (this.hidden) this.$emit('show') + const wasHidden = this.hidden this.hidden = false - this.$nextTick(this.updateStyles) + this.$nextTick(() => { + if (wasHidden) this.$emit('show') + this.updateStyles() + }) }, hidePopover () { if (!this.hidden) this.$emit('close') diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index 2252c68f..8588b351 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -6,6 +6,7 @@ <button ref="trigger" class="button-unstyled -fullwidth popover-trigger-button" + type="button" @click="onClick" > <slot name="trigger" /> @@ -32,7 +33,7 @@ @import '../../_variables.scss'; .popover-trigger-button { - display: block; + display: inline-block; } .popover { @@ -81,10 +82,9 @@ .dropdown-item { line-height: 21px; - margin-right: 5px; overflow: auto; display: block; - padding: .25rem 1.0rem .25rem 1.5rem; + padding: .5em 0.75em; clear: both; font-weight: 400; text-align: inherit; @@ -100,10 +100,9 @@ --btnText: var(--popoverText, $fallback--text); &-icon { - padding-left: 0.5rem; - svg { - margin-right: 0.25rem; + width: 22px; + margin-right: 0.75rem; color: var(--menuPopoverIcon, $fallback--icon) } } @@ -122,6 +121,33 @@ } } + .menu-checkbox { + display: inline-block; + vertical-align: middle; + min-width: 22px; + max-width: 22px; + min-height: 22px; + max-height: 22px; + line-height: 22px; + text-align: center; + border-radius: 0px; + background-color: $fallback--fg; + background-color: var(--input, $fallback--fg); + box-shadow: 0px 0px 2px black inset; + box-shadow: var(--inputShadow); + margin-right: 0.75em; + + &.menu-checkbox-checked::after { + font-size: 1.25em; + content: '✓'; + } + + &.menu-checkbox-radio::after { + font-size: 2em; + content: '•'; + } + } + } } </style> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 4148381c..fe07309f 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -4,6 +4,7 @@ import ScopeSelector from '../scope_selector/scope_selector.vue' import EmojiInput from '../emoji_input/emoji_input.vue' import PollForm from '../poll/poll_form.vue' import Attachment from '../attachment/attachment.vue' +import Gallery from 'src/components/gallery/gallery.vue' import StatusContent from '../status_content/status_content.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' @@ -11,10 +12,10 @@ import { reject, map, uniqBy, debounce } from 'lodash' import suggestor from '../emoji_input/suggestor.js' import { mapGetters, mapState } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' +import Select from '../select/select.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { - faChevronDown, faSmileBeam, faPollH, faUpload, @@ -24,7 +25,6 @@ import { } from '@fortawesome/free-solid-svg-icons' library.add( - faChevronDown, faSmileBeam, faPollH, faUpload, @@ -84,8 +84,10 @@ const PostStatusForm = { PollForm, ScopeSelector, Checkbox, + Select, Attachment, - StatusContent + StatusContent, + Gallery }, mounted () { this.updateIdempotencyKey() @@ -115,7 +117,7 @@ const PostStatusForm = { ? this.copyMessageScope : this.$store.state.users.currentUser.default_scope - const { postContentType: contentType } = this.$store.getters.mergedConfig + const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig return { dropFiles: [], @@ -126,7 +128,7 @@ const PostStatusForm = { newStatus: { spoilerText: this.subject || '', status: statusText, - nsfw: false, + nsfw: !!sensitiveByDefault, files: [], poll: {}, mediaDescriptions: {}, @@ -388,6 +390,21 @@ const PostStatusForm = { this.newStatus.files.splice(index, 1) this.$emit('resize') }, + editAttachment (fileInfo, newText) { + this.newStatus.mediaDescriptions[fileInfo.id] = newText + }, + shiftUpMediaFile (fileInfo) { + const { files } = this.newStatus + const index = this.newStatus.files.indexOf(fileInfo) + files.splice(index, 1) + files.splice(index - 1, 0, fileInfo) + }, + shiftDnMediaFile (fileInfo) { + const { files } = this.newStatus + const index = this.newStatus.files.indexOf(fileInfo) + files.splice(index, 1) + files.splice(index + 1, 0, fileInfo) + }, uploadFailed (errString, templateArgs) { templateArgs = templateArgs || {} this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 73f6a4f1..2e0980a2 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -189,28 +189,19 @@ v-if="postFormats.length > 1" class="text-format" > - <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="newStatus.contentType" - class="form-control" + <option + v-for="postFormat in postFormats" + :key="postFormat" + :value="postFormat" > - <option - v-for="postFormat in postFormats" - :key="postFormat" - :value="postFormat" - > - {{ $t(`post_status.content_type["${postFormat}"]`) }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + {{ $t(`post_status.content_type["${postFormat}"]`) }} + </option> + </Select> </div> <div v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'" @@ -272,7 +263,7 @@ disabled class="btn button-default" > - {{ $t('general.submit') }} + {{ $t('post_status.post') }} </button> <!-- touchstart is used to keep the OSK at the same position after a message send --> <button @@ -282,7 +273,7 @@ @touchstart.stop.prevent="postStatus($event, newStatus)" @click.stop.prevent="postStatus($event, newStatus)" > - {{ $t('general.submit') }} + {{ $t('post_status.post') }} </button> </div> <div @@ -296,32 +287,22 @@ @click="clearError" /> </div> - <div class="attachments"> - <div - v-for="file in newStatus.files" - :key="file.url" - class="media-upload-wrapper" - > - <button - class="button-unstyled hider" - @click="removeMediaFile(file)" - > - <FAIcon icon="times" /> - </button> - <attachment - :attachment="file" - :set-media="() => $store.dispatch('setMedia', newStatus.files)" - size="small" - allow-play="false" - /> - <input - v-model="newStatus.mediaDescriptions[file.id]" - type="text" - :placeholder="$t('post_status.media_description')" - @keydown.enter.prevent="" - > - </div> - </div> + <gallery + v-if="newStatus.files && newStatus.files.length > 0" + class="attachments" + :grid="true" + :nsfw="false" + :attachments="newStatus.files" + :descriptions="newStatus.mediaDescriptions" + :set-media="() => $store.dispatch('setMedia', newStatus.files)" + :editable="true" + :edit-attachment="editAttachment" + :remove-attachment="removeMediaFile" + :shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile" + :shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile" + @play="$emit('mediaplay', attachment.id)" + @pause="$emit('mediapause', attachment.id)" + /> <div v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox" class="upload_settings" @@ -339,26 +320,13 @@ <style lang="scss"> @import '../../_variables.scss'; -.tribute-container { - ul { - padding: 0px; - li { - display: flex; - align-items: center; - } - } - img { - padding: 3px; - width: 16px; - height: 16px; - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - } -} - .post-status-form { position: relative; + .attachments { + margin-bottom: 0.5em; + } + .form-bottom { display: flex; justify-content: space-between; @@ -516,15 +484,6 @@ flex-direction: column; } - .attachments .media-upload-wrapper { - position: relative; - - .attachment { - margin: 0; - padding: 0; - } - } - .btn { cursor: pointer; } @@ -625,11 +584,4 @@ border: 2px dashed var(--text, $fallback--text); } } - -// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before) -img.media-upload, .media-upload-container > video { - line-height: 0; - max-height: 200px; - max-width: 100%; -} </style> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index 5e7b7580..ce82c90d 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -23,6 +23,12 @@ const ReactButton = { this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) } close() + }, + focusInput () { + this.$nextTick(() => { + const input = this.$el.querySelector('input') + if (input) input.focus() + }) } }, computed: { diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index ac940b98..c69c315b 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -1,15 +1,14 @@ <template> <Popover trigger="click" + class="ReactButton" placement="top" :offset="{ y: 5 }" :bound-to="{ x: 'container' }" remove-padding + @show="focusInput" > - <div - slot="content" - slot-scope="{close}" - > + <template v-slot:content="{close}"> <div class="reaction-picker-filter"> <input v-model="filterWord" @@ -39,17 +38,18 @@ </span> <div class="reaction-bottom-fader" /> </div> - </div> - <span - slot="trigger" - class="ReactButton" - :title="$t('tool_tip.add_reaction')" - > - <FAIcon - class="fa-scale-110 fa-old-padding" - :icon="['far', 'smile-beam']" - /> - </span> + </template> + <template v-slot:trigger> + <button + class="button-unstyled popover-trigger" + :title="$t('tool_tip.add_reaction')" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + :icon="['far', 'smile-beam']" + /> + </button> + </template> </Popover> </template> @@ -58,62 +58,71 @@ <style lang="scss"> @import '../../_variables.scss'; -.reaction-picker-filter { - padding: 0.5em; - display: flex; - input { - flex: 1; +.ReactButton { + .reaction-picker-filter { + padding: 0.5em; + display: flex; + + input { + flex: 1; + } } -} -.reaction-picker-divider { - height: 1px; - width: 100%; - margin: 0.5em; - background-color: var(--border, $fallback--border); -} + .reaction-picker-divider { + height: 1px; + width: 100%; + margin: 0.5em; + background-color: var(--border, $fallback--border); + } -.reaction-picker { - width: 10em; - height: 9em; - font-size: 1.5em; - overflow-y: scroll; - display: flex; - flex-wrap: wrap; - padding: 0.5em; - text-align: center; - align-content: flex-start; - user-select: none; + .reaction-picker { + width: 10em; + height: 9em; + font-size: 1.5em; + overflow-y: scroll; + display: flex; + flex-wrap: wrap; + padding: 0.5em; + text-align: center; + align-content: flex-start; + user-select: none; - mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, - linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, - linear-gradient(to top, white, white); - transition: mask-size 150ms; - mask-size: 100% 20px, 100% 20px, auto; - // Autoprefixed seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; - mask-composite: exclude; + mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); + transition: mask-size 150ms; + mask-size: 100% 20px, 100% 20px, auto; - .emoji-button { - cursor: pointer; + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; - flex-basis: 20%; - line-height: 1.5em; - align-content: center; + .emoji-button { + cursor: pointer; - &:hover { - transform: scale(1.25); + flex-basis: 20%; + line-height: 1.5em; + align-content: center; + + &:hover { + transform: scale(1.25); + } } } -} -.ReactButton { - padding: 10px; - margin: -10px; + /* override of popover internal stuff */ + .popover-trigger-button { + width: auto; + } + + .popover-trigger { + padding: 10px; + margin: -10px; - &:hover .svg-inline--fa { - color: $fallback--text; - color: var(--text, $fallback--text); + &:hover .svg-inline--fa { + color: $fallback--text; + color: var(--text, $fallback--text); + } } } diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js index dab06e1e..1ac8e8be 100644 --- a/src/components/registration/registration.js +++ b/src/components/registration/registration.js @@ -10,7 +10,8 @@ const registration = { fullname: '', username: '', password: '', - confirm: '' + confirm: '', + reason: '' }, captcha: {} }), @@ -24,7 +25,8 @@ const registration = { confirm: { required, sameAsPassword: sameAs('password') - } + }, + reason: { required: requiredIf(() => this.accountApprovalRequired) } } } }, @@ -38,7 +40,10 @@ const registration = { computed: { token () { return this.$route.params.token }, bioPlaceholder () { - return this.$t('registration.bio_placeholder').replace(/\s*\n\s*/g, ' \n') + return this.replaceNewlines(this.$t('registration.bio_placeholder')) + }, + reasonPlaceholder () { + return this.replaceNewlines(this.$t('registration.reason_placeholder')) }, ...mapState({ registrationOpen: (state) => state.instance.registrationOpen, @@ -46,7 +51,8 @@ const registration = { isPending: (state) => state.users.signUpPending, serverValidationErrors: (state) => state.users.signUpErrors, termsOfService: (state) => state.instance.tos, - accountActivationRequired: (state) => state.instance.accountActivationRequired + accountActivationRequired: (state) => state.instance.accountActivationRequired, + accountApprovalRequired: (state) => state.instance.accountApprovalRequired }) }, methods: { @@ -73,6 +79,9 @@ const registration = { }, setCaptcha () { this.getCaptcha().then(cpt => { this.captcha = cpt }) + }, + replaceNewlines (str) { + return str.replace(/\s*\n\s*/g, ' \n') } } } diff --git a/src/components/registration/registration.vue b/src/components/registration/registration.vue index 100df0d6..65b4bb33 100644 --- a/src/components/registration/registration.vue +++ b/src/components/registration/registration.vue @@ -163,6 +163,23 @@ </div> <div + v-if="accountApprovalRequired" + class="form-group" + > + <label + class="form--label" + for="reason" + >{{ $t('registration.reason') }}</label> + <textarea + id="reason" + v-model="user.reason" + :disabled="isPending" + class="form-control" + :placeholder="reasonPlaceholder" + /> + </div> + + <div v-if="captcha.type != 'none'" id="captcha-group" class="form-group" @@ -213,7 +230,7 @@ type="submit" class="btn button-default" > - {{ $t('general.submit') }} + {{ $t('registration.register') }} </button> </div> </div> diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx new file mode 100644 index 00000000..46bc661a --- /dev/null +++ b/src/components/rich_content/rich_content.jsx @@ -0,0 +1,328 @@ +import Vue from 'vue' +import { unescape, flattenDeep } from 'lodash' +import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js' +import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' +import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js' +import StillImage from 'src/components/still-image/still-image.vue' +import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue' +import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' + +import './rich_content.scss' + +/** + * RichContent, The Über-powered component for rendering Post HTML. + * + * This takes post HTML and does multiple things to it: + * - Groups all mentions into <MentionsLine>, this affects all mentions regardles + * of where they are (beginning/middle/end), even single mentions are converted + * to a <MentionsLine> containing single <MentionLink>. + * - Replaces emoji shortcodes with <StillImage>'d images. + * + * There are two problems with this component's architecture: + * 1. Parsing HTML and rendering are inseparable. Attempts to separate the two + * proven to be a massive overcomplication due to amount of things done here. + * 2. We need to output both render and some extra data, which seems to be imp- + * possible in vue. Current solution is to emit 'parseReady' event when parsing + * is done within render() function. + * + * Apart from that one small hiccup with emit in render this _should_ be vue3-ready + */ +export default Vue.component('RichContent', { + name: 'RichContent', + props: { + // Original html content + html: { + required: true, + type: String + }, + attentions: { + required: false, + default: () => [] + }, + // Emoji object, as in status.emojis, note the "s" at the end... + emoji: { + required: true, + type: Array + }, + // Whether to handle links or not (posts: yes, everything else: no) + handleLinks: { + required: false, + type: Boolean, + default: false + }, + // Meme arrows + greentext: { + required: false, + type: Boolean, + default: false + } + }, + // NEVER EVER TOUCH DATA INSIDE RENDER + render (h) { + // Pre-process HTML + const { newHtml: html } = preProcessPerLine(this.html, this.greentext) + let currentMentions = null // Current chain of mentions, we group all mentions together + // This is used to recover spacing removed when parsing mentions + let lastSpacing = '' + + const lastTags = [] // Tags that appear at the end of post body + const writtenMentions = [] // All mentions that appear in post body + const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine) + // to collapse too many mentions in a row + const writtenTags = [] // All tags that appear in post body + // unique index for vue "tag" property + let mentionIndex = 0 + let tagsIndex = 0 + + const renderImage = (tag) => { + return <StillImage + {...{ attrs: getAttrs(tag) }} + class="img" + /> + } + + const renderHashtag = (attrs, children, encounteredTextReverse) => { + const linkData = getLinkData(attrs, children, tagsIndex++) + writtenTags.push(linkData) + if (!encounteredTextReverse) { + lastTags.push(linkData) + } + return <HashtagLink {...{ props: linkData }}/> + } + + const renderMention = (attrs, children) => { + const linkData = getLinkData(attrs, children, mentionIndex++) + linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url) + writtenMentions.push(linkData) + if (currentMentions === null) { + currentMentions = [] + } + currentMentions.push(linkData) + if (currentMentions.length > MENTIONS_LIMIT) { + invisibleMentions.push(linkData) + } + if (currentMentions.length === 1) { + return <MentionsLine mentions={ currentMentions } /> + } else { + return '' + } + } + + // Processor to use with html_tree_converter + const processItem = (item, index, array, what) => { + // Handle text nodes - just add emoji + if (typeof item === 'string') { + const emptyText = item.trim() === '' + if (item.includes('\n')) { + currentMentions = null + } + if (emptyText) { + // don't include spaces when processing mentions - we'll include them + // in MentionsLine + lastSpacing = item + // Don't remove last space in a container (fixes poast mentions) + return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item + } + + currentMentions = null + if (item.includes(':')) { + item = ['', processTextForEmoji( + item, + this.emoji, + ({ shortcode, url }) => { + return <StillImage + class="emoji img" + src={url} + title={`:${shortcode}:`} + alt={`:${shortcode}:`} + /> + } + )] + } + return item + } + + // Handle tag nodes + if (Array.isArray(item)) { + const [opener, children, closer] = item + const Tag = getTagName(opener) + const attrs = getAttrs(opener) + const previouslyMentions = currentMentions !== null + /* During grouping of mentions we trim all the empty text elements + * This padding is added to recover last space removed in case + * we have a tag right next to mentions + */ + const mentionsLinePadding = + // Padding is only needed if we just finished parsing mentions + previouslyMentions && + // Don't add padding if content is string and has padding already + !(children && typeof children[0] === 'string' && children[0].match(/^\s/)) + ? lastSpacing + : '' + switch (Tag) { + case 'br': + currentMentions = null + break + case 'img': // replace images with StillImage + return ['', [mentionsLinePadding, renderImage(opener)], ''] + case 'a': // replace mentions with MentionLink + if (!this.handleLinks) break + if (attrs['class'] && attrs['class'].includes('mention')) { + // Handling mentions here + return renderMention(attrs, children) + } else { + currentMentions = null + break + } + case 'span': + if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) { + return ['', children.map(processItem), ''] + } + } + + if (children !== undefined) { + return [ + '', + [ + mentionsLinePadding, + [opener, children.map(processItem), closer] + ], + '' + ] + } else { + return ['', [mentionsLinePadding, item], ''] + } + } + } + + // Processor for back direction (for finding "last" stuff, just easier this way) + let encounteredTextReverse = false + const processItemReverse = (item, index, array, what) => { + // Handle text nodes - just add emoji + if (typeof item === 'string') { + const emptyText = item.trim() === '' + if (emptyText) return item + if (!encounteredTextReverse) encounteredTextReverse = true + return unescape(item) + } else if (Array.isArray(item)) { + // Handle tag nodes + const [opener, children] = item + const Tag = opener === '' ? '' : getTagName(opener) + switch (Tag) { + case 'a': // replace mentions with MentionLink + if (!this.handleLinks) break + const attrs = getAttrs(opener) + // should only be this + if ( + (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style + (attrs['rel'] === 'tag') // Mastodon style + ) { + return renderHashtag(attrs, children, encounteredTextReverse) + } else { + attrs.target = '_blank' + const newChildren = [...children].reverse().map(processItemReverse).reverse() + + return <a {...{ attrs }}> + { newChildren } + </a> + } + case '': + return [...children].reverse().map(processItemReverse).reverse() + } + + // Render tag as is + if (children !== undefined) { + const newChildren = Array.isArray(children) + ? [...children].reverse().map(processItemReverse).reverse() + : children + return <Tag {...{ attrs: getAttrs(opener) }}> + { newChildren } + </Tag> + } else { + return <Tag/> + } + } + return item + } + + const pass1 = convertHtmlToTree(html).map(processItem) + const pass2 = [...pass1].reverse().map(processItemReverse).reverse() + // DO NOT USE SLOTS they cause a re-render feedback loop here. + // slots updated -> rerender -> emit -> update up the tree -> rerender -> ... + // at least until vue3? + const result = <span class="RichContent"> + { pass2 } + </span> + + const event = { + lastTags, + writtenMentions, + writtenTags, + invisibleMentions + } + + // DO NOT MOVE TO UPDATE. BAD IDEA. + this.$emit('parseReady', event) + + return result + } +}) + +const getLinkData = (attrs, children, index) => { + const stripTags = (item) => { + if (typeof item === 'string') { + return item + } else { + return item[1].map(stripTags).join('') + } + } + const textContent = children.map(stripTags).join('') + return { + index, + url: attrs.href, + tag: attrs['data-tag'], + content: flattenDeep(children).join(''), + textContent + } +} + +/** Pre-processing HTML + * + * Currently this does one thing: + * - add green/cyantexting + * + * @param {String} html - raw HTML to process + * @param {Boolean} greentext - whether to enable greentexting or not + */ +export const preProcessPerLine = (html, greentext) => { + const greentextHandle = new Set(['p', 'div']) + + const lines = convertHtmlToLines(html) + const newHtml = lines.reverse().map((item, index, array) => { + if (!item.text) return item + const string = item.text + + // Greentext stuff + if ( + // Only if greentext is engaged + greentext && + // Only handle p's and divs. Don't want to affect blockquotes, code etc + item.level.every(l => greentextHandle.has(l)) && + // Only if line begins with '>' or '<' + (string.includes('>') || string.includes('<')) + ) { + const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags + .replace(/@\w+/gi, '') // remove mentions (even failed ones) + .trim() + if (cleanedString.startsWith('>')) { + return `<span class='greentext'>${string}</span>` + } else if (cleanedString.startsWith('<')) { + return `<span class='cyantext'>${string}</span>` + } + } + + return string + }).reverse().join('') + + return { newHtml } +} diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss new file mode 100644 index 00000000..db08ef1e --- /dev/null +++ b/src/components/rich_content/rich_content.scss @@ -0,0 +1,64 @@ +.RichContent { + blockquote { + margin: 0.2em 0 0.2em 2em; + font-style: italic; + } + + pre { + overflow: auto; + } + + code, + samp, + kbd, + var, + pre { + font-family: var(--postCodeFont, monospace); + } + + p { + margin: 0 0 1em 0; + } + + p:last-child { + margin: 0 0 0 0; + } + + h1 { + font-size: 1.1em; + line-height: 1.2em; + margin: 1.4em 0; + } + + h2 { + font-size: 1.1em; + margin: 1em 0; + } + + h3 { + font-size: 1em; + margin: 1.2em 0; + } + + h4 { + margin: 1.1em 0; + } + + .img { + display: inline-block; + } + + .emoji { + display: inline-block; + width: var(--emoji-size, 32px); + height: var(--emoji-size, 32px); + } + + .img, + video { + max-width: 100%; + max-height: 400px; + vertical-align: middle; + object-fit: contain; + } +} diff --git a/src/components/scope_selector/scope_selector.vue b/src/components/scope_selector/scope_selector.vue index 66ac612e..a01242fc 100644 --- a/src/components/scope_selector/scope_selector.vue +++ b/src/components/scope_selector/scope_selector.vue @@ -8,6 +8,7 @@ class="button-unstyled scope" :class="css.direct" :title="$t('post_status.scope.direct')" + type="button" @click="changeVis('direct')" > <FAIcon @@ -20,6 +21,7 @@ class="button-unstyled scope" :class="css.private" :title="$t('post_status.scope.private')" + type="button" @click="changeVis('private')" > <FAIcon @@ -32,6 +34,7 @@ class="button-unstyled scope" :class="css.unlisted" :title="$t('post_status.scope.unlisted')" + type="button" @click="changeVis('unlisted')" > <FAIcon @@ -44,6 +47,7 @@ class="button-unstyled scope" :class="css.public" :title="$t('post_status.scope.public')" + type="button" @click="changeVis('public')" > <FAIcon diff --git a/src/components/search/search.vue b/src/components/search/search.vue index a6503c9f..b7bfc1f3 100644 --- a/src/components/search/search.vue +++ b/src/components/search/search.vue @@ -15,6 +15,7 @@ > <button class="btn button-default search-button" + type="submit" @click="newQuery(searchTerm)" > <FAIcon icon="search" /> diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue index 6cf9179e..222f57ba 100644 --- a/src/components/search_bar/search_bar.vue +++ b/src/components/search_bar/search_bar.vue @@ -7,6 +7,7 @@ v-if="hidden" class="button-unstyled nav-icon" :title="$t('nav.search')" + type="button" @click.prevent.stop="toggleHidden" > <FAIcon @@ -27,6 +28,7 @@ > <button class="button-default search-button" + type="submit" @click="find(searchTerm)" > <FAIcon @@ -36,6 +38,7 @@ </button> <button class="button-unstyled cancel-search" + type="button" @click.prevent.stop="toggleHidden" > <FAIcon diff --git a/src/components/select/select.js b/src/components/select/select.js new file mode 100644 index 00000000..49535d07 --- /dev/null +++ b/src/components/select/select.js @@ -0,0 +1,21 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faChevronDown +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faChevronDown +) + +export default { + model: { + prop: 'value', + event: 'change' + }, + props: [ + 'value', + 'disabled', + 'unstyled', + 'kind' + ] +} diff --git a/src/components/select/select.vue b/src/components/select/select.vue new file mode 100644 index 00000000..5ade1fa6 --- /dev/null +++ b/src/components/select/select.vue @@ -0,0 +1,62 @@ + +<template> + <label + class="Select input" + :class="{ disabled, unstyled }" + > + <select + :disabled="disabled" + :value="value" + @change="$emit('change', $event.target.value)" + > + <slot /> + </select> + <FAIcon + class="select-down-icon" + icon="chevron-down" + /> + </label> +</template> + +<script src="./select.js"> </script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.Select { + padding: 0; + + select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: transparent; + border: none; + color: $fallback--text; + color: var(--inputText, --text, $fallback--text); + margin: 0; + padding: 0 2em 0 .2em; + font-family: sans-serif; + font-family: var(--inputFont, sans-serif); + font-size: 14px; + width: 100%; + z-index: 1; + height: 28px; + line-height: 16px; + } + + .select-down-icon { + position: absolute; + top: 0; + bottom: 0; + right: 5px; + height: 100%; + color: $fallback--text; + color: var(--inputText, $fallback--text); + line-height: 28px; + z-index: 0; + pointer-events: none; + } + +} +</style> diff --git a/src/components/selectable_list/selectable_list.vue b/src/components/selectable_list/selectable_list.vue index a9bb12a1..3f885881 100644 --- a/src/components/selectable_list/selectable_list.vue +++ b/src/components/selectable_list/selectable_list.vue @@ -24,10 +24,7 @@ :items="items" :get-key="getKey" > - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <div class="selectable-list-item-inner" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }" @@ -44,7 +41,7 @@ /> </div> </template> - <template slot="empty"> + <template v-slot:empty> <slot name="empty" /> </template> </List> diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js new file mode 100644 index 00000000..5c52f697 --- /dev/null +++ b/src/components/settings_modal/helpers/boolean_setting.js @@ -0,0 +1,38 @@ +import { get, set } from 'lodash' +import Checkbox from 'src/components/checkbox/checkbox.vue' +import ModifiedIndicator from './modified_indicator.vue' +export default { + components: { + Checkbox, + ModifiedIndicator + }, + props: [ + 'path', + 'disabled' + ], + computed: { + pathDefault () { + const [firstSegment, ...rest] = this.path.split('.') + return [firstSegment + 'DefaultValue', ...rest].join('.') + }, + state () { + const value = get(this.$parent, this.path) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + defaultState () { + return get(this.$parent, this.pathDefault) + }, + isChanged () { + return this.state !== this.defaultState + } + }, + methods: { + update (e) { + set(this.$parent, this.path, e) + } + } +} diff --git a/src/components/settings_modal/helpers/boolean_setting.vue b/src/components/settings_modal/helpers/boolean_setting.vue new file mode 100644 index 00000000..c3ee6583 --- /dev/null +++ b/src/components/settings_modal/helpers/boolean_setting.vue @@ -0,0 +1,21 @@ +<template> + <label + class="BooleanSetting" + > + <Checkbox + :checked="state" + :disabled="disabled" + @change="update" + > + <span + v-if="!!$slots.default" + class="label" + > + <slot /> + </span> + <ModifiedIndicator :changed="isChanged" /> + </Checkbox> + </label> +</template> + +<script src="./boolean_setting.js"></script> diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js new file mode 100644 index 00000000..a15f6bac --- /dev/null +++ b/src/components/settings_modal/helpers/choice_setting.js @@ -0,0 +1,39 @@ +import { get, set } from 'lodash' +import Select from 'src/components/select/select.vue' +import ModifiedIndicator from './modified_indicator.vue' +export default { + components: { + Select, + ModifiedIndicator + }, + props: [ + 'path', + 'disabled', + 'options' + ], + computed: { + pathDefault () { + const [firstSegment, ...rest] = this.path.split('.') + return [firstSegment + 'DefaultValue', ...rest].join('.') + }, + state () { + const value = get(this.$parent, this.path) + if (value === undefined) { + return this.defaultState + } else { + return value + } + }, + defaultState () { + return get(this.$parent, this.pathDefault) + }, + isChanged () { + return this.state !== this.defaultState + } + }, + methods: { + update (e) { + set(this.$parent, this.path, e) + } + } +} diff --git a/src/components/settings_modal/helpers/choice_setting.vue b/src/components/settings_modal/helpers/choice_setting.vue new file mode 100644 index 00000000..fa17661b --- /dev/null +++ b/src/components/settings_modal/helpers/choice_setting.vue @@ -0,0 +1,29 @@ +<template> + <label + class="ChoiceSetting" + > + <slot /> + <Select + :value="state" + :disabled="disabled" + @change="update" + > + <option + v-for="option in options" + :key="option.key" + :value="option.value" + > + {{ option.label }} + {{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }} + </option> + </Select> + <ModifiedIndicator :changed="isChanged" /> + </label> +</template> + +<script src="./choice_setting.js"></script> + +<style lang="scss"> +.ChoiceSetting { +} +</style> diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/src/components/settings_modal/helpers/modified_indicator.vue new file mode 100644 index 00000000..ad212db9 --- /dev/null +++ b/src/components/settings_modal/helpers/modified_indicator.vue @@ -0,0 +1,51 @@ +<template> + <span + v-if="changed" + class="ModifiedIndicator" + > + <Popover + trigger="hover" + > + <template v-slot:trigger> + + <FAIcon + icon="wrench" + :aria-label="$t('settings.setting_changed')" + /> + </template> + <template v-slot:content> + <div class="modified-tooltip"> + {{ $t('settings.setting_changed') }} + </div> + </template> + </Popover> + </span> +</template> + +<script> +import Popover from 'src/components/popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faWrench +) + +export default { + components: { Popover }, + props: ['changed'] +} +</script> + +<style lang="scss"> +.ModifiedIndicator { + display: inline-block; + position: relative; + + .modified-tooltip { + margin: 0.5em 1em; + min-width: 10em; + text-align: center; + } +} +</style> diff --git a/src/components/settings_modal/helpers/shared_computed_object.js b/src/components/settings_modal/helpers/shared_computed_object.js index 86703697..2c833c0c 100644 --- a/src/components/settings_modal/helpers/shared_computed_object.js +++ b/src/components/settings_modal/helpers/shared_computed_object.js @@ -1,29 +1,15 @@ -import { - instanceDefaultProperties, - multiChoiceProperties, - defaultState as configDefaultState -} from 'src/modules/config.js' +import { defaultState as configDefaultState } from 'src/modules/config.js' const SharedComputedObject = () => ({ user () { return this.$store.state.users.currentUser }, - // Getting localized values for instance-default properties - ...instanceDefaultProperties - .filter(key => multiChoiceProperties.includes(key)) + // Getting values for default properties + ...Object.keys(configDefaultState) .map(key => [ key + 'DefaultValue', function () { - return this.$store.getters.instanceDefaultConfig[key] - } - ]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), - ...instanceDefaultProperties - .filter(key => !multiChoiceProperties.includes(key)) - .map(key => [ - key + 'LocalizedValue', - function () { - return this.$t('settings.values.' + this.$store.getters.instanceDefaultConfig[key]) + return this.$store.getters.defaultConfig[key] } ]) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}), diff --git a/src/components/settings_modal/settings_modal.js b/src/components/settings_modal/settings_modal.js index f0d49c91..04043483 100644 --- a/src/components/settings_modal/settings_modal.js +++ b/src/components/settings_modal/settings_modal.js @@ -2,10 +2,55 @@ import Modal from 'src/components/modal/modal.vue' import PanelLoading from 'src/components/panel_loading/panel_loading.vue' import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue' import getResettableAsyncComponent from 'src/services/resettable_async_component.js' +import Popover from '../popover/popover.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { cloneDeep } from 'lodash' +import { + newImporter, + newExporter +} from 'src/services/export_import/export_import.js' +import { + faTimes, + faFileUpload, + faFileDownload, + faChevronDown +} from '@fortawesome/free-solid-svg-icons' +import { + faWindowMinimize +} from '@fortawesome/free-regular-svg-icons' + +const PLEROMAFE_SETTINGS_MAJOR_VERSION = 1 +const PLEROMAFE_SETTINGS_MINOR_VERSION = 0 + +library.add( + faTimes, + faWindowMinimize, + faFileUpload, + faFileDownload, + faChevronDown +) const SettingsModal = { + data () { + return { + dataImporter: newImporter({ + validator: this.importValidator, + onImport: this.onImport, + onImportFailure: this.onImportFailure + }), + dataThemeExporter: newExporter({ + filename: 'pleromafe_settings.full', + getExportedObject: () => this.generateExport(true) + }), + dataExporter: newExporter({ + filename: 'pleromafe_settings', + getExportedObject: () => this.generateExport() + }) + } + }, components: { Modal, + Popover, SettingsModalContent: getResettableAsyncComponent( () => import('./settings_modal_content.vue'), { @@ -21,6 +66,85 @@ const SettingsModal = { }, peekModal () { this.$store.dispatch('togglePeekSettingsModal') + }, + importValidator (data) { + if (!Array.isArray(data._pleroma_settings_version)) { + return { + messageKey: 'settings.file_import_export.invalid_file' + } + } + + const [major, minor] = data._pleroma_settings_version + + if (major > PLEROMAFE_SETTINGS_MAJOR_VERSION) { + return { + messageKey: 'settings.file_export_import.errors.file_too_new', + messageArgs: { + fileMajor: major, + feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION + } + } + } + + if (major < PLEROMAFE_SETTINGS_MAJOR_VERSION) { + return { + messageKey: 'settings.file_export_import.errors.file_too_old', + messageArgs: { + fileMajor: major, + feMajor: PLEROMAFE_SETTINGS_MAJOR_VERSION + } + } + } + + if (minor > PLEROMAFE_SETTINGS_MINOR_VERSION) { + this.$store.dispatch('pushGlobalNotice', { + level: 'warning', + messageKey: 'settings.file_export_import.errors.file_slightly_new' + }) + } + + return true + }, + onImportFailure (result) { + if (result.error) { + this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_settings_imported', level: 'error' }) + } else { + this.$store.dispatch('pushGlobalNotice', { ...result.validationResult, level: 'error' }) + } + }, + onImport (data) { + if (data) { this.$store.dispatch('loadSettings', data) } + }, + restore () { + this.dataImporter.importData() + }, + backup () { + this.dataExporter.exportData() + }, + backupWithTheme () { + this.dataThemeExporter.exportData() + }, + generateExport (theme = false) { + const { config } = this.$store.state + let sample = config + if (!theme) { + const ignoreList = new Set([ + 'customTheme', + 'customThemeSource', + 'colors' + ]) + sample = Object.fromEntries( + Object + .entries(sample) + .filter(([key]) => !ignoreList.has(key)) + ) + } + const clone = cloneDeep(sample) + clone._pleroma_settings_version = [ + PLEROMAFE_SETTINGS_MAJOR_VERSION, + PLEROMAFE_SETTINGS_MINOR_VERSION + ] + return clone } }, computed: { diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue index 552ca41f..583c2ecc 100644 --- a/src/components/settings_modal/settings_modal.vue +++ b/src/components/settings_modal/settings_modal.vue @@ -31,20 +31,84 @@ </transition> <button class="btn button-default" + :title="$t('general.peek')" @click="peekModal" > - {{ $t('general.peek') }} + <FAIcon + :icon="['far', 'window-minimize']" + fixed-width + /> </button> <button class="btn button-default" + :title="$t('general.close')" @click="closeModal" > - {{ $t('general.close') }} + <FAIcon + icon="times" + fixed-width + /> </button> </div> <div class="panel-body"> <SettingsModalContent v-if="modalOpenedOnce" /> </div> + <div class="panel-footer"> + <Popover + class="export" + trigger="click" + placement="top" + :offset="{ y: 5, x: 5 }" + :bound-to="{ x: 'container' }" + remove-padding + > + <template v-slot:trigger> + <button + class="btn button-default" + :title="$t('general.close')" + > + <span>{{ $t("settings.file_export_import.backup_restore") }}</span> + <FAIcon + icon="chevron-down" + /> + </button> + </template> + <template v-slot:content="{close}"> + <div class="dropdown-menu"> + <button + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="backup" + @click="close" + > + <FAIcon + icon="file-download" + fixed-width + /><span>{{ $t("settings.file_export_import.backup_settings") }}</span> + </button> + <button + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="backupWithTheme" + @click="close" + > + <FAIcon + icon="file-download" + fixed-width + /><span>{{ $t("settings.file_export_import.backup_settings_theme") }}</span> + </button> + <button + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="restore" + @click="close" + > + <FAIcon + icon="file-upload" + fixed-width + /><span>{{ $t("settings.file_export_import.restore_settings") }}</span> + </button> + </div> + </template> + </Popover> + </div> </div> </Modal> </template> diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss index f066234c..81ab434b 100644 --- a/src/components/settings_modal/settings_modal_content.scss +++ b/src/components/settings_modal/settings_modal_content.scss @@ -7,13 +7,24 @@ margin: 1em 1em 1.4em; padding-bottom: 1.4em; - > div { + > div, + > label { + display: block; margin-bottom: .5em; &:last-child { margin-bottom: 0; } } + .select-multiple { + display: flex; + + .option-list { + margin: 0; + padding-left: .5em; + } + } + &:last-child { border-bottom: none; padding-bottom: 0; diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js index 5f38a5ae..4eaf4217 100644 --- a/src/components/settings_modal/tabs/filtering_tab.js +++ b/src/components/settings_modal/tabs/filtering_tab.js @@ -1,24 +1,23 @@ import { filter, trim } from 'lodash' -import Checkbox from 'src/components/checkbox/checkbox.vue' +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faChevronDown -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faChevronDown -) const FilteringTab = { data () { return { - muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n') + muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n'), + replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.reply_visibility_${mode}`) + })) } }, components: { - Checkbox + BooleanSetting, + ChoiceSetting }, computed: { ...SharedComputedObject(), diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue index 8f850c8b..50ee20e0 100644 --- a/src/components/settings_modal/tabs/filtering_tab.vue +++ b/src/components/settings_modal/tabs/filtering_tab.vue @@ -1,89 +1,138 @@ <template> <div :label="$t('settings.filtering')"> <div class="setting-item"> - <div class="select-multiple"> - <span class="label">{{ $t('settings.notification_visibility') }}</span> - <ul class="option-list"> - <li> - <Checkbox v-model="notificationVisibility.likes"> - {{ $t('settings.notification_visibility_likes') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.repeats"> - {{ $t('settings.notification_visibility_repeats') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.follows"> - {{ $t('settings.notification_visibility_follows') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.mentions"> - {{ $t('settings.notification_visibility_mentions') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.moves"> - {{ $t('settings.notification_visibility_moves') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="notificationVisibility.emojiReactions"> - {{ $t('settings.notification_visibility_emoji_reactions') }} - </Checkbox> - </li> - </ul> - </div> - <div> - {{ $t('settings.replies_in_timeline') }} - <label - for="replyVisibility" - class="select" - > - <select - id="replyVisibility" - v-model="replyVisibility" + <h2>{{ $t('settings.posts') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path="hideFilteredStatuses"> + {{ $t('settings.hide_filtered_statuses') }} + </BooleanSetting> + <ul + class="setting-list suboptions" + :class="[{disabled: !streaming}]" > - <option - value="all" - selected - >{{ $t('settings.reply_visibility_all') }}</option> - <option value="following">{{ $t('settings.reply_visibility_following') }}</option> - <option value="self">{{ $t('settings.reply_visibility_self') }}</option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" + <li> + <BooleanSetting + :disabled="hideFilteredStatuses" + path="hideWordFilteredPosts" + > + {{ $t('settings.hide_wordfiltered_statuses') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + :disabled="hideFilteredStatuses" + path="hideMutedThreads" + > + {{ $t('settings.hide_muted_threads') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting + :disabled="hideFilteredStatuses" + path="hideMutedPosts" + > + {{ $t('settings.hide_muted_posts') }} + </BooleanSetting> + </li> + </ul> + </li> + <li> + <BooleanSetting path="hidePostStats"> + {{ $t('settings.hide_post_stats') }} + </BooleanSetting> + </li> + <ChoiceSetting + id="replyVisibility" + path="replyVisibility" + :options="replyVisibilityOptions" + > + {{ $t('settings.replies_in_timeline') }} + </ChoiceSetting> + <li> + <h3>{{ $t('settings.wordfilter') }}</h3> + <textarea + id="muteWords" + v-model="muteWordsString" + class="resize-height" /> - </label> - </div> - <div> - <Checkbox v-model="hidePostStats"> - {{ $t('settings.hide_post_stats') }} {{ $t('settings.instance_default', { value: hidePostStatsLocalizedValue }) }} - </Checkbox> - </div> - <div> - <Checkbox v-model="hideUserStats"> - {{ $t('settings.hide_user_stats') }} {{ $t('settings.instance_default', { value: hideUserStatsLocalizedValue }) }} - </Checkbox> - </div> + <div>{{ $t('settings.filtering_explanation') }}</div> + </li> + <h3>{{ $t('settings.attachments') }}</h3> + <li> + <label for="maxThumbnails"> + {{ $t('settings.max_thumbnails') }} + </label> + <input + id="maxThumbnails" + path.number="maxThumbnails" + class="number-input" + type="number" + min="0" + step="1" + > + </li> + <li> + <BooleanSetting path="hideAttachments"> + {{ $t('settings.hide_attachments_in_tl') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="hideAttachmentsInConv"> + {{ $t('settings.hide_attachments_in_convo') }} + </BooleanSetting> + </li> + </ul> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.user_profiles') }}</h2> + <ul class="setting-list"> + <li> + <BooleanSetting path="hideUserStats"> + {{ $t('settings.hide_user_stats') }} + </BooleanSetting> + </li> + </ul> </div> <div class="setting-item"> - <div> - <p>{{ $t('settings.filtering_explanation') }}</p> - <textarea - id="muteWords" - class="resize-height" - v-model="muteWordsString" - /> - </div> - <div> - <Checkbox v-model="hideFilteredStatuses"> - {{ $t('settings.hide_filtered_statuses') }} {{ $t('settings.instance_default', { value: hideFilteredStatusesLocalizedValue }) }} - </Checkbox> - </div> + <h2>{{ $t('settings.notifications') }}</h2> + <ul class="setting-list"> + <li class="select-multiple"> + <span class="label">{{ $t('settings.notification_visibility') }}</span> + <ul class="option-list"> + <li> + <BooleanSetting path="notificationVisibility.likes"> + {{ $t('settings.notification_visibility_likes') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.repeats"> + {{ $t('settings.notification_visibility_repeats') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.follows"> + {{ $t('settings.notification_visibility_follows') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.mentions"> + {{ $t('settings.notification_visibility_mentions') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.moves"> + {{ $t('settings.notification_visibility_moves') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="notificationVisibility.emojiReactions"> + {{ $t('settings.notification_visibility_emoji_reactions') }} + </BooleanSetting> + </li> + </ul> + </li> + </ul> </div> </div> </template> diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index 029ee7a1..952c328d 100644 --- a/src/components/settings_modal/tabs/general_tab.js +++ b/src/components/settings_modal/tabs/general_tab.js @@ -1,21 +1,30 @@ -import Checkbox from 'src/components/checkbox/checkbox.vue' +import BooleanSetting from '../helpers/boolean_setting.vue' +import ChoiceSetting from '../helpers/choice_setting.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' import { library } from '@fortawesome/fontawesome-svg-core' import { - faChevronDown, faGlobe } from '@fortawesome/free-solid-svg-icons' library.add( - faChevronDown, faGlobe ) const GeneralTab = { data () { return { + subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`) + })), + mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({ + key: mode, + value: mode, + label: this.$t(`settings.mention_link_display_${mode}`) + })), loopSilentAvailable: // Firefox Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || @@ -26,18 +35,27 @@ const GeneralTab = { } }, components: { - Checkbox, + BooleanSetting, + ChoiceSetting, InterfaceLanguageSwitcher }, computed: { postFormats () { return this.$store.state.instance.postFormats || [] }, + postContentOptions () { + return this.postFormats.map(format => ({ + key: format, + value: format, + label: this.$t(`post_status.content_type["${format}"]`) + })) + }, instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, instanceWallpaperUsed () { return this.$store.state.instance.background && !this.$store.state.users.currentUser.background_image }, + instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable }, ...SharedComputedObject() } } diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index a9081793..eba3b268 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -7,228 +7,126 @@ <interface-language-switcher /> </li> <li v-if="instanceSpecificPanelPresent"> - <Checkbox v-model="hideISP"> + <BooleanSetting path="hideISP"> {{ $t('settings.hide_isp') }} - </Checkbox> + </BooleanSetting> + </li> + <li> + <BooleanSetting path="sidebarRight"> + {{ $t('settings.right_sidebar') }} + </BooleanSetting> </li> <li v-if="instanceWallpaperUsed"> - <Checkbox v-model="hideInstanceWallpaper"> + <BooleanSetting path="hideInstanceWallpaper"> {{ $t('settings.hide_wallpaper') }} - </Checkbox> + </BooleanSetting> </li> - </ul> - </div> - <div class="setting-item"> - <h2>{{ $t('nav.timeline') }}</h2> - <ul class="setting-list"> <li> - <Checkbox v-model="hideMutedPosts"> - {{ $t('settings.hide_muted_posts') }} {{ $t('settings.instance_default', { value: hideMutedPostsLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="collapseMessageWithSubject"> - {{ $t('settings.collapse_subject') }} {{ $t('settings.instance_default', { value: collapseMessageWithSubjectLocalizedValue }) }} - </Checkbox> + <BooleanSetting path="stopGifs"> + {{ $t('settings.stop_gifs') }} + </BooleanSetting> </li> <li> - <Checkbox v-model="streaming"> + <BooleanSetting path="streaming"> {{ $t('settings.streaming') }} - </Checkbox> + </BooleanSetting> <ul class="setting-list suboptions" :class="[{disabled: !streaming}]" > <li> - <Checkbox - v-model="pauseOnUnfocused" + <BooleanSetting + path="pauseOnUnfocused" :disabled="!streaming" > {{ $t('settings.pause_on_unfocused') }} - </Checkbox> + </BooleanSetting> </li> </ul> </li> <li> - <Checkbox v-model="useStreamingApi"> + <BooleanSetting path="useStreamingApi"> {{ $t('settings.useStreamingApi') }} <br> <small> {{ $t('settings.useStreamingApiWarning') }} </small> - </Checkbox> - </li> - <li> - <Checkbox v-model="emojiReactionsOnTimeline"> - {{ $t('settings.emoji_reactions_on_timeline') }} - </Checkbox> + </BooleanSetting> </li> <li> - <Checkbox v-model="virtualScrolling"> + <BooleanSetting path="virtualScrolling"> {{ $t('settings.virtual_scrolling') }} - </Checkbox> - </li> - </ul> - </div> - - <div class="setting-item"> - <h2>{{ $t('settings.composing') }}</h2> - <ul class="setting-list"> - <li> - <Checkbox v-model="scopeCopy"> - {{ $t('settings.scope_copy') }} {{ $t('settings.instance_default', { value: scopeCopyLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="alwaysShowSubjectInput"> - {{ $t('settings.subject_input_always_show') }} {{ $t('settings.instance_default', { value: alwaysShowSubjectInputLocalizedValue }) }} - </Checkbox> + </BooleanSetting> </li> <li> - <div> - {{ $t('settings.subject_line_behavior') }} - <label - for="subjectLineBehavior" - class="select" - > - <select - id="subjectLineBehavior" - v-model="subjectLineBehavior" - > - <option value="email"> - {{ $t('settings.subject_line_email') }} - {{ subjectLineBehaviorDefaultValue == 'email' ? $t('settings.instance_default_simple') : '' }} - </option> - <option value="masto"> - {{ $t('settings.subject_line_mastodon') }} - {{ subjectLineBehaviorDefaultValue == 'mastodon' ? $t('settings.instance_default_simple') : '' }} - </option> - <option value="noop"> - {{ $t('settings.subject_line_noop') }} - {{ subjectLineBehaviorDefaultValue == 'noop' ? $t('settings.instance_default_simple') : '' }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> - </div> - </li> - <li v-if="postFormats.length > 0"> - <div> - {{ $t('settings.post_status_content_type') }} - <label - for="postContentType" - class="select" - > - <select - id="postContentType" - v-model="postContentType" - > - <option - v-for="postFormat in postFormats" - :key="postFormat" - :value="postFormat" - > - {{ $t(`post_status.content_type["${postFormat}"]`) }} - {{ postContentTypeDefaultValue === postFormat ? $t('settings.instance_default_simple') : '' }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> - </div> - </li> - <li> - <Checkbox v-model="minimalScopesMode"> - {{ $t('settings.minimal_scopes_mode') }} {{ $t('settings.instance_default', { value: minimalScopesModeLocalizedValue }) }} - </Checkbox> - </li> - <li> - <Checkbox v-model="autohideFloatingPostButton"> + <BooleanSetting path="autohideFloatingPostButton"> {{ $t('settings.autohide_floating_post_button') }} - </Checkbox> + </BooleanSetting> </li> - <li> - <Checkbox v-model="padEmoji"> - {{ $t('settings.pad_emoji') }} - </Checkbox> + <li v-if="instanceShoutboxPresent"> + <BooleanSetting path="hideShoutbox"> + {{ $t('settings.hide_shoutbox') }} + </BooleanSetting> </li> </ul> </div> - <div class="setting-item"> - <h2>{{ $t('settings.attachments') }}</h2> + <h2>{{ $t('settings.post_look_feel') }}</h2> <ul class="setting-list"> <li> - <Checkbox v-model="hideAttachments"> - {{ $t('settings.hide_attachments_in_tl') }} - </Checkbox> + <BooleanSetting path="collapseMessageWithSubject"> + {{ $t('settings.collapse_subject') }} + </BooleanSetting> </li> <li> - <Checkbox v-model="hideAttachmentsInConv"> - {{ $t('settings.hide_attachments_in_convo') }} - </Checkbox> + <BooleanSetting path="emojiReactionsOnTimeline"> + {{ $t('settings.emoji_reactions_on_timeline') }} + </BooleanSetting> </li> + <h3>{{ $t('settings.attachments') }}</h3> <li> - <label for="maxThumbnails"> - {{ $t('settings.max_thumbnails') }} - </label> - <input - id="maxThumbnails" - v-model.number="maxThumbnails" - class="number-input" - type="number" - min="0" - step="1" - > + <BooleanSetting path="useContainFit"> + {{ $t('settings.use_contain_fit') }} + </BooleanSetting> </li> <li> - <Checkbox v-model="hideNsfw"> + <BooleanSetting path="hideNsfw"> {{ $t('settings.nsfw_clickthrough') }} - </Checkbox> + </BooleanSetting> </li> <ul class="setting-list suboptions"> <li> - <Checkbox - v-model="preloadImage" + <BooleanSetting + path="preloadImage" :disabled="!hideNsfw" > {{ $t('settings.preload_images') }} - </Checkbox> + </BooleanSetting> </li> <li> - <Checkbox - v-model="useOneClickNsfw" + <BooleanSetting + path="useOneClickNsfw" :disabled="!hideNsfw" > {{ $t('settings.use_one_click_nsfw') }} - </Checkbox> + </BooleanSetting> </li> </ul> <li> - <Checkbox v-model="stopGifs"> - {{ $t('settings.stop_gifs') }} - </Checkbox> - </li> - <li> - <Checkbox v-model="loopVideo"> + <BooleanSetting path="loopVideo"> {{ $t('settings.loop_video') }} - </Checkbox> + </BooleanSetting> <ul class="setting-list suboptions" :class="[{disabled: !streaming}]" > <li> - <Checkbox - v-model="loopVideoSilentOnly" + <BooleanSetting + path="loopVideoSilentOnly" :disabled="!loopVideo || !loopSilentAvailable" > {{ $t('settings.loop_video_silent_only') }} - </Checkbox> + </BooleanSetting> <div v-if="!loopSilentAvailable" class="unavailable" @@ -239,36 +137,130 @@ </ul> </li> <li> - <Checkbox v-model="playVideosInModal"> + <BooleanSetting path="playVideosInModal"> {{ $t('settings.play_videos_in_modal') }} - </Checkbox> + </BooleanSetting> </li> + <h3>{{ $t('settings.fun') }}</h3> <li> - <Checkbox v-model="useContainFit"> - {{ $t('settings.use_contain_fit') }} - </Checkbox> + <BooleanSetting path="greentext"> + {{ $t('settings.greentext') }} + </BooleanSetting> </li> + <li> + <BooleanSetting path="mentionLinkShowYous"> + {{ $t('settings.show_yous') }} + </BooleanSetting> + </li> + <li> + <ChoiceSetting + id="mentionLinkDisplay" + path="mentionLinkDisplay" + :options="mentionLinkDisplayOptions" + > + {{ $t('settings.mention_link_display') }} + </ChoiceSetting> + </li> + <ul + class="setting-list suboptions" + > + <li + v-if="mentionLinkDisplay === 'short'" + > + <BooleanSetting path="mentionLinkShowTooltip"> + {{ $t('settings.mention_link_show_tooltip') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="useAtIcon"> + {{ $t('settings.use_at_icon') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="mentionLinkShowAvatar"> + {{ $t('settings.mention_link_show_avatar') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="mentionLinkFadeDomain"> + {{ $t('settings.mention_link_fade_domain') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="mentionLinkBoldenYou"> + {{ $t('settings.mention_link_bolden_you') }} + </BooleanSetting> + </li> + </ul> </ul> </div> <div class="setting-item"> - <h2>{{ $t('settings.notifications') }}</h2> + <h2>{{ $t('settings.composing') }}</h2> <ul class="setting-list"> <li> - <Checkbox v-model="webPushNotifications"> - {{ $t('settings.enable_web_push_notifications') }} - </Checkbox> + <BooleanSetting path="scopeCopy"> + {{ $t('settings.scope_copy') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="alwaysShowSubjectInput"> + {{ $t('settings.subject_input_always_show') }} + </BooleanSetting> + </li> + <li> + <ChoiceSetting + id="subjectLineBehavior" + path="subjectLineBehavior" + :options="subjectLineOptions" + > + {{ $t('settings.subject_line_behavior') }} + </ChoiceSetting> + </li> + <li v-if="postFormats.length > 0"> + <ChoiceSetting + id="postContentType" + path="postContentType" + :options="postContentOptions" + > + {{ $t('settings.post_status_content_type') }} + </ChoiceSetting> + </li> + <li> + <BooleanSetting path="minimalScopesMode"> + {{ $t('settings.minimal_scopes_mode') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="sensitiveByDefault"> + {{ $t('settings.sensitive_by_default') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="alwaysShowNewPostButton"> + {{ $t('settings.always_show_post_button') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="autohideFloatingPostButton"> + {{ $t('settings.autohide_floating_post_button') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="padEmoji"> + {{ $t('settings.pad_emoji') }} + </BooleanSetting> </li> </ul> </div> <div class="setting-item"> - <h2>{{ $t('settings.fun') }}</h2> + <h2>{{ $t('settings.notifications') }}</h2> <ul class="setting-list"> <li> - <Checkbox v-model="greentext"> - {{ $t('settings.greentext') }} {{ $t('settings.instance_default', { value: greentextLocalizedValue }) }} - </Checkbox> + <BooleanSetting path="webPushNotifications"> + {{ $t('settings.enable_web_push_notifications') }} + </BooleanSetting> </li> </ul> </div> diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue index 63d36bf9..32a21415 100644 --- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue +++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue @@ -10,20 +10,18 @@ :query="queryUserIds" :placeholder="$t('settings.search_user_to_block')" > - <BlockCard - slot-scope="row" - :user-id="row.item" - /> + <template v-slot="row"> + <BlockCard + :user-id="row.item" + /> + </template> </Autosuggest> </div> <BlockList :refresh="true" :get-key="i => i" > - <template - slot="header" - slot-scope="{selected}" - > + <template v-slot:header="{selected}"> <div class="bulk-actions"> <ProgressButton v-if="selected.length > 0" @@ -31,7 +29,7 @@ :click="() => blockUsers(selected)" > {{ $t('user_card.block') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('user_card.block_progress') }} </template> </ProgressButton> @@ -41,19 +39,16 @@ :click="() => unblockUsers(selected)" > {{ $t('user_card.unblock') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('user_card.unblock_progress') }} </template> </ProgressButton> </div> </template> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <BlockCard :user-id="item" /> </template> - <template slot="empty"> + <template v-slot:empty> {{ $t('settings.no_blocks') }} </template> </BlockList> @@ -68,20 +63,18 @@ :query="queryUserIds" :placeholder="$t('settings.search_user_to_mute')" > - <MuteCard - slot-scope="row" - :user-id="row.item" - /> + <template v-slot="row"> + <MuteCard + :user-id="row.item" + /> + </template> </Autosuggest> </div> <MuteList :refresh="true" :get-key="i => i" > - <template - slot="header" - slot-scope="{selected}" - > + <template v-slot:header="{selected}"> <div class="bulk-actions"> <ProgressButton v-if="selected.length > 0" @@ -89,7 +82,7 @@ :click="() => muteUsers(selected)" > {{ $t('user_card.mute') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('user_card.mute_progress') }} </template> </ProgressButton> @@ -99,19 +92,16 @@ :click="() => unmuteUsers(selected)" > {{ $t('user_card.unmute') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('user_card.unmute_progress') }} </template> </ProgressButton> </div> </template> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <MuteCard :user-id="item" /> </template> - <template slot="empty"> + <template v-slot:empty> {{ $t('settings.no_mutes') }} </template> </MuteList> @@ -124,20 +114,18 @@ :query="queryKnownDomains" :placeholder="$t('settings.type_domains_to_mute')" > - <DomainMuteCard - slot-scope="row" - :domain="row.item" - /> + <template v-slot="row"> + <DomainMuteCard + :domain="row.item" + /> + </template> </Autosuggest> </div> <DomainMuteList :refresh="true" :get-key="i => i" > - <template - slot="header" - slot-scope="{selected}" - > + <template v-slot:header="{selected}"> <div class="bulk-actions"> <ProgressButton v-if="selected.length > 0" @@ -145,19 +133,16 @@ :click="() => unmuteDomains(selected)" > {{ $t('domain_mute_card.unmute') }} - <template slot="progress"> + <template v-slot:progress> {{ $t('domain_mute_card.unmute_progress') }} </template> </ProgressButton> </div> </template> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <DomainMuteCard :domain="item" /> </template> - <template slot="empty"> + <template v-slot:empty> {{ $t('settings.no_mutes') }} </template> </DomainMuteList> diff --git a/src/components/settings_modal/tabs/notifications_tab.vue b/src/components/settings_modal/tabs/notifications_tab.vue index 8f8fe48e..7e0568ea 100644 --- a/src/components/settings_modal/tabs/notifications_tab.vue +++ b/src/components/settings_modal/tabs/notifications_tab.vue @@ -24,7 +24,7 @@ class="btn button-default" @click="updateNotificationSettings" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> </div> </div> diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js index 9709424c..64079fcd 100644 --- a/src/components/settings_modal/tabs/profile_tab.js +++ b/src/components/settings_modal/tabs/profile_tab.js @@ -24,7 +24,7 @@ library.add( const ProfileTab = { data () { return { - newName: this.$store.state.users.currentUser.name, + newName: this.$store.state.users.currentUser.name_unescaped, newBio: unescape(this.$store.state.users.currentUser.description), newLocked: this.$store.state.users.currentUser.locked, newNoRichText: this.$store.state.users.currentUser.no_rich_text, diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue index 175a0219..bb3c301d 100644 --- a/src/components/settings_modal/tabs/profile_tab.vue +++ b/src/components/settings_modal/tabs/profile_tab.vue @@ -153,7 +153,7 @@ class="btn button-default" @click="updateProfile" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> </div> <div class="setting-item"> @@ -227,7 +227,7 @@ class="btn button-default" @click="submitBanner(banner)" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> </div> <div class="setting-item"> @@ -266,7 +266,7 @@ class="btn button-default" @click="submitBackground(background)" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> </div> </div> diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js index 811161a5..65d20fc0 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.js +++ b/src/components/settings_modal/tabs/security_tab/security_tab.js @@ -1,6 +1,7 @@ import ProgressButton from 'src/components/progress_button/progress_button.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import Mfa from './mfa.vue' +import localeService from 'src/services/locale/locale.service.js' const SecurityTab = { data () { @@ -37,7 +38,7 @@ const SecurityTab = { return { id: oauthToken.id, appName: oauthToken.app_name, - validUntil: new Date(oauthToken.valid_until).toLocaleDateString() + validUntil: new Date(oauthToken.valid_until).toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale)) } }) } diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue index 56bea1f4..275d4616 100644 --- a/src/components/settings_modal/tabs/security_tab/security_tab.vue +++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue @@ -22,7 +22,7 @@ class="btn button-default" @click="changeEmail" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> <p v-if="changedEmail"> {{ $t('settings.changed_email') }} @@ -60,7 +60,7 @@ class="btn button-default" @click="changePassword" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> <p v-if="changedPassword"> {{ $t('settings.changed_password') }} @@ -133,7 +133,7 @@ class="btn button-default" @click="confirmDelete" > - {{ $t('general.submit') }} + {{ $t('settings.save') }} </button> </div> </div> diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js index 6cf75fe7..0b6669fc 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -16,6 +16,10 @@ import { colors2to3 } from 'src/services/style_setter/style_setter.js' import { + newImporter, + newExporter +} from 'src/services/export_import/export_import.js' +import { SLOT_INHERITANCE } from 'src/services/theme_data/pleromafe.js' import { @@ -31,18 +35,10 @@ import ShadowControl from 'src/components/shadow_control/shadow_control.vue' import FontControl from 'src/components/font_control/font_control.vue' import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue' import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' -import ExportImport from 'src/components/export_import/export_import.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' +import Select from 'src/components/select/select.vue' import Preview from './preview.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faChevronDown -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faChevronDown -) // List of color values used in v1 const v1OnlyNames = [ @@ -67,8 +63,18 @@ const colorConvert = (color) => { export default { data () { return { + themeImporter: newImporter({ + validator: this.importValidator, + onImport: this.onImport, + onImportFailure: this.onImportFailure + }), + themeExporter: newExporter({ + filename: 'pleroma_theme', + getExportedObject: () => this.exportedTheme + }), availableStyles: [], - selected: this.$store.getters.mergedConfig.theme, + selected: '', + selectedTheme: this.$store.getters.mergedConfig.theme, themeWarning: undefined, tempImportFile: undefined, engineVersion: 0, @@ -202,7 +208,7 @@ export default { } }, selectedVersion () { - return Array.isArray(this.selected) ? 1 : 2 + return Array.isArray(this.selectedTheme) ? 1 : 2 }, currentColors () { return Object.keys(SLOT_INHERITANCE) @@ -383,8 +389,8 @@ export default { FontControl, TabSwitcher, Preview, - ExportImport, - Checkbox + Checkbox, + Select }, methods: { loadTheme ( @@ -469,7 +475,7 @@ export default { this.loadThemeFromLocalStorage(false, true) break case 'file': - console.err('Forcing snapshout from file is not supported yet') + console.error('Forcing snapshot from file is not supported yet') break } this.dismissWarning() @@ -528,10 +534,15 @@ export default { this.previewColors.mod ) }, + importTheme () { this.themeImporter.importData() }, + exportTheme () { this.themeExporter.exportData() }, onImport (parsed, forceSource = false) { this.tempImportFile = parsed this.loadTheme(parsed, 'file', forceSource) }, + onImportFailure (result) { + this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' }) + }, importValidator (parsed) { const version = parsed._pleroma_theme_version return version >= 1 || version <= 2 @@ -735,6 +746,16 @@ export default { } }, selected () { + this.selectedTheme = Object.entries(this.availableStyles).find(([k, s]) => { + if (Array.isArray(s)) { + console.log(s[0] === this.selected, this.selected) + return s[0] === this.selected + } else { + return s.name === this.selected + } + })[1] + }, + selectedTheme () { this.dismissWarning() if (this.selectedVersion === 1) { if (!this.keepRoundness) { @@ -752,17 +773,17 @@ export default { if (!this.keepColor) { this.clearV1() - this.bgColorLocal = this.selected[1] - this.fgColorLocal = this.selected[2] - this.textColorLocal = this.selected[3] - this.linkColorLocal = this.selected[4] - this.cRedColorLocal = this.selected[5] - this.cGreenColorLocal = this.selected[6] - this.cBlueColorLocal = this.selected[7] - this.cOrangeColorLocal = this.selected[8] + this.bgColorLocal = this.selectedTheme[1] + this.fgColorLocal = this.selectedTheme[2] + this.textColorLocal = this.selectedTheme[3] + this.linkColorLocal = this.selectedTheme[4] + this.cRedColorLocal = this.selectedTheme[5] + this.cGreenColorLocal = this.selectedTheme[6] + this.cBlueColorLocal = this.selectedTheme[7] + this.cOrangeColorLocal = this.selectedTheme[8] } } else if (this.selectedVersion >= 2) { - this.normalizeLocalState(this.selected.theme, 2, this.selected.source) + this.normalizeLocalState(this.selectedTheme.theme, 2, this.selectedTheme.source) } } } diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss index 1b7d9f31..0db21537 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss @@ -270,6 +270,9 @@ .apply-container { justify-content: center; + position: absolute; + bottom: 8px; + right: 5px; } .radius-item, diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue index b8add42f..c02986ed 100644 --- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue +++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -48,46 +48,47 @@ </template> </div> </div> - <ExportImport - :export-object="exportedTheme" - :export-label="$t("settings.export_theme")" - :import-label="$t("settings.import_theme")" - :import-failed-text="$t("settings.invalid_theme_imported")" - :on-import="onImport" - :validator="importValidator" - > - <template slot="before"> - <div class="presets"> - {{ $t('settings.presets') }} - <label - for="preset-switcher" - class="select" + <div class="top"> + <div class="presets"> + {{ $t('settings.presets') }} + <label + for="preset-switcher" + class="select" + > + <Select + id="preset-switcher" + v-model="selected" + class="preset-switcher" > - <select - id="preset-switcher" - v-model="selected" - class="preset-switcher" + <option + v-for="style in availableStyles" + :key="style.name" + :value="style.name || style[0]" + :style="{ + backgroundColor: style[1] || (style.theme || style.source).colors.bg, + color: style[3] || (style.theme || style.source).colors.text + }" > - <option - v-for="style in availableStyles" - :key="style.name" - :value="style" - :style="{ - backgroundColor: style[1] || (style.theme || style.source).colors.bg, - color: style[3] || (style.theme || style.source).colors.text - }" - > - {{ style[0] || style.name }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> - </div> - </template> - </ExportImport> + {{ style[0] || style.name }} + </option> + </Select> + </label> + </div> + <div class="export-import"> + <button + class="btn button-default" + @click="importTheme" + > + {{ $t("settings.import_theme") }} + </button> + <button + class="btn button-default" + @click="exportTheme" + > + {{ $t("settings.export_theme") }} + </button> + </div> + </div> </div> <div class="save-load-options"> <span class="keep-option"> @@ -902,28 +903,19 @@ <div class="tab-header shadow-selector"> <div class="select-container"> {{ $t('settings.style.shadows.component') }} - <label - for="shadow-switcher" - class="select" + <Select + id="shadow-switcher" + v-model="shadowSelected" + class="shadow-switcher" > - <select - id="shadow-switcher" - v-model="shadowSelected" - class="shadow-switcher" + <option + v-for="shadow in shadowsAvailable" + :key="shadow" + :value="shadow" > - <option - v-for="shadow in shadowsAvailable" - :key="shadow" - :value="shadow" - > - {{ $t('settings.style.shadows.components.' + shadow) }} - </option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + {{ $t('settings.style.shadows.components.' + shadow) }} + </option> + </Select> </div> <div class="override"> <label diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js index 800c39d5..2d5d6eb1 100644 --- a/src/components/shadow_control/shadow_control.js +++ b/src/components/shadow_control/shadow_control.js @@ -1,5 +1,6 @@ import ColorInput from '../color_input/color_input.vue' import OpacityInput from '../opacity_input/opacity_input.vue' +import Select from '../select/select.vue' import { getCssShadow } from '../../services/style_setter/style_setter.js' import { hex2rgb } from '../../services/color_convert/color_convert.js' import { library } from '@fortawesome/fontawesome-svg-core' @@ -45,7 +46,8 @@ export default { }, components: { ColorInput, - OpacityInput + OpacityInput, + Select }, methods: { add () { diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue index 37d491f0..511e07f3 100644 --- a/src/components/shadow_control/shadow_control.vue +++ b/src/components/shadow_control/shadow_control.vue @@ -59,30 +59,20 @@ :disabled="usingFallback" class="id-control style-control" > - <label - for="shadow-switcher" - class="select" + <Select + id="shadow-switcher" + v-model="selectedId" + class="shadow-switcher" :disabled="!ready || usingFallback" > - <select - id="shadow-switcher" - v-model="selectedId" - class="shadow-switcher" - :disabled="!ready || usingFallback" + <option + v-for="(shadow, index) in cValue" + :key="index" + :value="index" > - <option - v-for="(shadow, index) in cValue" - :key="index" - :value="index" - > - {{ $t('settings.style.shadows.shadow_id', { value: index }) }} - </option> - </select> - <FAIcon - icon="chevron-down" - class="select-down-icon" - /> - </label> + {{ $t('settings.style.shadows.shadow_id', { value: index }) }} + </option> + </Select> <button class="btn button-default" :disabled="!ready || !present" @@ -316,20 +306,20 @@ .id-control { align-items: stretch; - .select, .btn { + + .shadow-switcher { + flex: 1; + } + + .shadow-switcher, .btn { min-width: 1px; margin-right: 5px; } + .btn { padding: 0 .4em; margin: 0 .1em; } - .select { - flex: 1; - select { - align-self: initial; - } - } } } } diff --git a/src/components/chat_panel/chat_panel.js b/src/components/shout_panel/shout_panel.js index c3887098..a6168971 100644 --- a/src/components/chat_panel/chat_panel.js +++ b/src/components/shout_panel/shout_panel.js @@ -10,7 +10,7 @@ library.add( faTimes ) -const chatPanel = { +const shoutPanel = { props: [ 'floating' ], data () { return { @@ -21,12 +21,12 @@ const chatPanel = { }, computed: { messages () { - return this.$store.state.chat.messages + return this.$store.state.shout.messages } }, methods: { submit (message) { - this.$store.state.chat.channel.push('new_msg', { text: message }, 10000) + this.$store.state.shout.channel.push('new_msg', { text: message }, 10000) this.currentMessage = '' }, togglePanel () { @@ -35,7 +35,19 @@ const chatPanel = { userProfileLink (user) { return generateProfileLink(user.id, user.username, this.$store.state.instance.restrictedNicknames) } + }, + watch: { + messages (newVal) { + const scrollEl = this.$el.querySelector('.chat-window') + if (!scrollEl) return + if (scrollEl.scrollTop + scrollEl.offsetHeight + 20 > scrollEl.scrollHeight) { + this.$nextTick(() => { + if (!scrollEl) return + scrollEl.scrollTop = scrollEl.scrollHeight - scrollEl.offsetHeight + }) + } + } } } -export default chatPanel +export default shoutPanel diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/shout_panel/shout_panel.vue index 7993c94d..c88797d1 100644 --- a/src/components/chat_panel/chat_panel.vue +++ b/src/components/shout_panel/shout_panel.vue @@ -1,52 +1,50 @@ <template> <div v-if="!collapsed || !floating" - class="chat-panel" + class="shout-panel" > <div class="panel panel-default"> <div class="panel-heading timeline-heading" - :class="{ 'chat-heading': floating }" + :class="{ 'shout-heading': floating }" @click.stop.prevent="togglePanel" > <div class="title"> - <span>{{ $t('shoutbox.title') }}</span> + {{ $t('shoutbox.title') }} <FAIcon v-if="floating" icon="times" + class="close-icon" /> </div> </div> - <div - v-chat-scroll - class="chat-window" - > + <div class="shout-window"> <div v-for="message in messages" :key="message.id" - class="chat-message" + class="shout-message" > - <span class="chat-avatar"> + <span class="shout-avatar"> <img :src="message.author.avatar"> </span> - <div class="chat-content"> + <div class="shout-content"> <router-link - class="chat-name" + class="shout-name" :to="userProfileLink(message.author)" > {{ message.author.username }} </router-link> <br> - <span class="chat-text"> + <span class="shout-text"> {{ message.text }} </span> </div> </div> </div> - <div class="chat-input"> + <div class="shout-input"> <textarea v-model="currentMessage" - class="chat-input-textarea" + class="shout-input-textarea" rows="1" @keyup.enter="submit(currentMessage)" /> @@ -55,11 +53,11 @@ </div> <div v-else - class="chat-panel" + class="shout-panel" > <div class="panel panel-default"> <div - class="panel-heading stub timeline-heading chat-heading" + class="panel-heading stub timeline-heading shout-heading" @click.stop.prevent="togglePanel" > <div class="title"> @@ -74,45 +72,59 @@ </div> </template> -<script src="./chat_panel.js"></script> +<script src="./shout_panel.js"></script> <style lang="scss"> @import '../../_variables.scss'; -.floating-chat { +.floating-shout { position: fixed; - right: 0px; bottom: 0px; z-index: 1000; max-width: 25em; } -.chat-panel { - .chat-heading { +.floating-shout.left { + left: 0px; +} + +.floating-shout:not(.left) { + right: 0px; +} + +.shout-panel { + .shout-heading { cursor: pointer; .icon { color: $fallback--text; color: var(--text, $fallback--text); + margin-right: 0.5em; + } + + .title { + display: flex; + justify-content: space-between; + align-items: center; } } - .chat-window { + .shout-window { overflow-y: auto; overflow-x: hidden; max-height: 20em; } - .chat-window-container { + .shout-window-container { height: 100%; } - .chat-message { + .shout-message { display: flex; padding: 0.2em 0.5em } - .chat-avatar { + .shout-avatar { img { height: 24px; width: 24px; @@ -123,7 +135,7 @@ } } - .chat-input { + .shout-input { display: flex; textarea { flex: 1; @@ -133,7 +145,7 @@ } } - .chat-panel { + .shout-panel { .title { display: flex; justify-content: space-between; diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index fe736168..89719df3 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -49,7 +49,7 @@ const SideDrawer = { currentUser () { return this.$store.state.users.currentUser }, - chat () { return this.$store.state.chat.channel.state === 'joined' }, + shout () { return this.$store.state.shout.channel.state === 'joined' }, unseenNotifications () { return unseenNotificationsFromStore(this.$store) }, diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index 223b1632..dd88de7d 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -106,10 +106,10 @@ </router-link> </li> <li - v-if="chat" + v-if="shout" @click="toggleDrawer" > - <router-link :to="{ name: 'chat-panel' }"> + <router-link :to="{ name: 'shout-panel' }"> <FAIcon fixed-width class="fa-scale-110 fa-old-padding" @@ -273,9 +273,7 @@ --icon: var(--popoverIcon, $fallback--icon); .badge { - position: absolute; - right: 0.7rem; - top: 1em; + margin-left: 10px; } } diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index 8665648a..b9561bf1 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -1,4 +1,6 @@ import map from 'lodash/map' +import groupBy from 'lodash/groupBy' +import { mapGetters, mapState } from 'vuex' import BasicUserCard from '../basic_user_card/basic_user_card.vue' const StaffPanel = { @@ -10,9 +12,21 @@ const StaffPanel = { BasicUserCard }, computed: { - staffAccounts () { - return map(this.$store.state.instance.staffAccounts, nickname => this.$store.getters.findUser(nickname)).filter(_ => _) - } + groupedStaffAccounts () { + const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _) + const groupedStaffAccounts = groupBy(staffAccounts, 'role') + + return [ + { role: 'admin', users: groupedStaffAccounts['admin'] }, + { role: 'moderator', users: groupedStaffAccounts['moderator'] } + ].filter(group => group.users) + }, + ...mapGetters([ + 'findUser' + ]), + ...mapState({ + staffAccounts: state => state.instance.staffAccounts + }) } } diff --git a/src/components/staff_panel/staff_panel.vue b/src/components/staff_panel/staff_panel.vue index 1d13003d..c52ade42 100644 --- a/src/components/staff_panel/staff_panel.vue +++ b/src/components/staff_panel/staff_panel.vue @@ -7,11 +7,18 @@ </div> </div> <div class="panel-body"> - <basic-user-card - v-for="user in staffAccounts" - :key="user.screen_name" - :user="user" - /> + <div + v-for="group in groupedStaffAccounts" + :key="group.role" + class="staff-group" + > + <h4>{{ $t('general.role.' + group.role) }}</h4> + <basic-user-card + v-for="user in group.users" + :key="user.screen_name" + :user="user" + /> + </div> </div> </div> </div> @@ -20,4 +27,14 @@ <script src="./staff_panel.js" ></script> <style lang="scss"> + +.staff-group { + padding-left: 1em; + padding-top: 1em; + + .basic-user-card { + padding-left: 0; + } +} + </style> diff --git a/src/components/status/status.js b/src/components/status/status.js index 2bf93a9e..d8f94926 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -9,9 +9,12 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' import StatusContent from '../status_content/status_content.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import StatusPopover from '../status_popover/status_popover.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' +import MentionsLine from 'src/components/mentions_line/mentions_line.vue' +import MentionLink from 'src/components/mention_link/mention_link.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { muteWordHits } from '../../services/status_parser/status_parser.js' @@ -68,7 +71,10 @@ const Status = { StatusPopover, UserListPopover, EmojiReactions, - StatusContent + StatusContent, + RichContent, + MentionLink, + MentionsLine }, props: [ 'statusoid', @@ -92,7 +98,8 @@ const Status = { userExpanded: false, mediaPlaying: [], suspendable: true, - error: null + error: null, + headTailLinks: null } }, computed: { @@ -132,12 +139,15 @@ const Status = { }, replyProfileLink () { if (this.isReply) { - return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName) + const user = this.$store.getters.findUser(this.status.in_reply_to_user_id) + // FIXME Why user not found sometimes??? + return user ? user.statusnet_profile_url : 'NOT_FOUND' } }, retweet () { return !!this.statusoid.retweeted_status }, - retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name }, - retweeterHtml () { return this.statusoid.user.name_html }, + retweeterUser () { return this.statusoid.user }, + retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui }, + retweeterHtml () { return this.statusoid.user.name }, retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) }, status () { if (this.retweet) { @@ -156,27 +166,52 @@ const Status = { muteWordHits () { return muteWordHits(this.status, this.muteWords) }, + mentionsLine () { + if (!this.headTailLinks) return [] + const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url)) + return this.status.attentions.filter(attn => { + // no reply user + return attn.id !== this.status.in_reply_to_user_id && + // no self-replies + attn.statusnet_profile_url !== this.status.user.statusnet_profile_url && + // don't include if mentions is written + !writtenSet.has(attn.statusnet_profile_url) + }).map(attn => ({ + url: attn.statusnet_profile_url, + content: attn.screen_name, + userId: attn.id + })) + }, + hasMentionsLine () { + return this.mentionsLine.length > 0 + }, muted () { if (this.statusoid.user.id === this.currentUser.id) return false + const reasonsToMute = this.userIsMuted || + // Thread is muted + status.thread_muted || + // Wordfiltered + this.muteWordHits.length > 0 + return !this.unmuted && !this.shouldNotMute && reasonsToMute + }, + userIsMuted () { + if (this.statusoid.user.id === this.currentUser.id) return false const { status } = this const { reblog } = status const relationship = this.$store.getters.relationship(status.user.id) const relationshipReblog = reblog && this.$store.getters.relationship(reblog.user.id) - const reasonsToMute = ( - // Post is muted according to BE - status.muted || + return status.muted || // Reprööt of a muted post according to BE (reblog && reblog.muted) || // Muted user relationship.muting || // Muted user of a reprööt - (relationshipReblog && relationshipReblog.muting) || - // Thread is muted - status.thread_muted || - // Wordfiltered - this.muteWordHits.length > 0 - ) - const excusesNotToMute = ( + (relationshipReblog && relationshipReblog.muting) + }, + shouldNotMute () { + const { status } = this + const { reblog } = status + return ( ( this.inProfile && ( // Don't mute user's posts on user timeline (except reblogs) @@ -189,14 +224,26 @@ const Status = { (this.inConversation && status.thread_muted) // No excuses if post has muted words ) && !this.muteWordHits.length > 0 - - return !this.unmuted && !excusesNotToMute && reasonsToMute + }, + hideMutedUsers () { + return this.mergedConfig.hideMutedPosts + }, + hideMutedThreads () { + return this.mergedConfig.hideMutedThreads }, hideFilteredStatuses () { return this.mergedConfig.hideFilteredStatuses }, + hideWordFilteredPosts () { + return this.mergedConfig.hideWordFilteredPosts + }, hideStatus () { - return (this.muted && this.hideFilteredStatuses) || this.virtualHidden + return (this.virtualHidden || !this.shouldNotMute) && ( + (this.muted && this.hideFilteredStatuses) || + (this.userIsMuted && this.hideMutedUsers) || + (this.status.thread_muted && this.hideMutedThreads) || + (this.muteWordHits.length > 0 && this.hideWordFilteredPosts) + ) }, isFocused () { // retweet or root of an expanded conversation @@ -216,7 +263,7 @@ const Status = { return this.status.in_reply_to_screen_name } else { const user = this.$store.getters.findUser(this.status.in_reply_to_user_id) - return user && user.screen_name + return user && user.screen_name_ui } }, replySubject () { @@ -303,6 +350,9 @@ const Status = { }, removeMediaPlaying (id) { this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id) + }, + setHeadTailLinks (headTailLinks) { + this.headTailLinks = headTailLinks } }, watch: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss index 58b55bc8..2028ade9 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -1,10 +1,12 @@ - @import '../../_variables.scss'; $status-margin: 0.75em; .Status { min-width: 0; + white-space: normal; + word-wrap: break-word; + word-break: break-word; &:hover { --_still-image-img-visibility: visible; @@ -93,12 +95,8 @@ $status-margin: 0.75em; margin-right: 0.4em; text-overflow: ellipsis; - .emoji { - width: 14px; - height: 14px; - vertical-align: middle; - object-fit: contain; - } + --_still_image-label-scale: 0.25; + --emoji-size: 14px; } .status-favicon { @@ -155,42 +153,37 @@ $status-margin: 0.75em; } } + .glued-label { + display: inline-flex; + white-space: nowrap; + } + .timeago { margin-right: 0.2em; } - .heading-reply-row { + & .heading-reply-row { position: relative; align-content: baseline; font-size: 12px; - line-height: 18px; + margin-top: 0.2em; + line-height: 130%; max-width: 100%; - display: flex; - flex-wrap: wrap; align-items: stretch; } - .reply-to-and-accountname { - display: flex; - height: 18px; - margin-right: 0.5em; - max-width: 100%; - - .reply-to-link { - white-space: nowrap; - word-break: break-word; - text-overflow: ellipsis; - overflow-x: hidden; - } - } - & .reply-to-popover, - & .reply-to-no-popover { + & .reply-to-no-popover, + & .mentions { min-width: 0; margin-right: 0.4em; flex-shrink: 0; } + .reply-glued-label { + margin-right: 0.5em; + } + .reply-to-popover { .reply-to:hover::before { content: ''; @@ -220,21 +213,26 @@ $status-margin: 0.75em; } } - .reply-to { + & .mentions, + & .reply-to { + white-space: nowrap; position: relative; } - .reply-to-text { + & .mentions-text, + & .reply-to-text { + color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .replies-separator { - margin-left: 0.4em; + .mentions-line { + display: inline; } .replies { + margin-top: 0.25em; line-height: 18px; font-size: 12px; display: flex; diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 6ee8117f..3bb29db6 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,5 +1,4 @@ <template> - <!-- eslint-disable vue/no-v-html --> <div v-if="!hideStatus" class="Status" @@ -26,7 +25,7 @@ icon="retweet" /> <router-link :to="userProfileLink"> - {{ status.user.screen_name }} + {{ status.user.screen_name_ui }} </router-link> </small> <small @@ -89,8 +88,12 @@ <router-link v-if="retweeterHtml" :to="retweeterProfileLink" - v-html="retweeterHtml" - /> + > + <RichContent + :html="retweeterHtml" + :emoji="retweeterUser.emoji" + /> + </router-link> <router-link v-else :to="retweeterProfileLink" @@ -145,8 +148,12 @@ v-if="status.user.name_html" class="status-username" :title="status.user.name" - v-html="status.user.name_html" - /> + > + <RichContent + :html="status.user.name" + :emoji="status.user.emoji" + /> + </h4> <h4 v-else class="status-username" @@ -156,10 +163,10 @@ </h4> <router-link class="account-name" - :title="status.user.screen_name" + :title="status.user.screen_name_ui" :to="userProfileLink" > - {{ status.user.screen_name }} + {{ status.user.screen_name_ui }} </router-link> <img v-if="!!(status.user && status.user.favicon)" @@ -214,11 +221,13 @@ </button> </span> </div> - - <div class="heading-reply-row"> - <div + <div + v-if="isReply || hasMentionsLine" + class="heading-reply-row" + > + <span v-if="isReply" - class="reply-to-and-accountname" + class="glued-label reply-glued-label" > <StatusPopover v-if="!isPreview" @@ -238,7 +247,7 @@ flip="horizontal" /> <span - class="faint-link reply-to-text" + class="reply-to-text" > {{ $t('status.reply_to') }} </span> @@ -251,50 +260,76 @@ > <span class="reply-to-text">{{ $t('status.reply_to') }}</span> </span> - <router-link - class="reply-to-link" - :title="replyToName" - :to="replyProfileLink" - > - {{ replyToName }} - </router-link> - <span - v-if="replies && replies.length" - class="faint replies-separator" - > - - - </span> - </div> - <div - v-if="inConversation && !isPreview && replies && replies.length" - class="replies" + <MentionLink + :content="replyToName" + :url="replyProfileLink" + :user-id="status.in_reply_to_user_id" + :user-screen-name="status.in_reply_to_screen_name" + :first-mention="false" + /> + </span> + + <!-- This little wrapper is made for sole purpose of "gluing" --> + <!-- "Mentions" label to the first mention --> + <span + v-if="hasMentionsLine" + class="glued-label" > - <span class="faint">{{ $t('status.replies_list') }}</span> - <StatusPopover - v-for="reply in replies" - :key="reply.id" - :status-id="reply.id" + <span + class="mentions" + :aria-label="$t('tool_tip.mentions')" + @click.prevent="gotoOriginal(status.in_reply_to_status_id)" > - <button - class="button-unstyled -link reply-link" - @click.prevent="gotoOriginal(reply.id)" + <span + class="mentions-text" > - {{ reply.name }} - </button> - </StatusPopover> - </div> + {{ $t('status.mentions') }} + </span> + </span> + <MentionsLine + v-if="hasMentionsLine" + :mentions="mentionsLine.slice(0, 1)" + class="mentions-line-first" + /> + </span> + <MentionsLine + v-if="hasMentionsLine" + :mentions="mentionsLine.slice(1)" + class="mentions-line" + /> </div> </div> <StatusContent + ref="content" :status="status" :no-heading="noHeading" :highlight="highlight" :focused="isFocused" @mediaplay="addMediaPlaying($event)" @mediapause="removeMediaPlaying($event)" + @parseReady="setHeadTailLinks" /> + <div + v-if="inConversation && !isPreview && replies && replies.length" + class="replies" + > + <span class="faint">{{ $t('status.replies_list') }}</span> + <StatusPopover + v-for="reply in replies" + :key="reply.id" + :status-id="reply.id" + > + <button + class="button-unstyled -link reply-link" + @click.prevent="gotoOriginal(reply.id)" + > + {{ reply.name }} + </button> + </StatusPopover> + </div> + <transition name="fade"> <div v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0" @@ -402,7 +437,6 @@ </div> </template> </div> -<!-- eslint-enable vue/no-v-html --> </template> <script src="./status.js" ></script> diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js new file mode 100644 index 00000000..91c33135 --- /dev/null +++ b/src/components/status_body/status_body.js @@ -0,0 +1,129 @@ +import fileType from 'src/services/file_type/file_type.service' +import RichContent from 'src/components/rich_content/rich_content.jsx' +import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faFile, + faMusic, + faImage, + faLink, + faPollH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faFile, + faMusic, + faImage, + faLink, + faPollH +) + +const StatusContent = { + name: 'StatusContent', + props: [ + 'compact', + 'status', + 'focused', + 'noHeading', + 'fullContent', + 'singleLine' + ], + data () { + return { + showingTall: this.fullContent || (this.inConversation && this.focused), + showingLongSubject: false, + // not as computed because it sets the initial state which will be changed later + expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject, + postLength: this.status.text.length, + parseReadyDone: false + } + }, + computed: { + localCollapseSubjectDefault () { + return this.mergedConfig.collapseMessageWithSubject + }, + // This is a bit hacky, but we want to approximate post height before rendering + // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them) + // as well as approximate line count by counting characters and approximating ~80 + // per line. + // + // Using max-height + overflow: auto for status components resulted in false positives + // very often with japanese characters, and it was very annoying. + tallStatus () { + if (this.singleLine || this.compact) return false + const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80 + return lengthScore > 20 + }, + longSubject () { + return this.status.summary.length > 240 + }, + // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. + mightHideBecauseSubject () { + return !!this.status.summary && this.localCollapseSubjectDefault + }, + mightHideBecauseTall () { + return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault) + }, + hideSubjectStatus () { + return this.mightHideBecauseSubject && !this.expandingSubject + }, + hideTallStatus () { + return this.mightHideBecauseTall && !this.showingTall + }, + showingMore () { + return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject) + }, + attachmentTypes () { + return this.status.attachments.map(file => fileType.fileType(file.mimetype)) + }, + ...mapGetters(['mergedConfig']) + }, + components: { + RichContent + }, + mounted () { + this.status.attentions && this.status.attentions.forEach(attn => { + const { id } = attn + this.$store.dispatch('fetchUserIfMissing', id) + }) + }, + methods: { + onParseReady (event) { + if (this.parseReadyDone) return + this.parseReadyDone = true + this.$emit('parseReady', event) + const { writtenMentions, invisibleMentions } = event + writtenMentions + .filter(mention => !mention.notifying) + .forEach(mention => { + const { content, url } = mention + const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags + if (!cleanedString.startsWith('@')) return + const handle = cleanedString.slice(1) + const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '') + this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`) + }) + /* This is a bit of a hack to make current tall status detector work + * with rich mentions. Invisible mentions are detected at RichContent level + * and also we generate plaintext version of mentions by stripping tags + * so here we subtract from post length by each mention that became invisible + * via MentionsLine + */ + this.postLength = invisibleMentions.reduce((acc, mention) => { + return acc - mention.textContent.length - 1 + }, this.postLength) + }, + toggleShowMore () { + if (this.mightHideBecauseTall) { + this.showingTall = !this.showingTall + } else if (this.mightHideBecauseSubject) { + this.expandingSubject = !this.expandingSubject + } + }, + generateTagLink (tag) { + return `/tag/${tag}` + } + } +} + +export default StatusContent diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss new file mode 100644 index 00000000..f261108e --- /dev/null +++ b/src/components/status_body/status_body.scss @@ -0,0 +1,174 @@ +@import '../../_variables.scss'; + +.StatusBody { + display: flex; + flex-direction: column; + + .emoji { + --_still_image-label-scale: 0.5; + } + + .attachments { + margin-top: 0.5em; + } + + & .text, + & .summary { + font-family: var(--postFont, sans-serif); + white-space: pre-wrap; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + line-height: 1.4em; + } + + .summary { + display: block; + font-style: italic; + padding-bottom: 0.5em; + } + + .text { + &.-single-line { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + height: 1.4em; + } + } + + .summary-wrapper { + margin-bottom: 0.5em; + border-style: solid; + border-width: 0 0 1px 0; + border-color: var(--border, $fallback--border); + flex-grow: 0; + + &.-tall { + position: relative; + + .summary { + max-height: 2em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + .text-wrapper { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + &.-tall-status { + position: relative; + height: 220px; + overflow-x: hidden; + overflow-y: hidden; + z-index: 1; + + .media-body { + min-height: 0; + mask: + linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, + linear-gradient(to top, white, white); + + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + } + } + + & .tall-status-hider, + & .tall-subject-hider, + & .status-unhider, + & .cw-status-hider { + display: inline-block; + word-break: break-all; + width: 100%; + text-align: center; + } + + .tall-status-hider { + position: absolute; + height: 70px; + margin-top: 150px; + line-height: 110px; + z-index: 2; + } + + .tall-subject-hider { + // position: absolute; + padding-bottom: 0.5em; + } + + & .status-unhider, + & .cw-status-hider { + word-break: break-all; + + svg { + color: inherit; + } + } + + .greentext { + color: $fallback--cGreen; + color: var(--postGreentext, $fallback--cGreen); + } + + .cyantext { + color: var(--postCyantext, $fallback--cBlue); + } + + &.-compact { + align-items: top; + flex-direction: row; + + --emoji-size: 16px; + + & .body, + & .attachments { + max-height: 3.25em; + } + + .body { + overflow: hidden; + white-space: normal; + min-width: 5em; + flex: 5 1 auto; + mask-size: auto 3.5em, auto auto; + mask-position: 0 0, 0 0; + mask-repeat: repeat-x, repeat; + mask-image: linear-gradient(to bottom, white 2em, transparent 3em); + + /* Autoprefixed seem to ignore this one, and also syntax is different */ + -webkit-mask-composite: xor; + mask-composite: exclude; + } + + .attachments { + margin-top: 0; + flex: 1 1 0; + min-width: 5em; + height: 100%; + margin-left: 0.5em; + } + + .summary-wrapper { + .summary::after { + content: ': '; + } + + line-height: inherit; + margin: 0; + border: none; + display: inline-block; + } + + .text-wrapper { + display: inline-block; + } + } +} diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue new file mode 100644 index 00000000..a088e6bc --- /dev/null +++ b/src/components/status_body/status_body.vue @@ -0,0 +1,100 @@ +<template> + <div + class="StatusBody" + :class="{ '-compact': compact }" + > + <div class="body"> + <div + v-if="status.summary_raw_html" + class="summary-wrapper" + :class="{ '-tall': (longSubject && !showingLongSubject) }" + > + <RichContent + class="media-body summary" + :html="status.summary_raw_html" + :emoji="status.emojis" + /> + <button + v-if="longSubject && showingLongSubject" + class="button-unstyled -link tall-subject-hider" + @click.prevent="showingLongSubject=false" + > + {{ $t("status.hide_full_subject") }} + </button> + <button + v-else-if="longSubject" + class="button-unstyled -link tall-subject-hider" + @click.prevent="showingLongSubject=true" + > + {{ $t("status.show_full_subject") }} + </button> + </div> + <div + :class="{'-tall-status': hideTallStatus}" + class="text-wrapper" + > + <button + v-if="hideTallStatus" + class="button-unstyled -link tall-status-hider" + :class="{ '-focused': focused }" + @click.prevent="toggleShowMore" + > + {{ $t("general.show_more") }} + </button> + <RichContent + v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)" + :class="{ '-single-line': singleLine }" + class="text media-body" + :html="status.raw_html" + :emoji="status.emojis" + :handle-links="true" + :greentext="mergedConfig.greentext" + :attentions="status.attentions" + @parseReady="onParseReady" + /> + + <button + v-if="hideSubjectStatus" + class="button-unstyled -link cw-status-hider" + @click.prevent="toggleShowMore" + > + {{ $t("status.show_content") }} + <FAIcon + v-if="attachmentTypes.includes('image')" + icon="image" + /> + <FAIcon + v-if="attachmentTypes.includes('video')" + icon="video" + /> + <FAIcon + v-if="attachmentTypes.includes('audio')" + icon="music" + /> + <FAIcon + v-if="attachmentTypes.includes('unknown')" + icon="file" + /> + <FAIcon + v-if="status.poll && status.poll.options" + icon="poll-h" + /> + <FAIcon + v-if="status.card" + icon="link" + /> + </button> + <button + v-if="showingMore && !fullContent" + class="button-unstyled -link status-unhider" + @click.prevent="toggleShowMore" + > + {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }} + </button> + </div> + </div> + <slot v-if="!hideSubjectStatus" /> + </div> +</template> +<script src="./status_body.js" ></script> +<style lang="scss" src="./status_body.scss" /> diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js index a6f79d76..dec8914a 100644 --- a/src/components/status_content/status_content.js +++ b/src/components/status_content/status_content.js @@ -1,11 +1,8 @@ import Attachment from '../attachment/attachment.vue' import Poll from '../poll/poll.vue' import Gallery from '../gallery/gallery.vue' +import StatusBody from 'src/components/status_body/status_body.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 { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' -import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' import { mapGetters, mapState } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -30,57 +27,17 @@ const StatusContent = { name: 'StatusContent', props: [ 'status', + 'compact', 'focused', 'noHeading', 'fullContent', 'singleLine' ], - data () { - return { - showingTall: this.fullContent || (this.inConversation && this.focused), - showingLongSubject: false, - // not as computed because it sets the initial state which will be changed later - expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject - } - }, computed: { - localCollapseSubjectDefault () { - return this.mergedConfig.collapseMessageWithSubject - }, hideAttachments () { return (this.mergedConfig.hideAttachments && !this.inConversation) || (this.mergedConfig.hideAttachmentsInConv && this.inConversation) }, - // This is a bit hacky, but we want to approximate post height before rendering - // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them) - // as well as approximate line count by counting characters and approximating ~80 - // per line. - // - // Using max-height + overflow: auto for status components resulted in false positives - // very often with japanese characters, and it was very annoying. - tallStatus () { - const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 - return lengthScore > 20 - }, - longSubject () { - return this.status.summary.length > 240 - }, - // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. - mightHideBecauseSubject () { - return !!this.status.summary && this.localCollapseSubjectDefault - }, - mightHideBecauseTall () { - return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault) - }, - hideSubjectStatus () { - return this.mightHideBecauseSubject && !this.expandingSubject - }, - hideTallStatus () { - return this.mightHideBecauseTall && !this.showingTall - }, - showingMore () { - return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject) - }, nsfwClickthrough () { if (!this.status.nsfw) { return false @@ -91,72 +48,20 @@ const StatusContent = { return true }, attachmentSize () { - if ((this.mergedConfig.hideAttachments && !this.inConversation) || + if (this.compact) { + return 'small' + } else if ((this.mergedConfig.hideAttachments && !this.inConversation) || (this.mergedConfig.hideAttachmentsInConv && this.inConversation) || (this.status.attachments.length > this.maxThumbnails)) { return 'hide' - } else if (this.compact) { - return 'small' } return 'normal' }, - galleryTypes () { - if (this.attachmentSize === 'hide') { - return [] - } - return this.mergedConfig.playVideosInModal - ? ['image', 'video'] - : ['image'] - }, - galleryAttachments () { - return this.status.attachments.filter( - file => fileType.fileMatchesSomeType(this.galleryTypes, file) - ) - }, - nonGalleryAttachments () { - return this.status.attachments.filter( - file => !fileType.fileMatchesSomeType(this.galleryTypes, file) - ) - }, - attachmentTypes () { - return this.status.attachments.map(file => fileType.fileType(file.mimetype)) - }, maxThumbnails () { return this.mergedConfig.maxThumbnails }, - postBodyHtml () { - const html = this.status.statusnet_html - - if (this.mergedConfig.greentext) { - try { - if (html.includes('>')) { - // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works - return processHtml(html, (string) => { - if (string.includes('>') && - string - .replace(/<[^>]+?>/gi, '') // remove all tags - .replace(/@\w+/gi, '') // remove mentions (even failed ones) - .trim() - .startsWith('>')) { - return `<span class='greentext'>${string}</span>` - } else { - return string - } - }) - } else { - return html - } - } catch (e) { - console.err('Failed to process status html', e) - return html - } - } else { - return html - } - }, ...mapGetters(['mergedConfig']), ...mapState({ - betterShadow: state => state.interface.browserSupport.cssFilter, currentUser: state => state.users.currentUser }) }, @@ -164,52 +69,8 @@ const StatusContent = { Attachment, Poll, Gallery, - LinkPreview - }, - methods: { - linkClicked (event) { - const target = event.target.closest('.status-content a') - if (target) { - if (target.className.match(/mention/)) { - const href = target.href - const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) - if (attn) { - event.stopPropagation() - event.preventDefault() - const link = this.generateUserProfileLink(attn.id, attn.screen_name) - this.$router.push(link) - return - } - } - if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) { - // Extract tag name from dataset or link url - const tag = target.dataset.tag || extractTagFromUrl(target.href) - if (tag) { - const link = this.generateTagLink(tag) - this.$router.push(link) - return - } - } - window.open(target.href, '_blank') - } - }, - toggleShowMore () { - if (this.mightHideBecauseTall) { - this.showingTall = !this.showingTall - } else if (this.mightHideBecauseSubject) { - this.expandingSubject = !this.expandingSubject - } - }, - 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) - } + LinkPreview, + StatusBody } } diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue index 90bfaf40..69635aad 100644 --- a/src/components/status_content/status_content.vue +++ b/src/components/status_content/status_content.vue @@ -1,133 +1,53 @@ <template> - <!-- eslint-disable vue/no-v-html --> - <div class="StatusContent"> + <div + class="StatusContent" + :class="{ '-compact': compact }" + > <slot name="header" /> - <div - v-if="status.summary_html" - class="summary-wrapper" - :class="{ 'tall-subject': (longSubject && !showingLongSubject) }" + <StatusBody + :status="status" + :compact="compact" + :single-line="singleLine" + @parseReady="$emit('parseReady', $event)" > - <div - class="media-body summary" - @click.prevent="linkClicked" - v-html="status.summary_html" - /> - <button - v-if="longSubject && showingLongSubject" - class="button-unstyled -link tall-subject-hider" - @click.prevent="showingLongSubject=false" - > - {{ $t("status.hide_full_subject") }} - </button> - <button - v-else-if="longSubject" - class="button-unstyled -link tall-subject-hider" - :class="{ 'tall-subject-hider_focused': focused }" - @click.prevent="showingLongSubject=true" - > - {{ $t("status.show_full_subject") }} - </button> - </div> - <div - :class="{'tall-status': hideTallStatus}" - class="status-content-wrapper" - > - <button - v-if="hideTallStatus" - class="button-unstyled -link tall-status-hider" - :class="{ 'tall-status-hider_focused': focused }" - @click.prevent="toggleShowMore" - > - {{ $t("general.show_more") }} - </button> - <div - v-if="!hideSubjectStatus" - :class="{ 'single-line': singleLine }" - class="status-content media-body" - @click.prevent="linkClicked" - v-html="postBodyHtml" - /> - <button - v-if="hideSubjectStatus" - class="button-unstyled -link cw-status-hider" - @click.prevent="toggleShowMore" - > - {{ $t("status.show_content") }} - <FAIcon - v-if="attachmentTypes.includes('image')" - icon="image" - /> - <FAIcon - v-if="attachmentTypes.includes('video')" - icon="video" - /> - <FAIcon - v-if="attachmentTypes.includes('audio')" - icon="music" - /> - <FAIcon - v-if="attachmentTypes.includes('unknown')" - icon="file" + <div v-if="status.poll && status.poll.options && !compact"> + <Poll + :base-poll="status.poll" + :emoji="status.emojis" /> + </div> + + <div v-else-if="status.poll && status.poll.options && compact"> <FAIcon - v-if="status.poll && status.poll.options" icon="poll-h" + size="2x" /> - <FAIcon - v-if="status.card" - icon="link" - /> - </button> - <button - v-if="showingMore && !fullContent" - class="button-unstyled -link status-unhider" - @click.prevent="toggleShowMore" - > - {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }} - </button> - </div> - - <div v-if="status.poll && status.poll.options && !hideSubjectStatus"> - <poll :base-poll="status.poll" /> - </div> + </div> - <div - v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)" - class="attachments media-body" - > - <attachment - v-for="attachment in nonGalleryAttachments" - :key="attachment.id" - class="non-gallery" - :size="attachmentSize" + <gallery + v-if="status.attachments.length !== 0" + class="attachments media-body" :nsfw="nsfwClickthrough" - :attachment="attachment" - :allow-play="true" - :set-media="setMedia()" + :attachments="status.attachments" + :limit="compact ? 1 : 0" + :size="attachmentSize" @play="$emit('mediaplay', attachment.id)" @pause="$emit('mediapause', attachment.id)" /> - <gallery - v-if="galleryAttachments.length > 0" - :nsfw="nsfwClickthrough" - :attachments="galleryAttachments" - :set-media="setMedia()" - /> - </div> - <div - v-if="status.card && !hideSubjectStatus && !noHeading" - class="link-preview media-body" - > - <link-preview - :card="status.card" - :size="attachmentSize" - :nsfw="nsfwClickthrough" - /> - </div> + <div + v-if="status.card && !noHeading && !compact" + class="link-preview media-body" + > + <link-preview + :card="status.card" + :size="attachmentSize" + :nsfw="nsfwClickthrough" + /> + </div> + </StatusBody> <slot name="footer" /> </div> - <!-- eslint-enable vue/no-v-html --> </template> <script src="./status_content.js" ></script> @@ -139,156 +59,5 @@ $status-margin: 0.75em; .StatusContent { flex: 1; min-width: 0; - - .status-content-wrapper { - display: flex; - flex-direction: column; - flex-wrap: nowrap; - } - - .tall-status { - position: relative; - height: 220px; - overflow-x: hidden; - overflow-y: hidden; - z-index: 1; - .status-content { - min-height: 0; - mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, - linear-gradient(to top, white, white); - /* Autoprefixed seem to ignore this one, and also syntax is different */ - -webkit-mask-composite: xor; - mask-composite: exclude; - } - } - - .tall-status-hider { - display: inline-block; - word-break: break-all; - position: absolute; - height: 70px; - margin-top: 150px; - width: 100%; - text-align: center; - line-height: 110px; - z-index: 2; - } - - .status-unhider, .cw-status-hider { - width: 100%; - text-align: center; - display: inline-block; - word-break: break-all; - - svg { - color: inherit; - } - } - - img, video { - max-width: 100%; - max-height: 400px; - vertical-align: middle; - object-fit: contain; - - &.emoji { - width: 32px; - height: 32px; - } - } - - .summary-wrapper { - margin-bottom: 0.5em; - border-style: solid; - border-width: 0 0 1px 0; - border-color: var(--border, $fallback--border); - flex-grow: 0; - } - - .summary { - font-style: italic; - padding-bottom: 0.5em; - } - - .tall-subject { - position: relative; - .summary { - max-height: 2em; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - .tall-subject-hider { - display: inline-block; - word-break: break-all; - // position: absolute; - width: 100%; - text-align: center; - padding-bottom: 0.5em; - } - - .status-content { - font-family: var(--postFont, sans-serif); - line-height: 1.4em; - white-space: pre-wrap; - overflow-wrap: break-word; - word-wrap: break-word; - word-break: break-word; - - blockquote { - margin: 0.2em 0 0.2em 2em; - font-style: italic; - } - - pre { - overflow: auto; - } - - code, samp, kbd, var, pre { - font-family: var(--postCodeFont, monospace); - } - - p { - margin: 0 0 1em 0; - } - - p:last-child { - margin: 0 0 0 0; - } - - h1 { - font-size: 1.1em; - line-height: 1.2em; - margin: 1.4em 0; - } - - h2 { - font-size: 1.1em; - margin: 1.0em 0; - } - - h3 { - font-size: 1em; - margin: 1.2em 0; - } - - h4 { - margin: 1.1em 0; - } - - &.single-line { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - height: 1.4em; - } - } -} - -.greentext { - color: $fallback--cGreen; - color: var(--postGreentext, $fallback--cGreen); } </style> diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue index 8237ce00..fdca8c9c 100644 --- a/src/components/status_popover/status_popover.vue +++ b/src/components/status_popover/status_popover.vue @@ -5,12 +5,10 @@ :bound-to="{ x: 'container' }" @show="enter" > - <template slot="trigger"> + <template v-slot:trigger> <slot /> </template> - <div - slot="content" - > + <template v-slot:content> <Status v-if="status" :is-preview="true" @@ -33,7 +31,7 @@ size="2x" /> </div> - </div> + </template> </Popover> </template> diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 8044e994..d7abbcb5 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -5,7 +5,9 @@ const StillImage = { 'mimetype', 'imageLoadError', 'imageLoadHandler', - 'alt' + 'alt', + 'height', + 'width' ], data () { return { @@ -15,6 +17,13 @@ const StillImage = { computed: { animated () { return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) + }, + style () { + const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str + return { + height: this.height ? appendPx(this.height) : null, + width: this.width ? appendPx(this.width) : null + } } }, methods: { diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index d3eb5925..cca75fcb 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -2,6 +2,7 @@ <div class="still-image" :class="{ animated: animated }" + :style="style" > <canvas v-if="animated" @@ -30,7 +31,7 @@ position: relative; line-height: 0; overflow: hidden; - display: flex; + display: inline-flex; align-items: center; canvas { @@ -47,12 +48,13 @@ img { width: 100%; - min-height: 100%; + height: 100%; object-fit: contain; } &.animated { &::before { + zoom: var(--_still_image-label-scale, 1); content: 'gif'; position: absolute; line-height: 10px; diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index 76e7ef03..12aac8e6 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -93,7 +93,9 @@ export default Vue.component('tab-switcher', { <button disabled={slot.data.attrs.disabled} onClick={this.clickTab(index)} - class={classesTab.join(' ')}> + class={classesTab.join(' ')} + type="button" + > <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/> {slot.data.attrs.label ? '' : slot.data.attrs.label} </button> diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue index 6df0524d..55a2dd94 100644 --- a/src/components/timeago/timeago.vue +++ b/src/components/timeago/timeago.vue @@ -9,6 +9,7 @@ <script> import * as DateUtils from 'src/services/date_utils/date_utils.js' +import localeService from 'src/services/locale/locale.service.js' export default { name: 'Timeago', @@ -21,9 +22,10 @@ export default { }, computed: { localeDateString () { + const browserLocale = localeService.internalToBrowserLocale(this.$i18n.locale) return typeof this.time === 'string' - ? new Date(Date.parse(this.time)).toLocaleString() - : this.time.toLocaleString() + ? new Date(Date.parse(this.time)).toLocaleString(browserLocale) + : this.time.toLocaleString(browserLocale) } }, created () { diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 665d195e..04f0e7d6 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -2,27 +2,16 @@ import Status from '../status/status.vue' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import Conversation from '../conversation/conversation.vue' import TimelineMenu from '../timeline_menu/timeline_menu.vue' +import TimelineQuickSettings from './timeline_quick_settings.vue' import { debounce, throttle, keyBy } from 'lodash' import { library } from '@fortawesome/fontawesome-svg-core' -import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' +import { faCircleNotch, faCog } from '@fortawesome/free-solid-svg-icons' library.add( - faCircleNotch + faCircleNotch, + faCog ) -export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => { - const ids = [] - if (pinnedStatusIds && pinnedStatusIds.length > 0) { - for (let status of statuses) { - if (!pinnedStatusIds.includes(status.id)) { - break - } - ids.push(status.id) - } - } - return ids -} - const Timeline = { props: [ 'timeline', @@ -47,7 +36,8 @@ const Timeline = { components: { Status, Conversation, - TimelineMenu + TimelineMenu, + TimelineQuickSettings }, computed: { newStatusCount () { @@ -74,11 +64,6 @@ const Timeline = { } }, // id map of statuses which need to be hidden in the main list due to pinning logic - excludedStatusIdsObject () { - const ids = getExcludedStatusIdsByPinning(this.timeline.visibleStatuses, this.pinnedStatusIds) - // Convert id array to object - return keyBy(ids) - }, pinnedStatusIdsObject () { return keyBy(this.pinnedStatusIds) }, diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss new file mode 100644 index 00000000..2c5a67e2 --- /dev/null +++ b/src/components/timeline/timeline.scss @@ -0,0 +1,31 @@ +@import '../../_variables.scss'; + +.Timeline { + .loadmore-text { + opacity: 1; + } + + &.-blocked { + cursor: progress; + } + + .timeline-heading { + max-width: 100%; + flex-wrap: nowrap; + align-items: center; + position: relative; + + .loadmore-button { + flex-shrink: 0; + } + + .loadmore-text { + flex-shrink: 0; + line-height: 1em; + } + } + + .timeline-footer { + border: none; + } +} diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 4c43fe5c..ff16208d 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -16,6 +16,7 @@ > {{ $t('timeline.up_to_date') }} </div> + <TimelineQuickSettings v-if="!embedded" /> </div> <div :class="classes.body"> <div @@ -36,7 +37,7 @@ </template> <template v-for="status in timeline.visibleStatuses"> <conversation - v-if="!excludedStatusIdsObject[status.id]" + v-if="timelineName !== 'user' || (status.id >= timeline.minId && status.id <= timeline.maxId)" :key="status.id" class="status-fadein" :status-id="status.id" @@ -51,13 +52,13 @@ <div :class="classes.footer"> <div v-if="count===0" - class="new-status-notification text-center panel-footer faint" + class="new-status-notification text-center faint" > {{ $t('timeline.no_statuses') }} </div> <div v-else-if="bottomedOut" - class="new-status-notification text-center panel-footer faint" + class="new-status-notification text-center faint" > {{ $t('timeline.no_more_statuses') }} </div> @@ -66,13 +67,13 @@ class="button-unstyled -link -fullwidth" @click.prevent="fetchOlderStatuses()" > - <div class="new-status-notification text-center panel-footer"> + <div class="new-status-notification text-center"> {{ $t('timeline.load_older') }} </div> </button> <div v-else - class="new-status-notification text-center panel-footer" + class="new-status-notification text-center" > <FAIcon icon="circle-notch" @@ -86,29 +87,4 @@ <script src="./timeline.js"></script> -<style lang="scss"> -@import '../../_variables.scss'; - -.Timeline { - .loadmore-text { - opacity: 1; - } - - &.-blocked { - cursor: progress; - } -} - -.timeline-heading { - max-width: 100%; - flex-wrap: nowrap; - align-items: center; - .loadmore-button { - flex-shrink: 0; - } - .loadmore-text { - flex-shrink: 0; - line-height: 1em; - } -} -</style> +<style src="./timeline.scss" lang="scss"> </style> diff --git a/src/components/timeline/timeline_quick_settings.js b/src/components/timeline/timeline_quick_settings.js new file mode 100644 index 00000000..7b4931ce --- /dev/null +++ b/src/components/timeline/timeline_quick_settings.js @@ -0,0 +1,60 @@ +import Popover from '../popover/popover.vue' +import { mapGetters } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { faFilter, faFont, faWrench } from '@fortawesome/free-solid-svg-icons' + +library.add( + faFilter, + faFont, + faWrench +) + +const TimelineQuickSettings = { + components: { + Popover + }, + methods: { + setReplyVisibility (visibility) { + this.$store.dispatch('setOption', { name: 'replyVisibility', value: visibility }) + this.$store.dispatch('queueFlushAll') + }, + openTab (tab) { + this.$store.dispatch('openSettingsModalTab', tab) + } + }, + computed: { + ...mapGetters(['mergedConfig']), + loggedIn () { + return !!this.$store.state.users.currentUser + }, + replyVisibilitySelf: { + get () { return this.mergedConfig.replyVisibility === 'self' }, + set () { this.setReplyVisibility('self') } + }, + replyVisibilityFollowing: { + get () { return this.mergedConfig.replyVisibility === 'following' }, + set () { this.setReplyVisibility('following') } + }, + replyVisibilityAll: { + get () { return this.mergedConfig.replyVisibility === 'all' }, + set () { this.setReplyVisibility('all') } + }, + hideMedia: { + get () { return this.mergedConfig.hideAttachments || this.mergedConfig.hideAttachmentsInConv }, + set () { + const value = !this.hideMedia + this.$store.dispatch('setOption', { name: 'hideAttachments', value }) + this.$store.dispatch('setOption', { name: 'hideAttachmentsInConv', value }) + } + }, + hideMutedPosts: { + get () { return this.mergedConfig.hideFilteredStatuses }, + set () { + const value = !this.hideMutedPosts + this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value }) + } + } + } +} + +export default TimelineQuickSettings diff --git a/src/components/timeline/timeline_quick_settings.vue b/src/components/timeline/timeline_quick_settings.vue new file mode 100644 index 00000000..98996ebd --- /dev/null +++ b/src/components/timeline/timeline_quick_settings.vue @@ -0,0 +1,102 @@ +<template> + <Popover + trigger="click" + class="TimelineQuickSettings" + :bound-to="{ x: 'container' }" + > + <template v-slot:content> + <div class="dropdown-menu"> + <div v-if="loggedIn"> + <button + class="button-default dropdown-item" + @click="replyVisibilityAll = true" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-radio': replyVisibilityAll }" + />{{ $t('settings.reply_visibility_all') }} + </button> + <button + class="button-default dropdown-item" + @click="replyVisibilityFollowing = true" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-radio': replyVisibilityFollowing }" + />{{ $t('settings.reply_visibility_following_short') }} + </button> + <button + class="button-default dropdown-item" + @click="replyVisibilitySelf = true" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-radio': replyVisibilitySelf }" + />{{ $t('settings.reply_visibility_self_short') }} + </button> + <div + role="separator" + class="dropdown-divider" + /> + </div> + <button + class="button-default dropdown-item" + @click="hideMedia = !hideMedia" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hideMedia }" + />{{ $t('settings.hide_media_previews') }} + </button> + <button + class="button-default dropdown-item" + @click="hideMutedPosts = !hideMutedPosts" + > + <span + class="menu-checkbox" + :class="{ 'menu-checkbox-checked': hideMutedPosts }" + />{{ $t('settings.hide_all_muted_posts') }} + </button> + <button + class="button-default dropdown-item dropdown-item-icon" + @click="openTab('filtering')" + > + <FAIcon icon="font" />{{ $t('settings.word_filter') }} + </button> + <button + class="button-default dropdown-item dropdown-item-icon" + @click="openTab('general')" + > + <FAIcon icon="wrench" />{{ $t('settings.more_settings') }} + </button> + </div> + </template> + <template v-slot:trigger> + <button class="button-unstyled"> + <FAIcon icon="filter" /> + </button> + </template> + </Popover> +</template> + +<script src="./timeline_quick_settings.js"></script> + +<style lang="scss"> + +.TimelineQuickSettings { + align-self: stretch; + + > button { + font-size: 1.2em; + padding-left: 0.7em; + padding-right: 0.2em; + line-height: 100%; + height: 100%; + } + + .dropdown-item { + margin: 0; + } +} + +</style> diff --git a/src/components/timeline_menu/timeline_menu.js b/src/components/timeline_menu/timeline_menu.js index 8d6a58b1..bab51e75 100644 --- a/src/components/timeline_menu/timeline_menu.js +++ b/src/components/timeline_menu/timeline_menu.js @@ -1,29 +1,17 @@ import Popover from '../popover/popover.vue' -import { mapState } from 'vuex' +import TimelineMenuContent from './timeline_menu_content.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { - faUsers, - faGlobe, - faBookmark, - faEnvelope, - faHome, faChevronDown } from '@fortawesome/free-solid-svg-icons' -library.add( - faUsers, - faGlobe, - faBookmark, - faEnvelope, - faHome, - faChevronDown -) +library.add(faChevronDown) // Route -> i18n key mapping, exported and not in the computed // because nav panel benefits from the same information. export const timelineNames = () => { return { - 'friends': 'nav.timeline', + 'friends': 'nav.home_timeline', 'bookmarks': 'nav.bookmarks', 'dms': 'nav.dms', 'public-timeline': 'nav.public_tl', @@ -33,7 +21,8 @@ export const timelineNames = () => { const TimelineMenu = { components: { - Popover + Popover, + TimelineMenuContent }, data () { return { @@ -41,9 +30,6 @@ const TimelineMenu = { } }, created () { - if (this.currentUser && this.currentUser.locked) { - this.$store.dispatch('startFetchingFollowRequests') - } if (timelineNames()[this.$route.name]) { this.$store.dispatch('setLastTimeline', this.$route.name) } @@ -75,13 +61,6 @@ const TimelineMenu = { const i18nkey = timelineNames()[this.$route.name] return i18nkey ? this.$t(i18nkey) : route } - }, - computed: { - ...mapState({ - currentUser: state => state.users.currentUser, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) } } diff --git a/src/components/timeline_menu/timeline_menu.vue b/src/components/timeline_menu/timeline_menu.vue index 3c86842b..8f14093f 100644 --- a/src/components/timeline_menu/timeline_menu.vue +++ b/src/components/timeline_menu/timeline_menu.vue @@ -9,74 +9,26 @@ @show="openMenu" @close="() => isOpen = false" > - <div - slot="content" - class="timeline-menu-popover panel panel-default" - > - <ul> - <li v-if="currentUser"> - <router-link :to="{ name: 'friends' }"> - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="home" - />{{ $t("nav.timeline") }} - </router-link> - </li> - <li v-if="currentUser"> - <router-link :to="{ name: 'bookmarks'}"> - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="bookmark" - />{{ $t("nav.bookmarks") }} - </router-link> - </li> - <li v-if="currentUser"> - <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="envelope" - />{{ $t("nav.dms") }} - </router-link> - </li> - <li v-if="currentUser || !privateMode"> - <router-link :to="{ name: 'public-timeline' }"> - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="users" - />{{ $t("nav.public_tl") }} - </router-link> - </li> - <li v-if="federating && (currentUser || !privateMode)"> - <router-link :to="{ name: 'public-external-timeline' }"> - <FAIcon - fixed-width - class="fa-scale-110 fa-old-padding " - icon="globe" - />{{ $t("nav.twkn") }} - </router-link> - </li> - </ul> - </div> - <div - slot="trigger" - class="title timeline-menu-title" - > - <span class="timeline-title">{{ timelineName() }}</span> - <span> - <FAIcon - size="sm" - icon="chevron-down" + <template v-slot:content> + <div class="timeline-menu-popover popover-default"> + <TimelineMenuContent /> + </div> + </template> + <template v-slot:trigger> + <button class="button-unstyled title timeline-menu-title"> + <span class="timeline-title">{{ timelineName() }}</span> + <span> + <FAIcon + size="sm" + icon="chevron-down" + /> + </span> + <span + class="click-blocker" + @click="blockOpen" /> - </span> - <span - class="click-blocker" - @click="blockOpen" - /> - </div> + </button> + </template> </Popover> </template> diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js new file mode 100644 index 00000000..671570dd --- /dev/null +++ b/src/components/timeline_menu/timeline_menu_content.js @@ -0,0 +1,29 @@ +import { mapState } from 'vuex' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faHome +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUsers, + faGlobe, + faBookmark, + faEnvelope, + faHome +) + +const TimelineMenuContent = { + computed: { + ...mapState({ + currentUser: state => state.users.currentUser, + privateMode: state => state.instance.private, + federating: state => state.instance.federating + }) + } +} + +export default TimelineMenuContent diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue new file mode 100644 index 00000000..bed1b679 --- /dev/null +++ b/src/components/timeline_menu/timeline_menu_content.vue @@ -0,0 +1,66 @@ +<template> + <ul> + <li v-if="currentUser"> + <router-link + class="menu-item" + :to="{ name: 'friends' }" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="home" + />{{ $t("nav.home_timeline") }} + </router-link> + </li> + <li v-if="currentUser || !privateMode"> + <router-link + class="menu-item" + :to="{ name: 'public-timeline' }" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="users" + />{{ $t("nav.public_tl") }} + </router-link> + </li> + <li v-if="federating && (currentUser || !privateMode)"> + <router-link + class="menu-item" + :to="{ name: 'public-external-timeline' }" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="globe" + />{{ $t("nav.twkn") }} + </router-link> + </li> + <li v-if="currentUser"> + <router-link + class="menu-item" + :to="{ name: 'bookmarks'}" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="bookmark" + />{{ $t("nav.bookmarks") }} + </router-link> + </li> + <li v-if="currentUser"> + <router-link + class="menu-item" + :to="{ name: 'dms', params: { username: currentUser.screen_name } }" + > + <FAIcon + fixed-width + class="fa-scale-110 fa-old-padding " + icon="envelope" + />{{ $t("nav.dms") }} + </router-link> + </li> + </ul> +</template> + +<script src="./timeline_menu_content.js" ></script> diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue index 0f7c584b..4040e263 100644 --- a/src/components/user_avatar/user_avatar.vue +++ b/src/components/user_avatar/user_avatar.vue @@ -2,8 +2,8 @@ <StillImage v-if="user" class="Avatar" - :alt="user.screen_name" - :title="user.screen_name" + :alt="user.screen_name_ui" + :title="user.screen_name_ui" :src="imgSrc(user.profile_image_url_original)" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" :image-load-error="imageLoadError" diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 3a8efafc..4168c54a 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -4,23 +4,25 @@ import ProgressButton from '../progress_button/progress_button.vue' import FollowButton from '../follow_button/follow_button.vue' import ModerationTools from '../moderation_tools/moderation_tools.vue' import AccountActions from '../account_actions/account_actions.vue' +import Select from '../select/select.vue' +import RichContent from 'src/components/rich_content/rich_content.jsx' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { faBell, faRss, - faChevronDown, faSearchPlus, - faExternalLinkAlt + faExternalLinkAlt, + faEdit } from '@fortawesome/free-solid-svg-icons' library.add( faRss, faBell, - faChevronDown, faSearchPlus, - faExternalLinkAlt + faExternalLinkAlt, + faEdit ) export default { @@ -118,7 +120,9 @@ export default { ModerationTools, AccountActions, ProgressButton, - FollowButton + FollowButton, + Select, + RichContent }, methods: { muteUser () { @@ -153,13 +157,16 @@ export default { this.$store.state.instance.restrictedNicknames ) }, + openProfileTab () { + this.$store.dispatch('openSettingsModalTab', 'profile') + }, zoomAvatar () { const attachment = { url: this.user.profile_image_url_original, mimetype: 'image' } this.$store.dispatch('setMedia', [attachment]) - this.$store.dispatch('setCurrent', attachment) + this.$store.dispatch('setCurrentMedia', attachment) }, mentionUser () { this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user }) diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 773f764a..0708f387 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -38,26 +38,29 @@ </router-link> <div class="user-summary"> <div class="top-line"> - <!-- eslint-disable vue/no-v-html --> - <div - v-if="user.name_html" + <RichContent :title="user.name" class="user-name" - v-html="user.name_html" + :html="user.name" + :emoji="user.emoji" /> - <!-- eslint-enable vue/no-v-html --> - <div - v-else - :title="user.name" - class="user-name" + <button + v-if="!isOtherUser && user.is_local" + class="button-unstyled edit-profile-button" + @click.stop="openProfileTab" > - {{ user.name }} - </div> + <FAIcon + fixed-width + class="icon" + icon="edit" + :title="$t('user_card.edit_profile')" + /> + </button> <a v-if="isOtherUser && !user.is_local" :href="user.statusnet_profile_url" target="_blank" - class="external-link-button" + class="button-unstyled external-link-button" > <FAIcon class="icon" @@ -73,23 +76,29 @@ <div class="bottom-line"> <router-link class="user-screen-name" - :title="user.screen_name" + :title="user.screen_name_ui" :to="userProfileLink(user)" > - @{{ user.screen_name }} + @{{ user.screen_name_ui }} </router-link> <template v-if="!hideBio"> <span + v-if="user.deactivated" + class="alert user-role" + > + {{ $t('user_card.deactivated') }} + </span> + <span v-if="!!visibleRole" class="alert user-role" > - {{ $t(`user_card.roles.${visibleRole}`) }} + {{ $t(`general.role.${visibleRole}`) }} </span> <span v-if="user.bot" class="alert user-role" > - bot + {{ $t('user_card.bot') }} </span> </template> <span v-if="user.locked"> @@ -132,25 +141,24 @@ class="userHighlightCl" type="color" > - <label - for="theme_tab" - class="userHighlightSel select" + <Select + :id="'userHighlightSel'+user.id" + v-model="userHighlightType" + class="userHighlightSel" > - <select - :id="'userHighlightSel'+user.id" - v-model="userHighlightType" - class="userHighlightSel" - > - <option value="disabled">No highlight</option> - <option value="solid">Solid bg</option> - <option value="striped">Striped bg</option> - <option value="side">Side stripe</option> - </select> - <FAIcon - class="select-down-icon" - icon="chevron-down" - /> - </label> + <option value="disabled"> + {{ $t('user_card.highlight.disabled') }} + </option> + <option value="solid"> + {{ $t('user_card.highlight.solid') }} + </option> + <option value="striped"> + {{ $t('user_card.highlight.striped') }} + </option> + <option value="side"> + {{ $t('user_card.highlight.side') }} + </option> + </Select> </div> </div> <div @@ -158,7 +166,10 @@ class="user-interactions" > <div class="btn-group"> - <FollowButton :relationship="relationship" /> + <FollowButton + :relationship="relationship" + :user="user" + /> <template v-if="relationship.following"> <ProgressButton v-if="!relationship.subscribing" @@ -193,6 +204,7 @@ <button v-if="relationship.muting" class="btn button-default btn-block toggled" + :disabled="user.deactivated" @click="unmuteUser" > {{ $t('user_card.muted') }} @@ -200,6 +212,7 @@ <button v-else class="btn button-default btn-block" + :disabled="user.deactivated" @click="muteUser" > {{ $t('user_card.mute') }} @@ -208,6 +221,7 @@ <div> <button class="btn button-default btn-block" + :disabled="user.deactivated" @click="mentionUser" > {{ $t('user_card.mention') }} @@ -256,20 +270,13 @@ <span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span> </div> </div> - <!-- eslint-disable vue/no-v-html --> - <p - v-if="!hideBio && user.description_html" + <RichContent + v-if="!hideBio" class="user-card-bio" - @click.prevent="linkClicked" - v-html="user.description_html" + :html="user.description_html" + :emoji="user.emoji" + :handle-links="true" /> - <!-- eslint-enable vue/no-v-html --> - <p - v-else-if="!hideBio" - class="user-card-bio" - > - {{ user.description }} - </p> </div> </div> </template> @@ -282,9 +289,10 @@ .user-card { position: relative; - &:hover .Avatar { + &:hover { --_still-image-img-visibility: visible; --_still-image-canvas-visibility: hidden; + --_still-image-label-visibility: hidden; } .panel-heading { @@ -328,12 +336,12 @@ } } - p { - margin-bottom: 0; - } - &-bio { text-align: center; + display: block; + line-height: 18px; + padding: 1em; + margin: 0; a { color: $fallback--link; @@ -345,11 +353,6 @@ vertical-align: middle; max-width: 100%; max-height: 400px; - - &.emoji { - width: 32px; - height: 32px; - } } } @@ -427,7 +430,7 @@ } } - .external-link-button { + .external-link-button, .edit-profile-button { cursor: pointer; width: 2.5em; text-align: center; @@ -451,13 +454,6 @@ // big one z-index: 1; - img { - width: 26px; - height: 26px; - vertical-align: middle; - object-fit: contain - } - .top-line { display: flex; } @@ -470,12 +466,7 @@ margin-right: 1em; font-size: 15px; - img { - object-fit: contain; - height: 16px; - width: 16px; - vertical-align: middle; - } + --emoji-size: 14px; } .bottom-line { @@ -541,15 +532,11 @@ flex: 1 0 auto; } - .userHighlightSel, - .userHighlightSel.select { + .userHighlightSel { padding-top: 0; padding-bottom: 0; flex: 1 0 auto; } - .userHighlightSel.select svg { - line-height: 22px; - } .userHighlightText { width: 70px; @@ -558,9 +545,7 @@ .userHighlightCl, .userHighlightText, - .userHighlightSel, - .userHighlightSel.select { - height: 22px; + .userHighlightSel { vertical-align: top; margin-right: .5em; margin-bottom: .25em; @@ -585,6 +570,10 @@ } } +.sidebar .edit-profile-button { + display: none; +} + .user-counts { display: flex; line-height:16px; diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue index 95673733..8706d0ff 100644 --- a/src/components/user_list_popover/user_list_popover.vue +++ b/src/components/user_list_popover/user_list_popover.vue @@ -4,40 +4,44 @@ placement="top" :offset="{ y: 5 }" > - <template slot="trigger"> + <template v-slot:trigger> <slot /> </template> - <div - slot="content" - class="user-list-popover" - > - <div v-if="users.length"> - <div - v-for="(user) in usersCapped" - :key="user.id" - class="user-list-row" - > - <UserAvatar - :user="user" - class="avatar-small" - :compact="true" - /> - <div class="user-list-names"> - <!-- eslint-disable vue/no-v-html --> - <span v-html="user.name_html" /> - <!-- eslint-enable vue/no-v-html --> - <span class="user-list-screen-name">{{ user.screen_name }}</span> + <template v-slot:content> + <div class="user-list-popover"> + <template v-if="users.length"> + <div + v-for="(user) in usersCapped" + :key="user.id" + class="user-list-row" + > + <UserAvatar + :user="user" + class="avatar-small" + :compact="true" + /> + <div class="user-list-names"> + <!-- eslint-disable vue/no-v-html --> + <RichContent + class="username" + :title="'@'+user.screen_name_ui" + :html="user.name_html" + :emoji="user.emoji" + /> + <!-- eslint-enable vue/no-v-html --> + <span class="user-list-screen-name">{{ user.screen_name_ui }}</span> + </div> </div> - </div> - </div> - <div v-else> - <FAIcon - icon="circle-notch" - spin - size="3x" - /> + </template> + <template v-else> + <FAIcon + icon="circle-notch" + spin + size="3x" + /> + </template> </div> - </div> + </template> </Popover> </template> @@ -49,6 +53,8 @@ .user-list-popover { padding: 0.5em; + --emoji-size: 16px; + .user-list-row { padding: 0.25em; display: flex; diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index c0b55a6c..7a475609 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -4,6 +4,7 @@ import FollowCard from '../follow_card/follow_card.vue' import Timeline from '../timeline/timeline.vue' import Conversation from '../conversation/conversation.vue' import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js' +import RichContent from 'src/components/rich_content/rich_content.jsx' import List from '../list/list.vue' import withLoadMore from '../../hocs/with_load_more/with_load_more' import { library } from '@fortawesome/fontawesome-svg-core' @@ -164,7 +165,8 @@ const UserProfile = { FriendList, FollowCard, TabSwitcher, - Conversation + Conversation, + RichContent } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 745e795d..726216ff 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -20,20 +20,24 @@ :key="index" class="user-profile-field" > - <!-- eslint-disable vue/no-v-html --> <dt :title="user.fields_text[index].name" class="user-profile-field-name" - @click.prevent="linkClicked" - v-html="field.name" - /> + > + <RichContent + :html="field.name" + :emoji="user.emoji" + /> + </dt> <dd :title="user.fields_text[index].value" class="user-profile-field-value" - @click.prevent="linkClicked" - v-html="field.value" - /> - <!-- eslint-enable vue/no-v-html --> + > + <RichContent + :html="field.value" + :emoji="user.emoji" + /> + </dd> </dl> </div> <tab-switcher @@ -60,10 +64,7 @@ :disabled="!user.friends_count" > <FriendList :user-id="userId"> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <FollowCard :user="item" /> </template> </FriendList> @@ -75,10 +76,7 @@ :disabled="!user.followers_count" > <FollowerList :user-id="userId"> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <FollowCard :user="item" :no-follows-you="isUs" diff --git a/src/components/user_reporting_modal/user_reporting_modal.vue b/src/components/user_reporting_modal/user_reporting_modal.vue index fb43094f..1f67a5cc 100644 --- a/src/components/user_reporting_modal/user_reporting_modal.vue +++ b/src/components/user_reporting_modal/user_reporting_modal.vue @@ -6,7 +6,7 @@ <div class="user-reporting-panel panel"> <div class="panel-heading"> <div class="title"> - {{ $t('user_reporting.title', [user.screen_name]) }} + {{ $t('user_reporting.title', [user.screen_name_ui]) }} </div> </div> <div class="panel-body"> @@ -45,10 +45,7 @@ </div> <div class="user-reporting-panel-right"> <List :items="statuses"> - <template - slot="item" - slot-scope="{item}" - > + <template v-slot:item="{item}"> <div class="status-fadein user-reporting-panel-sitem"> <Status :in-conversation="false" |
