Compare commits

...

17 commits

Author SHA1 Message Date
Shpuld Shpuldson
72934c1f91 Merge branch 'feat/user-popovers' of git.pleroma.social:pleroma/pleroma-fe into feat/user-popovers 2020-09-03 18:51:17 +03:00
Shpuld Shpuldson
b3254c99d1 fix conflicts 2020-09-03 18:50:14 +03:00
Shpuld Shpludson
6b9ce950c8 Merge branch 'develop' into 'feat/user-popovers'
# Conflicts:
#   src/components/user_card/user_card.vue
2020-08-27 12:03:05 +00:00
Shpuld Shpuldson
fd0d4fbfcf remove redundant class 2020-08-25 11:27:47 +03:00
Shpuld Shpuldson
35c459f412 fix merge conflicts 2020-08-25 11:26:35 +03:00
Shpuld Shpuldson
2031f3822d fix conflicts, fix mouseover in statuses 2020-08-13 15:13:44 +03:00
Shpuld Shpuldson
14708ce433 remove log 2020-07-23 12:34:12 +03:00
Shpuld Shpuldson
5edb7e76ff swithing user in status content works 2020-07-23 12:33:45 +03:00
Shpuld Shpuldson
e785fbb3c3 change how status content user popovers work, switching doesnt work right now 2020-07-23 12:30:50 +03:00
Shpuld Shpuldson
7cf6fc32c0 wip 2020-07-22 14:41:06 +03:00
Shpuld Shpuldson
0f862e3512 polish things, enable user popover hover triggers on touch on mobile 2020-07-22 14:26:08 +03:00
Shpuld Shpuldson
b64af18eda remove now deprecated imports 2020-07-21 18:11:35 +03:00
Shpuld Shpuldson
4545f20f86 positioning mention popovers works now 2020-07-21 18:05:57 +03:00
Shpuld Shpuldson
cebf4989e7 fix minor bugs, preliminary support for status mention previews 2020-07-21 16:30:25 +03:00
Shpuld Shpuldson
df66af74dc make the user popover look and work better 2020-07-20 16:55:04 +03:00
Shpuld Shpuldson
c104c76889 clean up user card a lot 2020-07-17 15:42:36 +03:00
Shpuld Shpuldson
c4b1acd775 basic first version 2020-07-16 16:56:12 +03:00
24 changed files with 514 additions and 273 deletions

View file

@ -1,6 +1,7 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
const AccountActions = { const AccountActions = {
props: [ props: [
@ -11,7 +12,8 @@ const AccountActions = {
}, },
components: { components: {
ProgressButton, ProgressButton,
Popover Popover,
ModerationTools
}, },
methods: { methods: {
showRepeats () { showRepeats () {
@ -20,6 +22,12 @@ const AccountActions = {
hideRepeats () { hideRepeats () {
this.$store.dispatch('hideReblogs', this.user.id) this.$store.dispatch('hideReblogs', this.user.id)
}, },
muteUser () {
this.$store.dispatch('muteUser', this.user.id)
},
unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
},
blockUser () { blockUser () {
this.$store.dispatch('blockUser', this.user.id) this.$store.dispatch('blockUser', this.user.id)
}, },
@ -34,9 +42,15 @@ const AccountActions = {
name: 'chat', name: 'chat',
params: { recipient_id: this.user.id } params: { recipient_id: this.user.id }
}) })
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
} }
}, },
computed: { computed: {
loggedIn () {
return this.$store.state.users.currentUser
},
...mapState({ ...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}) })

View file

@ -30,6 +30,20 @@
class="dropdown-divider" class="dropdown-divider"
/> />
</template> </template>
<button
v-if="relationship.muting"
class="btn btn-default btn-block dropdown-item"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
</button>
<button
v-else
class="btn btn-default btn-block dropdown-item"
@click="muteUser"
>
{{ $t('user_card.mute') }}
</button>
<button <button
v-if="relationship.blocking" v-if="relationship.blocking"
class="btn btn-default btn-block dropdown-item" class="btn btn-default btn-block dropdown-item"
@ -50,6 +64,10 @@
> >
{{ $t('user_card.report') }} {{ $t('user_card.report') }}
</button> </button>
<div
role="separator"
class="dropdown-divider"
/>
<button <button
v-if="pleromaChatMessagesAvailable" v-if="pleromaChatMessagesAvailable"
class="btn btn-default btn-block dropdown-item" class="btn btn-default btn-block dropdown-item"
@ -57,14 +75,25 @@
> >
{{ $t('user_card.message') }} {{ $t('user_card.message') }}
</button> </button>
<button
class="btn btn-default btn-block dropdown-item"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
<ModerationTools
v-if="loggedIn.role === &quot;admin&quot; || loggedIn"
button-class="btn btn-default btn-block dropdown-item"
:user="user"
/>
</div> </div>
</div> </div>
<div <button
slot="trigger" slot="trigger"
class="btn btn-default ellipsis-button" class="btn btn-default ellipsis-button"
> >
<i class="icon-ellipsis trigger-button" /> <i class="icon-ellipsis trigger-button" />
</div> </button>
</Popover> </Popover>
</div> </div>
</template> </template>
@ -74,7 +103,7 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.account-actions { .account-actions {
margin: 0 .8em; margin: 0 .5em;
} }
.account-actions button.dropdown-item { .account-actions button.dropdown-item {

View file

@ -8,6 +8,7 @@
> >
<UserAvatar <UserAvatar
:user="user" :user="user"
no-popover="true"
class="avatar-small" class="avatar-small"
/> />
</router-link> </router-link>

View file

@ -6,19 +6,11 @@ const BasicUserCard = {
props: [ props: [
'user' 'user'
], ],
data () {
return {
userExpanded: false
}
},
components: { components: {
UserCard, UserCard,
UserAvatar UserAvatar
}, },
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

@ -4,22 +4,10 @@
<UserAvatar <UserAvatar
class="avatar" class="avatar"
:user="user" :user="user"
@click.prevent.native="toggleUserExpanded"
/> />
</router-link> </router-link>
<div <div
v-if="userExpanded" class="basic-user-card-content"
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 <div
:title="user.name" :title="user.name"
@ -59,7 +47,7 @@
margin: 0; margin: 0;
padding: 0.6em 1em; padding: 0.6em 1em;
&-collapsed-content { &-content {
margin-left: 0.7em; margin-left: 0.7em;
text-align: left; text-align: left;
flex: 1; flex: 1;
@ -83,11 +71,5 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
&-expanded-content {
flex: 1;
margin-left: 0.7em;
min-width: 0;
}
} }
</style> </style>

View file

@ -11,7 +11,8 @@ const QUARANTINE = 'mrf_tag:quarantine'
const ModerationTools = { const ModerationTools = {
props: [ props: [
'user' 'user',
'buttonClass'
], ],
data () { data () {
return { return {

View file

@ -124,8 +124,7 @@
</div> </div>
<button <button
slot="trigger" slot="trigger"
class="btn btn-default btn-block" :class="`${buttonClass} ${toggled && 'toggled'}`"
:class="{ toggled }"
> >
{{ $t('user_card.admin_menu.moderation') }} {{ $t('user_card.admin_menu.moderation') }}
</button> </button>

View file

@ -2,7 +2,6 @@ import StatusContent from '../status_content/status_content.vue'
import { mapState } from 'vuex' import { mapState } from 'vuex'
import Status from '../status/status.vue' import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.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'
@ -20,14 +19,10 @@ const Notification = {
components: { components: {
StatusContent, StatusContent,
UserAvatar, UserAvatar,
UserCard,
Timeago, Timeago,
Status Status
}, },
methods: { methods: {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink (user) { generateUserProfileLink (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

@ -26,24 +26,17 @@
:class="[userClass, { highlighted: userStyle }]" :class="[userClass, { highlighted: userStyle }]"
:style="[ userStyle ]" :style="[ userStyle ]"
> >
<a <router-link
class="avatar-container" class="avatar-container"
:href="notification.from_profile.statusnet_profile_url" :to="userProfileLink"
@click.stop.prevent.capture="toggleUserExpanded"
> >
<UserAvatar <UserAvatar
:compact="true" :compact="true"
:better-shadow="betterShadow" :better-shadow="betterShadow"
:user="notification.from_profile" :user="notification.from_profile"
/> />
</a> </router-link>
<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

@ -24,7 +24,7 @@ const Notifications = {
bottomedOut: false, bottomedOut: false,
// How many seen notifications to display in the list. The more there are, // How many seen notifications to display in the list. The more there are,
// the heavier the page becomes. This count is increased when loading // the heavier the page becomes. This count is increased when loading
// older notifications, and cut back to default whenever hitting "Read!". // older notifications, and cut back to default whenever hitting "Read!"1.
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
} }
}, },

View file

@ -18,16 +18,37 @@ const Popover = {
// Takes a x/y object and tells how many pixels to offset from // Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis // anchor point on either axis
offset: Object, offset: Object,
// Takes a x/y/h object and tells how much to offset the anchor point
anchorOffset: Object,
// Replaces the classes you may want for the popover container. // Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover // Use 'popover-default' in addition to get the default popover
// styles with your custom class. // styles with your custom class.
popoverClass: String popoverClass: String,
// Time in milliseconds until the popup appears, default is 100ms
delay: Number,
// If disabled, don't show popover even when trigger conditions met
disabled: Boolean
}, },
data () { data () {
return { return {
hidden: true, hidden: true,
styles: { opacity: 0 }, styles: { opacity: 0 },
oldSize: { width: 0, height: 0 } oldSize: { width: 0, height: 0 },
timeout: null
}
},
computed: {
isMobileLayout () {
return this.$store.state.interface.mobileLayout
}
},
watch: {
disabled (newValue, oldValue) {
if (newValue) {
this.styles = { opacity: 0 }
} else {
if (this.trigger === 'hover') this.onMouseenter()
}
} }
}, },
methods: { methods: {
@ -36,10 +57,8 @@ const Popover = {
return container.getBoundingClientRect() return container.getBoundingClientRect()
}, },
updateStyles () { updateStyles () {
if (this.hidden) { if (this.hidden || !(this.$el && this.$el.offsetParent)) {
this.styles = { this.hidePopover()
opacity: 0
}
return return
} }
@ -48,7 +67,15 @@ const Popover = {
const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
const screenBox = anchorEl.getBoundingClientRect() const screenBox = anchorEl.getBoundingClientRect()
// Screen position of the origin point for popover // Screen position of the origin point for popover
const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top } const anchorOffset = {
x: (this.anchorOffset && this.anchorOffset.x) || 0,
y: (this.anchorOffset && this.anchorOffset.y) || 0,
h: (this.anchorOffset && this.anchorOffset.h) || 0
}
const origin = {
x: screenBox.left + screenBox.width * 0.5 + anchorOffset.x,
y: screenBox.top + anchorOffset.y
}
const content = this.$refs.content const content = this.$refs.content
// 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 parentBounds = this.boundTo &&
@ -97,12 +124,13 @@ const Popover = {
if (origin.y - content.offsetHeight < yBounds.min) usingTop = false if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
const yOffset = (this.offset && this.offset.y) || 0 const yOffset = (this.offset && this.offset.y) || 0
const anchorHeight = anchorOffset.h || anchorEl.offsetHeight
const translateY = usingTop const translateY = usingTop
? -anchorEl.offsetHeight - yOffset - content.offsetHeight ? -anchorEl.offsetHeight - yOffset - content.offsetHeight + anchorOffset.y
: yOffset : -anchorEl.offsetHeight + anchorHeight + yOffset + anchorOffset.y
const xOffset = (this.offset && this.offset.x) || 0 const xOffset = (this.offset && this.offset.x) || 0
const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset + anchorOffset.x
// Note, separate translateX and translateY avoids blurry text on chromium, // Note, separate translateX and translateY avoids blurry text on chromium,
// single translate or translate3d resulted in blurry text. // single translate or translate3d resulted in blurry text.
@ -112,20 +140,36 @@ const Popover = {
} }
}, },
showPopover () { showPopover () {
if (this.hidden) this.$emit('show') if (this.disabled) return
if (this.hidden) {
this.$emit('show')
document.addEventListener('click', this.onClickOutside, true)
}
this.hidden = false this.hidden = false
this.$nextTick(this.updateStyles) this.$nextTick(this.updateStyles)
}, },
hidePopover () { hidePopover () {
if (!this.hidden) this.$emit('close') if (!this.hidden) {
this.$emit('close')
document.removeEventListener('click', this.onClickOutside, true)
}
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
this.hidden = true this.hidden = true
this.styles = { opacity: 0 } this.styles = { opacity: 0 }
}, },
onMouseenter (e) { onMouseenter (e) {
if (this.trigger === 'hover') this.showPopover() if (this.trigger === 'hover') {
this.$emit('enter')
this.timeout = setTimeout(this.showPopover, this.delay || 100)
}
}, },
onMouseleave (e) { onMouseleave (e) {
if (this.trigger === 'hover') this.hidePopover() if (this.trigger === 'hover') {
this.hidePopover()
}
}, },
onClick (e) { onClick (e) {
if (this.trigger === 'click') { if (this.trigger === 'click') {
@ -134,12 +178,25 @@ const Popover = {
} else { } else {
this.hidePopover() this.hidePopover()
} }
} else if ((this.trigger === 'hover') && this.isMobileLayout) {
// This is to enable using hover stuff with mobile:
// on first touch it opens the popover, when touching the trigger
// again it will do the click action. Can't use touch events as
// we can't stop/prevent the actual click which will be handled
// first.
if (this.hidden) {
this.$emit('enter')
this.showPopover()
e.preventDefault()
}
} }
}, },
onClickOutside (e) { onClickOutside (e) {
if (this.hidden) return if (this.hidden) return
if (this.$el.contains(e.target)) return if (this.$el.contains(e.target)) return
this.hidePopover() this.hidePopover()
e.preventDefault()
e.stopPropagation()
} }
}, },
updated () { updated () {
@ -153,11 +210,7 @@ const Popover = {
this.oldSize = { width: content.offsetWidth, height: content.offsetHeight } this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
} }
}, },
created () {
document.addEventListener('click', this.onClickOutside)
},
destroyed () { destroyed () {
document.removeEventListener('click', this.onClickOutside)
this.hidePopover() this.hidePopover()
} }
} }

View file

@ -5,12 +5,12 @@
> >
<div <div
ref="trigger" ref="trigger"
@click="onClick" @click.capture="onClick"
> >
<slot name="trigger" /> <slot name="trigger" />
</div> </div>
<div <div
v-if="!hidden" v-if="!hidden && !disabled"
ref="content" ref="content"
:style="styles" :style="styles"
class="popover" class="popover"
@ -31,7 +31,7 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.popover { .popover {
z-index: 8; z-index: 1000;
position: absolute; position: absolute;
min-width: 0; min-width: 0;
} }
@ -63,7 +63,6 @@
text-align: left; text-align: left;
list-style: none; list-style: none;
max-width: 100vw; max-width: 100vw;
z-index: 10;
white-space: nowrap; white-space: nowrap;
.dropdown-divider { .dropdown-divider {
@ -116,6 +115,17 @@
} }
} }
&.toggled {
background-color: $fallback--lightBg;
background-color: var(--selectedMenuPopover, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuPopoverText, $fallback--link);
--faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
--faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
--lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
--icon: var(--selectedMenuPopoverIcon, $fallback--icon);
}
} }
} }
</style> </style>

View file

@ -3,12 +3,12 @@ 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 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 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'
@ -25,12 +25,12 @@ const Status = {
RetweetButton, RetweetButton,
ExtraButtons, ExtraButtons,
PostStatusForm, PostStatusForm,
UserCard,
UserAvatar, UserAvatar,
AvatarList, AvatarList,
Timeago, Timeago,
StatusPopover, StatusPopover,
UserListPopover, UserListPopover,
UserPopover,
EmojiReactions, EmojiReactions,
StatusContent StatusContent
}, },
@ -53,7 +53,6 @@ const Status = {
return { return {
replying: false, replying: false,
unmuted: false, unmuted: false,
userExpanded: false,
error: null error: null
} }
}, },
@ -246,9 +245,6 @@ const Status = {
toggleMute () { toggleMute () {
this.unmuted = !this.unmuted this.unmuted = !this.unmuted
}, },
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink (id, name) { generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
} }

View file

@ -100,6 +100,7 @@ $status-margin: 0.75em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex: 1 1 0; flex: 1 1 0;
display: block;
} }
.heading-left { .heading-left {
@ -127,19 +128,21 @@ $status-margin: 0.75em;
align-items: stretch; align-items: stretch;
} }
.reply-to-accountname {
overflow-x: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
display: block;
}
.reply-to-and-accountname { .reply-to-and-accountname {
display: flex; display: flex;
height: 18px; height: 18px;
margin-right: 0.5em; margin-right: 0.5em;
max-width: 100%; max-width: 100%;
.reply-to-link {
white-space: nowrap;
word-break: break-word;
text-overflow: ellipsis;
overflow-x: hidden;
}
.icon-reply { .icon-reply {
// mirror the icon // mirror the icon
transform: scaleX(-1); transform: scaleX(-1);

View file

@ -22,9 +22,11 @@
v-if="muted && retweet" v-if="muted && retweet"
class="button-icon icon-retweet" class="button-icon icon-retweet"
/> />
<UserPopover :user-id="status.user.id">
<router-link :to="userProfileLink"> <router-link :to="userProfileLink">
{{ status.user.screen_name }} {{ status.user.screen_name }}
</router-link> </router-link>
</UserPopover>
</small> </small>
<small <small
v-if="showReasonMutedThread" v-if="showReasonMutedThread"
@ -76,6 +78,7 @@
class="status-username repeater-name" class="status-username repeater-name"
:title="retweeter" :title="retweeter"
> >
<UserPopover :user-id="statusoid.user.id">
<router-link <router-link
v-if="retweeterHtml" v-if="retweeterHtml"
:to="retweeterProfileLink" :to="retweeterProfileLink"
@ -85,6 +88,8 @@
v-else v-else
:to="retweeterProfileLink" :to="retweeterProfileLink"
>{{ retweeter }}</router-link> >{{ retweeter }}</router-link>
</UserPopover>
</span> </span>
<i <i
class="fa icon-retweet retweeted" class="fa icon-retweet retweeted"
@ -104,10 +109,7 @@
v-if="!noHeading" v-if="!noHeading"
class="left-side" class="left-side"
> >
<router-link <router-link :to="userProfileLink">
:to="userProfileLink"
@click.stop.prevent.capture.native="toggleUserExpanded"
>
<UserAvatar <UserAvatar
:compact="compact" :compact="compact"
:better-shadow="betterShadow" :better-shadow="betterShadow"
@ -116,13 +118,6 @@
</router-link> </router-link>
</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"
@ -142,6 +137,10 @@
> >
{{ status.user.name }} {{ status.user.name }}
</h4> </h4>
<UserPopover
:user-id="status.user.id"
class="account-name"
>
<router-link <router-link
class="account-name" class="account-name"
:title="status.user.screen_name" :title="status.user.screen_name"
@ -149,6 +148,7 @@
> >
{{ status.user.screen_name }} {{ status.user.screen_name }}
</router-link> </router-link>
</UserPopover>
<img <img
v-if="!!(status.user && status.user.favicon)" v-if="!!(status.user && status.user.favicon)"
class="status-favicon" class="status-favicon"
@ -233,13 +233,17 @@
> >
<span class="reply-to-text">{{ $t('status.reply_to') }}</span> <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span> </span>
<UserPopover
:user-id="status.in_reply_to_user_id"
>
<router-link <router-link
class="reply-to-link" class="reply-to-accountname"
:title="replyToName" :title="replyToName"
:to="replyProfileLink" :to="replyProfileLink"
> >
{{ replyToName }} {{ replyToName }}
</router-link> </router-link>
</UserPopover>
<span <span
v-if="replies && replies.length" v-if="replies && replies.length"
class="faint replies-separator" class="faint replies-separator"
@ -376,4 +380,5 @@
</template> </template>
<script src="./status.js" ></script> <script src="./status.js" ></script>
<style src="./status.scss" lang="scss"></style> <style src="./status.scss" lang="scss"></style>

View file

@ -2,6 +2,7 @@ import Attachment from '../attachment/attachment.vue'
import Poll from '../poll/poll.vue' import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue' import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue' import LinkPreview from '../link-preview/link-preview.vue'
import UserPopover from '../user_popover/user_popover.vue'
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'
import fileType from 'src/services/file_type/file_type.service' import fileType from 'src/services/file_type/file_type.service'
import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
@ -10,6 +11,13 @@ import { mapGetters, mapState } from 'vuex'
const StatusContent = { const StatusContent = {
name: 'StatusContent', name: 'StatusContent',
components: {
Attachment,
Poll,
Gallery,
LinkPreview,
UserPopover
},
props: [ props: [
'status', 'status',
'focused', 'focused',
@ -22,7 +30,9 @@ const StatusContent = {
showingTall: this.fullContent || (this.inConversation && this.focused), showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false, showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later // not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
focusedUserId: null,
userPopoverOffset: { x: 0, y: 0 }
} }
}, },
computed: { computed: {
@ -142,13 +152,18 @@ const StatusContent = {
currentUser: state => state.users.currentUser currentUser: state => state.users.currentUser
}) })
}, },
components: {
Attachment,
Poll,
Gallery,
LinkPreview
},
methods: { methods: {
setUserPopoverTarget (event, target, attn) {
// event.stopPropagation()
// event.preventDefault()
this.focusedUserId = attn.id
// Give the popover an offset to place it over the hovered element
const containerWidth = this.$refs.userPopover.$el.offsetWidth
const elementWidth = target.offsetWidth
const x = -containerWidth / 2 + target.offsetLeft + elementWidth / 2
const y = target.offsetTop
this.userPopoverOffset = { x, y, h: target.offsetHeight }
},
linkClicked (event) { linkClicked (event) {
const target = event.target.closest('.status-content a') const target = event.target.closest('.status-content a')
if (target) { if (target) {
@ -156,8 +171,10 @@ const StatusContent = {
const href = target.href const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) { if (attn) {
event.stopPropagation() if (this.$store.state.interface.mobileLayout) {
event.preventDefault() this.setUserPopoverTarget(event, target, attn)
return
}
const link = this.generateUserProfileLink(attn.id, attn.screen_name) const link = this.generateUserProfileLink(attn.id, attn.screen_name)
this.$router.push(link) this.$router.push(link)
return return
@ -175,6 +192,19 @@ const StatusContent = {
window.open(target.href, '_blank') window.open(target.href, '_blank')
} }
}, },
linkHover (event) {
const target = event.target.closest('.status-content a')
this.focusedUserId = null
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
this.setUserPopoverTarget(event, target, attn)
}
}
}
},
toggleShowMore () { toggleShowMore () {
if (this.mightHideBecauseTall) { if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall this.showingTall = !this.showingTall

View file

@ -28,6 +28,13 @@
{{ $t("status.show_full_subject") }} {{ $t("status.show_full_subject") }}
</a> </a>
</div> </div>
<UserPopover
ref="userPopover"
class="status-user-popover"
:user-id="focusedUserId"
:anchor-offset="userPopoverOffset"
>
<div <div
:class="{'tall-status': hideTallStatus}" :class="{'tall-status': hideTallStatus}"
class="status-content-wrapper" class="status-content-wrapper"
@ -46,6 +53,7 @@
:class="{ 'single-line': singleLine }" :class="{ 'single-line': singleLine }"
class="status-content media-body" class="status-content media-body"
@click.prevent="linkClicked" @click.prevent="linkClicked"
@mouseover="linkHover"
v-html="postBodyHtml" v-html="postBodyHtml"
/> />
<a <a
@ -89,6 +97,7 @@
{{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }} {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
</a> </a>
</div> </div>
</UserPopover>
<div v-if="status.poll && status.poll.options && !hideSubjectStatus"> <div v-if="status.poll && status.poll.options && !hideSubjectStatus">
<poll :base-poll="status.poll" /> <poll :base-poll="status.poll" />
@ -141,6 +150,10 @@ $status-margin: 0.75em;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
.status-user-popover {
position: relative;
}
.status-content-wrapper { .status-content-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -175,16 +175,16 @@
} }
&:not(.active) { &:not(.active) {
z-index: 4; z-index: 2;
&:hover { &:hover {
z-index: 6; z-index: 4;
} }
} }
&.active { &.active {
background: transparent; background: transparent;
z-index: 5; z-index: 3;
color: $fallback--text; color: $fallback--text;
color: var(--tabActiveText, $fallback--text); color: var(--tabActiveText, $fallback--text);
} }
@ -216,7 +216,7 @@
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
z-index: 7; z-index: 5;
} }
} }
} }

View file

@ -1,10 +1,12 @@
import StillImage from '../still-image/still-image.vue' import StillImage from '../still-image/still-image.vue'
import UserPopover from '../user_popover/user_popover.vue'
const UserAvatar = { const UserAvatar = {
props: [ props: [
'user', 'user',
'betterShadow', 'betterShadow',
'compact' 'compact',
'noPopover'
], ],
data () { data () {
return { return {
@ -13,7 +15,8 @@ const UserAvatar = {
} }
}, },
components: { components: {
StillImage StillImage,
UserPopover
}, },
methods: { methods: {
imgSrc (src) { imgSrc (src) {

View file

@ -1,4 +1,17 @@
<template> <template>
<StillImage
v-if="noPopover"
class="Avatar"
:alt="user.screen_name"
:title="user.screen_name"
:src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError"
/>
<UserPopover
v-else
:user-id="user.id"
>
<StillImage <StillImage
class="Avatar" class="Avatar"
:alt="user.screen_name" :alt="user.screen_name"
@ -7,6 +20,7 @@
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError" :image-load-error="imageLoadError"
/> />
</UserPopover>
</template> </template>
<script src="./user_avatar.js"></script> <script src="./user_avatar.js"></script>

View file

@ -9,7 +9,13 @@ import { mapGetters } from 'vuex'
export default { export default {
props: [ props: [
'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar' 'userId',
'switcher',
'selected',
'hideBio',
'rounded',
'bordered',
'allowZoomingAvatar'
], ],
data () { data () {
return { return {
@ -18,7 +24,10 @@ export default {
} }
}, },
created () { created () {
const relationship = this.$store.getters.relationship(this.userId)
if (!(relationship && !relationship.loading)) {
this.$store.dispatch('fetchUserRelationship', this.user.id) this.$store.dispatch('fetchUserRelationship', this.user.id)
}
}, },
computed: { computed: {
user () { user () {
@ -105,12 +114,6 @@ export default {
FollowButton FollowButton
}, },
methods: { methods: {
muteUser () {
this.$store.dispatch('muteUser', this.user.id)
},
unmuteUser () {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser () { subscribeUser () {
return this.$store.dispatch('subscribeUser', this.user.id) return this.$store.dispatch('subscribeUser', this.user.id)
}, },
@ -144,9 +147,6 @@ export default {
} }
this.$store.dispatch('setMedia', [attachment]) this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrent', attachment) this.$store.dispatch('setCurrent', attachment)
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
} }
} }
} }

View file

@ -19,6 +19,7 @@
<UserAvatar <UserAvatar
:better-shadow="betterShadow" :better-shadow="betterShadow"
:user="user" :user="user"
no-popover="true"
/> />
<div class="user-info-avatar-link-overlay"> <div class="user-info-avatar-link-overlay">
<i class="button-icon icon-zoom-in" /> <i class="button-icon icon-zoom-in" />
@ -31,6 +32,7 @@
<UserAvatar <UserAvatar
:better-shadow="betterShadow" :better-shadow="betterShadow"
:user="user" :user="user"
no-popover="true"
/> />
</router-link> </router-link>
<div class="user-summary"> <div class="user-summary">
@ -57,11 +59,6 @@
> >
<i class="icon-link-ext usersettings" /> <i class="icon-link-ext usersettings" />
</a> </a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
</div> </div>
<div class="bottom-line"> <div class="bottom-line">
<router-link <router-link
@ -100,6 +97,35 @@
> >
{{ $t('user_card.follows_you') }} {{ $t('user_card.follows_you') }}
</div> </div>
</div>
<div
v-if="loggedIn && isOtherUser"
class="user-interactions"
>
<div class="btn-group">
<FollowButton :relationship="relationship" />
<template v-if="relationship.following">
<ProgressButton
v-if="!relationship.subscribing"
class="btn btn-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
>
<i class="icon-bell-alt" />
</ProgressButton>
<ProgressButton
v-else
class="btn btn-default toggled"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
<i class="icon-bell-ringing-o" />
</ProgressButton>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
<div <div
v-if="isOtherUser && (loggedIn || !switcher)" v-if="isOtherUser && (loggedIn || !switcher)"
class="highlighter" class="highlighter"
@ -136,60 +162,8 @@
<i class="icon-down-open" /> <i class="icon-down-open" />
</label> </label>
</div> </div>
</div>
<div
v-if="loggedIn && isOtherUser"
class="user-interactions"
>
<div class="btn-group">
<FollowButton :relationship="relationship" />
<template v-if="relationship.following">
<ProgressButton
v-if="!relationship.subscribing"
class="btn btn-default"
:click="subscribeUser"
:title="$t('user_card.subscribe')"
>
<i class="icon-bell-alt" />
</ProgressButton>
<ProgressButton
v-else
class="btn btn-default toggled"
:click="unsubscribeUser"
:title="$t('user_card.unsubscribe')"
>
<i class="icon-bell-ringing-o" />
</ProgressButton>
</template> </template>
</div> </div>
<div>
<button
v-if="relationship.muting"
class="btn btn-default btn-block toggled"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
</button>
<button
v-else
class="btn btn-default btn-block"
@click="muteUser"
>
{{ $t('user_card.mute') }}
</button>
</div>
<div>
<button
class="btn btn-default btn-block"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
</button>
</div>
<ModerationTools
v-if="loggedIn.role === &quot;admin&quot;"
:user="user"
/>
</div> </div>
<div <div
v-if="!loggedIn && user.is_local" v-if="!loggedIn && user.is_local"
@ -354,7 +328,7 @@
align-items: flex-start; align-items: flex-start;
max-height: 56px; max-height: 56px;
.Avatar { .Avatar.still-image {
flex: 1 0 100%; flex: 1 0 100%;
width: 56px; width: 56px;
height: 56px; height: 56px;

View file

@ -0,0 +1,61 @@
const UserPopover = {
name: 'UserPopover',
props: [
'userId',
'anchorOffset'
],
data () {
return {
error: false,
fetching: false,
entered: false
}
},
computed: {
user () {
return this.$store.getters.findUser(this.userId)
},
relationshipAvailable () {
const relationship = this.$store.getters.relationship(this.userId)
return relationship && !relationship.loading
}
},
components: {
Popover: () => import('../popover/popover.vue'),
UserCard: () => import('../user_card/user_card.vue')
},
watch: {
userId (newValue, oldValue) {
if (this.entered) {
this.fetchUser()
}
}
},
methods: {
fetchUser () {
if (!this.userId) return
if (this.fetching) return
const promises = []
if (!this.user) {
promises.push(this.$store.dispatch('fetchUser', this.userId))
}
if (!this.relationshipAvailable) {
promises.push(this.$store.dispatch('fetchUserRelationship', this.userId))
}
if (promises.length > 0) {
this.fetching = true
Promise.all(promises)
.then(data => (this.error = false))
.catch(e => (this.error = true))
.finally(() => (this.fetching = false))
}
},
enter () {
this.entered = true
this.fetchUser()
}
}
}
export default UserPopover

View file

@ -0,0 +1,73 @@
<template>
<Popover
class="user-popover-container"
trigger="hover"
popover-class="popover-default user-popover"
:bound-to="{ x: 'container' }"
:margin="{ left: 5, right: 5 }"
:delay="200"
:anchor-offset="anchorOffset"
:disabled="!userId"
@enter="enter"
>
<template slot="trigger">
<slot />
</template>
<div
slot="content"
@click.prevent=""
>
<span v-if="user && relationshipAvailable">
<UserCard
:user-id="userId"
hide-bio="true"
/>
</span>
<div
v-else-if="error"
class="user-preview-no-content faint"
>
{{ $t('status.status_unavailable') }}
</div>
<div
v-else
class="user-preview-no-content"
>
<i class="icon-spin4 animate-spin" />
</div>
</div>
</Popover>
</template>
<script src="./user_popover.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.user-popover-container {
max-width: 100%;
min-width: 0;
&:first-child {
max-width: 100%;
}
}
.user-popover {
font-size: 1rem;
width: 30em;
max-width: 95%;
cursor: default;
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
.user-preview-no-content {
padding: 1em;
text-align: center;
i {
font-size: 2em;
}
}
}
</style>