Merge branch 'chat-refactor' into shigusegubu-themes3
This commit is contained in:
commit
6529d09164
78 changed files with 1927 additions and 1439 deletions
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Fix HTML attribute parsing for escaped quotes
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
Fix emojis breaking user bio/description editing
|
||||
|
|
@ -1 +0,0 @@
|
|||
Initial MFM rendering support
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
Fixed status action mute hiding itself on click
|
||||
|
|
@ -1 +0,0 @@
|
|||
displaying other user's backgrounds (if supported by BE)
|
||||
|
|
@ -1 +0,0 @@
|
|||
Add quoting by URL and in replies
|
||||
|
|
@ -1 +0,0 @@
|
|||
Fix reply form crash when quote-reply settings are unavailable
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
settings synchronization
|
||||
user highlight synchronization
|
||||
|
|
@ -1 +0,0 @@
|
|||
User administration + post scope/sensitivity admin change support
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
133
src/components/chat_message_list/chat_message_list.js
Normal file
133
src/components/chat_message_list/chat_message_list.js
Normal 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
|
||||
7
src/components/chat_message_list/chat_message_list.scss
Normal file
7
src/components/chat_message_list/chat_message_list.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.ChatMessageList {
|
||||
padding: 0.5em;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
17
src/components/chat_message_list/chat_message_list.vue
Normal file
17
src/components/chat_message_list/chat_message_list.vue
Normal 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" />
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
123
src/components/conversation/conversation.scss
Normal file
123
src/components/conversation/conversation.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
icon="bars"
|
||||
/>
|
||||
<div
|
||||
v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount"
|
||||
v-if="(unreadChatsCount && !chatsPinned) || unreadAnnouncementCount"
|
||||
class="badge -dot -notification"
|
||||
/>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export const ROOT_ITEMS = {
|
|||
icon: 'comments',
|
||||
label: 'nav.chats',
|
||||
badgeStyle: 'notification',
|
||||
badgeGetter: 'unreadChatCount',
|
||||
badgeGetter: 'unreadChatsCount',
|
||||
criteria: ['chats'],
|
||||
},
|
||||
friendRequests: {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
:placeholder="$t('polls.option')"
|
||||
:maxlength="maxLength"
|
||||
@keydown.enter.stop.prevent="nextOption(index)"
|
||||
@change="updateOption"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }"
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -385,4 +385,8 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.status-action-buttons {
|
||||
margin-top: var(--status-margin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,6 +514,7 @@
|
|||
|
||||
<StatusActionButtons
|
||||
v-if="!noHeading && !isPreview"
|
||||
class="status-action-buttons"
|
||||
:status="status"
|
||||
:replying="replying"
|
||||
@toggle-replying="toggleReplyForm"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export default {
|
|||
),
|
||||
),
|
||||
},
|
||||
props: ['button', 'status'],
|
||||
props: ['button', 'status', 'defaultButton', 'hideLabel'],
|
||||
emits: ['emojiPickerShown'],
|
||||
mounted() {
|
||||
if (this.button.name === 'mute') {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
.text {
|
||||
&.-single-line {
|
||||
white-space: nowrap;
|
||||
white-space-collapse: collapse;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
height: 1.4em;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
114
src/stores/chats.js
Normal 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,
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
244
test/unit/specs/components/chat_message_list.spec.js
Normal file
244
test/unit/specs/components/chat_message_list.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
134
test/unit/specs/components/chat_view.spec.js
Normal file
134
test/unit/specs/components/chat_view.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue