logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/
commit: ebd3b7d9f569186a612b5f019deaa895c5cf3417
parent eea173cf7e0651bedda62aac5ba6d77cbbf0ef1e
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date:   Tue, 21 Jan 2025 09:42:36 +0000

Merge branch 'customizable-post-actions' into 'develop'

Customizable post actions

See merge request pleroma/pleroma-fe!1985

Diffstat:

Achangelog.d/customizable-actions.add1+
Msrc/App.scss69++++++++++++++++++++++++++++++++++++---------------------------------
Msrc/components/account_actions/account_actions.vue100+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/components/chat_message/chat_message.vue14++++++++------
Asrc/components/confirm_modal/mute_confirm.js111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/confirm_modal/mute_confirm.vue61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/conversation/conversation.js1+
Msrc/components/conversation/conversation.vue2+-
Dsrc/components/extra_buttons/extra_buttons.js175-------------------------------------------------------------------------------
Dsrc/components/extra_buttons/extra_buttons.vue238-------------------------------------------------------------------------------
Dsrc/components/favorite_button/favorite_button.js49-------------------------------------------------
Dsrc/components/favorite_button/favorite_button.vue114-------------------------------------------------------------------------------
Msrc/components/input.style.js2+-
Msrc/components/moderation_tools/moderation_tools.vue219+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/components/notifications/notification_filters.vue160+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Msrc/components/popover/popover.js45+++++++++++++++++++++++++--------------------
Asrc/components/popover/popover.scss142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/popover/popover.vue101+++----------------------------------------------------------------------------
Msrc/components/post_status_form/post_status_form.vue2+-
Msrc/components/quick_filter_settings/quick_filter_settings.js24++++++++++++++++++++++--
Msrc/components/quick_filter_settings/quick_filter_settings.vue213+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/components/quick_view_settings/quick_view_settings.js11++++++++---
Msrc/components/quick_view_settings/quick_view_settings.vue166+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Dsrc/components/react_button/react_button.js54------------------------------------------------------
Dsrc/components/react_button/react_button.vue115-------------------------------------------------------------------------------
Dsrc/components/reply_button/reply_button.js27---------------------------
Dsrc/components/reply_button/reply_button.vue96-------------------------------------------------------------------------------
Dsrc/components/retweet_button/retweet_button.js68--------------------------------------------------------------------
Dsrc/components/retweet_button/retweet_button.vue133-------------------------------------------------------------------------------
Msrc/components/settings_modal/admin_tabs/frontends_tab.vue56++++++++++++++++++++++++++++++++++----------------------
Msrc/components/settings_modal/settings_modal.vue66++++++++++++++++++++++++++++++++++++------------------------------
Msrc/components/settings_modal/tabs/general_tab.vue10++++++++++
Msrc/components/status/status.js14+++-----------
Msrc/components/status/status.scss10++++------
Msrc/components/status/status.vue41+++++------------------------------------
Asrc/components/status_action_buttons/action_button.js133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/action_button.scss102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/action_button.vue107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/action_button_container.js89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/action_button_container.vue103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/buttons_definitions.js228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/status_action_buttons.js136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_action_buttons/status_action_buttons.scss27+++++++++++++++++++++++++++
Asrc/components/status_action_buttons/status_action_buttons.vue131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue50++++++++++++++++----------------------------------
Msrc/components/timeline/timeline.vue1+
Msrc/components/user_card/user_card.js31+++----------------------------
Msrc/components/user_card/user_card.scss5-----
Msrc/components/user_card/user_card.vue50+++++---------------------------------------------
Msrc/components/user_list_menu/user_list_menu.js5+++++
Msrc/components/user_list_menu/user_list_menu.vue29+++++++++++++++++------------
Msrc/i18n/en.json13++++++++++++-
Msrc/modules/config.js2++
Msrc/modules/instance.js2++
Msrc/modules/serverSideStorage.js41+++++++++++++++++++++++++++++++++++------
Mtest/unit/specs/modules/serverSideStorage.spec.js18+++++++++---------
56 files changed, 2174 insertions(+), 1839 deletions(-)

diff --git a/changelog.d/customizable-actions.add b/changelog.d/customizable-actions.add @@ -0,0 +1 @@ +Post actions can be customized diff --git a/src/App.scss b/src/App.scss @@ -408,32 +408,11 @@ nav { } } -.menu-item, .list-item { - display: block; - box-sizing: border-box; - border: none; - outline: none; - text-align: initial; - color: inherit; - clear: both; - position: relative; - white-space: nowrap; border-color: var(--border); border-style: solid; border-width: 0; border-top-width: 1px; - width: 100%; - padding: var(--__vertical-gap) var(--__horizontal-gap); - background: transparent; - - --__line-height: 1.5em; - --__horizontal-gap: 0.75em; - --__vertical-gap: 0.5em; - - &.-non-interactive { - cursor: auto; - } &.-active, &:hover { @@ -455,18 +434,6 @@ nav { border-bottom-width: 1px; } - a, - button:not(.button-default) { - text-align: initial; - padding: 0; - background: none; - border: none; - outline: none; - display: inline; - font-family: inherit; - line-height: unset; - } - &:first-child { border-top-right-radius: var(--roundness); border-top-left-radius: var(--roundness); @@ -480,6 +447,42 @@ nav { } } +.menu-item, +.list-item { + display: block; + box-sizing: border-box; + border: none; + outline: none; + text-align: initial; + color: inherit; + clear: both; + position: relative; + white-space: nowrap; + width: 100%; + padding: var(--__vertical-gap) var(--__horizontal-gap); + background: transparent; + + --__line-height: 1.5em; + --__horizontal-gap: 0.75em; + --__vertical-gap: 0.5em; + + &.-non-interactive { + cursor: auto; + } + + a, + button:not(.button-default) { + text-align: initial; + padding: 0; + background: none; + border: none; + outline: none; + display: inline; + font-family: inherit; + line-height: unset; + } +} + .button-unstyled { border: none; outline: none; diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue @@ -9,60 +9,80 @@ <template #content> <div class="dropdown-menu"> <template v-if="relationship.following"> - <button + <div v-if="relationship.showing_reblogs" - class="dropdown-item menu-item" - @click="hideRepeats" + class="menu-item dropdown-item" > - {{ $t('user_card.hide_repeats') }} - </button> - <button + <button + class="main-button" + @click="hideRepeats" + > + {{ $t('user_card.hide_repeats') }} + </button> + </div> + <div v-if="!relationship.showing_reblogs" - class="dropdown-item menu-item" - @click="showRepeats" + class="menu-item dropdown-item" > - {{ $t('user_card.show_repeats') }} - </button> + <button + class="main-button" + @click="showRepeats" + > + {{ $t('user_card.show_repeats') }} + </button> + </div> <div role="separator" class="dropdown-divider" /> </template> <UserListMenu :user="user" /> - <button + <div v-if="relationship.followed_by" - class="dropdown-item menu-item" - @click="removeUserFromFollowers" + class="menu-item dropdown-item" > - {{ $t('user_card.remove_follower') }} - </button> - <button - v-if="relationship.blocking" - class="dropdown-item menu-item" - @click="unblockUser" - > - {{ $t('user_card.unblock') }} - </button> - <button - v-else - class="dropdown-item menu-item" - @click="blockUser" - > - {{ $t('user_card.block') }} - </button> - <button - class="dropdown-item menu-item" - @click="reportUser" - > - {{ $t('user_card.report') }} - </button> - <button + <button + class="main-button" + @click="removeUserFromFollowers" + > + {{ $t('user_card.remove_follower') }} + </button> + </div> + <div class="menu-item dropdown-item"> + <button + v-if="relationship.blocking" + class="main-button" + @click="unblockUser" + > + {{ $t('user_card.unblock') }} + </button> + <button + v-else + class="main-button" + @click="blockUser" + > + {{ $t('user_card.block') }} + </button> + </div> + <div class="menu-item dropdown-item"> + <button + class="main-button" + @click="reportUser" + > + {{ $t('user_card.report') }} + </button> + </div> + <div v-if="pleromaChatMessagesAvailable" - class="dropdown-item menu-item" - @click="openChat" + class="menu-item dropdown-item" > - {{ $t('user_card.message') }} - </button> + <button + class="main-button" + @click="openChat" + > + {{ $t('user_card.message') }} + </button> + </div> </div> </template> <template #trigger> diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue @@ -51,12 +51,14 @@ > <template #content> <div class="dropdown-menu"> - <button - class="menu-item dropdown-item dropdown-item-icon" - @click="deleteMessage" - > - <FAIcon icon="times" /> {{ $t("chats.delete") }} - </button> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="deleteMessage" + > + <FAIcon icon="times" /> {{ $t("chats.delete") }} + </button> + </div> </div> </template> <template #trigger> diff --git a/src/components/confirm_modal/mute_confirm.js b/src/components/confirm_modal/mute_confirm.js @@ -0,0 +1,111 @@ +import { unitToSeconds } from 'src/services/date_utils/date_utils.js' +import { mapGetters } from 'vuex' + +import ConfirmModal from './confirm_modal.vue' +import Select from 'src/components/select/select.vue' + +export default { + props: ['type', 'user'], + emits: ['hide', 'show', 'muted'], + data: () => ({ + showing: false, + muteExpiryAmount: 2, + muteExpiryUnit: 'hours' + }), + components: { + ConfirmModal, + Select + }, + computed: { + muteExpiryValue () { + unitToSeconds(this.muteExpiryUnit, this.muteExpiryAmount) + }, + muteExpiryUnits () { + return ['minutes', 'hours', 'days'] + }, + domain () { + return this.user.fqn.split('@')[1] + }, + keypath () { + if (this.type === 'domain') { + return 'status.mute_domain_confirm' + } else if (this.type === 'conversation') { + return 'status.mute_conversation_confirm' + } else { + return 'user_card.mute_confirm' + } + }, + userIsMuted () { + return this.$store.getters.relationship(this.user.id).muting + }, + conversationIsMuted () { + return this.status.conversation_muted + }, + domainIsMuted () { + return new Set(this.$store.state.users.currentUser.domainMutes).has(this.domain) + }, + shouldConfirm () { + switch (this.type) { + case 'domain': { + return this.mergedConfig.modalOnMuteDomain + } + case 'conversation': { + return this.mergedConfig.modalOnMuteConversation + } + default: { + return this.mergedConfig.modalOnMute + } + } + }, + ...mapGetters(['mergedConfig']) + }, + methods: { + optionallyPrompt () { + console.log('Triggered') + if (this.shouldConfirm) { + console.log('SHAWN!!') + this.show() + } else { + this.doMute() + } + }, + show () { + this.showing = true + this.$emit('show') + }, + hide () { + this.showing = false + this.$emit('hide') + }, + doMute () { + switch (this.type) { + case 'domain': { + if (!this.domainIsMuted) { + this.$store.dispatch('muteDomain', { id: this.domain, expiresIn: this.muteExpiryValue }) + } else { + this.$store.dispatch('unmuteDomain', { id: this.domain }) + } + break + } + case 'conversation': { + if (!this.conversationIsMuted) { + this.$store.dispatch('muteConversation', { id: this.status.id, expiresIn: this.muteExpiryValue }) + } else { + this.$store.dispatch('unmuteConversation', { id: this.status.id }) + } + break + } + default: { + if (!this.userIsMuted) { + this.$store.dispatch('muteUser', { id: this.user.id, expiresIn: this.muteExpiryValue }) + } else { + this.$store.dispatch('unmuteUser', { id: this.user.id }) + } + break + } + } + this.$emit('muted') + this.hide() + } + } +} diff --git a/src/components/confirm_modal/mute_confirm.vue b/src/components/confirm_modal/mute_confirm.vue @@ -0,0 +1,61 @@ +<template> + <confirm-modal + v-if="showing" + :title="$t('user_card.mute_confirm_title')" + :confirm-text="$t('user_card.mute_confirm_accept_button')" + :cancel-text="$t('user_card.mute_confirm_cancel_button')" + @accepted="doMute" + @cancelled="hide" + > + <i18n-t + :keypath="keypath" + tag="div" + > + <template #domain> + <span v-text="domain" /> + </template> + <template #user> + <span v-text="user.screen_name_ui" /> + </template> + </i18n-t> + <div + v-if="type !== 'domain'" + class="mute-expiry" + > + <p> + <label> + {{ $t('user_card.mute_duration_prompt') }} + </label> + <input + v-model="muteExpiryAmount" + type="number" + class="input expiry-amount hide-number-spinner" + :min="0" + > + {{ ' ' }} + <Select + v-model="muteExpiryUnit" + unstyled="true" + class="expiry-unit" + > + <option + v-for="unit in muteExpiryUnits" + :key="unit" + :value="unit" + > + {{ $t(`time.unit.${unit}_short`, ['']) }} + </option> + </Select> + </p> + </div> + </confirm-modal> +</template> + +<script src="./mute_confirm.js" /> + +<style lang="scss"> +.expiry-amount { + width: 4em; + text-align: right; +} +</style> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js @@ -350,6 +350,7 @@ const conversation = { }, ...mapGetters(['mergedConfig']), ...mapState({ + mobileLayout: state => state.interface.layoutType === 'mobile', mastoUserSocketStatus: state => state.api.mastoUserSocketStatus }) }, diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue @@ -20,7 +20,7 @@ {{ $t('timeline.collapse') }} </button> <QuickFilterSettings - v-if="!collapsable" + v-if="!collapsable && mobileLayout" :conversation="true" class="rightside-button" /> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js @@ -1,175 +0,0 @@ -import Popover from '../popover/popover.vue' -import genRandomSeed from '../../services/random_seed/random_seed.service.js' -import ConfirmModal from '../confirm_modal/confirm_modal.vue' -import StatusBookmarkFolderMenu from '../status_bookmark_folder_menu/status_bookmark_folder_menu.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faEllipsisH, - faBookmark, - faEyeSlash, - faThumbtack, - faShareAlt, - faExternalLinkAlt, - faHistory, - faPlus, - faTimes -} from '@fortawesome/free-solid-svg-icons' -import { - faBookmark as faBookmarkReg, - faFlag -} from '@fortawesome/free-regular-svg-icons' - -library.add( - faEllipsisH, - faBookmark, - faBookmarkReg, - faEyeSlash, - faThumbtack, - faShareAlt, - faExternalLinkAlt, - faFlag, - faHistory, - faPlus, - faTimes -) - -const ExtraButtons = { - props: ['status'], - components: { - Popover, - ConfirmModal, - StatusBookmarkFolderMenu - }, - data () { - return { - expanded: false, - showingDeleteDialog: false, - randomSeed: genRandomSeed() - } - }, - methods: { - onShow () { - this.expanded = true - }, - onClose () { - this.expanded = false - }, - deleteStatus () { - if (this.shouldConfirmDelete) { - this.showDeleteStatusConfirmDialog() - } else { - this.doDeleteStatus() - } - }, - doDeleteStatus () { - this.$store.dispatch('deleteStatus', { id: this.status.id }) - this.hideDeleteStatusConfirmDialog() - }, - showDeleteStatusConfirmDialog () { - this.showingDeleteDialog = true - }, - hideDeleteStatusConfirmDialog () { - this.showingDeleteDialog = false - }, - pinStatus () { - this.$store.dispatch('pinStatus', this.status.id) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - unpinStatus () { - this.$store.dispatch('unpinStatus', this.status.id) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - muteConversation () { - this.$store.dispatch('muteConversation', this.status.id) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - unmuteConversation () { - this.$store.dispatch('unmuteConversation', this.status.id) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - copyLink () { - navigator.clipboard.writeText(this.statusLink) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - bookmarkStatus () { - this.$store.dispatch('bookmark', { id: this.status.id }) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - unbookmarkStatus () { - this.$store.dispatch('unbookmark', { id: this.status.id }) - .then(() => this.$emit('onSuccess')) - .catch(err => this.$emit('onError', err.error.error)) - }, - reportStatus () { - this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] }) - }, - editStatus () { - this.$store.dispatch('fetchStatusSource', { id: this.status.id }) - .then(data => this.$store.dispatch('openEditStatusModal', { - statusId: this.status.id, - subject: data.spoiler_text, - statusText: data.text, - statusIsSensitive: this.status.nsfw, - statusPoll: this.status.poll, - statusFiles: [...this.status.attachments], - visibility: this.status.visibility, - statusContentType: data.content_type - })) - }, - showStatusHistory () { - const originalStatus = { ...this.status } - const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html'] - stripFieldsList.forEach(p => delete originalStatus[p]) - this.$store.dispatch('openStatusHistoryModal', originalStatus) - } - }, - computed: { - currentUser () { return this.$store.state.users.currentUser }, - canDelete () { - if (!this.currentUser) { return } - return this.currentUser.privileges.includes('messages_delete') || this.status.user.id === this.currentUser.id - }, - ownStatus () { - return this.status.user.id === this.currentUser.id - }, - canPin () { - return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted') - }, - canMute () { - return !!this.currentUser - }, - canBookmark () { - return !!this.currentUser - }, - bookmarkFolders () { - return this.$store.state.instance.pleromaBookmarkFoldersAvailable - }, - statusLink () { - return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}` - }, - isEdited () { - return this.status.edited_at !== null - }, - editingAvailable () { return this.$store.state.instance.editingAvailable }, - shouldConfirmDelete () { - return this.$store.getters.mergedConfig.modalOnDelete - }, - triggerAttrs () { - return { - title: this.$t('status.more_actions'), - id: `popup-trigger-${this.randomSeed}`, - 'aria-controls': `popup-menu-${this.randomSeed}`, - 'aria-expanded': this.expanded, - 'aria-haspopup': 'menu' - } - } - } -} - -export default ExtraButtons diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue @@ -1,238 +0,0 @@ -<template> - <Popover - class="ExtraButtons" - trigger="click" - :trigger-attrs="triggerAttrs" - placement="top" - :offset="{ y: 5 }" - :bound-to="{ x: 'container' }" - remove-padding - @show="onShow" - @close="onClose" - > - <template #content="{close}"> - <div - :id="`popup-menu-${randomSeed}`" - class="dropdown-menu" - role="menu" - > - <button - v-if="canMute && !status.thread_muted" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="muteConversation" - > - <FAIcon - fixed-width - icon="eye-slash" - /><span>{{ $t("status.mute_conversation") }}</span> - </button> - <button - v-if="canMute && status.thread_muted" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="unmuteConversation" - > - <FAIcon - fixed-width - icon="eye-slash" - /><span>{{ $t("status.unmute_conversation") }}</span> - </button> - <button - v-if="!status.pinned && canPin" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="pinStatus" - @click="close" - > - <FAIcon - fixed-width - icon="thumbtack" - /><span>{{ $t("status.pin") }}</span> - </button> - <button - v-if="status.pinned && canPin" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="unpinStatus" - @click="close" - > - <FAIcon - fixed-width - icon="thumbtack" - /><span>{{ $t("status.unpin") }}</span> - </button> - <template v-if="canBookmark"> - <button - v-if="!status.bookmarked" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="bookmarkStatus" - @click="close" - > - <FAIcon - fixed-width - :icon="['far', 'bookmark']" - /><span>{{ $t("status.bookmark") }}</span> - </button> - <button - v-if="status.bookmarked" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="unbookmarkStatus" - @click="close" - > - <FAIcon - fixed-width - icon="bookmark" - /><span>{{ $t("status.unbookmark") }}</span> - </button> - <StatusBookmarkFolderMenu - v-if="status.bookmarked && bookmarkFolders" - :status="status" - /> - </template> - <button - v-if="ownStatus && editingAvailable" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="editStatus" - @click="close" - > - <FAIcon - fixed-width - icon="pen" - /><span>{{ $t("status.edit") }}</span> - </button> - <button - v-if="isEdited && editingAvailable" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="showStatusHistory" - @click="close" - > - <FAIcon - fixed-width - icon="history" - /><span>{{ $t("status.status_history") }}</span> - </button> - <button - v-if="canDelete" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="deleteStatus" - @click="close" - > - <FAIcon - fixed-width - icon="times" - /><span>{{ $t("status.delete") }}</span> - </button> - <button - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="copyLink" - @click="close" - > - <FAIcon - fixed-width - icon="share-alt" - /><span>{{ $t("status.copy_link") }}</span> - </button> - <a - v-if="!status.is_local" - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - title="Source" - :href="status.external_url" - target="_blank" - > - <FAIcon - fixed-width - icon="external-link-alt" - /><span>{{ $t("status.external_source") }}</span> - </a> - <button - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click.prevent="reportStatus" - @click="close" - > - <FAIcon - fixed-width - :icon="['far', 'flag']" - /><span>{{ $t("user_card.report") }}</span> - </button> - </div> - </template> - <template #trigger> - <span class="button-unstyled popover-trigger"> - <FALayers class="fa-old-padding-layer"> - <FAIcon - class="fa-scale-110 " - icon="ellipsis-h" - /> - <FAIcon - v-show="!expanded" - class="focus-marker" - transform="shrink-6 up-8 right-16" - icon="plus" - /> - <FAIcon - v-show="expanded" - class="focus-marker" - transform="shrink-6 up-8 right-16" - icon="times" - /> - </FALayers> - </span> - <teleport to="#modal"> - <ConfirmModal - v-if="showingDeleteDialog" - :title="$t('status.delete_confirm_title')" - :cancel-text="$t('status.delete_confirm_cancel_button')" - :confirm-text="$t('status.delete_confirm_accept_button')" - @cancelled="hideDeleteStatusConfirmDialog" - @accepted="doDeleteStatus" - > - {{ $t('status.delete_confirm') }} - </ConfirmModal> - </teleport> - </template> - </Popover> -</template> - -<script src="./extra_buttons.js"></script> - -<style lang="scss"> -@import "../../mixins"; - -.ExtraButtons { - .popover-trigger { - position: static; - padding: 10px; - margin: -10px; - - &:hover .svg-inline--fa { - color: var(--text); - } - } - - .popover-trigger-button { - /* override of popover internal stuff */ - width: auto; - - @include unfocused-style { - .focus-marker { - visibility: hidden; - } - } - - @include focused-style { - .focus-marker { - visibility: visible; - } - } - } -} -</style> diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js @@ -1,49 +0,0 @@ -import { mapGetters } from 'vuex' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faStar, - faPlus, - faMinus, - faCheck -} from '@fortawesome/free-solid-svg-icons' -import { - faStar as faStarRegular -} from '@fortawesome/free-regular-svg-icons' - -library.add( - faStar, - faStarRegular, - faPlus, - faMinus, - faCheck -) - -const FavoriteButton = { - props: ['status', 'loggedIn'], - data () { - return { - animated: false - } - }, - methods: { - favorite () { - if (!this.status.favorited) { - this.$store.dispatch('favorite', { id: this.status.id }) - } else { - this.$store.dispatch('unfavorite', { id: this.status.id }) - } - this.animated = true - setTimeout(() => { - this.animated = false - }, 500) - } - }, - computed: { - ...mapGetters(['mergedConfig']), - remoteInteractionLink () { - return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) - } - } -} - -export default FavoriteButton diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue @@ -1,114 +0,0 @@ -<template> - <div class="FavoriteButton"> - <button - v-if="loggedIn" - class="button-unstyled interactive" - :class="status.favorited && '-favorited'" - :title="$t('tool_tip.favorite')" - @click.prevent="favorite()" - > - <FALayers class="fa-scale-110 fa-old-padding-layer"> - <FAIcon - class="fa-scale-110" - :icon="[status.favorited ? 'fas' : 'far', 'star']" - :spin="animated" - /> - <FAIcon - v-if="status.favorited" - class="active-marker" - transform="shrink-6 up-9 right-12" - icon="check" - /> - <FAIcon - v-if="!status.favorited" - class="focus-marker" - transform="shrink-6 up-9 right-12" - icon="plus" - /> - <FAIcon - v-else - class="focus-marker" - transform="shrink-6 up-9 right-12" - icon="minus" - /> - </FALayers> - </button> - <a - v-else - class="button-unstyled interactive" - target="_blank" - role="button" - :title="$t('tool_tip.favorite')" - :href="remoteInteractionLink" - > - <FALayers class="fa-scale-110 fa-old-padding-layer"> - <FAIcon - class="fa-scale-110" - :icon="['far', 'star']" - /> - <FAIcon - class="focus-marker" - transform="shrink-6 up-9 right-12" - icon="plus" - /> - </FALayers> - </a> - <span - v-if="!mergedConfig.hidePostStats && status.fave_num > 0" - class="action-counter" - > - {{ status.fave_num }} - </span> - </div> -</template> - -<script src="./favorite_button.js"></script> - -<style lang="scss"> -@import "../../mixins"; - -.FavoriteButton { - display: flex; - - > :first-child { - padding: 10px; - margin: -10px -8px -10px -10px; - } - - .action-counter { - pointer-events: none; - user-select: none; - } - - .interactive { - .svg-inline--fa { - animation-duration: 0.6s; - } - - &:hover .svg-inline--fa, - &.-favorited .svg-inline--fa { - color: var(--cOrange); - } - - @include unfocused-style { - .focus-marker { - visibility: hidden; - } - - .active-marker { - visibility: visible; - } - } - - @include focused-style { - .focus-marker { - visibility: visible; - } - - .active-marker { - visibility: hidden; - } - } - } -} -</style> diff --git a/src/components/input.style.js b/src/components/input.style.js @@ -18,7 +18,7 @@ export default { { component: 'Root', directives: { - '--defaultInputBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2), $borderSide(#000000 top 0.2), inset 0 0 2 #000000 / 0.15', + '--defaultInputBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2), $borderSide(#000000 top 0.2), inset 0 0 2 #000000 / 0.15, 1 0 1 1 --text / 0.15, -1 0 1 1 --text / 0.15', '--defaultInputHoverGlow': 'shadow | 0 0 4 --text / 0.5', '--defaultInputFocusGlow': 'shadow | 0 0 4 4 --link / 0.5' } diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue @@ -10,119 +10,150 @@ > <template #content> <div class="dropdown-menu"> - <span v-if="canGrantRole"> - <button - class="menu-item dropdown-item menu-item" - @click="toggleRight(&quot;admin&quot;)" - > - {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }} - </button> - <button - class="menu-item dropdown-item menu-item" - @click="toggleRight(&quot;moderator&quot;)" - > - {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} - </button> + <template v-if="canGrantRole"> + <div class="menu-item dropdown-item -icon-space"> + <button + class="main-button" + @click="toggleRight(&quot;admin&quot;)" + > + {{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }} + </button> + </div> + <div class="menu-item dropdown-item -icon-space"> + <button + class="main-button" + @click="toggleRight(&quot;moderator&quot;)" + > + {{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }} + </button> + </div> <div v-if="canChangeActivationState || canDeleteAccount" role="separator" class="dropdown-divider" /> - </span> - <button + </template> + <div v-if="canChangeActivationState" - class="menu-item dropdown-item menu-item" - @click="toggleActivationStatus()" - > - {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} - </button> - <button - v-if="canDeleteAccount" - class="menu-item dropdown-item menu-item" - @click="deleteUserDialog(true)" + class="menu-item dropdown-item -icon-space" > - {{ $t('user_card.admin_menu.delete_account') }} - </button> - <div - v-if="canUseTagPolicy" - role="separator" - class="dropdown-divider" - /> - <span v-if="canUseTagPolicy"> - <button - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.FORCE_NSFW)" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }" - /> - {{ $t('user_card.admin_menu.force_nsfw') }} - </button> - <button - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.STRIP_MEDIA)" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }" - /> - {{ $t('user_card.admin_menu.strip_media') }} - </button> <button - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.FORCE_UNLISTED)" + class="main-button" + @click="toggleActivationStatus()" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }" - /> - {{ $t('user_card.admin_menu.force_unlisted') }} + {{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }} </button> + </div> + <div + v-if="canDeleteAccount" + class="menu-item dropdown-item -icon-space" + > <button - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.SANDBOX)" + class="main-button" + @click="deleteUserDialog(true)" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }" - /> - {{ $t('user_card.admin_menu.sandbox') }} + {{ $t('user_card.admin_menu.delete_account') }} </button> - <button + </div> + <template v-if="canUseTagPolicy"> + <div + role="separator" + class="dropdown-divider" + /> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleTag(tags.FORCE_NSFW)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }" + /> + {{ $t('user_card.admin_menu.force_nsfw') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleTag(tags.STRIP_MEDIA)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }" + /> + {{ $t('user_card.admin_menu.strip_media') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleTag(tags.FORCE_UNLISTED)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }" + /> + {{ $t('user_card.admin_menu.force_unlisted') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleTag(tags.SANDBOX)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }" + /> + {{ $t('user_card.admin_menu.sandbox') }} + </button> + </div> + <div v-if="user.is_local" - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)" + class="menu-item dropdown-item -icon" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }" - /> - {{ $t('user_card.admin_menu.disable_remote_subscription') }} - </button> - <button + <button + class="main-button" + @click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }" + /> + {{ $t('user_card.admin_menu.disable_remote_subscription') }} + </button> + </div> + <div v-if="user.is_local" - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)" + class="menu-item dropdown-item -icon" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }" - /> - {{ $t('user_card.admin_menu.disable_any_subscription') }} - </button> - <button + <button + class="main-button" + @click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }" + /> + {{ $t('user_card.admin_menu.disable_any_subscription') }} + </button> + </div> + <div v-if="user.is_local" - class="menu-item dropdown-item menu-item" - @click="toggleTag(tags.QUARANTINE)" + class="menu-item dropdown-item -icon" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }" - /> - {{ $t('user_card.admin_menu.quarantine') }} - </button> - </span> + <button + class="main-button" + @click="toggleTag(tags.QUARANTINE)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }" + /> + {{ $t('user_card.admin_menu.quarantine') }} + </button> + </div> + </template> </div> </template> <template #trigger> diff --git a/src/components/notifications/notification_filters.vue b/src/components/notifications/notification_filters.vue @@ -7,78 +7,94 @@ > <template #content> <div class="dropdown-menu"> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('likes')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.likes }" - />{{ $t('settings.notification_visibility_likes') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('repeats')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.repeats }" - />{{ $t('settings.notification_visibility_repeats') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('follows')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.follows }" - />{{ $t('settings.notification_visibility_follows') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('mentions')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.mentions }" - />{{ $t('settings.notification_visibility_mentions') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('statuses')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.statuses }" - />{{ $t('settings.notification_visibility_statuses') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('emojiReactions')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.emojiReactions }" - />{{ $t('settings.notification_visibility_emoji_reactions') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('moves')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.moves }" - />{{ $t('settings.notification_visibility_moves') }} - </button> - <button - class="menu-item dropdown-item" - @click="toggleNotificationFilter('polls')" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': filters.polls }" - />{{ $t('settings.notification_visibility_polls') }} - </button> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('likes')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.likes }" + />{{ $t('settings.notification_visibility_likes') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('repeats')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.repeats }" + />{{ $t('settings.notification_visibility_repeats') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('follows')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.follows }" + />{{ $t('settings.notification_visibility_follows') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('mentions')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.mentions }" + />{{ $t('settings.notification_visibility_mentions') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('statuses')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.statuses }" + />{{ $t('settings.notification_visibility_statuses') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('emojiReactions')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.emojiReactions }" + />{{ $t('settings.notification_visibility_emoji_reactions') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('moves')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.moves }" + />{{ $t('settings.notification_visibility_moves') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click="toggleNotificationFilter('polls')" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': filters.polls }" + />{{ $t('settings.notification_visibility_polls') }} + </button> + </div> </div> </template> <template #trigger> diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js @@ -197,8 +197,8 @@ const Popover = { // Default to whatever user wished with placement prop let usingTop = this.placement !== 'bottom' - // Handle special cases, first force to displaying on top if there's not space on bottom, - // regardless of what placement value was. Then check if there's not space on top, and + // Handle special cases, first force to displaying on top if there's no space on bottom, + // regardless of what placement value was. Then check if there's no space on top, and // force to bottom, again regardless of what placement value was. const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0) const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0) @@ -214,20 +214,20 @@ const Popover = { translateX = origin.x + horizOffset + xOffset } else { // Default to whatever user wished with placement prop - let usingRight = this.placement !== 'left' + let usingLeft = this.placement !== 'right' - // Handle special cases, first force to displaying on top if there's not space on bottom, - // regardless of what placement value was. Then check if there's not space on top, and - // force to bottom, again regardless of what placement value was. - const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0) - const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0) - if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true - if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false + // Handle special cases, first force to displaying on left if there's no space on right, + // regardless of what placement value was. Then check if there's no space on right, and + // force to left, again regardless of what placement value was. + const leftBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0) + const rightBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0) + if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true + if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false const xOffset = (this.offset && this.offset.x) || 0 - translateX = usingRight - ? rightBoundary - xOffset - content.offsetWidth - : leftBoundary + xOffset + translateX = usingLeft + ? leftBoundary - xOffset - content.offsetWidth + : rightBoundary + xOffset const yOffset = (this.offset && this.offset.y) || 0 translateY = origin.y + vertOffset + yOffset @@ -275,6 +275,11 @@ const Popover = { this.scrollable.removeEventListener('scroll', this.onScroll) this.scrollable.removeEventListener('resize', this.onResize) }, + resizePopover () { + setTimeout(() => { + this.updateStyles() + }, 1) + }, onMouseenter (e) { if (this.trigger === 'hover') { this.lockReEntry = false @@ -323,7 +328,12 @@ const Popover = { this.updateStyles() }, onResize (e) { - this.updateStyles() + const content = this.$refs.content + if (!content) return + if (this.oldSize.width !== content.offsetWidth || this.oldSize.height !== content.offsetHeight) { + this.updateStyles() + this.oldSize = { width: content.offsetWidth, height: content.offsetHeight } + } }, onChildPopoverState (childRef, state) { if (state) { @@ -337,12 +347,7 @@ const Popover = { // Monitor changes to content size, update styles only when content sizes have changed, // that should be the only time we need to move the popover box if we don't care about scroll // or resize - const content = this.$refs.content - if (!content) return - if (this.oldSize.width !== content.offsetWidth || this.oldSize.height !== content.offsetHeight) { - this.updateStyles() - this.oldSize = { width: content.offsetWidth, height: content.offsetHeight } - } + this.onResize() }, mounted () { this.teleport = true diff --git a/src/components/popover/popover.scss b/src/components/popover/popover.scss @@ -0,0 +1,142 @@ +.popover-trigger-button { + display: inline-block; +} + +.popover { + z-index: var(--ZI_popover_override, var(--ZI_popovers)); + position: fixed; + min-width: 0; + max-width: calc(100vw - 20px); + box-shadow: var(--shadow); +} + +.popover-default { + &::after { + content: ""; + position: absolute; + top: -1px; + bottom: -1px; + left: -1px; + right: -1px; + z-index: -1px; + box-shadow: var(--shadow); + pointer-events: none; + } + + border-radius: var(--roundness); + border-color: var(--border); + border-style: solid; + border-width: 1px; + background-color: var(--background); +} + +.dropdown-menu { + display: block; + padding: 0; + font-size: 1em; + text-align: left; + list-style: none; + max-width: 100vw; + z-index: var(--ZI_popover_override, var(--ZI_popovers)); + white-space: nowrap; + background-color: var(--background); + + .dropdown-divider { + height: 0; + margin: 0.5rem 0; + overflow: hidden; + border-top: 1px solid var(--border); + } + + .dropdown-item { + padding: 0; + display: grid; + grid-template-columns: 1fr; + grid-auto-flow: column; + grid-auto-columns: auto; + + .popover-wrapper { + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr; + } + + .extra-button { + border-left: 1px solid var(--icon); + padding-left: calc(var(--__horizontal-gap) - 1px); + border-right: var(--__horizontal-gap) solid transparent; + border-top: var(--__horizontal-gap) solid transparent; + border-bottom: var(--__horizontal-gap) solid transparent; + } + + .main-button { + width: 100%; + padding: var(--__horizontal-gap) var(--__horizontal-gap); + grid-gap: var(--__horizontal-gap); + grid-template-columns: 1fr var(--__line-height); + grid-auto-flow: column; + grid-auto-columns: auto; + + .menu-checkbox { + display: inline-block; + vertical-align: middle; + min-width: calc(var(--__line-height) + 1px); + max-width: calc(var(--__line-height) + 1px); + min-height: calc(var(--__line-height) + 1px); + max-height: calc(var(--__line-height) + 1px); + line-height: var(--__line-height); + text-align: center; + border-radius: 0; + box-shadow: var(--shadow); + margin-right: var(--__horizontal-gap); + + &.menu-checkbox-checked::after { + font-size: 1.25em; + content: "✓"; + } + + &.-radio { + border-radius: 9999px; + + &.menu-checkbox-checked::after { + font-size: 2em; + content: "•"; + } + } + } + } + + .main-button, + .extra-button { + display: grid; + box-sizing: border-box; + align-items: center; + + &.disabled { + cursor: not-allowed; + } + + &:not(.disabled) { + cursor: pointer; + } + } + + &.-icon { + .main-button { + grid-template-columns: var(--__line-height) 1fr; + } + } + + &.-icon-space { + .main-button { + padding-left: calc(var(--__line-height) + var(--__horizontal-gap) * 2); + } + } + + &.-icon-double { + .main-button { + grid-template-columns: var(--__line-height) var(--__line-height) 1fr; + } + } + } +} diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue @@ -1,5 +1,6 @@ <template> <span + class="popover-wrapper" @mouseenter="onMouseenter" @mouseleave="onMouseleave" > @@ -32,6 +33,7 @@ name="content" class="popover-inner" :close="hidePopover" + :resize="resizePopover" /> </div> </transition> @@ -41,101 +43,4 @@ <script src="./popover.js" /> -<style lang="scss"> -.popover-trigger-button { - display: inline-block; -} - -.popover { - z-index: var(--ZI_popover_override, var(--ZI_popovers)); - position: fixed; - min-width: 0; - max-width: calc(100vw - 20px); - box-shadow: var(--shadow); -} - -.popover-default { - &::after { - content: ""; - position: absolute; - top: -1px; - bottom: -1px; - left: -1px; - right: -1px; - z-index: -1px; - box-shadow: var(--shadow); - pointer-events: none; - } - - border-radius: var(--roundness); - border-color: var(--border); - border-style: solid; - border-width: 1px; - background-color: var(--background); -} - -.dropdown-menu { - display: block; - padding: 0; - font-size: 1em; - text-align: left; - list-style: none; - max-width: 100vw; - z-index: var(--ZI_popover_override, var(--ZI_popovers)); - white-space: nowrap; - background-color: var(--background); - - .dropdown-divider { - height: 0; - margin: 0.5rem 0; - overflow: hidden; - border-top: 1px solid var(--border); - } - - .dropdown-item { - border: none; - - &-icon { - svg { - width: var(--__line-height); - margin-right: var(--__horizontal-gap); - } - } - - &.-has-submenu { - .chevron-icon { - margin-right: 0.25rem; - margin-left: 2rem; - } - } - - .menu-checkbox { - display: inline-block; - vertical-align: middle; - min-width: calc(var(--__line-height) + 1px); - max-width: calc(var(--__line-height) + 1px); - min-height: calc(var(--__line-height) + 1px); - max-height: calc(var(--__line-height) + 1px); - line-height: var(--__line-height); - text-align: center; - border-radius: 0; - box-shadow: var(--shadow); - margin-right: var(--__horizontal-gap); - - &.menu-checkbox-checked::after { - font-size: 1.25em; - content: "✓"; - } - - &.-radio { - border-radius: 9999px; - - &.menu-checkbox-checked::after { - font-size: 2em; - content: "•"; - } - } - } - } -} -</style> +<style src="./popover.scss" lang="scss"></style> diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue @@ -336,7 +336,7 @@ > <button v-if="!hideDraft || !disableDraft" - class="menu-item dropdown-item dropdown-item-icon" + class="menu-item dropdown-item" role="menu" :disabled="!safeToSaveDraft && saveable" :class="{ disabled: !safeToSaveDraft }" diff --git a/src/components/quick_filter_settings/quick_filter_settings.js b/src/components/quick_filter_settings/quick_filter_settings.js @@ -1,5 +1,5 @@ import Popover from '../popover/popover.vue' -import { mapGetters } from 'vuex' +import { mapGetters, mapState } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { faFilter, faFont, faWrench } from '@fortawesome/free-solid-svg-icons' @@ -11,7 +11,8 @@ library.add( const QuickFilterSettings = { props: { - conversation: Boolean + conversation: Boolean, + nested: Boolean }, components: { Popover @@ -27,6 +28,25 @@ const QuickFilterSettings = { }, computed: { ...mapGetters(['mergedConfig']), + ...mapState({ + mobileLayout: state => state.interface.layoutType === 'mobile' + }), + triggerAttrs () { + if (this.mobileLayout) { + return {} + } else { + return { + title: this.$t('timeline.quick_filter_settings') + } + } + }, + mainClass () { + if (this.mobileLayout) { + return 'main-button' + } else { + return 'dropdown-item' + } + }, loggedIn () { return !!this.$store.state.users.currentUser }, diff --git a/src/components/quick_filter_settings/quick_filter_settings.vue b/src/components/quick_filter_settings/quick_filter_settings.vue @@ -1,9 +1,10 @@ <template> <Popover - trigger="click" + :trigger="nested ? 'hover' : 'click'" class="QuickFilterSettings" :bound-to="{ x: 'container' }" - :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }" + :position="nested ? 'right' : 'top'" + :trigger-attrs="triggerAttrs" > <template #content> <div @@ -14,110 +15,148 @@ v-if="loggedIn" role="group" > - <button + <div class="menu-item dropdown-item -icon"> + <button + v-if="!conversation" + class="main-button" + :aria-checked="replyVisibilityAll" + role="menuitemradio" + @click="replyVisibilityAll = true" + > + <span + class="input menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': replyVisibilityAll }" + :aria-hidden="true" + />{{ $t('settings.reply_visibility_all') }} + </button> + </div> + <div + v-if="!conversation" + class="menu-item dropdown-item -icon" + > + <button + class="main-button" + :aria-checked="replyVisibilityFollowing" + role="menuitemradio" + @click="replyVisibilityFollowing = true" + > + <span + class="input menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': replyVisibilityFollowing }" + :aria-hidden="true" + />{{ $t('settings.reply_visibility_following_short') }} + </button> + </div> + <div v-if="!conversation" - class="menu-item dropdown-item" - :aria-checked="replyVisibilityAll" - role="menuitemradio" - @click="replyVisibilityAll = true" + class="menu-item dropdown-item -icon" + > + <button + class="main-button" + :aria-checked="replyVisibilitySelf" + role="menuitemradio" + @click="replyVisibilitySelf = true" + > + <span + class="input menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': replyVisibilitySelf }" + :aria-hidden="true" + />{{ $t('settings.reply_visibility_self_short') }} + </button> + </div> + <div + v-if="!conversation" + role="separator" + class="dropdown-divider" + /> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + role="menuitemcheckbox" + :aria-checked="muteBotStatuses" + @click="muteBotStatuses = !muteBotStatuses" > <span - class="input menu-checkbox -radio" - :class="{ 'menu-checkbox-checked': replyVisibilityAll }" + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': muteBotStatuses }" :aria-hidden="true" - />{{ $t('settings.reply_visibility_all') }} + />{{ $t('settings.mute_bot_posts') }} </button> + </div> + <div class="menu-item dropdown-item -icon"> <button - v-if="!conversation" - class="menu-item dropdown-item" - :aria-checked="replyVisibilityFollowing" - role="menuitemradio" - @click="replyVisibilityFollowing = true" + class="main-button" + role="menuitemcheckbox" + :aria-checked="muteSensitiveStatuses" + @click="muteSensitiveStatuses = !muteSensitiveStatuses" > <span - class="input menu-checkbox -radio" - :class="{ 'menu-checkbox-checked': replyVisibilityFollowing }" + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': muteSensitiveStatuses }" :aria-hidden="true" - />{{ $t('settings.reply_visibility_following_short') }} + />{{ $t('settings.mute_sensitive_posts') }} </button> + </div> + <div class="menu-item dropdown-item -icon"> <button - v-if="!conversation" - class="menu-item dropdown-item" - :aria-checked="replyVisibilitySelf" - role="menuitemradio" - @click="replyVisibilitySelf = true" + class="main-button" + role="menuitemcheckbox" + :aria-checked="hideMedia" + @click="hideMedia = !hideMedia" > <span - class="input menu-checkbox -radio" - :class="{ 'menu-checkbox-checked': replyVisibilitySelf }" + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hideMedia }" :aria-hidden="true" - />{{ $t('settings.reply_visibility_self_short') }} + />{{ $t('settings.hide_media_previews') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + role="menuitemcheckbox" + :aria-checked="hideMutedPosts" + @click="hideMutedPosts = !hideMutedPosts" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': hideMutedPosts }" + :aria-hidden="true" + />{{ $t('settings.hide_all_muted_posts') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + role="menuitem" + @click="openTab('filtering')" + > + <FAIcon + fixed-width + icon="font" + />{{ $t('settings.word_filter_and_more') }} </button> - <div - v-if="!conversation" - role="separator" - class="dropdown-divider" - /> </div> - <button - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="muteBotStatuses" - @click="muteBotStatuses = !muteBotStatuses" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': muteBotStatuses }" - :aria-hidden="true" - />{{ $t('settings.mute_bot_posts') }} - </button> - <button - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="muteSensitiveStatuses" - @click="muteSensitiveStatuses = !muteSensitiveStatuses" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': muteSensitiveStatuses }" - :aria-hidden="true" - />{{ $t('settings.mute_sensitive_posts') }} - </button> - <button - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="hideMedia" - @click="hideMedia = !hideMedia" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hideMedia }" - :aria-hidden="true" - />{{ $t('settings.hide_media_previews') }} - </button> - <button - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="hideMutedPosts" - @click="hideMutedPosts = !hideMutedPosts" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': hideMutedPosts }" - :aria-hidden="true" - />{{ $t('settings.hide_all_muted_posts') }} - </button> - <button - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click="openTab('filtering')" - > - <FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }} - </button> </div> </template> <template #trigger> - <FAIcon icon="filter" /> + <div :class="mobileLayout ? 'main-button' : ''"> + <FAIcon + icon="filter" + :fixed-width="nested" + /> + <template v-if="nested"> + {{ $t('timeline.filter_settings') }} + </template> + <FAIcon + v-if="nested" + class="chevron-icon" + size="lg" + icon="chevron-right" + fixed-width + /> + </div> </template> </Popover> </template> diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js @@ -1,5 +1,6 @@ -import Popover from '../popover/popover.vue' -import { mapGetters } from 'vuex' +import Popover from 'src/components/popover/popover.vue' +import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue' +import { mapGetters, mapState } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { faList, faFolderTree, faBars, faWrench } from '@fortawesome/free-solid-svg-icons' @@ -15,7 +16,8 @@ const QuickViewSettings = { conversation: Boolean }, components: { - Popover + Popover, + QuickFilterSettings }, methods: { setConversationDisplay (visibility) { @@ -27,6 +29,9 @@ const QuickViewSettings = { }, computed: { ...mapGetters(['mergedConfig']), + ...mapState({ + mobileLayout: state => state.interface.layoutType === 'mobile' + }), loggedIn () { return !!this.$store.state.users.currentUser }, diff --git a/src/components/quick_view_settings/quick_view_settings.vue b/src/components/quick_view_settings/quick_view_settings.vue @@ -3,94 +3,126 @@ trigger="click" class="QuickViewSettings" :bound-to="{ x: 'container' }" - :trigger-attrs="{ title: $t('timeline.quick_view_settings') }" + :trigger-attrs="triggerAttrs" > <template #content> <div class="dropdown-menu" role="menu" > + <div + v-if="mobileLayout" + class="menu-item dropdown-item -icon" + > + <QuickFilterSettings :nested="true" /> + </div> + <div + v-if="mobileLayout" + role="separator" + class="dropdown-divider" + /> <div role="group"> + <div class="menu-item dropdown-item -icon-double"> + <button + class="main-button" + :aria-checked="conversationDisplay === 'tree'" + role="menuitemradio" + @click="conversationDisplay = 'tree'" + > + <span + class="input menu-checkbox -radio" + :aria-hidden="true" + :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }" + /><FAIcon + icon="folder-tree" + :aria-hidden="true" + fixed-width + /> {{ $t('settings.conversation_display_tree_quick') }} + </button> + </div> + <div class="menu-item dropdown-item -icon-double"> + <button + class="main-button" + :aria-checked="conversationDisplay === 'linear'" + role="menuitemradio" + @click="conversationDisplay = 'linear'" + > + <span + class="input menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }" + :aria-hidden="true" + /><FAIcon + icon="list" + :aria-hidden="true" + fixed-width + /> {{ $t('settings.conversation_display_linear_quick') }} + </button> + </div> + </div> + <div + role="separator" + class="dropdown-divider" + /> + <div class="menu-item dropdown-item -icon"> <button - class="menu-item dropdown-item" - :aria-checked="conversationDisplay === 'tree'" - role="menuitemradio" - @click="conversationDisplay = 'tree'" + class="main-button" + role="menuitemcheckbox" + :aria-checked="showUserAvatars" + @click="showUserAvatars = !showUserAvatars" > <span - class="input menu-checkbox -radio" + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': showUserAvatars }" :aria-hidden="true" - :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }" - /><FAIcon - icon="folder-tree" - :aria-hidden="true" - /> {{ $t('settings.conversation_display_tree_quick') }} + />{{ $t('settings.mention_link_show_avatar_quick') }} </button> + </div> + <div + v-if="!conversation" + class="menu-item dropdown-item -icon" + > <button - class="menu-item dropdown-item" - :aria-checked="conversationDisplay === 'linear'" - role="menuitemradio" - @click="conversationDisplay = 'linear'" + class="main-button" + role="menuitemcheckbox" + :aria-checked="autoUpdate" + @click="autoUpdate = !autoUpdate" > <span - class="input menu-checkbox -radio" - :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }" - :aria-hidden="true" - /><FAIcon - icon="list" + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': autoUpdate }" :aria-hidden="true" - /> {{ $t('settings.conversation_display_linear_quick') }} + />{{ $t('settings.auto_update') }} </button> </div> <div - role="separator" - class="dropdown-divider" - /> - <button - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="showUserAvatars" - @click="showUserAvatars = !showUserAvatars" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': showUserAvatars }" - :aria-hidden="true" - />{{ $t('settings.mention_link_show_avatar_quick') }} - </button> - <button v-if="!conversation" - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="autoUpdate" - @click="autoUpdate = !autoUpdate" + class="menu-item dropdown-item -icon" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': autoUpdate }" - :aria-hidden="true" - />{{ $t('settings.auto_update') }} - </button> - <button - v-if="!conversation" - class="menu-item dropdown-item" - role="menuitemcheckbox" - :aria-checked="collapseWithSubjects" - @click="collapseWithSubjects = !collapseWithSubjects" - > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': collapseWithSubjects }" - :aria-hidden="true" - />{{ $t('settings.collapse_subject') }} - </button> - <button - class="menu-item dropdown-item dropdown-item-icon" - role="menuitem" - @click="openTab('general')" - > - <FAIcon icon="wrench" />{{ $t('settings.more_settings') }} - </button> + <button + class="main-button" + role="menuitemcheckbox" + :aria-checked="collapseWithSubjects" + @click="collapseWithSubjects = !collapseWithSubjects" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': collapseWithSubjects }" + :aria-hidden="true" + />{{ $t('settings.collapse_subject') }} + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + role="menuitem" + @click="openTab('general')" + > + <FAIcon + icon="wrench" + fixed-width + />{{ $t('settings.more_settings') }} + </button> + </div> </div> </template> <template #trigger> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js @@ -1,54 +0,0 @@ -import Popover from '../popover/popover.vue' -import EmojiPicker from '../emoji_picker/emoji_picker.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' -import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' - -library.add( - faPlus, - faTimes, - faSmileBeam -) - -const ReactButton = { - props: ['status'], - data () { - return { - filterWord: '', - expanded: false - } - }, - components: { - Popover, - EmojiPicker - }, - methods: { - addReaction (event) { - const emoji = event.insertion - const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji) - if (existingReaction && existingReaction.me) { - this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) - } else { - this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) - } - }, - show () { - if (!this.expanded) { - this.$refs.picker.showPicker() - } - }, - onShow () { - this.expanded = true - }, - onClose () { - this.expanded = false - } - }, - computed: { - hideCustomEmoji () { - return !this.$store.state.instance.pleromaCustomEmojiReactionsAvailable - } - } -} - -export default ReactButton diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue @@ -1,115 +0,0 @@ -<template> - <span class="ReactButton"> - <EmojiPicker - ref="picker" - :enable-sticker-picker="false" - :hide-custom-emoji="hideCustomEmoji" - class="emoji-picker-panel" - @emoji="addReaction" - @show="onShow" - @close="onClose" - /> - <span - class="button-unstyled popover-trigger" - role="button" - :tabindex="0" - :title="$t('tool_tip.add_reaction')" - @click.stop.prevent="show" - > - <FALayers> - <FAIcon - class="fa-scale-110 fa-old-padding" - :icon="['far', 'smile-beam']" - /> - <FAIcon - v-show="!expanded" - class="focus-marker" - transform="shrink-6 up-9 right-17" - icon="plus" - /> - <FAIcon - v-show="expanded" - class="focus-marker" - transform="shrink-6 up-9 right-17" - icon="times" - /> - </FALayers> - </span> - </span> -</template> - -<script src="./react_button.js"></script> - -<style lang="scss"> -@import "../../mixins"; - -.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); - } - - .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 */ - mask-composite: xor; - mask-composite: exclude; - - .emoji-button { - cursor: pointer; - flex-basis: 20%; - line-height: 1.5; - align-content: center; - - &:hover { - transform: scale(1.25); - } - } - } - - .popover-trigger { - padding: 10px; - margin: -10px; - - @include unfocused-style { - .focus-marker { - visibility: hidden; - } - } - - @include focused-style { - .focus-marker { - visibility: visible; - } - } - } -} - -</style> diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js @@ -1,27 +0,0 @@ -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faReply, - faPlus, - faTimes -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faReply, - faPlus, - faTimes -) - -const ReplyButton = { - name: 'ReplyButton', - props: ['status', 'replying'], - computed: { - loggedIn () { - return !!this.$store.state.users.currentUser - }, - remoteInteractionLink () { - return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) - } - } -} - -export default ReplyButton diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue @@ -1,96 +0,0 @@ -<template> - <div class="ReplyButton"> - <button - v-if="loggedIn" - class="button-unstyled interactive" - :class="{'-active': replying}" - :title="$t('tool_tip.reply')" - @click.prevent="$emit('toggle')" - > - <FALayers class="fa-old-padding-layer"> - <FAIcon - class="fa-scale-110" - icon="reply" - /> - <FAIcon - v-if="!replying" - class="focus-marker" - transform="shrink-6 up-8 right-11" - icon="plus" - /> - <FAIcon - v-else - class="focus-marker" - transform="shrink-6 up-8 right-11" - icon="times" - /> - </FALayers> - </button> - <a - v-else - class="button-unstyled interactive" - target="_blank" - role="button" - :href="remoteInteractionLink" - :title="$t('tool_tip.reply')" - > - <FALayers class="fa-old-padding-layer"> - <FAIcon - class="fa-scale-110" - icon="reply" - /> - <FAIcon - v-if="!replying" - class="focus-marker" - transform="shrink-6 up-8 right-16" - icon="plus" - /> - </FALayers> - </a> - <span - v-if="status.replies_count > 0" - class="action-counter" - > - {{ status.replies_count }} - </span> - </div> -</template> - -<script src="./reply_button.js"></script> - -<style lang="scss"> -@import "../../mixins"; - -.ReplyButton { - display: flex; - - > :first-child { - padding: 10px; - margin: -10px -8px -10px -10px; - } - - .action-counter { - pointer-events: none; - user-select: none; - } - - .interactive { - &:hover .svg-inline--fa, - &.-active .svg-inline--fa { - color: var(--cBlue); - } - - @include unfocused-style { - .focus-marker { - visibility: hidden; - } - } - - @include focused-style { - .focus-marker { - visibility: visible; - } - } - } -} -</style> diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js @@ -1,68 +0,0 @@ -import ConfirmModal from '../confirm_modal/confirm_modal.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faRetweet, - faPlus, - faMinus, - faCheck -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faRetweet, - faPlus, - faMinus, - faCheck -) - -const RetweetButton = { - props: ['status', 'loggedIn', 'visibility'], - components: { - ConfirmModal - }, - data () { - return { - animated: false, - showingConfirmDialog: false - } - }, - methods: { - retweet () { - if (!this.status.repeated && this.shouldConfirmRepeat) { - this.showConfirmDialog() - } else { - this.doRetweet() - } - }, - doRetweet () { - if (!this.status.repeated) { - this.$store.dispatch('retweet', { id: this.status.id }) - } else { - this.$store.dispatch('unretweet', { id: this.status.id }) - } - this.animated = true - setTimeout(() => { - this.animated = false - }, 500) - this.hideConfirmDialog() - }, - showConfirmDialog () { - this.showingConfirmDialog = true - }, - hideConfirmDialog () { - this.showingConfirmDialog = false - } - }, - computed: { - mergedConfig () { - return this.$store.getters.mergedConfig - }, - remoteInteractionLink () { - return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) - }, - shouldConfirmRepeat () { - return this.mergedConfig.modalOnRepeat - } - } -} - -export default RetweetButton diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue @@ -1,133 +0,0 @@ -<template> - <div class="RetweetButton"> - <button - v-if="visibility !== 'private' && visibility !== 'direct' && loggedIn" - class="button-unstyled interactive" - :class="status.repeated && '-repeated'" - :title="$t('tool_tip.repeat')" - @click.prevent="retweet()" - > - <FALayers class="fa-old-padding-layer"> - <FAIcon - class="fa-scale-110" - icon="retweet" - :spin="animated" - /> - <FAIcon - v-if="status.repeated" - class="active-marker" - transform="shrink-6 up-9 right-12" - icon="check" - /> - <FAIcon - v-if="!status.repeated" - class="focus-marker" - transform="shrink-6 up-9 right-12" - icon="plus" - /> - <FAIcon - v-else - class="focus-marker" - transform="shrink-6 up-9 right-12" - icon="minus" - /> - </FALayers> - </button> - <span v-else-if="loggedIn"> - <FAIcon - class="fa-scale-110 fa-old-padding" - icon="lock" - :title="$t('timeline.no_retweet_hint')" - /> - </span> - <a - v-else - class="button-unstyled interactive" - target="_blank" - role="button" - :title="$t('tool_tip.repeat')" - :href="remoteInteractionLink" - > - <FALayers class="fa-old-padding-layer"> - <FAIcon - class="fa-scale-110" - icon="retweet" - /> - <FAIcon - class="focus-marker" - transform="shrink-6 up-9 right-12" - icon="plus" - /> - </FALayers> - </a> - <span - v-if="!mergedConfig.hidePostStats && status.repeat_num > 0" - class="no-event" - > - {{ status.repeat_num }} - </span> - <teleport to="#modal"> - <confirm-modal - v-if="showingConfirmDialog" - :title="$t('status.repeat_confirm_title')" - :confirm-text="$t('status.repeat_confirm_accept_button')" - :cancel-text="$t('status.repeat_confirm_cancel_button')" - @accepted="doRetweet" - @cancelled="hideConfirmDialog" - > - {{ $t('status.repeat_confirm') }} - </confirm-modal> - </teleport> - </div> -</template> - -<script src="./retweet_button.js"></script> - -<style lang="scss"> -@import "../../mixins"; - -.RetweetButton { - display: flex; - - > :first-child { - padding: 10px; - margin: -10px -8px -10px -10px; - } - - .action-counter { - pointer-events: none; - user-select: none; - } - - .interactive { - .svg-inline--fa { - animation-duration: 0.6s; - } - - &:hover .svg-inline--fa, - &.-repeated .svg-inline--fa { - color: var(--cGreen); - } - - @include unfocused-style { - .focus-marker { - visibility: hidden; - } - - .active-marker { - visibility: visible; - } - } - - @include focused-style { - .focus-marker { - visibility: visible; - } - - .active-marker { - visibility: hidden; - } - } - } -} -</style> diff --git a/src/components/settings_modal/admin_tabs/frontends_tab.vue b/src/components/settings_modal/admin_tabs/frontends_tab.vue @@ -115,22 +115,28 @@ > <template #content="{close}"> <div class="dropdown-menu"> - <button + <div v-for="ref in frontend.refs" :key="ref" class="menu-item dropdown-item" - @click.prevent="update(frontend, ref)" - @click="close" > - <i18n-t - keypath="admin_dash.frontend.install_version" - scope="global" + <button + class="main-button" + @click.prevent="update(frontend, ref)" + @click="close" > - <template #version> - <code>{{ ref }}</code> - </template> - </i18n-t> - </button> + <span> + <i18n-t + keypath="admin_dash.frontend.install_version" + scope="global" + > + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </span> + </button> + </div> </div> </template> <template #trigger> @@ -175,22 +181,28 @@ > <template #content="{close}"> <div class="dropdown-menu"> - <button + <div v-for="ref in frontend.installedRefs || frontend.refs" :key="ref" class="menu-item dropdown-item" - @click.prevent="setDefault(frontend, ref)" - @click="close" > - <i18n-t - keypath="admin_dash.frontend.set_default_version" - scope="global" + <button + class="main-button" + @click.prevent="setDefault(frontend, ref)" + @click="close" > - <template #version> - <code>{{ ref }}</code> - </template> - </i18n-t> - </button> + <span> + <i18n-t + keypath="admin_dash.frontend.set_default_version" + scope="global" + > + <template #version> + <code>{{ ref }}</code> + </template> + </i18n-t> + </span> + </button> + </div> </div> </template> <template #trigger> diff --git a/src/components/settings_modal/settings_modal.vue b/src/components/settings_modal/settings_modal.vue @@ -69,36 +69,42 @@ </template> <template #content="{close}"> <div class="dropdown-menu"> - <button - class="menu-item 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="menu-item 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="menu-item 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 class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click.prevent="backup" + @click="close" + > + <FAIcon + icon="file-download" + fixed-width + /><span>{{ $t("settings.file_export_import.backup_settings") }}</span> + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click.prevent="backupWithTheme" + @click="close" + > + <FAIcon + icon="file-download" + fixed-width + /><span>{{ $t("settings.file_export_import.backup_settings_theme") }}</span> + </button> + </div> + <div class="menu-item dropdown-item -icon"> + <button + class="main-button" + @click.prevent="restore" + @click="close" + > + <FAIcon + icon="file-upload" + fixed-width + /><span>{{ $t("settings.file_export_import.restore_settings") }}</span> + </button> + </div> </div> </template> </Popover> diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue @@ -117,6 +117,16 @@ </BooleanSetting> </li> <li> + <BooleanSetting path="modalOnMuteConversation"> + {{ $t('settings.confirm_dialogs_mute_conversation') }} + </BooleanSetting> + </li> + <li> + <BooleanSetting path="modalOnMuteDomain"> + {{ $t('settings.confirm_dialogs_mute_domain') }} + </BooleanSetting> + </li> + <li> <BooleanSetting path="modalOnDelete"> {{ $t('settings.confirm_dialogs_delete') }} </BooleanSetting> diff --git a/src/components/status/status.js b/src/components/status/status.js @@ -1,8 +1,3 @@ -import ReplyButton from '../reply_button/reply_button.vue' -import FavoriteButton from '../favorite_button/favorite_button.vue' -import ReactButton from '../react_button/react_button.vue' -import RetweetButton from '../retweet_button/retweet_button.vue' -import ExtraButtons from '../extra_buttons/extra_buttons.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import AvatarList from '../avatar_list/avatar_list.vue' @@ -16,6 +11,7 @@ import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import UserLink from '../user_link/user_link.vue' import MentionsLine from 'src/components/mentions_line/mentions_line.vue' import MentionLink from 'src/components/mention_link/mention_link.vue' +import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.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' @@ -102,11 +98,6 @@ const controlledOrUncontrolledSet = (obj, name, val) => { const Status = { name: 'Status', components: { - ReplyButton, - FavoriteButton, - ReactButton, - RetweetButton, - ExtraButtons, PostStatusForm, UserAvatar, AvatarList, @@ -119,7 +110,8 @@ const Status = { MentionLink, MentionsLine, UserPopover, - UserLink + UserLink, + StatusActionButtons }, props: [ 'statusoid', diff --git a/src/components/status/status.scss b/src/components/status/status.scss @@ -264,13 +264,11 @@ .status-actions { position: relative; width: 100%; - display: flex; + display: grid; + grid-template-columns: 1fr; + grid-auto-columns: 1fr; + grid-auto-flow: column; margin-top: var(--status-margin); - - > * { - max-width: 4em; - flex: 1; - } } .muted { diff --git a/src/components/status/status.vue b/src/components/status/status.vue @@ -535,37 +535,12 @@ :status="status" /> - <div + <StatusActionButtons v-if="!noHeading && !isPreview" - class="status-actions" - > - <reply-button - :replying="replying" - :status="status" - @toggle="toggleReplying" - /> - <retweet-button - :visibility="status.visibility" - :logged-in="loggedIn" - :status="status" - @click="$emit('interacted')" - /> - <favorite-button - :logged-in="loggedIn" - :status="status" - @click="$emit('interacted')" - /> - <ReactButton - v-if="loggedIn" - :status="status" - @click="$emit('interacted')" - /> - <extra-buttons - :status="status" - @onError="showError" - @onSuccess="clearError" - /> - </div> + :status="status" + :replying="replying" + @toggleReplying="toggleReplying" + /> </div> </div> <div @@ -583,12 +558,6 @@ <div class="deleted-text"> {{ $t('status.status_deleted') }} </div> - <reply-button - v-if="replying" - :replying="replying" - :status="status" - @toggle="toggleReplying" - /> </div> </div> <div diff --git a/src/components/status_action_buttons/action_button.js b/src/components/status_action_buttons/action_button.js @@ -0,0 +1,133 @@ +import StatusBookmarkFolderMenu from 'src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue' +import EmojiPicker from 'src/components/emoji_picker/emoji_picker.vue' +import Popover from 'src/components/popover/popover.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faPlus, + faMinus, + faCheck, + faTimes, + faWrench, + + faChevronRight, + faChevronUp, + + faReply, + faRetweet, + faStar, + faSmileBeam, + + faBookmark, + faEyeSlash, + faThumbtack, + faShareAlt, + faExternalLinkAlt, + faHistory +} from '@fortawesome/free-solid-svg-icons' +import { + faStar as faStarRegular, + faBookmark as faBookmarkRegular +} from '@fortawesome/free-regular-svg-icons' + +library.add( + faPlus, + faMinus, + faCheck, + faTimes, + faWrench, + + faChevronRight, + faChevronUp, + + faReply, + faRetweet, + faStar, + faStarRegular, + faSmileBeam, + + faBookmark, + faBookmarkRegular, + faEyeSlash, + faThumbtack, + faShareAlt, + faExternalLinkAlt, + faHistory +) + +export default { + props: [ + 'button', + 'status', + 'extra', + 'status', + 'funcArg', + 'getClass', + 'getComponent', + 'doAction', + 'close' + ], + components: { + StatusBookmarkFolderMenu, + EmojiPicker, + Popover + }, + data: () => ({ + animationState: false + }), + computed: { + buttonClass () { + return [ + this.button.name + '-button', + { + '-with-extra': this.button.name === 'bookmark', + '-extra': this.extra, + '-quick': !this.extra + } + ] + }, + userIsMuted () { + return this.$store.getters.relationship(this.status.user.id).muting + }, + threadIsMuted () { + return this.status.thread_muted + }, + buttonInnerClass () { + return [ + this.button.name + '-button', + { + 'main-button': this.extra, + 'button-unstyled': !this.extra, + '-active': this.button.active?.(this.funcArg), + disabled: this.button.interactive ? !this.button.interactive(this.funcArg) : false + } + ] + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) + } + }, + methods: { + addReaction (event) { + const emoji = event.insertion + const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji) + if (existingReaction && existingReaction.me) { + this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) + } else { + this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) + } + }, + doActionWrap (button, close) { + if (button.name === 'emoji') { + this.$refs.picker.showPicker() + } else { + this.animationState = true + this.getComponent(button) === 'button' && this.doAction(button) + setTimeout(() => { + this.animationState = false + }, 500) + close() + } + } + } +} diff --git a/src/components/status_action_buttons/action_button.scss b/src/components/status_action_buttons/action_button.scss @@ -0,0 +1,102 @@ +@import "../../mixins"; +/* stylelint-disable declaration-no-important */ + +.quick-action { + display: grid; + grid-template-columns: minmax(max-content, 1fr); + grid-gap: 0.25em; + align-items: center; + height: 1.5em; + + .action-counter { + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .action-button-inner, + .extra-button { + margin: -0.5em; + padding: 0.5em; + } + + .separator { + display: block; + align-self: stretch; + width: 1px; + background-color: var(--icon); + margin-right: 0.5em; + } + + &.-pin { + margin: calc(-2px - 0.25em); + padding: 0.25em; + border: 2px dashed var(--icon); + border-radius: var(--roundness); + grid-template-columns: minmax(max-content, 1fr) auto; + + .extra-button, + .separator { + display: none; + } + } + + .action-button-inner { + display: grid; + grid-gap: 1em; + grid-template-columns: max-content 1fr; + grid-auto-flow: column; + grid-auto-columns: max-content; + align-items: center; + } +} + +.action-button { + display: grid; + grid-auto-flow: column; + align-items: center; + padding: 0; + + .action-button-inner { + &:hover, + &.-active { + &.reply-button:not(.disabled) { + .svg-inline--fa { + color: var(--cBlue); + } + } + + &.retweet-button:not(.disabled) { + .svg-inline--fa { + color: var(--cGreen); + } + } + + &.favorite-button:not(.disabled) { + .svg-inline--fa { + color: var(--cOrange); + } + } + } + } + + @include unfocused-style { + .focus-marker { + visibility: hidden; + } + + .active-marker { + visibility: visible; + } + } + + @include focused-style { + .focus-marker { + visibility: visible; + } + + .active-marker { + visibility: hidden; + } + } +} diff --git a/src/components/status_action_buttons/action_button.vue b/src/components/status_action_buttons/action_button.vue @@ -0,0 +1,107 @@ +<template> + <div + class="action-button" + :class="buttonClass" + > + <component + :is="getComponent(button)" + class="action-button-inner" + :class="buttonInnerClass" + role="menuitem" + type="button" + target="_blank" + :tabindex="0" + :disabled="buttonClass.disabled" + :href="getComponent(button) == 'a' ? button.link?.(funcArg) || remoteInteractionLink : undefined" + @click="doActionWrap(button, close)" + > + <FALayers> + <FAIcon + class="fa-scale-110" + :icon="button.icon(funcArg)" + :spin="!extra && getComponent(button) == 'button' && button.animated?.() && animationState" + :style="{ '--fa-animation-duration': '750ms' }" + fixed-width + /> + <template v-if="!buttonClass.disabled && button.toggleable?.(funcArg) && button.active"> + <FAIcon + v-if="button.active(funcArg)" + class="active-marker" + transform="shrink-6 up-9 right-15" + :icon="button.activeIndicator?.(funcArg) || 'check'" + /> + <FAIcon + v-if="!button.active(funcArg)" + class="focus-marker" + transform="shrink-6 up-9 right-15" + :icon="button.openIndicator?.(funcArg) || 'plus'" + /> + <FAIcon + v-else + class="focus-marker" + transform="shrink-6 up-9 right-15" + :icon="button.closeIndicator?.(funcArg) || 'minus'" + /> + </template> + </FALayers> + <span + v-if="extra" + class="action-label" + > + {{ $t(button.label(funcArg)) }} + </span> + <span + v-if="!extra && button.counter?.(funcArg) > 0" + class="action-counter" + > + {{ button.counter?.(funcArg) }} + </span> + <FAIcon + v-if="button.dropdown?.()" + class="chevron-icon" + size="lg" + :icon="extra ? 'chevron-right' : 'chevron-up'" + fixed-width + /> + </component> + <span + v-if="!extra && button.name === 'bookmark'" + class="separator" + /> + <Popover + v-if="button.name === 'bookmark'" + trigger="hover" + :placement="extra ? 'right' : 'top'" + :offset="{ y: 5 }" + :trigger-attrs="{ class: 'extra-button' }" + > + <template #trigger> + <FAIcon + class="chevron-icon" + size="lg" + :icon="extra ? 'chevron-right' : 'chevron-up'" + fixed-width + /> + </template> + <template #content> + <StatusBookmarkFolderMenu + v-if="button.name === 'bookmark'" + :status="status" + /> + </template> + </Popover> + + <EmojiPicker + v-if="button.name === 'emoji'" + ref="picker" + :enable-sticker-picker="false" + :hide-custom-emoji="hideCustomEmoji" + class="emoji-picker-panel" + @emoji="addReaction" + /> + </div> +</template> + +<script src="./action_button.js" /> + +<style lang="scss" src="./action_button.scss" /> diff --git a/src/components/status_action_buttons/action_button_container.js b/src/components/status_action_buttons/action_button_container.js @@ -0,0 +1,89 @@ +import ActionButton from './action_button.vue' +import Popover from 'src/components/popover/popover.vue' +import MuteConfirm from 'src/components/confirm_modal/mute_confirm.vue' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faUser, + faGlobe, + faFolderTree +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faUser, + faGlobe, + faFolderTree +) + +export default { + components: { + ActionButton, + Popover, + MuteConfirm + }, + props: ['button', 'status'], + mounted () { + if (this.button.name === 'mute') { + this.$store.dispatch('fetchDomainMutes') + } + }, + computed: { + buttonClass () { + return [ + this.button.name + '-button', + { + '-with-extra': this.button.name === 'bookmark', + '-extra': this.extra, + '-quick': !this.extra + } + ] + }, + user () { + return this.status.user + }, + userIsMuted () { + return this.$store.getters.relationship(this.user.id).muting + }, + conversationIsMuted () { + return this.status.thread_muted + }, + domain () { + return this.user.fqn.split('@')[1] + }, + domainIsMuted () { + return new Set(this.$store.state.users.currentUser.domainMutes).has(this.domain) + } + }, + methods: { + unmuteUser () { + return this.$store.dispatch('unmuteUser', this.user.id) + }, + unmuteThread () { + return this.$store.dispatch('unmuteConversation', this.user.id) + }, + unmuteDomain () { + return this.$store.dispatch('unmuteDomain', this.user.id) + }, + toggleUserMute () { + if (this.userIsMuted) { + this.unmuteUser() + } else { + this.$refs.confirmUser.optionallyPrompt() + } + }, + toggleConversationMute () { + if (this.conversationIsMuted) { + this.unmuteConversation() + } else { + this.$refs.confirmConversation.optionallyPrompt() + } + }, + toggleDomainMute () { + if (this.domainIsMuted) { + this.unmuteDomain() + } else { + this.$refs.confirmDomain.optionallyPrompt() + } + } + } +} diff --git a/src/components/status_action_buttons/action_button_container.vue b/src/components/status_action_buttons/action_button_container.vue @@ -0,0 +1,103 @@ +<template> + <div> + <Popover + v-if="button.dropdown?.()" + trigger="hover" + :offset="{ y: 5 }" + :placement="$attrs.extra ? 'right' : 'top'" + > + <template #trigger> + <ActionButton + :button="button" + :status="status" + v-bind.prop="$attrs" + /> + </template> + <template #content> + <div + v-if="button.name === 'mute'" + :id="`popup-menu-${randomSeed}`" + class="dropdown-menu" + role="menu" + > + <div class="menu-item dropdown-item extra-action -icon"> + <button + class="main-button" + @click="toggleUserMute" + > + <FAIcon + icon="user" + fixed-width + /> + <template v-if="userIsMuted"> + {{ $t('status.unmute_user') }} + </template> + <template v-else> + {{ $t('status.mute_user') }} + </template> + </button> + </div> + <div class="menu-item dropdown-item extra-action -icon"> + <button + class="main-button" + @click="toggleUserMute" + > + <FAIcon + icon="folder-tree" + fixed-width + /> + <template v-if="threadIsMuted"> + {{ $t('status.unmute_conversation') }} + </template> + <template v-else> + {{ $t('status.mute_conversation') }} + </template> + </button> + </div> + <div class="menu-item dropdown-item extra-action -icon"> + <button + class="main-button" + @click="toggleDomainMute" + > + <FAIcon + icon="globe" + fixed-width + /> + <template v-if="domainIsMuted"> + {{ $t('status.unmute_domain') }} + </template> + <template v-else> + {{ $t('status.mute_domain') }} + </template> + </button> + </div> + </div> + </template> + </Popover> + <ActionButton + v-else + :button="button" + :status="status" + v-bind="$attrs" + /> + <teleport to="#modal"> + <mute-confirm + ref="confirmConversation" + type="conversation" + :status="status" + /> + <mute-confirm + ref="confirmDomain" + type="domain" + :user="user" + /> + <mute-confirm + ref="confirmUser" + type="user" + :user="user" + /> + </teleport> + </div> +</template> + +<script src="./action_button_container.js" /> diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js @@ -0,0 +1,228 @@ +const PRIVATE_SCOPES = new Set(['private', 'direct']) +const PUBLIC_SCOPES = new Set(['public', 'unlisted']) +export const BUTTONS = [{ + // ========= + // REPLY + // ========= + name: 'reply', + label: 'tool_tip.reply', + icon: 'reply', + active: ({ replying }) => replying, + counter: ({ status }) => status.replies_count, + anon: true, + anonLink: true, + toggleable: true, + closeIndicator: 'times', + activeIndicator: 'none', + action ({ emit }) { + emit('toggleReplying') + return Promise.resolve() + } +}, { + // ========= + // REPEAT + // ========= + name: 'retweet', + label: ({ status }) => status.repeated + ? 'tool_tip.unrepeat' + : 'tool_tip.repeat', + icon ({ status }) { + if (PRIVATE_SCOPES.has(status.visibility)) { + return 'lock' + } + return 'retweet' + }, + animated: true, + active: ({ status }) => status.repeated, + counter: ({ status }) => status.repeat_num, + anonLink: true, + interactive: ({ status, loggedIn }) => loggedIn && !PRIVATE_SCOPES.has(status.visibility), + toggleable: true, + confirm: ({ status, getters }) => !status.repeated && getters.mergedConfig.modalOnRepeat, + confirmStrings: { + title: 'status.repeat_confirm_title', + body: 'status.repeat_confirm', + confirm: 'status.repeat_confirm_accept_button', + cancel: 'status.repeat_confirm_cancel_button' + }, + action ({ status, dispatch }) { + if (!status.repeated) { + return dispatch('retweet', { id: status.id }) + } else { + return dispatch('unretweet', { id: status.id }) + } + } +}, { + // ========= + // FAVORITE + // ========= + name: 'favorite', + label: ({ status }) => status.favorited + ? 'tool_tip.unfavorite' + : 'tool_tip.favorite', + icon: ({ status }) => status.favorited + ? ['fas', 'star'] + : ['far', 'star'], + animated: true, + active: ({ status }) => status.favorited, + counter: ({ status }) => status.fave_num, + anonLink: true, + toggleable: true, + action ({ status, dispatch }) { + if (!status.favorited) { + return dispatch('favorite', { id: status.id }) + } else { + return dispatch('unfavorite', { id: status.id }) + } + } +}, { + // ========= + // EMOJI REACTIONS + // ========= + name: 'emoji', + label: 'tool_tip.add_reaction', + icon: ['far', 'smile-beam'], + anonLink: true +}, { + // ========= + // MUTE + // ========= + name: 'mute', + icon: 'eye-slash', + label: 'status.mute_ellipsis', + if: ({ loggedIn }) => loggedIn, + toggleable: true, + dropdown: true + // action ({ status, dispatch, emit }) { + // } +}, { + // ========= + // PIN STATUS + // ========= + name: 'pin', + icon: 'thumbtack', + label: ({ status }) => status.pinned + ? 'status.unpin' + : 'status.pin', + if ({ status, loggedIn, currentUser }) { + return loggedIn && + status.user.id === currentUser.id && + PUBLIC_SCOPES.has(status.visibility) + }, + action ({ status, dispatch, emit }) { + if (status.pinned) { + return dispatch('unpinStatus', { id: status.id }) + } else { + return dispatch('pinStatus', { id: status.id }) + } + } +}, { + // ========= + // BOOKMARK + // ========= + name: 'bookmark', + icon: ({ status }) => status.bookmarked + ? ['fas', 'bookmark'] + : ['far', 'bookmark'], + toggleable: true, + active: ({ status }) => status.bookmarked, + label: ({ status }) => status.bookmarked + ? 'status.unbookmark' + : 'status.bookmark', + if: ({ loggedIn }) => loggedIn, + action ({ status, dispatch, emit }) { + if (status.bookmarked) { + return dispatch('unbookmark', { id: status.id }) + } else { + return dispatch('bookmark', { id: status.id }) + } + } +}, { + // ========= + // EDIT + // ========= + name: 'edit', + icon: 'pen', + label: 'status.edit', + if ({ status, loggedIn, currentUser, state }) { + return loggedIn && + state.instance.editingAvailable && + status.user.id === currentUser.id + }, + action ({ dispatch, status }) { + return dispatch('fetchStatusSource', { id: status.id }) + .then(data => dispatch('openEditStatusModal', { + statusId: status.id, + subject: data.spoiler_text, + statusText: data.text, + statusIsSensitive: status.nsfw, + statusPoll: status.poll, + statusFiles: [...status.attachments], + visibility: status.visibility, + statusContentType: data.content_type + })) + } +}, { + // ========= + // DELETE + // ========= + name: 'delete', + icon: 'times', + label: 'status.delete', + if ({ status, loggedIn, currentUser }) { + return loggedIn && ( + status.user.id === currentUser.id || + currentUser.privileges.includes('messages_delete') + ) + }, + confirm: ({ status, getters }) => getters.mergedConfig.modalOnDelete, + confirmStrings: { + title: 'status.delete_confirm_title', + body: 'status.delete_confirm', + confirm: 'status.delete_confirm_accept_button', + cancel: 'status.delete_confirm_cancel_button' + }, + action ({ dispatch, status }) { + return dispatch('deleteStatus', { id: status.id }) + } +}, { + // ========= + // SHARE/COPY + // ========= + name: 'share', + icon: 'share-alt', + label: 'status.copy_link', + action ({ state, status, router }) { + navigator.clipboard.writeText([ + state.instance.server, + router.resolve({ name: 'conversation', params: { id: status.id } }).href + ].join('')) + return Promise.resolve() + } +}, { + // ========= + // EXTERNAL + // ========= + name: 'external', + icon: 'external-link-alt', + label: 'status.external_source', + link: ({ status }) => status.external_url +}, { + // ========= + // REPORT + // ========= + name: 'report', + icon: 'flag', + label: 'user_card.report', + if: ({ loggedIn }) => loggedIn, + action ({ dispatch, status }) { + dispatch('openUserReportingModal', { userId: status.user.id, statusIds: [status.id] }) + } +}].map(button => { + return Object.fromEntries( + Object.entries(button).map(([k, v]) => [ + k, + (typeof v === 'function' || k === 'name') ? v : () => v + ]) + ) +}) diff --git a/src/components/status_action_buttons/status_action_buttons.js b/src/components/status_action_buttons/status_action_buttons.js @@ -0,0 +1,136 @@ +import { mapState } from 'vuex' + +import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue' +import ActionButtonContainer from './action_button_container.vue' +import Popover from 'src/components/popover/popover.vue' +import genRandomSeed from 'src/services/random_seed/random_seed.service.js' + +import { BUTTONS } from './buttons_definitions.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEllipsisH +) + +const StatusActionButtons = { + props: ['status', 'replying'], + emits: ['toggleReplying'], + data () { + return { + showPin: false, + showingConfirmDialog: false, + currentConfirmTitle: '', + currentConfirmOkText: '', + currentConfirmCancelText: '', + currentConfirmAction: () => {}, + randomSeed: genRandomSeed() + } + }, + components: { + Popover, + ConfirmModal, + ActionButtonContainer + }, + computed: { + ...mapState({ + pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedStatusActions) + }), + buttons () { + return BUTTONS.filter(x => x.if ? x.if(this.funcArg) : true) + }, + quickButtons () { + return this.buttons.filter(x => this.pinnedItems.has(x.name)) + }, + extraButtons () { + return this.buttons.filter(x => !this.pinnedItems.has(x.name)) + }, + currentUser () { + return this.$store.state.users.currentUser + }, + hideCustomEmoji () { + return !this.$store.state.instance.pleromaCustomEmojiReactionsAvailable + }, + funcArg () { + return { + status: this.status, + replying: this.replying, + emit: this.$emit, + dispatch: this.$store.dispatch, + state: this.$store.state, + getters: this.$store.getters, + router: this.$router, + currentUser: this.currentUser, + loggedIn: !!this.currentUser + } + }, + triggerAttrs () { + return { + title: this.$t('status.more_actions'), + 'aria-controls': `popup-menu-${this.randomSeed}`, + 'aria-expanded': this.expanded, + 'aria-haspopup': 'menu' + } + } + }, + methods: { + doAction (button) { + if (button.confirm?.(this.funcArg)) { + // TODO move to action_button + this.currentConfirmTitle = this.$t(button.confirmStrings(this.funcArg).title) + this.currentConfirmOkText = this.$t(button.confirmStrings(this.funcArg).confirm) + this.currentConfirmCancelText = this.$t(button.confirmStrings(this.funcArg).cancel) + this.currentConfirmBody = this.$t(button.confirmStrings(this.funcArg).body) + this.currentConfirmAction = () => { + this.showingConfirmDialog = false + this.doActionReal(button) + } + this.showingConfirmDialog = true + } else { + this.doActionReal(button) + } + }, + doActionReal (button) { + button.action(this.funcArg) + .then(() => this.$emit('onSuccess')) + .catch(err => this.$emit('onError', err.error.error)) + }, + onExtraClose () { + this.showPin = false + }, + isPinned (button) { + return this.pinnedItems.has(button.name) + }, + unpin (button) { + this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name }) + this.$store.dispatch('pushServerSideStorage') + }, + pin (button) { + this.$store.commit('addCollectionPreference', { path: 'collections.pinnedStatusActions', value: button.name }) + this.$store.dispatch('pushServerSideStorage') + }, + getComponent (button) { + if (!this.$store.state.users.currentUser && button.anonLink) { + return 'a' + } else if (button.action == null && button.link != null) { + return 'a' + } else { + return 'button' + } + }, + getClass (button) { + return { + [button.name + '-button']: true, + disabled: button.interactive ? !button.interactive(this.funcArg) : false, + '-pin-edit': this.showPin, + '-dropdown': button.dropdown?.(), + '-active': button.active?.(this.funcArg) + } + } + } +} + +export default StatusActionButtons diff --git a/src/components/status_action_buttons/status_action_buttons.scss b/src/components/status_action_buttons/status_action_buttons.scss @@ -0,0 +1,27 @@ +@import "../../mixins"; + +.StatusActionButtons { + .quick-action-buttons { + display: grid; + grid-template-columns: repeat(auto-fill, 5em); + grid-auto-flow: row dense; + grid-auto-rows: 1fr; + grid-gap: 1.25em 1em; + margin-top: var(--status-margin); + align-items: baseline; + } + + .pin-action-button { + margin: -0.5em; + padding: 0.5em; + } +} +// popover +.extra-action-buttons { + .extra-action { + margin: 0; + padding-top: 0; + padding-bottom: 0; + padding-right: 0; + } +} diff --git a/src/components/status_action_buttons/status_action_buttons.vue b/src/components/status_action_buttons/status_action_buttons.vue @@ -0,0 +1,131 @@ +<template> + <div class="StatusActionButtons"> + <span class="quick-action-buttons"> + <span + v-for="button in quickButtons" + :key="button.name" + class="quick-action" + :class="{ '-pin': showPin, '-toggle': button.dropdown?.() }" + > + <ActionButtonContainer + :class="{ '-pin': showPin }" + :button="button" + :status="status" + :extra="false" + :func-arg="funcArg" + :get-class="getClass" + :get-component="getComponent" + :close="() => {}" + :do-action="doAction" + /> + <button + v-if="showPin && currentUser" + type="button" + class="button-unstyled pin-action-button" + :title="$t('general.unpin')" + :aria-pressed="true" + @click.stop.prevent="unpin(button)" + > + <FAIcon + v-if="showPin && currentUser" + fixed-width + class="fa-scale-110" + icon="thumbtack" + /> + </button> + </span> + <Popover + trigger="click" + :trigger-attrs="triggerAttrs" + :tabindex="0" + placement="top" + :offset="{ y: 5 }" + remove-padding + @close="onExtraClose" + > + <template #trigger> + <FAIcon + class="fa-scale-110 " + icon="ellipsis-h" + /> + </template> + <template #content="{close, resize}"> + <div + :id="`popup-menu-${randomSeed}`" + class="dropdown-menu extra-action-buttons" + role="menu" + > + <div + v-if="currentUser" + class="menu-item dropdown-item extra-action -icon" + > + <button + class="main-button" + role="menuitem" + :tabindex="0" + @click.stop="() => { resize(); showPin = !showPin }" + > + <FAIcon + class="fa-scale-110" + fixed-width + icon="wrench" + /><span>{{ $t('nav.edit_pinned') }}</span> + </button> + </div> + <div + v-for="button in extraButtons" + :key="button.name" + class="menu-item dropdown-item extra-action -icon" + :disabled="getClass(button).disabled" + :class="{ disabled: getClass(button).disabled }" + > + <ActionButtonContainer + :button="button" + :status="status" + :extra="true" + :func-arg="funcArg" + :get-class="getClass" + :get-component="getComponent" + :close="close" + :do-action="doAction" + /> + <button + v-if="showPin && currentUser" + type="button" + class="button-unstyled pin-action-button extra-button" + :title="$t('general.pin')" + :aria-pressed="false" + @click.stop.prevent="pin(button)" + > + <FAIcon + v-if="showPin && currentUser" + fixed-width + class="fa-scale-110" + transform="rotate-45" + icon="thumbtack" + /> + </button> + </div> + </div> + </template> + </Popover> + </span> + + <teleport to="#modal"> + <confirm-modal + v-if="showingConfirmDialog" + :title="currentConfirmTitle" + :confirm-text="currentConfirmOkText" + :cancel-text="currentConfirmCancelText" + @accepted="currentConfirmAction" + @cancelled="showingConfirmDialog = false" + > + {{ currentConfirmBody }} + </confirm-modal> + </teleport> + </div> +</template> + +<script src="./status_action_buttons.js"></script> + +<style lang="scss" src="./status_action_buttons.scss"></style> diff --git a/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue b/src/components/status_bookmark_folder_menu/status_bookmark_folder_menu.vue @@ -1,39 +1,21 @@ <template> - <div class="StatusBookmarkFolderMenu"> - <Popover - trigger="hover" - placement="left" - remove-padding + <div class="dropdown-menu"> + <div + v-for="folder in folders" + :key="folder.id" + class="menu-item dropdown-item -icon" > - <template #content> - <div class="dropdown-menu"> - <button - v-for="folder in folders" - :key="folder.id" - class="menu-item dropdown-item" - @click="toggleFolder(folder.id)" - > - <span - class="input menu-checkbox -radio" - :class="{ 'menu-checkbox-checked': status.bookmark_folder_id == folder.id }" - /> - {{ folder.name }} - </button> - </div> - </template> - <template #trigger> - <button class="menu-item dropdown-item dropdown-item-icon -has-submenu"> - <FAIcon - fixed-width - icon="folder" - />{{ $t('bookmark_folders.select_folder') }}<FAIcon - class="chevron-icon" - size="lg" - icon="chevron-right" - /> - </button> - </template> - </Popover> + <button + class="main-button" + @click="toggleFolder(folder.id)" + > + <span + class="input menu-checkbox -radio" + :class="{ 'menu-checkbox-checked': status.bookmark_folder_id == folder.id }" + /> + {{ folder.name }} + </button> + </div> </div> </template> diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue @@ -76,6 +76,7 @@ </div> </template> <QuickFilterSettings + v-if="!mobileLayout" class="rightside-button" /> <QuickViewSettings diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js @@ -1,4 +1,3 @@ -import { unitToSeconds } from 'src/services/date_utils/date_utils.js' import UserAvatar from '../user_avatar/user_avatar.vue' import RemoteFollow from '../remote_follow/remote_follow.vue' import ProgressButton from '../progress_button/progress_button.vue' @@ -9,7 +8,7 @@ import UserNote from '../user_note/user_note.vue' import Select from '../select/select.vue' import UserLink from '../user_link/user_link.vue' import RichContent from 'src/components/rich_content/rich_content.jsx' -import ConfirmModal from '../confirm_modal/confirm_modal.vue' +import MuteConfirm from '../confirm_modal/mute_confirm.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' @@ -48,7 +47,6 @@ export default { data () { return { followRequestInProgress: false, - showingConfirmMute: false, muteExpiryAmount: 0, muteExpiryUnit: 'minutes' } @@ -141,12 +139,6 @@ export default { supportsNote () { return 'note' in this.relationship }, - shouldConfirmMute () { - return this.mergedConfig.modalOnMute - }, - muteExpiryUnits () { - return ['minutes', 'hours', 'days'] - }, ...mapGetters(['mergedConfig']) }, components: { @@ -160,28 +152,11 @@ export default { RichContent, UserLink, UserNote, - ConfirmModal + MuteConfirm }, methods: { - showConfirmMute () { - this.showingConfirmMute = true - }, - hideConfirmMute () { - this.showingConfirmMute = false - }, muteUser () { - if (!this.shouldConfirmMute) { - this.doMuteUser() - } else { - this.showConfirmMute() - } - }, - doMuteUser () { - this.$store.dispatch('muteUser', { - id: this.user.id, - expiresIn: this.shouldConfirmMute ? unitToSeconds(this.muteExpiryUnit, this.muteExpiryAmount) : 0 - }) - this.hideConfirmMute() + this.$refs.confirmation.optionallyPrompt() }, unmuteUser () { this.$store.dispatch('unmuteUser', this.user.id) diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss @@ -321,8 +321,3 @@ text-decoration: none; } } - -.mute-expiry { - display: flex; - flex-direction: row; -} diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue @@ -311,51 +311,11 @@ /> </div> <teleport to="#modal"> - <confirm-modal - v-if="showingConfirmMute" - :title="$t('user_card.mute_confirm_title')" - :confirm-text="$t('user_card.mute_confirm_accept_button')" - :cancel-text="$t('user_card.mute_confirm_cancel_button')" - @accepted="doMuteUser" - @cancelled="hideConfirmMute" - > - <i18n-t - keypath="user_card.mute_confirm" - tag="div" - > - <template #user> - <span - v-text="user.screen_name_ui" - /> - </template> - </i18n-t> - <div - class="mute-expiry" - > - <label> - {{ $t('user_card.mute_duration_prompt') }} - </label> - <input - v-model="muteExpiryAmount" - type="number" - class="expiry-amount hide-number-spinner" - :min="0" - > - <Select - v-model="muteExpiryUnit" - unstyled="true" - class="expiry-unit" - > - <option - v-for="unit in muteExpiryUnits" - :key="unit" - :value="unit" - > - {{ $t(`time.${unit}_short`, ['']) }} - </option> - </Select> - </div> - </confirm-modal> + <mute-confirm + ref="confirmation" + type="user" + :user="user" + /> </teleport> </div> </template> diff --git a/src/components/user_list_menu/user_list_menu.js b/src/components/user_list_menu/user_list_menu.js @@ -34,6 +34,11 @@ const UserListMenu = { ...list, inList: this.inListsSet.has(list.id) })) + }, + triggerAttrs () { + return { + class: 'menu-item dropdown-item -has-submenu' + } } }, methods: { diff --git a/src/components/user_list_menu/user_list_menu.vue b/src/components/user_list_menu/user_list_menu.vue @@ -2,34 +2,39 @@ <div class="UserListMenu"> <Popover trigger="hover" - placement="left" + placement="right" + :trigger-attrs="triggerAttrs" remove-padding > <template #content> <div class="dropdown-menu"> - <button + <div v-for="list in lists" :key="list.id" - class="menu-item dropdown-item" - @click="toggleList(list.id)" + class="menu-item dropdown-item -icon" > - <span - class="input menu-checkbox" - :class="{ 'menu-checkbox-checked': list.inList }" - /> - {{ list.title }} - </button> + <button + class="main-button" + @click="toggleList(list.id)" + > + <span + class="input menu-checkbox" + :class="{ 'menu-checkbox-checked': list.inList }" + /> + {{ list.title }} + </button> + </div> </div> </template> <template #trigger> - <button class="menu-item dropdown-item -has-submenu"> + <span class="main-button"> {{ $t('lists.manage_lists') }} <FAIcon class="chevron-icon" size="lg" icon="chevron-right" /> - </button> + </span> </template> </Popover> </div> diff --git a/src/i18n/en.json b/src/i18n/en.json @@ -491,6 +491,8 @@ "confirm_dialogs_unfollow": "unfollowing a user", "confirm_dialogs_block": "blocking a user", "confirm_dialogs_mute": "muting a user", + "confirm_dialogs_mute_domain": "muting domains", + "confirm_dialogs_mute_conversation": "muting conversations", "confirm_dialogs_delete": "deleting a status", "confirm_dialogs_logout": "logging out", "confirm_dialogs_approve_follow": "approving a follower", @@ -1213,7 +1215,8 @@ "socket_reconnected": "Realtime connection established", "socket_broke": "Realtime connection lost: CloseEvent code {0}", "quick_view_settings": "Quick view settings", - "quick_filter_settings": "Quick filter settings" + "quick_filter_settings": "Quick filter settings", + "filter_settings": "Filter" }, "status": { "favorites": "Favorites", @@ -1242,6 +1245,11 @@ "mentions": "Mentions", "replies_list": "Replies:", "replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):", + "mute_ellipsis": "Mute…", + "mute_user": "Mute user", + "unmute_user": "Unmute user", + "mute_domain": "Mute domain", + "unmute_domain": "Unmute domain", "mute_conversation": "Mute conversation", "unmute_conversation": "Unmute conversation", "status_unavailable": "Status unavailable", @@ -1414,8 +1422,11 @@ "media_upload": "Upload media", "mentions": "Mentions", "repeat": "Repeat", + "unrepeat": "Unrepeat", "reply": "Reply", + "add_reaction": "Add reaction", "favorite": "Favorite", + "unfavorite": "Unfavorite", "add_reaction": "Add Reaction", "user_settings": "User Settings", "accept_follow_request": "Accept follow request", diff --git a/src/modules/config.js b/src/modules/config.js @@ -137,6 +137,8 @@ export const defaultState = { modalOnUnfollow: undefined, // instance default modalOnBlock: undefined, // instance default modalOnMute: undefined, // instance default + modalOnMuteConversation: undefined, // instance default + modalOnMuteDomain: undefined, // instance default modalOnDelete: undefined, // instance default modalOnLogout: undefined, // instance default modalOnApproveFollow: undefined, // instance default diff --git a/src/modules/instance.js b/src/modules/instance.js @@ -77,6 +77,8 @@ const defaultState = { modalOnUnfollow: false, modalOnBlock: true, modalOnMute: false, + modalOnMuteConversation: false, + modalOnMuteDomain: true, modalOnDelete: true, modalOnLogout: true, modalOnApproveFollow: false, diff --git a/src/modules/serverSideStorage.js b/src/modules/serverSideStorage.js @@ -1,5 +1,16 @@ import { toRaw } from 'vue' -import { isEqual, cloneDeep, set, get, clamp, flatten, groupBy, findLastIndex, takeRight, uniqWith } from 'lodash' +import { + isEqual, + cloneDeep, + set, + get, + clamp, + flatten, + groupBy, + findLastIndex, + takeRight, + uniqWith +} from 'lodash' import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js' export const VERSION = 1 @@ -26,6 +37,7 @@ export const defaultState = { collapseNav: false }, collections: { + pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'], pinnedNavItems: ['home', 'dms', 'chats'] } }, @@ -77,7 +89,7 @@ const _verifyPrefs = (state) => { }) } -export const _getRecentData = (cache, live) => { +export const _getRecentData = (cache, live, isTest) => { const result = { recent: null, stale: null, needUpload: false } const cacheValid = _checkValidity(cache || {}) const liveValid = _checkValidity(live || {}) @@ -110,6 +122,23 @@ export const _getRecentData = (cache, live) => { console.debug('Both sources are invalid, start from scratch') result.needUpload = true } + + const merge = (a, b) => ({ + _version: a._version ?? b._version, + _timestamp: a._timestamp ?? b._timestamp, + needUpload: b.needUpload ?? a.needUpload, + prefsStorage: { + ...a.prefsStorage, + ...b.prefsStorage + }, + flagStorage: { + ...a.flagStorage, + ...b.flagStorage + } + }) + result.recent = isTest ? result.recent : (result.recent && merge(defaultState, result.recent)) + result.stale = isTest ? result.stale : (result.stale && merge(defaultState, result.stale)) + return result } @@ -281,7 +310,7 @@ export const mutations = { clearServerSideStorage (state, userData) { state = { ...cloneDeep(defaultState) } }, - setServerSideStorage (state, userData) { + setServerSideStorage (state, userData, test) { const live = userData.storage state.raw = live let cache = state.cache @@ -292,7 +321,7 @@ export const mutations = { cache = _doMigrations(cache) - let { recent, stale, needsUpload } = _getRecentData(cache, live) + let { recent, stale, needUpload } = _getRecentData(cache, live) const userNew = userData.created_at > NEW_USER_DATE const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage @@ -306,7 +335,7 @@ export const mutations = { }) } - if (!needsUpload && recent && stale) { + if (!needUpload && recent && stale) { console.debug('Checking if data needs merging...') // discarding timestamps and versions const { _timestamp: _0, _version: _1, ...recentData } = recent @@ -335,7 +364,7 @@ export const mutations = { recent.flagStorage = { ...flagsTemplate, ...totalFlags } recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs } - state.dirty = dirty || needsUpload + state.dirty = dirty || needUpload state.cache = recent // set local timestamp to smaller one if we don't have any changes if (stale && recent && !state.dirty) { diff --git a/test/unit/specs/modules/serverSideStorage.spec.js b/test/unit/specs/modules/serverSideStorage.spec.js @@ -74,7 +74,7 @@ describe('The serverSideStorage module', () => { }) }) - it('should reset local timestamp to remote if contents are the same', () => { + it.only('should reset local timestamp to remote if contents are the same', () => { const state = { ...cloneDeep(defaultState), cache: null @@ -176,33 +176,33 @@ describe('The serverSideStorage module', () => { }) describe('_getRecentData', () => { it('should handle nulls correctly', () => { - expect(_getRecentData(null, null)).to.eql({ recent: null, stale: null, needUpload: true }) + expect(_getRecentData(null, null, true)).to.eql({ recent: null, stale: null, needUpload: true }) }) it('doesn\'t choke on invalid data', () => { - expect(_getRecentData({ a: 1 }, { b: 2 })).to.eql({ recent: null, stale: null, needUpload: true }) + expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({ recent: null, stale: null, needUpload: true }) }) it('should prefer the valid non-null correctly, needUpload works properly', () => { const nonNull = { _version: VERSION, _timestamp: 1 } - expect(_getRecentData(nonNull, null)).to.eql({ recent: nonNull, stale: null, needUpload: true }) - expect(_getRecentData(null, nonNull)).to.eql({ recent: nonNull, stale: null, needUpload: false }) + expect(_getRecentData(nonNull, null, true)).to.eql({ recent: nonNull, stale: null, needUpload: true }) + expect(_getRecentData(null, nonNull, true)).to.eql({ recent: nonNull, stale: null, needUpload: false }) }) it('should prefer the one with higher timestamp', () => { const a = { _version: VERSION, _timestamp: 1 } const b = { _version: VERSION, _timestamp: 2 } - expect(_getRecentData(a, b)).to.eql({ recent: b, stale: a, needUpload: false }) - expect(_getRecentData(b, a)).to.eql({ recent: b, stale: a, needUpload: false }) + expect(_getRecentData(a, b, true)).to.eql({ recent: b, stale: a, needUpload: false }) + expect(_getRecentData(b, a, true)).to.eql({ recent: b, stale: a, needUpload: false }) }) it('case where both are same', () => { const a = { _version: VERSION, _timestamp: 3 } const b = { _version: VERSION, _timestamp: 3 } - expect(_getRecentData(a, b)).to.eql({ recent: b, stale: a, needUpload: false }) - expect(_getRecentData(b, a)).to.eql({ recent: b, stale: a, needUpload: false }) + expect(_getRecentData(a, b, true)).to.eql({ recent: b, stale: a, needUpload: false }) + expect(_getRecentData(b, a, true)).to.eql({ recent: b, stale: a, needUpload: false }) }) })