Page MenuHomePhorge

No OneTemporary

Size
108 KB
Referenced Files
None
Subscribers
None
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

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)

Event Timeline