Browse Source

Merge branch 'from/develop/tusooa/confirm-dialogs' into 'develop'

Confirmation dialogs

See merge request pleroma/pleroma-fe!1431
HJ 1 year ago
parent
commit
65e10f07de
35 changed files with 759 additions and 42 deletions
  1. 1 0
      index.html
  2. 0 1
      src/App.vue
  3. 41 2
      src/components/account_actions/account_actions.js
  4. 42 0
      src/components/account_actions/account_actions.vue
  5. 37 0
      src/components/confirm_modal/confirm_modal.js
  6. 29 0
      src/components/confirm_modal/confirm_modal.vue
  7. 23 3
      src/components/desktop_nav/desktop_nav.js
  8. 12 0
      src/components/desktop_nav/desktop_nav.vue
  9. 2 2
      src/components/dialog_modal/dialog_modal.vue
  10. 25 6
      src/components/extra_buttons/extra_buttons.js
  11. 12 0
      src/components/extra_buttons/extra_buttons.vue
  12. 24 1
      src/components/follow_button/follow_button.js
  13. 21 0
      src/components/follow_button/follow_button.vue
  14. 1 0
      src/components/follow_card/follow_card.vue
  15. 48 1
      src/components/follow_request_card/follow_request_card.js
  16. 22 0
      src/components/follow_request_card/follow_request_card.vue
  17. 24 3
      src/components/mobile_nav/mobile_nav.js
  18. 22 0
      src/components/mobile_nav/mobile_nav.vue
  19. 43 2
      src/components/notification/notification.js
  20. 22 0
      src/components/notification/notification.vue
  21. 2 11
      src/components/poll/poll_form.js
  22. 25 2
      src/components/remove_follower_button/remove_follower_button.js
  23. 21 0
      src/components/remove_follower_button/remove_follower_button.vue
  24. 23 1
      src/components/retweet_button/retweet_button.js
  25. 12 0
      src/components/retweet_button/retweet_button.vue
  26. 50 0
      src/components/settings_modal/tabs/general_tab.vue
  27. 32 3
      src/components/user_card/user_card.js
  28. 5 0
      src/components/user_card/user_card.scss
  29. 47 0
      src/components/user_card/user_card.vue
  30. 46 0
      src/i18n/en.json
  31. 9 0
      src/modules/config.js
  32. 9 0
      src/modules/instance.js
  33. 5 2
      src/modules/users.js
  34. 6 2
      src/services/api/api.service.js
  35. 16 0
      src/services/date_utils/date_utils.js

+ 1 - 0
index.html

@@ -9,6 +9,7 @@
   <body class="hidden">
     <noscript>To use Pleroma, please enable JavaScript.</noscript>
     <div id="app"></div>
+    <div id="modal"></div>
     <!-- built files will be auto injected -->
     <div id="popovers" />
   </body>

+ 0 - 1
src/App.vue

@@ -71,7 +71,6 @@
     <StatusHistoryModal v-if="editingAvailable" />
     <SettingsModal />
     <UpdateNotification />
-    <div id="modal" />
     <GlobalNoticeList />
   </div>
 </template>

+ 41 - 2
src/components/account_actions/account_actions.js

@@ -2,6 +2,7 @@ import { mapState } from 'vuex'
 import ProgressButton from '../progress_button/progress_button.vue'
 import Popover from '../popover/popover.vue'
 import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faEllipsisV
@@ -16,14 +17,30 @@ const AccountActions = {
     'user', 'relationship'
   ],
   data () {
-    return { }
+    return {
+      showingConfirmBlock: false,
+      showingConfirmRemoveFollower: false
+    }
   },
   components: {
     ProgressButton,
     Popover,
-    UserListMenu
+    UserListMenu,
+    ConfirmModal
   },
   methods: {
+    showConfirmBlock () {
+      this.showingConfirmBlock = true
+    },
+    hideConfirmBlock () {
+      this.showingConfirmBlock = false
+    },
+    showConfirmRemoveUserFromFollowers () {
+      this.showingConfirmRemoveFollower = true
+    },
+    hideConfirmRemoveUserFromFollowers () {
+      this.showingConfirmRemoveFollower = false
+    },
     showRepeats () {
       this.$store.dispatch('showReblogs', this.user.id)
     },
@@ -31,13 +48,29 @@ const AccountActions = {
       this.$store.dispatch('hideReblogs', this.user.id)
     },
     blockUser () {
+      if (!this.shouldConfirmBlock) {
+        this.doBlockUser()
+      } else {
+        this.showConfirmBlock()
+      }
+    },
+    doBlockUser () {
       this.$store.dispatch('blockUser', this.user.id)
+      this.hideConfirmBlock()
     },
     unblockUser () {
       this.$store.dispatch('unblockUser', this.user.id)
     },
     removeUserFromFollowers () {
+      if (!this.shouldConfirmRemoveUserFromFollowers) {
+        this.doRemoveUserFromFollowers()
+      } else {
+        this.showConfirmRemoveUserFromFollowers()
+      }
+    },
+    doRemoveUserFromFollowers () {
       this.$store.dispatch('removeUserFromFollowers', this.user.id)
+      this.hideConfirmRemoveUserFromFollowers()
     },
     reportUser () {
       this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
@@ -50,6 +83,12 @@ const AccountActions = {
     }
   },
   computed: {
+    shouldConfirmBlock () {
+      return this.$store.getters.mergedConfig.modalOnBlock
+    },
+    shouldConfirmRemoveUserFromFollowers () {
+      return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
+    },
     ...mapState({
       pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
     })

+ 42 - 0
src/components/account_actions/account_actions.vue

@@ -74,6 +74,48 @@
         </button>
       </template>
     </Popover>
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingConfirmBlock"
+        :title="$t('user_card.block_confirm_title')"
+        :confirm-text="$t('user_card.block_confirm_accept_button')"
+        :cancel-text="$t('user_card.block_confirm_cancel_button')"
+        @accepted="doBlockUser"
+        @cancelled="hideConfirmBlock"
+      >
+        <i18n-t
+          keypath="user_card.block_confirm"
+          tag="span"
+        >
+          <template #user>
+            <span
+              v-text="user.screen_name_ui"
+            />
+          </template>
+        </i18n-t>
+      </confirm-modal>
+    </teleport>
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingConfirmRemoveFollower"
+        :title="$t('user_card.remove_follower_confirm_title')"
+        :confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
+        :cancel-text="$t('user_card.remove_follower_confirm_cancel_button')"
+        @accepted="doRemoveUserFromFollowers"
+        @cancelled="hideConfirmRemoveUserFromFollowers"
+      >
+        <i18n-t
+          keypath="user_card.remove_follower_confirm"
+          tag="span"
+        >
+          <template #user>
+            <span
+              v-text="user.screen_name_ui"
+            />
+          </template>
+        </i18n-t>
+      </confirm-modal>
+    </teleport>
   </div>
 </template>
 

+ 37 - 0
src/components/confirm_modal/confirm_modal.js

@@ -0,0 +1,37 @@
+import DialogModal from '../dialog_modal/dialog_modal.vue'
+
+/**
+ * This component emits the following events:
+ * cancelled, emitted when the action should not be performed;
+ * accepted, emitted when the action should be performed;
+ *
+ * The caller should close this dialog after receiving any of the two events.
+ */
+const ConfirmModal = {
+  components: {
+    DialogModal
+  },
+  props: {
+    title: {
+      type: String
+    },
+    cancelText: {
+      type: String
+    },
+    confirmText: {
+      type: String
+    }
+  },
+  computed: {
+  },
+  methods: {
+    onCancel () {
+      this.$emit('cancelled')
+    },
+    onAccept () {
+      this.$emit('accepted')
+    }
+  }
+}
+
+export default ConfirmModal

+ 29 - 0
src/components/confirm_modal/confirm_modal.vue

@@ -0,0 +1,29 @@
+<template>
+  <dialog-modal
+    v-body-scroll-lock="true"
+    class="confirm-modal"
+    :on-cancel="onCancel"
+  >
+    <template #header>
+      <span v-text="title" />
+    </template>
+
+    <slot />
+
+    <template #footer>
+      <button
+        class="btn button-default"
+        @click.prevent="onAccept"
+        v-text="confirmText"
+      />
+
+      <button
+        class="btn button-default"
+        @click.prevent="onCancel"
+        v-text="cancelText"
+      />
+    </template>
+  </dialog-modal>
+</template>
+
+<script src="./confirm_modal.js"></script>

+ 23 - 3
src/components/desktop_nav/desktop_nav.js

@@ -1,4 +1,5 @@
 import SearchBar from 'components/search_bar/search_bar.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faSignInAlt,
@@ -30,7 +31,8 @@ library.add(
 
 export default {
   components: {
-    SearchBar
+    SearchBar,
+    ConfirmModal
   },
   data: () => ({
     searchBarHidden: true,
@@ -40,7 +42,8 @@ export default {
         window.CSS.supports('-moz-mask-size', 'contain') ||
         window.CSS.supports('-ms-mask-size', 'contain') ||
         window.CSS.supports('-o-mask-size', 'contain')
-    )
+    ),
+    showingConfirmLogout: false
   }),
   computed: {
     enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
@@ -73,15 +76,32 @@ export default {
     hideSitename () { return this.$store.state.instance.hideSitename },
     logoLeft () { return this.$store.state.instance.logoLeft },
     currentUser () { return this.$store.state.users.currentUser },
-    privateMode () { return this.$store.state.instance.private }
+    privateMode () { return this.$store.state.instance.private },
+    shouldConfirmLogout () {
+      return this.$store.getters.mergedConfig.modalOnLogout
+    }
   },
   methods: {
     scrollToTop () {
       window.scrollTo(0, 0)
     },
+    showConfirmLogout () {
+      this.showingConfirmLogout = true
+    },
+    hideConfirmLogout () {
+      this.showingConfirmLogout = false
+    },
     logout () {
+      if (!this.shouldConfirmLogout) {
+        this.doLogout()
+      } else {
+        this.showConfirmLogout()
+      }
+    },
+    doLogout () {
       this.$router.replace('/main/public')
       this.$store.dispatch('logout')
+      this.hideConfirmLogout()
     },
     onSearchBarToggled (hidden) {
       this.searchBarHidden = hidden

+ 12 - 0
src/components/desktop_nav/desktop_nav.vue

@@ -76,6 +76,18 @@
         </button>
       </div>
     </div>
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingConfirmLogout"
+        :title="$t('login.logout_confirm_title')"
+        :confirm-text="$t('login.logout_confirm_accept_button')"
+        :cancel-text="$t('login.logout_confirm_cancel_button')"
+        @accepted="doLogout"
+        @cancelled="hideConfirmLogout"
+      >
+        {{ $t('login.logout_confirm') }}
+      </confirm-modal>
+    </teleport>
   </nav>
 </template>
 <script src="./desktop_nav.js"></script>

+ 2 - 2
src/components/dialog_modal/dialog_modal.vue

@@ -39,7 +39,7 @@
     right: 0;
     top: 0;
     background: rgb(27 31 35 / 50%);
-    z-index: 99;
+    z-index: 2000;
   }
 }
 
@@ -51,7 +51,7 @@
   margin: 15vh auto;
   position: fixed;
   transform: translateX(-50%);
-  z-index: 999;
+  z-index: 2001;
   cursor: default;
   display: block;
   background-color: $fallback--bg;

+ 25 - 6
src/components/extra_buttons/extra_buttons.js

@@ -1,4 +1,5 @@
 import Popover from '../popover/popover.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faEllipsisH,
@@ -32,10 +33,14 @@ library.add(
 
 const ExtraButtons = {
   props: ['status'],
-  components: { Popover },
+  components: {
+    Popover,
+    ConfirmModal
+  },
   data () {
     return {
-      expanded: false
+      expanded: false,
+      showingDeleteDialog: false
     }
   },
   methods: {
@@ -46,11 +51,22 @@ const ExtraButtons = {
       this.expanded = false
     },
     deleteStatus () {
-      const confirmed = window.confirm(this.$t('status.delete_confirm'))
-      if (confirmed) {
-        this.$store.dispatch('deleteStatus', { id: this.status.id })
+      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'))
@@ -133,7 +149,10 @@ const ExtraButtons = {
     isEdited () {
       return this.status.edited_at !== null
     },
-    editingAvailable () { return this.$store.state.instance.editingAvailable }
+    editingAvailable () { return this.$store.state.instance.editingAvailable },
+    shouldConfirmDelete () {
+      return this.$store.getters.mergedConfig.modalOnDelete
+    }
   }
 }
 

+ 12 - 0
src/components/extra_buttons/extra_buttons.vue

@@ -165,6 +165,18 @@
           />
         </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>

+ 24 - 1
src/components/follow_button/follow_button.js

@@ -1,12 +1,20 @@
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
 export default {
   props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
+  components: {
+    ConfirmModal
+  },
   data () {
     return {
-      inProgress: false
+      inProgress: false,
+      showingConfirmUnfollow: false
     }
   },
   computed: {
+    shouldConfirmUnfollow () {
+      return this.$store.getters.mergedConfig.modalOnUnfollow
+    },
     isPressed () {
       return this.inProgress || this.relationship.following
     },
@@ -35,6 +43,12 @@ export default {
     }
   },
   methods: {
+    showConfirmUnfollow () {
+      this.showingConfirmUnfollow = true
+    },
+    hideConfirmUnfollow () {
+      this.showingConfirmUnfollow = false
+    },
     onClick () {
       this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
     },
@@ -45,12 +59,21 @@ export default {
       })
     },
     unfollow () {
+      if (this.shouldConfirmUnfollow) {
+        this.showConfirmUnfollow()
+      } else {
+        this.doUnfollow()
+      }
+    },
+    doUnfollow () {
       const store = this.$store
       this.inProgress = true
       requestUnfollow(this.relationship.id, store).then(() => {
         this.inProgress = false
         store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
       })
+
+      this.hideConfirmUnfollow()
     }
   }
 }

+ 21 - 0
src/components/follow_button/follow_button.vue

@@ -7,6 +7,27 @@
     @click="onClick"
   >
     {{ label }}
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingConfirmUnfollow"
+        :title="$t('user_card.unfollow_confirm_title')"
+        :confirm-text="$t('user_card.unfollow_confirm_accept_button')"
+        :cancel-text="$t('user_card.unfollow_confirm_cancel_button')"
+        @accepted="doUnfollow"
+        @cancelled="hideConfirmUnfollow"
+      >
+        <i18n-t
+          keypath="user_card.unfollow_confirm"
+          tag="span"
+        >
+          <template #user>
+            <span
+              v-text="user.screen_name_ui"
+            />
+          </template>
+        </i18n-t>
+      </confirm-modal>
+    </teleport>
   </button>
 </template>
 

+ 1 - 0
src/components/follow_card/follow_card.vue

@@ -24,6 +24,7 @@
         />
         <RemoveFollowerButton
           v-if="noFollowsYou && relationship.followed_by"
+          :user="user"
           :relationship="relationship"
           class="follow-card-button"
         />

+ 48 - 1
src/components/follow_request_card/follow_request_card.js

@@ -1,10 +1,18 @@
 import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
 
 const FollowRequestCard = {
   props: ['user'],
   components: {
-    BasicUserCard
+    BasicUserCard,
+    ConfirmModal
+  },
+  data () {
+    return {
+      showingApproveConfirmDialog: false,
+      showingDenyConfirmDialog: false
+    }
   },
   methods: {
     findFollowRequestNotificationId () {
@@ -13,7 +21,26 @@ const FollowRequestCard = {
       )
       return notif && notif.id
     },
+    showApproveConfirmDialog () {
+      this.showingApproveConfirmDialog = true
+    },
+    hideApproveConfirmDialog () {
+      this.showingApproveConfirmDialog = false
+    },
+    showDenyConfirmDialog () {
+      this.showingDenyConfirmDialog = true
+    },
+    hideDenyConfirmDialog () {
+      this.showingDenyConfirmDialog = false
+    },
     approveUser () {
+      if (this.shouldConfirmApprove) {
+        this.showApproveConfirmDialog()
+      } else {
+        this.doApprove()
+      }
+    },
+    doApprove () {
       this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
       this.$store.dispatch('removeFollowRequest', this.user)
 
@@ -25,14 +52,34 @@ const FollowRequestCard = {
           notification.type = 'follow'
         }
       })
+      this.hideApproveConfirmDialog()
     },
     denyUser () {
+      if (this.shouldConfirmDeny) {
+        this.showDenyConfirmDialog()
+      } else {
+        this.doDeny()
+      }
+    },
+    doDeny () {
       const notifId = this.findFollowRequestNotificationId()
       this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
         .then(() => {
           this.$store.dispatch('dismissNotificationLocal', { id: notifId })
           this.$store.dispatch('removeFollowRequest', this.user)
         })
+      this.hideDenyConfirmDialog()
+    }
+  },
+  computed: {
+    mergedConfig () {
+      return this.$store.getters.mergedConfig
+    },
+    shouldConfirmApprove () {
+      return this.mergedConfig.modalOnApproveFollow
+    },
+    shouldConfirmDeny () {
+      return this.mergedConfig.modalOnDenyFollow
     }
   }
 }

+ 22 - 0
src/components/follow_request_card/follow_request_card.vue

@@ -14,6 +14,28 @@
         {{ $t('user_card.deny') }}
       </button>
     </div>
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingApproveConfirmDialog"
+        :title="$t('user_card.approve_confirm_title')"
+        :confirm-text="$t('user_card.approve_confirm_accept_button')"
+        :cancel-text="$t('user_card.approve_confirm_cancel_button')"
+        @accepted="doApprove"
+        @cancelled="hideApproveConfirmDialog"
+      >
+        {{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
+      </confirm-modal>
+      <confirm-modal
+        v-if="showingDenyConfirmDialog"
+        :title="$t('user_card.deny_confirm_title')"
+        :confirm-text="$t('user_card.deny_confirm_accept_button')"
+        :cancel-text="$t('user_card.deny_confirm_cancel_button')"
+        @accepted="doDeny"
+        @cancelled="hideDenyConfirmDialog"
+      >
+        {{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
+      </confirm-modal>
+    </teleport>
   </basic-user-card>
 </template>
 

+ 24 - 3
src/components/mobile_nav/mobile_nav.js

@@ -1,5 +1,6 @@
 import SideDrawer from '../side_drawer/side_drawer.vue'
 import Notifications from '../notifications/notifications.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
 import GestureService from '../../services/gesture_service/gesture_service'
 import NavigationPins from 'src/components/navigation/navigation_pins.vue'
@@ -25,12 +26,14 @@ const MobileNav = {
   components: {
     SideDrawer,
     Notifications,
-    NavigationPins
+    NavigationPins,
+    ConfirmModal
   },
   data: () => ({
     notificationsCloseGesture: undefined,
     notificationsOpen: false,
-    notificationsAtTop: true
+    notificationsAtTop: true,
+    showingConfirmLogout: false
   }),
   created () {
     this.notificationsCloseGesture = GestureService.swipeGesture(
@@ -57,7 +60,11 @@ const MobileNav = {
     ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']),
     chatsPinned () {
       return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
-    }
+    },
+    shouldConfirmLogout () {
+      return this.$store.getters.mergedConfig.modalOnLogout
+    },
+    ...mapGetters(['unreadChatCount'])
   },
   methods: {
     toggleMobileSidebar () {
@@ -88,9 +95,23 @@ const MobileNav = {
     scrollMobileNotificationsToTop () {
       this.$refs.mobileNotifications.scrollTo(0, 0)
     },
+    showConfirmLogout () {
+      this.showingConfirmLogout = true
+    },
+    hideConfirmLogout () {
+      this.showingConfirmLogout = false
+    },
     logout () {
+      if (!this.shouldConfirmLogout) {
+        this.doLogout()
+      } else {
+        this.showConfirmLogout()
+      }
+    },
+    doLogout () {
       this.$router.replace('/main/public')
       this.$store.dispatch('logout')
+      this.hideConfirmLogout()
     },
     markNotificationsAsSeen () {
       // this.$refs.notifications.markAsSeen()

+ 22 - 0
src/components/mobile_nav/mobile_nav.vue

@@ -88,6 +88,18 @@
       ref="sideDrawer"
       :logout="logout"
     />
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingConfirmLogout"
+        :title="$t('login.logout_confirm_title')"
+        :confirm-text="$t('login.logout_confirm_accept_button')"
+        :cancel-text="$t('login.logout_confirm_cancel_button')"
+        @accepted="doLogout"
+        @cancelled="hideConfirmLogout"
+      >
+        {{ $t('login.logout_confirm') }}
+      </confirm-modal>
+    </teleport>
   </div>
 </template>
 
@@ -235,6 +247,16 @@
       }
     }
   }
+
+  .confirm-modal.dark-overlay {
+    &::before {
+      z-index: 3000;
+    }
+
+    .dialog-modal.panel {
+      z-index: 3001;
+    }
+  }
 }
 
 </style>

+ 43 - 2
src/components/notification/notification.js

@@ -8,6 +8,7 @@ import Report from '../report/report.vue'
 import UserLink from '../user_link/user_link.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import UserPopover from '../user_popover/user_popover.vue'
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -43,7 +44,9 @@ const Notification = {
     return {
       statusExpanded: false,
       betterShadow: this.$store.state.interface.browserSupport.cssFilter,
-      unmuted: false
+      unmuted: false,
+      showingApproveConfirmDialog: false,
+      showingDenyConfirmDialog: false
     }
   },
   props: ['notification'],
@@ -56,7 +59,8 @@ const Notification = {
     Report,
     RichContent,
     UserPopover,
-    UserLink
+    UserLink,
+    ConfirmModal
   },
   methods: {
     toggleStatusExpanded () {
@@ -71,7 +75,26 @@ const Notification = {
     toggleMute () {
       this.unmuted = !this.unmuted
     },
+    showApproveConfirmDialog () {
+      this.showingApproveConfirmDialog = true
+    },
+    hideApproveConfirmDialog () {
+      this.showingApproveConfirmDialog = false
+    },
+    showDenyConfirmDialog () {
+      this.showingDenyConfirmDialog = true
+    },
+    hideDenyConfirmDialog () {
+      this.showingDenyConfirmDialog = false
+    },
     approveUser () {
+      if (this.shouldConfirmApprove) {
+        this.showApproveConfirmDialog()
+      } else {
+        this.doApprove()
+      }
+    },
+    doApprove () {
       this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
       this.$store.dispatch('removeFollowRequest', this.user)
       this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
@@ -81,13 +104,22 @@ const Notification = {
           notification.type = 'follow'
         }
       })
+      this.hideApproveConfirmDialog()
     },
     denyUser () {
+      if (this.shouldConfirmDeny) {
+        this.showDenyConfirmDialog()
+      } else {
+        this.doDeny()
+      }
+    },
+    doDeny () {
       this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
         .then(() => {
           this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
           this.$store.dispatch('removeFollowRequest', this.user)
         })
+      this.hideDenyConfirmDialog()
     }
   },
   computed: {
@@ -117,6 +149,15 @@ const Notification = {
     isStatusNotification () {
       return isStatusNotification(this.notification.type)
     },
+    mergedConfig () {
+      return this.$store.getters.mergedConfig
+    },
+    shouldConfirmApprove () {
+      return this.mergedConfig.modalOnApproveFollow
+    },
+    shouldConfirmDeny () {
+      return this.mergedConfig.modalOnDenyFollow
+    },
     ...mapState({
       currentUser: state => state.users.currentUser
     })

+ 22 - 0
src/components/notification/notification.vue

@@ -243,6 +243,28 @@
         </template>
       </div>
     </div>
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingApproveConfirmDialog"
+        :title="$t('user_card.approve_confirm_title')"
+        :confirm-text="$t('user_card.approve_confirm_accept_button')"
+        :cancel-text="$t('user_card.approve_confirm_cancel_button')"
+        @accepted="doApprove"
+        @cancelled="hideApproveConfirmDialog"
+      >
+        {{ $t('user_card.approve_confirm', { user: user.screen_name_ui }) }}
+      </confirm-modal>
+      <confirm-modal
+        v-if="showingDenyConfirmDialog"
+        :title="$t('user_card.deny_confirm_title')"
+        :confirm-text="$t('user_card.deny_confirm_accept_button')"
+        :cancel-text="$t('user_card.deny_confirm_cancel_button')"
+        @accepted="doDeny"
+        @cancelled="hideDenyConfirmDialog"
+      >
+        {{ $t('user_card.deny_confirm', { user: user.screen_name_ui }) }}
+      </confirm-modal>
+    </teleport>
   </article>
 </template>
 

+ 2 - 11
src/components/poll/poll_form.js

@@ -94,19 +94,10 @@ export default {
     },
     convertExpiryToUnit (unit, amount) {
       // Note: we want seconds and not milliseconds
-      switch (unit) {
-        case 'minutes': return (1000 * amount) / DateUtils.MINUTE
-        case 'hours': return (1000 * amount) / DateUtils.HOUR
-        case 'days': return (1000 * amount) / DateUtils.DAY
-      }
+      return DateUtils.secondsToUnit(unit, amount)
     },
     convertExpiryFromUnit (unit, amount) {
-      // Note: we want seconds and not milliseconds
-      switch (unit) {
-        case 'minutes': return 0.001 * amount * DateUtils.MINUTE
-        case 'hours': return 0.001 * amount * DateUtils.HOUR
-        case 'days': return 0.001 * amount * DateUtils.DAY
-      }
+      return DateUtils.unitToSeconds(unit, amount)
     },
     expiryAmountChange () {
       this.expiryAmount =

+ 25 - 2
src/components/remove_follower_button/remove_follower_button.js

@@ -1,10 +1,16 @@
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
+
 export default {
-  props: ['relationship'],
+  props: ['user', 'relationship'],
   data () {
     return {
-      inProgress: false
+      inProgress: false,
+      showingConfirmRemoveFollower: false
     }
   },
+  components: {
+    ConfirmModal
+  },
   computed: {
     label () {
       if (this.inProgress) {
@@ -12,14 +18,31 @@ export default {
       } else {
         return this.$t('user_card.remove_follower')
       }
+    },
+    shouldConfirmRemoveUserFromFollowers () {
+      return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers
     }
   },
   methods: {
+    showConfirmRemoveUserFromFollowers () {
+      this.showingConfirmRemoveFollower = true
+    },
+    hideConfirmRemoveUserFromFollowers () {
+      this.showingConfirmRemoveFollower = false
+    },
     onClick () {
+      if (!this.shouldConfirmRemoveUserFromFollowers) {
+        this.doRemoveUserFromFollowers()
+      } else {
+        this.showConfirmRemoveUserFromFollowers()
+      }
+    },
+    doRemoveUserFromFollowers () {
       this.inProgress = true
       this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
         this.inProgress = false
       })
+      this.hideConfirmRemoveUserFromFollowers()
     }
   }
 }

+ 21 - 0
src/components/remove_follower_button/remove_follower_button.vue

@@ -7,6 +7,27 @@
     @click="onClick"
   >
     {{ label }}
+    <teleport to="#modal">
+      <confirm-modal
+        v-if="showingConfirmRemoveFollower"
+        :title="$t('user_card.remove_follower_confirm_title')"
+        :confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
+        :cancel-text="$t('user_card.remove_follower_confirm_cancel_button')"
+        @accepted="doRemoveUserFromFollowers"
+        @cancelled="hideConfirmRemoveUserFromFollowers"
+      >
+        <i18n-t
+          keypath="user_card.remove_follower_confirm"
+          tag="span"
+        >
+          <template #user>
+            <span
+              v-text="user.screen_name_ui"
+            />
+          </template>
+        </i18n-t>
+      </confirm-modal>
+    </teleport>
   </button>
 </template>
 

+ 23 - 1
src/components/retweet_button/retweet_button.js

@@ -1,3 +1,4 @@
+import ConfirmModal from '../confirm_modal/confirm_modal.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faRetweet,
@@ -15,13 +16,24 @@ library.add(
 
 const RetweetButton = {
   props: ['status', 'loggedIn', 'visibility'],
+  components: {
+    ConfirmModal
+  },
   data () {
     return {
-      animated: false
+      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 {
@@ -31,6 +43,13 @@ const RetweetButton = {
       setTimeout(() => {
         this.animated = false
       }, 500)
+      this.hideConfirmDialog()
+    },
+    showConfirmDialog () {
+      this.showingConfirmDialog = true
+    },
+    hideConfirmDialog () {
+      this.showingConfirmDialog = false
     }
   },
   computed: {
@@ -39,6 +58,9 @@ const RetweetButton = {
     },
     remoteInteractionLink () {
       return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
+    },
+    shouldConfirmRepeat () {
+      return this.mergedConfig.modalOnRepeat
     }
   }
 }

+ 12 - 0
src/components/retweet_button/retweet_button.vue

@@ -59,6 +59,18 @@
     >
       {{ 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>
 

+ 50 - 0
src/components/settings_modal/tabs/general_tab.vue

@@ -148,6 +148,56 @@
             </SizeSetting>
           </div>
         </li>
+        <li class="select-multiple">
+          <span class="label">{{ $t('settings.confirm_dialogs') }}</span>
+          <ul class="option-list">
+            <li>
+              <BooleanSetting path="modalOnRepeat">
+                {{ $t('settings.confirm_dialogs_repeat') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnUnfollow">
+                {{ $t('settings.confirm_dialogs_unfollow') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnBlock">
+                {{ $t('settings.confirm_dialogs_block') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnMute">
+                {{ $t('settings.confirm_dialogs_mute') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnDelete">
+                {{ $t('settings.confirm_dialogs_delete') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnLogout">
+                {{ $t('settings.confirm_dialogs_logout') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnApproveFollow">
+                {{ $t('settings.confirm_dialogs_approve_follow') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnDenyFollow">
+                {{ $t('settings.confirm_dialogs_deny_follow') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="modalOnRemoveUserFromFollowers">
+                {{ $t('settings.confirm_dialogs_remove_follower') }}
+              </BooleanSetting>
+            </li>
+          </ul>
+        </li>
       </ul>
     </div>
     <div class="setting-item">

+ 32 - 3
src/components/user_card/user_card.js

@@ -1,3 +1,4 @@
+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'
@@ -8,6 +9,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 generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
@@ -46,7 +48,10 @@ export default {
   data () {
     return {
       followRequestInProgress: false,
-      betterShadow: this.$store.state.interface.browserSupport.cssFilter
+      betterShadow: this.$store.state.interface.browserSupport.cssFilter,
+      showingConfirmMute: false,
+      muteExpiryAmount: 0,
+      muteExpiryUnit: 'minutes'
     }
   },
   created () {
@@ -137,6 +142,12 @@ export default {
     supportsNote () {
       return 'note' in this.relationship
     },
+    shouldConfirmMute () {
+      return this.mergedConfig.modalOnMute
+    },
+    muteExpiryUnits () {
+      return ['minutes', 'hours', 'days']
+    },
     ...mapGetters(['mergedConfig'])
   },
   components: {
@@ -149,11 +160,29 @@ export default {
     Select,
     RichContent,
     UserLink,
-    UserNote
+    UserNote,
+    ConfirmModal
   },
   methods: {
+    showConfirmMute () {
+      this.showingConfirmMute = true
+    },
+    hideConfirmMute () {
+      this.showingConfirmMute = false
+    },
     muteUser () {
-      this.$store.dispatch('muteUser', this.user.id)
+      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()
     },
     unmuteUser () {
       this.$store.dispatch('unmuteUser', this.user.id)

+ 5 - 0
src/components/user_card/user_card.scss

@@ -355,3 +355,8 @@
     text-decoration: none;
   }
 }
+
+.mute-expiry {
+  display: flex;
+  flex-direction: row;
+}

+ 47 - 0
src/components/user_card/user_card.vue

@@ -314,6 +314,53 @@
         :handle-links="true"
       />
     </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>
+    </teleport>
   </div>
 </template>
 

+ 46 - 0
src/i18n/en.json

@@ -137,6 +137,10 @@
     "login": "Log in",
     "description": "Log in with OAuth",
     "logout": "Log out",
+    "logout_confirm_title": "Logout confirmation",
+    "logout_confirm": "Do you really want to logout?",
+    "logout_confirm_accept_button": "Logout",
+    "logout_confirm_cancel_button": "Do not logout",
     "password": "Password",
     "placeholder": "e.g. lain",
     "register": "Register",
@@ -420,6 +424,16 @@
     "composing": "Composing",
     "confirm_new_password": "Confirm new password",
     "current_password": "Current password",
+    "confirm_dialogs": "Ask for confirmation when",
+    "confirm_dialogs_repeat": "repeating a status",
+    "confirm_dialogs_unfollow": "unfollowing a user",
+    "confirm_dialogs_block": "blocking a user",
+    "confirm_dialogs_mute": "muting a user",
+    "confirm_dialogs_delete": "deleting a status",
+    "confirm_dialogs_logout": "logging out",
+    "confirm_dialogs_approve_follow": "approving a follower",
+    "confirm_dialogs_deny_follow": "denying a follower",
+    "confirm_dialogs_remove_follower": "removing a follower",
     "mutes_and_blocks": "Mutes and Blocks",
     "data_import_export_tab": "Data import / export",
     "default_vis": "Default visibility scope",
@@ -847,6 +861,10 @@
   "status": {
     "favorites": "Favorites",
     "repeats": "Repeats",
+    "repeat_confirm": "Do you really want to repeat this status?",
+    "repeat_confirm_title": "Repeat confirmation",
+    "repeat_confirm_accept_button": "Repeat",
+    "repeat_confirm_cancel_button": "Do not repeat",
     "delete": "Delete status",
     "edit": "Edit status",
     "edited_at": "(last edited {time})",
@@ -856,6 +874,9 @@
     "bookmark": "Bookmark",
     "unbookmark": "Unbookmark",
     "delete_confirm": "Do you really want to delete this status?",
+    "delete_confirm_title": "Delete confirmation",
+    "delete_confirm_accept_button": "Delete",
+    "delete_confirm_cancel_button": "Keep",
     "reply_to": "Reply to",
     "mentions": "Mentions",
     "replies_list": "Replies:",
@@ -902,10 +923,22 @@
   },
   "user_card": {
     "approve": "Approve",
+    "approve_confirm_title": "Approve confirmation",
+    "approve_confirm_accept_button": "Approve",
+    "approve_confirm_cancel_button": "Do not approve",
+    "approve_confirm": "Do you want to approve {user}'s follow request?",
     "block": "Block",
     "blocked": "Blocked!",
+    "block_confirm_title": "Block confirmation",
+    "block_confirm": "Do you really want to block {user}?",
+    "block_confirm_accept_button": "Block",
+    "block_confirm_cancel_button": "Do not block",
     "deactivated": "Deactivated",
     "deny": "Deny",
+    "deny_confirm_title": "Deny confirmation",
+    "deny_confirm_accept_button": "Deny",
+    "deny_confirm_cancel_button": "Do not deny",
+    "deny_confirm": "Do you want to deny {user}'s follow request?",
     "edit_profile": "Edit profile",
     "favorites": "Favorites",
     "follow": "Follow",
@@ -913,6 +946,10 @@
     "follow_sent": "Request sent!",
     "follow_progress": "Requesting…",
     "follow_unfollow": "Unfollow",
+    "unfollow_confirm_title": "Unfollow confirmation",
+    "unfollow_confirm": "Do you really want to unfollow {user}?",
+    "unfollow_confirm_accept_button": "Unfollow",
+    "unfollow_confirm_cancel_button": "Do not unfollow",
     "followees": "Following",
     "followers": "Followers",
     "following": "Following!",
@@ -924,9 +961,18 @@
     "message": "Message",
     "mute": "Mute",
     "muted": "Muted",
+    "mute_confirm_title": "Mute confirmation",
+    "mute_confirm": "Do you really want to mute {user}?",
+    "mute_confirm_accept_button": "Mute",
+    "mute_confirm_cancel_button": "Do not mute",
+    "mute_duration_prompt": "Mute this user for (0 for indefinite time):",
     "per_day": "per day",
     "remote_follow": "Remote follow",
     "remove_follower": "Remove follower",
+    "remove_follower_confirm_title": "Remove follower confirmation",
+    "remove_follower_confirm_accept_button": "Remove",
+    "remove_follower_confirm_cancel_button": "Keep",
+    "remove_follower_confirm": "Do you really want to remove {user} from your followers?",
     "report": "Report",
     "statuses": "Statuses",
     "subscribe": "Subscribe",

+ 9 - 0
src/modules/config.js

@@ -78,6 +78,15 @@ export const defaultState = {
   minimalScopesMode: undefined, // instance default
   // This hides statuses filtered via a word filter
   hideFilteredStatuses: undefined, // instance default
+  modalOnRepeat: undefined, // instance default
+  modalOnUnfollow: undefined, // instance default
+  modalOnBlock: undefined, // instance default
+  modalOnMute: undefined, // instance default
+  modalOnDelete: undefined, // instance default
+  modalOnLogout: undefined, // instance default
+  modalOnApproveFollow: undefined, // instance default
+  modalOnDenyFollow: undefined, // instance default
+  modalOnRemoveUserFromFollowers: undefined, // instance default
   playVideosInModal: false,
   useOneClickNsfw: false,
   useContainFit: true,

+ 9 - 0
src/modules/instance.js

@@ -71,6 +71,15 @@ const defaultState = {
   hideSitename: false,
   hideUserStats: false,
   muteBotStatuses: false,
+  modalOnRepeat: false,
+  modalOnUnfollow: false,
+  modalOnBlock: true,
+  modalOnMute: false,
+  modalOnDelete: true,
+  modalOnLogout: true,
+  modalOnApproveFollow: false,
+  modalOnDenyFollow: false,
+  modalOnRemoveUserFromFollowers: false,
   loginMethod: 'password',
   logo: '/static/logo.svg',
   logoMargin: '.2em',

+ 5 - 2
src/modules/users.js

@@ -61,13 +61,16 @@ const editUserNote = (store, { id, comment }) => {
     .then((relationship) => store.commit('updateUserRelationship', [relationship]))
 }
 
-const muteUser = (store, id) => {
+const muteUser = (store, args) => {
+  const id = typeof args === 'object' ? args.id : args
+  const expiresIn = typeof args === 'object' ? args.expiresIn : 0
+
   const predictedRelationship = store.state.relationships[id] || { id }
   predictedRelationship.muting = true
   store.commit('updateUserRelationship', [predictedRelationship])
   store.commit('addMuteId', id)
 
-  return store.rootState.api.backendInteractor.muteUser({ id })
+  return store.rootState.api.backendInteractor.muteUser({ id, expiresIn })
     .then((relationship) => {
       store.commit('updateUserRelationship', [relationship])
       store.commit('addMuteId', id)

+ 6 - 2
src/services/api/api.service.js

@@ -1118,8 +1118,12 @@ const fetchMutes = ({ credentials }) => {
     .then((users) => users.map(parseUser))
 }
 
-const muteUser = ({ id, credentials }) => {
-  return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST' })
+const muteUser = ({ id, expiresIn, credentials }) => {
+  const payload = {}
+  if (expiresIn) {
+    payload.expires_in = expiresIn
+  }
+  return promisedRequest({ url: MASTODON_MUTE_USER_URL(id), credentials, method: 'POST', payload })
 }
 
 const unmuteUser = ({ id, credentials }) => {

+ 16 - 0
src/services/date_utils/date_utils.js

@@ -41,3 +41,19 @@ export const relativeTimeShort = (date, nowThreshold = 1) => {
   r.key += '_short'
   return r
 }
+
+export const unitToSeconds = (unit, amount) => {
+  switch (unit) {
+    case 'minutes': return 0.001 * amount * MINUTE
+    case 'hours': return 0.001 * amount * HOUR
+    case 'days': return 0.001 * amount * DAY
+  }
+}
+
+export const secondsToUnit = (unit, amount) => {
+  switch (unit) {
+    case 'minutes': return (1000 * amount) / MINUTE
+    case 'hours': return (1000 * amount) / HOUR
+    case 'days': return (1000 * amount) / DAY
+  }
+}