Bladeren bron

Merge branch 'disjointed-popovers' into 'develop'

Disjointed popovers

See merge request pleroma/pleroma-fe!1540
HJ 2 jaren geleden
bovenliggende
commit
33ad712852
50 gewijzigde bestanden met toevoegingen van 656 en 356 verwijderingen
  1. 11 1
      CHANGELOG.md
  2. 1 2
      src/App.js
  3. 9 2
      src/App.scss
  4. 2 1
      src/App.vue
  5. 3 0
      src/boot/after_store.js
  6. 2 10
      src/components/basic_user_card/basic_user_card.js
  7. 14 17
      src/components/basic_user_card/basic_user_card.vue
  8. 3 5
      src/components/chat_message/chat_message.js
  9. 4 4
      src/components/chat_message/chat_message.vue
  10. 3 7
      src/components/chat_title/chat_title.js
  11. 3 3
      src/components/chat_title/chat_title.vue
  12. 1 0
      src/components/desktop_nav/desktop_nav.scss
  13. 1 1
      src/components/desktop_nav/desktop_nav.vue
  14. 2 1
      src/components/emoji_picker/emoji_picker.scss
  15. 3 0
      src/components/extra_buttons/extra_buttons.js
  16. 26 24
      src/components/extra_buttons/extra_buttons.vue
  17. 1 1
      src/components/global_notice_list/global_notice_list.vue
  18. 1 1
      src/components/media_modal/media_modal.vue
  19. 21 3
      src/components/mention_link/mention_link.js
  20. 15 14
      src/components/mention_link/mention_link.scss
  21. 38 46
      src/components/mention_link/mention_link.vue
  22. 1 2
      src/components/mentions_line/mentions_line.vue
  23. 4 2
      src/components/mobile_nav/mobile_nav.vue
  24. 4 1
      src/components/modal/modal.vue
  25. 2 0
      src/components/nav_panel/nav_panel.vue
  26. 3 1
      src/components/notification/notification.js
  27. 13 12
      src/components/notification/notification.vue
  28. 10 0
      src/components/notifications/notifications.js
  29. 173 48
      src/components/popover/popover.js
  30. 28 20
      src/components/popover/popover.vue
  31. 3 2
      src/components/react_button/react_button.vue
  32. 5 5
      src/components/settings_modal/helpers/modified_indicator.vue
  33. 5 5
      src/components/settings_modal/helpers/server_side_indicator.vue
  34. 18 12
      src/components/settings_modal/tabs/general_tab.vue
  35. 1 1
      src/components/shout_panel/shout_panel.vue
  36. 1 1
      src/components/side_drawer/side_drawer.vue
  37. 3 3
      src/components/status/status.js
  38. 14 18
      src/components/status/status.vue
  39. 7 0
      src/components/status_popover/status_popover.js
  40. 2 2
      src/components/status_popover/status_popover.vue
  41. 18 32
      src/components/timeline_menu/timeline_menu.vue
  42. 24 6
      src/components/user_card/user_card.js
  43. 38 13
      src/components/user_card/user_card.scss
  44. 41 9
      src/components/user_card/user_card.vue
  45. 23 0
      src/components/user_popover/user_popover.js
  46. 33 0
      src/components/user_popover/user_popover.vue
  47. 1 1
      src/components/user_profile/user_profile.vue
  48. 3 1
      src/i18n/en.json
  49. 2 0
      src/modules/config.js
  50. 12 16
      test/unit/specs/components/rich_content.spec.js

+ 11 - 1
CHANGELOG.md

@@ -16,17 +16,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Attachments are ALWAYS in same order as user uploaded, no more "videos first"
 - Attachment description is prefilled with backend-provided default when uploading
 - Proper visual feedback that next image is loading when browsing
+- UI no longer lags when switching between mobile and desktop mode
+- Popovers no longer constrained by DOM hierarchy, shouldn't be cut off by anything
+- "Always show mobile button" is working now
 
 ### Changed
+- Using Vue 3 now
 - (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)
 - User highlight background now also covers the `@`
 - Reverted back to textual `@`, svg version is opt-in.
-- Settings window has been throughly rearranged to make make more sense and make navication settings easier.
+- Settings window has been thoroughly rearranged to make more sense and make navigation settings easier.
 - Uploaded attachments are uniform with displayed attachments
 - Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues)
 - Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post.
+- Slight width/spacing adjustments
+- More sizing stuff is font-size dependent now
+- Scrollbars are styled/colorized now
+- Scrollbars are toggleable (for stuff that didn't have visible scrollbars before) (opt-in)
 
 ### Added
+- 3 column mode: only enables when there's space for it (opt-out, customizable)
 - Options to show domains in mentions
 - Option to show user avatars in mention links (opt-in)
 - Option to disable the tooltip for mentions
@@ -37,6 +46,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Media modal now also displays description and counter position in gallery (i.e. 1/5)
 - Ability to rearrange order of attachments when uploading
 - Enabled users to zoom and pan images in media viewer with mouse and touch
+- Timelines/panels and conversations have sticky headers now
 - Added frontend ui for account migration
 
 

+ 1 - 2
src/App.js

@@ -4,7 +4,6 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
 import FeaturesPanel from './components/features_panel/features_panel.vue'
 import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
 import ShoutPanel from './components/shout_panel/shout_panel.vue'
-import SettingsModal from './components/settings_modal/settings_modal.vue'
 import MediaModal from './components/media_modal/media_modal.vue'
 import SideDrawer from './components/side_drawer/side_drawer.vue'
 import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
@@ -32,7 +31,7 @@ export default {
     MobilePostStatusButton,
     MobileNav,
     DesktopNav,
-    SettingsModal,
+    SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
     UserReportingModal,
     PostStatusModal,
     GlobalNoticeList

+ 9 - 2
src/App.scss

@@ -4,6 +4,13 @@
 :root {
   --navbar-height: 3.5rem;
   --post-line-height: 1.4;
+  // Z-Index stuff
+  --ZI_media_modal: 90000;
+  --ZI_modals_popovers: 85000;
+  --ZI_modals: 80000;
+  --ZI_navbar_popovers: 75000;
+  --ZI_navbar: 70000;
+  --ZI_popovers: 60000;
 }
 
 html {
@@ -117,7 +124,7 @@ i[class*=icon-],
 }
 
 nav {
-  z-index: 1000;
+  z-index: var(--ZI_navbar);
   color: var(--topBarText);
   background-color: $fallback--fg;
   background-color: var(--topBar, $fallback--fg);
@@ -828,7 +835,7 @@ option {
 // Vue transitions
 .fade-enter-active,
 .fade-leave-active {
-  transition: opacity 0.2s;
+  transition: opacity 0.3s;
 }
 
 .fade-enter-from,

+ 2 - 1
src/App.vue

@@ -42,7 +42,7 @@
       </div>
       <div id="notifs-column" class="column -scrollable" :class="{ '-show-scrollbar': showScrollbars }"/>
     </div>
-    <media-modal />
+    <MediaModal />
     <shout-panel
       v-if="currentUser && shout && !hideShoutbox"
       :floating="true"
@@ -55,6 +55,7 @@
     <SettingsModal />
     <div id="modal" />
     <GlobalNoticeList />
+    <div id="popovers" />
   </div>
 </template>
 

+ 3 - 0
src/boot/after_store.js

@@ -396,6 +396,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
   app.component('FAIcon', FontAwesomeIcon)
   app.component('FALayers', FontAwesomeLayers)
 
+  // remove after vue 3.3
+  app.config.unwrapInjectedRef = true
+
   app.mount('#app')
 
   return app

+ 2 - 10
src/components/basic_user_card/basic_user_card.js

@@ -1,4 +1,4 @@
-import UserCard from '../user_card/user_card.vue'
+import UserPopover from '../user_popover/user_popover.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -7,20 +7,12 @@ const BasicUserCard = {
   props: [
     'user'
   ],
-  data () {
-    return {
-      userExpanded: false
-    }
-  },
   components: {
-    UserCard,
+    UserPopover,
     UserAvatar,
     RichContent
   },
   methods: {
-    toggleUserExpanded () {
-      this.userExpanded = !this.userExpanded
-    },
     userProfileLink (user) {
       return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
     }

+ 14 - 17
src/components/basic_user_card/basic_user_card.vue

@@ -1,24 +1,19 @@
 <template>
   <div class="basic-user-card">
-    <router-link :to="userProfileLink(user)">
-      <UserAvatar
-        class="avatar"
-        :user="user"
-        @click.prevent="toggleUserExpanded"
-      />
+    <router-link @click.prevent :to="userProfileLink(user)">
+      <UserPopover
+        :userId="user.id"
+        :overlayCenters="true"
+        overlayCentersSelector=".avatar"
+      >
+        <UserAvatar
+          class="user-avatar avatar"
+          :user="user"
+          @click.prevent
+        />
+      </UserPopover>
     </router-link>
     <div
-      v-if="userExpanded"
-      class="basic-user-card-expanded-content"
-    >
-      <UserCard
-        :user-id="user.id"
-        :rounded="true"
-        :bordered="true"
-      />
-    </div>
-    <div
-      v-else
       class="basic-user-card-collapsed-content"
     >
       <div
@@ -53,6 +48,8 @@
   margin: 0;
   padding: 0.6em 1em;
 
+   --emoji-size: 14px;
+
   &-collapsed-content {
     margin-left: 0.7em;
     text-align: left;

+ 3 - 5
src/components/chat_message/chat_message.js

@@ -6,7 +6,7 @@ import Gallery from '../gallery/gallery.vue'
 import LinkPreview from '../link-preview/link-preview.vue'
 import StatusContent from '../status_content/status_content.vue'
 import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { defineAsyncComponent } from 'vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faTimes,
@@ -35,7 +35,8 @@ const ChatMessage = {
     UserAvatar,
     Gallery,
     LinkPreview,
-    ChatMessageDate
+    ChatMessageDate,
+    UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
   },
   computed: {
     // Returns HH:MM (hours and minutes) in local time.
@@ -49,9 +50,6 @@ const ChatMessage = {
     message () {
       return this.chatViewItem.data
     },
-    userProfileLink () {
-      return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
-    },
     isMessage () {
       return this.chatViewItem.type === 'message'
     },

+ 4 - 4
src/components/chat_message/chat_message.vue

@@ -14,16 +14,16 @@
         v-if="!isCurrentUser"
         class="avatar-wrapper"
       >
-        <router-link
+        <UserPopover
           v-if="chatViewItem.isHead"
-          :to="userProfileLink"
+          :userId="author.id"
         >
           <UserAvatar
             :compact="true"
             :better-shadow="betterShadow"
             :user="author"
           />
-        </router-link>
+        </UserPopover>
       </div>
       <div class="chat-message-inner">
         <div
@@ -44,7 +44,7 @@
               <Popover
                 trigger="click"
                 placement="top"
-                :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
+                bound-to-selector=".chat-view-inner"
                 :bound-to="{ x: 'container' }"
                 :margin="popoverMarginStyle"
                 @show="menuOpened = true"

+ 3 - 7
src/components/chat_title/chat_title.js

@@ -1,12 +1,13 @@
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
+import { defineAsyncComponent } from 'vue'
 
 export default {
   name: 'ChatTitle',
   components: {
     UserAvatar,
-    RichContent
+    RichContent,
+    UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
   },
   props: [
     'user', 'withAvatar'
@@ -18,10 +19,5 @@ export default {
     htmlTitle () {
       return this.user ? this.user.name_html : ''
     }
-  },
-  methods: {
-    getUserProfileLink (user) {
-      return generateProfileLink(user.id, user.screen_name)
-    }
   }
 }

+ 3 - 3
src/components/chat_title/chat_title.vue

@@ -3,16 +3,16 @@
     class="chat-title"
     :title="title"
   >
-    <router-link
+    <UserPopover
       class="avatar-container"
       v-if="withAvatar && user"
-      :to="getUserProfileLink(user)"
+      :userId="user.id"
     >
       <UserAvatar
         class="titlebar-avatar"
         :user="user"
       />
-    </router-link>
+    </UserPopover>
     <RichContent
       v-if="user"
       class="username"

+ 1 - 0
src/components/desktop_nav/desktop_nav.scss

@@ -2,6 +2,7 @@
 
 .DesktopNav {
   width: 100%;
+  z-index: var(--ZI_navbar);
 
   input {
     color: var(--inputTopbarText, var(--inputText));

+ 1 - 1
src/components/desktop_nav/desktop_nav.vue

@@ -38,7 +38,7 @@
         />
         <button
           class="button-unstyled nav-icon"
-          @click.stop="openSettingsModal"
+          @click="openSettingsModal"
         >
           <FAIcon
             fixed-width

+ 2 - 1
src/components/emoji_picker/emoji_picker.scss

@@ -7,7 +7,8 @@
   right: 0;
   left: 0;
   margin: 0 !important;
-  z-index: 100;
+  // TODO: actually use popover in emoji picker
+  z-index: var(--ZI_popovers);
   background-color: $fallback--bg;
   background-color: var(--popover, $fallback--bg);
   color: $fallback--link;

+ 3 - 0
src/components/extra_buttons/extra_buttons.js

@@ -89,6 +89,9 @@ const ExtraButtons = {
     canMute () {
       return !!this.currentUser
     },
+    canBookmark () {
+      return !!this.currentUser
+    },
     statusLink () {
       return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
     }

+ 26 - 24
src/components/extra_buttons/extra_buttons.vue

@@ -51,28 +51,30 @@
             icon="thumbtack"
           /><span>{{ $t("status.unpin") }}</span>
         </button>
-        <button
-          v-if="!status.bookmarked"
-          class="button-default dropdown-item dropdown-item-icon"
-          @click.prevent="bookmarkStatus"
-          @click="close"
-        >
-          <FAIcon
-            fixed-width
-            :icon="['far', 'bookmark']"
-          /><span>{{ $t("status.bookmark") }}</span>
-        </button>
-        <button
-          v-if="status.bookmarked"
-          class="button-default dropdown-item dropdown-item-icon"
-          @click.prevent="unbookmarkStatus"
-          @click="close"
-        >
-          <FAIcon
-            fixed-width
-            icon="bookmark"
-          /><span>{{ $t("status.unbookmark") }}</span>
-        </button>
+        <template v-if="canBookmark">
+          <button
+            v-if="!status.bookmarked"
+            class="button-default dropdown-item dropdown-item-icon"
+            @click.prevent="bookmarkStatus"
+            @click="close"
+          >
+            <FAIcon
+              fixed-width
+              :icon="['far', 'bookmark']"
+            /><span>{{ $t("status.bookmark") }}</span>
+          </button>
+          <button
+            v-if="status.bookmarked"
+            class="button-default dropdown-item dropdown-item-icon"
+            @click.prevent="unbookmarkStatus"
+            @click="close"
+          >
+            <FAIcon
+              fixed-width
+              icon="bookmark"
+            /><span>{{ $t("status.unbookmark") }}</span>
+          </button>
+        </template>
         <button
           v-if="canDelete"
           class="button-default dropdown-item dropdown-item-icon"
@@ -119,12 +121,12 @@
       </div>
     </template>
     <template v-slot:trigger>
-      <button class="button-unstyled popover-trigger">
+      <span class="button-unstyled popover-trigger">
         <FAIcon
           class="fa-scale-110 fa-old-padding"
           icon="ellipsis-h"
         />
-      </button>
+      </span>
     </template>
   </Popover>
 </template>

+ 1 - 1
src/components/global_notice_list/global_notice_list.vue

@@ -32,7 +32,7 @@
   top: 50px;
   width: 100%;
   pointer-events: none;
-  z-index: 1001;
+  z-index: var(--ZI_popovers);
   display: flex;
   flex-direction: column;
   align-items: center;

+ 1 - 1
src/components/media_modal/media_modal.vue

@@ -121,7 +121,7 @@ $modal-view-button-icon-width: 3em;
 $modal-view-button-icon-margin: 0.5em;
 
 .modal-view.media-modal-view {
-  z-index: 9000;
+  z-index: var(--ZI_media_modal);
   flex-direction: column;
 
   .modal-view-button-arrow,

+ 21 - 3
src/components/mention_link/mention_link.js

@@ -2,6 +2,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
 import { mapGetters, mapState } from 'vuex'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import { defineAsyncComponent } from 'vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faAt
@@ -14,7 +15,8 @@ library.add(
 const MentionLink = {
   name: 'MentionLink',
   components: {
-    UserAvatar
+    UserAvatar,
+    UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
   },
   props: {
     url: {
@@ -34,15 +36,30 @@ const MentionLink = {
       type: String
     }
   },
+  data () {
+    return {
+      hasSelection: false
+    }
+  },
   methods: {
     onClick () {
+      if (this.shouldShowTooltip) return
       const link = generateProfileLink(
         this.userId || this.user.id,
         this.userScreenName || this.user.screen_name
       )
       this.$router.push(link)
+    },
+    handleSelection () {
+      this.hasSelection = document.getSelection().containsNode(this.$refs.full, true)
     }
   },
+  mounted () {
+    document.addEventListener('selectionchange', this.handleSelection)
+  },
+  unmounted () {
+    document.removeEventListener('selectionchange', this.handleSelection)
+  },
   computed: {
     user () {
       return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
@@ -88,7 +105,8 @@ const MentionLink = {
       return [
         {
           '-you': this.isYou && this.shouldBoldenYou,
-          '-highlighted': this.highlight
+          '-highlighted': this.highlight,
+          '-has-selection': this.hasSelection
         },
         this.highlightType
       ]
@@ -110,7 +128,7 @@ const MentionLink = {
       }
     },
     shouldShowTooltip () {
-      return this.mergedConfig.mentionLinkShowTooltip && this.mergedConfig.mentionLinkDisplay === 'short' && this.isRemote
+      return this.mergedConfig.mentionLinkShowTooltip
     },
     shouldShowAvatar () {
       return this.mergedConfig.mentionLinkShowAvatar

+ 15 - 14
src/components/mention_link/mention_link.scss

@@ -55,11 +55,14 @@
 
   .new {
     &.-you {
-      & .shortName,
-      & .full {
+      .shortName {
         font-weight: 600;
       }
     }
+    &.-has-selection {
+      color: var(--alertNeutralText, $fallback--text);
+      background-color: var(--alertNeutral, $fallback--fg);
+    }
 
     .at {
       color: var(--link);
@@ -72,8 +75,7 @@
     }
 
     &.-striped {
-      & .shortName,
-      & .full {
+      & .shortName {
         background-image:
           repeating-linear-gradient(
             135deg,
@@ -86,30 +88,29 @@
     }
 
     &.-solid {
-      & .shortName,
-      & .full {
+      .shortName {
         background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
       }
     }
 
     &.-side {
-      & .shortName,
-      & .userNameFull {
+      .shortName {
         box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
       }
     }
   }
 
-  &:hover .new .full {
-    opacity: 1;
-    pointer-events: initial;
+  .full {
+    pointer-events: none;
   }
 
   .serverName.-faded {
     color: var(--faintLink, $fallback--link);
   }
+}
 
-  .full .-faded {
-    color: var(--faint, $fallback--faint);
-  }
+.mention-link-popover {
+  max-width: 70ch;
+  max-height: 20rem;
+  overflow: hidden;
 }

+ 38 - 46
src/components/mention_link/mention_link.vue

@@ -9,66 +9,58 @@
       class="original"
       target="_blank"
       v-html="content"
-    /><!-- eslint-enable vue/no-v-html --><span
-      v-if="user"
-      class="new"
-      :style="style"
-      :class="classnames"
+    /><!-- eslint-enable vue/no-v-html -->
+    <UserPopover
+      v-else
+      :userId="user.id"
+      :disabled="!shouldShowTooltip"
     >
-      <a
-        class="short button-unstyled"
-        :class="{ '-with-tooltip': shouldShowTooltip }"
-        :href="url"
-        @click.prevent="onClick"
+      <span
+        v-if="user"
+        class="new"
+        :style="style"
+        :class="classnames"
       >
-        <!-- eslint-disable vue/no-v-html -->
-        <UserAvatar
-          v-if="shouldShowAvatar"
-          class="mention-avatar"
-          :user="user"
-        /><span
-          class="shortName"
-        ><FAIcon
-          v-if="useAtIcon"
-          size="sm"
-          icon="at"
-          class="at"
-        />{{ !useAtIcon ? '@' : '' }}<span
-          class="userName"
-          v-html="userName"
-        /><span
-          v-if="shouldShowFullUserName"
-          class="serverName"
-          :class="{ '-faded': shouldFadeDomain }"
-          v-html="'@' + serverName"
-        />
-        </span>
-        <span
-          v-if="isYou && shouldShowYous"
-          :class="{ '-you': shouldBoldenYou }"
-        > {{ ' ' + $t('status.you') }}</span>
-        <!-- eslint-enable vue/no-v-html -->
-      </a><span
-        v-if="shouldShowTooltip"
-        class="full popover-default"
-        :class="[highlightType]"
-      >
-        <span
-          class="userNameFull"
+        <a
+          class="short button-unstyled"
+          :class="{ '-with-tooltip': shouldShowTooltip }"
+          :href="url"
+          @click.prevent="onClick"
         >
           <!-- eslint-disable vue/no-v-html -->
-          @<span
+          <UserAvatar
+            v-if="shouldShowAvatar"
+            class="mention-avatar"
+            :user="user"
+          /><span
+            class="shortName"
+          ><FAIcon
+            v-if="useAtIcon"
+            size="sm"
+            icon="at"
+            class="at"
+          />{{ !useAtIcon ? '@' : '' }}<span
             class="userName"
             v-html="userName"
           /><span
+            v-if="shouldShowFullUserName"
             class="serverName"
             :class="{ '-faded': shouldFadeDomain }"
             v-html="'@' + serverName"
           />
+          </span>
+          <span
+            v-if="isYou && shouldShowYous"
+            :class="{ '-you': shouldBoldenYou }"
+          > {{ ' ' + $t('status.you') }}</span>
           <!-- eslint-enable vue/no-v-html -->
+        </a><span class="full" ref="full">
+            <!-- eslint-disable vue/no-v-html -->
+            @<span v-html="userName" /><span v-html="'@' + serverName" />
+            <!-- eslint-enable vue/no-v-html -->
         </span>
       </span>
-    </span>
+    </UserPopover>
   </span>
 </template>
 

+ 1 - 2
src/components/mentions_line/mentions_line.vue

@@ -13,8 +13,7 @@
       <span
         v-if="expanded"
         class="fullExtraMentions"
-      >
-        <MentionLink
+      >{{ ' ' }}<MentionLink
           v-for="mention in extraMentions"
           :key="mention.index"
           class="mention-link"

+ 4 - 2
src/components/mobile_nav/mobile_nav.vue

@@ -86,6 +86,8 @@
 @import '../../_variables.scss';
 
 .MobileNav {
+  z-index: var(--ZI_navbar);
+
   .mobile-nav {
     display: grid;
     line-height: var(--navbar-height);
@@ -147,7 +149,7 @@
     transition-property: transform;
     transition-duration: 0.25s;
     transform: translateX(0);
-    z-index: 1001;
+    z-index: var(--ZI_navbar);
     -webkit-overflow-scrolling: touch;
 
     &.-closed {
@@ -160,7 +162,7 @@
     display: flex;
     align-items: center;
     justify-content: space-between;
-    z-index: 1;
+    z-index: calc(var(--ZI_navbar) + 100);
     width: 100%;
     height: 50px;
     line-height: 50px;

+ 4 - 1
src/components/modal/modal.vue

@@ -22,6 +22,9 @@ export default {
       default: false
     }
   },
+  provide: {
+    popoversZLayer: 'modals'
+  },
   computed: {
     classes () {
       return {
@@ -35,7 +38,7 @@ export default {
 
 <style lang="scss">
 .modal-view {
-  z-index: 2000;
+  z-index: var(--ZI_modals);
   position: fixed;
   top: 0;
   left: 0;

+ 2 - 0
src/components/nav_panel/nav_panel.vue

@@ -113,7 +113,9 @@
     border-color: $fallback--border;
     border-color: var(--border, $fallback--border);
     padding: 0;
+  }
 
+  > li {
     &:first-child .menu-item {
       border-top-right-radius: $fallback--panelRadius;
       border-top-right-radius: var(--panelRadius, $fallback--panelRadius);

+ 3 - 1
src/components/notification/notification.js

@@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
 import UserCard from '../user_card/user_card.vue'
 import Timeago from '../timeago/timeago.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
+import UserPopover from '../user_popover/user_popover.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'
@@ -46,7 +47,8 @@ const Notification = {
     UserCard,
     Timeago,
     Status,
-    RichContent
+    RichContent,
+    UserPopover
   },
   methods: {
     toggleUserExpanded () {

+ 13 - 12
src/components/notification/notification.vue

@@ -34,21 +34,22 @@
       <a
         class="avatar-container"
         :href="$router.resolve(userProfileLink).href"
-        @click.stop.prevent.capture="toggleUserExpanded"
+        @click.prevent
       >
-        <UserAvatar
-          :compact="true"
-          :better-shadow="betterShadow"
-          :user="notification.from_profile"
-        />
+        <UserPopover
+          :userId="notification.from_profile.id"
+          :overlayCenters="true"
+        >
+          <UserAvatar
+            class="post-avatar"
+            :bot="botIndicator"
+            :compact="true"
+            :better-shadow="betterShadow"
+            :user="notification.from_profile"
+          />
+        </UserPopover>
       </a>
       <div class="notification-right">
-        <UserCard
-          v-if="userExpanded"
-          :user-id="getUser(notification).id"
-          :rounded="true"
-          :bordered="true"
-        />
         <span class="notification-details">
           <div class="name-and-action">
             <!-- eslint-disable vue/no-v-html -->

+ 10 - 0
src/components/notifications/notifications.js

@@ -1,3 +1,4 @@
+import { computed } from 'vue'
 import { mapGetters } from 'vuex'
 import Notification from '../notification/notification.vue'
 import NotificationFilters from './notification_filters.vue'
@@ -40,6 +41,11 @@ const Notifications = {
       seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
     }
   },
+  provide () {
+    return {
+      popoversZLayer: computed(() => this.popoversZLayer)
+    }
+  },
   computed: {
     mainClass () {
       return this.minimalMode ? '' : 'panel panel-default'
@@ -77,6 +83,10 @@ const Notifications = {
       }
       return map[layoutType] || '#notifs-sidebar'
     },
+    popoversZLayer () {
+      const { layoutType } = this.$store.state.interface
+      return layoutType === 'mobile' ? 'navbar' : null
+    },
     notificationsToDisplay () {
       return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
     },

+ 173 - 48
src/components/popover/popover.js

@@ -31,13 +31,35 @@ const Popover = {
 
     // If true, subtract padding when calculating position for the popover,
     // use it when popover offset looks to be different on top vs bottom.
-    removePadding: Boolean
+    removePadding: Boolean,
+
+    // self-explanatory (i hope)
+    disabled: Boolean,
+
+    // Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
+    overlayCenters: Boolean,
+
+    // What selector (witin popover!) to use for determining center of popover
+    overlayCentersSelector: String,
+
+    // Lets hover popover stay when clicking inside of it
+    stayOnClick: Boolean
   },
+  inject: ['popoversZLayer'], // override popover z layer
   data () {
     return {
+      // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
+      // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
+      // with popovers refusing to be hidden when user wants to interact with something in below popover
+      lockReEntry: false,
       hidden: true,
-      styles: { opacity: 0 },
-      oldSize: { width: 0, height: 0 }
+      styles: {},
+      oldSize: { width: 0, height: 0 },
+      scrollable: null,
+      // used to avoid blinking if hovered onto popover
+      graceTimeout: null,
+      parentPopover: null,
+      childrenShown: new Set()
     }
   },
   methods: {
@@ -47,9 +69,7 @@ const Popover = {
     },
     updateStyles () {
       if (this.hidden) {
-        this.styles = {
-          opacity: 0
-        }
+        this.styles = {}
         return
       }
 
@@ -57,14 +77,26 @@ const Popover = {
       // its children are what are inside the slot. Expect only one v-slot:trigger.
       const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
       // SVGs don't have offsetWidth/Height, use fallback
-      const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
       const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
-      const screenBox = anchorEl.getBoundingClientRect()
-      // Screen position of the origin point for popover
-      const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
+      const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
+      const anchorScreenBox = anchorEl.getBoundingClientRect()
+
+      const anchorStyle = getComputedStyle(anchorEl)
+      const topPadding = parseFloat(anchorStyle.paddingTop)
+      const bottomPadding = parseFloat(anchorStyle.paddingBottom)
+
+      // Screen position of the origin point for popover = center of the anchor
+      const origin = {
+        x: anchorScreenBox.left + anchorWidth * 0.5,
+        y: anchorScreenBox.top + anchorHeight * 0.5
+      }
       const content = this.$refs.content
+      const overlayCenter = this.overlayCenters
+        ? this.$refs.content.querySelector(this.overlayCentersSelector)
+        : null
+
       // Minor optimization, don't call a slow reflow call if we don't have to
-      const parentBounds = this.boundTo &&
+      const parentScreenBox = this.boundTo &&
         (this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
         this.containerBoundingClientRect()
 
@@ -73,81 +105,151 @@ const Popover = {
       // What are the screen bounds for the popover? Viewport vs container
       // when using viewport, using default margin values to dodge the navbar
       const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
-        min: parentBounds.left + (margin.left || 0),
-        max: parentBounds.right - (margin.right || 0)
+        min: parentScreenBox.left + (margin.left || 0),
+        max: parentScreenBox.right - (margin.right || 0)
       } : {
         min: 0 + (margin.left || 10),
         max: window.innerWidth - (margin.right || 10)
       }
 
       const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
-        min: parentBounds.top + (margin.top || 0),
-        max: parentBounds.bottom - (margin.bottom || 0)
+        min: parentScreenBox.top + (margin.top || 0),
+        max: parentScreenBox.bottom - (margin.bottom || 0)
       } : {
         min: 0 + (margin.top || 50),
         max: window.innerHeight - (margin.bottom || 5)
       }
 
       let horizOffset = 0
+      let vertOffset = 0
+
+      if (overlayCenter) {
+        const box = content.getBoundingClientRect()
+        const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
+        const leftInnerOffset = overlayCenterScreenBox.left - box.left
+        const topInnerOffset = overlayCenterScreenBox.top - box.top
+        horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
+        vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
+      } else {
+        horizOffset = content.offsetWidth * -0.5
+        vertOffset = content.offsetHeight * -0.5
+      }
+
+      const leftBorder = origin.x + horizOffset
+      const rightBorder = leftBorder + content.offsetWidth
+      const topBorder = origin.y + vertOffset
+      const bottomBorder = topBorder + content.offsetHeight
 
       // If overflowing from left, move it so that it doesn't
-      if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) {
-        horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min
+      if (leftBorder < xBounds.min) {
+        horizOffset += xBounds.min - leftBorder
       }
 
       // If overflowing from right, move it so that it doesn't
-      if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) {
-        horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max
+      if (rightBorder > xBounds.max) {
+        horizOffset -= rightBorder - xBounds.max
       }
 
-      // 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
-      // force to bottom, again regardless of what placement value was.
-      if (origin.y + content.offsetHeight > yBounds.max) usingTop = true
-      if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
+      // If overflowing from top, move it so that it doesn't
+      if (topBorder < yBounds.min) {
+        vertOffset += yBounds.min - topBorder
+      }
 
-      let vPadding = 0
-      if (this.removePadding && usingTop) {
-        const anchorStyle = getComputedStyle(anchorEl)
-        vPadding = parseFloat(anchorStyle.paddingTop) + parseFloat(anchorStyle.paddingBottom)
+      // If overflowing from bottom, move it so that it doesn't
+      if (bottomBorder > yBounds.max) {
+        vertOffset -= bottomBorder - yBounds.max
       }
 
-      const yOffset = (this.offset && this.offset.y) || 0
-      const translateY = usingTop
-        ? -anchorHeight + vPadding - yOffset - content.offsetHeight
-        : yOffset
+      let translateX = 0
+      let translateY = 0
+
+      if (overlayCenter) {
+        translateX = origin.x + horizOffset
+        translateY = origin.y + vertOffset
+      } else {
+        // 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
+        // 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)
+        if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
+        if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
 
-      const xOffset = (this.offset && this.offset.x) || 0
-      const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset
+        const yOffset = (this.offset && this.offset.y) || 0
+        translateY = usingTop
+          ? topBoundary - yOffset - content.offsetHeight
+          : bottomBoundary + yOffset
+
+        const xOffset = (this.offset && this.offset.x) || 0
+        translateX = origin.x + horizOffset + xOffset
+      }
 
-      // Note, separate translateX and translateY avoids blurry text on chromium,
-      // single translate or translate3d resulted in blurry text.
       this.styles = {
-        opacity: 1,
-        transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
+        left: `${Math.round(translateX)}px`,
+        top: `${Math.round(translateY)}px`
+      }
+
+      if (this.popoversZLayer) {
+        this.styles['--ZI_popover_override'] = `var(--ZI_${this.popoversZLayer}_popovers)`
+      }
+      if (parentScreenBox) {
+        this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
       }
     },
     showPopover () {
+      if (this.disabled) return
       const wasHidden = this.hidden
       this.hidden = false
+      this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
+      if (this.trigger === 'click' || this.stayOnClick) {
+        document.addEventListener('click', this.onClickOutside)
+      }
+      this.scrollable.addEventListener('scroll', this.onScroll)
+      this.scrollable.addEventListener('resize', this.onResize)
       this.$nextTick(() => {
         if (wasHidden) this.$emit('show')
         this.updateStyles()
       })
     },
     hidePopover () {
+      if (this.disabled) return
       if (!this.hidden) this.$emit('close')
       this.hidden = true
-      this.styles = { opacity: 0 }
+      this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
+      if (this.trigger === 'click') {
+        document.removeEventListener('click', this.onClickOutside)
+      }
+      this.scrollable.removeEventListener('scroll', this.onScroll)
+      this.scrollable.removeEventListener('resize', this.onResize)
     },
     onMouseenter (e) {
-      if (this.trigger === 'hover') this.showPopover()
+      if (this.trigger === 'hover') {
+        this.lockReEntry = false
+        clearTimeout(this.graceTimeout)
+        this.graceTimeout = null
+        this.showPopover()
+      }
     },
     onMouseleave (e) {
-      if (this.trigger === 'hover') this.hidePopover()
+      if (this.trigger === 'hover' && this.childrenShown.size === 0) {
+        this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
+      }
+    },
+    onMouseenterContent (e) {
+      if (this.trigger === 'hover' && !this.lockReEntry) {
+        this.lockReEntry = true
+        clearTimeout(this.graceTimeout)
+        this.graceTimeout = null
+        this.showPopover()
+      }
+    },
+    onMouseleaveContent (e) {
+      if (this.trigger === 'hover' && this.childrenShown.size === 0) {
+        this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
+      }
     },
     onClick (e) {
       if (this.trigger === 'click') {
@@ -160,8 +262,24 @@ const Popover = {
     },
     onClickOutside (e) {
       if (this.hidden) return
+      if (this.$refs.content && this.$refs.content.contains(e.target)) return
       if (this.$el.contains(e.target)) return
+      if (this.childrenShown.size > 0) return
       this.hidePopover()
+      if (this.parentPopover) this.parentPopover.onClickOutside(e)
+    },
+    onScroll (e) {
+      this.updateStyles()
+    },
+    onResize (e) {
+      this.updateStyles()
+    },
+    onChildPopoverState (childRef, state) {
+      if (state) {
+        this.childrenShown.add(childRef)
+      } else {
+        this.childrenShown.delete(childRef)
+      }
     }
   },
   updated () {
@@ -175,11 +293,18 @@ const Popover = {
       this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
     }
   },
-  created () {
-    document.addEventListener('click', this.onClickOutside)
+  mounted () {
+    let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
+        this.$refs.trigger.closest('.mobile-notifications')
+    if (!scrollable) scrollable = window
+    this.scrollable = scrollable
+    let parent = this.$parent
+    while (parent && parent.$.type.name !== 'Popover') {
+      parent = parent.$parent
+    }
+    this.parentPopover = parent
   },
-  unmounted () {
-    document.removeEventListener('click', this.onClickOutside)
+  beforeUnmount () {
     this.hidePopover()
   }
 }

+ 28 - 20
src/components/popover/popover.vue

@@ -1,5 +1,5 @@
 <template>
-  <div
+  <span
     @mouseenter="onMouseenter"
     @mouseleave="onMouseleave"
   >
@@ -11,20 +11,27 @@
     >
       <slot name="trigger" />
     </button>
-    <div
-      v-if="!hidden"
-      ref="content"
-      :style="styles"
-      class="popover"
-      :class="popoverClass || 'popover-default'"
-    >
-      <slot
-        name="content"
-        class="popover-inner"
-        :close="hidePopover"
-      />
-    </div>
-  </div>
+    <teleport to="#popovers">
+      <transition name="fade">
+        <div
+          v-if="!hidden"
+          ref="content"
+          :style="styles"
+          class="popover"
+          :class="popoverClass || 'popover-default'"
+          @mouseenter="onMouseenterContent"
+          @mouseleave="onMouseleaveContent"
+          @click="onClickContent"
+        >
+          <slot
+            name="content"
+            class="popover-inner"
+            :close="hidePopover"
+          />
+        </div>
+      </transition>
+    </teleport>
+  </span>
 </template>
 
 <script src="./popover.js" />
@@ -37,14 +44,15 @@
 }
 
 .popover {
-  z-index: 500;
-  position: absolute;
+  z-index: var(--ZI_popover_override, var(--ZI_popovers));
+  position: fixed;
   min-width: 0;
+  max-width: calc(100vw - 20px);
+  box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
+  box-shadow: var(--popupShadow);
 }
 
 .popover-default {
-  transition: opacity 0.3s;
-
   &:after {
     content: '';
     position: absolute;
@@ -80,7 +88,7 @@
   text-align: left;
   list-style: none;
   max-width: 100vw;
-  z-index: 200;
+  z-index: var(--ZI_popover_override, var(--ZI_popovers));
   white-space: nowrap;
 
   .dropdown-divider {

+ 3 - 2
src/components/react_button/react_button.vue

@@ -6,6 +6,7 @@
     :offset="{ y: 5 }"
     :bound-to="{ x: 'container' }"
     remove-padding
+    popover-class="ReactButton popover-default"
     @show="focusInput"
   >
     <template v-slot:content="{close}">
@@ -41,7 +42,7 @@
       </div>
     </template>
     <template v-slot:trigger>
-      <button
+      <span
         class="button-unstyled popover-trigger"
         :title="$t('tool_tip.add_reaction')"
       >
@@ -49,7 +50,7 @@
           class="fa-scale-110 fa-old-padding"
           :icon="['far', 'smile-beam']"
         />
-      </button>
+      </span>
     </template>
   </Popover>
 </template>

+ 5 - 5
src/components/settings_modal/helpers/modified_indicator.vue

@@ -41,11 +41,11 @@ export default {
 .ModifiedIndicator {
   display: inline-block;
   position: relative;
+}
 
-  .modified-tooltip {
-    margin: 0.5em 1em;
-    min-width: 10em;
-    text-align: center;
-  }
+.modified-tooltip {
+  margin: 0.5em 1em;
+  min-width: 10em;
+  text-align: center;
 }
 </style>

+ 5 - 5
src/components/settings_modal/helpers/server_side_indicator.vue

@@ -41,11 +41,11 @@ export default {
 .ServerSideIndicator {
   display: inline-block;
   position: relative;
+}
 
-  .serverside-tooltip {
-    margin: 0.5em 1em;
-    min-width: 10em;
-    text-align: center;
-  }
+.serverside-tooltip {
+  margin: 0.5em 1em;
+  min-width: 10em;
+  text-align: center;
 }
 </style>

+ 18 - 12
src/components/settings_modal/tabs/general_tab.vue

@@ -74,6 +74,16 @@
             {{ $t('settings.show_scrollbars') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting path="userPopoverZoom" expert="1">
+            {{ $t('settings.user_popover_avatar_zoom') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="userPopoverOverlay" expert="1">
+            {{ $t('settings.user_popover_avatar_overlay') }}
+          </BooleanSetting>
+        </li>
         <li>
           <ChoiceSetting
             v-if="user"
@@ -261,18 +271,14 @@
             {{ $t('settings.mention_link_display') }}
           </ChoiceSetting>
         </li>
-        <ul
-          class="setting-list suboptions"
-        >
-          <li v-if="mentionLinkDisplay === 'short'">
-            <BooleanSetting
-              path="mentionLinkShowTooltip"
-              expert="1"
-            >
-              {{ $t('settings.mention_link_show_tooltip') }}
-            </BooleanSetting>
-          </li>
-        </ul>
+        <li>
+          <BooleanSetting
+            path="mentionLinkShowTooltip"
+            expert="1"
+          >
+            {{ $t('settings.mention_link_use_tooltip') }}
+          </BooleanSetting>
+        </li>
         <li>
           <BooleanSetting
             path="useAtIcon"

+ 1 - 1
src/components/shout_panel/shout_panel.vue

@@ -80,7 +80,7 @@
 .floating-shout {
   position: fixed;
   bottom: 0.5em;
-  z-index: 1000;
+  z-index: var(--ZI_popovers);
   max-width: 25em;
 
   &.-left {

+ 1 - 1
src/components/side_drawer/side_drawer.vue

@@ -211,7 +211,7 @@
 
 .side-drawer-container {
   position: fixed;
-  z-index: 1000;
+  z-index: var(--ZI_navbar);
   top: 0;
   left: 0;
   width: 100%;

+ 3 - 3
src/components/status/status.js

@@ -4,13 +4,13 @@ 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 UserCard from '../user_card/user_card.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import AvatarList from '../avatar_list/avatar_list.vue'
 import Timeago from '../timeago/timeago.vue'
 import StatusContent from '../status_content/status_content.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import StatusPopover from '../status_popover/status_popover.vue'
+import UserPopover from '../user_popover/user_popover.vue'
 import UserListPopover from '../user_list_popover/user_list_popover.vue'
 import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
 import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
@@ -105,7 +105,6 @@ const Status = {
     RetweetButton,
     ExtraButtons,
     PostStatusForm,
-    UserCard,
     UserAvatar,
     AvatarList,
     Timeago,
@@ -115,7 +114,8 @@ const Status = {
     StatusContent,
     RichContent,
     MentionLink,
-    MentionsLine
+    MentionsLine,
+    UserPopover
   },
   props: [
     'statusoid',

+ 14 - 18
src/components/status/status.vue

@@ -122,27 +122,22 @@
           v-if="!noHeading"
           class="left-side"
         >
-          <a
-            :href="$router.resolve(userProfileLink).href"
-            @click.stop.prevent.capture="toggleUserExpanded"
-          >
-            <UserAvatar
-              class="post-avatar"
-              :bot="botIndicator"
-              :compact="compact"
-              :better-shadow="betterShadow"
-              :user="status.user"
-            />
+          <a :href="$router.resolve(userProfileLink).href" @click.prevent>
+            <UserPopover
+              :userId="status.user.id"
+              :overlayCenters="true"
+            >
+              <UserAvatar
+                class="post-avatar"
+                :bot="botIndicator"
+                :compact="compact"
+                :better-shadow="betterShadow"
+                :user="status.user"
+              />
+            </UserPopover>
           </a>
         </div>
         <div class="right-side">
-          <UserCard
-            v-if="userExpanded"
-            :user-id="status.user.id"
-            :rounded="true"
-            :bordered="true"
-            class="usercard"
-          />
           <div
             v-if="!noHeading"
             class="status-heading"
@@ -322,6 +317,7 @@
                   class="mentions-line-first"
                 />
               </span>
+              {{ ' ' }}
               <MentionsLine
                 v-if="hasMentionsLine"
                 :mentions="mentionsLine.slice(1)"

+ 7 - 0
src/components/status_popover/status_popover.js

@@ -38,6 +38,13 @@ const StatusPopover = {
           .catch(e => (this.error = true))
       }
     }
+  },
+  watch: {
+    status (newStatus, oldStatus) {
+      if (newStatus !== oldStatus) {
+        this.$nextTick(() => this.$refs.popover.updateStyles())
+      }
+    }
   }
 }
 

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

@@ -1,9 +1,11 @@
 <template>
   <Popover
     trigger="hover"
+    :stay-on-click="true"
     popover-class="popover-default status-popover"
     :bound-to="{ x: 'container' }"
     @show="enter"
+    ref="popover"
   >
     <template v-slot:trigger>
       <slot />
@@ -52,8 +54,6 @@
   border-width: 1px;
   border-radius: $fallback--tooltipRadius;
   border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
-  box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
-  box-shadow: var(--popupShadow);
 
   /* TODO cleanup this */
   .Status.Status {

+ 18 - 32
src/components/timeline_menu/timeline_menu.vue

@@ -3,19 +3,17 @@
     trigger="click"
     class="TimelineMenu"
     :class="{ 'open': isOpen }"
-    :margin="{ left: -15, right: -200 }"
     :bound-to="{ x: 'container' }"
-    popover-class="timeline-menu-popover-wrap"
+    bound-to-selector=".Timeline"
+    popover-class="timeline-menu-popover popover-default"
     @show="openMenu"
     @close="() => isOpen = false"
   >
     <template v-slot:content>
-      <div class="timeline-menu-popover popover-default">
-        <TimelineMenuContent />
-      </div>
+      <TimelineMenuContent />
     </template>
     <template v-slot:trigger>
-      <button class="button-unstyled title timeline-menu-title">
+      <span class="button-unstyled title timeline-menu-title">
         <span class="timeline-title">{{ timelineName() }}</span>
         <span>
           <FAIcon
@@ -27,7 +25,7 @@
           class="click-blocker"
           @click="blockOpen"
         />
-      </button>
+      </span>
     </template>
   </Popover>
 </template>
@@ -38,42 +36,18 @@
 @import '../../_variables.scss';
 
 .TimelineMenu {
-  flex-shrink: 1;
   margin-right: auto;
   min-width: 0;
-  width: 24rem;
 
   .popover-trigger-button {
     vertical-align: bottom;
   }
 
-  .timeline-menu-popover-wrap {
-    overflow: hidden;
-    // Match panel heading padding to line up menu with bottom of heading
-    margin-top: 0.6rem;
-    padding: 0 15px 15px 15px;
-  }
-
-  .timeline-menu-popover {
-    width: 24rem;
-    max-width: 100vw;
-    margin: 0;
-    font-size: 1rem;
-    border-top-right-radius: 0;
-    border-top-left-radius: 0;
-    transform: translateY(-100%);
-    transition: transform 100ms;
-  }
-
   .panel::after {
     border-top-right-radius: 0;
     border-top-left-radius: 0;
   }
 
-  &.open .timeline-menu-popover {
-    transform: translateY(0);
-  }
-
   .timeline-menu-title {
     margin: 0;
     cursor: pointer;
@@ -108,6 +82,16 @@
     box-shadow: var(--popoverShadow);
   }
 
+}
+
+.timeline-menu-popover {
+  min-width: 24rem;
+  max-width: 100vw;
+  margin-top: 0.6rem;
+  font-size: 1rem;
+  border-top-right-radius: 0;
+  border-top-left-radius: 0;
+
   ul {
     list-style: none;
     margin: 0;
@@ -134,7 +118,9 @@
 
   a {
     display: block;
-    padding: 0.6em 0.65em;
+    padding: 0 0.65em;
+    height: 3.5em;
+    line-height: 3.5em;
 
     &:hover {
       background-color: $fallback--lightBg;

+ 24 - 6
src/components/user_card/user_card.js

@@ -14,7 +14,9 @@ import {
   faRss,
   faSearchPlus,
   faExternalLinkAlt,
-  faEdit
+  faEdit,
+  faTimes,
+  faExpandAlt
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -22,12 +24,21 @@ library.add(
   faBell,
   faSearchPlus,
   faExternalLinkAlt,
-  faEdit
+  faEdit,
+  faTimes,
+  faExpandAlt
 )
 
 export default {
   props: [
-    'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
+    'userId',
+    'switcher',
+    'selected',
+    'hideBio',
+    'rounded',
+    'bordered',
+    'avatarAction', // default - open profile, 'zoom' - zoom, function - call function
+    'onClose'
   ],
   data () {
     return {
@@ -47,9 +58,10 @@ export default {
     },
     classes () {
       return [{
-        'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
-        'user-card-rounded': this.rounded === true, // set border-radius for all sides
-        'user-card-bordered': this.bordered === true // set border for all sides
+        '-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
+        '-rounded': this.rounded === true, // set border-radius for all sides
+        '-bordered': this.bordered === true, // set border for all sides
+        '-popover': !!this.onClose // set popover rounding
       }]
     },
     style () {
@@ -170,6 +182,12 @@ export default {
     },
     mentionUser () {
       this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
+    },
+    onAvatarClickHandler (e) {
+      if (this.onAvatarClick) {
+        e.preventDefault()
+        this.onAvatarClick()
+      }
     }
   }
 }

+ 38 - 13
src/components/user_card/user_card.scss

@@ -42,8 +42,10 @@
     mask-composite: exclude;
     background-size: cover;
     mask-size: 100% 60%;
-    border-top-left-radius: calc(var(--panelRadius) - 1px);
-    border-top-right-radius: calc(var(--panelRadius) - 1px);
+    border-top-left-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+    border-top-right-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+    border-bottom-left-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
+    border-bottom-right-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
     background-color: var(--profileBg);
     z-index: -2;
 
@@ -72,21 +74,33 @@
     }
   }
 
-  // Modifiers
-
-  &-rounded-t {
+  &.-rounded-t {
     border-top-left-radius: $fallback--panelRadius;
     border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
     border-top-right-radius: $fallback--panelRadius;
     border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
+
+    --__roundnessTop: var(--panelRadius);
+    --__roundnessBottom: 0;
   }
 
-  &-rounded {
+  &.-rounded {
     border-radius: $fallback--panelRadius;
     border-radius: var(--panelRadius, $fallback--panelRadius);
+
+    --__roundnessTop: var(--panelRadius);
+    --__roundnessBottom: var(--panelRadius);
   }
 
-  &-bordered {
+  &.-popover {
+    border-radius: $fallback--tooltipRadius;
+    border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+
+    --__roundnessTop: var(--tooltipRadius);
+    --__roundnessBottom: var(--tooltipRadius);
+  }
+
+  &.-bordered {
     border-width: 1px;
     border-style: solid;
     border-color: $fallback--border;
@@ -99,6 +113,15 @@
   color: var(--lightText, $fallback--lightText);
   padding: 0 26px;
 
+  a {
+    color: $fallback--lightText;
+    color: var(--lightText, $fallback--lightText);
+
+    &:hover {
+      color: var(--icon);
+    }
+  }
+
   .container {
     min-width: 0;
     padding: 16px 0 6px;
@@ -110,23 +133,27 @@
       min-width: 0;
     }
 
+    > a {
+      vertical-align: middle;
+      display: flex;
+    }
+
     .Avatar {
       --_avatarShadowBox: var(--avatarShadow);
       --_avatarShadowFilter: var(--avatarShadowFilter);
       --_avatarShadowInset: var(--avatarShadowInset);
 
-      flex: 1 0 100%;
       width: 56px;
       height: 56px;
       object-fit: cover;
     }
   }
 
-  &-avatar-link {
+  &-avatar {
     position: relative;
     cursor: pointer;
 
-    &-overlay {
+    &.-overlay {
       position: absolute;
       left: 0;
       top: 0;
@@ -146,7 +173,7 @@
       }
     }
 
-    &:hover &-overlay {
+    &:hover &.-overlay {
       opacity: 1;
     }
   }
@@ -206,8 +233,6 @@
       flex: 0 1 auto;
       text-overflow: ellipsis;
       overflow: hidden;
-      color: $fallback--lightText;
-      color: var(--lightText, $fallback--lightText);
     }
 
     .dailyAvg {

+ 41 - 9
src/components/user_card/user_card.vue

@@ -8,25 +8,32 @@
       :style="style"
       class="background-image"
     />
-    <div class="panel-heading -flexible-height">
+    <div :class="onClose ? '' : panel-heading -flexible-height">
       <div class="user-info">
         <div class="container">
           <a
-            v-if="allowZoomingAvatar"
-            class="user-info-avatar-link"
+            v-if="avatarAction === 'zoom'"
+            class="user-info-avatar -link"
             @click="zoomAvatar"
           >
             <UserAvatar
               :better-shadow="betterShadow"
               :user="user"
             />
-            <div class="user-info-avatar-link-overlay">
+            <div class="user-info-avatar -link -overlay">
               <FAIcon
                 class="fa-scale-110 fa-old-padding"
                 icon="search-plus"
               />
             </div>
           </a>
+          <UserAvatar
+            v-else-if="typeof avatarAction === 'function'"
+            @click="avatarAction"
+            class="user-info-avatar"
+            :better-shadow="betterShadow"
+            :user="user"
+          />
           <router-link
             v-else
             :to="userProfileLink(user)"
@@ -38,12 +45,16 @@
           </router-link>
           <div class="user-summary">
             <div class="top-line">
-              <RichContent
-                :title="user.name"
+              <router-link
+                :to="userProfileLink(user)"
                 class="user-name"
-                :html="user.name"
-                :emoji="user.emoji"
-              />
+              >
+                <RichContent
+                  :title="user.name"
+                  :html="user.name"
+                  :emoji="user.emoji"
+                />
+              </router-link>
               <button
                 v-if="!isOtherUser && user.is_local"
                 class="button-unstyled edit-profile-button"
@@ -72,6 +83,27 @@
                 :user="user"
                 :relationship="relationship"
               />
+              <router-link
+                v-if="onClose"
+                :to="userProfileLink(user)"
+                class="button-unstyled external-link-button"
+                @click="onClose"
+              >
+                <FAIcon
+                  class="icon"
+                  icon="expand-alt"
+                />
+              </router-link>
+              <button
+                v-if="onClose"
+                class="button-unstyled external-link-button"
+                @click="onClose"
+              >
+                <FAIcon
+                  class="icon"
+                  icon="times"
+                />
+              </button>
             </div>
             <div class="bottom-line">
               <router-link

+ 23 - 0
src/components/user_popover/user_popover.js

@@ -0,0 +1,23 @@
+import UserCard from '../user_card/user_card.vue'
+import { defineAsyncComponent } from 'vue'
+
+const UserPopover = {
+  name: 'UserPopover',
+  props: [
+    'userId', 'overlayCenters', 'disabled', 'overlayCentersSelector'
+  ],
+  components: {
+    UserCard,
+    Popover: defineAsyncComponent(() => import('../popover/popover.vue'))
+  },
+  computed: {
+    userPopoverZoom () {
+      return this.$store.getters.mergedConfig.userPopoverZoom
+    },
+    userPopoverOverlay () {
+      return this.$store.getters.mergedConfig.userPopoverOverlay
+    }
+  }
+}
+
+export default UserPopover

+ 33 - 0
src/components/user_popover/user_popover.vue

@@ -0,0 +1,33 @@
+<template>
+<Popover
+  trigger="click"
+  popover-class="popover-default user-popover"
+  :overlay-centers-selector="overlayCentersSelector || '.user-info .Avatar'"
+  :overlay-centers="overlayCenters && userPopoverOverlay"
+  :disabled="disabled"
+>
+  <template v-slot:trigger>
+    <slot />
+  </template>
+  <template v-slot:content={close}>
+    <UserCard
+      class="user-popover"
+      :user-id="userId"
+      :hide-bio="true"
+      :avatar-action="userPopoverZoom ? 'zoom' : close"
+      :on-close="close"
+    />
+  </template>
+</Popover>
+</template>
+
+<script src="./user_popover.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+/* popover styles load on-demand, so we need to override */
+.user-popover.popover {
+}
+
+</style>

+ 1 - 1
src/components/user_profile/user_profile.vue

@@ -8,7 +8,7 @@
         :user-id="userId"
         :switcher="true"
         :selected="timeline.viewing"
-        :allow-zooming-avatar="true"
+        avatar-action="zoom"
         rounded="top"
       />
       <div

+ 3 - 1
src/i18n/en.json

@@ -546,10 +546,12 @@
     "mention_link_display_short": "always as short names (e.g. {'@'}foo)",
     "mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
     "mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
-    "mention_link_show_tooltip": "Show full user names as tooltip for remote users",
+    "mention_link_use_tooltip": "Show user card when clicking mention links",
     "mention_link_show_avatar": "Show user avatar beside the link",
     "mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
     "mention_link_bolden_you": "Highlight mention of you when you are mentioned",
+    "user_popover_avatar_zoom": "Clicking on user avatar in popover zooms it instead of closing the popover",
+    "user_popover_avatar_overlay": "Show user popover over user avatar",
     "fun": "Fun",
     "greentext": "Meme arrows",
     "show_yous": "Show (You)s",

+ 2 - 0
src/modules/config.js

@@ -81,6 +81,8 @@ export const defaultState = {
   useContainFit: true,
   disableStickyHeaders: false,
   showScrollbars: false,
+  userPopoverZoom: false,
+  userPopoverOverlay: true,
   greentext: undefined, // instance default
   useAtIcon: undefined, // instance default
   mentionLinkDisplay: undefined, // instance default

+ 12 - 16
test/unit/specs/components/rich_content.spec.js

@@ -4,7 +4,15 @@ import RichContent from 'src/components/rich_content/rich_content.jsx'
 const attentions = []
 const global = {
   mocks: {
-    '$store': null
+    '$store': {
+      state: {},
+      getters: {
+        mergedConfig: () => ({
+          mentionLinkShowTooltip: true
+        }),
+        findUserByUrl: () => null
+      }
+    }
   },
   stubs: {
     FAIcon: true
@@ -131,8 +139,7 @@ describe('RichContent', () => {
       ].join(''),
       [
         makeMention('John'),
-        makeMention('Josh'),
-        makeMention('Jeremy')
+        makeMention('Josh'), makeMention('Jeremy')
       ].join('')
     ].join('\n')
 
@@ -349,7 +356,6 @@ describe('RichContent', () => {
       p(
         '<span class="MentionsLine">',
         '<span class="MentionLink mention-link">',
-        '<!-- eslint-disable vue/no-v-html -->',
         '<a href="lol" class="original" target="_blank">',
         '<span>',
         'https://</span>',
@@ -358,10 +364,7 @@ describe('RichContent', () => {
         '<span>',
         '</span>',
         '</a>',
-        '<!-- eslint-enable vue/no-v-html -->',
-        '<!--v-if-->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
         '</span>',
-        '<!--v-if-->', // v-if placeholder, mentionsline's extra mentions and stuff
         '</span>'
       ),
       p(
@@ -380,7 +383,7 @@ describe('RichContent', () => {
       }
     })
 
-    expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+    expect(wrapper.html().replace(/\n/g, '').replace(/<!--.*?-->/g, '')).to.eql(compwrap(expected))
   })
 
   it('rich contents of nested mentions are handled properly', () => {
@@ -412,7 +415,6 @@ describe('RichContent', () => {
       '<span class="poast-style">',
       '<span class="MentionsLine">',
       '<span class="MentionLink mention-link">',
-      '<!-- eslint-disable vue/no-v-html -->',
       '<a href="lol" class="original" target="_blank">',
       '<span>',
       'https://</span>',
@@ -421,11 +423,8 @@ describe('RichContent', () => {
       '<span>',
       '</span>',
       '</a>',
-      '<!-- eslint-enable vue/no-v-html -->',
-      '<!--v-if-->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
       '</span>',
       '<span class="MentionLink mention-link">',
-      '<!-- eslint-disable vue/no-v-html -->',
       '<a href="lol" class="original" target="_blank">',
       '<span>',
       'https://</span>',
@@ -434,10 +433,7 @@ describe('RichContent', () => {
       '<span>',
       '</span>',
       '</a>',
-      '<!-- eslint-enable vue/no-v-html -->',
-      '<!--v-if-->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
       '</span>',
-      '<!--v-if-->', // v-if placeholder, mentionsline's extra mentions and stuff
       '</span>',
       ' ',
       '</span>',
@@ -455,7 +451,7 @@ describe('RichContent', () => {
       }
     })
 
-    expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+    expect(wrapper.html().replace(/\n/g, '').replace(/<!--.*?-->/g, '')).to.eql(compwrap(expected))
   })
 
   it('rich contents of a link are handled properly', () => {