Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85595486
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
108 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/api/chats.js b/src/api/chats.js
index 51e83c74f3..8f93ad57fa 100644
--- a/src/api/chats.js
+++ b/src/api/chats.js
@@ -1,87 +1,91 @@
import { paramsString, promisedRequest } from './helpers.js'
-import { parseChat } from 'src/services/entity_normalizer/entity_normalizer.service.js'
+import { parseChat, parseChatMessage } from 'src/services/entity_normalizer/entity_normalizer.service.js'
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = (id, { maxId, sinceId, limit }) =>
`/api/v1/pleroma/chats/${id}/messages${paramsString({ maxId, sinceId, limit })}`
const PLEROMA_CHAT_READ_URL = (id) => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) =>
`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
export const chats = ({ credentials }) =>
promisedRequest({
url: PLEROMA_CHATS_URL,
credentials,
}).then(({ data }) => ({
- chatList: data.map(parseChat).filter((c) => c),
+ data: data.map(parseChat).filter((c) => c),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
- })
+ }).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
credentials,
maxId,
sinceId,
limit = 20,
}) => {
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
- })
+ }).then(({ data }) => ({
+ data: data.map(parseChatMessage).filter((c) => c),
+ }))
}
export const sendChatMessage = ({
id,
content,
mediaId = null,
idempotencyKey,
credentials,
}) => {
const payload = {
content,
}
if (mediaId) {
payload.media_id = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload,
credentials,
headers,
- })
+ }).then(({ data }) => ({
+ data: parseChatMessage(data),
+ }))
}
export const readChat = ({ id, lastReadId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
last_read_id: lastReadId,
},
credentials,
})
export const deleteChatMessage = ({ chatId, messageId, credentials }) =>
promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials,
})
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index c35e189dd1..c75f09d111 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -1,120 +1,115 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import Attachment from 'src/components/attachment/attachment.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'
import Popover from 'src/components/popover/popover.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
+import { faEllipsisH, faTimes, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
-library.add(faTimes, faEllipsisH)
+library.add(faTimes, faEllipsisH, faCircleNotch)
const ChatMessage = {
name: 'ChatMessage',
- props: [
- 'edited',
- 'noHeading',
- 'chatItem',
- 'hoveredMessageChain',
- ],
+ props: ['edited', 'noHeading', 'chatItem', 'hoveredMessageChain'],
emits: ['hover'],
components: {
Popover,
Attachment,
StatusContent,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate,
UserPopover,
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt() {
const time = this.chatItem.data.created_at
return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
author() {
const accountId = this.message.account_id || this.message.user.id
return this.$store.getters.findUser(accountId)
},
isCurrentUser() {
return this.author.id === this.currentUser.id
},
message() {
return this.isMessage ? this.chatItem.data : null
},
isMessage() {
return this.chatItem.type === 'message'
},
messageForStatusContent() {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || this.message.raw_html || '',
text: this.message.content || '',
attachments: this.message.attachments,
}
},
hasAttachment() {
return this.message.attachments.length > 0
},
...mapPiniaState(useInterfaceStore, {
betterShadow: (store) => store.browserSupport.cssFilter,
}),
...mapState({
currentUser: (state) => state.users.currentUser,
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}),
popoverMarginStyle() {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']),
},
data() {
return {
hovered: false,
menuOpened: false,
}
},
methods: {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatItem.messageChainId,
})
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatItem.data.id,
chatId: this.chatItem.data.chat_id,
})
}
this.hovered = false
this.menuOpened = false
},
},
}
export default ChatMessage
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index c058f8172e..4fcb81c33e 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -1,146 +1,150 @@
.chat-message-wrapper {
&.hovered-message-chain {
.animated.Avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
}
.chat-message-menu {
transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -0.8em;
button {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
.menu-icon {
cursor: pointer;
}
.popover {
width: 12em;
}
.chat-message {
display: flex;
padding-bottom: 0.5em;
.status-body:hover {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
}
.avatar-wrapper {
margin-right: 0.72em;
width: 32px;
}
.link-preview,
.attachments {
margin-bottom: 1em;
}
.status {
background-color: var(--background);
color: var(--text);
border-radius: var(--roundness);
display: flex;
padding: 0.75em;
border: 1px solid var(--border);
}
.created-at {
position: relative;
float: right;
font-size: 0.8em;
margin: -1em 0 -0.5em;
font-style: italic;
opacity: 0.8;
}
.without-attachment {
.message-content {
// TODO figure out how to do it properly
.RichContent::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
}
}
}
.pending {
.status-content.media-body,
.created-at {
color: var(--faint);
}
}
.error {
.status-content.media-body,
.created-at {
color: var(--badgeNotification);
}
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
}
- .outgoing {
+ .-outgoing {
display: flex;
flex-flow: row wrap;
place-content: end flex-end;
.chat-message-inner {
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
}
}
- .incoming {
+ .-incoming {
.chat-message-menu {
left: 0.4rem;
}
}
+ .-pending {
+ color: red !important;
+ }
+
.chat-message-inner.with-media {
width: 100%;
.status {
width: 100%;
}
}
.visible {
opacity: 1;
}
}
.chat-message-date-separator {
text-align: center;
margin: 1.4em 0;
font-size: 0.9em;
user-select: none;
color: var(--textFaint);
}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index 6ac8a4266d..feb2369552 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -1,102 +1,113 @@
<template>
<div
v-if="isMessage"
class="chat-message-wrapper"
:class="{ 'hovered-message-chain': hoveredMessageChain }"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<div
class="chat-message"
- :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
+ :class="[{ '-outgoing': isCurrentUser, '-incoming': !isCurrentUser, '-pending': message.pending }]"
>
<div
v-if="!isCurrentUser"
class="avatar-wrapper"
>
<UserPopover
v-if="chatItem.isHead"
:user-id="author.id"
>
<UserAvatar
:compact="true"
:user="author"
/>
</UserPopover>
</div>
<div class="chat-message-inner">
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<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"
>
<div
class="chat-message-menu"
:class="{ 'visible': hovered || menuOpened }"
>
<Popover
trigger="click"
placement="top"
bound-to-selector=".chat-view-inner"
:bound-to="{ x: 'container' }"
: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>
<button
class="button-default menu-icon"
:title="$t('chats.more')"
>
<FAIcon icon="ellipsis-h" />
</button>
</template>
</Popover>
</div>
<StatusContent
class="message-content"
+ :class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<span
class="created-at"
>
+ <span
+ v-if="message.pending"
+ class="loading-spinner"
+ >
+ <FAIcon
+ class="fa-old-padding"
+ spin
+ icon="circle-notch"
+ />
+ </span>
{{ createdAt }}
</span>
</template>
</StatusContent>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="chat-message-date-separator"
>
<ChatMessageDate :date="chatItem.date" :show-time="chatItem.isTime" />
</div>
</template>
<script src="./chat_message.js"></script>
<style src="./chat_message.scss" lang="scss" />
diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue
index 3d0b34e56d..6c1be505bc 100644
--- a/src/components/chat_message_date/chat_message_date.vue
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -1,41 +1,41 @@
<template>
<time>
{{ displayDate }}
</time>
</template>
<script>
-import localeService from 'src/services/locale/locale.service.js'
-
import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import localeService from 'src/services/locale/locale.service.js'
+
export default {
name: 'Timeago',
props: ['date', 'showTime'],
computed: {
time12hFormat() {
return useMergedConfigStore().mergedConfig.absoluteTimeFormat12h === '12h'
},
displayDate() {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
if (this.showTime) {
return this.date.toLocaleTimeString(
localeService.internalToBrowserLocale(this.$i18n.locale),
- { hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' }
+ { hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' },
)
} else {
return this.date.toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ day: 'numeric', month: 'long' },
)
}
}
},
},
}
</script>
diff --git a/src/components/chat_message_list/chat_message_list.js b/src/components/chat_message_list/chat_message_list.js
index 77943a64ce..168896e335 100644
--- a/src/components/chat_message_list/chat_message_list.js
+++ b/src/components/chat_message_list/chat_message_list.js
@@ -1,94 +1,110 @@
-import { throttle, orderBy, uniqueId } from 'lodash'
+import { orderBy, throttle, uniqueId } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import ChatMessage from 'src/components/chat_message/chat_message.vue'
const ChatMessageList = {
components: {
ChatMessage,
},
props: {
messages: Array,
+ pendingMessages: {
+ type: Array,
+ required: false,
+ default: [],
+ },
headerDate: Boolean,
},
data() {
return {
hoveredMessageChainId: undefined,
}
},
computed: {
chatItems() {
- const messages = orderBy(this.messages, ['pending', 'id'], ['asc', 'asc'])
- return messages.reduceRight((acc, message, index) => {
- const date = new Date(message.created_at)
+ const messages = [
+ ...orderBy(this.messages, ['pending', 'id'], ['asc', 'asc']),
+ ...this.pendingMessages.map((m) => ({ ...m, pending: true })),
+ ]
+ return messages
+ .reduceRight((acc, message, index) => {
+ const date = new Date(message.created_at)
- const olderMessage = messages[index - 1]
- const newerMessage = messages[index + 1]
- const newerItem = acc[acc.length - 1]
+ const olderMessage = messages[index - 1]
+ const newerMessage = messages[index + 1]
+ const newerItem = acc[acc.length - 1]
- const diff = olderMessage ? message.created_at - olderMessage.created_at : null
+ const diff = olderMessage
+ ? message.created_at - olderMessage.created_at
+ : null
- const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
+ const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
- const dateDiffs = (() => {
- if (olderMessage) {
- const newerDate = new Date(message.created_at)
- const olderDate = new Date(olderMessage.created_at)
+ const dateDiffs = (() => {
+ if (olderMessage) {
+ const newerDate = new Date(message.created_at)
+ const olderDate = new Date(olderMessage.created_at)
- newerDate.setHours(0, 0, 0, 0)
- olderDate.setHours(0, 0, 0, 0)
+ newerDate.setHours(0, 0, 0, 0)
+ olderDate.setHours(0, 0, 0, 0)
- return newerDate.toISOString() !== olderDate.toISOString()
- } else {
- return true
- }
- })()
+ return newerDate.toISOString() !== olderDate.toISOString()
+ } else {
+ return true
+ }
+ })()
- const chatItem = {
- type: 'message',
- data: message,
- date,
- id: message.id,
- isTail: true,
- isHead: true,
- }
+ const chatItem = {
+ type: 'message',
+ data: message,
+ date,
+ id: message.id,
+ isTail: true,
+ isHead: true,
+ }
- if (newerItem == null) {
- chatItem.messageChainId = uniqueId()
- } else {
- if (newerItem.type === 'date') {
+ if (newerItem == null) {
chatItem.messageChainId = uniqueId()
- } else if (newerItem.type === 'message') {
- if (newerItem.data.account_id !== message.account_id) {
+ } else {
+ if (newerItem.type === 'date') {
chatItem.messageChainId = uniqueId()
- } else {
- chatItem.messageChainId = newerItem.messageChainId
- chatItem.isTail = false
- newerItem.isHead = false
+ } else if (newerItem.type === 'message') {
+ if (newerItem.data.account_id !== message.account_id) {
+ chatItem.messageChainId = uniqueId()
+ } else {
+ chatItem.messageChainId = newerItem.messageChainId
+ chatItem.isTail = false
+ newerItem.isHead = false
+ }
}
}
- }
- if (diff > MAX_DIFF || (!olderMessage && this.headerDate)) {
- return [...acc, chatItem, {
- type: 'date',
- date,
- isDate: dateDiffs,
- isTime: diff > MAX_DIFF && !dateDiffs,
- id: date.getTime().toString(),
- }]
- } else {
- return [...acc, chatItem]
- }
- }, []).reverse()
- }
+ if (diff > MAX_DIFF || (!olderMessage && this.headerDate)) {
+ return [
+ ...acc,
+ chatItem,
+ {
+ type: 'date',
+ date,
+ isDate: dateDiffs,
+ isTime: diff > MAX_DIFF && !dateDiffs,
+ id: date.getTime().toString(),
+ },
+ ]
+ } else {
+ return [...acc, chatItem]
+ }
+ }, [])
+ .reverse()
+ },
},
methods: {
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
- }
+ },
}
export default ChatMessageList
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index 4b2bba34f5..7eb87660ca 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,433 +1,475 @@
import { throttle } from 'lodash'
+import { nextTick } from 'vue'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
-import ChatTitle from 'src/components/chat_title/chat_title.vue'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
+import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import chatService from '../../services/chat_service/chat_service.js'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import {
getNewTopPosition,
getScrollPosition,
isBottomedOut,
isScrollable,
} from './chat_layout_utils.js'
import { useInterfaceStore } from 'src/stores/interface.js'
-import { useOAuthStore } from 'src/stores/oauth.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useOAuthStore } from 'src/stores/oauth.js'
import {
chatMessages,
getOrCreateChat,
+ readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
+const isConfirmation = (storage, message) => {
+ if (!message.idempotency_key) return
+ return storage.idempotencyKeyIndex[message.idempotency_key]
+}
+
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
- props: {
- messages: Array,
- },
data() {
return {
- jumpToBottomButtonVisible: false,
- hoveredMessageChainId: undefined,
+ // Main info
+ chat: null,
+ messages: [],
+ messagesIndex: {},
+ pendingMessages: [],
+ pendingMessagesIndex: {},
+ minId: undefined,
+ maxId: undefined,
+
+ // Unread stuff
+ newMessageCount: 0,
+ lastReadMessageId: null,
lastScrollPosition: {},
- scrollableContainerHeight: '100%',
+ jumpToBottomButtonVisible: false,
+
+ // Internal network stuff
+ fetcher: null,
errorLoadingChat: false,
messageRetriers: {},
+ idempotencyKeyIndex: {},
}
},
created() {
this.startFetching()
window.addEventListener('resize', this.handleResize)
},
mounted() {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
}
this.$nextTick(() => {
this.handleResize()
})
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize)
if (typeof document.hidden !== 'undefined')
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
- this.$store.dispatch('clearCurrentChat')
},
computed: {
recipient() {
- return this.currentChat && this.currentChat.account
+ return this.chat?.account
},
recipientId() {
return this.$route.params.recipient_id
},
formPlaceholder() {
if (this.recipient) {
return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
} else {
return ''
}
},
- chatMessages() {
- return this.currentChatMessageService?.messages
- },
- newMessageCount() {
- return this.currentChatMessageService?.newMessageCount
- },
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
- ...mapGetters([
- 'currentChat',
- 'currentChatMessageService',
- 'findOpenedChatByRecipientId',
- ]),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
- chatMessages() {
+ messages() {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown()
}
})
},
$route: function () {
this.startFetching()
},
mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
},
},
methods: {
- // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
- onMessageHover({ isHovered, messageChainId }) {
- this.hoveredMessageChainId = isHovered ? messageChainId : undefined
- },
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
})
},
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize(opts = {}) {
const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 && !this.bottomedOut()) {
this.$nextTick(() => {
window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
- readChat() {
- if (
- !(
- this.currentChatMessageService && this.currentChatMessageService.maxId
- )
- ) {
- return
- }
- if (document.hidden) {
+ async readChat() {
+ if (!this.maxId || document.hidden) {
return
}
- const lastReadId = this.currentChatMessageService.maxId
- this.$store.dispatch('readChat', {
- id: this.currentChat.id,
+ const lastReadId = this.maxId
+ const isNewMessage = this.lastReadMessageId !== lastReadId
+
+ if (!isNewMessage) return
+
+ await readChat({
+ id: this.chat.id,
lastReadId,
+ credentials: useOAuthStore().token,
})
+
+ this.$store.commit('readChat', { id: this.chat.id, lastId: lastReadId })
},
bottomedOut(offset) {
return isBottomedOut(offset)
},
reachedTop() {
return window.scrollY <= 0
},
cullOlderCheck() {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
- this.$store.dispatch(
- 'cullOlderMessages',
- this.currentChatMessageService.chatId,
- )
+ const maxIndex = this.messages.length
+ const minIndex = maxIndex - 50
+ if (maxIndex <= 50) return
+
+ this.messages = sortBy(this.messages, ['id'])
+ this.minId = this.messages[minIndex].id
+
+ for (const message of this.messages) {
+ if (message.id < this.minId) {
+ delete this.messagesIndex[message.id]
+ delete this.idempotencyKeyIndex[message.idempotency_key]
+ }
+ }
+
+ this.messages = this.messages.slice(minIndex, maxIndex)
}
}, 5000)
},
handleScroll: throttle(function () {
- this.lastScrollPosition = getScrollPosition()
- if (!this.currentChat) {
+ if (!this.chat) {
return
}
+ this.lastScrollPosition = getScrollPosition()
if (this.reachedTop()) {
- this.fetchChat({ maxId: this.currentChatMessageService.minId })
+ this.fetchChat({ maxId: this.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
+
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
- fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
- const chatMessageService = this.currentChatMessageService
- if (!chatMessageService) {
- return
- }
+ clear() {
+ this.messages = this.messages.filter((m) => m.error)
+ this.messagesIndex = this.messages.reduce(
+ (acc, m) => ({
+ ...acc,
+ [m.id]: m,
+ }),
+ {},
+ )
+ this.newMessageCount = 0
+ this.lastReadMessageId = null
+ this.minId = undefined
+ this.maxId = undefined
+ },
+ async fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
if (fetchLatest && this.streamingEnabled) {
return
}
- const chatId = chatMessageService.chatId
- const fetchOlderMessages = !!maxId
- const sinceId = fetchLatest && chatMessageService.maxId
-
- return chatMessages({
- id: chatId,
+ const { data: messages } = await chatMessages({
+ id: this.chat.id,
maxId,
- sinceId,
+ sinceId: fetchLatest ? this.maxId : null,
credentials: useOAuthStore().token,
- }).then(({ data: messages }) => {
- // Clear the current chat in case we're recovering from a ws connection loss.
- if (isFirstFetch) {
- chatService.clear(chatMessageService)
- }
-
- const positionBeforeUpdate = getScrollPosition()
- this.$store
- .dispatch('addChatMessages', { chatId, messages })
- .then(() => {
- this.$nextTick(() => {
- if (fetchOlderMessages) {
- this.handleScrollUp(positionBeforeUpdate)
- }
-
- // In vertical screens, the first batch of fetched messages may not always take the
- // full height of the scrollable container.
- // If this is the case, we want to fetch the messages until the scrollable container
- // is fully populated so that the user has the ability to scroll up and load the history.
- if (!isScrollable() && messages.length > 0) {
- this.fetchChat({
- maxId: this.currentChatMessageService.minId,
- })
- }
- })
- })
})
+
+ // Clear the current chat in case we're recovering from a ws connection loss.
+ if (isFirstFetch) {
+ this.clear()
+ }
+
+ const positionBeforeUpdate = getScrollPosition()
+ this.addMessages({ messages })
+
+ await nextTick()
+
+ const fetchOlderMessages = !!maxId
+ if (fetchOlderMessages) {
+ this.handleScrollUp(positionBeforeUpdate)
+ }
+
+ // In vertical screens, the first batch of fetched messages may not always take the
+ // full height of the scrollable container.
+ // If this is the case, we want to fetch the messages until the scrollable container
+ // is fully populated so that the user has the ability to scroll up and load the history.
+ if (!isScrollable() && messages.length > 0) {
+ this.fetchChat({
+ maxId: this.minId,
+ })
+ }
},
async startFetching() {
- let chat = this.findOpenedChatByRecipientId(this.recipientId)
- if (!chat) {
- try {
- const { data } = await getOrCreateChat({
- accountId: this.recipientId,
- credentials: useOAuthStore().token,
- })
- chat = data
- } catch (e) {
- console.error('Error creating or getting a chat', e)
- this.errorLoadingChat = true
- }
+ try {
+ const { data } = await getOrCreateChat({
+ accountId: this.recipientId,
+ credentials: useOAuthStore().token,
+ })
+ this.chat = data
+ } catch (e) {
+ console.error('Error creating or getting a chat', e)
+ this.errorLoadingChat = true
}
- if (chat) {
+
+ if (this.chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
- this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}
},
doStartFetching() {
- this.$store.dispatch('startFetchingCurrentChat', {
- fetcher: () =>
- promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
- })
+ this.fetcher = promiseInterval(
+ () => this.fetchChat({ fetchLatest: true }),
+ 5000,
+ )
this.fetchChat({ isFirstFetch: true })
},
+ addMessages({ messages: newMessages }) {
+ for (let i = 0; i < newMessages.length; i++) {
+ const message = newMessages[i]
+
+ // Sanity check
+ if (message.chat_id !== this.chat.id) {
+ console.warn(`Chat message doesn't belong to current chat (id: ${this.chat.id})!!`, message)
+ return
+ }
+
+ // Clear any known pending messages
+ if (message.idempotency_key) {
+ if (this.pendingMessagesIndex[message.idempotencyKeyIndex]) {
+ delete this.pendingMessagesIndex[message.idempotencyKeyIndex]
+ this.pendingMessages = this.pendingMessages.filter(({ idempotency_key }) => idempotency_key !== message.idempotency_key)
+ }
+ }
+
+ if (!this.minId || (!message.pending && message.id < this.minId)) {
+ this.minId = message.id
+ }
+
+ if (!this.maxId || message.id > this.maxId) {
+ this.maxId = message.id
+ }
+
+ if (!this.messagesIndex[message.id] && !isConfirmation(this, message)) {
+ if (this.lastSeenMessageId < message.id) {
+ this.newMessageCount++
+ }
+ this.messagesIndex[message.id] = message
+ this.messages.push(this.messagesIndex[message.id])
+ this.idempotencyKeyIndex[message.idempotency_key] = true
+ }
+ }
+ },
handleAttachmentPosting() {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
this.scrollDown({ forceRead: true })
})
},
- sendMessage({ status, media, idempotencyKey }) {
+ async sendMessage({ status, media, idempotencyKey }) {
const params = {
- id: this.currentChat.id,
+ id: this.chat.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
- chatId: this.currentChat.id,
+ chatId: this.chat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey,
})
- this.$store
- .dispatch('addChatMessages', {
- chatId: this.currentChat.id,
- messages: [fakeMessage],
- })
- .then(() => {
- this.handleAttachmentPosting()
- })
+ this.pendingMessages.push(fakeMessage)
+ this.pendingMessagesIndex[idempotencyKey] = fakeMessage
+
+ this.handleAttachmentPosting()
return this.doSendMessage({
params,
- fakeMessage,
retriesLeft: MAX_RETRIES,
})
},
- doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
+ async doSendMessage({ params, pendingId, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
- sendChatMessage({
- params,
- credentials: useOAuthStore().token,
- })
- .then(({ data }) => {
- this.$store.dispatch('addChatMessages', {
- chatId: this.currentChat.id,
- updateMaxId: false,
- messages: [{ ...data, fakeId: fakeMessage.id }],
- })
+ try {
+ const { data } = await sendChatMessage({
+ ...params,
+ credentials: useOAuthStore().token,
+ })
- return data
+ this.addMessages({
+ messages: [{ ...data }],
})
- .catch((error) => {
- console.error('Error sending message', error)
- this.$store.dispatch('handleMessageError', {
- chatId: this.currentChat.id,
- fakeId: fakeMessage.id,
- isRetry: retriesLeft !== MAX_RETRIES,
- })
- if (
- (error.statusCode >= 500 && error.statusCode < 600) ||
- error.message === 'Failed to fetch'
- ) {
- this.messageRetriers[fakeMessage.id] = setTimeout(
- () => {
- this.doSendMessage({
- params,
- fakeMessage,
- retriesLeft: retriesLeft - 1,
- })
- },
- 1000 * 2 ** (MAX_RETRIES - retriesLeft),
- )
- }
- return {}
+ } catch (error) {
+ if (error.name !== 'StatusCodeError') throw error
+ console.error('Error sending message', error)
+
+ this.handleMessageError({
+ chatId: this.chat.id,
+ isRetry: retriesLeft !== MAX_RETRIES,
})
- return Promise.resolve(fakeMessage)
+ if (
+ (error.statusCode >= 500 && error.statusCode < 600) ||
+ error.message === 'Failed to fetch'
+ ) {
+ this.messageRetriers[fakeMessage.id] = setTimeout(
+ () => {
+ this.doSendMessage({
+ params,
+ fakeMessage,
+ retriesLeft: retriesLeft - 1,
+ })
+ },
+ 1000 * 2 ** (MAX_RETRIES - retriesLeft),
+ )
+ }
+ }
},
goBack() {
this.$router.push({
name: 'chats',
params: { username: this.currentUser.screen_name },
})
},
},
}
export default Chat
diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue
index ccb284cc76..5285bb1d82 100644
--- a/src/components/chat_view/chat_view.vue
+++ b/src/components/chat_view/chat_view.vue
@@ -1,80 +1,81 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading -sticky chat-view-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<div class="title text-center">
<ChatTitle
:user="recipient"
:with-avatar="true"
/>
</div>
</div>
<ChatMessageList
header-date
- :messages="chatMessages"
+ :messages="messages"
+ :pending-messages="pendingMessages"
/>
<div
ref="footer"
class="panel-body footer"
>
<div
class="jump-to-bottom-button"
:class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })"
>
<span>
<FAIcon icon="chevron-down" />
<div
v-if="newMessageCount"
class="badge -notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
</span>
</div>
<PostStatusForm
:disable-subject="true"
:disable-scope-selector="true"
:disable-notice="true"
:disable-lock-warning="true"
:disable-polls="true"
:disable-quotes="true"
:disable-sensitivity-checkbox="true"
- :disable-submit="errorLoadingChat || !currentChat"
+ :disable-submit="errorLoadingChat || !chat"
:disable-preview="true"
:disable-draft="true"
:optimistic-posting="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="1"
max-height="160"
emoji-picker-placement="top"
@resize="handleResize"
/>
</div>
</div>
</div>
</div>
</template>
<script src="./chat_view.js"></script>
<style src="./chat_view.scss" lang="scss" />
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index c014e88721..e45bdadc39 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,622 +1,622 @@
import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
+import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
-import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) =>
status.type === 'retweet' ||
status.id !== statusoid.retweeted_status.id,
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter((_) => _).sort(sortById)
}
const conversation = {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
collapsable: {
// Whether conversation can be collapsed
// i.e. when it's not a page
type: Boolean,
default: false,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
pinnedStatusIdsObject: {
// Used for user profile, map of pinned statuses
type: Object,
default: null,
},
inProfile: {
// Whether conversation is rendered in a user profile
// used for overriding muted status
type: Boolean,
default: false,
},
profileUserId: {
// used with inProfile, user id of the profile
type: String,
default: null,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
data() {
return {
focused: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
}
},
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
return this.mergedConfig.conversationDisplay
},
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isTreeView() {
return this.displayStyle === 'tree'
},
isLinearView() {
return this.displayStyle === 'linear'
},
isChatView() {
return this.displayStyle === 'chat'
},
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
return this.unsuspendibleIds.size > 0
},
hideStatus() {
return this.virtualHidden && this.suspendable
},
status() {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId],
)
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
].reduce((a, b) => a.concat(b), [])
})
.reduce((a, b) => a.concat(b), [])
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
this.getReplies(cur.id)
.map((v) => v.id)
.indexOf(k.id) === -1,
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeFocused() {
return this.isExpanded ? this.focused : null
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setFocused(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation() {
if (this.status) {
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}).then(({ data: { ancestors, descendants } }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setFocused(this.originalStatusId)
})
} else {
this.loadStatusError = null
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
})
.then(({ data: status }) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
console.error(error)
this.loadStatusError = error
})
}
},
getReplies(id) {
return this.replies[id] || []
},
setFocused(id) {
if (!id) return
this.focused = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive() {
this.inlineDivePosition = null
this.setFocused(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `focused` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `focused` watcher.
this.$nextTick(() => {
this.setFocused(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
onStatusSuspendStateChange({ id, suspend }) {
if (!suspend) {
this.unsuspendibleIds.add(id)
} else {
this.unsuspendibleIds.delete(id)
}
},
},
}
export default conversation
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index bb4051628e..7699a9b2ef 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,995 +1,997 @@
import { debounce, map, reject, uniqBy } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Attachment from 'src/components/attachment/attachment.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Gallery from 'src/components/gallery/gallery.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Popover from 'src/components/popover/popover.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: currentUser.id })
const mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
}
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const typeAndRefId = ({ replyTo, profileMention, statusId }) => {
if (replyTo) {
return ['reply', replyTo]
} else if (profileMention) {
return ['mention', profileMention]
} else if (statusId) {
return ['edit', statusId]
} else {
return ['new', '']
}
}
const PostStatusForm = {
props: [
'statusId',
'statusText',
'statusIsSensitive',
'statusPoll',
'statusFiles',
'statusMediaDescriptions',
'statusScope',
'statusContentType',
'replyTo',
'repliedUser',
'attentions',
'copyMessageScope',
'subject',
'disableSubject',
'disableScopeSelector',
'disableVisibilitySelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableQuotes',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'disableDraft',
'hideDraft',
'closeable',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting',
'profileMention',
'draftId',
],
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'close-accepted',
'update',
],
components: {
MediaUpload,
EmojiInput,
PollForm: defineAsyncComponent(
() => import('src/components/poll/poll_form.vue'),
),
QuoteForm: defineAsyncComponent(
() => import('src/components/quote/quote_form.vue'),
),
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus()
}
},
data() {
const preset = this.$route.query.message
let statusText = preset || ''
const { scopeCopy } = useMergedConfigStore().mergedConfig
const [statusType, refId] = typeAndRefId({
replyTo: this.replyTo,
profileMention: this.profileMention && this.repliedUser?.id,
statusId: this.statusId,
})
// If we are starting a new post, do not associate it with old drafts
let statusParams =
!this.disableDraft && (this.draftId || statusType !== 'new')
? this.getDraft(statusType, refId)
: null
if (!statusParams) {
if (statusType === 'reply' || statusType === 'mention') {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString(
{ user: this.repliedUser, attentions: this.attentions },
currentUser,
)
}
const scope =
(this.copyMessageScope && scopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.$store.state.users.currentUser.default_scope
const { postContentType: contentType, sensitiveByDefault } =
useMergedConfigStore().mergedConfig
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: statusText,
nsfw: !!sensitiveByDefault,
files: [],
poll: {},
hasPoll: false,
hasQuote: false,
quote: {
id: '',
url: '',
thread: false,
},
mediaDescriptions: {},
visibility: scope,
contentType,
quoting: false,
}
if (statusType === 'edit') {
const statusContentType = this.statusContentType || contentType
statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '',
status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [],
poll: this.statusPoll || {},
hasPoll: false,
hasQuote: false,
quote: {
id: '',
url: '',
thread: false,
},
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope,
contentType: statusContentType,
}
}
}
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
newStatus: statusParams,
caret: 0,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
saveable: false,
}
},
computed: {
users() {
return this.$store.state.users.users
},
userDefaultScope() {
return this.$store.state.users.currentUser.default_scope
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
statusLength() {
return this.newStatus.status.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
minimalScopesMode() {
return useInstanceStore().minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
isReply() {
return this.newStatus.type === 'reply'
},
quotable() {
return this.quotingAvailable && this.replyTo
},
quoteThreadToggled: {
get() {
return this.newStatus.hasQuote && this.newStatus.quote.thread
},
set(value) {
this.newStatus.hasQuote = value
this.newStatus.quote.thread = value
this.newStatus.quote.id = value ? this.replyTo : ''
},
},
defaultQuotable() {
if (
!this.quotingAvailable ||
!this.isReply ||
!useMergedConfigStore().mergedConfig.quoteReply
) {
return false
}
const repliedStatus =
this.$store.state.statuses.allStatusesObject[this.replyTo]
if (!repliedStatus) {
return false
}
if (
repliedStatus.visibility === 'public' ||
repliedStatus.visibility === 'unlisted' ||
repliedStatus.visibility === 'local'
) {
return true
} else if (repliedStatus.visibility === 'private') {
return repliedStatus.user.id === this.$store.state.users.currentUser.id
}
return false
},
inReplyStatusId() {
return !this.newStatus.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.replyTo
: undefined
},
quoteId() {
return this.newStatus.hasQuote ? this.newStatus.quote.id : undefined
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
pollFormVisible() {
return this.newStatus.hasPoll
},
quoteFormVisible() {
return this.newStatus.hasQuote && !this.newStatus.quote.thread
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote
)
)
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
methods: {
...mapActions(useMediaViewerStore, ['increment']),
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
clearStatus() {
const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
hasPoll: false,
hasQuote: false,
quote: {},
mediaDescriptions: {},
}
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.clearQuoteForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event, newStatus) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.emptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
const poll = newStatus.hasPoll ? pollFormToMasto(newStatus.poll) : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postingOptions = {
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
- postHandler(postingOptions).then((data) => {
- if (!data.error) {
+ postHandler(postingOptions)
+ .then((data) => {
this.abandonDraft()
this.clearStatus()
this.$emit('posted', data)
- } else {
+ })
+ .catch((error) => {
this.error = data.error
- }
- this.posting = false
- })
+ })
+ .finally(() => {
+ this.posting = false
+ })
},
previewStatus() {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster
.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.inReplyStatusId,
quoteId: this.quoteId,
contentType: newStatus.contentType,
poll: {},
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
this.preview = data
})
.catch((error) => {
this.preview = { error }
})
.finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () {
this.previewStatus()
}, 500),
autoPreview() {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview() {
this.preview = null
this.previewLoading = false
},
togglePreview() {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
clearError() {
this.error = null
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
togglePollForm() {
this.newStatus.hasPoll = !this.newStatus.hasPoll
},
setPoll(poll) {
this.newStatus.poll = poll
},
clearPollForm() {
if (this.$refs.pollForm) {
this.$refs.pollForm.clear()
}
},
clearQuoteForm() {
if (this.$refs.quoteForm) {
this.$refs.quoteForm.clear()
}
},
toggleQuoteForm() {
this.newStatus.hasQuote = !this.newStatus.hasQuote
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
},
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)
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft(statusType, refId) {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
statusType,
refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('close-accepted')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('close-accepted')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('close-accepted')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
},
}
export default PostStatusForm
diff --git a/src/components/quick_view_settings/quick_view_settings.js b/src/components/quick_view_settings/quick_view_settings.js
index a6c1b95d07..32d7c5e2a6 100644
--- a/src/components/quick_view_settings/quick_view_settings.js
+++ b/src/components/quick_view_settings/quick_view_settings.js
@@ -1,113 +1,113 @@
import { mapState } from 'pinia'
import Popover from 'src/components/popover/popover.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBars,
faFolderTree,
faList,
- faWrench,
faMessage,
+ faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(faList, faFolderTree, faBars, faWrench, faMessage)
const QuickViewSettings = {
props: {
conversation: Boolean,
},
components: {
Popover,
QuickFilterSettings,
},
methods: {
openTab(tab) {
useInterfaceStore().openSettingsModalTab(tab)
},
},
computed: {
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (state) => state.layoutType === 'mobile',
}),
loggedIn() {
return !!this.$store.state.users.currentUser
},
conversationDisplay: {
get() {
return this.mergedConfig.conversationDisplay
},
set(value) {
useSyncConfigStore().setSimplePrefAndSave({
path: 'conversationDisplay',
value,
})
},
},
autoUpdate: {
get() {
return this.mergedConfig.streaming
},
set() {
const value = !this.autoUpdate
useSyncConfigStore().setSimplePrefAndSave({ path: 'streaming', value })
},
},
collapseWithSubjects: {
get() {
return this.mergedConfig.collapseMessageWithSubject
},
set() {
const value = !this.collapseWithSubjects
useSyncConfigStore().setSimplePrefAndSave({
path: 'collapseMessageWithSubject',
value,
})
},
},
showUserAvatars: {
get() {
return this.mergedConfig.mentionLinkShowAvatar
},
set() {
const value = !this.showUserAvatars
useSyncConfigStore().setSimplePrefAndSave({
path: 'mentionLinkShowAvatar',
value,
})
},
},
muteBotStatuses: {
get() {
return this.mergedConfig.muteBotStatuses
},
set() {
const value = !this.muteBotStatuses
useSyncConfigStore().setSimplePrefAndSave({
path: 'muteBotStatuses',
value,
})
},
},
muteSensitiveStatuses: {
get() {
return this.mergedConfig.muteSensitiveStatuses
},
set() {
const value = !this.muteSensitiveStatuses
useSyncConfigStore().setSimplePrefAndSave({
path: 'muteSensitiveStatuses',
value,
})
},
},
},
}
export default QuickViewSettings
diff --git a/src/modules/chats.js b/src/modules/chats.js
index abc035f092..9bd85dccd4 100644
--- a/src/modules/chats.js
+++ b/src/modules/chats.js
@@ -1,277 +1,173 @@
import { find, omitBy, orderBy, sumBy } from 'lodash'
import { reactive } from 'vue'
import chatService from '../services/chat_service/chat_service.js'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
-import {
- parseChat,
- parseChatMessage,
-} from '../services/entity_normalizer/entity_normalizer.service.js'
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.js'
-import { chats, deleteChatMessage, readChat } from 'src/api/chats.js'
+import { chats, deleteChatMessage } from 'src/api/chats.js'
const emptyChatList = () => ({
data: [],
idStore: {},
})
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
- openedChats: reactive({}),
- openedChatMessageServices: reactive({}),
fetcher: undefined,
currentChatId: null,
lastReadMessageId: null,
}
const getChatById = (state, id) => {
return find(state.chatList.data, { id })
}
const sortedChatList = (state) => {
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
}
const unreadChatCount = (state) => {
return sumBy(state.chatList.data, 'unread')
}
const chatsModule = {
state: { ...defaultState },
getters: {
- currentChat: (state) => state.openedChats[state.currentChatId],
- currentChatMessageService: (state) =>
- state.openedChatMessageServices[state.currentChatId],
- findOpenedChatByRecipientId: (state) => (recipientId) =>
- find(state.openedChats, (c) => c.account.id === recipientId),
sortedChatList,
unreadChatCount,
},
actions: {
// Chat list
startFetchingChats({ dispatch, commit }) {
const fetcher = () => dispatch('fetchChats', { latest: true })
commit('setChatListFetcher', {
fetcher: () => promiseInterval(fetcher, 5000),
})
},
stopFetchingChats({ commit }) {
commit('setChatListFetcher', { fetcher: undefined })
},
fetchChats({ dispatch, rootState }) {
return chats({
credentials: useOAuthStore().token,
- }).then(({ chatList }) => {
- dispatch('addNewChats', { chats: chatList })
+ }).then(({ data }) => {
+ dispatch('addNewChats', { chats: data })
return chats
})
},
addNewChats(store, { chats }) {
const { commit, dispatch, rootGetters } = store
const newChatMessageSideEffects = (chat) => {
maybeShowChatNotification(store, chat)
}
commit(
'addNewUsers',
chats.map((k) => k.account).filter((k) => k),
)
commit('addNewChats', {
dispatch,
chats,
rootGetters,
newChatMessageSideEffects,
})
},
- updateChat({ commit }, { chat }) {
- commit('updateChat', { chat })
- },
// Opened Chats
- startFetchingCurrentChat({ dispatch }, { fetcher }) {
- dispatch('setCurrentChatFetcher', { fetcher })
- },
- setCurrentChatFetcher({ commit }, { fetcher }) {
- commit('setCurrentChatFetcher', { fetcher })
- },
addOpenedChat({ commit, dispatch }, { chat }) {
- commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
+ commit('addOpenedChat', { dispatch, chat })
dispatch('addNewUsers', [chat.account])
},
addChatMessages({ commit }, value) {
commit('addChatMessages', { commit, ...value })
},
- resetChatNewMessageCount({ commit }, value) {
- commit('resetChatNewMessageCount', value)
- },
- clearCurrentChat({ commit }) {
- commit('setCurrentChatId', { chatId: undefined })
- commit('setCurrentChatFetcher', { fetcher: undefined })
- },
- readChat({ rootState, commit, dispatch }, { id, lastReadId }) {
- const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
-
- dispatch('resetChatNewMessageCount')
- commit('readChat', { id, lastReadId })
-
- if (isNewMessage) {
- readChat({
- id,
- lastReadId,
- credentials: useOAuthStore().token,
- })
- }
- },
deleteChatMessage({ rootState, commit }, value) {
deleteChatMessage({
...value,
credentials: useOAuthStore().token,
})
commit('deleteChatMessage', { commit, ...value })
},
resetChats({ commit, dispatch }) {
- dispatch('clearCurrentChat')
commit('resetChats', { commit })
},
clearOpenedChats({ commit }) {
commit('clearOpenedChats', { commit })
},
handleMessageError({ commit }, value) {
commit('handleMessageError', { commit, ...value })
},
- cullOlderMessages({ commit }, chatId) {
- commit('cullOlderMessages', chatId)
- },
},
mutations: {
setChatListFetcher(state, { fetcher }) {
const prevFetcher = state.chatListFetcher
if (prevFetcher) {
prevFetcher.stop()
}
state.chatListFetcher = fetcher && fetcher()
},
- setCurrentChatFetcher(state, { fetcher }) {
- const prevFetcher = state.fetcher
- if (prevFetcher) {
- prevFetcher.stop()
- }
- state.fetcher = fetcher && fetcher()
- },
- addOpenedChat(state, { chat }) {
- state.currentChatId = chat.id
- state.openedChats[chat.id] = chat
-
- if (!state.openedChatMessageServices[chat.id]) {
- state.openedChatMessageServices[chat.id] = chatService.empty(chat.id)
- }
- },
setCurrentChatId(state, { chatId }) {
state.currentChatId = chatId
},
addNewChats(state, { chats, newChatMessageSideEffects }) {
chats.forEach((updatedChat) => {
const chat = getChatById(state, updatedChat.id)
if (chat) {
const isNewMessage =
(chat.lastMessage && chat.lastMessage.id) !==
(updatedChat.lastMessage && updatedChat.lastMessage.id)
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
if (isNewMessage && chat.unread) {
newChatMessageSideEffects(updatedChat)
}
} else {
state.chatList.data.push(updatedChat)
state.chatList.idStore[updatedChat.id] = updatedChat
}
})
},
updateChat(state, { chat: updatedChat }) {
const chat = getChatById(state, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
}
if (!chat) {
state.chatList.data.unshift(updatedChat)
}
state.chatList.idStore[updatedChat.id] = updatedChat
},
deleteChat(state, { id }) {
state.chats.data = state.chats.data.filter(
(conversation) => conversation.last_status.id !== id,
)
state.chats.idStore = omitBy(
state.chats.idStore,
(conversation) => conversation.last_status.id === id,
)
},
resetChats(state, { commit }) {
state.chatList = emptyChatList()
state.currentChatId = null
commit('setChatListFetcher', { fetcher: undefined })
- for (const chatId in state.openedChats) {
- chatService.clear(state.openedChatMessageServices[chatId])
- delete state.openedChats[chatId]
- delete state.openedChatMessageServices[chatId]
- }
},
setChatsLoading(state, { value }) {
state.chats.loading = value
},
- addChatMessages(state, { chatId, messages, updateMaxId }) {
- const chatMessageService = state.openedChatMessageServices[chatId]
- if (chatMessageService) {
- chatService.add(chatMessageService, {
- messages: messages.map(parseChatMessage),
- updateMaxId,
- })
- }
- },
- deleteChatMessage(state, { chatId, messageId }) {
- const chatMessageService = state.openedChatMessageServices[chatId]
- if (chatMessageService) {
- chatService.deleteMessage(chatMessageService, messageId)
- }
- },
- resetChatNewMessageCount(state) {
- const chatMessageService =
- state.openedChatMessageServices[state.currentChatId]
- chatService.resetNewMessageCount(chatMessageService)
- },
- // Used when a connection loss occurs
- clearOpenedChats(state) {
- const currentChatId = state.currentChatId
- for (const chatId in state.openedChats) {
- if (currentChatId !== chatId) {
- chatService.clear(state.openedChatMessageServices[chatId])
- delete state.openedChats[chatId]
- delete state.openedChatMessageServices[chatId]
- }
- }
- },
readChat(state, { id, lastReadId }) {
- state.lastReadMessageId = lastReadId
const chat = getChatById(state, id)
if (chat) {
chat.unread = 0
}
},
- handleMessageError(state, { chatId, fakeId, isRetry }) {
- const chatMessageService = state.openedChatMessageServices[chatId]
- chatService.handleMessageError(chatMessageService, fakeId, isRetry)
- },
- cullOlderMessages(state, chatId) {
- chatService.cullOlderMessages(state.openedChatMessageServices[chatId])
- },
},
}
export default chatsModule
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
index a356350afc..50d069b26b 100644
--- a/src/services/chat_service/chat_service.js
+++ b/src/services/chat_service/chat_service.js
@@ -1,172 +1,61 @@
import { maxBy, minBy, orderBy, sortBy, uniqueId } from 'lodash'
-const empty = (chatId) => {
- return {
- idIndex: {},
- idempotencyKeyIndex: {},
- messages: [],
- newMessageCount: 0,
- lastSeenMessageId: '0',
- chatId,
- minId: undefined,
- maxId: undefined,
- }
-}
-
-const clear = (storage) => {
- const failedMessageIds = []
-
- for (const message of storage.messages) {
- if (message.error) {
- failedMessageIds.push(message.id)
- } else {
- delete storage.idIndex[message.id]
- delete storage.idempotencyKeyIndex[message.idempotency_key]
- }
- }
-
- storage.messages = storage.messages.filter((m) =>
- failedMessageIds.includes(m.id),
- )
- storage.newMessageCount = 0
- storage.lastSeenMessageId = '0'
- storage.minId = undefined
- storage.maxId = undefined
-}
-
const deleteMessage = (storage, messageId) => {
if (!storage) {
return
}
storage.messages = storage.messages.filter((m) => m.id !== messageId)
delete storage.idIndex[messageId]
if (storage.maxId === messageId) {
const lastMessage = maxBy(storage.messages, 'id')
storage.maxId = lastMessage.id
}
if (storage.minId === messageId) {
const firstMessage = minBy(storage.messages, 'id')
storage.minId = firstMessage.id
}
}
-const cullOlderMessages = (storage) => {
- const maxIndex = storage.messages.length
- const minIndex = maxIndex - 50
- if (maxIndex <= 50) return
-
- storage.messages = sortBy(storage.messages, ['id'])
- storage.minId = storage.messages[minIndex].id
- for (const message of storage.messages) {
- if (message.id < storage.minId) {
- delete storage.idIndex[message.id]
- delete storage.idempotencyKeyIndex[message.idempotency_key]
- }
- }
- storage.messages = storage.messages.slice(minIndex, maxIndex)
-}
-
const handleMessageError = (storage, fakeId, isRetry) => {
if (!storage) {
return
}
const fakeMessage = storage.idIndex[fakeId]
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
if (!isRetry) {
// Ensure the failed message doesn't stay at the bottom of the list.
const lastPersistedMessage = orderBy(
storage.messages,
['pending', 'id'],
['asc', 'desc'],
)[0]
if (lastPersistedMessage) {
const oldId = fakeMessage.id
fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
storage.idIndex[fakeMessage.id] = fakeMessage
delete storage.idIndex[oldId]
}
}
}
}
-const add = (storage, { messages: newMessages, updateMaxId = true }) => {
- if (!storage) {
- return
- }
- for (let i = 0; i < newMessages.length; i++) {
- const message = newMessages[i]
-
- // sanity check
- if (message.chat_id !== storage.chatId) {
- return
- }
-
- if (message.fakeId) {
- const fakeMessage = storage.idIndex[message.fakeId]
- if (fakeMessage) {
- // In case the same id exists (chat update before POST response)
- // make sure to remove the older duplicate message.
- if (storage.idIndex[message.id]) {
- delete storage.idIndex[message.id]
- storage.messages = storage.messages.filter(
- (msg) => msg.id !== message.id,
- )
- }
- Object.assign(fakeMessage, message, { error: false })
- delete fakeMessage.fakeId
- storage.idIndex[fakeMessage.id] = fakeMessage
- delete storage.idIndex[message.fakeId]
-
- return
- }
- }
-
- if (!storage.minId || (!message.pending && message.id < storage.minId)) {
- storage.minId = message.id
- }
-
- if (!storage.maxId || message.id > storage.maxId) {
- if (updateMaxId) {
- storage.maxId = message.id
- }
- }
-
- if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
- if (storage.lastSeenMessageId < message.id) {
- storage.newMessageCount++
- }
- storage.idIndex[message.id] = message
- storage.messages.push(storage.idIndex[message.id])
- storage.idempotencyKeyIndex[message.idempotency_key] = true
- }
- }
-}
-
-const isConfirmation = (storage, message) => {
- if (!message.idempotency_key) return
- return storage.idempotencyKeyIndex[message.idempotency_key]
-}
const resetNewMessageCount = (storage) => {
if (!storage) {
return
}
storage.newMessageCount = 0
storage.lastSeenMessageId = storage.maxId
}
const ChatService = {
- add,
- empty,
deleteMessage,
- cullOlderMessages,
resetNewMessageCount,
- clear,
handleMessageError,
}
export default ChatService
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jul 19, 7:00 AM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1695215
Default Alt Text
(108 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment