Merge remote-tracking branch 'origin/develop' into settings-and-filtering

* origin/develop: (169 commits)
  Improve the user card for deactivated users
  Update CHANGELOG.md
  Update CHANGELOG.md
  Allow canceling a follow request
  Simple policy reasons for instance specific policies
  entity_normalizer: Escape name when parsing user
  Translated using Weblate (Spanish)
  Translated using Weblate (Catalan)
  Translated using Weblate (Korean)
  Translated using Weblate (Japanese (ja_PEDANTIC))
  Translated using Weblate (Indonesian)
  Translated using Weblate (Esperanto)
  Translated using Weblate (Vietnamese)
  Translated using Weblate (Italian)
  Translated using Weblate (Vietnamese)
  Translated using Weblate (Indonesian)
  Translated using Weblate (Italian)
  Translated using Weblate (Vietnamese)
  Translated using Weblate (Indonesian)
  Translated using Weblate (Chinese (Simplified))
  ...
This commit is contained in:
Henry Jameson 2022-01-24 19:12:17 +02:00
commit 9ea0f10abb
106 changed files with 5304 additions and 1167 deletions

View file

@ -1,5 +1,6 @@
import UserCard from '../user_card/user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
@ -13,7 +14,8 @@ const BasicUserCard = {
},
components: {
UserCard,
UserAvatar
UserAvatar,
RichContent
},
methods: {
toggleUserExpanded () {

View file

@ -25,17 +25,11 @@
:title="user.name"
class="basic-user-card-user-name"
>
<!-- eslint-disable vue/no-v-html -->
<span
v-if="user.name_html"
<RichContent
class="basic-user-card-user-name-value"
v-html="user.name_html"
:html="user.name"
:emoji="user.emoji"
/>
<!-- eslint-enable vue/no-v-html -->
<span
v-else
class="basic-user-card-user-name-value"
>{{ user.name }}</span>
</div>
<div>
<router-link

View file

@ -1,5 +1,5 @@
import { mapState } from 'vuex'
import StatusContent from '../status_content/status_content.vue'
import StatusBody from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
@ -16,7 +16,7 @@ const ChatListItem = {
AvatarList,
Timeago,
ChatTitle,
StatusContent
StatusBody
},
computed: {
...mapState({
@ -38,12 +38,14 @@ const ChatListItem = {
},
messageForStatusContent () {
const message = this.chat.lastMessage
const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
return {
summary: '',
statusnet_html: messagePreview,
emojis: messageEmojis,
raw_html: messagePreview,
text: messagePreview,
attachments: []
}

View file

@ -77,18 +77,15 @@
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.StatusContent {
img.emoji {
width: 1.4em;
height: 1.4em;
}
.chat-preview-body {
--emoji-size: 1.4em;
}
.time-wrapper {
line-height: 1.4em;
}
.single-line {
.chat-preview-body {
padding-right: 1em;
}
}

View file

@ -29,7 +29,8 @@
</div>
</div>
<div class="chat-preview">
<StatusContent
<StatusBody
class="chat-preview-body"
:status="messageForStatusContent"
:single-line="true"
/>

View file

@ -57,8 +57,9 @@ const ChatMessage = {
messageForStatusContent () {
return {
summary: '',
statusnet_html: this.message.content,
text: this.message.content,
emojis: this.message.emojis,
raw_html: this.message.content || '',
text: this.message.content || '',
attachments: this.message.attachments
}
},

View file

@ -89,8 +89,9 @@
}
.without-attachment {
.status-content {
&::after {
.message-content {
// TODO figure out how to do it properly
.RichContent::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
@ -162,6 +163,7 @@
.visible {
opacity: 1;
}
}
.chat-message-date-separator {

View file

@ -71,6 +71,7 @@
</Popover>
</div>
<StatusContent
class="message-content"
:status="messageForStatusContent"
:full-content="true"
>

View file

@ -1,9 +1,9 @@
<template>
<div
ref="root"
v-click-outside="onClickOutside"
class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }"
ref='root'
>
<slot />
<template v-if="enableEmojiPicker">

View file

@ -1,6 +1,6 @@
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
props: ['relationship', 'labelFollowing', 'buttonClass'],
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
data () {
return {
inProgress: false
@ -14,7 +14,7 @@ export default {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
return this.$t('user_card.follow_again')
return this.$t('user_card.follow_cancel')
} else {
return this.$t('user_card.follow')
}
@ -29,11 +29,14 @@ export default {
} else {
return this.$t('user_card.follow')
}
},
disabled () {
return this.inProgress || this.user.deactivated
}
},
methods: {
onClick () {
this.relationship.following ? this.unfollow() : this.follow()
this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
},
follow () {
this.inProgress = true

View file

@ -2,7 +2,7 @@
<button
class="btn button-default follow-button"
:class="{ toggled: isPressed }"
:disabled="inProgress"
:disabled="disabled"
:title="title"
@click="onClick"
>

View file

@ -0,0 +1,36 @@
import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
const HashtagLink = {
name: 'HashtagLink',
props: {
url: {
required: true,
type: String
},
content: {
required: true,
type: String
},
tag: {
required: false,
type: String,
default: ''
}
},
methods: {
onClick () {
const tag = this.tag || extractTagFromUrl(this.url)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
} else {
window.open(this.url, '_blank')
}
},
generateTagLink (tag) {
return `/tag/${tag}`
}
}
}
export default HashtagLink

View file

@ -0,0 +1,6 @@
.HashtagLink {
position: relative;
white-space: normal;
display: inline-block;
color: var(--link);
}

View file

@ -0,0 +1,19 @@
<template>
<span
class="HashtagLink"
>
<!-- eslint-disable vue/no-v-html -->
<a
:href="url"
class="original"
target="_blank"
@click.prevent="onClick"
v-html="content"
/>
<!-- eslint-enable vue/no-v-html -->
</span>
</template>
<script src="./hashtag_link.js"/>
<style lang="scss" src="./hashtag_link.scss"/>

View file

@ -0,0 +1,95 @@
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters, mapState } from 'vuex'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAt
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAt
)
const MentionLink = {
name: 'MentionLink',
props: {
url: {
required: true,
type: String
},
content: {
required: true,
type: String
},
userId: {
required: false,
type: String
},
userScreenName: {
required: false,
type: String
}
},
methods: {
onClick () {
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name
)
this.$router.push(link)
}
},
computed: {
user () {
return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
},
isYou () {
// FIXME why user !== currentUser???
return this.user && this.user.id === this.currentUser.id
},
userName () {
return this.user && this.userNameFullUi.split('@')[0]
},
userNameFull () {
return this.user && this.user.screen_name
},
userNameFullUi () {
return this.user && this.user.screen_name_ui
},
highlight () {
return this.user && this.mergedConfig.highlight[this.user.screen_name]
},
highlightType () {
return this.highlight && ('-' + this.highlight.type)
},
highlightClass () {
if (this.highlight) return highlightClass(this.user)
},
style () {
if (this.highlight) {
const {
backgroundColor,
backgroundPosition,
backgroundImage,
...rest
} = highlightStyle(this.highlight)
return rest
}
},
classnames () {
return [
{
'-you': this.isYou,
'-highlighted': this.highlight
},
this.highlightType
]
},
...mapGetters(['mergedConfig']),
...mapState({
currentUser: state => state.users.currentUser
})
}
}
export default MentionLink

View file

@ -0,0 +1,91 @@
.MentionLink {
position: relative;
white-space: normal;
display: inline-block;
color: var(--link);
& .new,
& .original {
display: inline-block;
border-radius: 2px;
}
.full {
position: absolute;
display: inline-block;
pointer-events: none;
opacity: 0;
top: 100%;
left: 0;
height: 100%;
word-wrap: normal;
white-space: nowrap;
transition: opacity 0.2s ease;
z-index: 1;
margin-top: 0.25em;
padding: 0.5em;
user-select: all;
}
.short {
user-select: none;
}
& .short,
& .full {
white-space: nowrap;
}
.new {
&.-you {
& .shortName,
& .full {
font-weight: 600;
}
}
.at {
color: var(--link);
opacity: 0.8;
display: inline-block;
height: 50%;
line-height: 1;
padding: 0 0.1em;
vertical-align: -25%;
margin: 0;
}
&.-striped {
& .userName,
& .full {
background-image:
repeating-linear-gradient(
135deg,
var(--____highlight-tintColor),
var(--____highlight-tintColor) 5px,
var(--____highlight-tintColor2) 5px,
var(--____highlight-tintColor2) 10px
);
}
}
&.-solid {
& .userName,
& .full {
background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
}
}
&.-side {
& .userName,
& .userNameFull {
box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
}
}
}
&:hover .new .full {
opacity: 1;
pointer-events: initial;
}
}

View file

@ -0,0 +1,56 @@
<template>
<span
class="MentionLink"
>
<!-- eslint-disable vue/no-v-html -->
<a
v-if="!user"
:href="url"
class="original"
target="_blank"
v-html="content"
/>
<!-- eslint-enable vue/no-v-html -->
<span
v-if="user"
class="new"
:style="style"
:class="classnames"
>
<a
class="short button-unstyled"
:href="url"
@click.prevent="onClick"
>
<!-- eslint-disable vue/no-v-html -->
<FAIcon
size="sm"
icon="at"
class="at"
/><span class="shortName"><span
class="userName"
v-html="userName"
/></span>
<span
v-if="isYou"
class="you"
>{{ $t('status.you') }}</span>
<!-- eslint-enable vue/no-v-html -->
</a>
<span
v-if="userName !== userNameFull"
class="full popover-default"
:class="[highlightType]"
>
<span
class="userNameFull"
v-text="'@' + userNameFull"
/>
</span>
</span>
</span>
</template>
<script src="./mention_link.js"/>
<style lang="scss" src="./mention_link.scss"/>

View file

@ -0,0 +1,37 @@
import MentionLink from 'src/components/mention_link/mention_link.vue'
import { mapGetters } from 'vuex'
export const MENTIONS_LIMIT = 5
const MentionsLine = {
name: 'MentionsLine',
props: {
mentions: {
required: true,
type: Array
}
},
data: () => ({ expanded: false }),
components: {
MentionLink
},
computed: {
mentionsComputed () {
return this.mentions.slice(0, MENTIONS_LIMIT)
},
extraMentions () {
return this.mentions.slice(MENTIONS_LIMIT)
},
manyMentions () {
return this.extraMentions.length > 0
},
...mapGetters(['mergedConfig'])
},
methods: {
toggleShowMore () {
this.expanded = !this.expanded
}
}
}
export default MentionsLine

View file

@ -0,0 +1,11 @@
.MentionsLine {
.showMoreLess {
white-space: normal;
color: var(--link);
}
.fullExtraMentions,
.mention-link:not(:last-child) {
margin-right: 0.25em;
}
}

View file

@ -0,0 +1,43 @@
<template>
<span class="MentionsLine">
<MentionLink
v-for="mention in mentionsComputed"
:key="mention.index"
class="mention-link"
:content="mention.content"
:url="mention.url"
:first-mention="false"
/><span
v-if="manyMentions"
class="extraMentions"
>
<span
v-if="expanded"
class="fullExtraMentions"
>
<MentionLink
v-for="mention in extraMentions"
:key="mention.index"
class="mention-link"
:content="mention.content"
:url="mention.url"
:first-mention="false"
/>
</span><button
v-if="!expanded"
class="button-unstyled showMoreLess"
@click="toggleShowMore"
>
{{ $t('status.plus_more', { number: extraMentions.length }) }}
</button><button
v-if="expanded"
class="button-unstyled showMoreLess"
@click="toggleShowMore"
>
{{ $t('general.show_less') }}
</button>
</span>
</span>
</template>
<script src="./mentions_line.js" ></script>
<style lang="scss" src="./mentions_line.scss" />

View file

@ -44,6 +44,9 @@ const MobilePostStatusButton = {
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
},
isPersistent () {
return !!this.$store.getters.mergedConfig.showNewPostButton
},
autohideFloatingPostButton () {
return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
}

View file

@ -2,7 +2,7 @@
<div v-if="isLoggedIn">
<button
class="button-default new-status-button"
:class="{ 'hidden': isHidden }"
:class="{ 'hidden': isHidden, 'always-show': isPersistent }"
@click="openPostForm"
>
<FAIcon icon="pen" />
@ -47,7 +47,7 @@
}
@media all and (min-width: 801px) {
.new-status-button {
.new-status-button:not(.always-show) {
display: none;
}
}

View file

@ -1,17 +1,56 @@
import { mapState } from 'vuex'
import { get } from 'lodash'
/**
* This is for backwards compatibility. We originally didn't recieve
* extra info like a reason why an instance was rejected/quarantined/etc.
* Because we didn't want to break backwards compatibility it was decided
* to add an extra "info" key.
*/
const toInstanceReasonObject = (instances, info, key) => {
return instances.map(instance => {
if (info[key] && info[key][instance] && info[key][instance]['reason']) {
return { instance: instance, reason: info[key][instance]['reason'] }
}
return { instance: instance, reason: '' }
})
}
const MRFTransparencyPanel = {
computed: {
...mapState({
federationPolicy: state => get(state, 'instance.federationPolicy'),
mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []),
quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []),
acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []),
rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []),
ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
quarantineInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.quarantined_instances', []),
get(state, 'instance.federationPolicy.quarantined_instances_info', []),
'quarantined_instances'
),
acceptInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.accept', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'accept'
),
rejectInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.reject', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'reject'
),
ftlRemovalInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'federated_timeline_removal'
),
mediaNsfwInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'media_nsfw'
),
mediaRemovalInstances: state => toInstanceReasonObject(
get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
get(state, 'instance.federationPolicy.mrf_simple_info', []),
'media_removal'
),
keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []),
keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', [])

View file

@ -0,0 +1,21 @@
.mrf-section {
margin: 1em;
table {
width:100%;
text-align: left;
padding-left:10px;
padding-bottom:20px;
th, td {
width: 180px;
max-width: 360px;
overflow: hidden;
vertical-align: text-top;
}
th+th, td+td {
width: auto;
}
}
}

View file

@ -31,13 +31,24 @@
<p>{{ $t("about.mrf.simple.accept_desc") }}</p>
<ul>
<li
v-for="instance in acceptInstances"
:key="instance"
v-text="instance"
/>
</ul>
<table>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in acceptInstances"
:key="entry.instance + '_accept'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div>
<div v-if="rejectInstances.length">
@ -45,13 +56,24 @@
<p>{{ $t("about.mrf.simple.reject_desc") }}</p>
<ul>
<li
v-for="instance in rejectInstances"
:key="instance"
v-text="instance"
/>
</ul>
<table>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in rejectInstances"
:key="entry.instance + '_reject'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div>
<div v-if="quarantineInstances.length">
@ -59,13 +81,24 @@
<p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
<ul>
<li
v-for="instance in quarantineInstances"
:key="instance"
v-text="instance"
/>
</ul>
<table>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in quarantineInstances"
:key="entry.instance + '_quarantine'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div>
<div v-if="ftlRemovalInstances.length">
@ -73,13 +106,24 @@
<p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
<ul>
<li
v-for="instance in ftlRemovalInstances"
:key="instance"
v-text="instance"
/>
</ul>
<table>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in ftlRemovalInstances"
:key="entry.instance + '_ftl_removal'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div>
<div v-if="mediaNsfwInstances.length">
@ -87,13 +131,24 @@
<p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
<ul>
<li
v-for="instance in mediaNsfwInstances"
:key="instance"
v-text="instance"
/>
</ul>
<table>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in mediaNsfwInstances"
:key="entry.instance + '_media_nsfw'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div>
<div v-if="mediaRemovalInstances.length">
@ -101,13 +156,24 @@
<p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
<ul>
<li
v-for="instance in mediaRemovalInstances"
:key="instance"
v-text="instance"
/>
</ul>
<table>
<tr>
<th>{{ $t("about.mrf.simple.instance") }}</th>
<th>{{ $t("about.mrf.simple.reason") }}</th>
</tr>
<tr
v-for="entry in mediaRemovalInstances"
:key="entry.instance + '_media_removal'"
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
</td>
</tr>
</table>
</div>
<h2 v-if="hasKeywordPolicies">
@ -161,7 +227,6 @@
<script src="./mrf_transparency_panel.js"></script>
<style lang="scss">
.mrf-section {
margin: 1em;
}
@import '../../_variables.scss';
@import './mrf_transparency_panel.scss';
</style>

View file

@ -4,6 +4,7 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -44,7 +45,8 @@ const Notification = {
UserAvatar,
UserCard,
Timeago,
Status
Status,
RichContent
},
methods: {
toggleUserExpanded () {

View file

@ -2,11 +2,7 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
word-wrap: break-word;
word-break: break-word;
--emoji-size: 14px;
&.-muted {
padding: 0.25em 0.6em;

View file

@ -52,12 +52,14 @@
<span class="notification-details">
<div class="name-and-action">
<!-- eslint-disable vue/no-v-html -->
<bdi
v-if="!!notification.from_profile.name_html"
class="username"
:title="'@'+notification.from_profile.screen_name_ui"
v-html="notification.from_profile.name_html"
/>
<bdi v-if="!!notification.from_profile.name_html">
<RichContent
class="username"
:title="'@'+notification.from_profile.screen_name_ui"
:html="notification.from_profile.name_html"
:emoji="notification.from_profile.emoji"
/>
</bdi>
<!-- eslint-enable vue/no-v-html -->
<span
v-else

View file

@ -143,13 +143,6 @@
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.timeago {

View file

@ -1,10 +1,14 @@
import Timeago from '../timeago/timeago.vue'
import Timeago from 'components/timeago/timeago.vue'
import RichContent from 'components/rich_content/rich_content.jsx'
import { forEach, map } from 'lodash'
export default {
name: 'Poll',
props: ['basePoll'],
components: { Timeago },
props: ['basePoll', 'emoji'],
components: {
Timeago,
RichContent
},
data () {
return {
loading: false,

View file

@ -17,8 +17,11 @@
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="option.title_html" />
<RichContent
:html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</div>
<div
class="result-fill"
@ -42,8 +45,11 @@
:value="index"
>
<label class="option-vote">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="option.title_html" />
<RichContent
:html="option.title_html"
:handle-links="false"
:emoji="emoji"
/>
</label>
</div>
</div>

View file

@ -0,0 +1,327 @@
import Vue from 'vue'
import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import StillImage from 'src/components/still-image/still-image.vue'
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import './rich_content.scss'
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
export default Vue.component('RichContent', {
name: 'RichContent',
props: {
// Original html content
html: {
required: true,
type: String
},
attentions: {
required: false,
default: () => []
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
type: Array
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
default: false
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render (h) {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage
{...{ attrs: getAttrs(tag) }}
class="img"
/>
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const linkData = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
return <HashtagLink {...{ props: linkData }}/>
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={ currentMentions } />
} else {
return ''
}
}
// Processor to use with html_tree_converter
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
return currentMentions !== null ? item.trim() : item
}
currentMentions = null
if (item.includes(':')) {
item = ['', processTextForEmoji(
item,
this.emoji,
({ shortcode, url }) => {
return <StillImage
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
/>
}
)]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
const Tag = getTagName(opener)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(children && typeof children[0] === 'string' && children[0].match(/^\s/))
? lastSpacing
: ''
switch (Tag) {
case 'br':
currentMentions = null
break
case 'img': // replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
if (attrs['class'] && attrs['class'].includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
break
}
case 'span':
if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
return ['', children.map(processItem), '']
}
}
if (children !== undefined) {
return [
'',
[
mentionsLinePadding,
[opener, children.map(processItem), closer]
],
''
]
} else {
return ['', [mentionsLinePadding, item], '']
}
}
}
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (emptyText) return item
if (!encounteredTextReverse) encounteredTextReverse = true
return unescape(item)
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
const attrs = getAttrs(opener)
// should only be this
if (
(attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
(attrs['rel'] === 'tag') // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children].reverse().map(processItemReverse).reverse()
return <a {...{ attrs }}>
{ newChildren }
</a>
}
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
return <Tag {...{ attrs: getAttrs(opener) }}>
{ newChildren }
</Tag>
} else {
return <Tag/>
}
}
return item
}
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = <span class="RichContent">
{ pass2 }
</span>
const event = {
lastTags,
writtenMentions,
writtenTags,
invisibleMentions
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
}
})
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent
}
}
/** Pre-processing HTML
*
* Currently this does one thing:
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
const lines = convertHtmlToLines(html)
const newHtml = lines.reverse().map((item, index, array) => {
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every(l => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
}).reverse().join('')
return { newHtml }
}

View file

@ -0,0 +1,64 @@
.RichContent {
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code,
samp,
kbd,
var,
pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0 0 1em 0;
}
p:last-child {
margin: 0 0 0 0;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
.img {
display: inline-block;
}
.emoji {
display: inline-block;
width: var(--emoji-size, 32px);
height: var(--emoji-size, 32px);
}
.img,
video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
}
}

View file

@ -16,10 +16,18 @@ export default {
return [firstSegment + 'DefaultValue', ...rest].join('.')
},
state () {
return get(this.$parent, this.path)
const value = get(this.$parent, this.path)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
defaultState () {
return get(this.$parent, this.pathDefault)
},
isChanged () {
return get(this.$parent, this.path) !== get(this.$parent, this.pathDefault)
return this.state !== this.defaultState
}
},
methods: {

View file

@ -17,13 +17,18 @@ export default {
return [firstSegment + 'DefaultValue', ...rest].join('.')
},
state () {
return get(this.$parent, this.path)
const value = get(this.$parent, this.path)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
defaultState () {
return get(this.$parent, this.pathDefault)
},
isChanged () {
return get(this.$parent, this.path) !== get(this.$parent, this.pathDefault)
return this.state !== this.defaultState
}
},
methods: {

View file

@ -191,6 +191,16 @@
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="alwaysShowNewPostButton">
{{ $t('settings.always_show_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="autohideFloatingPostButton">
{{ $t('settings.autohide_floating_post_button') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="padEmoji">
{{ $t('settings.pad_emoji') }}

View file

@ -24,7 +24,7 @@ library.add(
const ProfileTab = {
data () {
return {
newName: this.$store.state.users.currentUser.name,
newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,

View file

@ -73,7 +73,8 @@ export default {
getExportedObject: () => this.exportedTheme
}),
availableStyles: [],
selected: this.$store.getters.mergedConfig.theme,
selected: '',
selectedTheme: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
@ -207,7 +208,7 @@ export default {
}
},
selectedVersion () {
return Array.isArray(this.selected) ? 1 : 2
return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors () {
return Object.keys(SLOT_INHERITANCE)
@ -474,7 +475,7 @@ export default {
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
console.err('Forcing snapshout from file is not supported yet')
console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
@ -745,6 +746,16 @@ export default {
}
},
selected () {
this.selectedTheme = Object.entries(this.availableStyles).find(([k, s]) => {
if (Array.isArray(s)) {
console.log(s[0] === this.selected, this.selected)
return s[0] === this.selected
} else {
return s.name === this.selected
}
})[1]
},
selectedTheme () {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
@ -762,17 +773,17 @@ export default {
if (!this.keepColor) {
this.clearV1()
this.bgColorLocal = this.selected[1]
this.fgColorLocal = this.selected[2]
this.textColorLocal = this.selected[3]
this.linkColorLocal = this.selected[4]
this.cRedColorLocal = this.selected[5]
this.cGreenColorLocal = this.selected[6]
this.cBlueColorLocal = this.selected[7]
this.cOrangeColorLocal = this.selected[8]
this.bgColorLocal = this.selectedTheme[1]
this.fgColorLocal = this.selectedTheme[2]
this.textColorLocal = this.selectedTheme[3]
this.linkColorLocal = this.selectedTheme[4]
this.cRedColorLocal = this.selectedTheme[5]
this.cGreenColorLocal = this.selectedTheme[6]
this.cBlueColorLocal = this.selectedTheme[7]
this.cOrangeColorLocal = this.selectedTheme[8]
}
} else if (this.selectedVersion >= 2) {
this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
this.normalizeLocalState(this.selectedTheme.theme, 2, this.selectedTheme.source)
}
}
}

View file

@ -270,6 +270,9 @@
.apply-container {
justify-content: center;
position: absolute;
bottom: 8px;
right: 5px;
}
.radius-item,

View file

@ -63,7 +63,7 @@
<option
v-for="style in availableStyles"
:key="style.name"
:value="style"
:value="style.name || style[0]"
:style="{
backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || (style.theme || style.source).colors.text

View file

@ -79,12 +79,19 @@
.floating-shout {
position: fixed;
right: 0px;
bottom: 0px;
z-index: 1000;
max-width: 25em;
}
.floating-shout.left {
left: 0px;
}
.floating-shout:not(.left) {
right: 0px;
}
.shout-panel {
.shout-heading {
cursor: pointer;

View file

@ -49,6 +49,7 @@ const SideDrawer = {
currentUser () {
return this.$store.state.users.currentUser
},
shout () { return this.$store.state.shout.channel.state === 'joined' },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},

View file

@ -106,10 +106,10 @@
</router-link>
</li>
<li
v-if="chat"
v-if="shout"
@click="toggleDrawer"
>
<router-link :to="{ name: 'chat-panel' }">
<router-link :to="{ name: 'shout-panel' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
@ -273,9 +273,7 @@
--icon: var(--popoverIcon, $fallback--icon);
.badge {
position: absolute;
right: 0.7rem;
top: 1em;
margin-left: 10px;
}
}

View file

@ -9,9 +9,12 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import StatusPopover from '../status_popover/status_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import { muteWordHits } from '../../services/status_parser/status_parser.js'
@ -68,7 +71,10 @@ const Status = {
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent
StatusContent,
RichContent,
MentionLink,
MentionsLine
},
props: [
'statusoid',
@ -92,7 +98,8 @@ const Status = {
userExpanded: false,
mediaPlaying: [],
suspendable: true,
error: null
error: null,
headTailLinks: null
}
},
computed: {
@ -132,12 +139,15 @@ const Status = {
},
replyProfileLink () {
if (this.isReply) {
return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet () { return !!this.statusoid.retweeted_status },
retweeterUser () { return this.statusoid.user },
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
retweeterHtml () { return this.statusoid.user.name_html },
retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
status () {
if (this.retweet) {
@ -156,6 +166,25 @@ const Status = {
muteWordHits () {
return muteWordHits(this.status, this.muteWords)
},
mentionsLine () {
if (!this.headTailLinks) return []
const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
return this.status.attentions.filter(attn => {
// no reply user
return attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !== this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
}).map(attn => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id
}))
},
hasMentionsLine () {
return this.mentionsLine.length > 0
},
muted () {
if (this.statusoid.user.id === this.currentUser.id) return false
const reasonsToMute = this.userIsMuted ||
@ -321,6 +350,9 @@ const Status = {
},
removeMediaPlaying (id) {
this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
},
setHeadTailLinks (headTailLinks) {
this.headTailLinks = headTailLinks
}
},
watch: {

View file

@ -1,10 +1,10 @@
@import '../../_variables.scss';
$status-margin: 0.75em;
.Status {
min-width: 0;
white-space: normal;
&:hover {
--_still-image-img-visibility: visible;
@ -93,12 +93,8 @@ $status-margin: 0.75em;
margin-right: 0.4em;
text-overflow: ellipsis;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain;
}
--_still_image-label-scale: 0.25;
--emoji-size: 14px;
}
.status-favicon {
@ -155,35 +151,24 @@ $status-margin: 0.75em;
}
}
.glued-label {
display: inline-flex;
white-space: nowrap;
}
.timeago {
margin-right: 0.2em;
}
.heading-reply-row {
& .heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
line-height: 18px;
line-height: 160%;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
max-width: 100%;
.reply-to-link {
white-space: nowrap;
word-break: break-word;
text-overflow: ellipsis;
overflow-x: hidden;
}
}
& .reply-to-popover,
& .reply-to-no-popover {
min-width: 0;
@ -220,21 +205,27 @@ $status-margin: 0.75em;
}
}
.reply-to {
& .mentions,
& .reply-to {
white-space: nowrap;
position: relative;
padding-right: 0.25em;
}
.reply-to-text {
& .mentions-text,
& .reply-to-text {
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.replies-separator {
margin-left: 0.4em;
.mentions-line {
display: inline;
}
.replies {
margin-top: 0.25em;
line-height: 18px;
font-size: 12px;
display: flex;

View file

@ -1,5 +1,4 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="!hideStatus"
class="Status"
@ -89,8 +88,12 @@
<router-link
v-if="retweeterHtml"
:to="retweeterProfileLink"
v-html="retweeterHtml"
/>
>
<RichContent
:html="retweeterHtml"
:emoji="retweeterUser.emoji"
/>
</router-link>
<router-link
v-else
:to="retweeterProfileLink"
@ -145,8 +148,12 @@
v-if="status.user.name_html"
class="status-username"
:title="status.user.name"
v-html="status.user.name_html"
/>
>
<RichContent
:html="status.user.name"
:emoji="status.user.emoji"
/>
</h4>
<h4
v-else
class="status-username"
@ -214,11 +221,13 @@
</button>
</span>
</div>
<div class="heading-reply-row">
<div
<div
v-if="isReply || hasMentionsLine"
class="heading-reply-row"
>
<span
v-if="isReply"
class="reply-to-and-accountname"
class="glued-label"
>
<StatusPopover
v-if="!isPreview"
@ -238,7 +247,7 @@
flip="horizontal"
/>
<span
class="faint-link reply-to-text"
class="reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
@ -251,50 +260,76 @@
>
<span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span>
<router-link
class="reply-to-link"
:title="replyToName"
:to="replyProfileLink"
>
{{ replyToName }}
</router-link>
<span
v-if="replies && replies.length"
class="faint replies-separator"
>
-
</span>
</div>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
<MentionLink
:content="replyToName"
:url="replyProfileLink"
:user-id="status.in_reply_to_user_id"
:user-screen-name="status.in_reply_to_screen_name"
:first-mention="false"
/>
</span>
<!-- This little wrapper is made for sole purpose of "gluing" -->
<!-- "Mentions" label to the first mention -->
<span
v-if="hasMentionsLine"
class="glued-label"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
<span
class="mentions"
:aria-label="$t('tool_tip.mentions')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
<span
class="mentions-text"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
{{ $t('status.mentions') }}
</span>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(0, 1)"
class="mentions-line-first"
/>
</span>
<MentionsLine
v-if="hasMentionsLine"
:mentions="mentionsLine.slice(1)"
class="mentions-line"
/>
</div>
</div>
<StatusContent
ref="content"
:status="status"
:no-heading="noHeading"
:highlight="highlight"
:focused="isFocused"
@mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)"
@parseReady="setHeadTailLinks"
/>
<div
v-if="inConversation && !isPreview && replies && replies.length"
class="replies"
>
<span class="faint">{{ $t('status.replies_list') }}</span>
<StatusPopover
v-for="reply in replies"
:key="reply.id"
:status-id="reply.id"
>
<button
class="button-unstyled -link reply-link"
@click.prevent="gotoOriginal(reply.id)"
>
{{ reply.name }}
</button>
</StatusPopover>
</div>
<transition name="fade">
<div
v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"
@ -402,7 +437,6 @@
</div>
</template>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status.js" ></script>

View file

@ -0,0 +1,127 @@
import fileType from 'src/services/file_type/file_type.service'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFile,
faMusic,
faImage,
faLink,
faPollH
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faLink,
faPollH
)
const StatusContent = {
name: 'StatusContent',
props: [
'status',
'focused',
'noHeading',
'fullContent',
'singleLine'
],
data () {
return {
showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
postLength: this.status.text.length,
parseReadyDone: false
}
},
computed: {
localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 240
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
return !!this.status.summary && this.localCollapseSubjectDefault
},
mightHideBecauseTall () {
return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus () {
return this.mightHideBecauseTall && !this.showingTall
},
showingMore () {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
attachmentTypes () {
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
...mapGetters(['mergedConfig'])
},
components: {
RichContent
},
mounted () {
this.status.attentions && this.status.attentions.forEach(attn => {
const { id } = attn
this.$store.dispatch('fetchUserIfMissing', id)
})
},
methods: {
onParseReady (event) {
if (this.parseReadyDone) return
this.parseReadyDone = true
this.$emit('parseReady', event)
const { writtenMentions, invisibleMentions } = event
writtenMentions
.filter(mention => !mention.notifying)
.forEach(mention => {
const { content, url } = mention
const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
if (!cleanedString.startsWith('@')) return
const handle = cleanedString.slice(1)
const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
})
/* This is a bit of a hack to make current tall status detector work
* with rich mentions. Invisible mentions are detected at RichContent level
* and also we generate plaintext version of mentions by stripping tags
* so here we subtract from post length by each mention that became invisible
* via MentionsLine
*/
this.postLength = invisibleMentions.reduce((acc, mention) => {
return acc - mention.textContent.length - 1
}, this.postLength)
},
toggleShowMore () {
if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall
} else if (this.mightHideBecauseSubject) {
this.expandingSubject = !this.expandingSubject
}
},
generateTagLink (tag) {
return `/tag/${tag}`
}
}
}
export default StatusContent

View file

@ -0,0 +1,118 @@
@import '../../_variables.scss';
.StatusBody {
.emoji {
--_still_image-label-scale: 0.5;
}
& .text,
& .summary {
font-family: var(--postFont, sans-serif);
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
line-height: 1.4em;
}
.summary {
display: block;
font-style: italic;
padding-bottom: 0.5em;
}
.text {
&.-single-line {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.4em;
}
}
.summary-wrapper {
margin-bottom: 0.5em;
border-style: solid;
border-width: 0 0 1px 0;
border-color: var(--border, $fallback--border);
flex-grow: 0;
&.-tall {
position: relative;
.summary {
max-height: 2em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.text-wrapper {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
&.-tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.media-body {
min-height: 0;
mask:
linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
}
& .tall-status-hider,
& .tall-subject-hider,
& .status-unhider,
& .cw-status-hider {
display: inline-block;
word-break: break-all;
width: 100%;
text-align: center;
}
.tall-status-hider {
position: absolute;
height: 70px;
margin-top: 150px;
line-height: 110px;
z-index: 2;
}
.tall-subject-hider {
// position: absolute;
padding-bottom: 0.5em;
}
& .status-unhider,
& .cw-status-hider {
word-break: break-all;
svg {
color: inherit;
}
}
.greentext {
color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen);
}
.cyantext {
color: var(--postCyantext, $fallback--cBlue);
}
}

View file

@ -0,0 +1,97 @@
<template>
<div class="StatusBody">
<div class="body">
<div
v-if="status.summary_raw_html"
class="summary-wrapper"
:class="{ '-tall': (longSubject && !showingLongSubject) }"
>
<RichContent
class="media-body summary"
:html="status.summary_raw_html"
:emoji="status.emojis"
/>
<button
v-if="longSubject && showingLongSubject"
class="button-unstyled -link tall-subject-hider"
@click.prevent="showingLongSubject=false"
>
{{ $t("status.hide_full_subject") }}
</button>
<button
v-else-if="longSubject"
class="button-unstyled -link tall-subject-hider"
@click.prevent="showingLongSubject=true"
>
{{ $t("status.show_full_subject") }}
</button>
</div>
<div
:class="{'-tall-status': hideTallStatus}"
class="text-wrapper"
>
<button
v-if="hideTallStatus"
class="button-unstyled -link tall-status-hider"
:class="{ '-focused': focused }"
@click.prevent="toggleShowMore"
>
{{ $t("general.show_more") }}
</button>
<RichContent
v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
:class="{ '-single-line': singleLine }"
class="text media-body"
:html="status.raw_html"
:emoji="status.emojis"
:handle-links="true"
:greentext="mergedConfig.greentext"
:attentions="status.attentions"
@parseReady="onParseReady"
/>
<button
v-if="hideSubjectStatus"
class="button-unstyled -link cw-status-hider"
@click.prevent="toggleShowMore"
>
{{ $t("status.show_content") }}
<FAIcon
v-if="attachmentTypes.includes('image')"
icon="image"
/>
<FAIcon
v-if="attachmentTypes.includes('video')"
icon="video"
/>
<FAIcon
v-if="attachmentTypes.includes('audio')"
icon="music"
/>
<FAIcon
v-if="attachmentTypes.includes('unknown')"
icon="file"
/>
<FAIcon
v-if="status.poll && status.poll.options"
icon="poll-h"
/>
<FAIcon
v-if="status.card"
icon="link"
/>
</button>
<button
v-if="showingMore && !fullContent"
class="button-unstyled -link status-unhider"
@click.prevent="toggleShowMore"
>
{{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
</button>
</div>
</div>
<slot v-if="!hideSubjectStatus" />
</div>
</template>
<script src="./status_body.js" ></script>
<style lang="scss" src="./status_body.scss" />

View file

@ -1,11 +1,9 @@
import Attachment from '../attachment/attachment.vue'
import Poll from '../poll/poll.vue'
import Gallery from '../gallery/gallery.vue'
import StatusBody from 'src/components/status_body/status_body.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
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 { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
import { mapGetters, mapState } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -35,52 +33,11 @@ const StatusContent = {
'fullContent',
'singleLine'
],
data () {
return {
showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
}
},
computed: {
localCollapseSubjectDefault () {
return this.mergedConfig.collapseMessageWithSubject
},
hideAttachments () {
return (this.mergedConfig.hideAttachments && !this.inConversation) ||
(this.mergedConfig.hideAttachmentsInConv && this.inConversation)
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
return lengthScore > 20
},
longSubject () {
return this.status.summary.length > 240
},
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
return !!this.status.summary && this.localCollapseSubjectDefault
},
mightHideBecauseTall () {
return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
},
hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus () {
return this.mightHideBecauseTall && !this.showingTall
},
showingMore () {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
@ -118,45 +75,11 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
attachmentTypes () {
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails
},
postBodyHtml () {
const html = this.status.statusnet_html
if (this.mergedConfig.greentext) {
try {
if (html.includes('&gt;')) {
// This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
return processHtml(html, (string) => {
if (string.includes('&gt;') &&
string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else {
return string
}
})
} else {
return html
}
} catch (e) {
console.err('Failed to process status html', e)
return html
}
} else {
return html
}
},
...mapGetters(['mergedConfig']),
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser
})
},
@ -164,48 +87,10 @@ const StatusContent = {
Attachment,
Poll,
Gallery,
LinkPreview
LinkPreview,
StatusBody
},
methods: {
linkClicked (event) {
const target = event.target.closest('.status-content a')
if (target) {
if (target.className.match(/mention/)) {
const href = target.href
const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
if (attn) {
event.stopPropagation()
event.preventDefault()
const link = this.generateUserProfileLink(attn.id, attn.screen_name)
this.$router.push(link)
return
}
}
if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
// Extract tag name from dataset or link url
const tag = target.dataset.tag || extractTagFromUrl(target.href)
if (tag) {
const link = this.generateTagLink(tag)
this.$router.push(link)
return
}
}
window.open(target.href, '_blank')
}
},
toggleShowMore () {
if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall
} else if (this.mightHideBecauseSubject) {
this.expandingSubject = !this.expandingSubject
}
},
generateUserProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
},
generateTagLink (tag) {
return `/tag/${tag}`
},
setMedia () {
const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
return () => this.$store.dispatch('setMedia', attachments)

View file

@ -1,133 +1,55 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div class="StatusContent">
<slot name="header" />
<div
v-if="status.summary_html"
class="summary-wrapper"
:class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
<StatusBody
:status="status"
:single-line="singleLine"
@parseReady="$emit('parseReady', $event)"
>
<div v-if="status.poll && status.poll.options">
<Poll
:base-poll="status.poll"
:emoji="status.emojis"
/>
</div>
<div
class="media-body summary"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<button
v-if="longSubject && showingLongSubject"
class="button-unstyled -link tall-subject-hider"
@click.prevent="showingLongSubject=false"
v-if="status.attachments.length !== 0"
class="attachments media-body"
>
{{ $t("status.hide_full_subject") }}
</button>
<button
v-else-if="longSubject"
class="button-unstyled -link tall-subject-hider"
:class="{ 'tall-subject-hider_focused': focused }"
@click.prevent="showingLongSubject=true"
>
{{ $t("status.show_full_subject") }}
</button>
</div>
<div
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<button
v-if="hideTallStatus"
class="button-unstyled -link tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
@click.prevent="toggleShowMore"
>
{{ $t("general.show_more") }}
</button>
<attachment
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allow-play="true"
:set-media="setMedia()"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div
v-if="!hideSubjectStatus"
:class="{ 'single-line': singleLine }"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="postBodyHtml"
/>
<button
v-if="hideSubjectStatus"
class="button-unstyled -link cw-status-hider"
@click.prevent="toggleShowMore"
v-if="status.card && !noHeading"
class="link-preview media-body"
>
{{ $t("status.show_content") }}
<FAIcon
v-if="attachmentTypes.includes('image')"
icon="image"
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
<FAIcon
v-if="attachmentTypes.includes('video')"
icon="video"
/>
<FAIcon
v-if="attachmentTypes.includes('audio')"
icon="music"
/>
<FAIcon
v-if="attachmentTypes.includes('unknown')"
icon="file"
/>
<FAIcon
v-if="status.poll && status.poll.options"
icon="poll-h"
/>
<FAIcon
v-if="status.card"
icon="link"
/>
</button>
<button
v-if="showingMore && !fullContent"
class="button-unstyled -link status-unhider"
@click.prevent="toggleShowMore"
>
{{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
</button>
</div>
<div v-if="status.poll && status.poll.options && !hideSubjectStatus">
<poll :base-poll="status.poll" />
</div>
<div
v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)"
class="attachments media-body"
>
<attachment
v-for="attachment in nonGalleryAttachments"
:key="attachment.id"
class="non-gallery"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
:attachment="attachment"
:allow-play="true"
:set-media="setMedia()"
@play="$emit('mediaplay', attachment.id)"
@pause="$emit('mediapause', attachment.id)"
/>
<gallery
v-if="galleryAttachments.length > 0"
:nsfw="nsfwClickthrough"
:attachments="galleryAttachments"
:set-media="setMedia()"
/>
</div>
<div
v-if="status.card && !hideSubjectStatus && !noHeading"
class="link-preview media-body"
>
<link-preview
:card="status.card"
:size="attachmentSize"
:nsfw="nsfwClickthrough"
/>
</div>
</div>
</StatusBody>
<slot name="footer" />
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./status_content.js" ></script>
@ -139,156 +61,5 @@ $status-margin: 0.75em;
.StatusContent {
flex: 1;
min-width: 0;
.status-content-wrapper {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
z-index: 1;
.status-content {
min-height: 0;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */
-webkit-mask-composite: xor;
mask-composite: exclude;
}
}
.tall-status-hider {
display: inline-block;
word-break: break-all;
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
z-index: 2;
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
display: inline-block;
word-break: break-all;
svg {
color: inherit;
}
}
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
&.emoji {
width: 32px;
height: 32px;
}
}
.summary-wrapper {
margin-bottom: 0.5em;
border-style: solid;
border-width: 0 0 1px 0;
border-color: var(--border, $fallback--border);
flex-grow: 0;
}
.summary {
font-style: italic;
padding-bottom: 0.5em;
}
.tall-subject {
position: relative;
.summary {
max-height: 2em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.tall-subject-hider {
display: inline-block;
word-break: break-all;
// position: absolute;
width: 100%;
text-align: center;
padding-bottom: 0.5em;
}
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code, samp, kbd, var, pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0 0 1em 0;
}
p:last-child {
margin: 0 0 0 0;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
&.single-line {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.4em;
}
}
}
.greentext {
color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen);
}
</style>

View file

@ -30,7 +30,7 @@
position: relative;
line-height: 0;
overflow: hidden;
display: flex;
display: inline-flex;
align-items: center;
canvas {
@ -47,12 +47,13 @@
img {
width: 100%;
min-height: 100%;
height: 100%;
object-fit: contain;
}
&.animated {
&::before {
zoom: var(--_still_image-label-scale, 1);
content: 'gif';
position: absolute;
line-height: 10px;

View file

@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue'
import Select from '../select/select.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -12,14 +13,16 @@ import {
faBell,
faRss,
faSearchPlus,
faExternalLinkAlt
faExternalLinkAlt,
faEdit
} from '@fortawesome/free-solid-svg-icons'
library.add(
faRss,
faBell,
faSearchPlus,
faExternalLinkAlt
faExternalLinkAlt,
faEdit
)
export default {
@ -118,7 +121,8 @@ export default {
AccountActions,
ProgressButton,
FollowButton,
Select
Select,
RichContent
},
methods: {
muteUser () {
@ -153,6 +157,9 @@ export default {
this.$store.state.instance.restrictedNicknames
)
},
openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile')
},
zoomAvatar () {
const attachment = {
url: this.user.profile_image_url_original,

View file

@ -38,22 +38,25 @@
</router-link>
<div class="user-summary">
<div class="top-line">
<!-- eslint-disable vue/no-v-html -->
<div
v-if="user.name_html"
<RichContent
:title="user.name"
class="user-name"
v-html="user.name_html"
:html="user.name"
:emoji="user.emoji"
/>
<!-- eslint-enable vue/no-v-html -->
<div
v-else
:title="user.name"
class="user-name"
>
{{ user.name }}
</div>
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
>
<FAIcon
fixed-width
class="icon"
icon="edit"
:title="$t('user_card.edit_profile')"
/>
</button>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
@ -63,7 +66,7 @@
class="icon"
icon="external-link-alt"
/>
</button>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
@ -79,6 +82,12 @@
@{{ user.screen_name_ui }}
</router-link>
<template v-if="!hideBio">
<span
v-if="user.deactivated"
class="alert user-role"
>
{{ $t('user_card.deactivated') }}
</span>
<span
v-if="!!visibleRole"
class="alert user-role"
@ -157,7 +166,10 @@
class="user-interactions"
>
<div class="btn-group">
<FollowButton :relationship="relationship" />
<FollowButton
:relationship="relationship"
:user="user"
/>
<template v-if="relationship.following">
<ProgressButton
v-if="!relationship.subscribing"
@ -192,6 +204,7 @@
<button
v-if="relationship.muting"
class="btn button-default btn-block toggled"
:disabled="user.deactivated"
@click="unmuteUser"
>
{{ $t('user_card.muted') }}
@ -199,6 +212,7 @@
<button
v-else
class="btn button-default btn-block"
:disabled="user.deactivated"
@click="muteUser"
>
{{ $t('user_card.mute') }}
@ -207,6 +221,7 @@
<div>
<button
class="btn button-default btn-block"
:disabled="user.deactivated"
@click="mentionUser"
>
{{ $t('user_card.mention') }}
@ -255,20 +270,12 @@
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
<p
v-if="!hideBio && user.description_html"
<RichContent
v-if="!hideBio"
class="user-card-bio"
@click.prevent="linkClicked"
v-html="user.description_html"
:html="user.description_html"
:emoji="user.emoji"
/>
<!-- eslint-enable vue/no-v-html -->
<p
v-else-if="!hideBio"
class="user-card-bio"
>
{{ user.description }}
</p>
</div>
</div>
</template>
@ -281,9 +288,10 @@
.user-card {
position: relative;
&:hover .Avatar {
&:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
.panel-heading {
@ -327,12 +335,12 @@
}
}
p {
margin-bottom: 0;
}
&-bio {
text-align: center;
display: block;
line-height: 18px;
padding: 1em;
margin: 0;
a {
color: $fallback--link;
@ -344,11 +352,6 @@
vertical-align: middle;
max-width: 100%;
max-height: 400px;
&.emoji {
width: 32px;
height: 32px;
}
}
}
@ -426,7 +429,7 @@
}
}
.external-link-button {
.external-link-button, .edit-profile-button {
cursor: pointer;
width: 2.5em;
text-align: center;
@ -450,13 +453,6 @@
// big one
z-index: 1;
img {
width: 26px;
height: 26px;
vertical-align: middle;
object-fit: contain
}
.top-line {
display: flex;
}
@ -469,12 +465,7 @@
margin-right: 1em;
font-size: 15px;
img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
}
--emoji-size: 14px;
}
.bottom-line {
@ -578,6 +569,10 @@
}
}
.sidebar .edit-profile-button {
display: none;
}
.user-counts {
display: flex;
line-height:16px;

View file

@ -4,6 +4,7 @@ import FollowCard from '../follow_card/follow_card.vue'
import Timeline from '../timeline/timeline.vue'
import Conversation from '../conversation/conversation.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -164,7 +165,8 @@ const UserProfile = {
FriendList,
FollowCard,
TabSwitcher,
Conversation
Conversation,
RichContent
}
}

View file

@ -20,20 +20,24 @@
:key="index"
class="user-profile-field"
>
<!-- eslint-disable vue/no-v-html -->
<dt
:title="user.fields_text[index].name"
class="user-profile-field-name"
@click.prevent="linkClicked"
v-html="field.name"
/>
>
<RichContent
:html="field.name"
:emoji="user.emoji"
/>
</dt>
<dd
:title="user.fields_text[index].value"
class="user-profile-field-value"
@click.prevent="linkClicked"
v-html="field.value"
/>
<!-- eslint-enable vue/no-v-html -->
>
<RichContent
:html="field.value"
:emoji="user.emoji"
/>
</dd>
</dl>
</div>
<tab-switcher