Merge branch 'chat-refactor' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-07-22 16:04:03 +03:00
commit a36e60f508
16 changed files with 508 additions and 325 deletions

View file

@ -207,7 +207,7 @@ export const postStatus = ({
idempotencyKey,
}) => {
const form = new FormData()
const pollOptions = poll.options || []
const pollOptions = poll?.options || []
form.append('status', status)
form.append('source', 'Pleroma FE')

View file

@ -1,8 +1,8 @@
import { find } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import Attachment from 'src/components/attachment/attachment.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.vue'
@ -10,6 +10,7 @@ import Popover from 'src/components/popover/popover.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusBody from 'src/components/status_body/status_body.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
@ -22,9 +23,10 @@ import {
faCircleNotch,
faEllipsisH,
faTimes,
faReply,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH, faCircleNotch)
library.add(faTimes, faEllipsisH, faCircleNotch, faReply)
const ChatMessage = {
name: 'ChatMessage',
@ -35,6 +37,8 @@ const ChatMessage = {
'chatItem',
'previousItem',
'hoveredMessageChain',
'focused',
'repliedTo',
],
emits: ['hover', 'replyRequested'],
components: {
@ -48,6 +52,8 @@ const ChatMessage = {
LinkPreview,
ChatMessageDate,
UserPopover,
StatusPopover,
MentionLink,
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
@ -89,6 +95,25 @@ const ChatMessage = {
this.chatItem.data.in_reply_to_status_id
]
},
replyToName() {
if (this.message.in_reply_to_screen_name) {
return this.message.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
return user && user.screen_name_ui
}
},
replyProfileLink() {
if (this.isCustomReply) {
const user = this.$store.getters.findUser(
this.message.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
messageForStatusContent() {
return {
summary: '',
@ -101,6 +126,14 @@ const ChatMessage = {
hasAttachment() {
return this.message.attachments.length > 0
},
classnames() {
return {
'-outgoing': this.isCurrentUser,
'-incoming': !this.isCurrentUser,
'-pending': this.message.pending,
'-focused': this.focused,
}
},
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),

View file

@ -17,7 +17,7 @@
position: absolute;
top: -0.8em;
right: 0.4rem;
z-index: 10;
z-index: 1;
.quick-action-buttons {
justify-items: end;
@ -39,23 +39,16 @@
}
.reply-to-header {
white-space: nowrap;
width: 100%;
display: flex;
align-items: baseline;
gap: 0.5em;
gap: 0.5rem;
align-items: center;
padding: 0.125em 0;
width: 100%;
}
.reply-label {
display: inline-block;
line-height: 1;
}
.reply-body {
line-height: 1;
display: inline-block;
overflow-x: hidden;
text-overflow: ellipsis;
}
.avatar-spacer {
flex: 0 0 2.2rem;
width: 2.2rem;
}
.popover {
@ -73,8 +66,7 @@
}
.avatar-wrapper {
margin-right: 0.72em;
width: 32px;
margin-right: 0.5em;
}
.link-preview,
@ -124,27 +116,70 @@
}
}
.message-bubble-wrapper {
display: flex;
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
}
.-outgoing {
.reply-indicator {
display: flex;
flex-flow: row wrap;
place-content: end flex-end;
place-items: center;
place-content: center;
padding: 0.25em;
margin: var(--roundness) 0;
background: var(--border);
border-radius: var(--roundness);
border: 1px solid var(--border);
}
.chat-message-inner {
align-items: flex-end;
.end-spacer {
flex: 1 1 0;
min-width: calc(2.2em + 0.5em + 2em);
}
&.-incoming {
.reply-indicator {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
}
.reply-to-popover {
white-space: nowrap;
}
.reply-label {
white-space: nowrap;
}
&.-outgoing {
&,
.message-bubble-wrapper,
.chat-message{
flex-direction: row-reverse;
}
.reply-to-header {
justify-content: end;
}
.reply-indicator {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
.icon {
transform: scaleX(-1);
}
}
.chat-message-inner {
align-items: flex-end;
}
}
.chat-message-inner.with-media {

View file

@ -4,6 +4,9 @@ export default {
variants: {
outgoing: '.outgoing',
},
states: {
focused: '.-focused',
},
validInnerComponents: ['Text', 'Icon', 'Border', 'PollGraph'],
defaultRules: [
{
@ -18,5 +21,11 @@ export default {
background: '--bg, 5',
},
},
{
state: ['focused'],
directives: {
background: '--inheritedBackground, 10',
},
},
],
}

View file

@ -2,13 +2,64 @@
<div
v-if="isMessage"
class="chat-message-wrapper"
:class="{ 'hovered-message-chain': hoveredMessageChain }"
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<i18n-t
v-if="isStatus && isCustomReply"
keypath="status.reply_to_with_arg"
scope="global"
tag="small"
class="reply-to-header faint"
>
<template #replyToWithIcon>
<div class="avatar-spacer" />
<StatusPopover
:status-id="customReplyTo?.id"
class="reply-to-popover"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span class="reply-label">
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</StatusPopover>
</template>
<template #user>
<!-- v-if is there because status might not be loaded yet -->
<StatusBody
v-if="customReplyTo && customReplyTo.text.trim().length > 0"
class="reply-body faint"
:status="customReplyTo"
collapse
single-line
/>
<MentionLink
v-else-if="customReplyTo"
class="reply-body"
:content="replyToName"
:url="replyProfileLink"
:user-id="message.in_reply_to_user_id"
:user-screen-name="message.in_reply_to_screen_name"
/>
</template>
</i18n-t>
<div
class="chat-message"
:class="[{ '-outgoing': isCurrentUser, '-incoming': !isCurrentUser, '-pending': message.pending }]"
:class="classnames"
>
<div
v-if="!isCurrentUser"
@ -23,118 +74,107 @@
:user="author"
/>
</UserPopover>
<div v-else class="avatar-spacer" />
</div>
<div class="chat-message-inner">
<small
v-if="isStatus && isCustomReply"
class="reply-to-header faint"
>
<strong class="reply-label">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
{{ $t('status.reply_to') }}
</strong>
<!-- v-if is there because status might not be loaded yet -->
<StatusBody
v-if="customReplyTo"
class="reply-body"
:status="customReplyTo"
collapse
single-line
/>
</small>
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div class="message-bubble-wrapper">
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<StatusActionButtons
v-if="isStatus"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
:status="message"
:pinned="new Set(['reply', 'emoji'])"
fixed-pinned
use-default-buttons
hide-labels
@toggle-replying="$emit('replyRequested', message)"
/>
<div
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
v-else
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
<StatusActionButtons
v-if="isStatus"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
:status="message"
:pinned="new Set(['reply', 'emoji'])"
fixed-pinned
use-default-buttons
hide-labels
@toggle-replying="$emit('replyRequested', message)"
/>
<div
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
v-else
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<span
class="created-at"
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
class="created-at"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ createdAt }}
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ createdAt }}
</span>
</template>
</StatusContent>
</template>
</StatusContent>
</div>
</div>
<div
v-if="repliedTo"
class="reply-indicator"
>
<FAIcon class="icon" icon="reply" />
</div>
<div class="end-spacer" />
</div>
</div>
</div>

View file

@ -14,6 +14,8 @@ const ChatMessageList = {
default: [],
},
headerDate: Boolean,
focusedId: String,
repliedId: String,
},
data() {
return {

View file

@ -6,6 +6,8 @@
:chat-item="chatItem"
:previous-item="getPreviousItem(index)"
:hovered-message-chain="chatItem.messageChainId === hoveredMessageChainId"
:focused="chatItem.id === focusedId"
:repliedTo="chatItem.id === repliedId"
@hover="onMessageHover"
@delete="onMessageDelete"
@reply-requested="onReplyRequested"

View file

@ -99,10 +99,10 @@
h4 {
margin: 0.5em 0;
line-height: 1.5;
line-height: 1.1;
button {
line-height: 1.5;
line-height: 1.1;
}
}
@ -110,6 +110,9 @@
display: flex;
align-items: center;
gap: 0.5em;
border: 1px solid var(--border);
border-radius: var(--roundness);
padding: 0.5em;
}
}
@ -119,5 +122,9 @@
flex-direction: column;
align-items: stretch;
bottom: 0;
.reply-form form {
margin-top: 0;
}
}
}

View file

@ -202,6 +202,8 @@
>
<ChatMessageList
:messages="conversation"
:replied-id="replyStatus?.id"
:focused-id="maybeFocused"
@reply-requested="e => explicitReplyStatus = e"
/>
</div>
@ -211,9 +213,7 @@
class="chat-view-reply-form panel-footer -flexible-height"
>
<div class="auto-reply-to-section">
<h4
class="reply-to-text"
>
<h4 class="reply-to-text">
{{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
v-if="explicitReplyStatus"
@ -224,27 +224,24 @@
{{ $t('general.cancel') }}
</button>
</h4>
<div class="reply-to-preview">
<FAIcon
icon="reply"
flip="horizontal"
/>
<StatusContent
:status="replyStatus"
compact
collapse
/>
</div>
<StatusContent
:status="replyStatus"
class="reply-to-preview"
compact
collapse
/>
</div>
<PostStatusForm
class="reply-form"
:reply-to="replyStatus.id"
:attentions="replyStatus.attentions"
:replied-user="replyStatus.user"
:replied-sibject="replyStatus.summary"
:replied-scope="replyStatus.visibility"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:reply-to="replyStatus.id"
:disable-quotes="true"
:copy-message-scope="replyStatus.visibility"
:attentions="replyStatus.attentions"
:replied-user="replyStatus.user"
disable-quotes
mentions-line
mentions-line-read-only
/>

View file

@ -30,7 +30,10 @@ export default {
return pollFallback(this.modelValue, 'pollType')
},
set(newVal) {
this.$emit('update:modelValue', { ...this.modelValue, pollType: newVal })
this.$emit('update:modelValue', {
...this.modelValue,
pollType: newVal,
})
},
},
options: {
@ -39,14 +42,17 @@ export default {
},
set(newVal) {
this.$emit('update:modelValue', { ...this.modelValue, options: newVal })
}
},
},
expiryAmount: {
get() {
return pollFallback(this.modelValue, 'expiryAmount')
},
set(newVal) {
this.$emit('update:modelValue', { ...this.modelValue, expiryAmount: newVal })
this.$emit('update:modelValue', {
...this.modelValue,
expiryAmount: newVal,
})
},
},
expiryUnit: {
@ -54,7 +60,10 @@ export default {
return pollFallback(this.modelValue, 'expiryUnit')
},
set(newVal) {
this.$emit('update:modelValue', { ...this.modelValue, expiryUnit: newVal })
this.$emit('update:modelValue', {
...this.modelValue,
expiryUnit: newVal,
})
},
},
pollLimits() {

View file

@ -1,4 +1,10 @@
import { debounce, reject, uniqBy } from 'lodash'
import {
debounce,
isEqual,
unescape as ldUnescape,
reject,
uniqBy,
} from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
@ -60,14 +66,12 @@ const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const DEFAULT_NEWSTATUS = {
}
const PostStatusForm = {
props: {
// Status editing stuff
statusId: String,
statusText: String,
statusSubject: String,
statusIsSensitive: {
type: Boolean,
required: false,
@ -79,30 +83,34 @@ const PostStatusForm = {
statusMediaDescriptions: Object,
statusScope: String,
statusContentType: String,
// Replies/mentions
replyTo: String,
repliedUser: Object,
mentionsLine: Boolean,
mentionsLineReadOnly: Boolean,
attentions: Array,
subject: String,
copyMessageScope: String,
profileMention: String,
replyTo: String, // id of the post replying to
repliedUser: Object, // user object replying to
attentions: Array, // list of users mentioned
repliedScope: String, // scope of the post replying to
repliedSubject: String, // subject of post replying to
profileMention: Boolean, // is mentioning a user (used in profile page -> mention)
// Draft stuff
hideDraft: Boolean,
closeable: Boolean,
draftId: String,
hideDraft: Boolean, // Disable drafts functionality
closeable: Boolean, // Whether form can be closed (i.e. in replies)
draftId: String, // ID of the draft to be used
// Chats stuff
maxHeight: Number,
placeholder: String,
postHandler: Function,
preserveFocus: Boolean,
autoFocus: Boolean,
fileLimit: Number,
postHandler: Function, // Used to override poster to use chats one instead of status one
preserveFocus: Boolean, // Keep focus on form after posting
autoFocus: Boolean, // Steal focus when form is opened
fileLimit: Number, // Chats only support 1 attachment :(
submitOnEnter: Boolean,
emojiPickerPlacement: String,
optimisticPosting: Boolean,
optimisticPosting: Boolean, // Don't wait for confirmation that post is done
// Feature toggles for special cases (mostly chats)
mentionsLine: Boolean, // Use separate field for specifying mentions
mentionsLineReadOnly: Boolean, // Make the said field read-only (for chat conversation view)
disableSubject: Boolean,
disableScopeSelector: Boolean,
disableVisibilitySelector: Boolean,
@ -126,20 +134,19 @@ const PostStatusForm = {
],
data() {
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
initialized: false,
// Data is initialized first, but we have no access to .computed
// so we pre-fill with stuff meant for status editing and later
// back-fill with defaults in .created()
randomSeed: genRandomSeed(),
// Posting stuff
idempotencyKey: '',
/* Data is initialized first, but we have no access to .computed yet
* which we need for some defaults (i.e. user configration)
* so we pre-fill with stuff meant for status editing and later
* back-fill with defaults in .created()
*/
newStatus: {
status: this.statusText ?? null,
mentions: this.statusMentionLine ?? null,
spoilerText: this.subject ?? null,
spoilerText: this.statusSubject ?? null,
quote: this.statusQuote ?? null,
files: this.statusFiles ?? null,
poll: this.statusPoll ?? null,
@ -148,15 +155,25 @@ const PostStatusForm = {
visibility: this.statusVisibility ?? null,
contentType: this.statusContentType ?? null,
},
caret: 0,
// Attachments
dropFiles: [],
uploadingFiles: false,
showDropIcon: 'hide',
dropStopTimeout: null,
// Preview
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
// Draft
saveInhibited: true,
saveable: false,
// Misc States
emojiInputShown: false,
error: null,
posting: false,
}
},
components: {
@ -178,8 +195,11 @@ const PostStatusForm = {
Popover,
},
created() {
this.updateIdempotencyKey()
// If we are starting a new post, do not associate it with old drafts
const draft = !this.disableDraft && (this.draftId || this.statusType !== 'new')
const draft =
!this.disableDraft && (this.draftId || this.statusType !== 'new')
? this.getDraft(this.statusType, this.refId)
: null
@ -189,43 +209,14 @@ const PostStatusForm = {
this.newStatus[key] = draft[key] ?? this.newStatus[key]
})
} else {
const defaultNewStatus = {
spoilerText: '',
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope =
(this.copyMessageScope && this.userDefaultScopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.userDefaultScope
const preset = this.$route.query.message
let statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
defaultNewStatus.mentions = this.mentionsString.trim()
} else {
defaultNewStatus.status = this.mentionsString + statusText
defaultNewStatus.mentions = ''
}
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
Object.entries(defaultNewStatus).forEach(([key, value]) => {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
this.newStatus[key] = this.newStatus[key] ?? value
})
}
this.initialized = true
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
if (this.replyTo) {
@ -249,7 +240,7 @@ const PostStatusForm = {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
// Composition stuff
// Composing stuff
statusType() {
if (this.replyTo) {
return 'reply'
@ -264,16 +255,17 @@ const PostStatusForm = {
refId() {
if (this.replyTo) {
return this.replyTo
} else if (profileMention) {
} else if (this.profileMention && this.repliedUser?.id) {
return this.profileMention && this.repliedUser?.id
} else if (statusId) {
} else if (this.statusId) {
return this.statusId
} else {
return null
}
},
mentionsString() {
if (this.statusType !== 'reply' && this.statusType !== 'mention') return ''
if (this.statusType !== 'reply' && this.statusType !== 'mention')
return ''
let allAttentions = [...(this.attentions || [])]
allAttentions.unshift(this.repliedUser)
@ -281,7 +273,9 @@ const PostStatusForm = {
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: this.currentUser.id })
const mentions = allAttentions.map((attention) => `@${attention.screen_name}`)
const mentions = allAttentions.map(
(attention) => `@${attention.screen_name}`,
)
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
},
@ -290,6 +284,39 @@ const PostStatusForm = {
? this.mentionsString + this.newStatus.status
: this.newStatus.status
},
defaultNewStatus() {
const defaultNewStatus = {
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope =
(this.repliedScope && this.userDefaultScopeCopy) ||
this.repliedScope === 'direct'
? this.repliedScope
: this.userDefaultScope
const preset = this.$route.query.message
const statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
defaultNewStatus.mentions = this.mentionsString.trim()
} else {
defaultNewStatus.status = this.mentionsString + statusText
defaultNewStatus.mentions = ''
}
defaultNewStatus.spoilerText = this.repliedSubjectString ?? ''
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
return defaultNewStatus
},
// -Edit
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
@ -297,13 +324,26 @@ const PostStatusForm = {
isReply() {
return this.statusType === 'reply'
},
inReplyStatusId() {
inReplyToStatusId() {
return !this.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.replyTo
: undefined
},
repliedSubjectString() {
if (!this.repliedSubject) return null
const decodedSummary = ldUnescape(this.repliedSubject)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
// -Poll
hasPoll() {
return this.newStatus.poll != null
@ -406,7 +446,7 @@ const PostStatusForm = {
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
emptyStatus() {
isEmptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
@ -416,6 +456,15 @@ const PostStatusForm = {
},
// Drafts
isDirty() {
return Object.entries(this.defaultNewStatus).some(
([key, defaultValue]) => {
const actualValue = this.newStatus[key]
if (actualValue === null) return false
return !isEqual(actualValue, defaultValue)
},
)
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
@ -525,11 +574,8 @@ const PostStatusForm = {
}),
},
watch: {
newStatus: {
deep: true,
handler() {
if (this.initialized) this.statusChanged()
},
isDirty(newVal, oldVal) {
this.statusChanged()
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
@ -541,45 +587,25 @@ const PostStatusForm = {
}
},
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
methods: {
...mapActions(useMediaViewerStore, ['increment']),
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
// Composing
onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return
this.newStatus.mentionsLine = e
},
toggleQuoteForm() {
if (!this.hasQuote) {
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
} else {
this.newStatus.quote = null
}
changeVis(visibility) {
this.newStatus.visibility = visibility
},
clearStatus() {
const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus.status = ''
this.newStatus.mentionsLine = '',
this.newStatus.spoilerText = '',
this.newStatus.files = [],
this.newStatus.poll = null,
this.newStatus.quote = null,
this.newStatus.mediaDescriptions = {},
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
;(this.newStatus.mentionsLine = ''),
(this.newStatus.spoilerText = ''),
(this.newStatus.files = []),
(this.newStatus.poll = null),
(this.newStatus.quote = null),
(this.newStatus.mediaDescriptions = {}),
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearQuoteForm()
if (this.preserveFocus) {
this.$nextTick(() => {
@ -610,12 +636,12 @@ const PostStatusForm = {
if (
this.optimisticPosting &&
(this.emptyStatus || this.isOverLengthLimit)
(this.isEmptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.emptyStatus) {
if (this.isEmptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
@ -638,12 +664,12 @@ const PostStatusForm = {
const postingOptions = {
status: this.newStatusContent,
spoilerText: newStatus.spoilerText || null,
spoilerText: newStatus.spoilerText ?? null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
inReplyToStatusId: this.inReplyToStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll,
@ -667,8 +693,10 @@ const PostStatusForm = {
this.posting = false
})
},
// Preview
previewStatus() {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
if (this.isEmptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
@ -684,10 +712,11 @@ const PostStatusForm = {
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
inReplyToStatusId: this.inReplyToStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll: {},
poll: null,
quote: null,
preview: true,
})
.then((data) => {
@ -722,6 +751,21 @@ const PostStatusForm = {
this.previewStatus()
}
},
// Attachments
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
@ -795,6 +839,9 @@ const PostStatusForm = {
this.showDropIcon = 'show'
}
},
// Auto-sizable input field
// TODO separate into its own component pls
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
@ -908,53 +955,39 @@ const PostStatusForm = {
scrollerRef.scrollTop = targetScroll
}
},
clearError() {
this.error = null
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
// Poll
togglePollForm() {
this.newStatus.poll = this.hasPoll ? null : {}
},
setPoll(poll) {
this.newStatus.poll = poll
},
// Quote
toggleQuoteForm() {
if (!this.hasQuote) {
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
} else {
this.newStatus.quote = null
}
},
clearQuoteForm() {
if (this.$refs.quoteForm) {
this.$refs.quoteForm.clear()
}
},
dismissScopeNotice() {
useSyncConfigStore().setSimplePrefAndSave({
path: 'hideScopeNotice',
value: true,
})
},
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
propsToNative(props) {
return propsToNative(props)
// Drafts
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
@ -1036,6 +1069,34 @@ const PostStatusForm = {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
// Misc
propsToNative(props) {
return propsToNative(props)
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
dismissScopeNotice() {
useSyncConfigStore().setSimplePrefAndSave({
path: 'hideScopeNotice',
value: true,
})
},
clearError() {
this.error = null
},
...mapActions(useMediaViewerStore, ['increment']),
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
}

View file

@ -233,7 +233,7 @@
v-if="!disableVisibilitySelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
:original-scope="repliedScope"
:initial-scope="newStatus.visibility"
:on-scope-change="changeVis"
/>

View file

@ -479,7 +479,8 @@ export default {
>
{this.collapse
? pass2.map((x) => {
if (!Array.isArray(x)) return x.replace(/\n/g, ' ')
if (typeof x === 'string') return x.replace(/\n/g, ' ')
if (!Array.isArray(x)) return x
return x.map((y) => (y.type === 'br' ? ' ' : y))
})
: pass2}

View file

@ -1,4 +1,4 @@
import { unescape as ldUnescape, uniqBy } from 'lodash'
import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
@ -377,19 +377,6 @@ const Status = {
return user && user.screen_name_ui
}
},
replySubject() {
if (!this.status.summary) return ''
const decodedSummary = ldUnescape(this.status.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(

View file

@ -549,8 +549,8 @@
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
:replied-scope="status.visibility"
:replied-subject="status.summary"
@posted="closeReplyForm"
@draft-done="closeReplyForm"
@close-accepted="closeReplyForm"

View file

@ -45,10 +45,10 @@ import {
} from 'src/api/public.js'
import {
blockUser as apiBlockUser,
editUserNote as apiEditUserNote,
muteUser as apiMuteUser,
unblockUser as apiUnblockUser,
unmuteUser as apiUnmuteUser,
editUserNote as apiEditUserNote,
fetchBlocks,
fetchDomainMutes,
fetchMutes,