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, idempotencyKey,
}) => { }) => {
const form = new FormData() const form = new FormData()
const pollOptions = poll.options || [] const pollOptions = poll?.options || []
form.append('status', status) form.append('status', status)
form.append('source', 'Pleroma FE') form.append('source', 'Pleroma FE')

View file

@ -1,8 +1,8 @@
import { find } from 'lodash'
import { mapState as mapPiniaState } from 'pinia' import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex' import { mapState } from 'vuex'
import Attachment from 'src/components/attachment/attachment.vue' 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 ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
import Gallery from 'src/components/gallery/gallery.vue' import Gallery from 'src/components/gallery/gallery.vue'
import LinkPreview from 'src/components/link-preview/link-preview.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 StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusBody from 'src/components/status_body/status_body.vue' import StatusBody from 'src/components/status_body/status_body.vue'
import StatusContent from 'src/components/status_content/status_content.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 UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue' import UserPopover from 'src/components/user_popover/user_popover.vue'
@ -22,9 +23,10 @@ import {
faCircleNotch, faCircleNotch,
faEllipsisH, faEllipsisH,
faTimes, faTimes,
faReply,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH, faCircleNotch) library.add(faTimes, faEllipsisH, faCircleNotch, faReply)
const ChatMessage = { const ChatMessage = {
name: 'ChatMessage', name: 'ChatMessage',
@ -35,6 +37,8 @@ const ChatMessage = {
'chatItem', 'chatItem',
'previousItem', 'previousItem',
'hoveredMessageChain', 'hoveredMessageChain',
'focused',
'repliedTo',
], ],
emits: ['hover', 'replyRequested'], emits: ['hover', 'replyRequested'],
components: { components: {
@ -48,6 +52,8 @@ const ChatMessage = {
LinkPreview, LinkPreview,
ChatMessageDate, ChatMessageDate,
UserPopover, UserPopover,
StatusPopover,
MentionLink,
}, },
computed: { computed: {
// Returns HH:MM (hours and minutes) in local time. // Returns HH:MM (hours and minutes) in local time.
@ -89,6 +95,25 @@ const ChatMessage = {
this.chatItem.data.in_reply_to_status_id 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() { messageForStatusContent() {
return { return {
summary: '', summary: '',
@ -101,6 +126,14 @@ const ChatMessage = {
hasAttachment() { hasAttachment() {
return this.message.attachments.length > 0 return this.message.attachments.length > 0
}, },
classnames() {
return {
'-outgoing': this.isCurrentUser,
'-incoming': !this.isCurrentUser,
'-pending': this.message.pending,
'-focused': this.focused,
}
},
...mapPiniaState(useInterfaceStore, { ...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter, betterShadow: (store) => store.browserSupport.cssFilter,
}), }),

View file

@ -17,7 +17,7 @@
position: absolute; position: absolute;
top: -0.8em; top: -0.8em;
right: 0.4rem; right: 0.4rem;
z-index: 10; z-index: 1;
.quick-action-buttons { .quick-action-buttons {
justify-items: end; justify-items: end;
@ -39,23 +39,16 @@
} }
.reply-to-header { .reply-to-header {
white-space: nowrap;
width: 100%;
display: flex; display: flex;
align-items: baseline; gap: 0.5rem;
gap: 0.5em; align-items: center;
padding: 0.125em 0;
width: 100%;
}
.reply-label { .avatar-spacer {
display: inline-block; flex: 0 0 2.2rem;
line-height: 1; width: 2.2rem;
}
.reply-body {
line-height: 1;
display: inline-block;
overflow-x: hidden;
text-overflow: ellipsis;
}
} }
.popover { .popover {
@ -73,8 +66,7 @@
} }
.avatar-wrapper { .avatar-wrapper {
margin-right: 0.72em; margin-right: 0.5em;
width: 32px;
} }
.link-preview, .link-preview,
@ -124,27 +116,70 @@
} }
} }
.message-bubble-wrapper {
display: flex;
}
.chat-message-inner { .chat-message-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
} }
.-outgoing { .reply-indicator {
display: flex; display: flex;
flex-flow: row wrap; place-items: center;
place-content: end flex-end; 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 { .end-spacer {
align-items: flex-end; 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 { .reply-to-header {
justify-content: end; 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 { .chat-message-inner.with-media {

View file

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

View file

@ -2,13 +2,64 @@
<div <div
v-if="isMessage" v-if="isMessage"
class="chat-message-wrapper" class="chat-message-wrapper"
:class="{ 'hovered-message-chain': hoveredMessageChain }" :class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
@mouseover="onHover(true)" @mouseover="onHover(true)"
@mouseleave="onHover(false)" @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 <div
class="chat-message" class="chat-message"
:class="[{ '-outgoing': isCurrentUser, '-incoming': !isCurrentUser, '-pending': message.pending }]" :class="classnames"
> >
<div <div
v-if="!isCurrentUser" v-if="!isCurrentUser"
@ -23,118 +74,107 @@
:user="author" :user="author"
/> />
</UserPopover> </UserPopover>
<div v-else class="avatar-spacer" />
</div> </div>
<div class="chat-message-inner"> <div class="chat-message-inner">
<small <div class="message-bubble-wrapper">
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 <div
class="media status" class="status-body"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }" :style="{ 'min-width': message.attachment ? '80%' : '' }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = 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 <div
class="chat-message-toolbar" class="media status"
:class="{ '-visible': hovered || menuOpened }" :class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
v-else style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
> >
<Popover
trigger="click" <StatusActionButtons
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }" v-if="isStatus"
placement="top" class="chat-message-toolbar"
:margin="popoverMarginStyle" :class="{ '-visible': hovered || menuOpened }"
@show="menuOpened = true" :status="message"
@close="menuOpened = false" :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> <Popover
<div class="dropdown-menu"> trigger="click"
<div class="menu-item dropdown-item -icon"> :trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
<button placement="top"
class="main-button" :margin="popoverMarginStyle"
@click="deleteMessage" @show="menuOpened = true"
> @close="menuOpened = false"
<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"
> >
<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 <span
v-if="message.visibility" class="created-at"
class="visibility-icon"
:title="visibilityLocalized"
> >
<FAIcon <span
class="fa-scale-110" v-if="message.visibility"
:icon="visibilityIcon(message.visibility)" class="visibility-icon"
fixed-width :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>
<span </template>
v-if="message.pending" </StatusContent>
class="loading-spinner" </div>
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ createdAt }}
</span>
</template>
</StatusContent>
</div> </div>
<div
v-if="repliedTo"
class="reply-indicator"
>
<FAIcon class="icon" icon="reply" />
</div>
<div class="end-spacer" />
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -479,7 +479,8 @@ export default {
> >
{this.collapse {this.collapse
? pass2.map((x) => { ? 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)) return x.map((y) => (y.type === 'br' ? ' ' : y))
}) })
: pass2} : pass2}

View file

@ -1,4 +1,4 @@
import { unescape as ldUnescape, uniqBy } from 'lodash' import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue' import AvatarList from 'src/components/avatar_list/avatar_list.vue'
@ -377,19 +377,6 @@ const Status = {
return user && user.screen_name_ui 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() { combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it // Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat( const combinedUsers = [].concat(

View file

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

View file

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