Merge branch 'chat-refactor' into sonarqube-cleanup
This commit is contained in:
commit
56efd36759
79 changed files with 3228 additions and 2094 deletions
1
changelog.d/chat_vew.add
Normal file
1
changelog.d/chat_vew.add
Normal file
|
|
@ -0,0 +1 @@
|
|||
Chat view for threads
|
||||
|
|
@ -186,7 +186,11 @@ export default {
|
|||
return useShoutStore().joined
|
||||
},
|
||||
isChats() {
|
||||
return this.$route.name === 'chat' || this.$route.name === 'chats'
|
||||
return (
|
||||
this.$route.name === 'chat' ||
|
||||
this.$route.name === 'chats' ||
|
||||
this.$route.name === 'conversation2'
|
||||
)
|
||||
},
|
||||
isListEdit() {
|
||||
return this.$route.name === 'lists-edit'
|
||||
|
|
@ -205,7 +209,7 @@ export default {
|
|||
)
|
||||
},
|
||||
hideShoutbox() {
|
||||
return useMergedConfigStore().mergedConfig.hideShoutbox
|
||||
return this.isChats || useMergedConfigStore().mergedConfig.hideShoutbox
|
||||
},
|
||||
reverseLayout() {
|
||||
const { thirdColumnMode, sidebarRight: reverseSetting } =
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { paramsString, promisedRequest } from './helpers.js'
|
||||
|
||||
import { parseChat } from 'src/services/entity_normalizer/entity_normalizer.service.js'
|
||||
import {
|
||||
parseChat,
|
||||
parseChatMessage,
|
||||
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
|
||||
|
||||
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
|
||||
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
|
||||
|
|
@ -15,7 +18,7 @@ export const chats = ({ credentials }) =>
|
|||
url: PLEROMA_CHATS_URL,
|
||||
credentials,
|
||||
}).then(({ data }) => ({
|
||||
chatList: data.map(parseChat).filter((c) => c),
|
||||
data: data.map(parseChat).filter((c) => c),
|
||||
}))
|
||||
|
||||
export const getOrCreateChat = ({ accountId, credentials }) =>
|
||||
|
|
@ -23,7 +26,7 @@ export const getOrCreateChat = ({ accountId, credentials }) =>
|
|||
url: PLEROMA_CHAT_URL(accountId),
|
||||
method: 'POST',
|
||||
credentials,
|
||||
})
|
||||
}).then(({ data }) => ({ data: parseChat(data) }))
|
||||
|
||||
export const chatMessages = ({
|
||||
id,
|
||||
|
|
@ -36,7 +39,9 @@ export const chatMessages = ({
|
|||
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
|
||||
method: 'GET',
|
||||
credentials,
|
||||
})
|
||||
}).then(({ data }) => ({
|
||||
data: data.map(parseChatMessage).filter((c) => c),
|
||||
}))
|
||||
}
|
||||
|
||||
export const sendChatMessage = ({
|
||||
|
|
@ -66,7 +71,9 @@ export const sendChatMessage = ({
|
|||
payload,
|
||||
credentials,
|
||||
headers,
|
||||
})
|
||||
}).then(({ data }) => ({
|
||||
data: parseChatMessage(data),
|
||||
}))
|
||||
}
|
||||
|
||||
export const readChat = ({ id, lastReadId, credentials }) =>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ export const postStatus = ({
|
|||
idempotencyKey,
|
||||
}) => {
|
||||
const form = new FormData()
|
||||
const pollOptions = poll.options || []
|
||||
const pollOptions = poll?.options || []
|
||||
|
||||
form.append('status', status)
|
||||
form.append('source', 'Pleroma FE')
|
||||
|
|
@ -266,7 +266,7 @@ export const editStatus = ({
|
|||
contentType,
|
||||
}) => {
|
||||
const form = new FormData()
|
||||
const pollOptions = poll.options || []
|
||||
const pollOptions = poll?.options || []
|
||||
|
||||
form.append('status', status)
|
||||
if (spoilerText) form.append('spoiler_text', spoilerText)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import AuthForm from 'src/components/auth_form/auth_form.js'
|
||||
import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue'
|
||||
import BubbleTimeline from 'src/components/bubble_timeline/bubble_timeline.vue'
|
||||
|
|
@ -65,6 +63,14 @@ export default (store) => {
|
|||
component: ConversationPage,
|
||||
meta: { dontScroll: true },
|
||||
},
|
||||
{
|
||||
name: 'conversation2',
|
||||
path: '/conversation/:statusId',
|
||||
component: () => import('src/components/chat_view/chat_view.vue'),
|
||||
props: true,
|
||||
meta: { dontScroll: true },
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
|
||||
{
|
||||
name: 'remote-user-profile-acct',
|
||||
|
|
@ -81,23 +87,18 @@ export default (store) => {
|
|||
{
|
||||
name: 'external-user-profile',
|
||||
path: '/users/$:id',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/user_profile/user_profile.vue'),
|
||||
),
|
||||
component: () => import('src/components/user_profile/user_profile.vue'),
|
||||
},
|
||||
{
|
||||
name: 'user-profile-admin-view',
|
||||
path: '/users/$:id/admin_view',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/user_profile/user_profile_admin_view.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/user_profile/user_profile_admin_view.vue'),
|
||||
},
|
||||
{
|
||||
name: 'interactions',
|
||||
path: '/users/:username/interactions',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/interactions/interactions.vue'),
|
||||
),
|
||||
component: () => import('src/components/interactions/interactions.vue'),
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
{
|
||||
|
|
@ -109,39 +110,31 @@ export default (store) => {
|
|||
{
|
||||
name: 'registration',
|
||||
path: '/registration',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/registration/registration.vue'),
|
||||
),
|
||||
component: () => import('src/components/registration/registration.vue'),
|
||||
},
|
||||
{
|
||||
name: 'password-reset',
|
||||
path: '/password-reset',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/password_reset/password_reset.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/password_reset/password_reset.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
name: 'registration-token',
|
||||
path: '/registration/:token',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/registration/registration.vue'),
|
||||
),
|
||||
component: () => import('src/components/registration/registration.vue'),
|
||||
},
|
||||
{
|
||||
name: 'friend-requests',
|
||||
path: '/friend-requests',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/follow_requests/follow_requests.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/follow_requests/follow_requests.vue'),
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
{
|
||||
name: 'notifications',
|
||||
path: '/:username/notifications',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/notifications/notifications.vue'),
|
||||
),
|
||||
component: () => import('src/components/notifications/notifications.vue'),
|
||||
props: () => ({ disableTeleport: true }),
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
|
|
@ -153,98 +146,74 @@ export default (store) => {
|
|||
{
|
||||
name: 'shout-panel',
|
||||
path: '/shout-panel',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/shout_panel/shout_panel.vue'),
|
||||
),
|
||||
component: () => import('src/components/shout_panel/shout_panel.vue'),
|
||||
props: () => ({ floating: false }),
|
||||
},
|
||||
{
|
||||
name: 'oauth-callback',
|
||||
path: '/oauth-callback',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/oauth_callback/oauth_callback.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/oauth_callback/oauth_callback.vue'),
|
||||
props: (route) => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
path: '/search',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/search/search.vue'),
|
||||
),
|
||||
component: () => import('src/components/search/search.vue'),
|
||||
props: (route) => ({ query: route.query.query }),
|
||||
},
|
||||
{
|
||||
name: 'who-to-follow',
|
||||
path: '/who-to-follow',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/who_to_follow/who_to_follow.vue'),
|
||||
),
|
||||
component: () => import('src/components/who_to_follow/who_to_follow.vue'),
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
{
|
||||
name: 'about',
|
||||
path: '/about',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/about/about.vue'),
|
||||
),
|
||||
component: () => import('src/components/about/about.vue'),
|
||||
},
|
||||
{
|
||||
name: 'announcements',
|
||||
path: '/announcements',
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('src/components/announcements_page/announcements_page.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/announcements_page/announcements_page.vue'),
|
||||
},
|
||||
{
|
||||
name: 'drafts',
|
||||
path: '/drafts',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/drafts/drafts.vue'),
|
||||
),
|
||||
component: () => import('src/components/drafts/drafts.vue'),
|
||||
},
|
||||
{
|
||||
name: 'user-profile',
|
||||
path: '/users/:name',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/user_profile/user_profile.vue'),
|
||||
),
|
||||
component: () => import('src/components/user_profile/user_profile.vue'),
|
||||
},
|
||||
{
|
||||
name: 'legacy-user-profile',
|
||||
path: '/:name',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/user_profile/user_profile.vue'),
|
||||
),
|
||||
component: () => import('src/components/user_profile/user_profile.vue'),
|
||||
},
|
||||
{
|
||||
name: 'lists',
|
||||
path: '/lists',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/lists/lists.vue'),
|
||||
),
|
||||
component: () => import('src/components/lists/lists.vue'),
|
||||
},
|
||||
{
|
||||
name: 'lists-timeline',
|
||||
path: '/lists/:id',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/lists_timeline/lists_timeline.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/lists_timeline/lists_timeline.vue'),
|
||||
},
|
||||
{
|
||||
name: 'lists-edit',
|
||||
path: '/lists/:id/edit',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/lists_edit/lists_edit.vue'),
|
||||
),
|
||||
component: () => import('src/components/lists_edit/lists_edit.vue'),
|
||||
},
|
||||
{
|
||||
name: 'lists-new',
|
||||
path: '/lists/new',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/lists_edit/lists_edit.vue'),
|
||||
),
|
||||
component: () => import('src/components/lists_edit/lists_edit.vue'),
|
||||
},
|
||||
{
|
||||
name: 'edit-navigation',
|
||||
|
|
@ -256,19 +225,14 @@ export default (store) => {
|
|||
{
|
||||
name: 'bookmark-folders',
|
||||
path: '/bookmark_folders',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/bookmark_folders/bookmark_folders.vue'),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/bookmark_folders/bookmark_folders.vue'),
|
||||
},
|
||||
{
|
||||
name: 'bookmark-folder-new',
|
||||
path: '/bookmarks/new-folder',
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
|
||||
),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'),
|
||||
},
|
||||
{
|
||||
name: 'bookmark-folder',
|
||||
|
|
@ -278,12 +242,8 @@ export default (store) => {
|
|||
{
|
||||
name: 'bookmark-folder-edit',
|
||||
path: '/bookmarks/:id/edit',
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
|
||||
),
|
||||
),
|
||||
component: () =>
|
||||
import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -291,19 +251,16 @@ export default (store) => {
|
|||
routes = routes.concat([
|
||||
{
|
||||
name: 'chat',
|
||||
path: '/users/:username/chats/:recipient_id',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/chat/chat.vue'),
|
||||
),
|
||||
path: '/users/:username/chats/:chatUserId',
|
||||
component: () => import('src/components/chat_view/chat_view.vue'),
|
||||
meta: { dontScroll: false },
|
||||
props: true,
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
{
|
||||
name: 'chats',
|
||||
path: '/users/:username/chats',
|
||||
component: defineAsyncComponent(
|
||||
() => import('src/components/chat_list/chat_list.vue'),
|
||||
),
|
||||
component: () => import('src/components/chat_list/chat_list.vue'),
|
||||
meta: { dontScroll: false },
|
||||
beforeEnter: validateAuthenticatedRoute,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,433 +0,0 @@
|
|||
import { throttle } from 'lodash'
|
||||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
|
||||
import ChatMessage from 'src/components/chat_message/chat_message.vue'
|
||||
import ChatTitle from 'src/components/chat_title/chat_title.vue'
|
||||
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
||||
import chatService from '../../services/chat_service/chat_service.js'
|
||||
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
|
||||
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
|
||||
import {
|
||||
getNewTopPosition,
|
||||
getScrollPosition,
|
||||
isBottomedOut,
|
||||
isScrollable,
|
||||
} from './chat_layout_utils.js'
|
||||
|
||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||
|
||||
import {
|
||||
chatMessages,
|
||||
getOrCreateChat,
|
||||
sendChatMessage,
|
||||
} from 'src/api/chats.js'
|
||||
import { WSConnectionStatus } from 'src/api/websocket.js'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(faChevronDown, faChevronLeft)
|
||||
|
||||
const BOTTOMED_OUT_OFFSET = 10
|
||||
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
|
||||
const SAFE_RESIZE_TIME_OFFSET = 100
|
||||
const MARK_AS_READ_DELAY = 1500
|
||||
const MAX_RETRIES = 10
|
||||
|
||||
const Chat = {
|
||||
components: {
|
||||
ChatMessage,
|
||||
ChatTitle,
|
||||
PostStatusForm,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
jumpToBottomButtonVisible: false,
|
||||
hoveredMessageChainId: undefined,
|
||||
lastScrollPosition: {},
|
||||
scrollableContainerHeight: '100%',
|
||||
errorLoadingChat: false,
|
||||
messageRetriers: {},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.startFetching()
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
if (document.hidden !== undefined) {
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
this.handleVisibilityChange,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.handleResize()
|
||||
})
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
if (document.hidden !== undefined)
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.handleVisibilityChange,
|
||||
false,
|
||||
)
|
||||
this.$store.dispatch('clearCurrentChat')
|
||||
},
|
||||
computed: {
|
||||
recipient() {
|
||||
return this.currentChat && this.currentChat.account
|
||||
},
|
||||
recipientId() {
|
||||
return this.$route.params.recipient_id
|
||||
},
|
||||
formPlaceholder() {
|
||||
if (this.recipient) {
|
||||
return this.$t('chats.message_user', {
|
||||
nickname: this.recipient.screen_name_ui,
|
||||
})
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
chatViewItems() {
|
||||
return chatService.getView(this.currentChatMessageService)
|
||||
},
|
||||
newMessageCount() {
|
||||
return (
|
||||
this.currentChatMessageService &&
|
||||
this.currentChatMessageService.newMessageCount
|
||||
)
|
||||
},
|
||||
streamingEnabled() {
|
||||
return (
|
||||
this.mergedConfig.useStreamingApi &&
|
||||
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
|
||||
)
|
||||
},
|
||||
...mapGetters([
|
||||
'currentChat',
|
||||
'currentChatMessageService',
|
||||
'findOpenedChatByRecipientId',
|
||||
]),
|
||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
||||
...mapPiniaState(useInterfaceStore, {
|
||||
mobileLayout: (store) => store.layoutType === 'mobile',
|
||||
}),
|
||||
...mapState({
|
||||
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
|
||||
currentUser: (state) => state.users.currentUser,
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
chatViewItems() {
|
||||
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
|
||||
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
|
||||
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
|
||||
this.$nextTick(() => {
|
||||
if (bottomedOutBeforeUpdate) {
|
||||
this.scrollDown()
|
||||
}
|
||||
})
|
||||
},
|
||||
$route: function () {
|
||||
this.startFetching()
|
||||
},
|
||||
mastoUserSocketStatus(newValue) {
|
||||
if (newValue === WSConnectionStatus.JOINED) {
|
||||
this.fetchChat({ isFirstFetch: true })
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
|
||||
onMessageHover({ isHovered, messageChainId }) {
|
||||
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
|
||||
},
|
||||
onFilesDropped() {
|
||||
this.$nextTick(() => {
|
||||
this.handleResize()
|
||||
})
|
||||
},
|
||||
handleVisibilityChange() {
|
||||
this.$nextTick(() => {
|
||||
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
|
||||
this.scrollDown({ forceRead: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
|
||||
handleResize(opts = {}) {
|
||||
const { delayed = false } = opts
|
||||
|
||||
if (delayed) {
|
||||
setTimeout(() => {
|
||||
this.handleResize({ ...opts, delayed: false })
|
||||
}, SAFE_RESIZE_TIME_OFFSET)
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const { offsetHeight = undefined } = getScrollPosition()
|
||||
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
|
||||
if (diff !== 0 && !this.bottomedOut()) {
|
||||
this.$nextTick(() => {
|
||||
window.scrollBy({ top: -Math.trunc(diff) })
|
||||
})
|
||||
}
|
||||
this.lastScrollPosition = getScrollPosition()
|
||||
})
|
||||
},
|
||||
scrollDown(options = {}) {
|
||||
const { behavior = 'auto', forceRead = false } = options
|
||||
this.$nextTick(() => {
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight,
|
||||
behavior,
|
||||
})
|
||||
})
|
||||
if (forceRead) {
|
||||
this.readChat()
|
||||
}
|
||||
},
|
||||
readChat() {
|
||||
if (
|
||||
!(
|
||||
this.currentChatMessageService && this.currentChatMessageService.maxId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (document.hidden) {
|
||||
return
|
||||
}
|
||||
const lastReadId = this.currentChatMessageService.maxId
|
||||
this.$store.dispatch('readChat', {
|
||||
id: this.currentChat.id,
|
||||
lastReadId,
|
||||
})
|
||||
},
|
||||
bottomedOut(offset) {
|
||||
return isBottomedOut(offset)
|
||||
},
|
||||
reachedTop() {
|
||||
return window.scrollY <= 0
|
||||
},
|
||||
cullOlderCheck() {
|
||||
window.setTimeout(() => {
|
||||
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
||||
this.$store.dispatch(
|
||||
'cullOlderMessages',
|
||||
this.currentChatMessageService.chatId,
|
||||
)
|
||||
}
|
||||
}, 5000)
|
||||
},
|
||||
handleScroll: throttle(function () {
|
||||
this.lastScrollPosition = getScrollPosition()
|
||||
if (!this.currentChat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.reachedTop()) {
|
||||
this.fetchChat({ maxId: this.currentChatMessageService.minId })
|
||||
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
||||
this.jumpToBottomButtonVisible = false
|
||||
this.cullOlderCheck()
|
||||
if (this.newMessageCount > 0) {
|
||||
// Use a delay before marking as read to prevent situation where new messages
|
||||
// arrive just as you're leaving the view and messages that you didn't actually
|
||||
// get to see get marked as read.
|
||||
window.setTimeout(() => {
|
||||
// Don't mark as read if the element doesn't exist, user has left chat view
|
||||
if (this.$el) this.readChat()
|
||||
}, MARK_AS_READ_DELAY)
|
||||
}
|
||||
} else {
|
||||
this.jumpToBottomButtonVisible = true
|
||||
}
|
||||
}, 200),
|
||||
handleScrollUp(positionBeforeLoading) {
|
||||
const positionAfterLoading = getScrollPosition()
|
||||
window.scrollTo({
|
||||
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
|
||||
})
|
||||
},
|
||||
fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
|
||||
const chatMessageService = this.currentChatMessageService
|
||||
if (!chatMessageService) {
|
||||
return
|
||||
}
|
||||
if (fetchLatest && this.streamingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const chatId = chatMessageService.chatId
|
||||
const fetchOlderMessages = !!maxId
|
||||
const sinceId = fetchLatest && chatMessageService.maxId
|
||||
|
||||
return chatMessages({
|
||||
id: chatId,
|
||||
maxId,
|
||||
sinceId,
|
||||
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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
async startFetching() {
|
||||
let chat = this.findOpenedChatByRecipientId(this.recipientId)
|
||||
if (!chat) {
|
||||
try {
|
||||
const { data } = await getOrCreateChat({
|
||||
accountId: this.recipientId,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
chat = data
|
||||
} catch (e) {
|
||||
console.error('Error creating or getting a chat', e)
|
||||
this.errorLoadingChat = true
|
||||
}
|
||||
}
|
||||
if (chat) {
|
||||
this.$nextTick(() => {
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
this.$store.dispatch('addOpenedChat', { chat })
|
||||
this.doStartFetching()
|
||||
}
|
||||
},
|
||||
doStartFetching() {
|
||||
this.$store.dispatch('startFetchingCurrentChat', {
|
||||
fetcher: () =>
|
||||
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
|
||||
})
|
||||
this.fetchChat({ isFirstFetch: true })
|
||||
},
|
||||
handleAttachmentPosting() {
|
||||
this.$nextTick(() => {
|
||||
this.handleResize()
|
||||
// When the posting form size changes because of a media attachment, we need an extra resize
|
||||
// to account for the potential delay in the DOM update.
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
},
|
||||
sendMessage({ status, media, idempotencyKey }) {
|
||||
const params = {
|
||||
id: this.currentChat.id,
|
||||
content: status,
|
||||
idempotencyKey,
|
||||
}
|
||||
|
||||
if (media[0]) {
|
||||
params.mediaId = media[0].id
|
||||
}
|
||||
|
||||
const fakeMessage = buildFakeMessage({
|
||||
attachments: media,
|
||||
chatId: this.currentChat.id,
|
||||
content: status,
|
||||
userId: this.currentUser.id,
|
||||
idempotencyKey,
|
||||
})
|
||||
|
||||
this.$store
|
||||
.dispatch('addChatMessages', {
|
||||
chatId: this.currentChat.id,
|
||||
messages: [fakeMessage],
|
||||
})
|
||||
.then(() => {
|
||||
this.handleAttachmentPosting()
|
||||
})
|
||||
|
||||
return this.doSendMessage({
|
||||
params,
|
||||
fakeMessage,
|
||||
retriesLeft: MAX_RETRIES,
|
||||
})
|
||||
},
|
||||
doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
|
||||
if (retriesLeft <= 0) return
|
||||
|
||||
sendChatMessage({
|
||||
...params,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.$store.dispatch('addChatMessages', {
|
||||
chatId: this.currentChat.id,
|
||||
updateMaxId: false,
|
||||
messages: [{ ...data, fakeId: fakeMessage.id }],
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error sending message', error)
|
||||
this.$store.dispatch('handleMessageError', {
|
||||
chatId: this.currentChat.id,
|
||||
fakeId: fakeMessage.id,
|
||||
isRetry: retriesLeft !== MAX_RETRIES,
|
||||
})
|
||||
if (
|
||||
(error.statusCode >= 500 && error.statusCode < 600) ||
|
||||
error.message === 'Failed to fetch'
|
||||
) {
|
||||
this.messageRetriers[fakeMessage.id] = setTimeout(
|
||||
() => {
|
||||
this.doSendMessage({
|
||||
params,
|
||||
fakeMessage,
|
||||
retriesLeft: retriesLeft - 1,
|
||||
})
|
||||
},
|
||||
1000 * 2 ** (MAX_RETRIES - retriesLeft),
|
||||
)
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
return Promise.resolve(fakeMessage)
|
||||
},
|
||||
goBack() {
|
||||
this.$router.push({
|
||||
name: 'chats',
|
||||
params: { username: this.currentUser.screen_name },
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default Chat
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="chat-view-inner">
|
||||
<div
|
||||
ref="inner"
|
||||
class="panel-default panel chat-view-body"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading -sticky chat-view-heading"
|
||||
>
|
||||
<button
|
||||
class="button-unstyled go-back-button"
|
||||
@click="goBack"
|
||||
>
|
||||
<FAIcon
|
||||
size="lg"
|
||||
icon="chevron-left"
|
||||
/>
|
||||
</button>
|
||||
<div class="title text-center">
|
||||
<ChatTitle
|
||||
:user="recipient"
|
||||
:with-avatar="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="chat-message-list message-list"
|
||||
:style="{ height: scrollableContainerHeight }"
|
||||
>
|
||||
<template v-if="!errorLoadingChat">
|
||||
<ChatMessage
|
||||
v-for="chatViewItem in chatViewItems"
|
||||
:key="chatViewItem.id"
|
||||
:author="recipient"
|
||||
:chat-view-item="chatViewItem"
|
||||
:hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
|
||||
@hover="onMessageHover"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="chat-loading-error"
|
||||
>
|
||||
<div class="alert error">
|
||||
{{ $t('chats.error_loading_chat') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="footer"
|
||||
class="panel-body footer"
|
||||
>
|
||||
<div
|
||||
class="jump-to-bottom-button"
|
||||
:class="{ 'visible': jumpToBottomButtonVisible }"
|
||||
@click="scrollDown({ behavior: 'smooth' })"
|
||||
>
|
||||
<span>
|
||||
<FAIcon icon="chevron-down" />
|
||||
<div
|
||||
v-if="newMessageCount"
|
||||
class="badge -notification unread-chat-count unread-message-count"
|
||||
>
|
||||
{{ newMessageCount }}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<PostStatusForm
|
||||
:disable-subject="true"
|
||||
:disable-scope-selector="true"
|
||||
:disable-notice="true"
|
||||
:disable-lock-warning="true"
|
||||
:disable-polls="true"
|
||||
:disable-quotes="true"
|
||||
:disable-sensitivity-checkbox="true"
|
||||
:disable-submit="errorLoadingChat || !currentChat"
|
||||
:disable-preview="true"
|
||||
:disable-draft="true"
|
||||
:optimistic-posting="true"
|
||||
:post-handler="sendMessage"
|
||||
:submit-on-enter="!mobileLayout"
|
||||
:preserve-focus="!mobileLayout"
|
||||
:auto-focus="!mobileLayout"
|
||||
:placeholder="formPlaceholder"
|
||||
:file-limit="1"
|
||||
max-height="160"
|
||||
emoji-picker-placement="top"
|
||||
@resize="handleResize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat.js"></script>
|
||||
<style src="./chat.scss" lang="scss" />
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { mapGetters, mapState } from 'vuex'
|
||||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import ChatListItem from 'src/components/chat_list_item/chat_list_item.vue'
|
||||
import ChatNew from 'src/components/chat_new/chat_new.vue'
|
||||
import List from 'src/components/list/list.vue'
|
||||
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
|
||||
const ChatList = {
|
||||
components: {
|
||||
ChatListItem,
|
||||
|
|
@ -14,7 +17,7 @@ const ChatList = {
|
|||
...mapState({
|
||||
currentUser: (state) => state.users.currentUser,
|
||||
}),
|
||||
...mapGetters(['sortedChatList']),
|
||||
...mapPiniaState(useChatsStore, ['sortedChatList']),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -22,12 +25,12 @@ const ChatList = {
|
|||
}
|
||||
},
|
||||
created() {
|
||||
this.$store.dispatch('fetchChats', { latest: true })
|
||||
useChatsStore().fetchChats()
|
||||
},
|
||||
methods: {
|
||||
cancelNewChat() {
|
||||
this.isNew = false
|
||||
this.$store.dispatch('fetchChats', { latest: true })
|
||||
useChatsStore().fetchChats()
|
||||
},
|
||||
newChat() {
|
||||
this.isNew = true
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const ChatListItem = {
|
|||
name: 'chat',
|
||||
params: {
|
||||
username: this.currentUser.screen_name,
|
||||
recipient_id: this.chat.account.id,
|
||||
chatUserId: this.chat.account.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import Attachment from 'src/components/attachment/attachment.vue'
|
||||
import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
|
||||
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
|
||||
import Gallery from 'src/components/gallery/gallery.vue'
|
||||
import LinkPreview from 'src/components/link-preview/link-preview.vue'
|
||||
import MentionLink from 'src/components/mention_link/mention_link.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 StatusPopover from 'src/components/status_popover/status_popover.vue'
|
||||
import Timeago from 'src/components/timeago/timeago.vue'
|
||||
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
|
||||
import UserPopover from 'src/components/user_popover/user_popover.vue'
|
||||
|
||||
|
|
@ -15,68 +22,142 @@ import { useInterfaceStore } from 'src/stores/interface'
|
|||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import {
|
||||
faCircleNotch,
|
||||
faEllipsisH,
|
||||
faReply,
|
||||
faRetweet,
|
||||
faStar,
|
||||
faTimes,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(faTimes, faEllipsisH)
|
||||
library.add(faTimes, faEllipsisH, faCircleNotch, faReply, faStar, faRetweet)
|
||||
|
||||
const ChatMessage = {
|
||||
name: 'ChatMessage',
|
||||
props: [
|
||||
'author',
|
||||
'edited',
|
||||
'noHeading',
|
||||
'chatViewItem',
|
||||
'previousItem',
|
||||
'chatItem',
|
||||
'previousItem',
|
||||
'hoveredMessageChain',
|
||||
'focused',
|
||||
'repliedTo',
|
||||
],
|
||||
emits: ['hover'],
|
||||
emits: ['hover', 'replyRequested'],
|
||||
components: {
|
||||
Popover,
|
||||
Attachment,
|
||||
StatusContent,
|
||||
StatusBody,
|
||||
StatusActionButtons,
|
||||
UserAvatar,
|
||||
Gallery,
|
||||
LinkPreview,
|
||||
ChatMessageDate,
|
||||
EmojiReactions,
|
||||
UserPopover,
|
||||
StatusPopover,
|
||||
MentionLink,
|
||||
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
|
||||
Timeago,
|
||||
},
|
||||
computed: {
|
||||
// Returns HH:MM (hours and minutes) in local time.
|
||||
createdAt() {
|
||||
const time = this.chatViewItem.data.created_at
|
||||
return time.toLocaleTimeString('en', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
},
|
||||
isCurrentUser() {
|
||||
return this.message.account_id === this.currentUser.id
|
||||
isMessage() {
|
||||
return this.chatItem.type === 'message'
|
||||
},
|
||||
message() {
|
||||
return this.chatViewItem.data
|
||||
if (!this.isMessage) return null
|
||||
return this.chatItem.data.retweeted_status ?? this.chatItem.data
|
||||
},
|
||||
isMessage() {
|
||||
return this.chatViewItem.type === 'message'
|
||||
isStatus() {
|
||||
// ChatMessage only has account_id while Status has full user data
|
||||
return !!this.message.user
|
||||
},
|
||||
authorId() {
|
||||
return this.isStatus ? this.message.user.id : this.message.account_id
|
||||
},
|
||||
author() {
|
||||
return this.$store.getters.findUser(this.authorId)
|
||||
},
|
||||
isCurrentUser() {
|
||||
// mini-hack/optimizaiton:
|
||||
// - current user would always be in memory so if user is missing it's obviously not us
|
||||
// - if anon views page then "us" pretty much doesn't exist
|
||||
if (!this.author || !this.currentUser) return false
|
||||
return this.author.id === this.currentUser.id
|
||||
},
|
||||
|
||||
// Reply stuff
|
||||
isCustomReply() {
|
||||
if (!this.previousItem) return false
|
||||
if (!this.message.in_reply_to_status_id) return false
|
||||
return this.previousItem.data.id !== this.message.in_reply_to_status_id
|
||||
},
|
||||
isBrokenReply() {
|
||||
if (!this.previousItem) return false
|
||||
return !this.message.in_reply_to_status_id
|
||||
},
|
||||
customReplyTo() {
|
||||
return this.$store.state.statuses.allStatusesObject[
|
||||
this.message.in_reply_to_status_id
|
||||
]
|
||||
},
|
||||
replyToName() {
|
||||
if (this.message.in_reply_to_screen_name) {
|
||||
return this.message.in_reply_to_screen_name
|
||||
} else {
|
||||
const user = this.$store.getters.findUser(
|
||||
this.message.in_reply_to_user_id,
|
||||
)
|
||||
return user && user.screen_name_ui
|
||||
}
|
||||
},
|
||||
replyProfileLink() {
|
||||
if (this.isCustomReply) {
|
||||
const user = this.$store.getters.findUser(
|
||||
this.message.in_reply_to_user_id,
|
||||
)
|
||||
// FIXME Why user not found sometimes???
|
||||
return user ? user.statusnet_profile_url : 'NOT_FOUND'
|
||||
}
|
||||
},
|
||||
|
||||
// Quote stuff
|
||||
quoteId() {
|
||||
return this.message.quote_id
|
||||
},
|
||||
quoteUrl() {
|
||||
return this.message.quote_url
|
||||
},
|
||||
quoteVisible() {
|
||||
return this.message.quote_visible
|
||||
},
|
||||
|
||||
// Content
|
||||
messageForStatusContent() {
|
||||
return {
|
||||
...this.message,
|
||||
summary: '',
|
||||
emojis: this.message.emojis,
|
||||
raw_html: this.message.content || '',
|
||||
raw_html: this.message.content || this.message.raw_html || '',
|
||||
text: this.message.content || '',
|
||||
attachments: this.message.attachments,
|
||||
}
|
||||
},
|
||||
hasAttachment() {
|
||||
return this.message.attachments.length > 0
|
||||
},
|
||||
...mapPiniaState(useInterfaceStore, {
|
||||
betterShadow: (store) => store.browserSupport.cssFilter,
|
||||
}),
|
||||
...mapState({
|
||||
currentUser: (state) => state.users.currentUser,
|
||||
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
|
||||
}),
|
||||
|
||||
// Stylistic
|
||||
classnames() {
|
||||
return {
|
||||
'-outgoing': this.isCurrentUser,
|
||||
'-incoming': !this.isCurrentUser,
|
||||
'-pending': this.message.pending,
|
||||
'-focused': this.focused,
|
||||
}
|
||||
},
|
||||
popoverMarginStyle() {
|
||||
if (this.isCurrentUser) {
|
||||
return {}
|
||||
|
|
@ -84,7 +165,16 @@ const ChatMessage = {
|
|||
return { left: 50 }
|
||||
}
|
||||
},
|
||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']),
|
||||
|
||||
// Global stuff
|
||||
...mapPiniaState(useInterfaceStore, {
|
||||
betterShadow: (store) => store.browserSupport.cssFilter,
|
||||
}),
|
||||
...mapState({
|
||||
currentUser: (state) => state.users.currentUser,
|
||||
restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
|
||||
}),
|
||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -96,15 +186,32 @@ const ChatMessage = {
|
|||
onHover(bool) {
|
||||
this.$emit('hover', {
|
||||
isHovered: bool,
|
||||
messageChainId: this.chatViewItem.messageChainId,
|
||||
messageChainId: this.chatItem.messageChainId,
|
||||
})
|
||||
},
|
||||
visibilityIcon(visibility) {
|
||||
switch (visibility) {
|
||||
case 'private':
|
||||
return 'lock'
|
||||
case 'unlisted':
|
||||
return 'lock-open'
|
||||
case 'direct':
|
||||
return 'envelope'
|
||||
case 'local':
|
||||
return 'igloo'
|
||||
default:
|
||||
return 'globe'
|
||||
}
|
||||
},
|
||||
visibilityLocalized() {
|
||||
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
|
||||
},
|
||||
async deleteMessage() {
|
||||
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
|
||||
if (confirmed) {
|
||||
await this.$store.dispatch('deleteChatMessage', {
|
||||
messageId: this.chatViewItem.data.id,
|
||||
chatId: this.chatViewItem.data.chat_id,
|
||||
await this.$emit('delete', {
|
||||
messageId: this.message.id,
|
||||
chatId: this.message.chat_id,
|
||||
})
|
||||
}
|
||||
this.hovered = false
|
||||
|
|
|
|||
|
|
@ -11,29 +11,60 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chat-message-menu {
|
||||
.attachments {
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.quoted-post {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.chat-message-toolbar {
|
||||
transition: opacity 0.1s;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: -0.8em;
|
||||
right: 0.4rem;
|
||||
z-index: 1;
|
||||
|
||||
button {
|
||||
.quick-action-buttons {
|
||||
justify-items: end;
|
||||
grid-template-columns: auto auto auto;
|
||||
}
|
||||
|
||||
.simple-button {
|
||||
padding-top: 0.2em;
|
||||
padding-bottom: 0.2em;
|
||||
}
|
||||
|
||||
&.-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reply-to-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.125em 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.avatar-spacer {
|
||||
flex: 0 0 2.2rem;
|
||||
width: 2.2rem;
|
||||
}
|
||||
|
||||
.popover {
|
||||
width: 12em;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
padding-bottom: 0.5em;
|
||||
|
||||
.status-body:hover {
|
||||
--_still-image-img-visibility: visible;
|
||||
|
|
@ -43,8 +74,7 @@
|
|||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-right: 0.72em;
|
||||
width: 32px;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.link-preview,
|
||||
|
|
@ -67,7 +97,6 @@
|
|||
font-size: 0.8em;
|
||||
margin: -1em 0 -0.5em;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.without-attachment {
|
||||
|
|
@ -95,33 +124,76 @@
|
|||
}
|
||||
}
|
||||
|
||||
.message-bubble-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-message-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: 80%;
|
||||
min-width: 10em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.outgoing {
|
||||
.end-spacer {
|
||||
flex: 1 1 0;
|
||||
min-width: calc(2.2rem + 0.5rem + 2rem);
|
||||
}
|
||||
|
||||
.reply-indicator {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
place-content: end flex-end;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
padding: 0.25em;
|
||||
width: 1em;
|
||||
margin: var(--roundness) 0;
|
||||
background: var(--border);
|
||||
border-radius: var(--roundness);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
+ .end-spacer {
|
||||
// Compensate for reply indicator
|
||||
min-width: calc(2.2rem + 0.5rem + 2rem - (1rem + (1px + 0.25rem) * 2));
|
||||
}
|
||||
}
|
||||
|
||||
&.-incoming {
|
||||
.reply-indicator {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-to-popover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reply-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.-outgoing {
|
||||
&,
|
||||
.message-bubble-wrapper,
|
||||
.chat-message{
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.reply-to-header {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.reply-indicator {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
|
||||
.icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-inner {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-menu {
|
||||
right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.incoming {
|
||||
.chat-message-menu {
|
||||
left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-inner.with-media {
|
||||
|
|
@ -139,8 +211,8 @@
|
|||
|
||||
.chat-message-date-separator {
|
||||
text-align: center;
|
||||
margin: 1.4em 0;
|
||||
font-size: 0.9em;
|
||||
line-height: 2;
|
||||
user-select: none;
|
||||
color: var(--textFaint);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ export default {
|
|||
variants: {
|
||||
outgoing: '.outgoing',
|
||||
},
|
||||
states: {
|
||||
focused: '.-focused',
|
||||
},
|
||||
validInnerComponents: ['Text', 'Icon', 'Border', 'PollGraph'],
|
||||
defaultRules: [
|
||||
{
|
||||
|
|
@ -18,5 +21,11 @@ export default {
|
|||
background: '--bg, 5',
|
||||
},
|
||||
},
|
||||
{
|
||||
state: ['focused'],
|
||||
directives: {
|
||||
background: '--inheritedBackground, 10',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,89 +2,228 @@
|
|||
<div
|
||||
v-if="isMessage"
|
||||
class="chat-message-wrapper"
|
||||
:class="{ 'hovered-message-chain': hoveredMessageChain }"
|
||||
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
|
||||
:id="`chatmessage-${message.id}`"
|
||||
@mouseover="onHover(true)"
|
||||
@mouseleave="onHover(false)"
|
||||
>
|
||||
<i18n-t
|
||||
v-if="isStatus && (isCustomReply || isBrokenReply)"
|
||||
keypath="status.reply_to_with_arg"
|
||||
scope="global"
|
||||
tag="small"
|
||||
class="reply-to-header faint"
|
||||
>
|
||||
<template #replyToWithIcon>
|
||||
<div class="avatar-spacer" />
|
||||
<StatusPopover
|
||||
v-if="!isBrokenReply"
|
||||
:status-id="customReplyTo?.id"
|
||||
class="reply-to-popover"
|
||||
:class="{ '-strikethrough': !message.parent_visible }"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="status.reply_to_with_icon"
|
||||
scope="global"
|
||||
>
|
||||
<template #icon>
|
||||
<FAIcon
|
||||
class="fa-scale-110"
|
||||
icon="reply"
|
||||
flip="horizontal"
|
||||
/>
|
||||
</template>
|
||||
<template #replyTo>
|
||||
<span class="reply-label">
|
||||
{{ $t('status.reply_to') }}
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</StatusPopover>
|
||||
<span v-else class="reply-label">
|
||||
{{ $t('status.broken_reply') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #user>
|
||||
<MentionLink
|
||||
class="reply-body"
|
||||
:content="replyToName"
|
||||
:url="replyProfileLink"
|
||||
:user-id="message.in_reply_to_user_id"
|
||||
:user-screen-name="message.in_reply_to_screen_name"
|
||||
/>
|
||||
<!-- v-if is there because status might not be loaded yet -->
|
||||
<template v-if="customReplyTo && customReplyTo.text.trim().length > 0">
|
||||
:
|
||||
<StatusBody
|
||||
class="reply-body faint"
|
||||
:status="customReplyTo"
|
||||
collapse
|
||||
single-line
|
||||
ignore-subject
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div
|
||||
class="chat-message"
|
||||
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
|
||||
:class="classnames"
|
||||
>
|
||||
<div
|
||||
v-if="!isCurrentUser"
|
||||
class="avatar-wrapper"
|
||||
>
|
||||
<UserPopover
|
||||
v-if="chatViewItem.isHead"
|
||||
:user-id="author.id"
|
||||
v-if="chatItem.isHead"
|
||||
:user-id="authorId"
|
||||
>
|
||||
<UserAvatar
|
||||
v-if="author"
|
||||
:compact="true"
|
||||
:user="author"
|
||||
/>
|
||||
</UserPopover>
|
||||
<div v-else class="avatar-spacer" />
|
||||
</div>
|
||||
<div class="chat-message-inner">
|
||||
<div
|
||||
class="status-body"
|
||||
:style="{ 'min-width': message.attachment ? '80%' : '' }"
|
||||
>
|
||||
<div class="message-bubble-wrapper">
|
||||
<div
|
||||
class="media status"
|
||||
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
|
||||
style="position: relative;"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
class="status-body"
|
||||
:style="{ 'min-width': message.attachment ? '80%' : '' }"
|
||||
>
|
||||
<div
|
||||
class="chat-message-menu"
|
||||
:class="{ 'visible': hovered || menuOpened }"
|
||||
class="media status"
|
||||
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
|
||||
style="position: relative;"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="top"
|
||||
bound-to-selector=".chat-view-inner"
|
||||
:bound-to="{ x: 'container' }"
|
||||
:margin="popoverMarginStyle"
|
||||
@show="menuOpened = true"
|
||||
@close="menuOpened = 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
|
||||
in-chat-view
|
||||
@toggle-replying="$emit('replyRequested', message)"
|
||||
/>
|
||||
<div
|
||||
class="chat-message-toolbar"
|
||||
:class="{ '-visible': hovered || menuOpened }"
|
||||
v-else
|
||||
>
|
||||
<template #content>
|
||||
<div class="dropdown-menu">
|
||||
<div class="menu-item dropdown-item -icon">
|
||||
<button
|
||||
class="main-button"
|
||||
@click="deleteMessage"
|
||||
>
|
||||
<FAIcon icon="times" /> {{ $t("chats.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<button
|
||||
class="button-default menu-icon"
|
||||
:title="$t('chats.more')"
|
||||
>
|
||||
<FAIcon icon="ellipsis-h" />
|
||||
</button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<StatusContent
|
||||
class="message-content"
|
||||
:status="messageForStatusContent"
|
||||
:full-content="true"
|
||||
>
|
||||
<template #footer>
|
||||
<span
|
||||
class="created-at"
|
||||
<Popover
|
||||
trigger="click"
|
||||
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
|
||||
placement="top"
|
||||
:margin="popoverMarginStyle"
|
||||
@show="menuOpened = true"
|
||||
@close="menuOpened = false"
|
||||
>
|
||||
{{ createdAt }}
|
||||
</span>
|
||||
</template>
|
||||
</StatusContent>
|
||||
<template #content>
|
||||
<div class="dropdown-menu">
|
||||
<div class="menu-item dropdown-item -icon">
|
||||
<button
|
||||
class="main-button"
|
||||
@click="deleteMessage"
|
||||
>
|
||||
<FAIcon icon="times" /> {{ $t("chats.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<FAIcon icon="ellipsis-h" />
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<StatusContent
|
||||
class="message-content"
|
||||
:class="{ faint: message.pending }"
|
||||
:status="messageForStatusContent"
|
||||
:full-content="true"
|
||||
>
|
||||
<template #footer>
|
||||
<EmojiReactions
|
||||
v-if="isStatus"
|
||||
:status="message"
|
||||
/>
|
||||
<Quote
|
||||
v-if="isStatus"
|
||||
class="quoted-post"
|
||||
:status-id="quoteId"
|
||||
:status-url="quoteUrl"
|
||||
:status-visible="quoteVisible"
|
||||
initially-expanded
|
||||
/>
|
||||
<span
|
||||
class="created-at"
|
||||
>
|
||||
<span
|
||||
v-if="message.favorited"
|
||||
>
|
||||
<FAIcon
|
||||
class="fa-scale-110"
|
||||
icon="star"
|
||||
fixed-width
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="message.repeated"
|
||||
>
|
||||
<FAIcon
|
||||
class="fa-scale-110"
|
||||
icon="retweet"
|
||||
fixed-width
|
||||
/>
|
||||
</span>
|
||||
<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>
|
||||
{{ ' ' }}
|
||||
<router-link
|
||||
class="timeago faint"
|
||||
:to="{ name: 'conversation2', params: { statusId: message.id } }"
|
||||
>
|
||||
<Timeago
|
||||
:time="message.created_at"
|
||||
:auto-update="60"
|
||||
/>
|
||||
</router-link>
|
||||
</span>
|
||||
</template>
|
||||
</StatusContent>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isStatus && repliedTo"
|
||||
class="reply-indicator"
|
||||
>
|
||||
<FAIcon class="icon" icon="reply" />
|
||||
</div>
|
||||
<div class="end-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -93,7 +232,7 @@
|
|||
v-else
|
||||
class="chat-message-date-separator"
|
||||
>
|
||||
<ChatMessageDate :date="chatViewItem.date" />
|
||||
<ChatMessageDate :date="chatItem.date" :show-time="chatItem.isTime" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
|
||||
import localeService from 'src/services/locale/locale.service.js'
|
||||
|
||||
export default {
|
||||
name: 'Timeago',
|
||||
props: ['date'],
|
||||
props: ['date', 'showTime'],
|
||||
computed: {
|
||||
time12hFormat() {
|
||||
return useMergedConfigStore().mergedConfig.absoluteTimeFormat12h === '12h'
|
||||
},
|
||||
displayDate() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
|
@ -18,10 +23,17 @@ export default {
|
|||
if (this.date.getTime() === today.getTime()) {
|
||||
return this.$t('display_date.today')
|
||||
} else {
|
||||
return this.date.toLocaleDateString(
|
||||
localeService.internalToBrowserLocale(this.$i18n.locale),
|
||||
{ day: 'numeric', month: 'long' },
|
||||
)
|
||||
if (this.showTime) {
|
||||
return this.date.toLocaleTimeString(
|
||||
localeService.internalToBrowserLocale(this.$i18n.locale),
|
||||
{ hour12: this.time12hFormat, hour: 'numeric', minute: 'numeric' },
|
||||
)
|
||||
} else {
|
||||
return this.date.toLocaleDateString(
|
||||
localeService.internalToBrowserLocale(this.$i18n.locale),
|
||||
{ day: 'numeric', month: 'long' },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
135
src/components/chat_message_list/chat_message_list.js
Normal file
135
src/components/chat_message_list/chat_message_list.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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,
|
||||
focusedId: String,
|
||||
repliedId: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hoveredMessageChainId: undefined,
|
||||
}
|
||||
},
|
||||
emits: ['messageDelete', 'replyRequested'],
|
||||
computed: {
|
||||
chatItems() {
|
||||
const messages = [
|
||||
...orderBy(this.messages, ['pending', 'id'], ['asc', 'asc']),
|
||||
...this.pendingMessages.map((m) => ({ ...m, pending: true })),
|
||||
]
|
||||
return messages
|
||||
.reduceRight((acc, message, index) => {
|
||||
const date = new Date(message.created_at)
|
||||
|
||||
const olderMessage = messages[index - 1]
|
||||
const newerItem = acc[acc.length - 1]
|
||||
|
||||
const diff = olderMessage
|
||||
? message.created_at - olderMessage.created_at
|
||||
: null
|
||||
|
||||
const MAX_DIFF = 1000 * 60 * 5 // 5 minutes
|
||||
|
||||
const dateDiffs = (() => {
|
||||
if (olderMessage) {
|
||||
const newerDate = new Date(message.created_at)
|
||||
const olderDate = new Date(olderMessage.created_at)
|
||||
|
||||
newerDate.setHours(0, 0, 0, 0)
|
||||
olderDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return newerDate.toISOString() !== olderDate.toISOString()
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
})()
|
||||
|
||||
const chatItem = {
|
||||
type: 'message',
|
||||
data: message,
|
||||
date,
|
||||
id: message.id,
|
||||
isTail: true,
|
||||
isHead: true,
|
||||
}
|
||||
|
||||
if (newerItem == null) {
|
||||
chatItem.messageChainId = uniqueId()
|
||||
} else {
|
||||
if (newerItem.type === 'date') {
|
||||
chatItem.messageChainId = uniqueId()
|
||||
} else if (newerItem.type === 'message') {
|
||||
const newerUser =
|
||||
newerItem.data.account_id || newerItem.data.user.id
|
||||
const olderUser = message.account_id || message.user.id
|
||||
if (newerUser !== olderUser) {
|
||||
chatItem.messageChainId = uniqueId()
|
||||
} else {
|
||||
chatItem.messageChainId = newerItem.messageChainId
|
||||
chatItem.isTail = false
|
||||
newerItem.isHead = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff > MAX_DIFF || (!olderMessage && this.headerDate)) {
|
||||
return [
|
||||
...acc,
|
||||
chatItem,
|
||||
{
|
||||
type: 'date',
|
||||
date,
|
||||
isDate: dateDiffs,
|
||||
isTime: diff > MAX_DIFF && !dateDiffs,
|
||||
id: date.getTime().toString(),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [...acc, chatItem]
|
||||
}
|
||||
}, [])
|
||||
.reverse()
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onMessageHover({ isHovered, messageChainId }) {
|
||||
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
|
||||
},
|
||||
onMessageDelete({ messageId, chatId }) {
|
||||
this.$emit('messageDelete', { messageId, chatId })
|
||||
},
|
||||
onReplyRequested(message) {
|
||||
this.$emit('replyRequested', message)
|
||||
},
|
||||
getPreviousItem(index) {
|
||||
let result = null
|
||||
|
||||
this.chatItems
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.some((item) => {
|
||||
const isMessage = item.type === 'message'
|
||||
if (isMessage) {
|
||||
result = item
|
||||
}
|
||||
return isMessage
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default ChatMessageList
|
||||
7
src/components/chat_message_list/chat_message_list.scss
Normal file
7
src/components/chat_message_list/chat_message_list.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.ChatMessageList {
|
||||
padding: 0.5em;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
export default {
|
||||
name: 'Chat',
|
||||
selector: '.chat-message-list',
|
||||
selector: '.ChatMessageList',
|
||||
validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
|
||||
defaultRules: [
|
||||
{
|
||||
directives: {
|
||||
backgroundNoCssColor: 'yes',
|
||||
background: '--bg',
|
||||
blur: '5px',
|
||||
},
|
||||
19
src/components/chat_message_list/chat_message_list.vue
Normal file
19
src/components/chat_message_list/chat_message_list.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<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"
|
||||
:focused="chatItem.id === focusedId"
|
||||
:repliedTo="chatItem.id === repliedId"
|
||||
@hover="onMessageHover"
|
||||
@delete="onMessageDelete"
|
||||
@reply-requested="onReplyRequested"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_message_list.js"></script>
|
||||
<style src="./chat_message_list.scss" lang="scss" />
|
||||
|
|
@ -26,10 +26,10 @@ const chatNew = {
|
|||
}
|
||||
},
|
||||
async created() {
|
||||
const { chatList } = await chats({
|
||||
const { data } = await chats({
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
chatList.forEach((chat) => this.suggestions.push(chat.account))
|
||||
data.forEach((chat) => this.suggestions.push(chat.account))
|
||||
},
|
||||
computed: {
|
||||
users() {
|
||||
|
|
|
|||
647
src/components/chat_view/chat_view.js
Normal file
647
src/components/chat_view/chat_view.js
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
import { get, maxBy, minBy, sortBy, throttle } from 'lodash'
|
||||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
|
||||
import ChatTitle from 'src/components/chat_title/chat_title.vue'
|
||||
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
||||
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
|
||||
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
|
||||
import {
|
||||
getNewTopPosition,
|
||||
getScrollPosition,
|
||||
isBottomedOut,
|
||||
isScrollable,
|
||||
} from './chat_layout_utils.js'
|
||||
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||
|
||||
import {
|
||||
chatMessages,
|
||||
deleteChatMessage,
|
||||
getOrCreateChat,
|
||||
readChat,
|
||||
sendChatMessage,
|
||||
} from 'src/api/chats.js'
|
||||
import { fetchConversation, fetchStatus } from 'src/api/public.js'
|
||||
import { WSConnectionStatus } from 'src/api/websocket.js'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(faChevronDown, faChevronLeft)
|
||||
|
||||
const BOTTOMED_OUT_OFFSET = 10
|
||||
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
|
||||
const SAFE_RESIZE_TIME_OFFSET = 100
|
||||
const MARK_AS_READ_DELAY = 1500
|
||||
const MAX_RETRIES = 10
|
||||
|
||||
const isConfirmation = (storage, message) => {
|
||||
if (!message.idempotency_key) return
|
||||
return storage.idempotencyKeyIndex[message.idempotency_key]
|
||||
}
|
||||
|
||||
const Chat = {
|
||||
components: {
|
||||
ChatMessageList,
|
||||
ChatTitle,
|
||||
PostStatusForm,
|
||||
},
|
||||
props: {
|
||||
statusId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
chatUserId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
testMode: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Main info
|
||||
chat: null,
|
||||
messages: [],
|
||||
messagesIndex: {},
|
||||
pendingMessages: [],
|
||||
pendingMessagesIndex: {},
|
||||
minId: undefined,
|
||||
maxId: undefined,
|
||||
|
||||
// Conversation stuff
|
||||
explicitReplyStatus: null,
|
||||
|
||||
// Unread stuff
|
||||
newMessageCount: 0,
|
||||
lastReadMessageId: null,
|
||||
lastScrollPosition: {},
|
||||
jumpToBottomButtonVisible: false,
|
||||
|
||||
// Internal network stuff
|
||||
fetcher: null,
|
||||
errorLoadingChat: false,
|
||||
messageRetriers: {},
|
||||
idempotencyKeyIndex: {},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.testMode) return
|
||||
this.startFetching()
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
if (typeof document.hidden !== 'undefined') {
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
this.handleVisibilityChange,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.handleResize()
|
||||
})
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
if (typeof document.hidden !== 'undefined')
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
this.handleVisibilityChange,
|
||||
false,
|
||||
)
|
||||
},
|
||||
computed: {
|
||||
conversationId() {
|
||||
const status = this.$store.state.statuses.allStatusesObject[this.statusId]
|
||||
return get(
|
||||
status,
|
||||
'retweeted_status.statusnet_conversation_id',
|
||||
get(status, 'statusnet_conversation_id'),
|
||||
)
|
||||
},
|
||||
isConversation() {
|
||||
return this.statusId !== null
|
||||
},
|
||||
recipient() {
|
||||
return this.chat?.account
|
||||
},
|
||||
formPlaceholder() {
|
||||
if (this.recipient) {
|
||||
return this.$t('chats.message_user', {
|
||||
nickname: this.recipient.screen_name_ui,
|
||||
})
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
|
||||
// Conversation stuff
|
||||
lastStatus() {
|
||||
return this.messages[this.messages.length - 1]
|
||||
},
|
||||
replyStatus() {
|
||||
return this.explicitReplyStatus ?? this.lastStatus
|
||||
},
|
||||
|
||||
// Global Stuff
|
||||
streamingEnabled() {
|
||||
if (this.isConversation) return false // Unsupported
|
||||
return (
|
||||
this.mergedConfig.useStreamingApi &&
|
||||
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
|
||||
)
|
||||
},
|
||||
...mapPiniaState(useInterfaceStore, {
|
||||
mobileLayout: (store) => store.layoutType === 'mobile',
|
||||
}),
|
||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
||||
...mapState({
|
||||
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
|
||||
currentUser: (state) => state.users.currentUser,
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
messages(old, neu) {
|
||||
if (old.length === neu.length) return
|
||||
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
|
||||
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
|
||||
const bottomedOutBeforeUpdate = isBottomedOut(BOTTOMED_OUT_OFFSET)
|
||||
this.$nextTick(() => {
|
||||
if (bottomedOutBeforeUpdate) {
|
||||
this.scrollDown()
|
||||
}
|
||||
})
|
||||
},
|
||||
async replyStatus(newVal) {
|
||||
await nextTick() // wait for changes to propagate to postStatusForm
|
||||
if (this.testMode) return
|
||||
this.$refs.postStatusForm.update()
|
||||
},
|
||||
$route: async function (newVal) {
|
||||
if (this.messagesIndex[newVal.params.statusId]) {
|
||||
const focused = document.getElementById(
|
||||
`chatmessage-${this.$route.params.statusId}`,
|
||||
)
|
||||
if (focused?.getBoundingClientRect == null) return
|
||||
const bottomBoundary =
|
||||
window.innerHeight - this.$refs.footer.clientHeight
|
||||
const topBoundary =
|
||||
this.$refs.header.clientHeight +
|
||||
document.getElementById('nav').clientHeight
|
||||
const margin = Number(
|
||||
window
|
||||
.getComputedStyle(this.$refs.messageList.$el)
|
||||
.gap.replace('px', ''),
|
||||
)
|
||||
|
||||
const rect = focused.getBoundingClientRect()
|
||||
const scrollAmount = (() => {
|
||||
if (rect.top < topBoundary) {
|
||||
// Post is above screen, match its top to screen top
|
||||
return rect.top - topBoundary - margin
|
||||
} else if (rect.height >= bottomBoundary) {
|
||||
// Post we want to see is taller than screen so match its top to screen top
|
||||
return rect.top - topBoundary - margin
|
||||
} else if (rect.bottom > bottomBoundary) {
|
||||
// Post is below screen, match its bottom to screen bottom
|
||||
return rect.bottom - bottomBoundary + margin
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})()
|
||||
|
||||
if (scrollAmount !== 0) {
|
||||
window.scrollBy(0, scrollAmount)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.clear()
|
||||
this.startFetching()
|
||||
},
|
||||
mastoUserSocketStatus(newValue) {
|
||||
if (newValue === WSConnectionStatus.JOINED) {
|
||||
this.fetchChat({ isFirstFetch: true })
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Actions
|
||||
async readChat() {
|
||||
if (this.conversationId) return // Unsupported
|
||||
if (!this.maxId || document.hidden) {
|
||||
return
|
||||
}
|
||||
const lastReadId = this.maxId
|
||||
const isNewMessage = this.lastReadMessageId !== lastReadId
|
||||
|
||||
if (!isNewMessage) return
|
||||
|
||||
if (!this.testMode) {
|
||||
await readChat({
|
||||
id: this.chat.id,
|
||||
lastReadId,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
}
|
||||
|
||||
useChatsStore().readChat(this.chat.id)
|
||||
this.lastReadMessageId = this.maxId
|
||||
this.newMessageCount = 0
|
||||
},
|
||||
scrollDown(options = {}) {
|
||||
const { behavior = 'auto', forceRead = false } = options
|
||||
this.$nextTick(() => {
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight,
|
||||
behavior,
|
||||
})
|
||||
})
|
||||
if (forceRead) {
|
||||
this.readChat()
|
||||
}
|
||||
},
|
||||
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)
|
||||
},
|
||||
clear() {
|
||||
this.messages = this.messages.filter((m) => m.error)
|
||||
this.messagesIndex = this.messages.reduce(
|
||||
(acc, m) => ({
|
||||
...acc,
|
||||
[m.id]: m,
|
||||
}),
|
||||
{},
|
||||
)
|
||||
this.newMessageCount = 0
|
||||
this.lastReadMessageId = null
|
||||
this.minId = undefined
|
||||
this.maxId = undefined
|
||||
},
|
||||
async fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
|
||||
if (fetchLatest && this.streamingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
let messages
|
||||
if (this.isConversation) {
|
||||
const [
|
||||
{ data: status },
|
||||
{
|
||||
data: { ancestors, descendants },
|
||||
},
|
||||
] = await Promise.all([
|
||||
fetchStatus({
|
||||
id: this.statusId,
|
||||
credentials: useOAuthStore().token,
|
||||
}),
|
||||
fetchConversation({
|
||||
id: this.statusId,
|
||||
credentials: useOAuthStore().token,
|
||||
}),
|
||||
])
|
||||
messages = [...ancestors, status, ...descendants]
|
||||
} else {
|
||||
const { data } = await chatMessages({
|
||||
id: this.chat.id,
|
||||
maxId,
|
||||
sinceId: fetchLatest ? this.maxId : null,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
messages = data
|
||||
}
|
||||
|
||||
// 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()
|
||||
if (isFirstFetch) {
|
||||
this.scrollDown()
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// Conversation fetching doesn't support pagination and spews out everything at once
|
||||
// so we both can't and don't need to fetch previous posts
|
||||
if (!this.isConversation && !isScrollable() && messages.length > 0) {
|
||||
this.fetchChat({
|
||||
maxId: this.minId,
|
||||
})
|
||||
}
|
||||
},
|
||||
async startFetching() {
|
||||
if (!this.isConversation) {
|
||||
try {
|
||||
const { data } = await getOrCreateChat({
|
||||
accountId: this.chatUserId,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
this.$store.commit('addNewUsers', [data.account])
|
||||
data.account = this.$store.getters.findUser(data.account.id)
|
||||
this.chat = data
|
||||
} catch (e) {
|
||||
console.error('Error creating or getting a chat', e)
|
||||
this.errorLoadingChat = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isConversation || this.chat) {
|
||||
this.$nextTick(() => {
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
this.doStartFetching()
|
||||
}
|
||||
},
|
||||
doStartFetching() {
|
||||
this.fetcher = promiseInterval(
|
||||
() => this.fetchChat({ fetchLatest: true }),
|
||||
5000,
|
||||
)
|
||||
this.fetchChat({ isFirstFetch: true })
|
||||
},
|
||||
addMessages({ messages: newMessages }) {
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const message = newMessages[i]
|
||||
|
||||
// Sanity check
|
||||
if (!this.isConversation && 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
|
||||
}
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
this.$router.back()
|
||||
},
|
||||
|
||||
// Optimistic posting (chats only)
|
||||
async sendMessage({ status, media, idempotencyKey }) {
|
||||
const params = {
|
||||
id: this.chat.id,
|
||||
content: status,
|
||||
idempotencyKey,
|
||||
}
|
||||
|
||||
if (media[0]) {
|
||||
params.mediaId = media[0].id
|
||||
}
|
||||
|
||||
const fakeMessage = buildFakeMessage({
|
||||
attachments: media,
|
||||
chatId: this.chat.id,
|
||||
content: status,
|
||||
userId: this.currentUser.id,
|
||||
idempotencyKey,
|
||||
})
|
||||
|
||||
this.pendingMessages.push(fakeMessage)
|
||||
this.pendingMessagesIndex[idempotencyKey] = fakeMessage
|
||||
|
||||
this.handleAttachmentPosting()
|
||||
|
||||
return this.doSendMessage({
|
||||
params,
|
||||
retriesLeft: MAX_RETRIES,
|
||||
})
|
||||
},
|
||||
async doSendMessage({ params, retriesLeft = MAX_RETRIES }) {
|
||||
if (retriesLeft <= 0) return
|
||||
|
||||
try {
|
||||
const { data } = await sendChatMessage({
|
||||
...params,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
// Checks
|
||||
hasReachedTop() {
|
||||
return window.scrollY <= 0
|
||||
},
|
||||
cullOlderCheck() {
|
||||
if (this.conversationId) return
|
||||
window.setTimeout(() => {
|
||||
if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
||||
this.cullOlder()
|
||||
}
|
||||
}, 5000)
|
||||
},
|
||||
|
||||
// Event handlers
|
||||
onPosted(data) {
|
||||
this.explicitReplyStatus = null
|
||||
this.$router.push({
|
||||
name: 'conversation2',
|
||||
params: { statusId: data.id },
|
||||
})
|
||||
},
|
||||
handleVisibilityChange() {
|
||||
this.$nextTick(() => {
|
||||
if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) {
|
||||
this.scrollDown({ forceRead: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
onFilesDropped() {
|
||||
this.$nextTick(() => {
|
||||
this.handleResize()
|
||||
})
|
||||
},
|
||||
handleResize(opts = {}) {
|
||||
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
|
||||
const { delayed = false } = opts
|
||||
|
||||
if (delayed) {
|
||||
setTimeout(() => {
|
||||
this.handleResize({ ...opts, delayed: false })
|
||||
}, SAFE_RESIZE_TIME_OFFSET)
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const { offsetHeight = undefined } = getScrollPosition()
|
||||
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
|
||||
if (diff !== 0 && !isBottomedOut()) {
|
||||
this.$nextTick(() => {
|
||||
window.scrollBy({ top: -Math.trunc(diff) })
|
||||
})
|
||||
}
|
||||
this.lastScrollPosition = getScrollPosition()
|
||||
})
|
||||
},
|
||||
handleScroll: throttle(function () {
|
||||
if (!this.chat) {
|
||||
return
|
||||
}
|
||||
this.lastScrollPosition = getScrollPosition()
|
||||
|
||||
if (this.hasReachedTop()) {
|
||||
this.fetchChat({ maxId: this.minId })
|
||||
} else if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
|
||||
this.jumpToBottomButtonVisible = false
|
||||
this.cullOlderCheck()
|
||||
if (this.newMessageCount > 0) {
|
||||
// Use a delay before marking as read to prevent situation where new messages
|
||||
// arrive just as you're leaving the view and messages that you didn't actually
|
||||
// get to see get marked as read.
|
||||
window.setTimeout(() => {
|
||||
// Don't mark as read if the element doesn't exist, user has left chat view
|
||||
if (this.$el) this.readChat()
|
||||
}, MARK_AS_READ_DELAY)
|
||||
}
|
||||
} else {
|
||||
this.jumpToBottomButtonVisible = true
|
||||
}
|
||||
}, 200),
|
||||
handleScrollUp(positionBeforeLoading) {
|
||||
const positionAfterLoading = getScrollPosition()
|
||||
|
||||
window.scrollTo({
|
||||
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
|
||||
})
|
||||
},
|
||||
handleAttachmentPosting() {
|
||||
this.$nextTick(() => {
|
||||
this.handleResize()
|
||||
// When the posting form size changes because of a media attachment, we need an extra resize
|
||||
// to account for the potential delay in the DOM update.
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
},
|
||||
|
||||
// Ugly
|
||||
// TODO move to ChatMessage
|
||||
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
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default Chat
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
.chat-view {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.chat-list-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-spacer {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-view-inner {
|
||||
height: auto;
|
||||
|
|
@ -36,6 +46,10 @@
|
|||
|
||||
.footer {
|
||||
position: sticky;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
@ -95,4 +109,11 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reply-to-text {
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
padding-top: 0.5em;
|
||||
margin-bottom: -0.5em;
|
||||
}
|
||||
}
|
||||
124
src/components/chat_view/chat_view.vue
Normal file
124
src/components/chat_view/chat_view.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="chat-view-inner">
|
||||
<div
|
||||
ref="inner"
|
||||
class="panel-default panel chat-view-body"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading -sticky chat-view-heading"
|
||||
>
|
||||
<button
|
||||
class="button-unstyled go-back-button"
|
||||
@click="goBack"
|
||||
>
|
||||
<FAIcon
|
||||
size="lg"
|
||||
icon="chevron-left"
|
||||
/>
|
||||
</button>
|
||||
<div class="title">
|
||||
<template v-if="isConversation">
|
||||
<RichContent
|
||||
v-if="messages[0]?.summary_raw_html"
|
||||
:html="messages[0].summary_raw_html"
|
||||
:emoji="messages[0].emojis"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ $t('timeline.conversation') }}
|
||||
</template>
|
||||
</template>
|
||||
<ChatTitle
|
||||
v-else
|
||||
:user="recipient"
|
||||
:with-avatar="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-list-wrapper panel-body">
|
||||
<div class="top-spacer" />
|
||||
<ChatMessageList
|
||||
ref="messageList"
|
||||
header-date
|
||||
:messages="messages"
|
||||
:pending-messages="pendingMessages"
|
||||
:replied-id="replyStatus?.id"
|
||||
:focused-id="statusId"
|
||||
@message-delete="deleteChatMessage"
|
||||
@reply-requested="e => explicitReplyStatus = e"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="footer"
|
||||
class="panel-footer -flexible-height footer"
|
||||
>
|
||||
<div
|
||||
class="jump-to-bottom-button"
|
||||
:class="{ 'visible': jumpToBottomButtonVisible }"
|
||||
@click="scrollDown({ behavior: 'smooth' })"
|
||||
>
|
||||
<span>
|
||||
<FAIcon icon="chevron-down" />
|
||||
<div
|
||||
v-if="newMessageCount"
|
||||
class="badge -notification unread-chat-count unread-message-count"
|
||||
>
|
||||
{{ newMessageCount }}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isConversation"
|
||||
class="auto-reply-to-section"
|
||||
>
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
<PostStatusForm
|
||||
ref="postStatusForm"
|
||||
:replied-status="replyStatus"
|
||||
:mentions-line="isConversation"
|
||||
mentions-line-read-only
|
||||
|
||||
disable-quotes
|
||||
disable-notice
|
||||
disable-lock-warning
|
||||
:disable-subject="!isConversation"
|
||||
:disable-scope-selector="!isConversation"
|
||||
:disable-polls="!isConversation"
|
||||
:disable-sensitivity-checkbox="!isConversation"
|
||||
:disable-preview="!isConversation"
|
||||
:disable-draft="!isConversation"
|
||||
|
||||
:disable-submit="isConversation ? !replyStatus : (errorLoadingChat || !chat)"
|
||||
:optimistic-posting="!isConversation"
|
||||
|
||||
chat-view
|
||||
preserve-focus
|
||||
:auto-focus="!mobileLayout"
|
||||
:placeholder="formPlaceholder"
|
||||
:file-limit="isConversation ? null : 1"
|
||||
:max-height="160"
|
||||
emoji-picker-placement="top"
|
||||
:post-handler="isConversation ? null : sendMessage"
|
||||
@resize="handleResize"
|
||||
@posted="onPosted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_view.js"></script>
|
||||
<style src="./chat_view.scss" lang="scss" />
|
||||
|
|
@ -2,8 +2,11 @@ import { clone, filter, findIndex, get, reduce } from 'lodash'
|
|||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
|
||||
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
||||
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
|
||||
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
|
||||
import RichContent from 'src/components/rich_content/rich_content.jsx'
|
||||
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
|
||||
|
||||
import { useInterfaceStore } from 'src/stores/interface'
|
||||
|
|
@ -18,9 +21,17 @@ import {
|
|||
faAngleDoubleDown,
|
||||
faAngleDoubleLeft,
|
||||
faChevronLeft,
|
||||
faReply,
|
||||
faTimes,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
|
||||
library.add(
|
||||
faAngleDoubleDown,
|
||||
faAngleDoubleLeft,
|
||||
faChevronLeft,
|
||||
faReply,
|
||||
faTimes,
|
||||
)
|
||||
|
||||
const sortById = (a, b) => {
|
||||
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
|
||||
|
|
@ -127,14 +138,14 @@ const conversation = {
|
|||
displayStyle() {
|
||||
return this.mergedConfig.conversationDisplay
|
||||
},
|
||||
isTreeView() {
|
||||
return !this.isLinearView
|
||||
},
|
||||
treeViewIsSimple() {
|
||||
return !this.mergedConfig.conversationTreeAdvanced
|
||||
},
|
||||
isTreeView() {
|
||||
return this.displayStyle === 'tree'
|
||||
},
|
||||
isLinearView() {
|
||||
return this.displayStyle === 'linear'
|
||||
return this.displayStyle !== 'tree'
|
||||
},
|
||||
shouldFadeAncestors() {
|
||||
return this.mergedConfig.conversationTreeFadeAncestors
|
||||
|
|
@ -404,6 +415,9 @@ const conversation = {
|
|||
ThreadTree,
|
||||
QuickFilterSettings,
|
||||
QuickViewSettings,
|
||||
ChatMessageList,
|
||||
PostStatusForm,
|
||||
RichContent,
|
||||
},
|
||||
watch: {
|
||||
statusId(newVal, oldVal) {
|
||||
|
|
@ -611,6 +625,11 @@ const conversation = {
|
|||
this.unsuspendibleIds.delete(id)
|
||||
}
|
||||
},
|
||||
onPosted(data) {
|
||||
if (this.isPage) {
|
||||
this.$router.push({ name: 'conversation', params: { id: data.id } })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
95
src/components/conversation/conversation.scss
Normal file
95
src/components/conversation/conversation.scss
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,14 @@
|
|||
class="panel-heading conversation-heading -sticky"
|
||||
>
|
||||
<h1 class="title">
|
||||
<RichContent
|
||||
v-if="conversation[0]?.summary_raw_html"
|
||||
:html="conversation[0].summary_raw_html"
|
||||
:emoji="conversation[0].emojis"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ $t('timeline.conversation') }}
|
||||
</template>
|
||||
</h1>
|
||||
<button
|
||||
v-if="collapsable"
|
||||
|
|
@ -170,7 +177,7 @@
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLinearView"
|
||||
v-else-if="isLinearView"
|
||||
class="thread-body"
|
||||
>
|
||||
<article>
|
||||
|
|
@ -206,101 +213,4 @@
|
|||
</template>
|
||||
|
||||
<script src="./conversation.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
.Conversation {
|
||||
z-index: 1;
|
||||
|
||||
&.-hidden {
|
||||
background: var(--__panel-background);
|
||||
backdrop-filter: var(--__panel-backdrop-filter);
|
||||
}
|
||||
|
||||
.conversation-dive-to-top-level-box {
|
||||
padding: var(--status-margin);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
|
||||
/* Make the button stretch along the whole row */
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.thread-ancestors {
|
||||
margin-left: var(--status-margin);
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.thread-ancestor.-faded .RichContent {
|
||||
/* stylelint-disable declaration-no-important */
|
||||
--text: var(--textFaint) !important;
|
||||
--link: var(--linkFaint) !important;
|
||||
--funtextGreentext: var(--funtextGreentextFaint) !important;
|
||||
--funtextCyantext: var(--funtextCyantextFaint) !important;
|
||||
/* stylelint-enable declaration-no-important */
|
||||
}
|
||||
|
||||
.thread-ancestor-dive-box {
|
||||
padding-left: var(--status-margin);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
|
||||
/* Make the button stretch along the whole row */
|
||||
&,
|
||||
&-inner {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-ancestor-dive-box-inner {
|
||||
padding: var(--status-margin);
|
||||
}
|
||||
|
||||
.conversation-status {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.thread-ancestor-has-other-replies .conversation-status,
|
||||
&:last-child:not(.-expanded) .conversation-status,
|
||||
&.-expanded .conversation-status:last-child,
|
||||
.thread-ancestor:last-child .conversation-status,
|
||||
.thread-ancestor:last-child .thread-ancestor-dive-box,
|
||||
&.-expanded .thread-tree .conversation-status {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.thread-ancestors + .thread-tree > .conversation-status {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* expanded conversation in timeline */
|
||||
&.status-fadein.-expanded .thread-body {
|
||||
border-left: 4px solid var(--cRed);
|
||||
border-radius: var(--roundness);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
&.-expanded.status-fadein {
|
||||
--___margin: calc(var(--status-margin) / 2);
|
||||
|
||||
background: var(--background);
|
||||
margin: var(--___margin);
|
||||
|
||||
&::before {
|
||||
z-index: -1;
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: calc(var(--___margin) * -1);
|
||||
background: var(--background);
|
||||
backdrop-filter: var(--__panel-backdrop-filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style src="./conversation.scss" />
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ const Draft = {
|
|||
if (this.draft.type === 'edit') {
|
||||
return { statusId: this.draft.refId }
|
||||
} else if (this.draft.type === 'reply') {
|
||||
return { replyTo: this.draft.refId }
|
||||
return {
|
||||
repliedStatus: this.refStatus,
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
@pause="$emit('mediapause', attachment.id)"
|
||||
/>
|
||||
<div
|
||||
v-if="draft.poll.options"
|
||||
v-if="draft.poll?.options"
|
||||
class="poll-indicator-container"
|
||||
:title="$t('drafts.poll_tooltip')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { mapState } from 'pinia'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
import { useSyncConfigStore } from 'src/stores/sync_config.js'
|
||||
|
|
@ -21,7 +22,7 @@ const ExtraNotifications = {
|
|||
return (
|
||||
this.mergedConfig.showExtraNotifications &&
|
||||
this.mergedConfig.showChatsInExtraNotifications &&
|
||||
this.unreadChatCount
|
||||
this.unreadChatsCount
|
||||
)
|
||||
},
|
||||
shouldShowAnnouncements() {
|
||||
|
|
@ -53,11 +54,12 @@ const ExtraNotifications = {
|
|||
currentUser() {
|
||||
return this.$store.state.users.currentUser
|
||||
},
|
||||
...mapGetters(['unreadChatCount', 'followRequestCount']),
|
||||
...mapPiniaState(useAnnouncementsStore, {
|
||||
...mapGetters(['followRequestCount']),
|
||||
...mapState(useAnnouncementsStore, {
|
||||
unreadAnnouncementCount: 'unreadAnnouncementCount',
|
||||
}),
|
||||
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
|
||||
...mapState(useMergedConfigStore, ['mergedConfig']),
|
||||
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||
},
|
||||
methods: {
|
||||
openNotificationSettings() {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
class="fa-scale-110 icon"
|
||||
icon="comments"
|
||||
/>
|
||||
{{ $t('notifications.unread_chats', { num: unreadChatCount }, unreadChatCount) }}
|
||||
{{ $t('notifications.unread_chats', { num: unreadChatsCount }, unreadChatsCount) }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { mapState } from 'pinia'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
||||
import GestureService from '../../services/gesture_service/gesture_service'
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
} from '../../services/notification_utils/notification_utils'
|
||||
|
||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInstanceStore } from 'src/stores/instance.js'
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
|
||||
|
|
@ -68,6 +68,7 @@ const MobileNav = {
|
|||
countExtraNotifications(
|
||||
this.$store,
|
||||
useMergedConfigStore().mergedConfig,
|
||||
useChatsStore().unreadChatsCount,
|
||||
useAnnouncementsStore().unreadAnnouncementCount,
|
||||
)
|
||||
)
|
||||
|
|
@ -87,18 +88,18 @@ const MobileNav = {
|
|||
isChat() {
|
||||
return this.$route.name === 'chat'
|
||||
},
|
||||
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
|
||||
...mapState(useMergedConfigStore, {
|
||||
pinnedItems: (store) =>
|
||||
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
|
||||
}),
|
||||
shouldConfirmLogout() {
|
||||
return useMergedConfigStore().mergedConfig.modalOnLogout
|
||||
},
|
||||
closingDrawerMarksAsSeen() {
|
||||
return useMergedConfigStore().mergedConfig.closingDrawerMarksAsSeen
|
||||
},
|
||||
...mapGetters(['unreadChatCount']),
|
||||
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
|
||||
...mapState(useMergedConfigStore, {
|
||||
pinnedItems: (store) =>
|
||||
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
|
||||
}),
|
||||
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||
},
|
||||
methods: {
|
||||
toggleMobileSidebar() {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
icon="bars"
|
||||
/>
|
||||
<div
|
||||
v-if="(unreadChatCount && !chatsPinned) || unreadAnnouncementCount"
|
||||
v-if="(unreadChatsCount && !chatsPinned) || unreadAnnouncementCount"
|
||||
class="badge -dot -notification"
|
||||
/>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { mapState as mapPiniaState } from 'pinia'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
|
||||
import Checkbox from 'src/components/checkbox/checkbox.vue'
|
||||
|
|
@ -10,6 +10,7 @@ import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
|
|||
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
|
||||
|
||||
import { useAnnouncementsStore } from 'src/stores/announcements'
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInstanceStore } from 'src/stores/instance.js'
|
||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||
import { useSyncConfigStore } from 'src/stores/sync_config.js'
|
||||
|
|
@ -131,6 +132,7 @@ const NavPanel = {
|
|||
currentUser: (state) => state.users.currentUser,
|
||||
followRequestCount: (state) => state.api.followRequests.length,
|
||||
}),
|
||||
...mapPiniaState(useChatsStore, ['unreadChatsCount']),
|
||||
timelinesItems() {
|
||||
return filterNavigation(
|
||||
Object.entries({ ...TIMELINES })
|
||||
|
|
@ -162,7 +164,6 @@ const NavPanel = {
|
|||
},
|
||||
)
|
||||
},
|
||||
...mapGetters(['unreadChatCount']),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export const ROOT_ITEMS = {
|
|||
icon: 'comments',
|
||||
label: 'nav.chats',
|
||||
badgeStyle: 'notification',
|
||||
badgeGetter: 'unreadChatCount',
|
||||
badgeGetter: 'unreadChatsCount',
|
||||
criteria: ['chats'],
|
||||
},
|
||||
friendRequests: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { mapState } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import ExtraNotifications from 'src/components/extra_notifications/extra_notifications.vue'
|
||||
import Notification from 'src/components/notification/notification.vue'
|
||||
|
|
@ -16,6 +15,7 @@ import notificationsFetcher from '../../services/notifications_fetcher/notificat
|
|||
import NotificationFilters from './notification_filters.vue'
|
||||
|
||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
|
||||
|
|
@ -115,13 +115,14 @@ const Notifications = {
|
|||
return countExtraNotifications(
|
||||
this.$store,
|
||||
useMergedConfigStore().mergedConfig,
|
||||
useChatsStore().unreadChatsCount,
|
||||
useAnnouncementsStore().unreadAnnouncementCount,
|
||||
)
|
||||
},
|
||||
unseenCountTitle() {
|
||||
return (
|
||||
this.unseenNotifications.length +
|
||||
this.unreadChatCount +
|
||||
this.unreadChatsCount +
|
||||
this.unreadAnnouncementCount
|
||||
)
|
||||
},
|
||||
|
|
@ -160,7 +161,7 @@ const Notifications = {
|
|||
return !this.noExtra
|
||||
},
|
||||
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
|
||||
...mapGetters(['unreadChatCount']),
|
||||
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||
},
|
||||
mounted() {
|
||||
this.scrollerRef = this.$refs.root.closest('.column.-scrollable')
|
||||
|
|
|
|||
|
|
@ -16,42 +16,54 @@ export default {
|
|||
},
|
||||
name: 'PollForm',
|
||||
props: {
|
||||
visible: {},
|
||||
params: {
|
||||
visible: Boolean,
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
pollType: {
|
||||
get() {
|
||||
return pollFallback(this.params, 'pollType')
|
||||
return pollFallback(this.modelValue, 'pollType')
|
||||
},
|
||||
set(newVal) {
|
||||
this.params.pollType = newVal
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
pollType: newVal,
|
||||
})
|
||||
},
|
||||
},
|
||||
options() {
|
||||
const hasOptions = !!this.params.options
|
||||
if (!hasOptions) {
|
||||
this.params.options = pollFallback(this.params, 'options')
|
||||
}
|
||||
return this.params.options
|
||||
options: {
|
||||
get() {
|
||||
return pollFallback(this.modelValue, 'options')
|
||||
},
|
||||
set(newVal) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, options: newVal })
|
||||
},
|
||||
},
|
||||
expiryAmount: {
|
||||
get() {
|
||||
return pollFallback(this.params, 'expiryAmount')
|
||||
return pollFallback(this.modelValue, 'expiryAmount')
|
||||
},
|
||||
set(newVal) {
|
||||
this.params.expiryAmount = newVal
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
expiryAmount: newVal,
|
||||
})
|
||||
},
|
||||
},
|
||||
expiryUnit: {
|
||||
get() {
|
||||
return pollFallback(this.params, 'expiryUnit')
|
||||
return pollFallback(this.modelValue, 'expiryUnit')
|
||||
},
|
||||
set(newVal) {
|
||||
this.params.expiryUnit = newVal
|
||||
this.$emit('update:modelValue', {
|
||||
...this.modelValue,
|
||||
expiryUnit: newVal,
|
||||
})
|
||||
},
|
||||
},
|
||||
pollLimits() {
|
||||
|
|
@ -88,12 +100,6 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
clear() {
|
||||
this.pollType = 'single'
|
||||
this.options = ['', '']
|
||||
this.expiryAmount = 10
|
||||
this.expiryUnit = 'minutes'
|
||||
},
|
||||
nextOption(index) {
|
||||
const element = this.$el.querySelector(`#poll-${index + 1}`)
|
||||
if (element) {
|
||||
|
|
@ -110,7 +116,7 @@ export default {
|
|||
},
|
||||
addOption() {
|
||||
if (this.options.length < this.maxOptions) {
|
||||
this.options.push('')
|
||||
this.options = [...this.options, '']
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
|
@ -118,8 +124,12 @@ export default {
|
|||
deleteOption(index) {
|
||||
if (this.options.length > 2) {
|
||||
this.options.splice(index, 1)
|
||||
this.options = this.options
|
||||
}
|
||||
},
|
||||
updateOption(index, value) {
|
||||
this.options = this.options
|
||||
},
|
||||
convertExpiryToUnit(unit, amount) {
|
||||
// Note: we want seconds and not milliseconds
|
||||
return DateUtils.secondsToUnit(unit, amount)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
:placeholder="$t('polls.option')"
|
||||
:maxlength="maxLength"
|
||||
@keydown.enter.stop.prevent="nextOption(index)"
|
||||
@change="updateOption"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,20 @@
|
|||
.post-status-form {
|
||||
position: relative;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5em;
|
||||
position: relative;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
|
@ -16,7 +30,6 @@
|
|||
.form-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5em;
|
||||
height: 2.5em;
|
||||
|
||||
.post-button-group {
|
||||
|
|
@ -42,11 +55,11 @@
|
|||
.form-bottom-left {
|
||||
display: flex;
|
||||
gap: 1.5em;
|
||||
margin-right: 1em;
|
||||
margin: 0 0.5em;
|
||||
|
||||
button {
|
||||
padding: 0.5em;
|
||||
margin: -0.5em;
|
||||
padding: 0.25em;
|
||||
margin: -0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,22 +203,15 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0.6em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25em 0.5em 0.5em;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.inputs-wrapper {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.keyboard-enter-hint {
|
||||
text-align: right;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
textarea.input.form-post-body {
|
||||
|
|
@ -236,6 +242,10 @@
|
|||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mentions-input {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.character-counter {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
|
@ -262,8 +272,7 @@
|
|||
|
||||
.drop-indicator {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
font-size: 5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<div
|
||||
ref="form"
|
||||
class="post-status-form"
|
||||
v-if="initialized"
|
||||
>
|
||||
<form
|
||||
autocomplete="off"
|
||||
|
|
@ -10,7 +11,7 @@
|
|||
>
|
||||
<div class="form-group">
|
||||
<div
|
||||
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
|
||||
v-if="!currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
|
||||
class="visibility-notice notice-dismissible"
|
||||
>
|
||||
<i18n-t
|
||||
|
|
@ -58,7 +59,7 @@
|
|||
</a>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked"
|
||||
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && currentUser.locked"
|
||||
class="visibility-notice notice-dismissible"
|
||||
>
|
||||
<span>{{ $t('post_status.scope_notice.private') }}</span>
|
||||
|
|
@ -73,7 +74,7 @@
|
|||
</a>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="newStatus.visibility === 'direct'"
|
||||
v-else-if="!hideScopeNotice && newStatus.visibility === 'direct'"
|
||||
class="visibility-notice notice-dismissible"
|
||||
>
|
||||
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
|
||||
|
|
@ -172,6 +173,16 @@
|
|||
>
|
||||
</template>
|
||||
</EmojiInput>
|
||||
<input
|
||||
v-if="mentionsLine"
|
||||
:value="mentionsLineReadOnly ? mentionsString : newStatus.mentionsLine"
|
||||
@change="onMentionsLineUpdate"
|
||||
type="text"
|
||||
:placeholder="$t('post_status.mentions_line')"
|
||||
:disabled="mentionsLineReadOnly || (posting && !optimisticPosting)"
|
||||
size="1"
|
||||
class="input mentions-input form-post-mentions unstyled"
|
||||
>
|
||||
<EmojiInput
|
||||
ref="emoji-input"
|
||||
v-model="newStatus.status"
|
||||
|
|
@ -197,9 +208,9 @@
|
|||
class="input form-post-body"
|
||||
:class="{ 'scrollable-form': !!maxHeight }"
|
||||
v-bind="propsToNative(inputProps)"
|
||||
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
|
||||
@keydown.exact.enter="submitOnEnter && postStatus($event)"
|
||||
@keydown.meta.enter="postStatus($event, newStatus)"
|
||||
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
|
||||
@keydown.ctrl.enter="!submitOnEnter && postStatus($event)"
|
||||
@input="resize"
|
||||
@compositionupdate="resize"
|
||||
@paste="paste"
|
||||
|
|
@ -220,11 +231,12 @@
|
|||
>
|
||||
<scope-selector
|
||||
v-if="!disableVisibilitySelector"
|
||||
ref="scopeSelector"
|
||||
:show-all="showAllScopes"
|
||||
:user-default="userDefaultScope"
|
||||
:original-scope="copyMessageScope"
|
||||
:original-scope="newStatus.visibility"
|
||||
:initial-scope="newStatus.visibility"
|
||||
:on-scope-change="changeVis"
|
||||
@change="changeVis"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -260,14 +272,14 @@
|
|||
v-if="pollsAvailable"
|
||||
ref="pollForm"
|
||||
:visible="pollFormVisible"
|
||||
:params="newStatus.poll"
|
||||
v-model="newStatus.poll"
|
||||
/>
|
||||
<QuoteForm
|
||||
v-if="quotingAvailable"
|
||||
:id="newStatus.quote.id"
|
||||
ref="quoteForm"
|
||||
:visible="quoteFormVisible"
|
||||
:url="newStatus.quote.url"
|
||||
:id="newStatus.quote?.id"
|
||||
:url="newStatus.quote?.url"
|
||||
@update:url="url => newStatus.quote.url = url"
|
||||
@update:id="id => newStatus.quote.id = id"
|
||||
/>
|
||||
|
|
@ -316,7 +328,7 @@
|
|||
<button
|
||||
class="btn button-default post-button"
|
||||
:disabled="isOverLengthLimit || posting || uploadingFiles || disableSubmit"
|
||||
@click.stop.prevent="postStatus($event, newStatus)"
|
||||
@click.stop.prevent="postStatus($event)"
|
||||
>
|
||||
<template v-if="posting">
|
||||
{{ $t('post_status.posting') }}
|
||||
|
|
@ -370,6 +382,14 @@
|
|||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<small class="keyboard-enter-hint faint">
|
||||
<i v-if="submitOnEnter">
|
||||
{{ $t('post_status.enter_submits') }}
|
||||
</i>
|
||||
<i v-else>
|
||||
{{ $t('post_status.enter_newline') }}
|
||||
</i>
|
||||
</small>
|
||||
<div
|
||||
v-show="showDropIcon !== 'hide'"
|
||||
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
|
||||
|
|
|
|||
|
|
@ -479,7 +479,8 @@ export default {
|
|||
>
|
||||
{this.collapse
|
||||
? pass2.map((x) => {
|
||||
if (!Array.isArray(x)) return x.replace(/\n/g, ' ')
|
||||
if (typeof x === 'string') return x.replace(/\n/g, ' ')
|
||||
if (!Array.isArray(x)) return x
|
||||
return x.map((y) => (y.type === 'br' ? ' ' : y))
|
||||
})
|
||||
: pass2}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,13 @@ const ScopeSelector = {
|
|||
required: false,
|
||||
type: String,
|
||||
},
|
||||
onScopeChange: {
|
||||
required: true,
|
||||
type: Function,
|
||||
},
|
||||
unstyled: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
currentScope: this.initialScope,
|
||||
|
|
@ -84,7 +81,12 @@ const ScopeSelector = {
|
|||
},
|
||||
changeVis(scope) {
|
||||
this.currentScope = scope
|
||||
this.onScopeChange && this.onScopeChange(scope)
|
||||
this.$emit('change', scope)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
originalScope(newVal) {
|
||||
this.currentScope = newVal
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,13 @@
|
|||
{{ $t('settings.subject_line_behavior') }}
|
||||
</ChoiceSetting>
|
||||
</li>
|
||||
<li>
|
||||
<BooleanSetting
|
||||
path="chatSubmitOnEnter"
|
||||
>
|
||||
{{ $t('settings.submit_on_enter_in_chats') }}
|
||||
</BooleanSetting>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 v-if="expertLevel > 0">
|
||||
{{ $t('settings.attachments') }}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import GestureService from '../../services/gesture_service/gesture_service'
|
|||
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
||||
|
||||
import { useAnnouncementsStore } from 'src/stores/announcements'
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInstanceStore } from 'src/stores/instance.js'
|
||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||
import { useInterfaceStore } from 'src/stores/interface'
|
||||
|
|
@ -113,7 +114,8 @@ const SideDrawer = {
|
|||
sitename: (store) => store.instanceIdentity.name,
|
||||
hideSitename: (store) => store.instanceIdentity.hideSitename,
|
||||
}),
|
||||
...mapGetters(['unreadChatCount', 'draftCount']),
|
||||
...mapState(useChatsStore, ['unreadChatsCount']),
|
||||
...mapGetters(['draftCount']),
|
||||
},
|
||||
methods: {
|
||||
toggleDrawer() {
|
||||
|
|
|
|||
|
|
@ -106,10 +106,10 @@
|
|||
icon="comments"
|
||||
/> {{ $t("nav.chats") }}
|
||||
<span
|
||||
v-if="unreadChatCount"
|
||||
v-if="unreadChatsCount"
|
||||
class="badge -notification"
|
||||
>
|
||||
{{ unreadChatCount }}
|
||||
{{ unreadChatsCount }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { unescape as ldUnescape, uniqBy } from 'lodash'
|
||||
import { uniqBy } from 'lodash'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
|
||||
|
|
@ -377,19 +377,6 @@ const Status = {
|
|||
return user && user.screen_name_ui
|
||||
}
|
||||
},
|
||||
replySubject() {
|
||||
if (!this.status.summary) return ''
|
||||
const decodedSummary = ldUnescape(this.status.summary)
|
||||
const behavior = this.mergedConfig.subjectLineBehavior
|
||||
const startsWithRe = decodedSummary.match(/^re[: ]/i)
|
||||
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
|
||||
return decodedSummary
|
||||
} else if (behavior === 'email') {
|
||||
return 're: '.concat(decodedSummary)
|
||||
} else if (behavior === 'noop') {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
combinedFavsAndRepeatsUsers() {
|
||||
// Use the status from the global status repository since favs and repeats are saved in it
|
||||
const combinedUsers = [].concat(
|
||||
|
|
@ -553,10 +540,9 @@ const Status = {
|
|||
toggleThreadDisplay() {
|
||||
this.controlledToggleThreadDisplay()
|
||||
},
|
||||
scrollIfFocused(focusedId) {
|
||||
scrollIfFocused(focused) {
|
||||
if (this.$el.getBoundingClientRect == null) return
|
||||
const id = focusedId
|
||||
if (this.status.id === id) {
|
||||
if (focused) {
|
||||
const rect = this.$el.getBoundingClientRect()
|
||||
if (rect.top < 100) {
|
||||
// Post is above screen, match its top to screen top
|
||||
|
|
|
|||
|
|
@ -385,4 +385,8 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.status-action-buttons {
|
||||
margin-top: var(--status-margin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,6 +514,7 @@
|
|||
|
||||
<StatusActionButtons
|
||||
v-if="!noHeading && !isPreview"
|
||||
class="status-action-buttons"
|
||||
:status="status"
|
||||
:replying="replying"
|
||||
@toggle-replying="toggleReplyForm"
|
||||
|
|
@ -545,11 +546,7 @@
|
|||
ref="postStatusForm"
|
||||
class="reply-body"
|
||||
:closeable="true"
|
||||
:reply-to="status.id"
|
||||
:attentions="status.attentions"
|
||||
:replied-user="status.user"
|
||||
:copy-message-scope="status.visibility"
|
||||
:subject="replySubject"
|
||||
:replied-status="status"
|
||||
@posted="closeReplyForm"
|
||||
@draft-done="closeReplyForm"
|
||||
@close-accepted="closeReplyForm"
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ import {
|
|||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faComments,
|
||||
faExternalLinkAlt,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faHistory,
|
||||
faList,
|
||||
faMinus,
|
||||
faPencil,
|
||||
faPlus,
|
||||
faReply,
|
||||
faRetweet,
|
||||
|
|
@ -53,7 +56,10 @@ library.add(
|
|||
faEyeSlash,
|
||||
faEye,
|
||||
faThumbtack,
|
||||
faPencil,
|
||||
faShareAlt,
|
||||
faComments,
|
||||
faList,
|
||||
faExternalLinkAlt,
|
||||
faHistory,
|
||||
)
|
||||
|
|
@ -69,6 +75,8 @@ export default {
|
|||
'getComponent',
|
||||
'doAction',
|
||||
'outerClose',
|
||||
'defaultButtonStyle',
|
||||
'hideLabel',
|
||||
],
|
||||
components: {
|
||||
StatusBookmarkFolderMenu,
|
||||
|
|
@ -103,11 +111,14 @@ export default {
|
|||
return useMergedConfigStore().mergedConfig.hidePostStats
|
||||
},
|
||||
buttonInnerClass() {
|
||||
const buttonStyleClass = this.defaultButtonStyle
|
||||
? 'button-default'
|
||||
: 'button-unstyled'
|
||||
return [
|
||||
this.button.name + '-button',
|
||||
{
|
||||
'main-button': this.extra,
|
||||
'button-unstyled': !this.extra,
|
||||
[buttonStyleClass]: !this.extra,
|
||||
'-active': this.button.active?.(this.funcArg),
|
||||
disabled: this.button.interactive
|
||||
? !this.button.interactive(this.funcArg)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
/>
|
||||
</component>
|
||||
<span
|
||||
v-if="!hidePostStats && button.counter?.(funcArg) > 0"
|
||||
v-if="!hidePostStats && button.counter?.(funcArg) > 0 && !hideLabel"
|
||||
class="action-counter"
|
||||
>
|
||||
{{ button.counter?.(funcArg) }}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import ActionButton from './action_button.vue'
|
|||
|
||||
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
|
||||
|
||||
import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faEnvelope,
|
||||
|
|
@ -42,13 +44,18 @@ export default {
|
|||
),
|
||||
),
|
||||
},
|
||||
props: ['button', 'status'],
|
||||
props: ['button', 'status', 'defaultButton', 'hideLabel'],
|
||||
emits: ['emojiPickerShown'],
|
||||
mounted() {
|
||||
if (this.button.name === 'mute') {
|
||||
this.$store.dispatch('fetchDomainMutes')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
randomSeed: genRandomSeed(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
buttonClass() {
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<ActionButton
|
||||
:button="button"
|
||||
:status="status"
|
||||
:hide-label="hideLabel"
|
||||
v-bind.prop="$attrs"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -131,6 +132,7 @@
|
|||
v-else
|
||||
:button="button"
|
||||
:status="status"
|
||||
:hide-label="hideLabel"
|
||||
v-bind="$attrs"
|
||||
@emoji-picker-shown="e => $emit('emojiPickerShown', e)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -207,17 +207,45 @@ export const BUTTONS = [
|
|||
return dispatch('fetchStatusSource', { id: status.id }).then((data) =>
|
||||
useEditStatusStore().openEditStatusModal({
|
||||
statusId: status.id,
|
||||
subject: data.spoiler_text,
|
||||
statusSubject: data.spoiler_text,
|
||||
statusText: data.text,
|
||||
statusIsSensitive: status.nsfw,
|
||||
statusPoll: status.poll,
|
||||
statusFiles: [...status.attachments],
|
||||
visibility: status.visibility,
|
||||
statusVisibility: status.visibility,
|
||||
statusContentType: data.content_type,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// =========
|
||||
// OPEN IN CHAT VIEW
|
||||
// =========
|
||||
name: 'chat_view',
|
||||
icon: 'comments',
|
||||
label: 'status.open_in_chat_view',
|
||||
if({ chatView }) {
|
||||
return !chatView
|
||||
},
|
||||
action({ router, status }) {
|
||||
router.push({ name: 'conversation2', params: { statusId: status.id } })
|
||||
},
|
||||
},
|
||||
{
|
||||
// =========
|
||||
// OPEN IN THREAD VIEW
|
||||
// =========
|
||||
name: 'thread_view',
|
||||
icon: 'list',
|
||||
label: 'status.open_in_thread_view',
|
||||
if({ chatView }) {
|
||||
return chatView
|
||||
},
|
||||
action({ router, status }) {
|
||||
router.push({ name: 'conversation', params: { id: status.id } })
|
||||
},
|
||||
},
|
||||
{
|
||||
// =========
|
||||
// DELETE
|
||||
|
|
|
|||
|
|
@ -15,7 +15,35 @@ import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
|
|||
library.add(faEllipsisH)
|
||||
|
||||
const StatusActionButtons = {
|
||||
props: ['status', 'replying'],
|
||||
props: {
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
replying: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fixedPinned: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pinned: {
|
||||
type: Set,
|
||||
},
|
||||
useDefaultButtons: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLabels: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inChatView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['toggleReplying', 'onSuccess', 'onError'],
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -36,14 +64,20 @@ const StatusActionButtons = {
|
|||
ConfirmModal: defineAsyncComponent(
|
||||
() => import('src/components/confirm_modal/confirm_modal.vue'),
|
||||
),
|
||||
|
||||
ActionButtonContainer,
|
||||
},
|
||||
computed: {
|
||||
...mapState(useSyncConfigStore, {
|
||||
pinnedItems: (store) =>
|
||||
userPinnedItems: (store) =>
|
||||
new Set(store.prefsStorage.collections.pinnedStatusActions),
|
||||
}),
|
||||
pinnedItems() {
|
||||
if (this.fixedPinned) {
|
||||
return this.pinned
|
||||
} else {
|
||||
return this.userPinnedItems
|
||||
}
|
||||
},
|
||||
buttons() {
|
||||
return BUTTONS.filter((x) => (x.if ? x.if(this.funcArg) : true))
|
||||
},
|
||||
|
|
@ -68,6 +102,7 @@ const StatusActionButtons = {
|
|||
router: this.$router,
|
||||
currentUser: this.currentUser,
|
||||
loggedIn: !!this.currentUser,
|
||||
chatView: !!this.inChatView,
|
||||
}
|
||||
},
|
||||
triggerAttrs() {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
grid-auto-flow: row dense;
|
||||
grid-auto-rows: 1fr;
|
||||
grid-gap: 0.5em 0.1em;
|
||||
margin-top: var(--status-margin);
|
||||
}
|
||||
|
||||
.pin-action-button {
|
||||
|
|
@ -17,6 +16,12 @@
|
|||
padding: 0.5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quick-action.popover-wrapper {
|
||||
button {
|
||||
padding: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
// popover
|
||||
.extra-action-buttons {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
:close="() => { /* no-op */ }"
|
||||
:do-action="doAction"
|
||||
@emoji-picker-shown="onEmojiPickerShown"
|
||||
:default-button-style="useDefaultButtons"
|
||||
:hide-label="hideLabels"
|
||||
/>
|
||||
<button
|
||||
v-if="showPin && currentUser"
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
<Popover
|
||||
trigger="click"
|
||||
:trigger-attrs="triggerAttrs"
|
||||
:normal-button="useDefaultButtons"
|
||||
class="quick-action"
|
||||
:tabindex="0"
|
||||
placement="bottom"
|
||||
|
|
@ -75,6 +78,8 @@
|
|||
:get-component="getComponent"
|
||||
:outer-close="close"
|
||||
:do-action="doAction"
|
||||
:default-button-style="useDefaultButtons"
|
||||
:hide-label="hideLabels"
|
||||
/>
|
||||
<button
|
||||
v-if="showPin && currentUser"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { mapState } from 'pinia'
|
||||
|
||||
import RichContent from 'src/components/rich_content/rich_content.jsx'
|
||||
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
|
|
@ -15,6 +17,9 @@ library.add(faFile, faMusic, faImage, faLink, faPollH)
|
|||
|
||||
const StatusBody = {
|
||||
name: 'StatusBody',
|
||||
components: {
|
||||
RichContent,
|
||||
},
|
||||
props: {
|
||||
status: {
|
||||
// Main thing
|
||||
|
|
@ -44,6 +49,12 @@ const StatusBody = {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
ignoreSubject: {
|
||||
// Pretend subject line doesn't exist. Useful for chat messages
|
||||
// to indicate what post reply belongs to
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -76,7 +87,7 @@ const StatusBody = {
|
|||
return this.status.summary.length > 240
|
||||
},
|
||||
hasSubject() {
|
||||
return !!this.status.summary
|
||||
return !!this.status.summary && !this.ignoreSubject
|
||||
},
|
||||
// When a status has a subject and is also tall, we should only have one show more/less
|
||||
// button. If the default is to collapse statuses with subjects, we just treat it like
|
||||
|
|
@ -140,7 +151,6 @@ const StatusBody = {
|
|||
},
|
||||
...mapState(useMergedConfigStore, ['mergedConfig']),
|
||||
},
|
||||
components: {},
|
||||
mounted() {
|
||||
this.status.attentions &&
|
||||
this.status.attentions.forEach((attn) => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
.text {
|
||||
&.-single-line {
|
||||
white-space: nowrap;
|
||||
white-space-collapse: collapse;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
height: 1.4em;
|
||||
|
|
@ -92,6 +92,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.-single-line {
|
||||
.summary-wrapper {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
.summary {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
& .tall-status-hider,
|
||||
& .tall-subject-hider,
|
||||
& .status-unhider,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div
|
||||
class="StatusBody"
|
||||
:class="{ '-compact': compact }"
|
||||
:class="{ '-compact': compact, '-single-line': singleLine }"
|
||||
>
|
||||
<div class="body">
|
||||
<div
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
<div
|
||||
class="text-wrapper"
|
||||
:class="{'-tall-status': hideTallStatus, '-hidden': shouldHide, '-expanded': showingMore}"
|
||||
:class="{'-tall-status': hideTallStatus, '-hidden': shouldHide, '-expanded': showingMore }"
|
||||
>
|
||||
<RichContent
|
||||
v-if="!(singleLine && hasSubject) && !shouldHide"
|
||||
|
|
|
|||
|
|
@ -491,8 +491,7 @@ export default {
|
|||
},
|
||||
mentionUser() {
|
||||
usePostStatusStore().openPostStatusModal({
|
||||
profileMention: true,
|
||||
repliedUser: this.user,
|
||||
profileMention: this.user,
|
||||
})
|
||||
},
|
||||
onAvatarClickHandler(e) {
|
||||
|
|
|
|||
|
|
@ -39,9 +39,7 @@
|
|||
}
|
||||
|
||||
.post-status-form {
|
||||
form {
|
||||
margin-top: 0;
|
||||
}
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.signed-in {
|
||||
|
|
|
|||
|
|
@ -306,6 +306,9 @@
|
|||
},
|
||||
"content_type_selection": "Post format",
|
||||
"content_warning": "Subject (optional)",
|
||||
"mentions_line": "Mentioned users",
|
||||
"enter_submits": "Enter key sends the post",
|
||||
"enter_newline": "Enter key adds a newline",
|
||||
"default": "Just landed in L.A.",
|
||||
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
|
||||
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
|
||||
|
|
@ -777,6 +780,7 @@
|
|||
"subject_line_email": "Like email: \"re: subject\"",
|
||||
"subject_line_mastodon": "Like mastodon: copy as is",
|
||||
"subject_line_noop": "Do not copy",
|
||||
"submit_on_enter_in_chats": "Send message on Enter in chats",
|
||||
"force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot",
|
||||
"conversation_display": "Conversation display style",
|
||||
"conversation_display_tree": "Tree-style",
|
||||
|
|
@ -1644,6 +1648,8 @@
|
|||
"delete_error": "Error deleting status: {0}",
|
||||
"edit": "Edit status",
|
||||
"edited_at": "(last edited {time})",
|
||||
"open_in_chat_view": "Open in chat view",
|
||||
"open_in_thread_view": "Open in thread view",
|
||||
"pin": "Pin on profile",
|
||||
"unpin": "Unpin from profile",
|
||||
"pinned": "Pinned",
|
||||
|
|
@ -1654,7 +1660,10 @@
|
|||
"delete_confirm_accept_button": "Delete",
|
||||
"delete_confirm_cancel_button": "Keep",
|
||||
"reply_to": "Reply to",
|
||||
"reply_to_selected": "Replying to selected message",
|
||||
"reply_to_last": "Replying to last message",
|
||||
"reply_to_with_icon": "{icon} {replyTo}",
|
||||
"broken_reply": "Message belongs to the thread but is not a reply",
|
||||
"reply_to_with_arg": "{replyToWithIcon} {user}",
|
||||
"mentions": "Mentions",
|
||||
"replies_list": "Replies:",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Socket } from 'phoenix'
|
|||
|
||||
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
|
||||
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||
import { useInterfaceStore } from 'src/stores/interface.js'
|
||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||
|
|
@ -174,7 +175,7 @@ const api = {
|
|||
) {
|
||||
dispatch('stopFetchingTimeline', { timeline: 'friends' })
|
||||
dispatch('stopFetchingNotifications')
|
||||
dispatch('stopFetchingChats')
|
||||
useChatsStore().stopFetchingChats()
|
||||
}
|
||||
commit('resetRetryMultiplier')
|
||||
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
import { find, omitBy, orderBy, sumBy } from 'lodash'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import chatService from '../services/chat_service/chat_service.js'
|
||||
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
|
||||
import {
|
||||
parseChat,
|
||||
parseChatMessage,
|
||||
} from '../services/entity_normalizer/entity_normalizer.service.js'
|
||||
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
|
||||
|
||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||
|
||||
import { chats, deleteChatMessage, readChat } from 'src/api/chats.js'
|
||||
|
||||
const emptyChatList = () => ({
|
||||
data: [],
|
||||
idStore: {},
|
||||
})
|
||||
|
||||
const defaultState = {
|
||||
chatList: emptyChatList(),
|
||||
chatListFetcher: null,
|
||||
openedChats: reactive({}),
|
||||
openedChatMessageServices: reactive({}),
|
||||
fetcher: undefined,
|
||||
currentChatId: null,
|
||||
lastReadMessageId: null,
|
||||
}
|
||||
|
||||
const getChatById = (state, id) => {
|
||||
return find(state.chatList.data, { id })
|
||||
}
|
||||
|
||||
const sortedChatList = (state) => {
|
||||
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
|
||||
}
|
||||
|
||||
const unreadChatCount = (state) => {
|
||||
return sumBy(state.chatList.data, 'unread')
|
||||
}
|
||||
|
||||
const chatsModule = {
|
||||
state: { ...defaultState },
|
||||
getters: {
|
||||
currentChat: (state) => state.openedChats[state.currentChatId],
|
||||
currentChatMessageService: (state) =>
|
||||
state.openedChatMessageServices[state.currentChatId],
|
||||
findOpenedChatByRecipientId: (state) => (recipientId) =>
|
||||
find(state.openedChats, (c) => c.account.id === recipientId),
|
||||
sortedChatList,
|
||||
unreadChatCount,
|
||||
},
|
||||
actions: {
|
||||
// Chat list
|
||||
startFetchingChats({ dispatch, commit }) {
|
||||
const fetcher = () => dispatch('fetchChats', { latest: true })
|
||||
commit('setChatListFetcher', {
|
||||
fetcher: () => promiseInterval(fetcher, 5000),
|
||||
})
|
||||
},
|
||||
stopFetchingChats({ commit }) {
|
||||
commit('setChatListFetcher', { fetcher: undefined })
|
||||
},
|
||||
fetchChats({ dispatch, rootState }) {
|
||||
return chats({
|
||||
credentials: useOAuthStore().token,
|
||||
}).then(({ chatList }) => {
|
||||
dispatch('addNewChats', { chats: chatList })
|
||||
return chats
|
||||
})
|
||||
},
|
||||
addNewChats(store, { chats }) {
|
||||
const { commit, dispatch, rootGetters } = store
|
||||
const newChatMessageSideEffects = (chat) => {
|
||||
maybeShowChatNotification(store, chat)
|
||||
}
|
||||
commit(
|
||||
'addNewUsers',
|
||||
chats.map((k) => k.account).filter((k) => k),
|
||||
)
|
||||
commit('addNewChats', {
|
||||
dispatch,
|
||||
chats,
|
||||
rootGetters,
|
||||
newChatMessageSideEffects,
|
||||
})
|
||||
},
|
||||
updateChat({ commit }, { chat }) {
|
||||
commit('updateChat', { chat })
|
||||
},
|
||||
|
||||
// Opened Chats
|
||||
startFetchingCurrentChat({ dispatch }, { fetcher }) {
|
||||
dispatch('setCurrentChatFetcher', { fetcher })
|
||||
},
|
||||
setCurrentChatFetcher({ commit }, { fetcher }) {
|
||||
commit('setCurrentChatFetcher', { fetcher })
|
||||
},
|
||||
addOpenedChat({ commit, dispatch }, { chat }) {
|
||||
commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
|
||||
dispatch('addNewUsers', [chat.account])
|
||||
},
|
||||
addChatMessages({ commit }, value) {
|
||||
commit('addChatMessages', { commit, ...value })
|
||||
},
|
||||
resetChatNewMessageCount({ commit }, value) {
|
||||
commit('resetChatNewMessageCount', value)
|
||||
},
|
||||
clearCurrentChat({ commit }) {
|
||||
commit('setCurrentChatId', { chatId: undefined })
|
||||
commit('setCurrentChatFetcher', { fetcher: undefined })
|
||||
},
|
||||
readChat({ rootState, commit, dispatch }, { id, lastReadId }) {
|
||||
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
|
||||
|
||||
dispatch('resetChatNewMessageCount')
|
||||
commit('readChat', { id, lastReadId })
|
||||
|
||||
if (isNewMessage) {
|
||||
readChat({
|
||||
id,
|
||||
lastReadId,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
}
|
||||
},
|
||||
deleteChatMessage({ rootState, commit }, value) {
|
||||
deleteChatMessage({
|
||||
...value,
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
commit('deleteChatMessage', { commit, ...value })
|
||||
},
|
||||
resetChats({ commit, dispatch }) {
|
||||
dispatch('clearCurrentChat')
|
||||
commit('resetChats', { commit })
|
||||
},
|
||||
clearOpenedChats({ commit }) {
|
||||
commit('clearOpenedChats', { commit })
|
||||
},
|
||||
handleMessageError({ commit }, value) {
|
||||
commit('handleMessageError', { commit, ...value })
|
||||
},
|
||||
cullOlderMessages({ commit }, chatId) {
|
||||
commit('cullOlderMessages', chatId)
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
setChatListFetcher(state, { fetcher }) {
|
||||
const prevFetcher = state.chatListFetcher
|
||||
if (prevFetcher) {
|
||||
prevFetcher.stop()
|
||||
}
|
||||
state.chatListFetcher = fetcher && fetcher()
|
||||
},
|
||||
setCurrentChatFetcher(state, { fetcher }) {
|
||||
const prevFetcher = state.fetcher
|
||||
if (prevFetcher) {
|
||||
prevFetcher.stop()
|
||||
}
|
||||
state.fetcher = fetcher && fetcher()
|
||||
},
|
||||
addOpenedChat(state, { chat }) {
|
||||
state.currentChatId = chat.id
|
||||
state.openedChats[chat.id] = chat
|
||||
|
||||
if (!state.openedChatMessageServices[chat.id]) {
|
||||
state.openedChatMessageServices[chat.id] = chatService.empty(chat.id)
|
||||
}
|
||||
},
|
||||
setCurrentChatId(state, { chatId }) {
|
||||
state.currentChatId = chatId
|
||||
},
|
||||
addNewChats(state, { chats, newChatMessageSideEffects }) {
|
||||
chats.forEach((updatedChat) => {
|
||||
const chat = getChatById(state, updatedChat.id)
|
||||
|
||||
if (chat) {
|
||||
const isNewMessage =
|
||||
(chat.lastMessage && chat.lastMessage.id) !==
|
||||
(updatedChat.lastMessage && updatedChat.lastMessage.id)
|
||||
chat.lastMessage = updatedChat.lastMessage
|
||||
chat.unread = updatedChat.unread
|
||||
chat.updated_at = updatedChat.updated_at
|
||||
if (isNewMessage && chat.unread) {
|
||||
newChatMessageSideEffects(updatedChat)
|
||||
}
|
||||
} else {
|
||||
state.chatList.data.push(updatedChat)
|
||||
state.chatList.idStore[updatedChat.id] = updatedChat
|
||||
}
|
||||
})
|
||||
},
|
||||
updateChat(state, { chat: updatedChat }) {
|
||||
const chat = getChatById(state, updatedChat.id)
|
||||
if (chat) {
|
||||
chat.lastMessage = updatedChat.lastMessage
|
||||
chat.unread = updatedChat.unread
|
||||
chat.updated_at = updatedChat.updated_at
|
||||
}
|
||||
if (!chat) {
|
||||
state.chatList.data.unshift(updatedChat)
|
||||
}
|
||||
state.chatList.idStore[updatedChat.id] = updatedChat
|
||||
},
|
||||
deleteChat(state, { id }) {
|
||||
state.chats.data = state.chats.data.filter(
|
||||
(conversation) => conversation.last_status.id !== id,
|
||||
)
|
||||
state.chats.idStore = omitBy(
|
||||
state.chats.idStore,
|
||||
(conversation) => conversation.last_status.id === id,
|
||||
)
|
||||
},
|
||||
resetChats(state, { commit }) {
|
||||
state.chatList = emptyChatList()
|
||||
state.currentChatId = null
|
||||
commit('setChatListFetcher', { fetcher: undefined })
|
||||
for (const chatId in state.openedChats) {
|
||||
chatService.clear(state.openedChatMessageServices[chatId])
|
||||
delete state.openedChats[chatId]
|
||||
delete state.openedChatMessageServices[chatId]
|
||||
}
|
||||
},
|
||||
setChatsLoading(state, { value }) {
|
||||
state.chats.loading = value
|
||||
},
|
||||
addChatMessages(state, { chatId, messages, updateMaxId }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
if (chatMessageService) {
|
||||
chatService.add(chatMessageService, {
|
||||
messages: messages.map(parseChatMessage),
|
||||
updateMaxId,
|
||||
})
|
||||
}
|
||||
},
|
||||
deleteChatMessage(state, { chatId, messageId }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
if (chatMessageService) {
|
||||
chatService.deleteMessage(chatMessageService, messageId)
|
||||
}
|
||||
},
|
||||
resetChatNewMessageCount(state) {
|
||||
const chatMessageService =
|
||||
state.openedChatMessageServices[state.currentChatId]
|
||||
chatService.resetNewMessageCount(chatMessageService)
|
||||
},
|
||||
// Used when a connection loss occurs
|
||||
clearOpenedChats(state) {
|
||||
const currentChatId = state.currentChatId
|
||||
for (const chatId in state.openedChats) {
|
||||
if (currentChatId !== chatId) {
|
||||
chatService.clear(state.openedChatMessageServices[chatId])
|
||||
delete state.openedChats[chatId]
|
||||
delete state.openedChatMessageServices[chatId]
|
||||
}
|
||||
}
|
||||
},
|
||||
readChat(state, { id, lastReadId }) {
|
||||
state.lastReadMessageId = lastReadId
|
||||
const chat = getChatById(state, id)
|
||||
if (chat) {
|
||||
chat.unread = 0
|
||||
}
|
||||
},
|
||||
handleMessageError(state, { chatId, fakeId, isRetry }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
chatService.handleMessageError(chatMessageService, fakeId, isRetry)
|
||||
},
|
||||
cullOlderMessages(state, chatId) {
|
||||
chatService.cullOlderMessages(state.openedChatMessageServices[chatId])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default chatsModule
|
||||
|
|
@ -673,6 +673,11 @@ export const LOCAL_DEFAULT_CONFIG_DEFINITIONS = {
|
|||
type: 'string',
|
||||
default: null,
|
||||
},
|
||||
chatSubmitOnEnter: {
|
||||
description: 'Post status on enter key in chats and chat view',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
themeDebug: {
|
||||
description:
|
||||
'Debug mode that uses computed backgrounds instead of real ones to debug contrast functions',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import api from './api.js'
|
||||
import chats from './chats.js'
|
||||
import drafts from './drafts.js'
|
||||
import notifications from './notifications.js'
|
||||
import profileConfig from './profileConfig.js'
|
||||
|
|
@ -13,5 +12,4 @@ export default {
|
|||
api,
|
||||
profileConfig,
|
||||
drafts,
|
||||
chats,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
|
||||
import { useAnnouncementsStore } from 'src/stores/announcements.js'
|
||||
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
|
||||
import { useChatsStore } from 'src/stores/chats.js'
|
||||
import { useEmojiStore } from 'src/stores/emoji.js'
|
||||
import { useInstanceStore } from 'src/stores/instance.js'
|
||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||
|
|
@ -726,7 +727,7 @@ const users = {
|
|||
store.dispatch('stopFetchingFollowRequests')
|
||||
store.commit('clearNotifications')
|
||||
store.commit('resetStatuses')
|
||||
store.dispatch('resetChats')
|
||||
useChatsStore().resetChats()
|
||||
oauth.clearToken()
|
||||
Cookies.remove('__Host-pleroma_key', { path: '/' })
|
||||
useInterfaceStore().setLastTimeline('public-timeline')
|
||||
|
|
|
|||
|
|
@ -1,251 +0,0 @@
|
|||
import { maxBy, minBy, orderBy, sortBy, uniqueId } from 'lodash'
|
||||
|
||||
const empty = (chatId) => {
|
||||
return {
|
||||
idIndex: {},
|
||||
idempotencyKeyIndex: {},
|
||||
messages: [],
|
||||
newMessageCount: 0,
|
||||
lastSeenMessageId: '0',
|
||||
chatId,
|
||||
minId: undefined,
|
||||
maxId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const clear = (storage) => {
|
||||
const failedMessageIds = []
|
||||
|
||||
for (const message of storage.messages) {
|
||||
if (message.error) {
|
||||
failedMessageIds.push(message.id)
|
||||
} else {
|
||||
delete storage.idIndex[message.id]
|
||||
delete storage.idempotencyKeyIndex[message.idempotency_key]
|
||||
}
|
||||
}
|
||||
|
||||
storage.messages = storage.messages.filter((m) =>
|
||||
failedMessageIds.includes(m.id),
|
||||
)
|
||||
storage.newMessageCount = 0
|
||||
storage.lastSeenMessageId = '0'
|
||||
storage.minId = undefined
|
||||
storage.maxId = undefined
|
||||
}
|
||||
|
||||
const deleteMessage = (storage, messageId) => {
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
storage.messages = storage.messages.filter((m) => m.id !== messageId)
|
||||
delete storage.idIndex[messageId]
|
||||
|
||||
if (storage.maxId === messageId) {
|
||||
const lastMessage = maxBy(storage.messages, 'id')
|
||||
storage.maxId = lastMessage.id
|
||||
}
|
||||
|
||||
if (storage.minId === messageId) {
|
||||
const firstMessage = minBy(storage.messages, 'id')
|
||||
storage.minId = firstMessage.id
|
||||
}
|
||||
}
|
||||
|
||||
const cullOlderMessages = (storage) => {
|
||||
const maxIndex = storage.messages.length
|
||||
const minIndex = maxIndex - 50
|
||||
if (maxIndex <= 50) return
|
||||
|
||||
storage.messages = sortBy(storage.messages, ['id'])
|
||||
storage.minId = storage.messages[minIndex].id
|
||||
for (const message of storage.messages) {
|
||||
if (message.id < storage.minId) {
|
||||
delete storage.idIndex[message.id]
|
||||
delete storage.idempotencyKeyIndex[message.idempotency_key]
|
||||
}
|
||||
}
|
||||
storage.messages = storage.messages.slice(minIndex, maxIndex)
|
||||
}
|
||||
|
||||
const handleMessageError = (storage, fakeId, isRetry) => {
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
const fakeMessage = storage.idIndex[fakeId]
|
||||
if (fakeMessage) {
|
||||
fakeMessage.error = true
|
||||
fakeMessage.pending = false
|
||||
if (!isRetry) {
|
||||
// Ensure the failed message doesn't stay at the bottom of the list.
|
||||
const lastPersistedMessage = orderBy(
|
||||
storage.messages,
|
||||
['pending', 'id'],
|
||||
['asc', 'desc'],
|
||||
)[0]
|
||||
if (lastPersistedMessage) {
|
||||
const oldId = fakeMessage.id
|
||||
fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
|
||||
storage.idIndex[fakeMessage.id] = fakeMessage
|
||||
delete storage.idIndex[oldId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const message = newMessages[i]
|
||||
|
||||
// sanity check
|
||||
if (message.chat_id !== storage.chatId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.fakeId) {
|
||||
const fakeMessage = storage.idIndex[message.fakeId]
|
||||
if (fakeMessage) {
|
||||
// In case the same id exists (chat update before POST response)
|
||||
// make sure to remove the older duplicate message.
|
||||
if (storage.idIndex[message.id]) {
|
||||
delete storage.idIndex[message.id]
|
||||
storage.messages = storage.messages.filter(
|
||||
(msg) => msg.id !== message.id,
|
||||
)
|
||||
}
|
||||
Object.assign(fakeMessage, message, { error: false })
|
||||
delete fakeMessage.fakeId
|
||||
storage.idIndex[fakeMessage.id] = fakeMessage
|
||||
delete storage.idIndex[message.fakeId]
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!storage.minId || (!message.pending && message.id < storage.minId)) {
|
||||
storage.minId = message.id
|
||||
}
|
||||
|
||||
if (!storage.maxId || message.id > storage.maxId) {
|
||||
if (updateMaxId) {
|
||||
storage.maxId = message.id
|
||||
}
|
||||
}
|
||||
|
||||
if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
|
||||
if (storage.lastSeenMessageId < message.id) {
|
||||
storage.newMessageCount++
|
||||
}
|
||||
storage.idIndex[message.id] = message
|
||||
storage.messages.push(storage.idIndex[message.id])
|
||||
storage.idempotencyKeyIndex[message.idempotency_key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isConfirmation = (storage, message) => {
|
||||
if (!message.idempotency_key) return
|
||||
return storage.idempotencyKeyIndex[message.idempotency_key]
|
||||
}
|
||||
|
||||
const resetNewMessageCount = (storage) => {
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
storage.newMessageCount = 0
|
||||
storage.lastSeenMessageId = storage.maxId
|
||||
}
|
||||
|
||||
// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
|
||||
const getView = (storage) => {
|
||||
if (!storage) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = []
|
||||
const messages = orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
|
||||
const firstMessage = messages[0]
|
||||
let previousMessage = messages[messages.length - 1]
|
||||
let currentMessageChainId
|
||||
|
||||
if (firstMessage) {
|
||||
const date = new Date(firstMessage.created_at)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
result.push({
|
||||
type: 'date',
|
||||
date,
|
||||
id: date.getTime().toString(),
|
||||
})
|
||||
}
|
||||
|
||||
let afterDate = false
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const nextMessage = messages[i + 1]
|
||||
|
||||
const date = new Date(message.created_at)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
// insert date separator and start a new message chain
|
||||
if (previousMessage && previousMessage.date < date) {
|
||||
result.push({
|
||||
type: 'date',
|
||||
date,
|
||||
id: date.getTime().toString(),
|
||||
})
|
||||
|
||||
previousMessage.isTail = true
|
||||
currentMessageChainId = undefined
|
||||
afterDate = true
|
||||
}
|
||||
|
||||
const object = {
|
||||
type: 'message',
|
||||
data: message,
|
||||
date,
|
||||
id: message.id,
|
||||
messageChainId: currentMessageChainId,
|
||||
}
|
||||
|
||||
// end a message chian
|
||||
if ((nextMessage && nextMessage.account_id) !== message.account_id) {
|
||||
object.isTail = true
|
||||
currentMessageChainId = undefined
|
||||
}
|
||||
|
||||
// start a new message chain
|
||||
if (
|
||||
(previousMessage &&
|
||||
previousMessage.data &&
|
||||
previousMessage.data.account_id) !== message.account_id ||
|
||||
afterDate
|
||||
) {
|
||||
currentMessageChainId = uniqueId()
|
||||
object.isHead = true
|
||||
object.messageChainId = currentMessageChainId
|
||||
}
|
||||
|
||||
result.push(object)
|
||||
previousMessage = object
|
||||
afterDate = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const ChatService = {
|
||||
add,
|
||||
empty,
|
||||
getView,
|
||||
deleteMessage,
|
||||
cullOlderMessages,
|
||||
resetNewMessageCount,
|
||||
clear,
|
||||
handleMessageError,
|
||||
}
|
||||
|
||||
export default ChatService
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
|
||||
|
||||
export const maybeShowChatNotification = (store, chat) => {
|
||||
export const maybeShowChatNotification = (chat) => {
|
||||
if (!chat.lastMessage) return
|
||||
if (store.rootState.chats.currentChatId === chat.id && !document.hidden)
|
||||
return
|
||||
if (store.rootState.users.currentUser.id === chat.lastMessage.account_id)
|
||||
if (window.vuex.state.users.currentUser.id === chat.lastMessage.account_id)
|
||||
return
|
||||
|
||||
const opts = {
|
||||
|
|
@ -21,7 +19,7 @@ export const maybeShowChatNotification = (store, chat) => {
|
|||
opts.image = chat.lastMessage.attachment.preview_url
|
||||
}
|
||||
|
||||
showDesktopNotification(store.rootState, opts)
|
||||
showDesktopNotification(window.vuex.state, opts)
|
||||
}
|
||||
|
||||
export const buildFakeMessage = ({
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export const prepareNotificationObject = (notification, i18n) => {
|
|||
export const countExtraNotifications = (
|
||||
store,
|
||||
mergedConfig,
|
||||
unreadChatsCount,
|
||||
unreadAnnouncementCount,
|
||||
) => {
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
|
|
@ -200,9 +201,7 @@ export const countExtraNotifications = (
|
|||
}
|
||||
|
||||
return [
|
||||
mergedConfig.showChatsInExtraNotifications
|
||||
? rootGetters.unreadChatCount
|
||||
: 0,
|
||||
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
|
||||
mergedConfig.showAnnouncementsInExtraNotifications
|
||||
? unreadAnnouncementCount
|
||||
: 0,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ const pollFallbackValues = {
|
|||
expiryUnit: 'minutes',
|
||||
}
|
||||
|
||||
const pollFallback = (object, attr) => {
|
||||
export const pollFallback = (object, attr) => {
|
||||
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
|
||||
}
|
||||
|
||||
const pollFormToMasto = (poll) => {
|
||||
export const pollFormToMasto = (poll) => {
|
||||
const expiresIn = DateUtils.unitToSeconds(
|
||||
pollFallback(poll, 'expiryUnit'),
|
||||
pollFallback(poll, 'expiryAmount'),
|
||||
|
|
@ -32,5 +32,3 @@ const pollFormToMasto = (poll) => {
|
|||
expiresIn,
|
||||
}
|
||||
}
|
||||
|
||||
export { pollFallback, pollFormToMasto }
|
||||
|
|
|
|||
114
src/stores/chats.js
Normal file
114
src/stores/chats.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { find, omitBy, orderBy, sumBy } from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
|
||||
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
|
||||
|
||||
import { useOAuthStore } from 'src/stores/oauth.js'
|
||||
|
||||
import { chats } from 'src/api/chats.js'
|
||||
|
||||
const emptyChatList = () => ({
|
||||
data: [],
|
||||
idStore: {},
|
||||
})
|
||||
|
||||
const defaultState = {
|
||||
chatList: emptyChatList(),
|
||||
chatListFetcher: null,
|
||||
}
|
||||
|
||||
const getChatById = (state, id) => {
|
||||
return find(state.chatList.data, { id })
|
||||
}
|
||||
|
||||
export const useChatsStore = defineStore('chats', {
|
||||
state: () => ({ ...defaultState }),
|
||||
getters: {
|
||||
sortedChatList(state) {
|
||||
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
|
||||
},
|
||||
unreadChatsCount(state) {
|
||||
return sumBy(state.chatList.data, 'unread')
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
startFetchingChats() {
|
||||
const fetcher = () => this.fetchChats()
|
||||
this.setChatListFetcher(() => promiseInterval(fetcher, 5000))
|
||||
},
|
||||
stopFetchingChats() {
|
||||
this.setChatListFetcher(null)
|
||||
},
|
||||
async fetchChats() {
|
||||
const { data } = await chats({
|
||||
credentials: useOAuthStore().token,
|
||||
})
|
||||
|
||||
this.addNewChats(data)
|
||||
},
|
||||
setChatListFetcher(fetcher) {
|
||||
const prevFetcher = this.chatListFetcher
|
||||
if (prevFetcher) {
|
||||
prevFetcher.stop()
|
||||
}
|
||||
this.chatListFetcher = fetcher?.()
|
||||
},
|
||||
resetChats() {
|
||||
this.chatList = emptyChatList()
|
||||
this.setChatListFetcher(null)
|
||||
},
|
||||
addNewChats(chats) {
|
||||
window.vuex.commit(
|
||||
'addNewUsers',
|
||||
chats.map((k) => k.account).filter((k) => k),
|
||||
)
|
||||
|
||||
chats.forEach((updatedChat) => {
|
||||
const chat = getChatById(this, updatedChat.id)
|
||||
|
||||
if (chat) {
|
||||
const isNewMessage =
|
||||
(chat.lastMessage && chat.lastMessage.id) !==
|
||||
(updatedChat.lastMessage && updatedChat.lastMessage.id)
|
||||
chat.lastMessage = updatedChat.lastMessage
|
||||
chat.unread = updatedChat.unread
|
||||
chat.updated_at = updatedChat.updated_at
|
||||
if (isNewMessage && chat.unread) {
|
||||
maybeShowChatNotification(chat)
|
||||
}
|
||||
} else {
|
||||
this.chatList.data.push(updatedChat)
|
||||
this.chatList.idStore[updatedChat.id] = updatedChat
|
||||
}
|
||||
})
|
||||
},
|
||||
readChat(id) {
|
||||
const chat = getChatById(this, id)
|
||||
if (chat) {
|
||||
chat.unread = 0
|
||||
}
|
||||
},
|
||||
updateChat({ chat: updatedChat }) {
|
||||
const chat = getChatById(this, updatedChat.id)
|
||||
if (chat) {
|
||||
chat.lastMessage = updatedChat.lastMessage
|
||||
chat.unread = updatedChat.unread
|
||||
chat.updated_at = updatedChat.updated_at
|
||||
}
|
||||
if (!chat) {
|
||||
this.chatList.data.unshift(updatedChat)
|
||||
}
|
||||
this.chatList.idStore[updatedChat.id] = updatedChat
|
||||
},
|
||||
deleteChat(id) {
|
||||
this.chats.data = this.chats.data.filter(
|
||||
(conversation) => conversation.last_status.id !== id,
|
||||
)
|
||||
this.chats.idStore = omitBy(
|
||||
this.chats.idStore,
|
||||
(conversation) => conversation.last_status.id === id,
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -188,7 +188,6 @@ export const useInterfaceStore = defineStore('interface', {
|
|||
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
|
||||
},
|
||||
setGlobalError({ error, instance, info }) {
|
||||
console.log(info)
|
||||
switch (info) {
|
||||
case 'https://vuejs.org/error-reference/#runtime-13': {
|
||||
this.globalError = {
|
||||
|
|
@ -206,7 +205,6 @@ export const useInterfaceStore = defineStore('interface', {
|
|||
break
|
||||
}
|
||||
}
|
||||
console.log(this.globalError)
|
||||
},
|
||||
clearGlobalError() {
|
||||
this.globalError = null
|
||||
|
|
|
|||
2
test/fixtures/setup_test.js
vendored
2
test/fixtures/setup_test.js
vendored
|
|
@ -26,6 +26,7 @@ const getDefaultOpts = ({
|
|||
global: {
|
||||
plugins: [
|
||||
applyAfterStore(makeMockStore(), afterStore),
|
||||
createTestingPinia(),
|
||||
VueVirtualScroller,
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
|
|
@ -41,7 +42,6 @@ const getDefaultOpts = ({
|
|||
(Vue) => {
|
||||
Vue.directive('body-scroll-lock', {})
|
||||
},
|
||||
createTestingPinia(),
|
||||
],
|
||||
components: {
|
||||
RichContent,
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ describe('routes', () => {
|
|||
|
||||
const matchedComponents = router.currentRoute.value.matched
|
||||
|
||||
expect(matchedComponents[0].components.default.name).to.eql(
|
||||
'AsyncComponentWrapper',
|
||||
expect(matchedComponents[0].components.default.__file).to.contain(
|
||||
'user_profile.vue',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -47,8 +47,8 @@ describe('routes', () => {
|
|||
|
||||
const matchedComponents = router.currentRoute.value.matched
|
||||
|
||||
expect(matchedComponents[0].components.default.name).to.eql(
|
||||
'AsyncComponentWrapper',
|
||||
expect(matchedComponents[0].components.default.__file).to.contain(
|
||||
'user_profile.vue',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -56,9 +56,8 @@ describe('routes', () => {
|
|||
await router.push('/lists')
|
||||
|
||||
const matchedComponents = router.currentRoute.value.matched
|
||||
|
||||
expect(matchedComponents[0].components.default.name).to.eql(
|
||||
'AsyncComponentWrapper',
|
||||
expect(matchedComponents[0].components.default.__file).to.contain(
|
||||
'lists.vue',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -67,8 +66,8 @@ describe('routes', () => {
|
|||
|
||||
const matchedComponents = router.currentRoute.value.matched
|
||||
|
||||
expect(matchedComponents[0].components.default.name).to.eql(
|
||||
'AsyncComponentWrapper',
|
||||
expect(matchedComponents[0].components.default.__file).to.contain(
|
||||
'lists_timeline.vue',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -77,8 +76,8 @@ describe('routes', () => {
|
|||
|
||||
const matchedComponents = router.currentRoute.value.matched
|
||||
|
||||
expect(matchedComponents[0].components.default.name).to.eql(
|
||||
'AsyncComponentWrapper',
|
||||
expect(matchedComponents[0].components.default.__file).to.contain(
|
||||
'lists_edit.vue',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
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 'src/components/chat_message_list/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
137
test/unit/specs/components/chat_view.spec.js
Normal file
137
test/unit/specs/components/chat_view.spec.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import ChatView from 'src/components/chat_view/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: {},
|
||||
statuses: {
|
||||
allStatusesObject: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
$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', async () => {
|
||||
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)
|
||||
|
||||
await 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,11 +1,11 @@
|
|||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
||||
import { mountOpts } from '../../../fixtures/setup_test'
|
||||
|
||||
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
|
||||
import { useMergedConfigStore } from 'src/stores/merged_config.js'
|
||||
|
||||
const currentUser = {
|
||||
id: 'current-user',
|
||||
|
|
@ -24,15 +24,16 @@ const repliedStatus = {
|
|||
user: repliedUser,
|
||||
}
|
||||
|
||||
const replyMountOpts = () =>
|
||||
const repliedStatus2 = {
|
||||
id: 'status-2',
|
||||
visibility: 'private',
|
||||
summary: 'subject',
|
||||
user: repliedUser,
|
||||
}
|
||||
|
||||
const replyMountOpts = (props) =>
|
||||
mountOpts({
|
||||
props: {
|
||||
replyTo: repliedStatus.id,
|
||||
repliedUser,
|
||||
attentions: [],
|
||||
copyMessageScope: repliedStatus.visibility,
|
||||
disableDraft: true,
|
||||
},
|
||||
props,
|
||||
afterStore(store) {
|
||||
store.state.users.currentUser = currentUser
|
||||
store.state.statuses.allStatusesObject = {
|
||||
|
|
@ -43,19 +44,279 @@ const replyMountOpts = () =>
|
|||
|
||||
describe('PostStatusForm', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('initializes a reply form when quoteReply is unset', () => {
|
||||
useInstanceCapabilitiesStore().quotingAvailable = true
|
||||
|
||||
it('Clean empty initial state', () => {
|
||||
const wrapper = mount(PostStatusForm, replyMountOpts())
|
||||
|
||||
expect(wrapper.vm.newStatus.type).to.equal('reply')
|
||||
expect(wrapper.vm.newStatus.quote).to.eql({
|
||||
id: '',
|
||||
url: '',
|
||||
thread: false,
|
||||
})
|
||||
expect(wrapper.vm.statusType).to.equal('new')
|
||||
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('')
|
||||
})
|
||||
|
||||
it('Reset cleans form to pristine state equal to state form was when created', () => {
|
||||
const wrapper = mount(PostStatusForm, replyMountOpts())
|
||||
|
||||
const initial = { ...wrapper.vm.newStatus }
|
||||
wrapper.vm.clearStatus()
|
||||
|
||||
expect(wrapper.vm.newStatus).to.eql(initial)
|
||||
})
|
||||
|
||||
it('Initializes a reply form', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
repliedStatus: repliedStatus,
|
||||
}),
|
||||
)
|
||||
|
||||
useInstanceCapabilitiesStore().quotingAvailable = true
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
expect(wrapper.vm.refId).to.equal('status-1')
|
||||
expect(wrapper.vm.quotable).to.equal(true)
|
||||
expect(wrapper.vm.inReplyToStatusId).to.equal('status-1')
|
||||
expect(wrapper.vm.newStatus.quote).to.eql(null)
|
||||
expect(wrapper.vm.newStatus.poll).to.eql(null)
|
||||
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
|
||||
expect(wrapper.vm.newStatus.visibility).to.eql('public')
|
||||
})
|
||||
|
||||
it('Copies scope and subject line, disables quoting for locked posts', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
repliedStatus: repliedStatus2,
|
||||
}),
|
||||
)
|
||||
|
||||
useInstanceCapabilitiesStore().quotingAvailable = true
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
expect(wrapper.vm.quotable).to.equal(false)
|
||||
expect(wrapper.vm.newStatus.quote).to.eql(null)
|
||||
expect(wrapper.vm.newStatus.poll).to.eql(null)
|
||||
expect(wrapper.vm.newStatus.spoilerText).to.eql('re: subject')
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
|
||||
expect(wrapper.vm.newStatus.visibility).to.eql('private')
|
||||
|
||||
expect(wrapper.vm.postingOptions.status).to.eql('@replied ')
|
||||
expect(wrapper.vm.postingOptions.spoilerText).to.eql('re: subject')
|
||||
expect(wrapper.vm.postingOptions.visibility).to.eql('private')
|
||||
expect(wrapper.vm.postingOptions.sensitive).to.eql(false)
|
||||
expect(wrapper.vm.postingOptions.media).to.eql([])
|
||||
expect(wrapper.vm.postingOptions.inReplyToStatusId).to.eql('status-2')
|
||||
expect(wrapper.vm.postingOptions.quoteId).to.eql(null)
|
||||
expect(wrapper.vm.postingOptions.contentType).to.eql('text/plain')
|
||||
expect(wrapper.vm.postingOptions.poll).to.eql(null)
|
||||
})
|
||||
|
||||
it('Forces direct mode when replying to a DM, mastodon style subject handling', () => {
|
||||
// We need to initialize pinia first which is happening here...
|
||||
const options = replyMountOpts({
|
||||
repliedStatus: { ...repliedStatus2, visibility: 'direct' },
|
||||
})
|
||||
|
||||
// ...set our settings...
|
||||
useMergedConfigStore().mergedConfig = {
|
||||
...useMergedConfigStore().mergedConfig,
|
||||
subjectLineBehavior: 'masto',
|
||||
}
|
||||
|
||||
// ...and only then mount our component
|
||||
const wrapper = mount(PostStatusForm, options)
|
||||
|
||||
// Otherwise we get multiple instances of pinia that don't talk to each other
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
expect(wrapper.vm.quotable).to.equal(false)
|
||||
expect(wrapper.vm.newStatus.quote).to.eql(null)
|
||||
expect(wrapper.vm.newStatus.poll).to.eql(null)
|
||||
expect(wrapper.vm.newStatus.spoilerText).to.eql('subject')
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
|
||||
expect(wrapper.vm.newStatus.visibility).to.eql('direct')
|
||||
})
|
||||
|
||||
it('Sets status to statusText without mentions if mentions line is enabled', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
repliedStatus: repliedStatus2,
|
||||
statusText: 'testing',
|
||||
mentionsLine: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('testing')
|
||||
})
|
||||
|
||||
it('Sets mention when asked for it', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
profileMention: repliedUser,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('mention')
|
||||
expect(wrapper.vm.isReply).to.equal(false)
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
|
||||
})
|
||||
|
||||
it('Initializes quote when reply/quote toggled to quote', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
repliedStatus: repliedStatus2,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
|
||||
wrapper.vm.quoteThreadToggled = true
|
||||
|
||||
expect(wrapper.vm.newStatus.quote).to.eql({ thread: true, id: 'status-2' })
|
||||
})
|
||||
|
||||
it('Resets quote when reply/quote toggled to reply', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
repliedStatus: repliedStatus2,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
|
||||
wrapper.vm.quoteThreadToggled = true
|
||||
wrapper.vm.quoteThreadToggled = false
|
||||
|
||||
expect(wrapper.vm.newStatus.quote).to.eql(null)
|
||||
})
|
||||
|
||||
it('Initializes and reset quote when toggling quote attachment', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
repliedStatus: repliedStatus2,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('reply')
|
||||
expect(wrapper.vm.isReply).to.equal(true)
|
||||
|
||||
wrapper.vm.toggleQuoteForm()
|
||||
expect(wrapper.vm.newStatus.quote).to.eql({
|
||||
thread: false,
|
||||
id: null,
|
||||
url: '',
|
||||
})
|
||||
wrapper.vm.toggleQuoteForm()
|
||||
expect(wrapper.vm.newStatus.quote).to.eql(null)
|
||||
})
|
||||
|
||||
it('Status editing', () => {
|
||||
const wrapper = mount(
|
||||
PostStatusForm,
|
||||
replyMountOpts({
|
||||
statusId: 'edited',
|
||||
statusText: 'text',
|
||||
statusSubject: 'heading',
|
||||
statusIsSensitive: true,
|
||||
statusPoll: {},
|
||||
statusQuote: {},
|
||||
statusFiles: [],
|
||||
statusMediaDescriptions: {},
|
||||
statusVisibility: 'unlisted',
|
||||
statusContentType: 'text/markdown',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wrapper.vm.statusType).to.equal('edit')
|
||||
expect(wrapper.vm.isReply).to.equal(false) // edits don't support changing reply-to so it's pretty much ignored
|
||||
expect(wrapper.vm.isEdit).to.equal(true)
|
||||
expect(wrapper.vm.newStatus.quote).to.eql({})
|
||||
expect(wrapper.vm.newStatus.poll).to.eql({})
|
||||
expect(wrapper.vm.newStatus.spoilerText).to.eql('heading')
|
||||
expect(wrapper.vm.newStatus.mentions).to.eql('')
|
||||
expect(wrapper.vm.newStatus.status).to.eql('text')
|
||||
expect(wrapper.vm.newStatus.visibility).to.eql('unlisted')
|
||||
expect(wrapper.vm.newStatus.contentType).to.eql('text/markdown')
|
||||
expect(wrapper.vm.newStatus.nsfw).to.equal(true)
|
||||
expect(wrapper.vm.newStatus.files).to.eql([])
|
||||
})
|
||||
|
||||
it('Posting should reset idempotency key', async () => {
|
||||
vi.setSystemTime(new Date(2027, 1, 1, 13))
|
||||
const wrapper = mount(PostStatusForm, replyMountOpts())
|
||||
const oldIdempotency = wrapper.vm.idempotencyKey
|
||||
|
||||
vi.setSystemTime(new Date(2028, 1, 1, 13))
|
||||
|
||||
wrapper.vm.newStatus.status = 'Testing'
|
||||
await wrapper.vm.postStatus()
|
||||
|
||||
expect(wrapper.vm.idempotencyKey).to.not.eql(oldIdempotency)
|
||||
})
|
||||
|
||||
// TODO Probably better to separate attachment upload/manipulation into its own component?
|
||||
// we need to upload-on-submit for compression setting anyway
|
||||
it('Attachments manipulations (moving, adding, removing)', () => {
|
||||
const wrapper = mount(PostStatusForm, replyMountOpts())
|
||||
|
||||
const i1 = { id: '1', url: 'a' }
|
||||
const i2 = { id: '2', url: 'b' }
|
||||
const i3 = { id: '3', url: 'c' }
|
||||
const i4 = { id: '4', url: 'd' }
|
||||
const iX = { id: 'x', url: 'x' }
|
||||
|
||||
wrapper.vm.newStatus.files = [i3, i1, iX, i2]
|
||||
|
||||
wrapper.vm.removeMediaFile(iX)
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i3, i1, i2])
|
||||
|
||||
wrapper.vm.shiftUpMediaFile(i1)
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
|
||||
|
||||
wrapper.vm.shiftUpMediaFile(i1) // should ignore
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
|
||||
|
||||
wrapper.vm.shiftDnMediaFile(i3)
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
|
||||
|
||||
wrapper.vm.shiftDnMediaFile(i3) // should ignore
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
|
||||
|
||||
wrapper.vm.addMediaFile(i4)
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3, i4])
|
||||
})
|
||||
|
||||
it('Attachment descriptions', () => {
|
||||
const wrapper = mount(PostStatusForm, replyMountOpts())
|
||||
|
||||
const i1 = { id: '1', url: 'a' }
|
||||
|
||||
wrapper.vm.addMediaFile(i1)
|
||||
expect(wrapper.vm.newStatus.files).to.eql([i1])
|
||||
|
||||
wrapper.vm.editAttachment(i1, 'description')
|
||||
expect(wrapper.vm.newStatus.mediaDescriptions['1']).to.eql('description')
|
||||
})
|
||||
// TODO: Drafts (needs vuex to pinia migration)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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