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
  ...
This commit is contained in:
Henry Jameson 2022-07-31 11:44:15 +03:00
commit 1cf7af3374
50 changed files with 658 additions and 358 deletions

View File

@ -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" - Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Attachment description is prefilled with backend-provided default when uploading - Attachment description is prefilled with backend-provided default when uploading
- Proper visual feedback that next image is loading when browsing - 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 ### Changed
- Using Vue 3 now
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out) - (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)
- User highlight background now also covers the `@` - User highlight background now also covers the `@`
- Reverted back to textual `@`, svg version is opt-in. - 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 - Uploaded attachments are uniform with displayed attachments
- Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues) - 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. - 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 ### Added
- 3 column mode: only enables when there's space for it (opt-out, customizable)
- Options to show domains in mentions - Options to show domains in mentions
- Option to show user avatars in mention links (opt-in) - Option to show user avatars in mention links (opt-in)
- Option to disable the tooltip for mentions - 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) - Media modal now also displays description and counter position in gallery (i.e. 1/5)
- Ability to rearrange order of attachments when uploading - Ability to rearrange order of attachments when uploading
- Enabled users to zoom and pan images in media viewer with mouse and touch - 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 - Added frontend ui for account migration

View File

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

View File

@ -4,6 +4,13 @@
:root { :root {
--navbar-height: 3.5rem; --navbar-height: 3.5rem;
--post-line-height: 1.4; --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 { html {
@ -117,7 +124,7 @@ i[class*=icon-],
} }
nav { nav {
z-index: 1000; z-index: var(--ZI_navbar);
color: var(--topBarText); color: var(--topBarText);
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg); background-color: var(--topBar, $fallback--fg);
@ -828,7 +835,7 @@ option {
// Vue transitions // Vue transitions
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.2s; transition: opacity 0.3s;
} }
.fade-enter-from, .fade-enter-from,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -51,6 +51,7 @@
icon="thumbtack" icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span> /><span>{{ $t("status.unpin") }}</span>
</button> </button>
<template v-if="canBookmark">
<button <button
v-if="!status.bookmarked" v-if="!status.bookmarked"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@ -73,6 +74,7 @@
icon="bookmark" icon="bookmark"
/><span>{{ $t("status.unbookmark") }}</span> /><span>{{ $t("status.unbookmark") }}</span>
</button> </button>
</template>
<button <button
v-if="canDelete" v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon" class="button-default dropdown-item dropdown-item-icon"
@ -119,12 +121,12 @@
</div> </div>
</template> </template>
<template v-slot:trigger> <template v-slot:trigger>
<button class="button-unstyled popover-trigger"> <span class="button-unstyled popover-trigger">
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
icon="ellipsis-h" icon="ellipsis-h"
/> />
</button> </span>
</template> </template>
</Popover> </Popover>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,13 @@
class="original" class="original"
target="_blank" target="_blank"
v-html="content" v-html="content"
/><!-- eslint-enable vue/no-v-html --><span /><!-- eslint-enable vue/no-v-html -->
<UserPopover
v-else
:userId="user.id"
:disabled="!shouldShowTooltip"
>
<span
v-if="user" v-if="user"
class="new" class="new"
:style="style" :style="style"
@ -48,27 +54,13 @@
:class="{ '-you': shouldBoldenYou }" :class="{ '-you': shouldBoldenYou }"
> {{ ' ' + $t('status.you') }}</span> > {{ ' ' + $t('status.you') }}</span>
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
</a><span </a><span class="full" ref="full">
v-if="shouldShowTooltip"
class="full popover-default"
:class="[highlightType]"
>
<span
class="userNameFull"
>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
@<span @<span v-html="userName" /><span v-html="'@' + serverName" />
class="userName"
v-html="userName"
/><span
class="serverName"
:class="{ '-faded': shouldFadeDomain }"
v-html="'@' + serverName"
/>
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
</span> </span>
</span> </span>
</span> </UserPopover>
</span> </span>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,13 +31,35 @@ const Popover = {
// If true, subtract padding when calculating position for the popover, // If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom. // 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 () { data () {
return { 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, hidden: true,
styles: { opacity: 0 }, styles: {},
oldSize: { width: 0, height: 0 } oldSize: { width: 0, height: 0 },
scrollable: null,
// used to avoid blinking if hovered onto popover
graceTimeout: null,
parentPopover: null,
childrenShown: new Set()
} }
}, },
methods: { methods: {
@ -47,9 +69,7 @@ const Popover = {
}, },
updateStyles () { updateStyles () {
if (this.hidden) { if (this.hidden) {
this.styles = { this.styles = {}
opacity: 0
}
return return
} }
@ -57,14 +77,26 @@ const Popover = {
// its children are what are inside the slot. Expect only one v-slot:trigger. // 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 const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
// SVGs don't have offsetWidth/Height, use fallback // SVGs don't have offsetWidth/Height, use fallback
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const screenBox = anchorEl.getBoundingClientRect() const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
// Screen position of the origin point for popover const anchorScreenBox = anchorEl.getBoundingClientRect()
const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
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 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 // 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.boundTo.x === 'container' || this.boundTo.y === 'container') &&
this.containerBoundingClientRect() this.containerBoundingClientRect()
@ -73,81 +105,151 @@ const Popover = {
// What are the screen bounds for the popover? Viewport vs container // What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar // when using viewport, using default margin values to dodge the navbar
const xBounds = this.boundTo && this.boundTo.x === 'container' ? { const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
min: parentBounds.left + (margin.left || 0), min: parentScreenBox.left + (margin.left || 0),
max: parentBounds.right - (margin.right || 0) max: parentScreenBox.right - (margin.right || 0)
} : { } : {
min: 0 + (margin.left || 10), min: 0 + (margin.left || 10),
max: window.innerWidth - (margin.right || 10) max: window.innerWidth - (margin.right || 10)
} }
const yBounds = this.boundTo && this.boundTo.y === 'container' ? { const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
min: parentBounds.top + (margin.top || 0), min: parentScreenBox.top + (margin.top || 0),
max: parentBounds.bottom - (margin.bottom || 0) max: parentScreenBox.bottom - (margin.bottom || 0)
} : { } : {
min: 0 + (margin.top || 50), min: 0 + (margin.top || 50),
max: window.innerHeight - (margin.bottom || 5) max: window.innerHeight - (margin.bottom || 5)
} }
let horizOffset = 0 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 overflowing from left, move it so that it doesn't
if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) { if (leftBorder < xBounds.min) {
horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min horizOffset += xBounds.min - leftBorder
} }
// If overflowing from right, move it so that it doesn't // If overflowing from right, move it so that it doesn't
if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) { if (rightBorder > xBounds.max) {
horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max horizOffset -= rightBorder - xBounds.max
} }
// If overflowing from top, move it so that it doesn't
if (topBorder < yBounds.min) {
vertOffset += yBounds.min - topBorder
}
// If overflowing from bottom, move it so that it doesn't
if (bottomBorder > yBounds.max) {
vertOffset -= bottomBorder - yBounds.max
}
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 // Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom' let usingTop = this.placement !== 'bottom'
// Handle special cases, first force to displaying on top if there's not space on 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 // 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. // force to bottom, again regardless of what placement value was.
if (origin.y + content.offsetHeight > yBounds.max) usingTop = true const topBoundary = origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
if (origin.y - content.offsetHeight < yBounds.min) usingTop = false const bottomBoundary = origin.y + anchorHeight * 0.5 - (this.removePadding ? bottomPadding : 0)
if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
let vPadding = 0 if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
if (this.removePadding && usingTop) {
const anchorStyle = getComputedStyle(anchorEl)
vPadding = parseFloat(anchorStyle.paddingTop) + parseFloat(anchorStyle.paddingBottom)
}
const yOffset = (this.offset && this.offset.y) || 0 const yOffset = (this.offset && this.offset.y) || 0
const translateY = usingTop translateY = usingTop
? -anchorHeight + vPadding - yOffset - content.offsetHeight ? topBoundary - yOffset - content.offsetHeight
: yOffset : bottomBoundary + yOffset
const xOffset = (this.offset && this.offset.x) || 0 const xOffset = (this.offset && this.offset.x) || 0
const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset 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 = { this.styles = {
opacity: 1, left: `${Math.round(translateX)}px`,
transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}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 () { showPopover () {
if (this.disabled) return
const wasHidden = this.hidden const wasHidden = this.hidden
this.hidden = false 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(() => { this.$nextTick(() => {
if (wasHidden) this.$emit('show') if (wasHidden) this.$emit('show')
this.updateStyles() this.updateStyles()
}) })
}, },
hidePopover () { hidePopover () {
if (this.disabled) return
if (!this.hidden) this.$emit('close') if (!this.hidden) this.$emit('close')
this.hidden = true 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) { 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) { 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) { onClick (e) {
if (this.trigger === 'click') { if (this.trigger === 'click') {
@ -160,8 +262,24 @@ const Popover = {
}, },
onClickOutside (e) { onClickOutside (e) {
if (this.hidden) return if (this.hidden) return
if (this.$refs.content && this.$refs.content.contains(e.target)) return
if (this.$el.contains(e.target)) return if (this.$el.contains(e.target)) return
if (this.childrenShown.size > 0) return
this.hidePopover() 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 () { updated () {
@ -175,11 +293,18 @@ const Popover = {
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight } this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
} }
}, },
created () { mounted () {
document.addEventListener('click', this.onClickOutside) 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 () { beforeUnmount () {
document.removeEventListener('click', this.onClickOutside)
this.hidePopover() this.hidePopover()
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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>

View File

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

View File

@ -546,10 +546,12 @@
"mention_link_display_short": "always as short names (e.g. {'@'}foo)", "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_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_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_show_avatar": "Show user avatar beside the link",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)", "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", "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", "fun": "Fun",
"greentext": "Meme arrows", "greentext": "Meme arrows",
"show_yous": "Show (You)s", "show_yous": "Show (You)s",

View File

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

View File

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