Merge branch 'chat-refactor' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-07-22 01:24:00 +03:00
commit 6529d09164
78 changed files with 1927 additions and 1439 deletions

View file

@ -3,6 +3,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## 2.11
### Added
- Initial MFM rendering support
- Button to remove all drafts
- Option to remove forced aspect ratio for user profiles (requested)
- Showing user tags (MRF policies for user + custom if present)
- Version information now is also in about page
- Mention autosuggest now sorts by recent activity
- Non-square emoji support (toggleable by user)
- Displaying other user's backgrounds (if supported by BE)
- Add quoting by URL and in replies
- Settings synchronization
- User highlight synchronization
- User administration + post scope/sensitivity admin change support
### Changed
- Migrated to Vite 8 and optimized our imports, more stuff is loaded on-demand, reducing the initial load time and transfer size
- Overall improved spacing in status action buttons and post form
- Logout confirm button is now dangerous
- Reply/quote now is a radio group and wraps, fixes overflow on languages where labels are too wide
- Personal note input is now bigger
- Moved "edit pinned" to the bottom for status action buttons.
- Dots status action button drops down instead of up to avoid hiding the action buttons
- Improved attachment description (alt text) input and display
### Fixed
- Fix HTML attribute parsing for escaped quotes
- Fix emojis breaking user bio/description editing
- Navbar wide logo cropping search input
- Danger buttons being too bright
- User background upload failure no longer breaks new uploads + displays an error
- Importing theme from old theme editor
- Removed duplicate federationpolicy entry in admin tab
- Repeater name overflowing content
- Reply popover is now shown if replied-to status is muted
- Second language input not having header
- Post form's bottom left buttons not showing their toggled state
- Some font overrides not working
- Popovers opening outside of window's boundaries
- Occasional blank page when showing new posts
- Fixed status action mute hiding itself on click
- Fix reply form crash when quote-reply settings are unavailable
## 2.10.1
### Fixed
- fixed being unable to set actor type from profile page

View file

@ -1 +0,0 @@
Fix HTML attribute parsing for escaped quotes

View file

@ -1 +0,0 @@
Migrated to Vite 8 and optimized our imports, more stuff is loaded on-demand, reducing the initial load time and transfer size

View file

@ -1 +0,0 @@
Fix emojis breaking user bio/description editing

View file

@ -1 +0,0 @@
Initial MFM rendering support

View file

@ -1,6 +0,0 @@
button to remove all drafts
option to remove forced aspect ratio for user profiles (requested)
showing user tags (mrf policies for user + custom if present)
version information now is also in about page
mention autosuggest now sorts by recent activity
non-square emoji support (toggleable by user)

View file

@ -1,7 +0,0 @@
overall improved spacings in status action buttons and post form
logout confirm button is now dangerous
reply/quote now is a radio group and wraps, fixes overflow on languages where labels are too wide
personal note input is now bigger
moved "edit pinned" to the bottom for status action buttons.
dots status action button drops down instead of up to avoid hiding the action buttons
improved attachment description (alt text) input and display

View file

@ -1,12 +0,0 @@
navbar wide logo cropping search input
danger buttons being too bright
user background upload failure no longer breaks new uploads + displays an error
importing theme from old theme editor
removed duplicate federationpolicy entry in admin tab
repeater name overflowing content
reply popover is now shown if replied-to status is muted
second language input not having header
post form's bottom left buttons not showing their toggled state
some font overrides not working
popovers opening outside of window's boundaries
occasional blank page when showing new posts

View file

@ -1 +0,0 @@
Fixed status action mute hiding itself on click

View file

@ -1 +0,0 @@
displaying other user's backgrounds (if supported by BE)

View file

@ -1 +0,0 @@
Add quoting by URL and in replies

View file

@ -1 +0,0 @@
Fix reply form crash when quote-reply settings are unavailable

View file

@ -1,2 +0,0 @@
settings synchronization
user highlight synchronization

View file

@ -1 +0,0 @@
User administration + post scope/sensitivity admin change support

View file

@ -1,6 +1,6 @@
{
"name": "pleroma_fe",
"version": "2.10.1",
"version": "2.11.0",
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/src/CONTRIBUTORS.md>",
"private": false,

View file

@ -1,6 +1,9 @@
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}`
@ -15,7 +18,7 @@ export const chats = ({ credentials }) =>
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 }) =>
@ -23,7 +26,7 @@ export const getOrCreateChat = ({ accountId, credentials }) =>
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
})
}).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
@ -36,7 +39,9 @@ export const chatMessages = ({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
})
}).then(({ data }) => ({
data: data.map(parseChatMessage).filter((c) => c),
}))
}
export const sendChatMessage = ({
@ -66,7 +71,9 @@ export const sendChatMessage = ({
payload,
credentials,
headers,
})
}).then(({ data }) => ({
data: parseChatMessage(data),
}))
}
export const readChat = ({ id, lastReadId, credentials }) =>

View file

@ -293,7 +293,7 @@ export default (store) => {
name: 'chat',
path: '/users/:username/chats/:recipient_id',
component: defineAsyncComponent(
() => import('src/components/chat/chat.vue'),
() => import('src/components/chat_view/chat_view.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,

View file

@ -1,9 +1,12 @@
import { mapGetters, mapState } from 'vuex'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatListItem from 'src/components/chat_list_item/chat_list_item.vue'
import ChatNew from 'src/components/chat_new/chat_new.vue'
import List from 'src/components/list/list.vue'
import { useChatsStore } from 'src/stores/chats.js'
const ChatList = {
components: {
ChatListItem,
@ -14,7 +17,7 @@ const ChatList = {
...mapState({
currentUser: (state) => state.users.currentUser,
}),
...mapGetters(['sortedChatList']),
...mapPiniaState(useChatsStore, ['sortedChatList']),
},
data() {
return {
@ -22,12 +25,12 @@ const ChatList = {
}
},
created() {
this.$store.dispatch('fetchChats', { latest: true })
useChatsStore().fetchChats()
},
methods: {
cancelNewChat() {
this.isNew = false
this.$store.dispatch('fetchChats', { latest: true })
useChatsStore().fetchChats()
},
newChat() {
this.isNew = true

View file

@ -1,3 +1,4 @@
import { find } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
@ -6,6 +7,8 @@ import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.
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 StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusBody from 'src/components/status_body/status_body.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
@ -15,24 +18,31 @@ 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 {
faCircleNotch,
faEllipsisH,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
library.add(faTimes, faEllipsisH, faCircleNotch)
const ChatMessage = {
name: 'ChatMessage',
props: [
'author',
'edited',
'noHeading',
'chatViewItem',
'previousItem',
'chatItem',
'previousItem',
'hoveredMessageChain',
],
emits: ['hover'],
emits: ['hover', 'replyRequested'],
components: {
Popover,
Attachment,
StatusContent,
StatusBody,
StatusActionButtons,
UserAvatar,
Gallery,
LinkPreview,
@ -42,27 +52,48 @@ const ChatMessage = {
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt() {
const time = this.chatViewItem.data.created_at
const time = this.chatItem.data.created_at
return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
isStatus() {
// ChatMessage only has account_id while Status has full user data
return !!this.message.user
},
author() {
const accountId = this.message.account_id || this.message.user.id
return this.$store.getters.findUser(accountId)
},
isCurrentUser() {
return this.message.account_id === this.currentUser.id
return this.author.id === this.currentUser.id
},
message() {
return this.chatViewItem.data
return this.isMessage ? this.chatItem.data : null
},
isMessage() {
return this.chatViewItem.type === 'message'
return this.chatItem.type === 'message'
},
isCustomReply() {
if (!this.previousItem) return false
if (!this.chatItem.data.in_reply_to_status_id) return false
return (
this.previousItem.data.id !== this.chatItem.data.in_reply_to_status_id
)
},
customReplyTo() {
return this.$store.state.statuses.allStatusesObject[
this.chatItem.data.in_reply_to_status_id
]
},
messageForStatusContent() {
return {
summary: '',
emojis: this.message.emojis,
raw_html: this.message.content || '',
raw_html: this.message.content || this.message.raw_html || '',
text: this.message.content || '',
attachments: this.message.attachments,
}
@ -96,15 +127,32 @@ const ChatMessage = {
onHover(bool) {
this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatViewItem.messageChainId,
messageChainId: this.chatItem.messageChainId,
})
},
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id,
await this.$emit('delete', {
messageId: this.chatItem.data.id,
chatId: this.chatItem.data.chat_id,
})
}
this.hovered = false

View file

@ -11,29 +11,59 @@
}
}
.chat-message-menu {
.chat-message-toolbar {
transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -0.8em;
right: 0.4rem;
z-index: 10;
button {
.quick-action-buttons {
justify-items: end;
grid-template-columns: auto auto auto;
}
.simple-button {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
&.-visible {
opacity: 1;
}
}
.menu-icon {
cursor: pointer;
}
.reply-to-header {
white-space: nowrap;
width: 100%;
display: flex;
align-items: baseline;
gap: 0.5em;
.reply-label {
display: inline-block;
line-height: 1;
}
.reply-body {
line-height: 1;
display: inline-block;
overflow-x: hidden;
text-overflow: ellipsis;
}
}
.popover {
width: 12em;
}
.chat-message {
display: flex;
padding-bottom: 0.5em;
.status-body:hover {
--_still-image-img-visibility: visible;
@ -67,7 +97,6 @@
font-size: 0.8em;
margin: -1em 0 -0.5em;
font-style: italic;
opacity: 0.8;
}
.without-attachment {
@ -104,7 +133,7 @@
width: 100%;
}
.outgoing {
.-outgoing {
display: flex;
flex-flow: row wrap;
place-content: end flex-end;
@ -113,14 +142,8 @@
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
}
}
.incoming {
.chat-message-menu {
left: 0.4rem;
.reply-to-header {
justify-content: end;
}
}
@ -139,8 +162,8 @@
.chat-message-date-separator {
text-align: center;
margin: 1.4em 0;
font-size: 0.9em;
line-height: 2;
user-select: none;
color: var(--textFaint);
}

View file

@ -8,14 +8,14 @@
>
<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="chatViewItem.isHead"
v-if="chatItem.isHead"
:user-id="author.id"
>
<UserAvatar
@ -25,26 +25,59 @@
</UserPopover>
</div>
<div class="chat-message-inner">
<small
v-if="isStatus && isCustomReply"
class="reply-to-header faint"
>
<strong class="reply-label">
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="reply"
flip="horizontal"
/>
{{ $t('status.reply_to') }}
</strong>
<!-- v-if is there because status might not be loaded yet -->
<StatusBody
v-if="customReplyTo"
class="reply-body"
:status="customReplyTo"
collapse
single-line
/>
</small>
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<StatusActionButtons
v-if="isStatus"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
:status="message"
:pinned="new Set(['reply', 'emoji'])"
fixed-pinned
use-default-buttons
hide-labels
@toggle-replying="$emit('replyRequested', message)"
/>
<div
class="chat-message-menu"
:class="{ 'visible': hovered || menuOpened }"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
v-else
>
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
bound-to-selector=".chat-view-inner"
:bound-to="{ x: 'container' }"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
@ -62,17 +95,13 @@
</div>
</template>
<template #trigger>
<button
class="button-default menu-icon"
:title="$t('chats.more')"
>
<FAIcon icon="ellipsis-h" />
</button>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
@ -80,6 +109,27 @@
<span
class="created-at"
>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ createdAt }}
</span>
</template>
@ -93,7 +143,7 @@
v-else
class="chat-message-date-separator"
>
<ChatMessageDate :date="chatViewItem.date" />
<ChatMessageDate :date="chatItem.date" :show-time="chatItem.isTime" />
</div>
</template>

View file

@ -5,12 +5,17 @@
</template>
<script>
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import localeService from 'src/services/locale/locale.service.js'
export default {
name: 'Timeago',
props: ['date'],
props: ['date', 'showTime'],
computed: {
time12hFormat() {
return useMergedConfigStore().mergedConfig.absoluteTimeFormat12h === '12h'
},
displayDate() {
const today = new Date()
today.setHours(0, 0, 0, 0)
@ -18,10 +23,17 @@ export default {
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
return this.date.toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ day: 'numeric', month: 'long' },
)
if (this.showTime) {
return this.date.toLocaleTimeString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' },
)
} else {
return this.date.toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ day: 'numeric', month: 'long' },
)
}
}
},
},

View file

@ -0,0 +1,133 @@
import { orderBy, uniqueId } from 'lodash'
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,
}
},
emits: ['messageDelete', 'replyRequested'],
computed: {
chatItems() {
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 newerItem = acc[acc.length - 1]
const diff = olderMessage
? message.created_at - olderMessage.created_at
: null
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)
newerDate.setHours(0, 0, 0, 0)
olderDate.setHours(0, 0, 0, 0)
return newerDate.toISOString() !== olderDate.toISOString()
} else {
return 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') {
chatItem.messageChainId = uniqueId()
} else if (newerItem.type === 'message') {
const newerUser =
newerItem.data.account_id || newerItem.data.user.id
const olderUser = message.account_id || message.user.id
if (newerUser !== olderUser) {
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()
},
},
methods: {
onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
onMessageDelete({ messageId, chatId }) {
this.$emit('messageDelete', { messageId, chatId })
},
onReplyRequested(message) {
this.$emit('replyRequested', message)
},
getPreviousItem(index) {
let result = null
this.chatItems
.slice(0, index)
.reverse()
.some((item) => {
const isMessage = item.type === 'message'
if (isMessage) {
result = item
}
return isMessage
})
return result
},
},
}
export default ChatMessageList

View file

@ -0,0 +1,7 @@
.ChatMessageList {
padding: 0.5em;
display: flex;
gap: 0.5em;
flex-direction: column;
justify-content: end;
}

View file

@ -1,10 +1,11 @@
export default {
name: 'Chat',
selector: '.chat-message-list',
selector: '.ChatMessageList',
validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
defaultRules: [
{
directives: {
backgroundNoCssColor: 'yes',
background: '--bg',
blur: '5px',
},

View file

@ -0,0 +1,17 @@
<template>
<div class="ChatMessageList">
<ChatMessage
v-for="(chatItem, index) in chatItems"
:key="chatItem.id"
:chat-item="chatItem"
:previous-item="getPreviousItem(index)"
:hovered-message-chain="chatItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
@delete="onMessageDelete"
@reply-requested="onReplyRequested"
/>
</div>
</template>
<script src="./chat_message_list.js"></script>
<style src="./chat_message_list.scss" lang="scss" />

View file

@ -26,10 +26,10 @@ const chatNew = {
}
},
async created() {
const { chatList } = await chats({
const { data } = await chats({
credentials: useOAuthStore().token,
})
chatList.forEach((chat) => this.suggestions.push(chat.account))
data.forEach((chat) => this.suggestions.push(chat.account))
},
computed: {
users() {

View file

@ -1,11 +1,11 @@
import { throttle } from 'lodash'
import { maxBy, minBy, sortBy, throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import { nextTick } from 'vue'
import { mapState } from 'vuex'
import ChatMessage from 'src/components/chat_message/chat_message.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 {
@ -15,13 +15,16 @@ import {
isScrollable,
} from './chat_layout_utils.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
chatMessages,
deleteChatMessage,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
@ -37,23 +40,46 @@ 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: {
ChatMessage,
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
testMode: Boolean,
},
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() {
if (this.testMode) return
this.startFetching()
window.addEventListener('resize', this.handleResize)
},
@ -80,11 +106,10 @@ const Chat = {
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
@ -98,37 +123,23 @@ const Chat = {
return ''
}
},
chatViewItems() {
return chatService.getView(this.currentChatMessageService)
},
newMessageCount() {
return (
this.currentChatMessageService &&
this.currentChatMessageService.newMessageCount
)
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
]),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
chatViewItems() {
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)
@ -148,10 +159,6 @@ const Chat = {
},
},
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()
@ -198,22 +205,25 @@ const Chat = {
this.readChat()
}
},
readChat() {
if (
!(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
async readChat() {
if (!this.maxId || document.hidden) {
return
}
if (document.hidden) {
return
}
const lastReadId = this.currentChatMessageService.maxId
this.$store.dispatch('readChat', {
id: this.currentChat.id,
lastReadId,
})
const lastReadId = this.maxId
const isNewMessage = this.lastReadMessageId !== lastReadId
if (!isNewMessage) return
if (!this.testMode)
await readChat({
id: this.chat.id,
lastReadId,
credentials: useOAuthStore().token,
})
useChatsStore().readChat(this.chat.id)
this.lastReadMessageId = this.maxId
this.newMessageCount = 0
},
bottomedOut(offset) {
return isBottomedOut(offset)
@ -221,24 +231,38 @@ const Chat = {
reachedTop() {
return window.scrollY <= 0
},
cullOlder() {
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)
},
cullOlderCheck() {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch(
'cullOlderMessages',
this.currentChatMessageService.chatId,
)
this.cullOlder()
}
}, 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()
@ -257,85 +281,151 @@ const Chat = {
}, 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 })
},
async deleteChatMessage({ chatId, messageId }) {
if (!this.testMode)
await deleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
this.messages = this.messages.filter((m) => m.id !== messageId)
delete this.messagesIndex[messageId]
if (this.maxId === messageId) {
const lastMessage = maxBy(this.messages, 'id')
this.maxId = lastMessage.id
}
if (this.minId === messageId) {
const firstMessage = minBy(this.messages, 'id')
this.minId = firstMessage.id
}
},
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.lastReadMessageId < 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()
@ -344,9 +434,9 @@ const Chat = {
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,
}
@ -357,69 +447,71 @@ const Chat = {
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, 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 }],
})
return 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 {}
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
return Promise.resolve(fakeMessage)
this.addMessages({
messages: [{ ...data }],
})
} catch (error) {
if (
error.name !== 'StatusCodeError' ||
error.message === 'Failed to fetch'
)
throw error
console.error('Error sending message', error)
this.handleMessageError({
chatId: this.chat.id,
idempotencyKey: params.idempotencyKey,
isRetry: retriesLeft !== MAX_RETRIES,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[params.idempotencyKey] = setTimeout(
() => {
this.doSendMessage({
params,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
}
},
handleMessageError(idempotencyKey, isRetry) {
const fakeMessage = this.pendingMessagesIndex[idempotencyKey]
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
}
},
goBack() {
this.$router.push({

View file

@ -25,29 +25,12 @@
/>
</div>
</div>
<div
class="chat-message-list message-list"
:style="{ height: scrollableContainerHeight }"
>
<template v-if="!errorLoadingChat">
<ChatMessage
v-for="chatViewItem in chatViewItems"
:key="chatViewItem.id"
:author="recipient"
:chat-view-item="chatViewItem"
:hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</template>
<div
v-else
class="chat-loading-error"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
</div>
</div>
</div>
<ChatMessageList
header-date
:messages="messages"
:pending-messages="pendingMessages"
@message-delete="deleteChatMessage"
/>
<div
ref="footer"
class="panel-body footer"
@ -75,7 +58,7 @@
: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"
@ -95,5 +78,5 @@
</div>
</template>
<script src="./chat.js"></script>
<style src="./chat.scss" lang="scss" />
<script src="./chat_view.js"></script>
<style src="./chat_view.scss" lang="scss" />

View file

@ -2,8 +2,11 @@ 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 PostStatusForm from 'src/components/post_status_form/post_status_form.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 StatusContent from 'src/components/status_content/status_content.vue'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface'
@ -18,9 +21,17 @@ import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@ -103,6 +114,7 @@ const conversation = {
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
explicitReplyStatus: null,
}
},
created() {
@ -118,6 +130,12 @@ const conversation = {
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
lastStatus() {
return this.conversation[this.conversation.length - 1]
},
replyStatus() {
return this.explicitReplyStatus ?? this.lastStatus
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
@ -127,15 +145,18 @@ const conversation = {
displayStyle() {
return this.mergedConfig.conversationDisplay
},
isTreeView() {
return !this.isLinearView
},
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
},
@ -404,6 +425,9 @@ const conversation = {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
StatusContent,
},
watch: {
statusId(newVal, oldVal) {

View file

@ -0,0 +1,123 @@
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
.conversation-dive-to-top-level-box {
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
.conversation-status {
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
&:last-child:not(.-expanded) .conversation-status,
&.-expanded .conversation-status:last-child,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
&.-expanded.status-fadein {
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
.auto-reply-to-section {
margin: 0 1em;
gap: 0.5em;
h4 {
margin: 0.5em 0;
line-height: 1.5;
button {
line-height: 1.5;
}
}
.reply-to-preview {
display: flex;
align-items: center;
gap: 0.5em;
}
}
.chat-view-reply-form {
position: sticky;
display: flex;
flex-direction: column;
align-items: stretch;
bottom: 0;
}
}

View file

@ -170,7 +170,7 @@
/>
</div>
<div
v-if="isLinearView"
v-else-if="isLinearView || (isChatView && !isExpanded)"
class="thread-body"
>
<article>
@ -196,6 +196,58 @@
/>
</article>
</div>
<div
v-else-if="isChatView"
class="chat-view"
>
<ChatMessageList
:messages="conversation"
@reply-requested="e => explicitReplyStatus = e"
/>
</div>
</div>
<div
v-if="isChatView && isExpanded && replyStatus"
class="chat-view-reply-form panel-footer -flexible-height"
>
<div class="auto-reply-to-section">
<h4
class="reply-to-text"
>
{{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
v-if="explicitReplyStatus"
class="button-default"
@click="explicitReplyStatus = null"
>
<FAIcon icon="times" />
{{ $t('general.cancel') }}
</button>
</h4>
<div class="reply-to-preview">
<FAIcon
icon="reply"
flip="horizontal"
/>
<StatusContent
:status="replyStatus"
compact
collapse
/>
</div>
</div>
<PostStatusForm
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:reply-to="replyStatus.id"
:disable-quotes="true"
:copy-message-scope="replyStatus.visibility"
:attentions="replyStatus.attentions"
:replied-user="replyStatus.user"
mentions-line
mentions-line-read-only
/>
</div>
</div>
<div
@ -206,101 +258,4 @@
</template>
<script src="./conversation.js"></script>
<style lang="scss">
.Conversation {
z-index: 1;
&.-hidden {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
.conversation-dive-to-top-level-box {
padding: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
display: flex;
align-items: stretch;
flex-direction: column;
}
.thread-ancestors {
margin-left: var(--status-margin);
border-left: 2px solid var(--border);
}
.thread-ancestor.-faded .RichContent {
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
}
.thread-ancestor-dive-box {
padding-left: var(--status-margin);
border-bottom: 1px solid var(--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
display: flex;
align-items: stretch;
flex-direction: column;
}
}
.thread-ancestor-dive-box-inner {
padding: var(--status-margin);
}
.conversation-status {
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.thread-ancestor-has-other-replies .conversation-status,
&:last-child:not(.-expanded) .conversation-status,
&.-expanded .conversation-status:last-child,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
.thread-ancestors + .thread-tree > .conversation-status {
border-top: 1px solid var(--border);
}
/* expanded conversation in timeline */
&.status-fadein.-expanded .thread-body {
border-left: 4px solid var(--cRed);
border-radius: var(--roundness);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 1px solid var(--border);
}
&.-expanded.status-fadein {
--___margin: calc(var(--status-margin) / 2);
background: var(--background);
margin: var(--___margin);
&::before {
z-index: -1;
content: "";
display: block;
position: absolute;
inset: calc(var(--___margin) * -1);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
}
}
}
</style>
<style src="./conversation.scss" />

View file

@ -1,7 +1,8 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'pinia'
import { mapGetters } from 'vuex'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
@ -21,7 +22,7 @@ const ExtraNotifications = {
return (
this.mergedConfig.showExtraNotifications &&
this.mergedConfig.showChatsInExtraNotifications &&
this.unreadChatCount
this.unreadChatsCount
)
},
shouldShowAnnouncements() {
@ -53,11 +54,12 @@ const ExtraNotifications = {
currentUser() {
return this.$store.state.users.currentUser
},
...mapGetters(['unreadChatCount', 'followRequestCount']),
...mapPiniaState(useAnnouncementsStore, {
...mapGetters(['followRequestCount']),
...mapState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useChatsStore, ['unreadChatsCount']),
},
methods: {
openNotificationSettings() {

View file

@ -14,7 +14,7 @@
class="fa-scale-110 icon"
icon="comments"
/>
{{ $t('notifications.unread_chats', { num: unreadChatCount }, unreadChatCount) }}
{{ $t('notifications.unread_chats', { num: unreadChatsCount }, unreadChatsCount) }}
</router-link>
</div>
<div

View file

@ -1,6 +1,5 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { mapGetters } from 'vuex'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import GestureService from '../../services/gesture_service/gesture_service'
@ -10,6 +9,7 @@ import {
} from '../../services/notification_utils/notification_utils'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
@ -68,6 +68,7 @@ const MobileNav = {
countExtraNotifications(
this.$store,
useMergedConfigStore().mergedConfig,
useChatsStore().unreadChatsCount,
useAnnouncementsStore().unreadAnnouncementCount,
)
)
@ -87,18 +88,18 @@ const MobileNav = {
isChat() {
return this.$route.name === 'chat'
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useMergedConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
}),
shouldConfirmLogout() {
return useMergedConfigStore().mergedConfig.modalOnLogout
},
closingDrawerMarksAsSeen() {
return useMergedConfigStore().mergedConfig.closingDrawerMarksAsSeen
},
...mapGetters(['unreadChatCount']),
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useMergedConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
}),
...mapState(useChatsStore, ['unreadChatsCount']),
},
methods: {
toggleMobileSidebar() {

View file

@ -19,7 +19,7 @@
icon="bars"
/>
<div
v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount"
v-if="(unreadChatsCount && !chatsPinned) || unreadAnnouncementCount"
class="badge -dot -notification"
/>
</button>

View file

@ -1,5 +1,5 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import { mapState } from 'vuex'
import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
@ -10,6 +10,7 @@ import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
@ -131,6 +132,7 @@ const NavPanel = {
currentUser: (state) => state.users.currentUser,
followRequestCount: (state) => state.api.followRequests.length,
}),
...mapPiniaState(useChatsStore, ['unreadChatsCount']),
timelinesItems() {
return filterNavigation(
Object.entries({ ...TIMELINES })
@ -162,7 +164,6 @@ const NavPanel = {
},
)
},
...mapGetters(['unreadChatCount']),
},
}

View file

@ -76,7 +76,7 @@ export const ROOT_ITEMS = {
icon: 'comments',
label: 'nav.chats',
badgeStyle: 'notification',
badgeGetter: 'unreadChatCount',
badgeGetter: 'unreadChatsCount',
criteria: ['chats'],
},
friendRequests: {

View file

@ -1,6 +1,5 @@
import { mapState } from 'pinia'
import { computed } from 'vue'
import { mapGetters } from 'vuex'
import ExtraNotifications from 'src/components/extra_notifications/extra_notifications.vue'
import Notification from 'src/components/notification/notification.vue'
@ -16,6 +15,7 @@ import notificationsFetcher from '../../services/notifications_fetcher/notificat
import NotificationFilters from './notification_filters.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
@ -115,13 +115,14 @@ const Notifications = {
return countExtraNotifications(
this.$store,
useMergedConfigStore().mergedConfig,
useChatsStore().unreadChatsCount,
useAnnouncementsStore().unreadAnnouncementCount,
)
},
unseenCountTitle() {
return (
this.unseenNotifications.length +
this.unreadChatCount +
this.unreadChatsCount +
this.unreadAnnouncementCount
)
},
@ -160,7 +161,7 @@ const Notifications = {
return !this.noExtra
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapGetters(['unreadChatCount']),
...mapState(useChatsStore, ['unreadChatsCount']),
},
mounted() {
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')

View file

@ -16,42 +16,45 @@ export default {
},
name: 'PollForm',
props: {
visible: {},
params: {
visible: Boolean,
modelValue: {
type: Object,
required: true,
required: false,
default: null,
},
},
emits: ['update:modelValue'],
computed: {
pollType: {
get() {
return pollFallback(this.params, 'pollType')
return pollFallback(this.modelValue, 'pollType')
},
set(newVal) {
this.params.pollType = newVal
this.$emit('update:modelValue', { ...this.modelValue, pollType: newVal })
},
},
options() {
const hasOptions = !!this.params.options
if (!hasOptions) {
this.params.options = pollFallback(this.params, 'options')
options: {
get() {
return pollFallback(this.modelValue, 'options')
},
set(newVal) {
this.$emit('update:modelValue', { ...this.modelValue, options: newVal })
}
return this.params.options
},
expiryAmount: {
get() {
return pollFallback(this.params, 'expiryAmount')
return pollFallback(this.modelValue, 'expiryAmount')
},
set(newVal) {
this.params.expiryAmount = newVal
this.$emit('update:modelValue', { ...this.modelValue, expiryAmount: newVal })
},
},
expiryUnit: {
get() {
return pollFallback(this.params, 'expiryUnit')
return pollFallback(this.modelValue, 'expiryUnit')
},
set(newVal) {
this.params.expiryUnit = newVal
this.$emit('update:modelValue', { ...this.modelValue, expiryUnit: newVal })
},
},
pollLimits() {
@ -88,12 +91,6 @@ export default {
},
},
methods: {
clear() {
this.pollType = 'single'
this.options = ['', '']
this.expiryAmount = 10
this.expiryUnit = 'minutes'
},
nextOption(index) {
const element = this.$el.querySelector(`#poll-${index + 1}`)
if (element) {
@ -110,7 +107,7 @@ export default {
},
addOption() {
if (this.options.length < this.maxOptions) {
this.options.push('')
this.options = [...this.options, '']
return true
}
return false
@ -118,8 +115,12 @@ export default {
deleteOption(index) {
if (this.options.length > 2) {
this.options.splice(index, 1)
this.options = this.options
}
},
updateOption(index, value) {
this.options = this.options
},
convertExpiryToUnit(unit, amount) {
// Note: we want seconds and not milliseconds
return DateUtils.secondsToUnit(unit, amount)

View file

@ -18,6 +18,7 @@
:placeholder="$t('polls.option')"
:maxlength="maxLength"
@keydown.enter.stop.prevent="nextOption(index)"
@change="updateOption"
>
</div>
<button

View file

@ -1,4 +1,4 @@
import { debounce, map, reject, uniqBy } from 'lodash'
import { debounce, reject, uniqBy } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
@ -55,78 +55,66 @@ library.add(
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 DEFAULT_NEWSTATUS = {
}
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',
],
props: {
// Status editing stuff
statusId: String,
statusText: String,
statusIsSensitive: {
type: Boolean,
required: false,
default: null, // Avoiding automatic conversion null -> false
},
statusPoll: Object,
statusQuote: Object,
statusFiles: Array,
statusMediaDescriptions: Object,
statusScope: String,
statusContentType: String,
// Replies/mentions
replyTo: String,
repliedUser: Object,
mentionsLine: Boolean,
mentionsLineReadOnly: Boolean,
attentions: Array,
subject: String,
copyMessageScope: String,
profileMention: String,
// Draft stuff
hideDraft: Boolean,
closeable: Boolean,
draftId: String,
// Chats stuff
maxHeight: Number,
placeholder: String,
postHandler: Function,
preserveFocus: Boolean,
autoFocus: Boolean,
fileLimit: Number,
submitOnEnter: Boolean,
emojiPickerPlacement: String,
optimisticPosting: Boolean,
// Feature toggles for special cases (mostly chats)
disableSubject: Boolean,
disableScopeSelector: Boolean,
disableVisibilitySelector: Boolean,
disableNotice: Boolean,
disableLockWarning: Boolean,
disablePolls: Boolean,
disableQuotes: Boolean,
disableSensitivityCheckbox: Boolean,
disableSubmit: Boolean,
disablePreview: Boolean,
disableDraft: Boolean,
},
emits: [
'posted',
'draft-done',
@ -136,6 +124,41 @@ const PostStatusForm = {
'close-accepted',
'update',
],
data() {
return {
randomSeed: genRandomSeed(),
dropFiles: [],
uploadingFiles: false,
error: null,
posting: false,
highlighted: 0,
initialized: false,
// Data is initialized first, but we have no access to .computed
// so we pre-fill with stuff meant for status editing and later
// back-fill with defaults in .created()
newStatus: {
status: this.statusText ?? null,
mentions: this.statusMentionLine ?? null,
spoilerText: this.subject ?? null,
quote: this.statusQuote ?? null,
files: this.statusFiles ?? null,
poll: this.statusPoll ?? null,
mediaDescriptions: this.statusMediaDescriptions ?? null,
nsfw: this.statusIsSensitive ?? null,
visibility: this.statusVisibility ?? null,
contentType: this.statusContentType ?? null,
},
caret: 0,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: '',
saveInhibited: true,
saveable: false,
}
},
components: {
MediaUpload,
EmojiInput,
@ -154,6 +177,53 @@ const PostStatusForm = {
DraftCloser,
Popover,
},
created() {
// If we are starting a new post, do not associate it with old drafts
const draft = !this.disableDraft && (this.draftId || this.statusType !== 'new')
? this.getDraft(this.statusType, this.refId)
: null
if (draft) {
// Copying and overriding defaults from the draft for each field
Object.keys(this.newStatus).forEach((key) => {
this.newStatus[key] = draft[key] ?? this.newStatus[key]
})
} else {
const defaultNewStatus = {
spoilerText: '',
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope =
(this.copyMessageScope && this.userDefaultScopeCopy) ||
this.copyMessageScope === 'direct'
? this.copyMessageScope
: this.userDefaultScope
const preset = this.$route.query.message
let statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
defaultNewStatus.mentions = this.mentionsString.trim()
} else {
defaultNewStatus.status = this.mentionsString + statusText
defaultNewStatus.mentions = ''
}
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
Object.entries(defaultNewStatus).forEach(([key, value]) => {
this.newStatus[key] = this.newStatus[key] ?? value
})
}
this.initialized = true
},
mounted() {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea)
@ -167,229 +237,84 @@ const PostStatusForm = {
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
// Visibility / expansion state of subcomponents
pollFormVisible() {
return this.hasPoll
},
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
)
quoteFormVisible() {
return this.hasQuote && !this.newStatus.quote.thread
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
// Composition stuff
statusType() {
if (this.replyTo) {
return 'reply'
} else if (this.profileMention && this.repliedUser?.id) {
return 'mention'
} else if (this.statusId) {
return 'edit'
} else {
return 'new'
}
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
refId() {
if (this.replyTo) {
return this.replyTo
} else if (profileMention) {
return this.profileMention && this.repliedUser?.id
} else if (statusId) {
return this.statusId
} else {
return null
}
},
mentionsString() {
if (this.statusType !== 'reply' && this.statusType !== 'mention') return ''
let allAttentions = [...(this.attentions || [])]
allAttentions.unshift(this.repliedUser)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: this.currentUser.id })
const mentions = allAttentions.map((attention) => `@${attention.screen_name}`)
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
},
newStatusContent() {
return this.mentionsLine
? this.mentionsString + this.newStatus.status
: this.newStatus.status
},
isEdit() {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
// -Reply
isReply() {
return this.newStatus.type === 'reply'
return this.statusType === 'reply'
},
inReplyStatusId() {
return !this.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.replyTo
: undefined
},
// -Poll
hasPoll() {
return this.newStatus.poll != null
},
// -Quotes
hasQuote() {
return this.newStatus.quote !== null
},
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 ||
@ -412,33 +337,91 @@ const PostStatusForm = {
) {
return true
} else if (repliedStatus.visibility === 'private') {
return repliedStatus.user.id === this.$store.state.users.currentUser.id
return repliedStatus.user.id === this.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
return this.newStatus.quote?.id
},
quoteThreadToggled: {
get() {
return this.newStatus.quote?.thread
},
set(value) {
if (value) {
this.newStatus.quote = {}
this.newStatus.quote.thread = value
this.newStatus.quote.id = value ? this.replyTo : ''
} else {
this.newStatus.quote = null
}
},
},
// Emoji stuff
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
},
// Length & Limits
statusLength() {
return this.newStatusContent.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
},
emptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
// Drafts
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
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')
@ -452,9 +435,9 @@ const PostStatusForm = {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote) &&
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote) &&
this.saveable
)
},
@ -464,12 +447,78 @@ const PostStatusForm = {
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files?.length ||
this.newStatus.hasPoll ||
this.newStatus.hasQuote
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote
)
)
},
// Error handling
pollContentError() {
return (
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
)
},
// Featureset detection
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
// User configuration
userDefaultScope() {
return this.currentUser.default_scope
},
userDefaultPostContentType() {
return this.mergedConfig.postContentType
},
userDefaultScopeCopy() {
return this.mergedConfig.scopeCopy
},
userDefaultSensitive() {
return this.mergedConfig.sensitiveByDefault
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
minimalScopesMode() {
return this.mergedConfig.minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
// Global stuff
currentUser() {
return this.$store.state.users.currentUser
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
@ -479,7 +528,7 @@ const PostStatusForm = {
newStatus: {
deep: true,
handler() {
this.statusChanged()
if (this.initialized) this.statusChanged()
},
},
saveable(val) {
@ -505,23 +554,32 @@ const PostStatusForm = {
this.saveable = true
this.saveInhibited = false
},
onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return
this.newStatus.mentionsLine = e
},
toggleQuoteForm() {
if (!this.hasQuote) {
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
} else {
this.newStatus.quote = null
}
},
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.newStatus.status = ''
this.newStatus.mentionsLine = '',
this.newStatus.spoilerText = '',
this.newStatus.files = [],
this.newStatus.poll = null,
this.newStatus.quote = null,
this.newStatus.mediaDescriptions = {},
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.clearQuoteForm()
if (this.preserveFocus) {
this.$nextTick(() => {
@ -562,7 +620,7 @@ const PostStatusForm = {
return
}
const poll = newStatus.hasPoll ? pollFormToMasto(newStatus.poll) : {}
const poll = this.hasPoll ? pollFormToMasto(newStatus.poll) : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
@ -579,7 +637,7 @@ const PostStatusForm = {
}
const postingOptions = {
status: newStatus.status,
status: this.newStatusContent,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
@ -620,7 +678,7 @@ const PostStatusForm = {
statusPoster
.postStatus({
status: newStatus.status,
status: this.newStatusContent,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
@ -857,29 +915,16 @@ const PostStatusForm = {
this.newStatus.visibility = visibility
},
togglePollForm() {
this.newStatus.hasPoll = !this.newStatus.hasPoll
this.newStatus.poll = this.hasPoll ? null : {}
},
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',
@ -947,14 +992,14 @@ const PostStatusForm = {
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft(statusType, refId) {
getDraft() {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
statusType,
refId,
this.statusType,
this.refId,
)
if (existingDrafts.length) {

View file

@ -16,7 +16,7 @@
.form-bottom {
display: flex;
justify-content: space-between;
padding: 0.5em;
padding: 0.5em 0.5em 0.25em;
height: 2.5em;
.post-button-group {
@ -206,6 +206,15 @@
.inputs-wrapper {
padding: 0;
display: flex;
flex-direction: column;
}
.keyboard-enter-hint {
text-align: right;
margin-right: 1em;
line-height: 1;
margin-bottom: 0.5em;
}
textarea.input.form-post-body {
@ -236,6 +245,10 @@
border-bottom: 1px solid var(--border);
}
.mentions-input {
border-bottom: 1px solid var(--border);
}
.character-counter {
position: absolute;
bottom: 0;

View file

@ -2,6 +2,7 @@
<div
ref="form"
class="post-status-form"
v-if="initialized"
>
<form
autocomplete="off"
@ -10,7 +11,7 @@
>
<div class="form-group">
<div
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
v-if="!currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
class="visibility-notice notice-dismissible"
>
<i18n-t
@ -58,7 +59,7 @@
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked"
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
@ -172,6 +173,16 @@
>
</template>
</EmojiInput>
<input
v-if="mentionsLine"
:value="mentionsLineReadOnly ? mentionsString : newStatus.mentionsLine"
@change="onMentionsLineUpdate"
type="text"
:placeholder="$t('post_status.mentions_line')"
:disabled="mentionsLineReadOnly || (posting && !optimisticPosting)"
size="1"
class="input mentions-input form-post-mentions unstyled"
>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
@ -260,14 +271,14 @@
v-if="pollsAvailable"
ref="pollForm"
:visible="pollFormVisible"
:params="newStatus.poll"
v-model="newStatus.poll"
/>
<QuoteForm
v-if="quotingAvailable"
:id="newStatus.quote.id"
ref="quoteForm"
:visible="quoteFormVisible"
:url="newStatus.quote.url"
:id="newStatus.quote?.id"
:url="newStatus.quote?.url"
@update:url="url => newStatus.quote.url = url"
@update:id="id => newStatus.quote.id = id"
/>
@ -370,6 +381,14 @@
</Popover>
</div>
</div>
<small class="keyboard-enter-hint faint">
<i v-if="submitOnEnter">
{{ $t('post_status.enter_submits') }}
</i>
<i v-else>
{{ $t('post_status.enter_newline') }}
</i>
</small>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"

View file

@ -12,10 +12,11 @@ import {
faBars,
faFolderTree,
faList,
faMessage,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
library.add(faList, faFolderTree, faBars, faWrench)
library.add(faList, faFolderTree, faBars, faWrench, faMessage)
const QuickViewSettings = {
props: {

View file

@ -57,6 +57,24 @@
/> {{ $t('settings.conversation_display_linear_quick') }}
</button>
</div>
<div class="menu-item dropdown-item -icon-double">
<button
class="main-button"
:aria-checked="conversationDisplay === 'chat'"
role="menuitemradio"
@click="conversationDisplay = 'chat'"
>
<span
class="input menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': conversationDisplay === 'chat' }"
:aria-hidden="true"
/><FAIcon
icon="message"
:aria-hidden="true"
fixed-width
/> {{ $t('settings.conversation_display_chat_quick') }}
</button>
</div>
</div>
<div
role="separator"

View file

@ -7,6 +7,7 @@ import GestureService from '../../services/gesture_service/gesture_service'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
@ -113,7 +114,8 @@ const SideDrawer = {
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
...mapGetters(['unreadChatCount', 'draftCount']),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapGetters(['draftCount']),
},
methods: {
toggleDrawer() {

View file

@ -106,10 +106,10 @@
icon="comments"
/> {{ $t("nav.chats") }}
<span
v-if="unreadChatCount"
v-if="unreadChatsCount"
class="badge -notification"
>
{{ unreadChatCount }}
{{ unreadChatsCount }}
</span>
</router-link>
</li>

View file

@ -385,4 +385,8 @@
text-decoration: underline;
}
}
.status-action-buttons {
margin-top: var(--status-margin);
}
}

View file

@ -514,6 +514,7 @@
<StatusActionButtons
v-if="!noHeading && !isPreview"
class="status-action-buttons"
:status="status"
:replying="replying"
@toggle-replying="toggleReplyForm"

View file

@ -69,6 +69,8 @@ export default {
'getComponent',
'doAction',
'outerClose',
'defaultButtonStyle',
'hideLabel',
],
components: {
StatusBookmarkFolderMenu,
@ -103,11 +105,14 @@ export default {
return useMergedConfigStore().mergedConfig.hidePostStats
},
buttonInnerClass() {
const buttonStyleClass = this.defaultButtonStyle
? 'button-default'
: 'button-unstyled'
return [
this.button.name + '-button',
{
'main-button': this.extra,
'button-unstyled': !this.extra,
[buttonStyleClass]: !this.extra,
'-active': this.button.active?.(this.funcArg),
disabled: this.button.interactive
? !this.button.interactive(this.funcArg)

View file

@ -60,7 +60,7 @@
/>
</component>
<span
v-if="!hidePostStats && button.counter?.(funcArg) > 0"
v-if="!hidePostStats && button.counter?.(funcArg) > 0 && !hideLabel"
class="action-counter"
>
{{ button.counter?.(funcArg) }}

View file

@ -42,7 +42,7 @@ export default {
),
),
},
props: ['button', 'status'],
props: ['button', 'status', 'defaultButton', 'hideLabel'],
emits: ['emojiPickerShown'],
mounted() {
if (this.button.name === 'mute') {

View file

@ -10,6 +10,7 @@
<ActionButton
:button="button"
:status="status"
:hide-label="hideLabel"
v-bind.prop="$attrs"
/>
</template>
@ -131,6 +132,7 @@
v-else
:button="button"
:status="status"
:hide-label="hideLabel"
v-bind="$attrs"
@emoji-picker-shown="e => $emit('emojiPickerShown', e)"
/>

View file

@ -15,7 +15,31 @@ import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisH)
const StatusActionButtons = {
props: ['status', 'replying'],
props: {
status: {
type: Object,
required: true,
},
replying: {
type: Boolean,
default: false,
},
fixedPinned: {
type: Boolean,
default: false,
},
pinned: {
type: Set,
},
useDefaultButtons: {
type: Boolean,
default: false,
},
hideLabels: {
type: Boolean,
default: false,
},
},
emits: ['toggleReplying', 'onSuccess', 'onError'],
data() {
return {
@ -36,14 +60,20 @@ const StatusActionButtons = {
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
ActionButtonContainer,
},
computed: {
...mapState(useSyncConfigStore, {
pinnedItems: (store) =>
userPinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedStatusActions),
}),
pinnedItems() {
if (this.fixedPinned) {
return this.pinned
} else {
return this.userPinnedItems
}
},
buttons() {
return BUTTONS.filter((x) => (x.if ? x.if(this.funcArg) : true))
},

View file

@ -8,7 +8,6 @@
grid-auto-flow: row dense;
grid-auto-rows: 1fr;
grid-gap: 0.5em 0.1em;
margin-top: var(--status-margin);
}
.pin-action-button {
@ -17,6 +16,12 @@
padding: 0.5em;
margin: 0;
}
.quick-action.popover-wrapper {
button {
padding: 0
}
}
}
// popover
.extra-action-buttons {

View file

@ -21,6 +21,8 @@
:close="() => { /* no-op */ }"
:do-action="doAction"
@emoji-picker-shown="onEmojiPickerShown"
:default-button-style="useDefaultButtons"
:hide-label="hideLabels"
/>
<button
v-if="showPin && currentUser"
@ -40,6 +42,7 @@
<Popover
trigger="click"
:trigger-attrs="triggerAttrs"
:normal-button="useDefaultButtons"
class="quick-action"
:tabindex="0"
placement="bottom"
@ -75,6 +78,8 @@
:get-component="getComponent"
:outer-close="close"
:do-action="doAction"
:default-button-style="useDefaultButtons"
:hide-label="hideLabels"
/>
<button
v-if="showPin && currentUser"

View file

@ -26,7 +26,7 @@
.text {
&.-single-line {
white-space: nowrap;
white-space-collapse: collapse;
text-overflow: ellipsis;
overflow: hidden;
height: 1.4em;

View file

@ -306,6 +306,9 @@
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"mentions_line": "Mentioned users",
"enter_submits": "Enter key sends the post",
"enter_newline": "Enter key adds a newline",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
@ -798,6 +801,8 @@
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_display_chat": "Chat-style",
"conversation_display_chat_quick": "Chat view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
@ -1654,6 +1659,8 @@
"delete_confirm_accept_button": "Delete",
"delete_confirm_cancel_button": "Keep",
"reply_to": "Reply to",
"reply_to_selected": "Replying to selected message",
"reply_to_last": "Replying to last message",
"reply_to_with_icon": "{icon} {replyTo}",
"reply_to_with_arg": "{replyToWithIcon} {user}",
"mentions": "Mentions",

View file

@ -2,6 +2,7 @@ import { Socket } from 'phoenix'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
@ -174,7 +175,7 @@ const api = {
) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
dispatch('stopFetchingChats')
useChatsStore().stopFetchingChats()
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)

View file

@ -1,277 +0,0 @@
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'
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 })
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) })
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

View file

@ -1,5 +1,4 @@
import api from './api.js'
import chats from './chats.js'
import drafts from './drafts.js'
import notifications from './notifications.js'
import profileConfig from './profileConfig.js'
@ -13,5 +12,4 @@ export default {
api,
profileConfig,
drafts,
chats,
}

View file

@ -21,6 +21,7 @@ import {
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -47,6 +48,7 @@ import {
muteUser as apiMuteUser,
unblockUser as apiUnblockUser,
unmuteUser as apiUnmuteUser,
editUserNote as apiEditUserNote,
fetchBlocks,
fetchDomainMutes,
fetchMutes,
@ -123,7 +125,7 @@ const removeUserFromFollowers = (store, id) => {
}
const editUserNote = (store, { id, comment }) => {
return editUserNote({ id, comment }).then((relationship) =>
return apiEditUserNote({ id, comment }).then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
@ -725,7 +727,7 @@ const users = {
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
store.dispatch('resetChats')
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().setLastTimeline('public-timeline')

View file

@ -1,251 +0,0 @@
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
}
// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
const getView = (storage) => {
if (!storage) {
return []
}
const result = []
const messages = orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
const firstMessage = messages[0]
let previousMessage = messages[messages.length - 1]
let currentMessageChainId
if (firstMessage) {
const date = new Date(firstMessage.created_at)
date.setHours(0, 0, 0, 0)
result.push({
type: 'date',
date,
id: date.getTime().toString(),
})
}
let afterDate = false
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const nextMessage = messages[i + 1]
const date = new Date(message.created_at)
date.setHours(0, 0, 0, 0)
// insert date separator and start a new message chain
if (previousMessage && previousMessage.date < date) {
result.push({
type: 'date',
date,
id: date.getTime().toString(),
})
previousMessage.isTail = true
currentMessageChainId = undefined
afterDate = true
}
const object = {
type: 'message',
data: message,
date,
id: message.id,
messageChainId: currentMessageChainId,
}
// end a message chian
if ((nextMessage && nextMessage.account_id) !== message.account_id) {
object.isTail = true
currentMessageChainId = undefined
}
// start a new message chain
if (
(previousMessage &&
previousMessage.data &&
previousMessage.data.account_id) !== message.account_id ||
afterDate
) {
currentMessageChainId = uniqueId()
object.isHead = true
object.messageChainId = currentMessageChainId
}
result.push(object)
previousMessage = object
afterDate = false
}
return result
}
const ChatService = {
add,
empty,
getView,
deleteMessage,
cullOlderMessages,
resetNewMessageCount,
clear,
handleMessageError,
}
export default ChatService

View file

@ -1,10 +1,8 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
export const maybeShowChatNotification = (store, chat) => {
export const maybeShowChatNotification = (chat) => {
if (!chat.lastMessage) return
if (store.rootState.chats.currentChatId === chat.id && !document.hidden)
return
if (store.rootState.users.currentUser.id === chat.lastMessage.account_id)
if (window.vuex.state.users.currentUser.id === chat.lastMessage.account_id)
return
const opts = {
@ -21,7 +19,7 @@ export const maybeShowChatNotification = (store, chat) => {
opts.image = chat.lastMessage.attachment.preview_url
}
showDesktopNotification(store.rootState, opts)
showDesktopNotification(window.vuex.state, opts)
}
export const buildFakeMessage = ({

View file

@ -191,6 +191,7 @@ export const prepareNotificationObject = (notification, i18n) => {
export const countExtraNotifications = (
store,
mergedConfig,
unreadChatsCount,
unreadAnnouncementCount,
) => {
const rootGetters = store.rootGetters || store.getters
@ -200,9 +201,7 @@ export const countExtraNotifications = (
}
return [
mergedConfig.showChatsInExtraNotifications
? rootGetters.unreadChatCount
: 0,
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
mergedConfig.showAnnouncementsInExtraNotifications
? unreadAnnouncementCount
: 0,

View file

@ -9,11 +9,11 @@ const pollFallbackValues = {
expiryUnit: 'minutes',
}
const pollFallback = (object, attr) => {
export const pollFallback = (object, attr) => {
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
}
const pollFormToMasto = (poll) => {
export const pollFormToMasto = (poll) => {
const expiresIn = DateUtils.unitToSeconds(
pollFallback(poll, 'expiryUnit'),
pollFallback(poll, 'expiryAmount'),
@ -32,5 +32,3 @@ const pollFormToMasto = (poll) => {
expiresIn,
}
}
export { pollFallback, pollFormToMasto }

114
src/stores/chats.js Normal file
View file

@ -0,0 +1,114 @@
import { find, omitBy, orderBy, sumBy } from 'lodash'
import { defineStore } from 'pinia'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { chats } from 'src/api/chats.js'
const emptyChatList = () => ({
data: [],
idStore: {},
})
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
}
const getChatById = (state, id) => {
return find(state.chatList.data, { id })
}
export const useChatsStore = defineStore('chats', {
state: () => ({ ...defaultState }),
getters: {
sortedChatList(state) {
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
},
unreadChatsCount(state) {
return sumBy(state.chatList.data, 'unread')
},
},
actions: {
startFetchingChats() {
const fetcher = () => this.fetchChats()
this.setChatListFetcher(() => promiseInterval(fetcher, 5000))
},
stopFetchingChats() {
this.setChatListFetcher(null)
},
async fetchChats() {
const { data } = await chats({
credentials: useOAuthStore().token,
})
this.addNewChats(data)
},
setChatListFetcher(fetcher) {
const prevFetcher = this.chatListFetcher
if (prevFetcher) {
prevFetcher.stop()
}
this.chatListFetcher = fetcher?.()
},
resetChats() {
this.chatList = emptyChatList()
this.setChatListFetcher(null)
},
addNewChats(chats) {
window.vuex.commit(
'addNewUsers',
chats.map((k) => k.account).filter((k) => k),
)
chats.forEach((updatedChat) => {
const chat = getChatById(this, 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) {
maybeShowChatNotification(chat)
}
} else {
this.chatList.data.push(updatedChat)
this.chatList.idStore[updatedChat.id] = updatedChat
}
})
},
readChat(id) {
const chat = getChatById(this, id)
if (chat) {
chat.unread = 0
}
},
updateChat({ chat: updatedChat }) {
const chat = getChatById(this, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
}
if (!chat) {
this.chatList.data.unshift(updatedChat)
}
this.chatList.idStore[updatedChat.id] = updatedChat
},
deleteChat(id) {
this.chats.data = this.chats.data.filter(
(conversation) => conversation.last_status.id !== id,
)
this.chats.idStore = omitBy(
this.chats.idStore,
(conversation) => conversation.last_status.id === id,
)
},
},
})

View file

@ -0,0 +1,244 @@
import { shallowMount } from '@vue/test-utils'
import ChatMessageList from './chat_message_list.vue'
describe('ChatMessageList', () => {
describe('computed.chatItems', () => {
it('Inserts date separators', () => {
const component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:01:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'message',
'message',
'date',
'message',
])
})
it('Inserts date header if needed', () => {
const component = shallowMount(ChatMessageList, {
props: {
headerDate: true,
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'date',
'message',
])
})
it('Inserts time separators if messages were sent with considerable delay (5 minutes)', () => {
const component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
],
},
})
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'message',
'date',
'message',
'date',
'message',
])
expect(component.vm.chatItems.map((i) => i.isTime)).to.eql([
undefined,
true,
undefined,
false,
undefined,
])
})
it('Groups message chains by time and author', () => {
const component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
id: '1',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
id: '2',
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
{
id: '3',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:01:00.000Z'),
},
{
id: '4',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:02:00.000Z'),
},
{
id: '5',
account_id: 'Bob',
created_at: new Date('2020-06-23T20:03:00.000Z'),
},
{
id: '6',
account_id: 'Eve',
created_at: new Date('2020-06-23T20:04:00.000Z'),
},
],
},
})
// Type check
expect(component.vm.chatItems.map((i) => i.type)).to.eql([
'message',
'date',
'message',
'date',
'message',
'message',
'message',
'message',
'message',
])
// Chain head/Tail checks
expect(component.vm.chatItems.map((i) => [i.isHead, i.isTail])).to.eql([
[true, true],
[undefined, undefined],
[true, true],
[undefined, undefined],
[true, true],
[true, false],
[false, false],
[false, true],
[true, true],
])
// Unique ID is randomly generated so we have to compare data against itself
// Two messages from Bob next to each other
expect(component.vm.chatItems[5].messageChainId).to.eql(
component.vm.chatItems[6].messageChainId,
)
// Message from Even right after Bob
expect(component.vm.chatItems[7].messageChainId).to.not.eql(
component.vm.chatItems[8].messageChainId,
)
})
})
describe('methods.getPreviousItem', () => {
describe('Finds correct previous meaningful (non-separator) message in the chatlist', () => {
let component
beforeEach(() => {
component = shallowMount(ChatMessageList, {
props: {
messages: [
{
id: '0',
account_id: 'Alice',
created_at: new Date('2020-06-22T20:00:00.000Z'),
},
{
// Separator
id: '1', // 2
account_id: 'Alice',
created_at: new Date('2020-06-22T20:06:00.000Z'),
},
{
// Separator
id: '2', // 4
account_id: 'Alice',
created_at: new Date('2020-06-23T20:00:00.000Z'),
},
{
id: '3', // 5
account_id: 'Bob',
created_at: new Date('2020-06-23T20:01:00.000Z'),
},
{
id: '4', // 6
account_id: 'Bob',
created_at: new Date('2020-06-23T20:02:00.000Z'),
},
{
id: '5', // 7
account_id: 'Bob',
created_at: new Date('2020-06-23T20:03:00.000Z'),
},
{
id: '6', // 8
account_id: 'Eve',
created_at: new Date('2020-06-23T20:04:00.000Z'),
},
],
},
})
})
it('Directly next to each other', () => {
const correct = component.vm.chatItems[6]
expect(component.vm.getPreviousItem(7)).to.eql(correct)
})
it('Across separator', () => {
const correct = component.vm.chatItems[2]
expect(component.vm.getPreviousItem(4)).to.eql(correct)
})
it('Returns null if no previous item exist', () => {
const correct = null
expect(component.vm.getPreviousItem(0)).to.eql(correct)
})
})
})
})

View file

@ -0,0 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import ChatView from './chat_view.vue'
const message1 = {
id: '1',
chat_id: 2,
idempotency_key: '1',
created_at: new Date('2020-06-22T18:45:53.000Z'),
}
const message2 = {
id: '2',
chat_id: 2,
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-06-22T18:45:56.000Z'),
}
const message3 = {
id: '3',
chat_id: 2,
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-07-22T18:45:59.000Z'),
}
const global = {
mocks: {
$store: {
state: {
api: {},
users: {},
},
},
$route: {
params: {
recipient_id: 2,
},
},
$router: {
push: () => {
/* noop */
},
},
},
stubs: {
FAIcon: true,
},
}
describe('ChatView methods', () => {
let component
beforeEach(() => {
setActivePinia(createTestingPinia())
component = shallowMount(ChatView, { global, props: { testMode: true } })
component.vm.chat = { id: 2 }
})
describe('addMessages', () => {
it("Doesn't add duplicates", () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message1] })
expect(component.vm.messages.length).to.eql(1)
component.vm.addMessages({ messages: [message2] })
expect(component.vm.messages.length).to.eql(2)
})
it('Updates minId and lastMessage and newMessageCount', () => {
component.vm.addMessages({ messages: [message1] })
expect(component.vm.maxId).to.eql(message1.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(1)
component.vm.addMessages({ messages: [message2] })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(2)
component.vm.readChat()
expect(component.vm.newMessageCount).to.eql(0)
expect(component.vm.lastReadMessageId).to.eql(message2.id)
// Add message with higher id
component.vm.addMessages({ messages: [message3] })
expect(component.vm.newMessageCount).to.eql(1)
})
})
describe('deleteChatMessage', () => {
it('Updates minId and lastMessage', () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message2] })
component.vm.addMessages({ messages: [message3] })
expect(component.vm.maxId).to.eql(message3.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message3.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message1.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message2.id)
})
})
describe('cullOlder', () => {
it('keeps 50 newest messages and messagesIndex matches', () => {
for (let i = 100; i > 0; i--) {
// Use decimal values with toFixed to hack together constant length predictable strings
component.vm.addMessages({
messages: [
{
...message1,
id: 'a' + (i / 1000).toFixed(3),
idempotency_key: i,
},
],
})
}
component.vm.cullOlder()
expect(component.vm.messages.length).to.eql(50)
expect(component.vm.messages[0].id).to.eql('a0.051')
expect(component.vm.minId).to.eql('a0.051')
expect(component.vm.messages[49].id).to.eql('a0.100')
expect(Object.keys(component.vm.messagesIndex).length).to.eql(50)
})
})
})

View file

@ -1,122 +0,0 @@
import chatService from '../../../../../src/services/chat_service/chat_service.js'
const message1 = {
id: '9wLkdcmQXD21Oy8lEX',
idempotency_key: '1',
created_at: new Date('2020-06-22T18:45:53.000Z'),
}
const message2 = {
id: '9wLkdp6ihaOVdNj8Wu',
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-06-22T18:45:56.000Z'),
}
const message3 = {
id: '9wLke9zL4Dy4OZR2RM',
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-07-22T18:45:59.000Z'),
}
describe('chatService', () => {
describe('.add', () => {
it("Doesn't add duplicates", () => {
const chat = chatService.empty()
chatService.add(chat, { messages: [message1] })
chatService.add(chat, { messages: [message1] })
expect(chat.messages.length).to.eql(1)
chatService.add(chat, { messages: [message2] })
expect(chat.messages.length).to.eql(2)
})
it('Updates minId and lastMessage and newMessageCount', () => {
const chat = chatService.empty()
chatService.add(chat, { messages: [message1] })
expect(chat.maxId).to.eql(message1.id)
expect(chat.minId).to.eql(message1.id)
expect(chat.newMessageCount).to.eql(1)
chatService.add(chat, { messages: [message2] })
expect(chat.maxId).to.eql(message2.id)
expect(chat.minId).to.eql(message1.id)
expect(chat.newMessageCount).to.eql(2)
chatService.resetNewMessageCount(chat)
expect(chat.newMessageCount).to.eql(0)
expect(chat.lastSeenMessageId).to.eql(message2.id)
// Add message with higher id
chatService.add(chat, { messages: [message3] })
expect(chat.newMessageCount).to.eql(1)
})
})
describe('.delete', () => {
it('Updates minId and lastMessage', () => {
const chat = chatService.empty()
chatService.add(chat, { messages: [message1] })
chatService.add(chat, { messages: [message2] })
chatService.add(chat, { messages: [message3] })
expect(chat.maxId).to.eql(message3.id)
expect(chat.minId).to.eql(message1.id)
chatService.deleteMessage(chat, message3.id)
expect(chat.maxId).to.eql(message2.id)
expect(chat.minId).to.eql(message1.id)
chatService.deleteMessage(chat, message1.id)
expect(chat.maxId).to.eql(message2.id)
expect(chat.minId).to.eql(message2.id)
})
})
describe('.getView', () => {
it('Inserts date separators', () => {
const chat = chatService.empty()
chatService.add(chat, { messages: [message1] })
chatService.add(chat, { messages: [message2] })
chatService.add(chat, { messages: [message3] })
const view = chatService.getView(chat)
expect(view.map((i) => i.type)).to.eql([
'date',
'message',
'message',
'date',
'message',
])
})
})
describe('.cullOlderMessages', () => {
it('keeps 50 newest messages and idIndex matches', () => {
const chat = chatService.empty()
for (let i = 100; i > 0; i--) {
// Use decimal values with toFixed to hack together constant length predictable strings
chatService.add(chat, {
messages: [
{
...message1,
id: 'a' + (i / 1000).toFixed(3),
idempotency_key: i,
},
],
})
}
chatService.cullOlderMessages(chat)
expect(chat.messages.length).to.eql(50)
expect(chat.messages[0].id).to.eql('a0.051')
expect(chat.minId).to.eql('a0.051')
expect(chat.messages[49].id).to.eql('a0.100')
expect(Object.keys(chat.idIndex).length).to.eql(50)
})
})
})