logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://hacktivis.me/git/pleroma-fe.git
commit: 1cf7af33741ee43aff9e597f0a69f62f7d660cf6
parent 0b88c56aa674ad19be7e7e883a3687ec89569940
Author: Henry Jameson <me@hjkos.com>
Date:   Sun, 31 Jul 2022 11:44:15 +0300

Merge branch 'disjointed-popovers' into eslint-update

* disjointed-popovers: (56 commits)
  fix typo
  fix errors in console
  pinned no longer needed
  popover stack
  add stay-on-click prop to solve case of clicking user avatar in status popover
  fix settings tooltips
  vertical nudge for popovers, especially for overlay-centers ones
  make user popover options expert
  use same sizing for timeline dropdown as in the main nav
  fix avatar not zooming in profile page
  fix spacing in mentionsline
  add popovers to chats
  fix avatar not closing, add option to put popovers next to avatar instead of over it
  fix the incorrect rounding in nav list
  re-unfuck the timeline popover
  Revert "unify styling of timelines dropdown with other dropdown menus"
  close on avatar click again, add zooming as option
  fix basicusercard
  make hover popovers less annoying to close
  move tooltips setting
  ...

Diffstat:

MCHANGELOG.md12+++++++++++-
Msrc/App.js3+--
Msrc/App.scss11+++++++++--
Msrc/App.vue3++-
Msrc/boot/after_store.js3+++
Msrc/components/basic_user_card/basic_user_card.js12++----------
Msrc/components/basic_user_card/basic_user_card.vue31++++++++++++++-----------------
Msrc/components/chat_message/chat_message.js8+++-----
Msrc/components/chat_message/chat_message.vue8++++----
Msrc/components/chat_title/chat_title.js10+++-------
Msrc/components/chat_title/chat_title.vue6+++---
Msrc/components/desktop_nav/desktop_nav.scss1+
Msrc/components/desktop_nav/desktop_nav.vue2+-
Msrc/components/emoji_picker/emoji_picker.scss3++-
Msrc/components/extra_buttons/extra_buttons.js3+++
Msrc/components/extra_buttons/extra_buttons.vue50++++++++++++++++++++++++++------------------------
Msrc/components/global_notice_list/global_notice_list.vue2+-
Msrc/components/media_modal/media_modal.vue2+-
Msrc/components/mention_link/mention_link.js24+++++++++++++++++++++---
Msrc/components/mention_link/mention_link.scss29+++++++++++++++--------------
Msrc/components/mention_link/mention_link.vue84++++++++++++++++++++++++++++++++++++-------------------------------------------
Msrc/components/mentions_line/mentions_line.vue3+--
Msrc/components/mobile_nav/mobile_nav.vue6++++--
Msrc/components/modal/modal.vue5++++-
Msrc/components/nav_panel/nav_panel.vue2++
Msrc/components/notification/notification.js4+++-
Msrc/components/notification/notification.vue25+++++++++++++------------
Msrc/components/notifications/notifications.js10++++++++++
Msrc/components/popover/popover.js221++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/components/popover/popover.vue48++++++++++++++++++++++++++++--------------------
Msrc/components/react_button/react_button.vue5+++--
Msrc/components/settings_modal/helpers/modified_indicator.vue10+++++-----
Msrc/components/settings_modal/helpers/server_side_indicator.vue10+++++-----
Msrc/components/settings_modal/tabs/general_tab.vue30++++++++++++++++++------------
Msrc/components/shout_panel/shout_panel.vue2+-
Msrc/components/side_drawer/side_drawer.vue2+-
Msrc/components/status/status.js6+++---
Msrc/components/status/status.vue32++++++++++++++------------------
Msrc/components/status_popover/status_popover.js7+++++++
Msrc/components/status_popover/status_popover.vue4++--
Msrc/components/timeline_menu/timeline_menu.vue50++++++++++++++++++--------------------------------
Msrc/components/user_card/user_card.js30++++++++++++++++++++++++------
Msrc/components/user_card/user_card.scss51++++++++++++++++++++++++++++++++++++++-------------
Msrc/components/user_card/user_card.vue50+++++++++++++++++++++++++++++++++++++++++---------
Asrc/components/user_popover/user_popover.js23+++++++++++++++++++++++
Asrc/components/user_popover/user_popover.vue33+++++++++++++++++++++++++++++++++
Msrc/components/user_profile/user_profile.vue2+-
Msrc/i18n/en.json4+++-
Msrc/modules/config.js2++
Mtest/unit/specs/components/rich_content.spec.js28++++++++++++----------------
50 files changed, 656 insertions(+), 356 deletions(-)

diff --git a/CHANGELOG.md b/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 diff --git a/src/App.js b/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 diff --git a/src/App.scss b/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, diff --git a/src/App.vue b/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> diff --git a/src/boot/after_store.js b/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 diff --git a/src/components/basic_user_card/basic_user_card.js b/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) } diff --git a/src/components/basic_user_card/basic_user_card.vue b/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; diff --git a/src/components/chat_message/chat_message.js b/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' }, diff --git a/src/components/chat_message/chat_message.vue b/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" diff --git a/src/components/chat_title/chat_title.js b/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) - } } } diff --git a/src/components/chat_title/chat_title.vue b/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" diff --git a/src/components/desktop_nav/desktop_nav.scss b/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)); diff --git a/src/components/desktop_nav/desktop_nav.vue b/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 diff --git a/src/components/emoji_picker/emoji_picker.scss b/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; diff --git a/src/components/extra_buttons/extra_buttons.js b/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}` } diff --git a/src/components/extra_buttons/extra_buttons.vue b/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> diff --git a/src/components/global_notice_list/global_notice_list.vue b/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; diff --git a/src/components/media_modal/media_modal.vue b/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, diff --git a/src/components/mention_link/mention_link.js b/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 diff --git a/src/components/mention_link/mention_link.scss b/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; } diff --git a/src/components/mention_link/mention_link.vue b/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> diff --git a/src/components/mentions_line/mentions_line.vue b/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" diff --git a/src/components/mobile_nav/mobile_nav.vue b/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; diff --git a/src/components/modal/modal.vue b/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; diff --git a/src/components/nav_panel/nav_panel.vue b/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); diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js @@ -5,6 +5,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' import Timeago from '../timeago/timeago.vue' import 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 () { diff --git a/src/components/notification/notification.vue b/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 --> diff --git a/src/components/notifications/notifications.js b/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) }, diff --git a/src/components/popover/popover.js b/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() } } diff --git a/src/components/popover/popover.vue b/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 { diff --git a/src/components/react_button/react_button.vue b/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> diff --git a/src/components/settings_modal/helpers/modified_indicator.vue b/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> diff --git a/src/components/settings_modal/helpers/server_side_indicator.vue b/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> diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue @@ -75,6 +75,16 @@ </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" id="thirdColumnMode" @@ -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" diff --git a/src/components/shout_panel/shout_panel.vue b/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 { diff --git a/src/components/side_drawer/side_drawer.vue b/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%; diff --git a/src/components/status/status.js b/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', diff --git a/src/components/status/status.vue b/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)" diff --git a/src/components/status_popover/status_popover.js b/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()) + } + } } } diff --git a/src/components/status_popover/status_popover.vue b/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 { diff --git a/src/components/timeline_menu/timeline_menu.vue b/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; diff --git a/src/components/user_card/user_card.js b/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() + } } } } diff --git a/src/components/user_card/user_card.scss b/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 { diff --git a/src/components/user_card/user_card.vue b/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 diff --git a/src/components/user_popover/user_popover.js b/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 diff --git a/src/components/user_popover/user_popover.vue b/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> diff --git a/src/components/user_profile/user_profile.vue b/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 diff --git a/src/i18n/en.json b/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", diff --git a/src/modules/config.js b/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 diff --git a/test/unit/specs/components/rich_content.spec.js b/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', () => {