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) {
+ if (button.name === 'emoji') {
+ this.$refs.picker.showPicker()
+ } else {
+ this.getComponent(button) === 'button' && this.doAction(button)
+ }
+ }
+ }
+}
diff --git a/src/components/status_action_buttons/action_button.scss b/src/components/status_action_buttons/action_button.scss
new file mode 100644
index 000000000..fe4983946
--- /dev/null
+++ b/src/components/status_action_buttons/action_button.scss
@@ -0,0 +1,92 @@
+@import "../../mixins";
+/* stylelint-disable declaration-no-important */
+
+.quick-action {
+ display: grid;
+ grid-template-columns: max-content;
+ grid-gap: 0.25em;
+
+ &.-pin {
+ margin: calc(-2px - 0.25em);
+ padding: 0.25em;
+ border: 2px dashed var(--icon);
+ border-radius: var(--roundness);
+ grid-template-columns: 1fr auto;
+ }
+
+ .action-button-inner,
+ .extra-button {
+ margin: -0.5em;
+ padding: 0.5em;
+ }
+
+ .separator {
+ width: 0.5em;
+
+ &::before {
+ content: "";
+ display: block;
+ width: 1px;
+ height: 1.5em;
+ background-color: var(--icon);
+ }
+ }
+
+ .action-button-inner {
+ display: grid;
+ grid-gap: 1em;
+ grid-template-columns: max-content max-content;
+ grid-auto-flow: column;
+ grid-auto-columns: max-content;
+ align-items: center;
+ }
+}
+
+.action-button {
+ display: grid;
+ grid-auto-flow: column;
+ 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
new file mode 100644
index 000000000..40d3397c5
--- /dev/null
+++ b/src/components/status_action_buttons/action_button.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
diff --git a/src/components/status_action_buttons/action_button_container.js b/src/components/status_action_buttons/action_button_container.js
new file mode 100644
index 000000000..6bd3fde54
--- /dev/null
+++ 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
new file mode 100644
index 000000000..0cde21efc
--- /dev/null
+++ b/src/components/status_action_buttons/action_button_container.vue
@@ -0,0 +1,94 @@
+
+
+
+
+ {{ props }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
new file mode 100644
index 000000000..9013981c0
--- /dev/null
+++ 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
new file mode 100644
index 000000000..45dac0dc1
--- /dev/null
+++ b/src/components/status_action_buttons/status_action_buttons.js
@@ -0,0 +1,143 @@
+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 {
+ Popover,
+ animationState: {
+ retweet: false,
+ favorite: false
+ },
+ 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) {
+ this.animationState[button.name] = true
+ button.action(this.funcArg)
+ .then(() => this.$emit('onSuccess'))
+ .catch(err => this.$emit('onError', err.error.error))
+ .finally(() => setTimeout(() => { this.animationState[button.name] = 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)
+ }
+ },
+ getRemoteInteractionLink () {
+ return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
+ }
+ }
+}
+
+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
new file mode 100644
index 000000000..e18083513
--- /dev/null
+++ b/src/components/status_action_buttons/status_action_buttons.scss
@@ -0,0 +1,21 @@
+@import "../../mixins";
+
+.StatusActionButtons {
+ .quick-action-buttons {
+ display: grid;
+ grid-template-columns: repeat(var(--_actionsColumnCount, 6), 1fr);
+ grid-auto-flow: row dense;
+ grid-auto-rows: 1fr;
+ grid-gap: 1.25em 1em;
+ margin-top: var(--status-margin);
+ }
+}
+// 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
new file mode 100644
index 000000000..a3b50975a
--- /dev/null
+++ b/src/components/status_action_buttons/status_action_buttons.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
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
index 70baa60d8..75f389946 100644
--- 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 @@
-
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 1d2e19467..3c97b1664 100644
--- 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
index 70a76d54c..13ef610c6 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -292,6 +292,10 @@
}
}
+#sidebar {
+ --_actionsColumnCount: 4;
+}
+
.sidebar .edit-profile-button {
display: none;
}
@@ -321,8 +325,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
index ed5401589..50abe9fff 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -311,51 +311,11 @@
/>