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