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/).
|
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
|
## 2.10.1
|
||||||
### Fixed
|
### Fixed
|
||||||
- fixed being unable to set actor type from profile page
|
- 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",
|
"name": "pleroma_fe",
|
||||||
"version": "2.10.1",
|
"version": "2.11.0",
|
||||||
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
|
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
|
||||||
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/src/CONTRIBUTORS.md>",
|
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/src/CONTRIBUTORS.md>",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { paramsString, promisedRequest } from './helpers.js'
|
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_CHATS_URL = '/api/v1/pleroma/chats'
|
||||||
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
|
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,
|
url: PLEROMA_CHATS_URL,
|
||||||
credentials,
|
credentials,
|
||||||
}).then(({ data }) => ({
|
}).then(({ data }) => ({
|
||||||
chatList: data.map(parseChat).filter((c) => c),
|
data: data.map(parseChat).filter((c) => c),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const getOrCreateChat = ({ accountId, credentials }) =>
|
export const getOrCreateChat = ({ accountId, credentials }) =>
|
||||||
|
|
@ -23,7 +26,7 @@ export const getOrCreateChat = ({ accountId, credentials }) =>
|
||||||
url: PLEROMA_CHAT_URL(accountId),
|
url: PLEROMA_CHAT_URL(accountId),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials,
|
credentials,
|
||||||
})
|
}).then(({ data }) => ({ data: parseChat(data) }))
|
||||||
|
|
||||||
export const chatMessages = ({
|
export const chatMessages = ({
|
||||||
id,
|
id,
|
||||||
|
|
@ -36,7 +39,9 @@ export const chatMessages = ({
|
||||||
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
|
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials,
|
credentials,
|
||||||
})
|
}).then(({ data }) => ({
|
||||||
|
data: data.map(parseChatMessage).filter((c) => c),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendChatMessage = ({
|
export const sendChatMessage = ({
|
||||||
|
|
@ -66,7 +71,9 @@ export const sendChatMessage = ({
|
||||||
payload,
|
payload,
|
||||||
credentials,
|
credentials,
|
||||||
headers,
|
headers,
|
||||||
})
|
}).then(({ data }) => ({
|
||||||
|
data: parseChatMessage(data),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const readChat = ({ id, lastReadId, credentials }) =>
|
export const readChat = ({ id, lastReadId, credentials }) =>
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,7 @@ export default (store) => {
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
path: '/users/:username/chats/:recipient_id',
|
path: '/users/:username/chats/:recipient_id',
|
||||||
component: defineAsyncComponent(
|
component: defineAsyncComponent(
|
||||||
() => import('src/components/chat/chat.vue'),
|
() => import('src/components/chat_view/chat_view.vue'),
|
||||||
),
|
),
|
||||||
meta: { dontScroll: false },
|
meta: { dontScroll: false },
|
||||||
beforeEnter: validateAuthenticatedRoute,
|
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 ChatListItem from 'src/components/chat_list_item/chat_list_item.vue'
|
||||||
import ChatNew from 'src/components/chat_new/chat_new.vue'
|
import ChatNew from 'src/components/chat_new/chat_new.vue'
|
||||||
import List from 'src/components/list/list.vue'
|
import List from 'src/components/list/list.vue'
|
||||||
|
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
|
|
||||||
const ChatList = {
|
const ChatList = {
|
||||||
components: {
|
components: {
|
||||||
ChatListItem,
|
ChatListItem,
|
||||||
|
|
@ -14,7 +17,7 @@ const ChatList = {
|
||||||
...mapState({
|
...mapState({
|
||||||
currentUser: (state) => state.users.currentUser,
|
currentUser: (state) => state.users.currentUser,
|
||||||
}),
|
}),
|
||||||
...mapGetters(['sortedChatList']),
|
...mapPiniaState(useChatsStore, ['sortedChatList']),
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -22,12 +25,12 @@ const ChatList = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.$store.dispatch('fetchChats', { latest: true })
|
useChatsStore().fetchChats()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
cancelNewChat() {
|
cancelNewChat() {
|
||||||
this.isNew = false
|
this.isNew = false
|
||||||
this.$store.dispatch('fetchChats', { latest: true })
|
useChatsStore().fetchChats()
|
||||||
},
|
},
|
||||||
newChat() {
|
newChat() {
|
||||||
this.isNew = true
|
this.isNew = true
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { find } from 'lodash'
|
||||||
import { mapState as mapPiniaState } from 'pinia'
|
import { mapState as mapPiniaState } from 'pinia'
|
||||||
import { mapState } from 'vuex'
|
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 Gallery from 'src/components/gallery/gallery.vue'
|
||||||
import LinkPreview from 'src/components/link-preview/link-preview.vue'
|
import LinkPreview from 'src/components/link-preview/link-preview.vue'
|
||||||
import Popover from 'src/components/popover/popover.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 StatusContent from 'src/components/status_content/status_content.vue'
|
||||||
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
|
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
|
||||||
import UserPopover from 'src/components/user_popover/user_popover.vue'
|
import UserPopover from 'src/components/user_popover/user_popover.vue'
|
||||||
|
|
@ -15,24 +18,31 @@ import { useInterfaceStore } from 'src/stores/interface'
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
|
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
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 = {
|
const ChatMessage = {
|
||||||
name: 'ChatMessage',
|
name: 'ChatMessage',
|
||||||
props: [
|
props: [
|
||||||
'author',
|
|
||||||
'edited',
|
'edited',
|
||||||
'noHeading',
|
'noHeading',
|
||||||
'chatViewItem',
|
'previousItem',
|
||||||
|
'chatItem',
|
||||||
|
'previousItem',
|
||||||
'hoveredMessageChain',
|
'hoveredMessageChain',
|
||||||
],
|
],
|
||||||
emits: ['hover'],
|
emits: ['hover', 'replyRequested'],
|
||||||
components: {
|
components: {
|
||||||
Popover,
|
Popover,
|
||||||
Attachment,
|
Attachment,
|
||||||
StatusContent,
|
StatusContent,
|
||||||
|
StatusBody,
|
||||||
|
StatusActionButtons,
|
||||||
UserAvatar,
|
UserAvatar,
|
||||||
Gallery,
|
Gallery,
|
||||||
LinkPreview,
|
LinkPreview,
|
||||||
|
|
@ -42,27 +52,48 @@ const ChatMessage = {
|
||||||
computed: {
|
computed: {
|
||||||
// Returns HH:MM (hours and minutes) in local time.
|
// Returns HH:MM (hours and minutes) in local time.
|
||||||
createdAt() {
|
createdAt() {
|
||||||
const time = this.chatViewItem.data.created_at
|
const time = this.chatItem.data.created_at
|
||||||
return time.toLocaleTimeString('en', {
|
return time.toLocaleTimeString('en', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: false,
|
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() {
|
isCurrentUser() {
|
||||||
return this.message.account_id === this.currentUser.id
|
return this.author.id === this.currentUser.id
|
||||||
},
|
},
|
||||||
message() {
|
message() {
|
||||||
return this.chatViewItem.data
|
return this.isMessage ? this.chatItem.data : null
|
||||||
},
|
},
|
||||||
isMessage() {
|
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() {
|
messageForStatusContent() {
|
||||||
return {
|
return {
|
||||||
summary: '',
|
summary: '',
|
||||||
emojis: this.message.emojis,
|
emojis: this.message.emojis,
|
||||||
raw_html: this.message.content || '',
|
raw_html: this.message.content || this.message.raw_html || '',
|
||||||
text: this.message.content || '',
|
text: this.message.content || '',
|
||||||
attachments: this.message.attachments,
|
attachments: this.message.attachments,
|
||||||
}
|
}
|
||||||
|
|
@ -96,15 +127,32 @@ const ChatMessage = {
|
||||||
onHover(bool) {
|
onHover(bool) {
|
||||||
this.$emit('hover', {
|
this.$emit('hover', {
|
||||||
isHovered: bool,
|
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() {
|
async deleteMessage() {
|
||||||
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
|
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await this.$store.dispatch('deleteChatMessage', {
|
await this.$emit('delete', {
|
||||||
messageId: this.chatViewItem.data.id,
|
messageId: this.chatItem.data.id,
|
||||||
chatId: this.chatViewItem.data.chat_id,
|
chatId: this.chatItem.data.chat_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.hovered = false
|
this.hovered = false
|
||||||
|
|
|
||||||
|
|
@ -11,29 +11,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-menu {
|
.chat-message-toolbar {
|
||||||
transition: opacity 0.1s;
|
transition: opacity 0.1s;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -0.8em;
|
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-top: 0.2em;
|
||||||
padding-bottom: 0.2em;
|
padding-bottom: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
cursor: pointer;
|
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 {
|
.popover {
|
||||||
width: 12em;
|
width: 12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message {
|
.chat-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-bottom: 0.5em;
|
|
||||||
|
|
||||||
.status-body:hover {
|
.status-body:hover {
|
||||||
--_still-image-img-visibility: visible;
|
--_still-image-img-visibility: visible;
|
||||||
|
|
@ -67,7 +97,6 @@
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
margin: -1em 0 -0.5em;
|
margin: -1em 0 -0.5em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.without-attachment {
|
.without-attachment {
|
||||||
|
|
@ -104,7 +133,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.outgoing {
|
.-outgoing {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
place-content: end flex-end;
|
place-content: end flex-end;
|
||||||
|
|
@ -113,14 +142,8 @@
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-menu {
|
.reply-to-header {
|
||||||
right: 0.4rem;
|
justify-content: end;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.incoming {
|
|
||||||
.chat-message-menu {
|
|
||||||
left: 0.4rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,8 +162,8 @@
|
||||||
|
|
||||||
.chat-message-date-separator {
|
.chat-message-date-separator {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 1.4em 0;
|
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
line-height: 2;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--textFaint);
|
color: var(--textFaint);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="chat-message"
|
class="chat-message"
|
||||||
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
|
:class="[{ '-outgoing': isCurrentUser, '-incoming': !isCurrentUser, '-pending': message.pending }]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="!isCurrentUser"
|
v-if="!isCurrentUser"
|
||||||
class="avatar-wrapper"
|
class="avatar-wrapper"
|
||||||
>
|
>
|
||||||
<UserPopover
|
<UserPopover
|
||||||
v-if="chatViewItem.isHead"
|
v-if="chatItem.isHead"
|
||||||
:user-id="author.id"
|
:user-id="author.id"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
|
|
@ -25,26 +25,59 @@
|
||||||
</UserPopover>
|
</UserPopover>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-message-inner">
|
<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
|
<div
|
||||||
class="status-body"
|
class="status-body"
|
||||||
:style="{ 'min-width': message.attachment ? '80%' : '' }"
|
:style="{ 'min-width': message.attachment ? '80%' : '' }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="media status"
|
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;"
|
style="position: relative;"
|
||||||
@mouseenter="hovered = true"
|
@mouseenter="hovered = true"
|
||||||
@mouseleave="hovered = false"
|
@mouseleave="hovered = false"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<StatusActionButtons
|
||||||
|
v-if="isStatus"
|
||||||
|
class="chat-message-toolbar"
|
||||||
|
:class="{ '-visible': hovered || menuOpened }"
|
||||||
|
:status="message"
|
||||||
|
:pinned="new Set(['reply', 'emoji'])"
|
||||||
|
fixed-pinned
|
||||||
|
use-default-buttons
|
||||||
|
hide-labels
|
||||||
|
@toggle-replying="$emit('replyRequested', message)"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="chat-message-menu"
|
class="chat-message-toolbar"
|
||||||
:class="{ 'visible': hovered || menuOpened }"
|
:class="{ '-visible': hovered || menuOpened }"
|
||||||
|
v-else
|
||||||
>
|
>
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
|
||||||
placement="top"
|
placement="top"
|
||||||
bound-to-selector=".chat-view-inner"
|
|
||||||
:bound-to="{ x: 'container' }"
|
|
||||||
:margin="popoverMarginStyle"
|
:margin="popoverMarginStyle"
|
||||||
@show="menuOpened = true"
|
@show="menuOpened = true"
|
||||||
@close="menuOpened = false"
|
@close="menuOpened = false"
|
||||||
|
|
@ -62,17 +95,13 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<button
|
<FAIcon icon="ellipsis-h" />
|
||||||
class="button-default menu-icon"
|
|
||||||
:title="$t('chats.more')"
|
|
||||||
>
|
|
||||||
<FAIcon icon="ellipsis-h" />
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<StatusContent
|
<StatusContent
|
||||||
class="message-content"
|
class="message-content"
|
||||||
|
:class="{ faint: message.pending }"
|
||||||
:status="messageForStatusContent"
|
:status="messageForStatusContent"
|
||||||
:full-content="true"
|
:full-content="true"
|
||||||
>
|
>
|
||||||
|
|
@ -80,6 +109,27 @@
|
||||||
<span
|
<span
|
||||||
class="created-at"
|
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 }}
|
{{ createdAt }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -93,7 +143,7 @@
|
||||||
v-else
|
v-else
|
||||||
class="chat-message-date-separator"
|
class="chat-message-date-separator"
|
||||||
>
|
>
|
||||||
<ChatMessageDate :date="chatViewItem.date" />
|
<ChatMessageDate :date="chatItem.date" :show-time="chatItem.isTime" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,17 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
|
|
||||||
import localeService from 'src/services/locale/locale.service.js'
|
import localeService from 'src/services/locale/locale.service.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Timeago',
|
name: 'Timeago',
|
||||||
props: ['date'],
|
props: ['date', 'showTime'],
|
||||||
computed: {
|
computed: {
|
||||||
|
time12hFormat() {
|
||||||
|
return useMergedConfigStore().mergedConfig.absoluteTimeFormat12h === '12h'
|
||||||
|
},
|
||||||
displayDate() {
|
displayDate() {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
@ -18,10 +23,17 @@ export default {
|
||||||
if (this.date.getTime() === today.getTime()) {
|
if (this.date.getTime() === today.getTime()) {
|
||||||
return this.$t('display_date.today')
|
return this.$t('display_date.today')
|
||||||
} else {
|
} else {
|
||||||
return this.date.toLocaleDateString(
|
if (this.showTime) {
|
||||||
localeService.internalToBrowserLocale(this.$i18n.locale),
|
return this.date.toLocaleTimeString(
|
||||||
{ day: 'numeric', month: 'long' },
|
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 {
|
export default {
|
||||||
name: 'Chat',
|
name: 'Chat',
|
||||||
selector: '.chat-message-list',
|
selector: '.ChatMessageList',
|
||||||
validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
|
validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
|
||||||
defaultRules: [
|
defaultRules: [
|
||||||
{
|
{
|
||||||
directives: {
|
directives: {
|
||||||
|
backgroundNoCssColor: 'yes',
|
||||||
background: '--bg',
|
background: '--bg',
|
||||||
blur: '5px',
|
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() {
|
async created() {
|
||||||
const { chatList } = await chats({
|
const { data } = await chats({
|
||||||
credentials: useOAuthStore().token,
|
credentials: useOAuthStore().token,
|
||||||
})
|
})
|
||||||
chatList.forEach((chat) => this.suggestions.push(chat.account))
|
data.forEach((chat) => this.suggestions.push(chat.account))
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
users() {
|
users() {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { throttle } from 'lodash'
|
import { maxBy, minBy, sortBy, throttle } from 'lodash'
|
||||||
import { mapState as mapPiniaState } from 'pinia'
|
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 ChatTitle from 'src/components/chat_title/chat_title.vue'
|
||||||
import PostStatusForm from 'src/components/post_status_form/post_status_form.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 { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
|
||||||
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
|
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,13 +15,16 @@ import {
|
||||||
isScrollable,
|
isScrollable,
|
||||||
} from './chat_layout_utils.js'
|
} from './chat_layout_utils.js'
|
||||||
|
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
chatMessages,
|
chatMessages,
|
||||||
|
deleteChatMessage,
|
||||||
getOrCreateChat,
|
getOrCreateChat,
|
||||||
|
readChat,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
} from 'src/api/chats.js'
|
} from 'src/api/chats.js'
|
||||||
import { WSConnectionStatus } from 'src/api/websocket.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 MARK_AS_READ_DELAY = 1500
|
||||||
const MAX_RETRIES = 10
|
const MAX_RETRIES = 10
|
||||||
|
|
||||||
|
const isConfirmation = (storage, message) => {
|
||||||
|
if (!message.idempotency_key) return
|
||||||
|
return storage.idempotencyKeyIndex[message.idempotency_key]
|
||||||
|
}
|
||||||
|
|
||||||
const Chat = {
|
const Chat = {
|
||||||
components: {
|
components: {
|
||||||
ChatMessage,
|
ChatMessageList,
|
||||||
ChatTitle,
|
ChatTitle,
|
||||||
PostStatusForm,
|
PostStatusForm,
|
||||||
},
|
},
|
||||||
|
props: {
|
||||||
|
testMode: Boolean,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
jumpToBottomButtonVisible: false,
|
// Main info
|
||||||
hoveredMessageChainId: undefined,
|
chat: null,
|
||||||
|
messages: [],
|
||||||
|
messagesIndex: {},
|
||||||
|
pendingMessages: [],
|
||||||
|
pendingMessagesIndex: {},
|
||||||
|
minId: undefined,
|
||||||
|
maxId: undefined,
|
||||||
|
|
||||||
|
// Unread stuff
|
||||||
|
newMessageCount: 0,
|
||||||
|
lastReadMessageId: null,
|
||||||
lastScrollPosition: {},
|
lastScrollPosition: {},
|
||||||
scrollableContainerHeight: '100%',
|
jumpToBottomButtonVisible: false,
|
||||||
|
|
||||||
|
// Internal network stuff
|
||||||
|
fetcher: null,
|
||||||
errorLoadingChat: false,
|
errorLoadingChat: false,
|
||||||
messageRetriers: {},
|
messageRetriers: {},
|
||||||
|
idempotencyKeyIndex: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
if (this.testMode) return
|
||||||
this.startFetching()
|
this.startFetching()
|
||||||
window.addEventListener('resize', this.handleResize)
|
window.addEventListener('resize', this.handleResize)
|
||||||
},
|
},
|
||||||
|
|
@ -80,11 +106,10 @@ const Chat = {
|
||||||
this.handleVisibilityChange,
|
this.handleVisibilityChange,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
this.$store.dispatch('clearCurrentChat')
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
recipient() {
|
recipient() {
|
||||||
return this.currentChat && this.currentChat.account
|
return this.chat?.account
|
||||||
},
|
},
|
||||||
recipientId() {
|
recipientId() {
|
||||||
return this.$route.params.recipient_id
|
return this.$route.params.recipient_id
|
||||||
|
|
@ -98,37 +123,23 @@ const Chat = {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
chatViewItems() {
|
|
||||||
return chatService.getView(this.currentChatMessageService)
|
|
||||||
},
|
|
||||||
newMessageCount() {
|
|
||||||
return (
|
|
||||||
this.currentChatMessageService &&
|
|
||||||
this.currentChatMessageService.newMessageCount
|
|
||||||
)
|
|
||||||
},
|
|
||||||
streamingEnabled() {
|
streamingEnabled() {
|
||||||
return (
|
return (
|
||||||
this.mergedConfig.useStreamingApi &&
|
this.mergedConfig.useStreamingApi &&
|
||||||
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
|
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
...mapGetters([
|
|
||||||
'currentChat',
|
|
||||||
'currentChatMessageService',
|
|
||||||
'findOpenedChatByRecipientId',
|
|
||||||
]),
|
|
||||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
|
||||||
...mapPiniaState(useInterfaceStore, {
|
...mapPiniaState(useInterfaceStore, {
|
||||||
mobileLayout: (store) => store.layoutType === 'mobile',
|
mobileLayout: (store) => store.layoutType === 'mobile',
|
||||||
}),
|
}),
|
||||||
|
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
||||||
...mapState({
|
...mapState({
|
||||||
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
|
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
|
||||||
currentUser: (state) => state.users.currentUser,
|
currentUser: (state) => state.users.currentUser,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
chatViewItems() {
|
messages() {
|
||||||
// We don't want to scroll to the bottom on a new message when the user is viewing older 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.
|
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
|
||||||
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
|
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
|
||||||
|
|
@ -148,10 +159,6 @@ const Chat = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
onFilesDropped() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.handleResize()
|
this.handleResize()
|
||||||
|
|
@ -198,22 +205,25 @@ const Chat = {
|
||||||
this.readChat()
|
this.readChat()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
readChat() {
|
async readChat() {
|
||||||
if (
|
if (!this.maxId || document.hidden) {
|
||||||
!(
|
|
||||||
this.currentChatMessageService && this.currentChatMessageService.maxId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (document.hidden) {
|
const lastReadId = this.maxId
|
||||||
return
|
const isNewMessage = this.lastReadMessageId !== lastReadId
|
||||||
}
|
|
||||||
const lastReadId = this.currentChatMessageService.maxId
|
if (!isNewMessage) return
|
||||||
this.$store.dispatch('readChat', {
|
|
||||||
id: this.currentChat.id,
|
if (!this.testMode)
|
||||||
lastReadId,
|
await readChat({
|
||||||
})
|
id: this.chat.id,
|
||||||
|
lastReadId,
|
||||||
|
credentials: useOAuthStore().token,
|
||||||
|
})
|
||||||
|
|
||||||
|
useChatsStore().readChat(this.chat.id)
|
||||||
|
this.lastReadMessageId = this.maxId
|
||||||
|
this.newMessageCount = 0
|
||||||
},
|
},
|
||||||
bottomedOut(offset) {
|
bottomedOut(offset) {
|
||||||
return isBottomedOut(offset)
|
return isBottomedOut(offset)
|
||||||
|
|
@ -221,24 +231,38 @@ const Chat = {
|
||||||
reachedTop() {
|
reachedTop() {
|
||||||
return window.scrollY <= 0
|
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() {
|
cullOlderCheck() {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
||||||
this.$store.dispatch(
|
this.cullOlder()
|
||||||
'cullOlderMessages',
|
|
||||||
this.currentChatMessageService.chatId,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
},
|
},
|
||||||
handleScroll: throttle(function () {
|
handleScroll: throttle(function () {
|
||||||
this.lastScrollPosition = getScrollPosition()
|
if (!this.chat) {
|
||||||
if (!this.currentChat) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.lastScrollPosition = getScrollPosition()
|
||||||
|
|
||||||
if (this.reachedTop()) {
|
if (this.reachedTop()) {
|
||||||
this.fetchChat({ maxId: this.currentChatMessageService.minId })
|
this.fetchChat({ maxId: this.minId })
|
||||||
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
||||||
this.jumpToBottomButtonVisible = false
|
this.jumpToBottomButtonVisible = false
|
||||||
this.cullOlderCheck()
|
this.cullOlderCheck()
|
||||||
|
|
@ -257,85 +281,151 @@ const Chat = {
|
||||||
}, 200),
|
}, 200),
|
||||||
handleScrollUp(positionBeforeLoading) {
|
handleScrollUp(positionBeforeLoading) {
|
||||||
const positionAfterLoading = getScrollPosition()
|
const positionAfterLoading = getScrollPosition()
|
||||||
|
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
|
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
|
clear() {
|
||||||
const chatMessageService = this.currentChatMessageService
|
this.messages = this.messages.filter((m) => m.error)
|
||||||
if (!chatMessageService) {
|
this.messagesIndex = this.messages.reduce(
|
||||||
return
|
(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) {
|
if (fetchLatest && this.streamingEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatId = chatMessageService.chatId
|
const { data: messages } = await chatMessages({
|
||||||
const fetchOlderMessages = !!maxId
|
id: this.chat.id,
|
||||||
const sinceId = fetchLatest && chatMessageService.maxId
|
|
||||||
|
|
||||||
return chatMessages({
|
|
||||||
id: chatId,
|
|
||||||
maxId,
|
maxId,
|
||||||
sinceId,
|
sinceId: fetchLatest ? this.maxId : null,
|
||||||
credentials: useOAuthStore().token,
|
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() {
|
async startFetching() {
|
||||||
let chat = this.findOpenedChatByRecipientId(this.recipientId)
|
try {
|
||||||
if (!chat) {
|
const { data } = await getOrCreateChat({
|
||||||
try {
|
accountId: this.recipientId,
|
||||||
const { data } = await getOrCreateChat({
|
credentials: useOAuthStore().token,
|
||||||
accountId: this.recipientId,
|
})
|
||||||
credentials: useOAuthStore().token,
|
this.chat = data
|
||||||
})
|
} catch (e) {
|
||||||
chat = data
|
console.error('Error creating or getting a chat', e)
|
||||||
} catch (e) {
|
this.errorLoadingChat = true
|
||||||
console.error('Error creating or getting a chat', e)
|
|
||||||
this.errorLoadingChat = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (chat) {
|
|
||||||
|
if (this.chat) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.scrollDown({ forceRead: true })
|
this.scrollDown({ forceRead: true })
|
||||||
})
|
})
|
||||||
this.$store.dispatch('addOpenedChat', { chat })
|
|
||||||
this.doStartFetching()
|
this.doStartFetching()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
doStartFetching() {
|
doStartFetching() {
|
||||||
this.$store.dispatch('startFetchingCurrentChat', {
|
this.fetcher = promiseInterval(
|
||||||
fetcher: () =>
|
() => this.fetchChat({ fetchLatest: true }),
|
||||||
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
|
5000,
|
||||||
})
|
)
|
||||||
this.fetchChat({ isFirstFetch: true })
|
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() {
|
handleAttachmentPosting() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.handleResize()
|
this.handleResize()
|
||||||
|
|
@ -344,9 +434,9 @@ const Chat = {
|
||||||
this.scrollDown({ forceRead: true })
|
this.scrollDown({ forceRead: true })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
sendMessage({ status, media, idempotencyKey }) {
|
async sendMessage({ status, media, idempotencyKey }) {
|
||||||
const params = {
|
const params = {
|
||||||
id: this.currentChat.id,
|
id: this.chat.id,
|
||||||
content: status,
|
content: status,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
}
|
}
|
||||||
|
|
@ -357,69 +447,71 @@ const Chat = {
|
||||||
|
|
||||||
const fakeMessage = buildFakeMessage({
|
const fakeMessage = buildFakeMessage({
|
||||||
attachments: media,
|
attachments: media,
|
||||||
chatId: this.currentChat.id,
|
chatId: this.chat.id,
|
||||||
content: status,
|
content: status,
|
||||||
userId: this.currentUser.id,
|
userId: this.currentUser.id,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.$store
|
this.pendingMessages.push(fakeMessage)
|
||||||
.dispatch('addChatMessages', {
|
this.pendingMessagesIndex[idempotencyKey] = fakeMessage
|
||||||
chatId: this.currentChat.id,
|
|
||||||
messages: [fakeMessage],
|
this.handleAttachmentPosting()
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.handleAttachmentPosting()
|
|
||||||
})
|
|
||||||
|
|
||||||
return this.doSendMessage({
|
return this.doSendMessage({
|
||||||
params,
|
params,
|
||||||
fakeMessage,
|
|
||||||
retriesLeft: MAX_RETRIES,
|
retriesLeft: MAX_RETRIES,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
|
async doSendMessage({ params, retriesLeft = MAX_RETRIES }) {
|
||||||
if (retriesLeft <= 0) return
|
if (retriesLeft <= 0) return
|
||||||
|
|
||||||
sendChatMessage({
|
try {
|
||||||
...params,
|
const { data } = await sendChatMessage({
|
||||||
credentials: useOAuthStore().token,
|
...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 {}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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() {
|
goBack() {
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
|
|
@ -25,29 +25,12 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ChatMessageList
|
||||||
class="chat-message-list message-list"
|
header-date
|
||||||
:style="{ height: scrollableContainerHeight }"
|
:messages="messages"
|
||||||
>
|
:pending-messages="pendingMessages"
|
||||||
<template v-if="!errorLoadingChat">
|
@message-delete="deleteChatMessage"
|
||||||
<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>
|
|
||||||
<div
|
<div
|
||||||
ref="footer"
|
ref="footer"
|
||||||
class="panel-body footer"
|
class="panel-body footer"
|
||||||
|
|
@ -75,7 +58,7 @@
|
||||||
:disable-polls="true"
|
:disable-polls="true"
|
||||||
:disable-quotes="true"
|
:disable-quotes="true"
|
||||||
:disable-sensitivity-checkbox="true"
|
:disable-sensitivity-checkbox="true"
|
||||||
:disable-submit="errorLoadingChat || !currentChat"
|
:disable-submit="errorLoadingChat || !chat"
|
||||||
:disable-preview="true"
|
:disable-preview="true"
|
||||||
:disable-draft="true"
|
:disable-draft="true"
|
||||||
:optimistic-posting="true"
|
:optimistic-posting="true"
|
||||||
|
|
@ -95,5 +78,5 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./chat.js"></script>
|
<script src="./chat_view.js"></script>
|
||||||
<style src="./chat.scss" lang="scss" />
|
<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 as mapPiniaState } from 'pinia'
|
||||||
import { mapState } from 'vuex'
|
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 QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
|
||||||
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_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 ThreadTree from 'src/components/thread_tree/thread_tree.vue'
|
||||||
|
|
||||||
import { useInterfaceStore } from 'src/stores/interface'
|
import { useInterfaceStore } from 'src/stores/interface'
|
||||||
|
|
@ -18,9 +21,17 @@ import {
|
||||||
faAngleDoubleDown,
|
faAngleDoubleDown,
|
||||||
faAngleDoubleLeft,
|
faAngleDoubleLeft,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
|
faReply,
|
||||||
|
faTimes,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
|
library.add(
|
||||||
|
faAngleDoubleDown,
|
||||||
|
faAngleDoubleLeft,
|
||||||
|
faChevronLeft,
|
||||||
|
faReply,
|
||||||
|
faTimes,
|
||||||
|
)
|
||||||
|
|
||||||
const sortById = (a, b) => {
|
const sortById = (a, b) => {
|
||||||
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
|
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
|
||||||
|
|
@ -103,6 +114,7 @@ const conversation = {
|
||||||
inlineDivePosition: null,
|
inlineDivePosition: null,
|
||||||
loadStatusError: null,
|
loadStatusError: null,
|
||||||
unsuspendibleIds: new Set(),
|
unsuspendibleIds: new Set(),
|
||||||
|
explicitReplyStatus: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
|
@ -118,6 +130,12 @@ const conversation = {
|
||||||
const maxDepth = this.mergedConfig.maxDepthInThread - 2
|
const maxDepth = this.mergedConfig.maxDepthInThread - 2
|
||||||
return maxDepth >= 1 ? maxDepth : 1
|
return maxDepth >= 1 ? maxDepth : 1
|
||||||
},
|
},
|
||||||
|
lastStatus() {
|
||||||
|
return this.conversation[this.conversation.length - 1]
|
||||||
|
},
|
||||||
|
replyStatus() {
|
||||||
|
return this.explicitReplyStatus ?? this.lastStatus
|
||||||
|
},
|
||||||
streamingEnabled() {
|
streamingEnabled() {
|
||||||
return (
|
return (
|
||||||
this.mergedConfig.useStreamingApi &&
|
this.mergedConfig.useStreamingApi &&
|
||||||
|
|
@ -127,15 +145,18 @@ const conversation = {
|
||||||
displayStyle() {
|
displayStyle() {
|
||||||
return this.mergedConfig.conversationDisplay
|
return this.mergedConfig.conversationDisplay
|
||||||
},
|
},
|
||||||
isTreeView() {
|
|
||||||
return !this.isLinearView
|
|
||||||
},
|
|
||||||
treeViewIsSimple() {
|
treeViewIsSimple() {
|
||||||
return !this.mergedConfig.conversationTreeAdvanced
|
return !this.mergedConfig.conversationTreeAdvanced
|
||||||
},
|
},
|
||||||
|
isTreeView() {
|
||||||
|
return this.displayStyle === 'tree'
|
||||||
|
},
|
||||||
isLinearView() {
|
isLinearView() {
|
||||||
return this.displayStyle === 'linear'
|
return this.displayStyle === 'linear'
|
||||||
},
|
},
|
||||||
|
isChatView() {
|
||||||
|
return this.displayStyle === 'chat'
|
||||||
|
},
|
||||||
shouldFadeAncestors() {
|
shouldFadeAncestors() {
|
||||||
return this.mergedConfig.conversationTreeFadeAncestors
|
return this.mergedConfig.conversationTreeFadeAncestors
|
||||||
},
|
},
|
||||||
|
|
@ -404,6 +425,9 @@ const conversation = {
|
||||||
ThreadTree,
|
ThreadTree,
|
||||||
QuickFilterSettings,
|
QuickFilterSettings,
|
||||||
QuickViewSettings,
|
QuickViewSettings,
|
||||||
|
ChatMessageList,
|
||||||
|
PostStatusForm,
|
||||||
|
StatusContent,
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
statusId(newVal, oldVal) {
|
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>
|
||||||
<div
|
<div
|
||||||
v-if="isLinearView"
|
v-else-if="isLinearView || (isChatView && !isExpanded)"
|
||||||
class="thread-body"
|
class="thread-body"
|
||||||
>
|
>
|
||||||
<article>
|
<article>
|
||||||
|
|
@ -196,6 +196,58 @@
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -206,101 +258,4 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./conversation.js"></script>
|
<script src="./conversation.js"></script>
|
||||||
|
<style src="./conversation.scss" />
|
||||||
<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>
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { mapState as mapPiniaState } from 'pinia'
|
import { mapState } from 'pinia'
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
import { useSyncConfigStore } from 'src/stores/sync_config.js'
|
import { useSyncConfigStore } from 'src/stores/sync_config.js'
|
||||||
|
|
@ -21,7 +22,7 @@ const ExtraNotifications = {
|
||||||
return (
|
return (
|
||||||
this.mergedConfig.showExtraNotifications &&
|
this.mergedConfig.showExtraNotifications &&
|
||||||
this.mergedConfig.showChatsInExtraNotifications &&
|
this.mergedConfig.showChatsInExtraNotifications &&
|
||||||
this.unreadChatCount
|
this.unreadChatsCount
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
shouldShowAnnouncements() {
|
shouldShowAnnouncements() {
|
||||||
|
|
@ -53,11 +54,12 @@ const ExtraNotifications = {
|
||||||
currentUser() {
|
currentUser() {
|
||||||
return this.$store.state.users.currentUser
|
return this.$store.state.users.currentUser
|
||||||
},
|
},
|
||||||
...mapGetters(['unreadChatCount', 'followRequestCount']),
|
...mapGetters(['followRequestCount']),
|
||||||
...mapPiniaState(useAnnouncementsStore, {
|
...mapState(useAnnouncementsStore, {
|
||||||
unreadAnnouncementCount: 'unreadAnnouncementCount',
|
unreadAnnouncementCount: 'unreadAnnouncementCount',
|
||||||
}),
|
}),
|
||||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
...mapState(useMergedConfigStore, ['mergedConfig']),
|
||||||
|
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openNotificationSettings() {
|
openNotificationSettings() {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
class="fa-scale-110 icon"
|
class="fa-scale-110 icon"
|
||||||
icon="comments"
|
icon="comments"
|
||||||
/>
|
/>
|
||||||
{{ $t('notifications.unread_chats', { num: unreadChatCount }, unreadChatCount) }}
|
{{ $t('notifications.unread_chats', { num: unreadChatsCount }, unreadChatsCount) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { mapState } from 'pinia'
|
import { mapState } from 'pinia'
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { defineAsyncComponent } from 'vue'
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
||||||
import GestureService from '../../services/gesture_service/gesture_service'
|
import GestureService from '../../services/gesture_service/gesture_service'
|
||||||
|
|
@ -10,6 +9,7 @@ import {
|
||||||
} from '../../services/notification_utils/notification_utils'
|
} from '../../services/notification_utils/notification_utils'
|
||||||
|
|
||||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useInstanceStore } from 'src/stores/instance.js'
|
import { useInstanceStore } from 'src/stores/instance.js'
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
|
|
||||||
|
|
@ -68,6 +68,7 @@ const MobileNav = {
|
||||||
countExtraNotifications(
|
countExtraNotifications(
|
||||||
this.$store,
|
this.$store,
|
||||||
useMergedConfigStore().mergedConfig,
|
useMergedConfigStore().mergedConfig,
|
||||||
|
useChatsStore().unreadChatsCount,
|
||||||
useAnnouncementsStore().unreadAnnouncementCount,
|
useAnnouncementsStore().unreadAnnouncementCount,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -87,18 +88,18 @@ const MobileNav = {
|
||||||
isChat() {
|
isChat() {
|
||||||
return this.$route.name === 'chat'
|
return this.$route.name === 'chat'
|
||||||
},
|
},
|
||||||
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
|
|
||||||
...mapState(useMergedConfigStore, {
|
|
||||||
pinnedItems: (store) =>
|
|
||||||
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
|
|
||||||
}),
|
|
||||||
shouldConfirmLogout() {
|
shouldConfirmLogout() {
|
||||||
return useMergedConfigStore().mergedConfig.modalOnLogout
|
return useMergedConfigStore().mergedConfig.modalOnLogout
|
||||||
},
|
},
|
||||||
closingDrawerMarksAsSeen() {
|
closingDrawerMarksAsSeen() {
|
||||||
return useMergedConfigStore().mergedConfig.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: {
|
methods: {
|
||||||
toggleMobileSidebar() {
|
toggleMobileSidebar() {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
icon="bars"
|
icon="bars"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount"
|
v-if="(unreadChatsCount && !chatsPinned) || unreadAnnouncementCount"
|
||||||
class="badge -dot -notification"
|
class="badge -dot -notification"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { mapState as mapPiniaState } from 'pinia'
|
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 BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
|
||||||
import Checkbox from 'src/components/checkbox/checkbox.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 NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
||||||
|
|
||||||
import { useAnnouncementsStore } from 'src/stores/announcements'
|
import { useAnnouncementsStore } from 'src/stores/announcements'
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useInstanceStore } from 'src/stores/instance.js'
|
import { useInstanceStore } from 'src/stores/instance.js'
|
||||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||||
import { useSyncConfigStore } from 'src/stores/sync_config.js'
|
import { useSyncConfigStore } from 'src/stores/sync_config.js'
|
||||||
|
|
@ -131,6 +132,7 @@ const NavPanel = {
|
||||||
currentUser: (state) => state.users.currentUser,
|
currentUser: (state) => state.users.currentUser,
|
||||||
followRequestCount: (state) => state.api.followRequests.length,
|
followRequestCount: (state) => state.api.followRequests.length,
|
||||||
}),
|
}),
|
||||||
|
...mapPiniaState(useChatsStore, ['unreadChatsCount']),
|
||||||
timelinesItems() {
|
timelinesItems() {
|
||||||
return filterNavigation(
|
return filterNavigation(
|
||||||
Object.entries({ ...TIMELINES })
|
Object.entries({ ...TIMELINES })
|
||||||
|
|
@ -162,7 +164,6 @@ const NavPanel = {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
...mapGetters(['unreadChatCount']),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const ROOT_ITEMS = {
|
||||||
icon: 'comments',
|
icon: 'comments',
|
||||||
label: 'nav.chats',
|
label: 'nav.chats',
|
||||||
badgeStyle: 'notification',
|
badgeStyle: 'notification',
|
||||||
badgeGetter: 'unreadChatCount',
|
badgeGetter: 'unreadChatsCount',
|
||||||
criteria: ['chats'],
|
criteria: ['chats'],
|
||||||
},
|
},
|
||||||
friendRequests: {
|
friendRequests: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { mapState } from 'pinia'
|
import { mapState } from 'pinia'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import ExtraNotifications from 'src/components/extra_notifications/extra_notifications.vue'
|
import ExtraNotifications from 'src/components/extra_notifications/extra_notifications.vue'
|
||||||
import Notification from 'src/components/notification/notification.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 NotificationFilters from './notification_filters.vue'
|
||||||
|
|
||||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||||
|
|
||||||
|
|
@ -115,13 +115,14 @@ const Notifications = {
|
||||||
return countExtraNotifications(
|
return countExtraNotifications(
|
||||||
this.$store,
|
this.$store,
|
||||||
useMergedConfigStore().mergedConfig,
|
useMergedConfigStore().mergedConfig,
|
||||||
|
useChatsStore().unreadChatsCount,
|
||||||
useAnnouncementsStore().unreadAnnouncementCount,
|
useAnnouncementsStore().unreadAnnouncementCount,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
unseenCountTitle() {
|
unseenCountTitle() {
|
||||||
return (
|
return (
|
||||||
this.unseenNotifications.length +
|
this.unseenNotifications.length +
|
||||||
this.unreadChatCount +
|
this.unreadChatsCount +
|
||||||
this.unreadAnnouncementCount
|
this.unreadAnnouncementCount
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -160,7 +161,7 @@ const Notifications = {
|
||||||
return !this.noExtra
|
return !this.noExtra
|
||||||
},
|
},
|
||||||
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
|
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
|
||||||
...mapGetters(['unreadChatCount']),
|
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
|
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
|
||||||
|
|
|
||||||
|
|
@ -16,42 +16,45 @@ export default {
|
||||||
},
|
},
|
||||||
name: 'PollForm',
|
name: 'PollForm',
|
||||||
props: {
|
props: {
|
||||||
visible: {},
|
visible: Boolean,
|
||||||
params: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: false,
|
||||||
|
default: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
computed: {
|
computed: {
|
||||||
pollType: {
|
pollType: {
|
||||||
get() {
|
get() {
|
||||||
return pollFallback(this.params, 'pollType')
|
return pollFallback(this.modelValue, 'pollType')
|
||||||
},
|
},
|
||||||
set(newVal) {
|
set(newVal) {
|
||||||
this.params.pollType = newVal
|
this.$emit('update:modelValue', { ...this.modelValue, pollType: newVal })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
options() {
|
options: {
|
||||||
const hasOptions = !!this.params.options
|
get() {
|
||||||
if (!hasOptions) {
|
return pollFallback(this.modelValue, 'options')
|
||||||
this.params.options = pollFallback(this.params, 'options')
|
},
|
||||||
|
set(newVal) {
|
||||||
|
this.$emit('update:modelValue', { ...this.modelValue, options: newVal })
|
||||||
}
|
}
|
||||||
return this.params.options
|
|
||||||
},
|
},
|
||||||
expiryAmount: {
|
expiryAmount: {
|
||||||
get() {
|
get() {
|
||||||
return pollFallback(this.params, 'expiryAmount')
|
return pollFallback(this.modelValue, 'expiryAmount')
|
||||||
},
|
},
|
||||||
set(newVal) {
|
set(newVal) {
|
||||||
this.params.expiryAmount = newVal
|
this.$emit('update:modelValue', { ...this.modelValue, expiryAmount: newVal })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expiryUnit: {
|
expiryUnit: {
|
||||||
get() {
|
get() {
|
||||||
return pollFallback(this.params, 'expiryUnit')
|
return pollFallback(this.modelValue, 'expiryUnit')
|
||||||
},
|
},
|
||||||
set(newVal) {
|
set(newVal) {
|
||||||
this.params.expiryUnit = newVal
|
this.$emit('update:modelValue', { ...this.modelValue, expiryUnit: newVal })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pollLimits() {
|
pollLimits() {
|
||||||
|
|
@ -88,12 +91,6 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clear() {
|
|
||||||
this.pollType = 'single'
|
|
||||||
this.options = ['', '']
|
|
||||||
this.expiryAmount = 10
|
|
||||||
this.expiryUnit = 'minutes'
|
|
||||||
},
|
|
||||||
nextOption(index) {
|
nextOption(index) {
|
||||||
const element = this.$el.querySelector(`#poll-${index + 1}`)
|
const element = this.$el.querySelector(`#poll-${index + 1}`)
|
||||||
if (element) {
|
if (element) {
|
||||||
|
|
@ -110,7 +107,7 @@ export default {
|
||||||
},
|
},
|
||||||
addOption() {
|
addOption() {
|
||||||
if (this.options.length < this.maxOptions) {
|
if (this.options.length < this.maxOptions) {
|
||||||
this.options.push('')
|
this.options = [...this.options, '']
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -118,8 +115,12 @@ export default {
|
||||||
deleteOption(index) {
|
deleteOption(index) {
|
||||||
if (this.options.length > 2) {
|
if (this.options.length > 2) {
|
||||||
this.options.splice(index, 1)
|
this.options.splice(index, 1)
|
||||||
|
this.options = this.options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateOption(index, value) {
|
||||||
|
this.options = this.options
|
||||||
|
},
|
||||||
convertExpiryToUnit(unit, amount) {
|
convertExpiryToUnit(unit, amount) {
|
||||||
// Note: we want seconds and not milliseconds
|
// Note: we want seconds and not milliseconds
|
||||||
return DateUtils.secondsToUnit(unit, amount)
|
return DateUtils.secondsToUnit(unit, amount)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
:placeholder="$t('polls.option')"
|
:placeholder="$t('polls.option')"
|
||||||
:maxlength="maxLength"
|
:maxlength="maxLength"
|
||||||
@keydown.enter.stop.prevent="nextOption(index)"
|
@keydown.enter.stop.prevent="nextOption(index)"
|
||||||
|
@change="updateOption"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { debounce, map, reject, uniqBy } from 'lodash'
|
import { debounce, reject, uniqBy } from 'lodash'
|
||||||
import { mapActions, mapState } from 'pinia'
|
import { mapActions, mapState } from 'pinia'
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
|
@ -55,78 +55,66 @@ library.add(
|
||||||
faChevronRight,
|
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
|
// Converts a string with px to a number like '2px' -> 2
|
||||||
const pxStringToNumber = (str) => {
|
const pxStringToNumber = (str) => {
|
||||||
return Number(str.substring(0, str.length - 2))
|
return Number(str.substring(0, str.length - 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeAndRefId = ({ replyTo, profileMention, statusId }) => {
|
const DEFAULT_NEWSTATUS = {
|
||||||
if (replyTo) {
|
|
||||||
return ['reply', replyTo]
|
|
||||||
} else if (profileMention) {
|
|
||||||
return ['mention', profileMention]
|
|
||||||
} else if (statusId) {
|
|
||||||
return ['edit', statusId]
|
|
||||||
} else {
|
|
||||||
return ['new', '']
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostStatusForm = {
|
const PostStatusForm = {
|
||||||
props: [
|
props: {
|
||||||
'statusId',
|
// Status editing stuff
|
||||||
'statusText',
|
statusId: String,
|
||||||
'statusIsSensitive',
|
statusText: String,
|
||||||
'statusPoll',
|
statusIsSensitive: {
|
||||||
'statusFiles',
|
type: Boolean,
|
||||||
'statusMediaDescriptions',
|
required: false,
|
||||||
'statusScope',
|
default: null, // Avoiding automatic conversion null -> false
|
||||||
'statusContentType',
|
},
|
||||||
'replyTo',
|
statusPoll: Object,
|
||||||
'repliedUser',
|
statusQuote: Object,
|
||||||
'attentions',
|
statusFiles: Array,
|
||||||
'copyMessageScope',
|
statusMediaDescriptions: Object,
|
||||||
'subject',
|
statusScope: String,
|
||||||
'disableSubject',
|
statusContentType: String,
|
||||||
'disableScopeSelector',
|
// Replies/mentions
|
||||||
'disableVisibilitySelector',
|
replyTo: String,
|
||||||
'disableNotice',
|
repliedUser: Object,
|
||||||
'disableLockWarning',
|
mentionsLine: Boolean,
|
||||||
'disablePolls',
|
mentionsLineReadOnly: Boolean,
|
||||||
'disableQuotes',
|
attentions: Array,
|
||||||
'disableSensitivityCheckbox',
|
subject: String,
|
||||||
'disableSubmit',
|
copyMessageScope: String,
|
||||||
'disablePreview',
|
profileMention: String,
|
||||||
'disableDraft',
|
// Draft stuff
|
||||||
'hideDraft',
|
hideDraft: Boolean,
|
||||||
'closeable',
|
closeable: Boolean,
|
||||||
'placeholder',
|
draftId: String,
|
||||||
'maxHeight',
|
// Chats stuff
|
||||||
'postHandler',
|
maxHeight: Number,
|
||||||
'preserveFocus',
|
placeholder: String,
|
||||||
'autoFocus',
|
postHandler: Function,
|
||||||
'fileLimit',
|
preserveFocus: Boolean,
|
||||||
'submitOnEnter',
|
autoFocus: Boolean,
|
||||||
'emojiPickerPlacement',
|
fileLimit: Number,
|
||||||
'optimisticPosting',
|
submitOnEnter: Boolean,
|
||||||
'profileMention',
|
emojiPickerPlacement: String,
|
||||||
'draftId',
|
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: [
|
emits: [
|
||||||
'posted',
|
'posted',
|
||||||
'draft-done',
|
'draft-done',
|
||||||
|
|
@ -136,6 +124,41 @@ const PostStatusForm = {
|
||||||
'close-accepted',
|
'close-accepted',
|
||||||
'update',
|
'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: {
|
components: {
|
||||||
MediaUpload,
|
MediaUpload,
|
||||||
EmojiInput,
|
EmojiInput,
|
||||||
|
|
@ -154,6 +177,53 @@ const PostStatusForm = {
|
||||||
DraftCloser,
|
DraftCloser,
|
||||||
Popover,
|
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() {
|
mounted() {
|
||||||
this.updateIdempotencyKey()
|
this.updateIdempotencyKey()
|
||||||
this.resize(this.$refs.textarea)
|
this.resize(this.$refs.textarea)
|
||||||
|
|
@ -167,229 +237,84 @@ const PostStatusForm = {
|
||||||
this.$refs.textarea.focus()
|
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: {
|
computed: {
|
||||||
users() {
|
// Visibility / expansion state of subcomponents
|
||||||
return this.$store.state.users.users
|
pollFormVisible() {
|
||||||
|
return this.hasPoll
|
||||||
},
|
},
|
||||||
userDefaultScope() {
|
quoteFormVisible() {
|
||||||
return this.$store.state.users.currentUser.default_scope
|
return this.hasQuote && !this.newStatus.quote.thread
|
||||||
},
|
|
||||||
showAllScopes() {
|
|
||||||
return !this.mergedConfig.minimalScopesMode
|
|
||||||
},
|
|
||||||
hideExtraActions() {
|
|
||||||
return this.disableDraft || this.hideDraft
|
|
||||||
},
|
|
||||||
emojiUserSuggestor() {
|
|
||||||
return suggestor({
|
|
||||||
emoji: [
|
|
||||||
...useEmojiStore().standardEmojiList,
|
|
||||||
...useEmojiStore().customEmoji,
|
|
||||||
],
|
|
||||||
store: this.$store,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
emojiSuggestor() {
|
|
||||||
return suggestor({
|
|
||||||
emoji: [
|
|
||||||
...useEmojiStore().standardEmojiList,
|
|
||||||
...useEmojiStore().customEmoji,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
emoji() {
|
|
||||||
return useEmojiStore().standardEmojiList
|
|
||||||
},
|
|
||||||
customEmoji() {
|
|
||||||
return useEmojiStore().customEmoji
|
|
||||||
},
|
|
||||||
statusLength() {
|
|
||||||
return this.newStatus.status.length
|
|
||||||
},
|
|
||||||
spoilerTextLength() {
|
|
||||||
return this.newStatus.spoilerText.length
|
|
||||||
},
|
|
||||||
statusLengthLimit() {
|
|
||||||
return useInstanceStore().limits.textLimit
|
|
||||||
},
|
|
||||||
hasStatusLengthLimit() {
|
|
||||||
return this.statusLengthLimit > 0
|
|
||||||
},
|
|
||||||
charactersLeft() {
|
|
||||||
return (
|
|
||||||
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
isOverLengthLimit() {
|
|
||||||
return this.hasStatusLengthLimit && this.charactersLeft < 0
|
|
||||||
},
|
|
||||||
minimalScopesMode() {
|
|
||||||
return useInstanceStore().minimalScopesMode
|
|
||||||
},
|
|
||||||
alwaysShowSubject() {
|
|
||||||
return this.mergedConfig.alwaysShowSubjectInput
|
|
||||||
},
|
|
||||||
postFormats() {
|
|
||||||
return useInstanceCapabilitiesStore().postFormats || []
|
|
||||||
},
|
|
||||||
safeDMEnabled() {
|
|
||||||
return useInstanceCapabilitiesStore().safeDM
|
|
||||||
},
|
|
||||||
pollsAvailable() {
|
|
||||||
return (
|
|
||||||
useInstanceCapabilitiesStore().pollsAvailable &&
|
|
||||||
useInstanceStore().limits.pollLimits.max_options >= 2 &&
|
|
||||||
this.disablePolls !== true
|
|
||||||
)
|
|
||||||
},
|
|
||||||
hideScopeNotice() {
|
|
||||||
return (
|
|
||||||
this.disableNotice ||
|
|
||||||
useMergedConfigStore().mergedConfig.hideScopeNotice
|
|
||||||
)
|
|
||||||
},
|
|
||||||
pollContentError() {
|
|
||||||
return (
|
|
||||||
this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
showPreview() {
|
showPreview() {
|
||||||
return !this.disablePreview && (!!this.preview || this.previewLoading)
|
return !this.disablePreview && (!!this.preview || this.previewLoading)
|
||||||
},
|
},
|
||||||
emptyStatus() {
|
|
||||||
return (
|
// Composition stuff
|
||||||
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
|
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() {
|
refId() {
|
||||||
return this.newStatus.files.length >= this.fileLimit
|
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() {
|
isEdit() {
|
||||||
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
|
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
|
||||||
},
|
},
|
||||||
quotingAvailable() {
|
// -Reply
|
||||||
if (!useInstanceCapabilitiesStore().quotingAvailable) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.disableQuotes !== true
|
|
||||||
},
|
|
||||||
isReply() {
|
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() {
|
quotable() {
|
||||||
return this.quotingAvailable && this.replyTo
|
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() {
|
defaultQuotable() {
|
||||||
if (
|
if (
|
||||||
!this.quotingAvailable ||
|
!this.quotingAvailable ||
|
||||||
|
|
@ -412,33 +337,91 @@ const PostStatusForm = {
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
} else if (repliedStatus.visibility === 'private') {
|
} else if (repliedStatus.visibility === 'private') {
|
||||||
return repliedStatus.user.id === this.$store.state.users.currentUser.id
|
return repliedStatus.user.id === this.currentUser.id
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
inReplyStatusId() {
|
|
||||||
return !this.newStatus.hasQuote ||
|
|
||||||
!this.newStatus.quote.thread ||
|
|
||||||
!this.newStatus.quote.id
|
|
||||||
? this.replyTo
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
quoteId() {
|
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() {
|
debouncedMaybeAutoSaveDraft() {
|
||||||
return debounce(this.maybeAutoSaveDraft, 3000)
|
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() {
|
autoSaveState() {
|
||||||
if (this.saveable) {
|
if (this.saveable) {
|
||||||
return this.$t('post_status.auto_save_saving')
|
return this.$t('post_status.auto_save_saving')
|
||||||
|
|
@ -452,9 +435,9 @@ const PostStatusForm = {
|
||||||
return (
|
return (
|
||||||
(this.newStatus.status ||
|
(this.newStatus.status ||
|
||||||
this.newStatus.spoilerText ||
|
this.newStatus.spoilerText ||
|
||||||
this.newStatus.files?.length ||
|
this.newStatus.files.length ||
|
||||||
this.newStatus.hasPoll ||
|
this.hasPoll ||
|
||||||
this.newStatus.hasQuote) &&
|
this.hasQuote) &&
|
||||||
this.saveable
|
this.saveable
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -464,12 +447,78 @@ const PostStatusForm = {
|
||||||
!(
|
!(
|
||||||
this.newStatus.status ||
|
this.newStatus.status ||
|
||||||
this.newStatus.spoilerText ||
|
this.newStatus.spoilerText ||
|
||||||
this.newStatus.files?.length ||
|
this.newStatus.files.length ||
|
||||||
this.newStatus.hasPoll ||
|
this.hasPoll ||
|
||||||
this.newStatus.hasQuote
|
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(useMergedConfigStore, ['mergedConfig']),
|
||||||
...mapState(useInterfaceStore, {
|
...mapState(useInterfaceStore, {
|
||||||
mobileLayout: (store) => store.mobileLayout,
|
mobileLayout: (store) => store.mobileLayout,
|
||||||
|
|
@ -479,7 +528,7 @@ const PostStatusForm = {
|
||||||
newStatus: {
|
newStatus: {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler() {
|
handler() {
|
||||||
this.statusChanged()
|
if (this.initialized) this.statusChanged()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
saveable(val) {
|
saveable(val) {
|
||||||
|
|
@ -505,23 +554,32 @@ const PostStatusForm = {
|
||||||
this.saveable = true
|
this.saveable = true
|
||||||
this.saveInhibited = false
|
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() {
|
clearStatus() {
|
||||||
const newStatus = this.newStatus
|
const newStatus = this.newStatus
|
||||||
this.saveInhibited = true
|
this.saveInhibited = true
|
||||||
this.newStatus = {
|
this.newStatus.status = ''
|
||||||
status: '',
|
this.newStatus.mentionsLine = '',
|
||||||
spoilerText: '',
|
this.newStatus.spoilerText = '',
|
||||||
files: [],
|
this.newStatus.files = [],
|
||||||
visibility: newStatus.visibility,
|
this.newStatus.poll = null,
|
||||||
contentType: newStatus.contentType,
|
this.newStatus.quote = null,
|
||||||
poll: {},
|
this.newStatus.mediaDescriptions = {},
|
||||||
hasPoll: false,
|
|
||||||
hasQuote: false,
|
|
||||||
quote: {},
|
|
||||||
mediaDescriptions: {},
|
|
||||||
}
|
|
||||||
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
|
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
|
||||||
this.clearPollForm()
|
|
||||||
this.clearQuoteForm()
|
this.clearQuoteForm()
|
||||||
if (this.preserveFocus) {
|
if (this.preserveFocus) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|
@ -562,7 +620,7 @@ const PostStatusForm = {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const poll = newStatus.hasPoll ? pollFormToMasto(newStatus.poll) : {}
|
const poll = this.hasPoll ? pollFormToMasto(newStatus.poll) : {}
|
||||||
if (this.pollContentError) {
|
if (this.pollContentError) {
|
||||||
this.error = this.pollContentError
|
this.error = this.pollContentError
|
||||||
return
|
return
|
||||||
|
|
@ -579,7 +637,7 @@ const PostStatusForm = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const postingOptions = {
|
const postingOptions = {
|
||||||
status: newStatus.status,
|
status: this.newStatusContent,
|
||||||
spoilerText: newStatus.spoilerText || null,
|
spoilerText: newStatus.spoilerText || null,
|
||||||
visibility: newStatus.visibility,
|
visibility: newStatus.visibility,
|
||||||
sensitive: newStatus.nsfw,
|
sensitive: newStatus.nsfw,
|
||||||
|
|
@ -620,7 +678,7 @@ const PostStatusForm = {
|
||||||
|
|
||||||
statusPoster
|
statusPoster
|
||||||
.postStatus({
|
.postStatus({
|
||||||
status: newStatus.status,
|
status: this.newStatusContent,
|
||||||
spoilerText: newStatus.spoilerText || null,
|
spoilerText: newStatus.spoilerText || null,
|
||||||
visibility: newStatus.visibility,
|
visibility: newStatus.visibility,
|
||||||
sensitive: newStatus.nsfw,
|
sensitive: newStatus.nsfw,
|
||||||
|
|
@ -857,29 +915,16 @@ const PostStatusForm = {
|
||||||
this.newStatus.visibility = visibility
|
this.newStatus.visibility = visibility
|
||||||
},
|
},
|
||||||
togglePollForm() {
|
togglePollForm() {
|
||||||
this.newStatus.hasPoll = !this.newStatus.hasPoll
|
this.newStatus.poll = this.hasPoll ? null : {}
|
||||||
},
|
},
|
||||||
setPoll(poll) {
|
setPoll(poll) {
|
||||||
this.newStatus.poll = poll
|
this.newStatus.poll = poll
|
||||||
},
|
},
|
||||||
clearPollForm() {
|
|
||||||
if (this.$refs.pollForm) {
|
|
||||||
this.$refs.pollForm.clear()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clearQuoteForm() {
|
clearQuoteForm() {
|
||||||
if (this.$refs.quoteForm) {
|
if (this.$refs.quoteForm) {
|
||||||
this.$refs.quoteForm.clear()
|
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() {
|
dismissScopeNotice() {
|
||||||
useSyncConfigStore().setSimplePrefAndSave({
|
useSyncConfigStore().setSimplePrefAndSave({
|
||||||
path: 'hideScopeNotice',
|
path: 'hideScopeNotice',
|
||||||
|
|
@ -947,14 +992,14 @@ const PostStatusForm = {
|
||||||
abandonDraft() {
|
abandonDraft() {
|
||||||
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
|
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
|
||||||
},
|
},
|
||||||
getDraft(statusType, refId) {
|
getDraft() {
|
||||||
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
|
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
|
||||||
if (this.draftId && maybeDraft) {
|
if (this.draftId && maybeDraft) {
|
||||||
return maybeDraft
|
return maybeDraft
|
||||||
} else {
|
} else {
|
||||||
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
|
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
|
||||||
statusType,
|
this.statusType,
|
||||||
refId,
|
this.refId,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingDrafts.length) {
|
if (existingDrafts.length) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
.form-bottom {
|
.form-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.5em;
|
padding: 0.5em 0.5em 0.25em;
|
||||||
height: 2.5em;
|
height: 2.5em;
|
||||||
|
|
||||||
.post-button-group {
|
.post-button-group {
|
||||||
|
|
@ -206,6 +206,15 @@
|
||||||
|
|
||||||
.inputs-wrapper {
|
.inputs-wrapper {
|
||||||
padding: 0;
|
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 {
|
textarea.input.form-post-body {
|
||||||
|
|
@ -236,6 +245,10 @@
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mentions-input {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.character-counter {
|
.character-counter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<div
|
<div
|
||||||
ref="form"
|
ref="form"
|
||||||
class="post-status-form"
|
class="post-status-form"
|
||||||
|
v-if="initialized"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
|
@ -10,7 +11,7 @@
|
||||||
>
|
>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div
|
<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"
|
class="visibility-notice notice-dismissible"
|
||||||
>
|
>
|
||||||
<i18n-t
|
<i18n-t
|
||||||
|
|
@ -58,7 +59,7 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<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"
|
class="visibility-notice notice-dismissible"
|
||||||
>
|
>
|
||||||
<span>{{ $t('post_status.scope_notice.private') }}</span>
|
<span>{{ $t('post_status.scope_notice.private') }}</span>
|
||||||
|
|
@ -172,6 +173,16 @@
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</EmojiInput>
|
</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
|
<EmojiInput
|
||||||
ref="emoji-input"
|
ref="emoji-input"
|
||||||
v-model="newStatus.status"
|
v-model="newStatus.status"
|
||||||
|
|
@ -260,14 +271,14 @@
|
||||||
v-if="pollsAvailable"
|
v-if="pollsAvailable"
|
||||||
ref="pollForm"
|
ref="pollForm"
|
||||||
:visible="pollFormVisible"
|
:visible="pollFormVisible"
|
||||||
:params="newStatus.poll"
|
v-model="newStatus.poll"
|
||||||
/>
|
/>
|
||||||
<QuoteForm
|
<QuoteForm
|
||||||
v-if="quotingAvailable"
|
v-if="quotingAvailable"
|
||||||
:id="newStatus.quote.id"
|
|
||||||
ref="quoteForm"
|
ref="quoteForm"
|
||||||
:visible="quoteFormVisible"
|
:visible="quoteFormVisible"
|
||||||
:url="newStatus.quote.url"
|
:id="newStatus.quote?.id"
|
||||||
|
:url="newStatus.quote?.url"
|
||||||
@update:url="url => newStatus.quote.url = url"
|
@update:url="url => newStatus.quote.url = url"
|
||||||
@update:id="id => newStatus.quote.id = id"
|
@update:id="id => newStatus.quote.id = id"
|
||||||
/>
|
/>
|
||||||
|
|
@ -370,6 +381,14 @@
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-show="showDropIcon !== 'hide'"
|
v-show="showDropIcon !== 'hide'"
|
||||||
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
|
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import {
|
||||||
faBars,
|
faBars,
|
||||||
faFolderTree,
|
faFolderTree,
|
||||||
faList,
|
faList,
|
||||||
|
faMessage,
|
||||||
faWrench,
|
faWrench,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
library.add(faList, faFolderTree, faBars, faWrench)
|
library.add(faList, faFolderTree, faBars, faWrench, faMessage)
|
||||||
|
|
||||||
const QuickViewSettings = {
|
const QuickViewSettings = {
|
||||||
props: {
|
props: {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,24 @@
|
||||||
/> {{ $t('settings.conversation_display_linear_quick') }}
|
/> {{ $t('settings.conversation_display_linear_quick') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import GestureService from '../../services/gesture_service/gesture_service'
|
||||||
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
||||||
|
|
||||||
import { useAnnouncementsStore } from 'src/stores/announcements'
|
import { useAnnouncementsStore } from 'src/stores/announcements'
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useInstanceStore } from 'src/stores/instance.js'
|
import { useInstanceStore } from 'src/stores/instance.js'
|
||||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||||
import { useInterfaceStore } from 'src/stores/interface'
|
import { useInterfaceStore } from 'src/stores/interface'
|
||||||
|
|
@ -113,7 +114,8 @@ const SideDrawer = {
|
||||||
sitename: (store) => store.instanceIdentity.name,
|
sitename: (store) => store.instanceIdentity.name,
|
||||||
hideSitename: (store) => store.instanceIdentity.hideSitename,
|
hideSitename: (store) => store.instanceIdentity.hideSitename,
|
||||||
}),
|
}),
|
||||||
...mapGetters(['unreadChatCount', 'draftCount']),
|
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||||
|
...mapGetters(['draftCount']),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleDrawer() {
|
toggleDrawer() {
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,10 @@
|
||||||
icon="comments"
|
icon="comments"
|
||||||
/> {{ $t("nav.chats") }}
|
/> {{ $t("nav.chats") }}
|
||||||
<span
|
<span
|
||||||
v-if="unreadChatCount"
|
v-if="unreadChatsCount"
|
||||||
class="badge -notification"
|
class="badge -notification"
|
||||||
>
|
>
|
||||||
{{ unreadChatCount }}
|
{{ unreadChatsCount }}
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -385,4 +385,8 @@
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-action-buttons {
|
||||||
|
margin-top: var(--status-margin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,7 @@
|
||||||
|
|
||||||
<StatusActionButtons
|
<StatusActionButtons
|
||||||
v-if="!noHeading && !isPreview"
|
v-if="!noHeading && !isPreview"
|
||||||
|
class="status-action-buttons"
|
||||||
:status="status"
|
:status="status"
|
||||||
:replying="replying"
|
:replying="replying"
|
||||||
@toggle-replying="toggleReplyForm"
|
@toggle-replying="toggleReplyForm"
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ export default {
|
||||||
'getComponent',
|
'getComponent',
|
||||||
'doAction',
|
'doAction',
|
||||||
'outerClose',
|
'outerClose',
|
||||||
|
'defaultButtonStyle',
|
||||||
|
'hideLabel',
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
StatusBookmarkFolderMenu,
|
StatusBookmarkFolderMenu,
|
||||||
|
|
@ -103,11 +105,14 @@ export default {
|
||||||
return useMergedConfigStore().mergedConfig.hidePostStats
|
return useMergedConfigStore().mergedConfig.hidePostStats
|
||||||
},
|
},
|
||||||
buttonInnerClass() {
|
buttonInnerClass() {
|
||||||
|
const buttonStyleClass = this.defaultButtonStyle
|
||||||
|
? 'button-default'
|
||||||
|
: 'button-unstyled'
|
||||||
return [
|
return [
|
||||||
this.button.name + '-button',
|
this.button.name + '-button',
|
||||||
{
|
{
|
||||||
'main-button': this.extra,
|
'main-button': this.extra,
|
||||||
'button-unstyled': !this.extra,
|
[buttonStyleClass]: !this.extra,
|
||||||
'-active': this.button.active?.(this.funcArg),
|
'-active': this.button.active?.(this.funcArg),
|
||||||
disabled: this.button.interactive
|
disabled: this.button.interactive
|
||||||
? !this.button.interactive(this.funcArg)
|
? !this.button.interactive(this.funcArg)
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
/>
|
/>
|
||||||
</component>
|
</component>
|
||||||
<span
|
<span
|
||||||
v-if="!hidePostStats && button.counter?.(funcArg) > 0"
|
v-if="!hidePostStats && button.counter?.(funcArg) > 0 && !hideLabel"
|
||||||
class="action-counter"
|
class="action-counter"
|
||||||
>
|
>
|
||||||
{{ button.counter?.(funcArg) }}
|
{{ button.counter?.(funcArg) }}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export default {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
props: ['button', 'status'],
|
props: ['button', 'status', 'defaultButton', 'hideLabel'],
|
||||||
emits: ['emojiPickerShown'],
|
emits: ['emojiPickerShown'],
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.button.name === 'mute') {
|
if (this.button.name === 'mute') {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<ActionButton
|
<ActionButton
|
||||||
:button="button"
|
:button="button"
|
||||||
:status="status"
|
:status="status"
|
||||||
|
:hide-label="hideLabel"
|
||||||
v-bind.prop="$attrs"
|
v-bind.prop="$attrs"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -131,6 +132,7 @@
|
||||||
v-else
|
v-else
|
||||||
:button="button"
|
:button="button"
|
||||||
:status="status"
|
:status="status"
|
||||||
|
:hide-label="hideLabel"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@emoji-picker-shown="e => $emit('emojiPickerShown', e)"
|
@emoji-picker-shown="e => $emit('emojiPickerShown', e)"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,31 @@ import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
|
||||||
library.add(faEllipsisH)
|
library.add(faEllipsisH)
|
||||||
|
|
||||||
const StatusActionButtons = {
|
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'],
|
emits: ['toggleReplying', 'onSuccess', 'onError'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -36,14 +60,20 @@ const StatusActionButtons = {
|
||||||
ConfirmModal: defineAsyncComponent(
|
ConfirmModal: defineAsyncComponent(
|
||||||
() => import('src/components/confirm_modal/confirm_modal.vue'),
|
() => import('src/components/confirm_modal/confirm_modal.vue'),
|
||||||
),
|
),
|
||||||
|
|
||||||
ActionButtonContainer,
|
ActionButtonContainer,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useSyncConfigStore, {
|
...mapState(useSyncConfigStore, {
|
||||||
pinnedItems: (store) =>
|
userPinnedItems: (store) =>
|
||||||
new Set(store.prefsStorage.collections.pinnedStatusActions),
|
new Set(store.prefsStorage.collections.pinnedStatusActions),
|
||||||
}),
|
}),
|
||||||
|
pinnedItems() {
|
||||||
|
if (this.fixedPinned) {
|
||||||
|
return this.pinned
|
||||||
|
} else {
|
||||||
|
return this.userPinnedItems
|
||||||
|
}
|
||||||
|
},
|
||||||
buttons() {
|
buttons() {
|
||||||
return BUTTONS.filter((x) => (x.if ? x.if(this.funcArg) : true))
|
return BUTTONS.filter((x) => (x.if ? x.if(this.funcArg) : true))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
grid-auto-flow: row dense;
|
grid-auto-flow: row dense;
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
grid-gap: 0.5em 0.1em;
|
grid-gap: 0.5em 0.1em;
|
||||||
margin-top: var(--status-margin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-action-button {
|
.pin-action-button {
|
||||||
|
|
@ -17,6 +16,12 @@
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-action.popover-wrapper {
|
||||||
|
button {
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// popover
|
// popover
|
||||||
.extra-action-buttons {
|
.extra-action-buttons {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
:close="() => { /* no-op */ }"
|
:close="() => { /* no-op */ }"
|
||||||
:do-action="doAction"
|
:do-action="doAction"
|
||||||
@emoji-picker-shown="onEmojiPickerShown"
|
@emoji-picker-shown="onEmojiPickerShown"
|
||||||
|
:default-button-style="useDefaultButtons"
|
||||||
|
:hide-label="hideLabels"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="showPin && currentUser"
|
v-if="showPin && currentUser"
|
||||||
|
|
@ -40,6 +42,7 @@
|
||||||
<Popover
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
:trigger-attrs="triggerAttrs"
|
:trigger-attrs="triggerAttrs"
|
||||||
|
:normal-button="useDefaultButtons"
|
||||||
class="quick-action"
|
class="quick-action"
|
||||||
:tabindex="0"
|
:tabindex="0"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
|
|
@ -75,6 +78,8 @@
|
||||||
:get-component="getComponent"
|
:get-component="getComponent"
|
||||||
:outer-close="close"
|
:outer-close="close"
|
||||||
:do-action="doAction"
|
:do-action="doAction"
|
||||||
|
:default-button-style="useDefaultButtons"
|
||||||
|
:hide-label="hideLabels"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="showPin && currentUser"
|
v-if="showPin && currentUser"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
&.-single-line {
|
&.-single-line {
|
||||||
white-space: nowrap;
|
white-space-collapse: collapse;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 1.4em;
|
height: 1.4em;
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,9 @@
|
||||||
},
|
},
|
||||||
"content_type_selection": "Post format",
|
"content_type_selection": "Post format",
|
||||||
"content_warning": "Subject (optional)",
|
"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.",
|
"default": "Just landed in L.A.",
|
||||||
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
|
"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.",
|
"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",
|
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
|
||||||
"conversation_display_linear": "Linear-style",
|
"conversation_display_linear": "Linear-style",
|
||||||
"conversation_display_linear_quick": "Linear view",
|
"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": "Show the \"other replies\" button",
|
||||||
"conversation_other_replies_button_below": "Below statuses",
|
"conversation_other_replies_button_below": "Below statuses",
|
||||||
"conversation_other_replies_button_inside": "Inside statuses",
|
"conversation_other_replies_button_inside": "Inside statuses",
|
||||||
|
|
@ -1654,6 +1659,8 @@
|
||||||
"delete_confirm_accept_button": "Delete",
|
"delete_confirm_accept_button": "Delete",
|
||||||
"delete_confirm_cancel_button": "Keep",
|
"delete_confirm_cancel_button": "Keep",
|
||||||
"reply_to": "Reply to",
|
"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_icon": "{icon} {replyTo}",
|
||||||
"reply_to_with_arg": "{replyToWithIcon} {user}",
|
"reply_to_with_arg": "{replyToWithIcon} {user}",
|
||||||
"mentions": "Mentions",
|
"mentions": "Mentions",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Socket } from 'phoenix'
|
||||||
|
|
||||||
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
|
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 { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||||
|
|
@ -174,7 +175,7 @@ const api = {
|
||||||
) {
|
) {
|
||||||
dispatch('stopFetchingTimeline', { timeline: 'friends' })
|
dispatch('stopFetchingTimeline', { timeline: 'friends' })
|
||||||
dispatch('stopFetchingNotifications')
|
dispatch('stopFetchingNotifications')
|
||||||
dispatch('stopFetchingChats')
|
useChatsStore().stopFetchingChats()
|
||||||
}
|
}
|
||||||
commit('resetRetryMultiplier')
|
commit('resetRetryMultiplier')
|
||||||
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
|
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 api from './api.js'
|
||||||
import chats from './chats.js'
|
|
||||||
import drafts from './drafts.js'
|
import drafts from './drafts.js'
|
||||||
import notifications from './notifications.js'
|
import notifications from './notifications.js'
|
||||||
import profileConfig from './profileConfig.js'
|
import profileConfig from './profileConfig.js'
|
||||||
|
|
@ -13,5 +12,4 @@ export default {
|
||||||
api,
|
api,
|
||||||
profileConfig,
|
profileConfig,
|
||||||
drafts,
|
drafts,
|
||||||
chats,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
|
|
||||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||||
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
|
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
|
||||||
|
import { useChatsStore } from 'src/stores/chats.js'
|
||||||
import { useEmojiStore } from 'src/stores/emoji.js'
|
import { useEmojiStore } from 'src/stores/emoji.js'
|
||||||
import { useInstanceStore } from 'src/stores/instance.js'
|
import { useInstanceStore } from 'src/stores/instance.js'
|
||||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||||
|
|
@ -47,6 +48,7 @@ import {
|
||||||
muteUser as apiMuteUser,
|
muteUser as apiMuteUser,
|
||||||
unblockUser as apiUnblockUser,
|
unblockUser as apiUnblockUser,
|
||||||
unmuteUser as apiUnmuteUser,
|
unmuteUser as apiUnmuteUser,
|
||||||
|
editUserNote as apiEditUserNote,
|
||||||
fetchBlocks,
|
fetchBlocks,
|
||||||
fetchDomainMutes,
|
fetchDomainMutes,
|
||||||
fetchMutes,
|
fetchMutes,
|
||||||
|
|
@ -123,7 +125,7 @@ const removeUserFromFollowers = (store, id) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const editUserNote = (store, { id, comment }) => {
|
const editUserNote = (store, { id, comment }) => {
|
||||||
return editUserNote({ id, comment }).then((relationship) =>
|
return apiEditUserNote({ id, comment }).then((relationship) =>
|
||||||
store.commit('updateUserRelationship', [relationship]),
|
store.commit('updateUserRelationship', [relationship]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -725,7 +727,7 @@ const users = {
|
||||||
store.dispatch('stopFetchingFollowRequests')
|
store.dispatch('stopFetchingFollowRequests')
|
||||||
store.commit('clearNotifications')
|
store.commit('clearNotifications')
|
||||||
store.commit('resetStatuses')
|
store.commit('resetStatuses')
|
||||||
store.dispatch('resetChats')
|
useChatsStore().resetChats()
|
||||||
oauth.clearToken()
|
oauth.clearToken()
|
||||||
Cookies.remove('__Host-pleroma_key', { path: '/' })
|
Cookies.remove('__Host-pleroma_key', { path: '/' })
|
||||||
useInterfaceStore().setLastTimeline('public-timeline')
|
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'
|
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
|
||||||
|
|
||||||
export const maybeShowChatNotification = (store, chat) => {
|
export const maybeShowChatNotification = (chat) => {
|
||||||
if (!chat.lastMessage) return
|
if (!chat.lastMessage) return
|
||||||
if (store.rootState.chats.currentChatId === chat.id && !document.hidden)
|
if (window.vuex.state.users.currentUser.id === chat.lastMessage.account_id)
|
||||||
return
|
|
||||||
if (store.rootState.users.currentUser.id === chat.lastMessage.account_id)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
|
|
@ -21,7 +19,7 @@ export const maybeShowChatNotification = (store, chat) => {
|
||||||
opts.image = chat.lastMessage.attachment.preview_url
|
opts.image = chat.lastMessage.attachment.preview_url
|
||||||
}
|
}
|
||||||
|
|
||||||
showDesktopNotification(store.rootState, opts)
|
showDesktopNotification(window.vuex.state, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildFakeMessage = ({
|
export const buildFakeMessage = ({
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ export const prepareNotificationObject = (notification, i18n) => {
|
||||||
export const countExtraNotifications = (
|
export const countExtraNotifications = (
|
||||||
store,
|
store,
|
||||||
mergedConfig,
|
mergedConfig,
|
||||||
|
unreadChatsCount,
|
||||||
unreadAnnouncementCount,
|
unreadAnnouncementCount,
|
||||||
) => {
|
) => {
|
||||||
const rootGetters = store.rootGetters || store.getters
|
const rootGetters = store.rootGetters || store.getters
|
||||||
|
|
@ -200,9 +201,7 @@ export const countExtraNotifications = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
mergedConfig.showChatsInExtraNotifications
|
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
|
||||||
? rootGetters.unreadChatCount
|
|
||||||
: 0,
|
|
||||||
mergedConfig.showAnnouncementsInExtraNotifications
|
mergedConfig.showAnnouncementsInExtraNotifications
|
||||||
? unreadAnnouncementCount
|
? unreadAnnouncementCount
|
||||||
: 0,
|
: 0,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ const pollFallbackValues = {
|
||||||
expiryUnit: 'minutes',
|
expiryUnit: 'minutes',
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollFallback = (object, attr) => {
|
export const pollFallback = (object, attr) => {
|
||||||
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
|
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollFormToMasto = (poll) => {
|
export const pollFormToMasto = (poll) => {
|
||||||
const expiresIn = DateUtils.unitToSeconds(
|
const expiresIn = DateUtils.unitToSeconds(
|
||||||
pollFallback(poll, 'expiryUnit'),
|
pollFallback(poll, 'expiryUnit'),
|
||||||
pollFallback(poll, 'expiryAmount'),
|
pollFallback(poll, 'expiryAmount'),
|
||||||
|
|
@ -32,5 +32,3 @@ const pollFormToMasto = (poll) => {
|
||||||
expiresIn,
|
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