Merge branch 'chat-refactor' into sonarqube-cleanup

This commit is contained in:
Henry Jameson 2026-08-04 15:02:03 +03:00
commit 56efd36759
79 changed files with 3228 additions and 2094 deletions

1
changelog.d/chat_vew.add Normal file
View file

@ -0,0 +1 @@
Chat view for threads

View file

@ -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 } =

View file

@ -1,6 +1,9 @@
import { paramsString, promisedRequest } from './helpers.js'
import { parseChat } from 'src/services/entity_normalizer/entity_normalizer.service.js'
import {
parseChat,
parseChatMessage,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
@ -15,7 +18,7 @@ export const chats = ({ credentials }) =>
url: PLEROMA_CHATS_URL,
credentials,
}).then(({ data }) => ({
chatList: data.map(parseChat).filter((c) => c),
data: data.map(parseChat).filter((c) => c),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
@ -23,7 +26,7 @@ export const getOrCreateChat = ({ accountId, credentials }) =>
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
})
}).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
@ -36,7 +39,9 @@ export const chatMessages = ({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
})
}).then(({ data }) => ({
data: data.map(parseChatMessage).filter((c) => c),
}))
}
export const sendChatMessage = ({
@ -66,7 +71,9 @@ export const sendChatMessage = ({
payload,
credentials,
headers,
})
}).then(({ data }) => ({
data: parseChatMessage(data),
}))
}
export const readChat = ({ id, lastReadId, credentials }) =>

View file

@ -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)

View file

@ -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,
},

View file

@ -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

View file

@ -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" />

View file

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

View file

@ -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,
},
})
}

View file

@ -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

View file

@ -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);
}

View file

@ -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',
},
},
],
}

View file

@ -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>

View file

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

View file

@ -0,0 +1,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

View file

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

View file

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

View file

@ -0,0 +1,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" />

View file

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

View file

@ -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

View file

@ -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;
}
}

View 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" />

View file

@ -2,8 +2,11 @@ import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import 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 } })
}
},
},
}

View 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);
}
}
}

View file

@ -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" />

View file

@ -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 {}
}

View file

@ -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')"
>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,42 +16,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)

View file

@ -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

View file

@ -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;

View file

@ -2,6 +2,7 @@
<div
ref="form"
class="post-status-form"
v-if="initialized"
>
<form
autocomplete="off"
@ -10,7 +11,7 @@
>
<div class="form-group">
<div
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
v-if="!currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
class="visibility-notice notice-dismissible"
>
<i18n-t
@ -58,7 +59,7 @@
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && $store.state.users.currentUser.locked"
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
@ -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' }"

View file

@ -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}

View file

@ -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
},
},
}

View file

@ -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') }}

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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 [

View file

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

View file

@ -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

View file

@ -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() {

View file

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

View file

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

View file

@ -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) => {

View file

@ -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,

View file

@ -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"

View file

@ -491,8 +491,7 @@ export default {
},
mentionUser() {
usePostStatusStore().openPostStatusModal({
profileMention: true,
repliedUser: this.user,
profileMention: this.user,
})
},
onAvatarClickHandler(e) {

View file

@ -39,9 +39,7 @@
}
.post-status-form {
form {
margin-top: 0;
}
margin: 0.5em;
}
.signed-in {

View file

@ -306,6 +306,9 @@
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"mentions_line": "Mentioned users",
"enter_submits": "Enter key sends the post",
"enter_newline": "Enter key adds a newline",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
@ -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:",

View file

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

View file

@ -1,277 +0,0 @@
import { find, omitBy, orderBy, sumBy } from 'lodash'
import { reactive } from 'vue'
import chatService from '../services/chat_service/chat_service.js'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import {
parseChat,
parseChatMessage,
} from '../services/entity_normalizer/entity_normalizer.service.js'
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { chats, deleteChatMessage, readChat } from 'src/api/chats.js'
const emptyChatList = () => ({
data: [],
idStore: {},
})
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
openedChats: reactive({}),
openedChatMessageServices: reactive({}),
fetcher: undefined,
currentChatId: null,
lastReadMessageId: null,
}
const getChatById = (state, id) => {
return find(state.chatList.data, { id })
}
const sortedChatList = (state) => {
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
}
const unreadChatCount = (state) => {
return sumBy(state.chatList.data, 'unread')
}
const chatsModule = {
state: { ...defaultState },
getters: {
currentChat: (state) => state.openedChats[state.currentChatId],
currentChatMessageService: (state) =>
state.openedChatMessageServices[state.currentChatId],
findOpenedChatByRecipientId: (state) => (recipientId) =>
find(state.openedChats, (c) => c.account.id === recipientId),
sortedChatList,
unreadChatCount,
},
actions: {
// Chat list
startFetchingChats({ dispatch, commit }) {
const fetcher = () => dispatch('fetchChats', { latest: true })
commit('setChatListFetcher', {
fetcher: () => promiseInterval(fetcher, 5000),
})
},
stopFetchingChats({ commit }) {
commit('setChatListFetcher', { fetcher: undefined })
},
fetchChats({ dispatch, rootState }) {
return chats({
credentials: useOAuthStore().token,
}).then(({ chatList }) => {
dispatch('addNewChats', { chats: chatList })
return chats
})
},
addNewChats(store, { chats }) {
const { commit, dispatch, rootGetters } = store
const newChatMessageSideEffects = (chat) => {
maybeShowChatNotification(store, chat)
}
commit(
'addNewUsers',
chats.map((k) => k.account).filter((k) => k),
)
commit('addNewChats', {
dispatch,
chats,
rootGetters,
newChatMessageSideEffects,
})
},
updateChat({ commit }, { chat }) {
commit('updateChat', { chat })
},
// Opened Chats
startFetchingCurrentChat({ dispatch }, { fetcher }) {
dispatch('setCurrentChatFetcher', { fetcher })
},
setCurrentChatFetcher({ commit }, { fetcher }) {
commit('setCurrentChatFetcher', { fetcher })
},
addOpenedChat({ commit, dispatch }, { chat }) {
commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
dispatch('addNewUsers', [chat.account])
},
addChatMessages({ commit }, value) {
commit('addChatMessages', { commit, ...value })
},
resetChatNewMessageCount({ commit }, value) {
commit('resetChatNewMessageCount', value)
},
clearCurrentChat({ commit }) {
commit('setCurrentChatId', { chatId: undefined })
commit('setCurrentChatFetcher', { fetcher: undefined })
},
readChat({ rootState, commit, dispatch }, { id, lastReadId }) {
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
dispatch('resetChatNewMessageCount')
commit('readChat', { id, lastReadId })
if (isNewMessage) {
readChat({
id,
lastReadId,
credentials: useOAuthStore().token,
})
}
},
deleteChatMessage({ rootState, commit }, value) {
deleteChatMessage({
...value,
credentials: useOAuthStore().token,
})
commit('deleteChatMessage', { commit, ...value })
},
resetChats({ commit, dispatch }) {
dispatch('clearCurrentChat')
commit('resetChats', { commit })
},
clearOpenedChats({ commit }) {
commit('clearOpenedChats', { commit })
},
handleMessageError({ commit }, value) {
commit('handleMessageError', { commit, ...value })
},
cullOlderMessages({ commit }, chatId) {
commit('cullOlderMessages', chatId)
},
},
mutations: {
setChatListFetcher(state, { fetcher }) {
const prevFetcher = state.chatListFetcher
if (prevFetcher) {
prevFetcher.stop()
}
state.chatListFetcher = fetcher && fetcher()
},
setCurrentChatFetcher(state, { fetcher }) {
const prevFetcher = state.fetcher
if (prevFetcher) {
prevFetcher.stop()
}
state.fetcher = fetcher && fetcher()
},
addOpenedChat(state, { chat }) {
state.currentChatId = chat.id
state.openedChats[chat.id] = chat
if (!state.openedChatMessageServices[chat.id]) {
state.openedChatMessageServices[chat.id] = chatService.empty(chat.id)
}
},
setCurrentChatId(state, { chatId }) {
state.currentChatId = chatId
},
addNewChats(state, { chats, newChatMessageSideEffects }) {
chats.forEach((updatedChat) => {
const chat = getChatById(state, updatedChat.id)
if (chat) {
const isNewMessage =
(chat.lastMessage && chat.lastMessage.id) !==
(updatedChat.lastMessage && updatedChat.lastMessage.id)
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
if (isNewMessage && chat.unread) {
newChatMessageSideEffects(updatedChat)
}
} else {
state.chatList.data.push(updatedChat)
state.chatList.idStore[updatedChat.id] = updatedChat
}
})
},
updateChat(state, { chat: updatedChat }) {
const chat = getChatById(state, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
}
if (!chat) {
state.chatList.data.unshift(updatedChat)
}
state.chatList.idStore[updatedChat.id] = updatedChat
},
deleteChat(state, { id }) {
state.chats.data = state.chats.data.filter(
(conversation) => conversation.last_status.id !== id,
)
state.chats.idStore = omitBy(
state.chats.idStore,
(conversation) => conversation.last_status.id === id,
)
},
resetChats(state, { commit }) {
state.chatList = emptyChatList()
state.currentChatId = null
commit('setChatListFetcher', { fetcher: undefined })
for (const chatId in state.openedChats) {
chatService.clear(state.openedChatMessageServices[chatId])
delete state.openedChats[chatId]
delete state.openedChatMessageServices[chatId]
}
},
setChatsLoading(state, { value }) {
state.chats.loading = value
},
addChatMessages(state, { chatId, messages, updateMaxId }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
chatService.add(chatMessageService, {
messages: messages.map(parseChatMessage),
updateMaxId,
})
}
},
deleteChatMessage(state, { chatId, messageId }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
chatService.deleteMessage(chatMessageService, messageId)
}
},
resetChatNewMessageCount(state) {
const chatMessageService =
state.openedChatMessageServices[state.currentChatId]
chatService.resetNewMessageCount(chatMessageService)
},
// Used when a connection loss occurs
clearOpenedChats(state) {
const currentChatId = state.currentChatId
for (const chatId in state.openedChats) {
if (currentChatId !== chatId) {
chatService.clear(state.openedChatMessageServices[chatId])
delete state.openedChats[chatId]
delete state.openedChatMessageServices[chatId]
}
}
},
readChat(state, { id, lastReadId }) {
state.lastReadMessageId = lastReadId
const chat = getChatById(state, id)
if (chat) {
chat.unread = 0
}
},
handleMessageError(state, { chatId, fakeId, isRetry }) {
const chatMessageService = state.openedChatMessageServices[chatId]
chatService.handleMessageError(chatMessageService, fakeId, isRetry)
},
cullOlderMessages(state, chatId) {
chatService.cullOlderMessages(state.openedChatMessageServices[chatId])
},
},
}
export default chatsModule

View file

@ -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',

View file

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

View file

@ -21,6 +21,7 @@ import {
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -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')

View file

@ -1,251 +0,0 @@
import { maxBy, minBy, orderBy, sortBy, uniqueId } from 'lodash'
const empty = (chatId) => {
return {
idIndex: {},
idempotencyKeyIndex: {},
messages: [],
newMessageCount: 0,
lastSeenMessageId: '0',
chatId,
minId: undefined,
maxId: undefined,
}
}
const clear = (storage) => {
const failedMessageIds = []
for (const message of storage.messages) {
if (message.error) {
failedMessageIds.push(message.id)
} else {
delete storage.idIndex[message.id]
delete storage.idempotencyKeyIndex[message.idempotency_key]
}
}
storage.messages = storage.messages.filter((m) =>
failedMessageIds.includes(m.id),
)
storage.newMessageCount = 0
storage.lastSeenMessageId = '0'
storage.minId = undefined
storage.maxId = undefined
}
const deleteMessage = (storage, messageId) => {
if (!storage) {
return
}
storage.messages = storage.messages.filter((m) => m.id !== messageId)
delete storage.idIndex[messageId]
if (storage.maxId === messageId) {
const lastMessage = maxBy(storage.messages, 'id')
storage.maxId = lastMessage.id
}
if (storage.minId === messageId) {
const firstMessage = minBy(storage.messages, 'id')
storage.minId = firstMessage.id
}
}
const cullOlderMessages = (storage) => {
const maxIndex = storage.messages.length
const minIndex = maxIndex - 50
if (maxIndex <= 50) return
storage.messages = sortBy(storage.messages, ['id'])
storage.minId = storage.messages[minIndex].id
for (const message of storage.messages) {
if (message.id < storage.minId) {
delete storage.idIndex[message.id]
delete storage.idempotencyKeyIndex[message.idempotency_key]
}
}
storage.messages = storage.messages.slice(minIndex, maxIndex)
}
const handleMessageError = (storage, fakeId, isRetry) => {
if (!storage) {
return
}
const fakeMessage = storage.idIndex[fakeId]
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
if (!isRetry) {
// Ensure the failed message doesn't stay at the bottom of the list.
const lastPersistedMessage = orderBy(
storage.messages,
['pending', 'id'],
['asc', 'desc'],
)[0]
if (lastPersistedMessage) {
const oldId = fakeMessage.id
fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
storage.idIndex[fakeMessage.id] = fakeMessage
delete storage.idIndex[oldId]
}
}
}
}
const add = (storage, { messages: newMessages, updateMaxId = true }) => {
if (!storage) {
return
}
for (let i = 0; i < newMessages.length; i++) {
const message = newMessages[i]
// sanity check
if (message.chat_id !== storage.chatId) {
return
}
if (message.fakeId) {
const fakeMessage = storage.idIndex[message.fakeId]
if (fakeMessage) {
// In case the same id exists (chat update before POST response)
// make sure to remove the older duplicate message.
if (storage.idIndex[message.id]) {
delete storage.idIndex[message.id]
storage.messages = storage.messages.filter(
(msg) => msg.id !== message.id,
)
}
Object.assign(fakeMessage, message, { error: false })
delete fakeMessage.fakeId
storage.idIndex[fakeMessage.id] = fakeMessage
delete storage.idIndex[message.fakeId]
return
}
}
if (!storage.minId || (!message.pending && message.id < storage.minId)) {
storage.minId = message.id
}
if (!storage.maxId || message.id > storage.maxId) {
if (updateMaxId) {
storage.maxId = message.id
}
}
if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
if (storage.lastSeenMessageId < message.id) {
storage.newMessageCount++
}
storage.idIndex[message.id] = message
storage.messages.push(storage.idIndex[message.id])
storage.idempotencyKeyIndex[message.idempotency_key] = true
}
}
}
const isConfirmation = (storage, message) => {
if (!message.idempotency_key) return
return storage.idempotencyKeyIndex[message.idempotency_key]
}
const resetNewMessageCount = (storage) => {
if (!storage) {
return
}
storage.newMessageCount = 0
storage.lastSeenMessageId = storage.maxId
}
// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
const getView = (storage) => {
if (!storage) {
return []
}
const result = []
const messages = orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
const firstMessage = messages[0]
let previousMessage = messages[messages.length - 1]
let currentMessageChainId
if (firstMessage) {
const date = new Date(firstMessage.created_at)
date.setHours(0, 0, 0, 0)
result.push({
type: 'date',
date,
id: date.getTime().toString(),
})
}
let afterDate = false
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const nextMessage = messages[i + 1]
const date = new Date(message.created_at)
date.setHours(0, 0, 0, 0)
// insert date separator and start a new message chain
if (previousMessage && previousMessage.date < date) {
result.push({
type: 'date',
date,
id: date.getTime().toString(),
})
previousMessage.isTail = true
currentMessageChainId = undefined
afterDate = true
}
const object = {
type: 'message',
data: message,
date,
id: message.id,
messageChainId: currentMessageChainId,
}
// end a message chian
if ((nextMessage && nextMessage.account_id) !== message.account_id) {
object.isTail = true
currentMessageChainId = undefined
}
// start a new message chain
if (
(previousMessage &&
previousMessage.data &&
previousMessage.data.account_id) !== message.account_id ||
afterDate
) {
currentMessageChainId = uniqueId()
object.isHead = true
object.messageChainId = currentMessageChainId
}
result.push(object)
previousMessage = object
afterDate = false
}
return result
}
const ChatService = {
add,
empty,
getView,
deleteMessage,
cullOlderMessages,
resetNewMessageCount,
clear,
handleMessageError,
}
export default ChatService

View file

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

View file

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

View file

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

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

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

View file

@ -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

View file

@ -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,

View file

@ -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',
)
})
})

View 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)
})
})
})
})

View 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)
})
})
})

View file

@ -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)
})

View file

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