Merge remote-tracking branch 'origin/develop' into shigusegubu
* origin/develop: Apply suggestion to src/components/chat_list/chat_list.vue Add the single-line prop to StatusContent and use it for chat list items Remove direct style manipulations in favor of classes Undo the promise rejection on the json parser error in promisedRequest Add the empty chat list placeholder. Disable status preview in the chat posting form Address feedback Add Chats
This commit is contained in:
commit
13bab19494
67 changed files with 2804 additions and 173 deletions
|
@ -14,7 +14,7 @@ import MobileNav from './components/mobile_nav/mobile_nav.vue'
|
|||
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
|
||||
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
|
||||
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
|
||||
import { windowWidth } from './services/window_utils/window_utils'
|
||||
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
|
@ -127,10 +127,12 @@ export default {
|
|||
},
|
||||
updateMobileState () {
|
||||
const mobileLayout = windowWidth() <= 800
|
||||
const layoutHeight = windowHeight()
|
||||
const changed = mobileLayout !== this.isMobileLayout
|
||||
if (changed) {
|
||||
this.$store.dispatch('setMobileLayout', mobileLayout)
|
||||
}
|
||||
this.$store.dispatch('setLayoutHeight', layoutHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
51
src/App.scss
51
src/App.scss
|
@ -47,6 +47,7 @@ html {
|
|||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
font-family: sans-serif;
|
||||
font-family: var(--interfaceFont, sans-serif);
|
||||
margin: 0;
|
||||
|
@ -319,7 +320,7 @@ option {
|
|||
|
||||
i[class*=icon-] {
|
||||
color: $fallback--icon;
|
||||
color: var(--icon, $fallback--icon)
|
||||
color: var(--icon, $fallback--icon);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
|
@ -928,3 +929,51 @@ nav {
|
|||
background-color: $fallback--fg;
|
||||
background-color: var(--panel, $fallback--fg);
|
||||
}
|
||||
|
||||
.unread-chat-count {
|
||||
font-size: 0.9em;
|
||||
font-weight: bolder;
|
||||
font-style: normal;
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
padding: 0 0.3em;
|
||||
min-width: 1.3rem;
|
||||
min-height: 1.3rem;
|
||||
max-height: 1.3rem;
|
||||
line-height: 1.3rem;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
// Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
// Ensures the fixed position of the mobile browser bars on scroll up / down events.
|
||||
// Prevents the mobile browser bars from overlapping or hiding the message posting form.
|
||||
@media all and (max-width: 800px) {
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
#app_bg_wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding-top: 0;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,6 +77,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="app-bg-wrapper app-container-wrapper" />
|
||||
<div
|
||||
id="content"
|
||||
class="container underlay"
|
||||
|
@ -112,9 +113,7 @@
|
|||
{{ $t("login.hint") }}
|
||||
</router-link>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<router-view />
|
||||
</transition>
|
||||
<router-view />
|
||||
</div>
|
||||
<media-modal />
|
||||
</div>
|
||||
|
|
|
@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px;
|
|||
$fallback--avatarRadius: 4px;
|
||||
$fallback--avatarAltRadius: 10px;
|
||||
$fallback--attachmentRadius: 10px;
|
||||
$fallback--chatMessageRadius: 10px;
|
||||
|
||||
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
|
||||
|
|
|
@ -238,6 +238,7 @@ const getNodeInfo = async ({ store }) => {
|
|||
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
|
||||
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
|
||||
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
|
||||
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
|
||||
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
|
||||
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
|
||||
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
|
||||
|
|
|
@ -6,6 +6,8 @@ import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue
|
|||
import ConversationPage from 'components/conversation-page/conversation-page.vue'
|
||||
import Interactions from 'components/interactions/interactions.vue'
|
||||
import DMs from 'components/dm_timeline/dm_timeline.vue'
|
||||
import ChatList from 'components/chat_list/chat_list.vue'
|
||||
import Chat from 'components/chat/chat.vue'
|
||||
import UserProfile from 'components/user_profile/user_profile.vue'
|
||||
import Search from 'components/search/search.vue'
|
||||
import Registration from 'components/registration/registration.vue'
|
||||
|
@ -28,7 +30,7 @@ export default (store) => {
|
|||
}
|
||||
}
|
||||
|
||||
return [
|
||||
let routes = [
|
||||
{ name: 'root',
|
||||
path: '/',
|
||||
redirect: _to => {
|
||||
|
@ -62,11 +64,20 @@ export default (store) => {
|
|||
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'login', path: '/login', component: AuthForm },
|
||||
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
|
||||
{ name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
|
||||
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
|
||||
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
|
||||
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'about', path: '/about', component: About },
|
||||
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
|
||||
]
|
||||
|
||||
if (store.state.instance.pleromaChatMessagesAvailable) {
|
||||
routes = routes.concat([
|
||||
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
|
||||
])
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { mapState } from 'vuex'
|
||||
import ProgressButton from '../progress_button/progress_button.vue'
|
||||
import Popover from '../popover/popover.vue'
|
||||
|
||||
|
@ -27,7 +28,18 @@ const AccountActions = {
|
|||
},
|
||||
reportUser () {
|
||||
this.$store.dispatch('openUserReportingModal', this.user.id)
|
||||
},
|
||||
openChat () {
|
||||
this.$router.push({
|
||||
name: 'chat',
|
||||
params: { recipient_id: this.user.id }
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,13 @@
|
|||
>
|
||||
{{ $t('user_card.report') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pleromaChatMessagesAvailable"
|
||||
class="btn btn-default btn-block dropdown-item"
|
||||
@click="openChat"
|
||||
>
|
||||
{{ $t('user_card.message') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
333
src/components/chat/chat.js
Normal file
333
src/components/chat/chat.js
Normal file
|
@ -0,0 +1,333 @@
|
|||
import _ from 'lodash'
|
||||
import { WSConnectionStatus } from '../../services/api/api.service.js'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import ChatMessage from '../chat_message/chat_message.vue'
|
||||
import PostStatusForm from '../post_status_form/post_status_form.vue'
|
||||
import ChatTitle from '../chat_title/chat_title.vue'
|
||||
import chatService from '../../services/chat_service/chat_service.js'
|
||||
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
|
||||
|
||||
const BOTTOMED_OUT_OFFSET = 10
|
||||
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
|
||||
const SAFE_RESIZE_TIME_OFFSET = 100
|
||||
|
||||
const Chat = {
|
||||
components: {
|
||||
ChatMessage,
|
||||
ChatTitle,
|
||||
PostStatusForm
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
jumpToBottomButtonVisible: false,
|
||||
hoveredMessageChainId: undefined,
|
||||
lastScrollPosition: {},
|
||||
scrollableContainerHeight: '100%',
|
||||
errorLoadingChat: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.startFetching()
|
||||
window.addEventListener('resize', this.handleLayoutChange)
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
if (typeof document.hidden !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
this.handleResize()
|
||||
})
|
||||
this.setChatLayout()
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleLayoutChange)
|
||||
this.unsetChatLayout()
|
||||
if (typeof 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 })
|
||||
} 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',
|
||||
'mergedConfig'
|
||||
]),
|
||||
...mapState({
|
||||
backendInteractor: state => state.api.backendInteractor,
|
||||
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
|
||||
mobileLayout: state => state.interface.mobileLayout,
|
||||
layoutHeight: state => state.interface.layoutHeight,
|
||||
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({ forceRead: !document.hidden })
|
||||
}
|
||||
})
|
||||
},
|
||||
'$route': function () {
|
||||
this.startFetching()
|
||||
},
|
||||
layoutHeight () {
|
||||
this.handleResize({ expand: true })
|
||||
},
|
||||
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()
|
||||
this.updateScrollableContainerHeight()
|
||||
})
|
||||
},
|
||||
handleVisibilityChange () {
|
||||
this.$nextTick(() => {
|
||||
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
|
||||
this.scrollDown({ forceRead: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
setChatLayout () {
|
||||
// This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
|
||||
// This layout prevents empty spaces from being visible at the bottom
|
||||
// of the chat on iOS Safari (`safe-area-inset`) when
|
||||
// - the on-screen keyboard appears and the user starts typing
|
||||
// - the user selects the text inside the input area
|
||||
// - the user selects and deletes the text that is multiple lines long
|
||||
// TODO: unify the chat layout with the global layout.
|
||||
let html = document.querySelector('html')
|
||||
if (html) {
|
||||
html.classList.add('chat-layout')
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
})
|
||||
},
|
||||
unsetChatLayout () {
|
||||
let html = document.querySelector('html')
|
||||
if (html) {
|
||||
html.classList.remove('chat-layout')
|
||||
}
|
||||
},
|
||||
handleLayoutChange () {
|
||||
this.$nextTick(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
this.scrollDown()
|
||||
})
|
||||
},
|
||||
// Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
|
||||
updateScrollableContainerHeight () {
|
||||
const header = this.$refs.header
|
||||
const footer = this.$refs.footer
|
||||
const inner = this.mobileLayout ? window.document.body : this.$refs.inner
|
||||
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
|
||||
},
|
||||
// Preserves the scroll position when OSK appears or the posting form changes its height.
|
||||
handleResize (opts = {}) {
|
||||
const { expand = false, delayed = false } = opts
|
||||
|
||||
if (delayed) {
|
||||
setTimeout(() => {
|
||||
this.handleResize({ ...opts, delayed: false })
|
||||
}, SAFE_RESIZE_TIME_OFFSET)
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
|
||||
const { offsetHeight = undefined } = this.lastScrollPosition
|
||||
this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
|
||||
|
||||
const diff = this.lastScrollPosition.offsetHeight - offsetHeight
|
||||
if (diff < 0 || (!this.bottomedOut() && expand)) {
|
||||
this.$nextTick(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
this.$refs.scrollable.scrollTo({
|
||||
top: this.$refs.scrollable.scrollTop - diff,
|
||||
left: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
scrollDown (options = {}) {
|
||||
const { behavior = 'auto', forceRead = false } = options
|
||||
const scrollable = this.$refs.scrollable
|
||||
if (!scrollable) { return }
|
||||
this.$nextTick(() => {
|
||||
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
|
||||
})
|
||||
if (forceRead || this.newMessageCount > 0) {
|
||||
this.readChat()
|
||||
}
|
||||
},
|
||||
readChat () {
|
||||
if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
|
||||
if (document.hidden) { return }
|
||||
const lastReadId = this.currentChatMessageService.lastMessage.id
|
||||
this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
|
||||
},
|
||||
bottomedOut (offset) {
|
||||
return isBottomedOut(this.$refs.scrollable, offset)
|
||||
},
|
||||
reachedTop () {
|
||||
const scrollable = this.$refs.scrollable
|
||||
return scrollable && scrollable.scrollTop <= 0
|
||||
},
|
||||
handleScroll: _.throttle(function () {
|
||||
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
|
||||
if (this.newMessageCount > 0) {
|
||||
this.readChat()
|
||||
}
|
||||
} else {
|
||||
this.jumpToBottomButtonVisible = true
|
||||
}
|
||||
}, 100),
|
||||
handleScrollUp (positionBeforeLoading) {
|
||||
const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
|
||||
this.$refs.scrollable.scrollTo({
|
||||
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
|
||||
left: 0
|
||||
})
|
||||
},
|
||||
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.lastMessage && chatMessageService.lastMessage.id
|
||||
|
||||
this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
|
||||
.then((messages) => {
|
||||
// Clear the current chat in case we're recovering from a ws connection loss.
|
||||
if (isFirstFetch) {
|
||||
chatService.clear(chatMessageService)
|
||||
}
|
||||
|
||||
const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
|
||||
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
|
||||
this.$nextTick(() => {
|
||||
if (fetchOlderMessages) {
|
||||
this.handleScrollUp(positionBeforeUpdate)
|
||||
}
|
||||
|
||||
if (isFirstFetch) {
|
||||
this.updateScrollableContainerHeight()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
async startFetching () {
|
||||
let chat = this.findOpenedChatByRecipientId(this.recipientId)
|
||||
if (!chat) {
|
||||
try {
|
||||
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
|
||||
} 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: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
|
||||
})
|
||||
this.fetchChat({ isFirstFetch: true })
|
||||
},
|
||||
sendMessage ({ status, media }) {
|
||||
const params = {
|
||||
id: this.currentChat.id,
|
||||
content: status
|
||||
}
|
||||
|
||||
if (media[0]) {
|
||||
params.mediaId = media[0].id
|
||||
}
|
||||
|
||||
return this.backendInteractor.sendChatMessage(params)
|
||||
.then(data => {
|
||||
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
|
||||
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.
|
||||
setTimeout(() => {
|
||||
this.updateScrollableContainerHeight()
|
||||
}, SAFE_RESIZE_TIME_OFFSET)
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sending message', error)
|
||||
return {
|
||||
error: this.$t('chats.error_sending_message')
|
||||
}
|
||||
})
|
||||
},
|
||||
goBack () {
|
||||
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Chat
|
162
src/components/chat/chat.scss
Normal file
162
src/components/chat/chat.scss
Normal file
|
@ -0,0 +1,162 @@
|
|||
.chat-view {
|
||||
display: flex;
|
||||
height: calc(100vh - 60px);
|
||||
width: 100%;
|
||||
|
||||
.chat-title {
|
||||
// prevents chat header jumping on when the user avatar loads
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.chat-view-inner {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
margin: 0.5em 0.5em 0 0.5em;
|
||||
}
|
||||
|
||||
.chat-view-body {
|
||||
background-color: var(--chatBg, $fallback--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
min-height: 100%;
|
||||
margin: 0 0 0 0;
|
||||
border-radius: 10px 10px 0 0;
|
||||
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
|
||||
|
||||
&::after {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable-message-list {
|
||||
padding: 0 0.8em;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.chat-view-heading {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
position: sticky;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.go-back-button {
|
||||
cursor: pointer;
|
||||
margin-right: 1.4em;
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.jump-to-bottom-button {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
border-radius: 100%;
|
||||
position: absolute;
|
||||
right: 1.3em;
|
||||
top: -3.2em;
|
||||
background-color: $fallback--fg;
|
||||
background-color: var(--btn, $fallback--fg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
transition: 0.35s all;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1em;
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
|
||||
.unread-message-count {
|
||||
font-size: 0.8em;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
border-radius: 100%;
|
||||
margin-top: -1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-loading-error {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 800px) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.chat-view-inner {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.chat-view-body {
|
||||
display: flex;
|
||||
min-height: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-view-heading {
|
||||
position: static;
|
||||
z-index: 9999;
|
||||
top: 0;
|
||||
margin-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.scrollable-message-list {
|
||||
display: unset;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
}
|
100
src/components/chat/chat.vue
Normal file
100
src/components/chat/chat.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="chat-view-inner">
|
||||
<div
|
||||
id="nav"
|
||||
ref="inner"
|
||||
class="panel-default panel chat-view-body"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading chat-view-heading mobile-hidden"
|
||||
>
|
||||
<a
|
||||
class="go-back-button"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="button-icon icon-left-open" />
|
||||
</a>
|
||||
<div class="title text-center">
|
||||
<ChatTitle
|
||||
:user="recipient"
|
||||
:with-avatar="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template>
|
||||
<div
|
||||
ref="scrollable"
|
||||
class="scrollable-message-list"
|
||||
:style="{ height: scrollableContainerHeight }"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<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' })"
|
||||
>
|
||||
<i class="icon-down-open">
|
||||
<div
|
||||
v-if="newMessageCount"
|
||||
class="badge badge-notification unread-chat-count unread-message-count"
|
||||
>
|
||||
{{ newMessageCount }}
|
||||
</div>
|
||||
</i>
|
||||
</div>
|
||||
<PostStatusForm
|
||||
:disable-subject="true"
|
||||
:disable-scope-selector="true"
|
||||
:disable-notice="true"
|
||||
:disable-lock-warning="true"
|
||||
:disable-polls="true"
|
||||
:disable-sensitivity-checkbox="true"
|
||||
:disable-submit="errorLoadingChat || !currentChat"
|
||||
:disable-preview="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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat.js"></script>
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import './chat.scss';
|
||||
</style>
|
26
src/components/chat/chat_layout_utils.js
Normal file
26
src/components/chat/chat_layout_utils.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
// Captures a scroll position
|
||||
export const getScrollPosition = (el) => {
|
||||
return {
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
offsetHeight: el.offsetHeight
|
||||
}
|
||||
}
|
||||
|
||||
// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
|
||||
// Takes two scroll positions, before and after the update.
|
||||
export const getNewTopPosition = (previousPosition, newPosition) => {
|
||||
return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
|
||||
}
|
||||
|
||||
export const isBottomedOut = (el, offset = 0) => {
|
||||
if (!el) { return }
|
||||
const scrollHeight = el.scrollTop + offset
|
||||
const totalHeight = el.scrollHeight - el.offsetHeight
|
||||
return totalHeight <= scrollHeight
|
||||
}
|
||||
|
||||
// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
|
||||
export const scrollableContainerHeight = (inner, header, footer) => {
|
||||
return inner.offsetHeight - header.clientHeight - footer.clientHeight
|
||||
}
|
37
src/components/chat_list/chat_list.js
Normal file
37
src/components/chat_list/chat_list.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
import ChatListItem from '../chat_list_item/chat_list_item.vue'
|
||||
import ChatNew from '../chat_new/chat_new.vue'
|
||||
import List from '../list/list.vue'
|
||||
|
||||
const ChatList = {
|
||||
components: {
|
||||
ChatListItem,
|
||||
List,
|
||||
ChatNew
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
}),
|
||||
...mapGetters(['sortedChatList'])
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isNew: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('fetchChats', { latest: true })
|
||||
},
|
||||
methods: {
|
||||
cancelNewChat () {
|
||||
this.isNew = false
|
||||
this.$store.dispatch('fetchChats', { latest: true })
|
||||
},
|
||||
newChat () {
|
||||
this.isNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatList
|
64
src/components/chat_list/chat_list.vue
Normal file
64
src/components/chat_list/chat_list.vue
Normal file
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div v-if="isNew">
|
||||
<ChatNew @cancel="cancelNewChat" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="chat-list panel panel-default"
|
||||
>
|
||||
<div class="panel-heading">
|
||||
<span class="title">
|
||||
{{ $t("chats.chats") }}
|
||||
</span>
|
||||
<button @click="newChat">
|
||||
{{ $t("chats.new") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
v-if="sortedChatList.length > 0"
|
||||
class="timeline"
|
||||
>
|
||||
<List :items="sortedChatList">
|
||||
<template
|
||||
slot="item"
|
||||
slot-scope="{item}"
|
||||
>
|
||||
<ChatListItem
|
||||
:key="item.id"
|
||||
:compact="false"
|
||||
:chat="item"
|
||||
/>
|
||||
</template>
|
||||
</List>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="emtpy-chat-list-alert"
|
||||
>
|
||||
<span>{{ $t('chats.empty_chat_list_placeholder') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_list.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.chat-list {
|
||||
min-height: 25em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.emtpy-chat-list-alert {
|
||||
padding: 3em;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: $fallback--text;
|
||||
color: var(--faint, $fallback--text);
|
||||
}
|
||||
|
||||
</style>
|
65
src/components/chat_list_item/chat_list_item.js
Normal file
65
src/components/chat_list_item/chat_list_item.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { mapState } from 'vuex'
|
||||
import StatusContent from '../status_content/status_content.vue'
|
||||
import fileType from 'src/services/file_type/file_type.service'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import AvatarList from '../avatar_list/avatar_list.vue'
|
||||
import Timeago from '../timeago/timeago.vue'
|
||||
import ChatTitle from '../chat_title/chat_title.vue'
|
||||
|
||||
const ChatListItem = {
|
||||
name: 'ChatListItem',
|
||||
props: [
|
||||
'chat'
|
||||
],
|
||||
components: {
|
||||
UserAvatar,
|
||||
AvatarList,
|
||||
Timeago,
|
||||
ChatTitle,
|
||||
StatusContent
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
}),
|
||||
attachmentInfo () {
|
||||
if (this.chat.lastMessage.attachments.length === 0) { return }
|
||||
|
||||
const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
|
||||
if (types.includes('video')) {
|
||||
return this.$t('file_type.video')
|
||||
} else if (types.includes('audio')) {
|
||||
return this.$t('file_type.audio')
|
||||
} else if (types.includes('image')) {
|
||||
return this.$t('file_type.image')
|
||||
} else {
|
||||
return this.$t('file_type.file')
|
||||
}
|
||||
},
|
||||
messageForStatusContent () {
|
||||
const content = this.chat.lastMessage ? (this.attachmentInfo || this.chat.lastMessage.content) : ''
|
||||
|
||||
return {
|
||||
summary: '',
|
||||
statusnet_html: content,
|
||||
text: content,
|
||||
attachments: []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openChat (_e) {
|
||||
if (this.chat.id) {
|
||||
this.$router.push({
|
||||
name: 'chat',
|
||||
params: {
|
||||
username: this.currentUser.screen_name,
|
||||
recipient_id: this.chat.account.id
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatListItem
|
94
src/components/chat_list_item/chat_list_item.scss
Normal file
94
src/components/chat_list_item/chat_list_item.scss
Normal file
|
@ -0,0 +1,94 @@
|
|||
.chat-list-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0.75em;
|
||||
height: 5em;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--selectedPost, $fallback--lightBg);
|
||||
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chat-list-item-left {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.chat-list-item-center {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.heading {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.heading-right {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-and-account-name {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.chat-preview {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0.35em 0;
|
||||
color: $fallback--text;
|
||||
color: var(--faint, $fallback--text);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--faintLink, $fallback--link);
|
||||
text-decoration: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover .animated.avatar {
|
||||
canvas {
|
||||
display: none;
|
||||
}
|
||||
img {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar.still-image {
|
||||
border-radius: $fallback--avatarAltRadius;
|
||||
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
|
||||
}
|
||||
|
||||
.status-body {
|
||||
img.emoji {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.time-wrapper {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.single-line {
|
||||
padding-right: 1em;
|
||||
}
|
||||
}
|
52
src/components/chat_list_item/chat_list_item.vue
Normal file
52
src/components/chat_list_item/chat_list_item.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div
|
||||
class="chat-list-item"
|
||||
@click.capture.prevent="openChat"
|
||||
>
|
||||
<div class="chat-list-item-left">
|
||||
<UserAvatar
|
||||
:user="chat.account"
|
||||
height="48px"
|
||||
width="48px"
|
||||
/>
|
||||
</div>
|
||||
<div class="chat-list-item-center">
|
||||
<div class="heading">
|
||||
<span
|
||||
v-if="chat.account"
|
||||
class="name-and-account-name"
|
||||
>
|
||||
<ChatTitle
|
||||
:user="chat.account"
|
||||
/>
|
||||
</span>
|
||||
<span class="heading-right" />
|
||||
</div>
|
||||
<div class="chat-preview">
|
||||
<StatusContent
|
||||
:status="messageForStatusContent"
|
||||
:single-line="true"
|
||||
/>
|
||||
<div
|
||||
v-if="chat.unread > 0"
|
||||
class="badge badge-notification unread-chat-count"
|
||||
>
|
||||
{{ chat.unread }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time-wrapper">
|
||||
<Timeago
|
||||
:time="chat.updated_at"
|
||||
:auto-update="60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_list_item.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import './chat_list_item.scss';
|
||||
</style>
|
96
src/components/chat_message/chat_message.js
Normal file
96
src/components/chat_message/chat_message.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
import Popover from '../popover/popover.vue'
|
||||
import Attachment from '../attachment/attachment.vue'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import Gallery from '../gallery/gallery.vue'
|
||||
import LinkPreview from '../link-preview/link-preview.vue'
|
||||
import StatusContent from '../status_content/status_content.vue'
|
||||
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
||||
const ChatMessage = {
|
||||
name: 'ChatMessage',
|
||||
props: [
|
||||
'author',
|
||||
'edited',
|
||||
'noHeading',
|
||||
'chatViewItem',
|
||||
'hoveredMessageChain'
|
||||
],
|
||||
components: {
|
||||
Popover,
|
||||
Attachment,
|
||||
StatusContent,
|
||||
UserAvatar,
|
||||
Gallery,
|
||||
LinkPreview,
|
||||
ChatMessageDate
|
||||
},
|
||||
computed: {
|
||||
// Returns HH:MM (hours and minutes) in local time.
|
||||
createdAt () {
|
||||
const time = this.chatViewItem.data.created_at
|
||||
return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
},
|
||||
isCurrentUser () {
|
||||
return this.message.account_id === this.currentUser.id
|
||||
},
|
||||
message () {
|
||||
return this.chatViewItem.data
|
||||
},
|
||||
userProfileLink () {
|
||||
return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
|
||||
},
|
||||
isMessage () {
|
||||
return this.chatViewItem.type === 'message'
|
||||
},
|
||||
messageForStatusContent () {
|
||||
return {
|
||||
summary: '',
|
||||
statusnet_html: this.message.content,
|
||||
text: this.message.content,
|
||||
attachments: this.message.attachments
|
||||
}
|
||||
},
|
||||
hasAttachment () {
|
||||
return this.message.attachments.length > 0
|
||||
},
|
||||
...mapState({
|
||||
betterShadow: state => state.interface.browserSupport.cssFilter,
|
||||
currentUser: state => state.users.currentUser,
|
||||
restrictedNicknames: state => state.instance.restrictedNicknames
|
||||
}),
|
||||
popoverMarginStyle () {
|
||||
if (this.isCurrentUser) {
|
||||
return {}
|
||||
} else {
|
||||
return { left: 50 }
|
||||
}
|
||||
},
|
||||
...mapGetters(['mergedConfig', 'findUser'])
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
hovered: false,
|
||||
menuOpened: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onHover (bool) {
|
||||
this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
|
||||
},
|
||||
async deleteMessage () {
|
||||
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
|
||||
if (confirmed) {
|
||||
await this.$store.dispatch('deleteChatMessage', {
|
||||
messageId: this.chatViewItem.data.id,
|
||||
chatId: this.chatViewItem.data.chat_id
|
||||
})
|
||||
}
|
||||
this.hovered = false
|
||||
this.menuOpened = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatMessage
|
164
src/components/chat_message/chat_message.scss
Normal file
164
src/components/chat_message/chat_message.scss
Normal file
|
@ -0,0 +1,164 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.chat-message-wrapper {
|
||||
&.hovered-message-chain {
|
||||
.animated.avatar {
|
||||
canvas {
|
||||
display: none;
|
||||
}
|
||||
img {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-menu {
|
||||
transition: opacity 0.1s;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: -0.8em;
|
||||
|
||||
button {
|
||||
padding-top: 0.2em;
|
||||
padding-bottom: 0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-ellipsis {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover, .extra-button-popover.open & {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
|
||||
border-radius: $fallback--chatMessageRadius;
|
||||
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
|
||||
}
|
||||
|
||||
.popover {
|
||||
width: 12em;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-right: 0.72em;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.link-preview, .attachments {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.chat-message-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: 80%;
|
||||
min-width: 10em;
|
||||
width: 100%;
|
||||
|
||||
&.with-media {
|
||||
width: 100%;
|
||||
|
||||
.gallery-row {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: $fallback--chatMessageRadius;
|
||||
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
|
||||
display: flex;
|
||||
padding: 0.75em;
|
||||
}
|
||||
|
||||
.created-at {
|
||||
position: relative;
|
||||
float: right;
|
||||
font-size: 0.8em;
|
||||
margin: -1em 0 -0.5em 0;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.without-attachment {
|
||||
.status-content {
|
||||
&::after {
|
||||
margin-right: 5.4em;
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incoming {
|
||||
a {
|
||||
color: var(--chatMessageIncomingLink, $fallback--link);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--chatMessageIncomingText, $fallback--text);
|
||||
background-color: var(--chatMessageIncomingBg, $fallback--bg);
|
||||
border: 1px solid var(--chatMessageIncomingBorder, --border);
|
||||
}
|
||||
|
||||
.created-at {
|
||||
a {
|
||||
color: var(--chatMessageIncomingText, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-menu {
|
||||
left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-content: end;
|
||||
justify-content: flex-end;
|
||||
|
||||
a {
|
||||
color: var(--chatMessageOutgoingLink, $fallback--link);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--chatMessageOutgoingText, $fallback--text);
|
||||
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
|
||||
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
|
||||
}
|
||||
|
||||
.chat-message-inner {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-menu {
|
||||
right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-date-separator {
|
||||
text-align: center;
|
||||
margin: 1.4em 0;
|
||||
font-size: 0.9em;
|
||||
user-select: none;
|
||||
color: $fallback--text;
|
||||
color: var(--faintedText, $fallback--text);
|
||||
}
|
99
src/components/chat_message/chat_message.vue
Normal file
99
src/components/chat_message/chat_message.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isMessage"
|
||||
class="chat-message-wrapper"
|
||||
:class="{ 'hovered-message-chain': hoveredMessageChain }"
|
||||
@mouseover="onHover(true)"
|
||||
@mouseleave="onHover(false)"
|
||||
>
|
||||
<div
|
||||
class="chat-message"
|
||||
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
|
||||
>
|
||||
<div
|
||||
v-if="!isCurrentUser"
|
||||
class="avatar-wrapper"
|
||||
>
|
||||
<router-link
|
||||
v-if="chatViewItem.isHead"
|
||||
:to="userProfileLink"
|
||||
>
|
||||
<UserAvatar
|
||||
:compact="true"
|
||||
:better-shadow="betterShadow"
|
||||
:user="author"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="chat-message-inner">
|
||||
<div
|
||||
class="status-body"
|
||||
:style="{ 'min-width': message.attachment ? '80%' : '' }"
|
||||
>
|
||||
<div
|
||||
class="media status"
|
||||
:class="{ 'without-attachment': !hasAttachment }"
|
||||
style="position: relative"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<div
|
||||
class="chat-message-menu"
|
||||
:class="{ 'visible': hovered || menuOpened }"
|
||||
>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="top"
|
||||
:bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
|
||||
:bound-to="{ x: 'container' }"
|
||||
:margin="popoverMarginStyle"
|
||||
@show="menuOpened = true"
|
||||
@close="menuOpened = false"
|
||||
>
|
||||
<div slot="content">
|
||||
<div class="dropdown-menu">
|
||||
<button
|
||||
class="dropdown-item dropdown-item-icon"
|
||||
@click="deleteMessage"
|
||||
>
|
||||
<i class="icon-cancel" /> {{ $t("chats.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
slot="trigger"
|
||||
:title="$t('chats.more')"
|
||||
>
|
||||
<i class="icon-ellipsis" />
|
||||
</button>
|
||||
</Popover>
|
||||
</div>
|
||||
<StatusContent
|
||||
:status="messageForStatusContent"
|
||||
:full-content="true"
|
||||
>
|
||||
<span
|
||||
slot="footer"
|
||||
class="created-at"
|
||||
>
|
||||
{{ createdAt }}
|
||||
</span>
|
||||
</StatusContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="chat-message-date-separator"
|
||||
>
|
||||
<ChatMessageDate :date="chatViewItem.date" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_message.js" ></script>
|
||||
<style lang="scss">
|
||||
@import './chat_message.scss';
|
||||
|
||||
</style>
|
24
src/components/chat_message_date/chat_message_date.vue
Normal file
24
src/components/chat_message_date/chat_message_date.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<time>
|
||||
{{ displayDate }}
|
||||
</time>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Timeago',
|
||||
props: ['date'],
|
||||
computed: {
|
||||
displayDate () {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (this.date.getTime() === today.getTime()) {
|
||||
return this.$t('display_date.today')
|
||||
} else {
|
||||
return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
73
src/components/chat_new/chat_new.js
Normal file
73
src/components/chat_new/chat_new.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
|
||||
const chatNew = {
|
||||
components: {
|
||||
BasicUserCard,
|
||||
UserAvatar
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
suggestions: [],
|
||||
userIds: [],
|
||||
loading: false,
|
||||
query: ''
|
||||
}
|
||||
},
|
||||
async created () {
|
||||
const { chats } = await this.backendInteractor.chats()
|
||||
chats.forEach(chat => this.suggestions.push(chat.account))
|
||||
},
|
||||
computed: {
|
||||
users () {
|
||||
return this.userIds.map(userId => this.findUser(userId))
|
||||
},
|
||||
availableUsers () {
|
||||
if (this.query.length !== 0) {
|
||||
return this.users
|
||||
} else {
|
||||
return this.suggestions
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
backendInteractor: state => state.api.backendInteractor
|
||||
}),
|
||||
...mapGetters(['findUser'])
|
||||
},
|
||||
methods: {
|
||||
goBack () {
|
||||
this.$emit('cancel')
|
||||
},
|
||||
goToChat (user) {
|
||||
this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
|
||||
},
|
||||
onInput () {
|
||||
this.search(this.query)
|
||||
},
|
||||
addUser (user) {
|
||||
this.selectedUserIds.push(user.id)
|
||||
this.query = ''
|
||||
},
|
||||
removeUser (userId) {
|
||||
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
|
||||
},
|
||||
search (query) {
|
||||
if (!query) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.userIds = []
|
||||
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
|
||||
.then(data => {
|
||||
this.loading = false
|
||||
this.userIds = data.accounts.map(a => a.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default chatNew
|
29
src/components/chat_new/chat_new.scss
Normal file
29
src/components/chat_new/chat_new.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
.chat-new {
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
margin: 0.7em 0.5em 0.7em 0.5em;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-search {
|
||||
font-size: 1.5em;
|
||||
float: right;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
padding-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.basic-user-card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--selectedPost, $fallback--lightBg);
|
||||
}
|
||||
|
||||
.go-back-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
46
src/components/chat_new/chat_new.vue
Normal file
46
src/components/chat_new/chat_new.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div
|
||||
id="nav"
|
||||
class="panel-default panel chat-new"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading"
|
||||
>
|
||||
<a
|
||||
class="go-back-button"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="button-icon icon-left-open" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<div class="input-search">
|
||||
<i class="button-icon icon-search" />
|
||||
</div>
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
placeholder="Search people"
|
||||
@input="onInput"
|
||||
>
|
||||
</div>
|
||||
<div class="member-list">
|
||||
<div
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
class="member"
|
||||
>
|
||||
<div @click.capture.prevent="goToChat(user)">
|
||||
<BasicUserCard :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_new.js"></script>
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import './chat_new.scss';
|
||||
</style>
|
|
@ -84,54 +84,56 @@
|
|||
max-width: 25em;
|
||||
}
|
||||
|
||||
.chat-heading {
|
||||
cursor: pointer;
|
||||
.icon-comment-empty {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
.chat-window-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
padding: 0.2em 0.5em
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: $fallback--avatarRadius;
|
||||
border-radius: var(--avatarRadius, $fallback--avatarRadius);
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
textarea {
|
||||
flex: 1;
|
||||
margin: 0.6em;
|
||||
min-height: 3.5em;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.title {
|
||||
.chat-heading {
|
||||
cursor: pointer;
|
||||
.icon-comment-empty {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
.chat-window-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.2em 0.5em
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: $fallback--avatarRadius;
|
||||
border-radius: var(--avatarRadius, $fallback--avatarRadius);
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
textarea {
|
||||
flex: 1;
|
||||
margin: 0.6em;
|
||||
min-height: 3.5em;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
26
src/components/chat_title/chat_title.js
Normal file
26
src/components/chat_title/chat_title.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import Vue from 'vue'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
|
||||
export default Vue.component('chat-title', {
|
||||
name: 'ChatTitle',
|
||||
components: {
|
||||
UserAvatar
|
||||
},
|
||||
props: [
|
||||
'user', 'withAvatar'
|
||||
],
|
||||
computed: {
|
||||
title () {
|
||||
return this.user ? this.user.screen_name : ''
|
||||
},
|
||||
htmlTitle () {
|
||||
return this.user ? this.user.name_html : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getUserProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name)
|
||||
}
|
||||
}
|
||||
})
|
67
src/components/chat_title/chat_title.vue
Normal file
67
src/components/chat_title/chat_title.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="chat-title"
|
||||
:title="title"
|
||||
>
|
||||
<router-link
|
||||
v-if="withAvatar && user"
|
||||
:to="getUserProfileLink(user)"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="user"
|
||||
width="23px"
|
||||
height="23px"
|
||||
/>
|
||||
</router-link>
|
||||
<span
|
||||
class="username"
|
||||
v-html="htmlTitle"
|
||||
/>
|
||||
</div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</template>
|
||||
|
||||
<script src="./chat_title.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.chat-title {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
|
||||
.username {
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.emoji {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain
|
||||
}
|
||||
}
|
||||
|
||||
.still-image.avatar {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
margin-right: 0.5em;
|
||||
|
||||
border-radius: $fallback--avatarAltRadius;
|
||||
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
|
||||
|
||||
&.animated::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -79,6 +79,20 @@ const EmojiInput = {
|
|||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placement: {
|
||||
/**
|
||||
* Forces the panel to take a specific position relative to the input element.
|
||||
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
|
||||
*/
|
||||
required: false,
|
||||
type: String, // 'auto', 'top', 'bottom'
|
||||
default: 'auto'
|
||||
},
|
||||
newlineOnCtrlEnter: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
|
@ -162,6 +176,11 @@ const EmojiInput = {
|
|||
input.elm.removeEventListener('input', this.onInput)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showSuggestions: function (newValue) {
|
||||
this.$emit('shown', newValue)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
triggerShowPicker () {
|
||||
this.showPicker = true
|
||||
|
@ -190,7 +209,7 @@ const EmojiInput = {
|
|||
this.$emit('input', newValue)
|
||||
this.caret = 0
|
||||
},
|
||||
insert ({ insertion, keepOpen }) {
|
||||
insert ({ insertion, keepOpen, surroundingSpace = true }) {
|
||||
const before = this.value.substring(0, this.caret) || ''
|
||||
const after = this.value.substring(this.caret) || ''
|
||||
|
||||
|
@ -209,8 +228,8 @@ const EmojiInput = {
|
|||
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
|
||||
*/
|
||||
const isSpaceRegex = /\s/
|
||||
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
|
||||
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
|
||||
const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
|
||||
const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
|
||||
|
||||
const newValue = [
|
||||
before,
|
||||
|
@ -367,6 +386,18 @@ const EmojiInput = {
|
|||
},
|
||||
onKeyDown (e) {
|
||||
const { ctrlKey, shiftKey, key } = e
|
||||
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
|
||||
this.insert({ insertion: '\n', surroundingSpace: false })
|
||||
// Ensure only one new line is added on macos
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
// Scroll the input element to the position of the cursor
|
||||
this.$nextTick(() => {
|
||||
this.input.elm.blur()
|
||||
this.input.elm.focus()
|
||||
})
|
||||
}
|
||||
// Disable suggestions hotkeys if suggestions are hidden
|
||||
if (!this.temporarilyHideSuggestions) {
|
||||
if (key === 'Tab') {
|
||||
|
@ -425,15 +456,29 @@ const EmojiInput = {
|
|||
this.caret = selectionStart
|
||||
},
|
||||
resize () {
|
||||
const { panel, picker } = this.$refs
|
||||
const panel = this.$refs.panel
|
||||
if (!panel) return
|
||||
const picker = this.$refs.picker.$el
|
||||
const panelBody = this.$refs['panel-body']
|
||||
const { offsetHeight, offsetTop } = this.input.elm
|
||||
const offsetBottom = offsetTop + offsetHeight
|
||||
|
||||
panel.style.top = offsetBottom + 'px'
|
||||
if (!picker) return
|
||||
picker.$el.style.top = offsetBottom + 'px'
|
||||
picker.$el.style.bottom = 'auto'
|
||||
this.setPlacement(panelBody, panel, offsetBottom)
|
||||
this.setPlacement(picker, picker, offsetBottom)
|
||||
},
|
||||
setPlacement (container, target, offsetBottom) {
|
||||
if (!container || !target) return
|
||||
|
||||
target.style.top = offsetBottom + 'px'
|
||||
target.style.bottom = 'auto'
|
||||
|
||||
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
|
||||
target.style.top = 'auto'
|
||||
target.style.bottom = this.input.elm.offsetHeight + 'px'
|
||||
}
|
||||
},
|
||||
overflowsBottom (el) {
|
||||
return el.getBoundingClientRect().bottom > window.innerHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,10 @@
|
|||
class="autocomplete-panel"
|
||||
:class="{ hide: !showSuggestions }"
|
||||
>
|
||||
<div class="autocomplete-panel-body">
|
||||
<div
|
||||
ref="panel-body"
|
||||
class="autocomplete-panel-body"
|
||||
>
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const FeaturesPanel = {
|
||||
computed: {
|
||||
chat: function () { return this.$store.state.instance.chatAvailable },
|
||||
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
|
||||
gopher: function () { return this.$store.state.instance.gopherAvailable },
|
||||
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
|
||||
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
<li v-if="chat">
|
||||
{{ $t('features_panel.chat') }}
|
||||
</li>
|
||||
<li v-if="pleromaChatMessages">
|
||||
{{ $t('features_panel.pleroma_chat_messages') }}
|
||||
</li>
|
||||
<li v-if="gopher">
|
||||
{{ $t('features_panel.gopher') }}
|
||||
</li>
|
||||
|
|
|
@ -61,7 +61,8 @@ const mediaUpload = {
|
|||
}
|
||||
},
|
||||
props: [
|
||||
'dropFiles'
|
||||
'dropFiles',
|
||||
'disabled'
|
||||
],
|
||||
watch: {
|
||||
'dropFiles': function (fileInfos) {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<div class="media-upload">
|
||||
<div
|
||||
class="media-upload"
|
||||
:class="{ disabled: disabled }"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
:title="$t('tool_tip.media_upload')"
|
||||
|
@ -14,6 +17,7 @@
|
|||
/>
|
||||
<input
|
||||
v-if="uploadReady"
|
||||
:disabled="disabled"
|
||||
type="file"
|
||||
style="position: fixed; top: -100em"
|
||||
multiple="true"
|
||||
|
@ -26,6 +30,8 @@
|
|||
<script src="./media_upload.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.media-upload {
|
||||
.label {
|
||||
display: inline-block;
|
||||
|
|
|
@ -30,7 +30,10 @@ const MobileNav = {
|
|||
return this.unseenNotifications.length
|
||||
},
|
||||
hideSitename () { return this.$store.state.instance.hideSitename },
|
||||
sitename () { return this.$store.state.instance.name }
|
||||
sitename () { return this.$store.state.instance.name },
|
||||
isChat () {
|
||||
return this.$route.name === 'chat'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleMobileSidebar () {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<nav
|
||||
id="nav"
|
||||
class="nav-bar container"
|
||||
:class="{ 'mobile-hidden': isChat }"
|
||||
>
|
||||
<div
|
||||
class="mobile-inner-nav"
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { debounce } from 'lodash'
|
||||
|
||||
const HIDDEN_FOR_PAGES = new Set([
|
||||
'chats',
|
||||
'chat'
|
||||
])
|
||||
|
||||
const MobilePostStatusButton = {
|
||||
data () {
|
||||
return {
|
||||
|
@ -27,6 +32,8 @@ const MobilePostStatusButton = {
|
|||
return !!this.$store.state.users.currentUser
|
||||
},
|
||||
isHidden () {
|
||||
if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
|
||||
|
||||
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
|
||||
},
|
||||
autohideFloatingPostButton () {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { mapState } from 'vuex'
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
|
||||
const NavPanel = {
|
||||
created () {
|
||||
|
@ -6,13 +6,17 @@ const NavPanel = {
|
|||
this.$store.dispatch('startFetchingFollowRequests')
|
||||
}
|
||||
},
|
||||
computed: mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
chat: state => state.chat.channel,
|
||||
followRequestCount: state => state.api.followRequests.length,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating
|
||||
})
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
chat: state => state.chat.channel,
|
||||
followRequestCount: state => state.api.followRequests.length,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating,
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
}),
|
||||
...mapGetters(['unreadChatCount'])
|
||||
}
|
||||
}
|
||||
|
||||
export default NavPanel
|
||||
|
|
|
@ -22,6 +22,17 @@
|
|||
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && pleromaChatMessagesAvailable">
|
||||
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
|
||||
<div
|
||||
v-if="unreadChatCount"
|
||||
class="badge badge-notification unread-chat-count"
|
||||
>
|
||||
{{ unreadChatCount }}
|
||||
</div>
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && currentUser.locked">
|
||||
<router-link :to="{ name: 'friend-requests' }">
|
||||
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import StatusContent from '../status_content/status_content.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import Status from '../status/status.vue'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import UserCard from '../user_card/user_card.vue'
|
||||
|
@ -81,7 +82,10 @@ const Notification = {
|
|||
},
|
||||
isStatusNotification () {
|
||||
return isStatusNotification(this.notification.type)
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { mapGetters } from 'vuex'
|
||||
import Notification from '../notification/notification.vue'
|
||||
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
|
||||
import {
|
||||
|
@ -51,18 +52,22 @@ const Notifications = {
|
|||
unseenCount () {
|
||||
return this.unseenNotifications.length
|
||||
},
|
||||
unseenCountTitle () {
|
||||
return this.unseenCount + (this.unreadChatCount)
|
||||
},
|
||||
loading () {
|
||||
return this.$store.state.statuses.notifications.loading
|
||||
},
|
||||
notificationsToDisplay () {
|
||||
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
|
||||
}
|
||||
},
|
||||
...mapGetters(['unreadChatCount'])
|
||||
},
|
||||
components: {
|
||||
Notification
|
||||
},
|
||||
watch: {
|
||||
unseenCount (count) {
|
||||
unseenCountTitle (count) {
|
||||
if (count > 0) {
|
||||
this.$store.dispatch('setPageTitle', `(${count})`)
|
||||
} else {
|
||||
|
|
|
@ -9,7 +9,7 @@ import fileTypeService from '../../services/file_type/file_type.service.js'
|
|||
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
|
||||
import { reject, map, uniqBy, debounce } from 'lodash'
|
||||
import suggestor from '../emoji_input/suggestor.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import Checkbox from '../checkbox/checkbox.vue'
|
||||
|
||||
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
|
||||
|
@ -33,7 +33,23 @@ const PostStatusForm = {
|
|||
'repliedUser',
|
||||
'attentions',
|
||||
'copyMessageScope',
|
||||
'subject'
|
||||
'subject',
|
||||
'disableSubject',
|
||||
'disableScopeSelector',
|
||||
'disableNotice',
|
||||
'disableLockWarning',
|
||||
'disablePolls',
|
||||
'disableSensitivityCheckbox',
|
||||
'disableSubmit',
|
||||
'disablePreview',
|
||||
'placeholder',
|
||||
'maxHeight',
|
||||
'postHandler',
|
||||
'preserveFocus',
|
||||
'autoFocus',
|
||||
'fileLimit',
|
||||
'submitOnEnter',
|
||||
'emojiPickerPlacement'
|
||||
],
|
||||
components: {
|
||||
MediaUpload,
|
||||
|
@ -46,10 +62,13 @@ const PostStatusForm = {
|
|||
},
|
||||
mounted () {
|
||||
this.resize(this.$refs.textarea)
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
|
||||
if (this.replyTo) {
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
}
|
||||
|
||||
if (this.replyTo || this.autoFocus) {
|
||||
this.$refs.textarea.focus()
|
||||
}
|
||||
},
|
||||
|
@ -72,7 +91,7 @@ const PostStatusForm = {
|
|||
|
||||
return {
|
||||
dropFiles: [],
|
||||
submitDisabled: false,
|
||||
uploadingFiles: false,
|
||||
error: null,
|
||||
posting: false,
|
||||
highlighted: 0,
|
||||
|
@ -91,7 +110,8 @@ const PostStatusForm = {
|
|||
showDropIcon: 'hide',
|
||||
dropStopTimeout: null,
|
||||
preview: null,
|
||||
previewLoading: false
|
||||
previewLoading: false,
|
||||
emojiInputShown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -160,10 +180,11 @@ const PostStatusForm = {
|
|||
},
|
||||
pollsAvailable () {
|
||||
return this.$store.state.instance.pollsAvailable &&
|
||||
this.$store.state.instance.pollLimits.max_options >= 2
|
||||
this.$store.state.instance.pollLimits.max_options >= 2 &&
|
||||
this.disablePolls !== true
|
||||
},
|
||||
hideScopeNotice () {
|
||||
return this.$store.getters.mergedConfig.hideScopeNotice
|
||||
return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
|
||||
},
|
||||
pollContentError () {
|
||||
return this.pollFormVisible &&
|
||||
|
@ -171,12 +192,18 @@ const PostStatusForm = {
|
|||
this.newStatus.poll.error
|
||||
},
|
||||
showPreview () {
|
||||
return !!this.preview || this.previewLoading
|
||||
return !this.disablePreview && (!!this.preview || this.previewLoading)
|
||||
},
|
||||
emptyStatus () {
|
||||
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
uploadFileLimitReached () {
|
||||
return this.newStatus.files.length >= this.fileLimit
|
||||
},
|
||||
...mapGetters(['mergedConfig']),
|
||||
...mapState({
|
||||
mobileLayout: state => state.interface.mobileLayout
|
||||
})
|
||||
},
|
||||
watch: {
|
||||
'newStatus.contentType': function () {
|
||||
|
@ -187,9 +214,15 @@ const PostStatusForm = {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
async postStatus (newStatus) {
|
||||
async postStatus (event, newStatus, opts = {}) {
|
||||
if (this.posting) { return }
|
||||
if (this.submitDisabled) { return }
|
||||
if (this.emojiInputShown) { return }
|
||||
if (this.submitOnEnter) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
if (this.emptyStatus) {
|
||||
this.error = this.$t('post_status.empty_status_error')
|
||||
return
|
||||
|
@ -211,7 +244,7 @@ const PostStatusForm = {
|
|||
return
|
||||
}
|
||||
|
||||
const data = await statusPoster.postStatus({
|
||||
const postingOptions = {
|
||||
status: newStatus.status,
|
||||
spoilerText: newStatus.spoilerText || null,
|
||||
visibility: newStatus.visibility,
|
||||
|
@ -221,32 +254,40 @@ const PostStatusForm = {
|
|||
inReplyToStatusId: this.replyTo,
|
||||
contentType: newStatus.contentType,
|
||||
poll
|
||||
})
|
||||
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {},
|
||||
mediaDescriptions: {}
|
||||
}
|
||||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted')
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
if (this.preview) this.previewStatus()
|
||||
} else {
|
||||
this.error = data.error
|
||||
}
|
||||
|
||||
this.posting = false
|
||||
const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
|
||||
|
||||
postHandler(postingOptions).then((data) => {
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {},
|
||||
mediaDescriptions: {}
|
||||
}
|
||||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted', data)
|
||||
if (this.preserveFocus) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textarea.focus()
|
||||
})
|
||||
}
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
if (this.preview) this.previewStatus()
|
||||
} else {
|
||||
this.error = data.error
|
||||
}
|
||||
this.posting = false
|
||||
})
|
||||
},
|
||||
previewStatus () {
|
||||
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
|
||||
|
@ -301,20 +342,23 @@ const PostStatusForm = {
|
|||
},
|
||||
addMediaFile (fileInfo) {
|
||||
this.newStatus.files.push(fileInfo)
|
||||
this.$emit('resize', { delayed: true })
|
||||
},
|
||||
removeMediaFile (fileInfo) {
|
||||
let index = this.newStatus.files.indexOf(fileInfo)
|
||||
this.newStatus.files.splice(index, 1)
|
||||
this.$emit('resize')
|
||||
},
|
||||
uploadFailed (errString, templateArgs) {
|
||||
templateArgs = templateArgs || {}
|
||||
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
|
||||
},
|
||||
disableSubmit () {
|
||||
this.submitDisabled = true
|
||||
startedUploadingFiles () {
|
||||
this.uploadingFiles = true
|
||||
},
|
||||
enableSubmit () {
|
||||
this.submitDisabled = false
|
||||
finishedUploadingFiles () {
|
||||
this.$emit('resize')
|
||||
this.uploadingFiles = false
|
||||
},
|
||||
type (fileInfo) {
|
||||
return fileTypeService.fileType(fileInfo.mimetype)
|
||||
|
@ -348,7 +392,7 @@ const PostStatusForm = {
|
|||
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
|
||||
},
|
||||
fileDrag (e) {
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
clearTimeout(this.dropStopTimeout)
|
||||
this.showDropIcon = 'show'
|
||||
|
@ -367,6 +411,7 @@ const PostStatusForm = {
|
|||
// Reset to default height for empty form, nothing else to do here.
|
||||
if (target.value === '') {
|
||||
target.style.height = null
|
||||
this.$emit('resize')
|
||||
this.$refs['emoji-input'].resize()
|
||||
return
|
||||
}
|
||||
|
@ -419,8 +464,10 @@ const PostStatusForm = {
|
|||
|
||||
// BEGIN content size update
|
||||
target.style.height = 'auto'
|
||||
const newHeight = target.scrollHeight - vertPadding
|
||||
const heightWithoutPadding = target.scrollHeight - vertPadding
|
||||
const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
|
||||
target.style.height = `${newHeight}px`
|
||||
this.$emit('resize', newHeight)
|
||||
// END content size update
|
||||
|
||||
// We check where the bottom border of form-bottom element is, this uses findOffset
|
||||
|
@ -480,6 +527,9 @@ const PostStatusForm = {
|
|||
setAllMediaDescriptions () {
|
||||
const ids = this.newStatus.files.map(file => file.id)
|
||||
return Promise.all(ids.map(id => this.setMediaDescription(id)))
|
||||
},
|
||||
handleEmojiInputShow (value) {
|
||||
this.emojiInputShown = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,19 +5,20 @@
|
|||
>
|
||||
<form
|
||||
autocomplete="off"
|
||||
@submit.prevent="postStatus(newStatus)"
|
||||
@submit.prevent
|
||||
@dragover.prevent="fileDrag"
|
||||
>
|
||||
<div
|
||||
v-show="showDropIcon !== 'hide'"
|
||||
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
|
||||
class="drop-indicator icon-upload"
|
||||
class="drop-indicator"
|
||||
:class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
|
||||
@dragleave="fileDragStop"
|
||||
@drop.stop="fileDrop"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<i18n
|
||||
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
|
||||
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
|
||||
path="post_status.account_not_locked_warning"
|
||||
tag="p"
|
||||
class="visibility-notice"
|
||||
|
@ -69,7 +70,10 @@
|
|||
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
|
||||
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
|
||||
</p>
|
||||
<div class="preview-heading faint">
|
||||
<div
|
||||
v-if="!disablePreview"
|
||||
class="preview-heading faint"
|
||||
>
|
||||
<a
|
||||
class="preview-toggle faint"
|
||||
@click.stop.prevent="togglePreview"
|
||||
|
@ -108,7 +112,7 @@
|
|||
/>
|
||||
</div>
|
||||
<EmojiInput
|
||||
v-if="newStatus.spoilerText || alwaysShowSubject"
|
||||
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
|
||||
v-model="newStatus.spoilerText"
|
||||
enable-emoji-picker
|
||||
:suggest="emojiSuggestor"
|
||||
|
@ -126,23 +130,28 @@
|
|||
ref="emoji-input"
|
||||
v-model="newStatus.status"
|
||||
:suggest="emojiUserSuggestor"
|
||||
:placement="emojiPickerPlacement"
|
||||
class="form-control main-input"
|
||||
enable-emoji-picker
|
||||
hide-emoji-button
|
||||
:newline-on-ctrl-enter="submitOnEnter"
|
||||
enable-sticker-picker
|
||||
@input="onEmojiInputInput"
|
||||
@sticker-uploaded="addMediaFile"
|
||||
@sticker-upload-failed="uploadFailed"
|
||||
@shown="handleEmojiInputShow"
|
||||
>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="newStatus.status"
|
||||
:placeholder="$t('post_status.default')"
|
||||
:placeholder="placeholder || $t('post_status.default')"
|
||||
rows="1"
|
||||
:disabled="posting"
|
||||
class="form-post-body"
|
||||
@keydown.meta.enter="postStatus(newStatus)"
|
||||
@keydown.ctrl.enter="postStatus(newStatus)"
|
||||
:class="{ 'scrollable-form': !!maxHeight }"
|
||||
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
|
||||
@keydown.meta.enter="postStatus($event, newStatus)"
|
||||
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
|
||||
@input="resize"
|
||||
@compositionupdate="resize"
|
||||
@paste="paste"
|
||||
|
@ -155,7 +164,10 @@
|
|||
{{ charactersLeft }}
|
||||
</p>
|
||||
</EmojiInput>
|
||||
<div class="visibility-tray">
|
||||
<div
|
||||
v-if="!disableScopeSelector"
|
||||
class="visibility-tray"
|
||||
>
|
||||
<scope-selector
|
||||
:show-all="showAllScopes"
|
||||
:user-default="userDefaultScope"
|
||||
|
@ -213,10 +225,11 @@
|
|||
ref="mediaUpload"
|
||||
class="media-upload-icon"
|
||||
:drop-files="dropFiles"
|
||||
@uploading="disableSubmit"
|
||||
:disabled="uploadFileLimitReached"
|
||||
@uploading="startedUploadingFiles"
|
||||
@uploaded="addMediaFile"
|
||||
@upload-failed="uploadFailed"
|
||||
@all-uploaded="enableSubmit"
|
||||
@all-uploaded="finishedUploadingFiles"
|
||||
/>
|
||||
<div
|
||||
class="emoji-icon"
|
||||
|
@ -253,11 +266,13 @@
|
|||
>
|
||||
{{ $t('general.submit') }}
|
||||
</button>
|
||||
<!-- touchstart is used to keep the OSK at the same position after a message send -->
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitDisabled"
|
||||
type="submit"
|
||||
:disabled="uploadingFiles || disableSubmit"
|
||||
class="btn btn-default"
|
||||
@touchstart.stop.prevent="postStatus($event, newStatus)"
|
||||
@click.stop.prevent="postStatus($event, newStatus)"
|
||||
>
|
||||
{{ $t('general.submit') }}
|
||||
</button>
|
||||
|
@ -297,7 +312,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="newStatus.files.length > 0"
|
||||
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
|
||||
class="upload_settings"
|
||||
>
|
||||
<Checkbox v-model="newStatus.nsfw">
|
||||
|
@ -331,6 +346,8 @@
|
|||
}
|
||||
|
||||
.post-status-form {
|
||||
position: relative;
|
||||
|
||||
.form-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -423,11 +440,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.selected, &:hover {
|
||||
// needs to be specific to override icon default color
|
||||
i, label {
|
||||
color: $fallback--lightText;
|
||||
color: var(--lightText, $fallback--lightText);
|
||||
&.disabled {
|
||||
i {
|
||||
cursor: not-allowed;
|
||||
color: $fallback--icon;
|
||||
color: var(--btnDisabledText, $fallback--icon);
|
||||
|
||||
&:hover {
|
||||
color: $fallback--icon;
|
||||
color: var(--btnDisabledText, $fallback--icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -438,12 +460,6 @@
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
// Order is not necessary but a good indicator
|
||||
.media-upload-icon {
|
||||
order: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.emoji-icon {
|
||||
order: 2;
|
||||
text-align: center;
|
||||
|
@ -561,6 +577,10 @@
|
|||
padding-bottom: 1.75em;
|
||||
min-height: 1px;
|
||||
box-sizing: content-box;
|
||||
|
||||
&.scrollable-form {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.main-input {
|
||||
|
@ -623,4 +643,11 @@
|
|||
border: 2px dashed var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
|
||||
img.media-upload, .media-upload-container > video {
|
||||
line-height: 0;
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -99,7 +99,8 @@ export default {
|
|||
avatarRadiusLocal: '',
|
||||
avatarAltRadiusLocal: '',
|
||||
attachmentRadiusLocal: '',
|
||||
tooltipRadiusLocal: ''
|
||||
tooltipRadiusLocal: '',
|
||||
chatMessageRadiusLocal: ''
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -214,7 +215,8 @@ export default {
|
|||
avatar: this.avatarRadiusLocal,
|
||||
avatarAlt: this.avatarAltRadiusLocal,
|
||||
tooltip: this.tooltipRadiusLocal,
|
||||
attachment: this.attachmentRadiusLocal
|
||||
attachment: this.attachmentRadiusLocal,
|
||||
chatMessage: this.chatMessageRadiusLocal
|
||||
}
|
||||
},
|
||||
preview () {
|
||||
|
|
|
@ -735,6 +735,65 @@
|
|||
/>
|
||||
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
|
||||
</div>
|
||||
<div class="color-item">
|
||||
<h4>{{ $t('chats.chats') }}</h4>
|
||||
<ColorInput
|
||||
v-model="chatBgColorLocal"
|
||||
name="chatBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.background')"
|
||||
/>
|
||||
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingBgColorLocal"
|
||||
name="chatMessageIncomingBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.background')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingTextColorLocal"
|
||||
name="chatMessageIncomingTextColor"
|
||||
:fallback="previewTheme.colors.text || 1"
|
||||
:label="$t('settings.text')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingLinkColorLocal"
|
||||
name="chatMessageIncomingLinkColor"
|
||||
:fallback="previewTheme.colors.link || 1"
|
||||
:label="$t('settings.links')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingBorderColorLocal"
|
||||
name="chatMessageIncomingBorderLinkColor"
|
||||
:fallback="previewTheme.colors.fg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.border')"
|
||||
/>
|
||||
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingBgColorLocal"
|
||||
name="chatMessageOutgoingBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.background')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingTextColorLocal"
|
||||
name="chatMessageOutgoingTextColor"
|
||||
:fallback="previewTheme.colors.text || 1"
|
||||
:label="$t('settings.text')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingLinkColorLocal"
|
||||
name="chatMessageOutgoingLinkColor"
|
||||
:fallback="previewTheme.colors.link || 1"
|
||||
:label="$t('settings.links')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingBorderColorLocal"
|
||||
name="chatMessageOutgoingBorderLinkColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.border')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -814,6 +873,14 @@
|
|||
max="50"
|
||||
hard-min="0"
|
||||
/>
|
||||
<RangeInput
|
||||
v-model="chatMessageRadiusLocal"
|
||||
name="chatMessageRadius"
|
||||
:label="$t('settings.chatMessageRadius')"
|
||||
:fallback="previewTheme.radii.chatMessage || 2"
|
||||
max="50"
|
||||
hard-min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
import UserCard from '../user_card/user_card.vue'
|
||||
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
||||
import GestureService from '../../services/gesture_service/gesture_service'
|
||||
|
@ -47,7 +48,11 @@ const SideDrawer = {
|
|||
},
|
||||
federating () {
|
||||
return this.$store.state.instance.federating
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
}),
|
||||
...mapGetters(['unreadChatCount'])
|
||||
},
|
||||
methods: {
|
||||
toggleDrawer () {
|
||||
|
|
|
@ -40,12 +40,24 @@
|
|||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
v-if="currentUser && pleromaChatMessagesAvailable"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
|
||||
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
|
||||
style="position: relative"
|
||||
>
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
|
||||
<span
|
||||
v-if="unreadChatCount"
|
||||
class="badge badge-notification unread-chat-count"
|
||||
>
|
||||
{{ unreadChatCount }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
|
@ -103,14 +115,6 @@
|
|||
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser && chat"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<router-link :to="{ name: 'chat' }">
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chat") }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li
|
||||
|
|
|
@ -14,11 +14,12 @@ const StatusContent = {
|
|||
'status',
|
||||
'focused',
|
||||
'noHeading',
|
||||
'fullContent'
|
||||
'fullContent',
|
||||
'singleLine'
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
showingTall: this.inConversation && this.focused,
|
||||
showingTall: this.fullContent || (this.inConversation && this.focused),
|
||||
showingLongSubject: false,
|
||||
// not as computed because it sets the initial state which will be changed later
|
||||
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
</a>
|
||||
<div
|
||||
v-if="!hideSubjectStatus"
|
||||
:class="{ 'single-line': singleLine }"
|
||||
class="status-content media-body"
|
||||
@click.prevent="linkClicked"
|
||||
v-html="postBodyHtml"
|
||||
|
@ -76,7 +77,7 @@
|
|||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="showingMore"
|
||||
v-if="showingMore && !fullContent"
|
||||
href="#"
|
||||
class="status-unhider"
|
||||
@click.prevent="toggleShowMore"
|
||||
|
@ -269,6 +270,12 @@ $status-margin: 0.75em;
|
|||
h4 {
|
||||
margin: 1.1em 0;
|
||||
}
|
||||
|
||||
&.single-line {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,5 +12,9 @@
|
|||
.error {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
},
|
||||
"features_panel": {
|
||||
"chat": "Chat",
|
||||
"pleroma_chat_messages": "Pleroma Chat",
|
||||
"gopher": "Gopher",
|
||||
"media_proxy": "Media proxy",
|
||||
"scope_options": "Scope options",
|
||||
|
@ -124,7 +125,8 @@
|
|||
"user_search": "User Search",
|
||||
"search": "Search",
|
||||
"who_to_follow": "Who to follow",
|
||||
"preferences": "Preferences"
|
||||
"preferences": "Preferences",
|
||||
"chats": "Chats"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "Unknown status, searching for it…",
|
||||
|
@ -287,6 +289,7 @@
|
|||
"change_password": "Change Password",
|
||||
"change_password_error": "There was an issue changing your password.",
|
||||
"changed_password": "Password changed successfully!",
|
||||
"chatMessageRadius": "Chat message",
|
||||
"collapse_subject": "Collapse posts with subjects",
|
||||
"composing": "Composing",
|
||||
"confirm_new_password": "Confirm new password",
|
||||
|
@ -519,7 +522,12 @@
|
|||
"selectedMenu": "Selected menu item",
|
||||
"disabled": "Disabled",
|
||||
"toggled": "Toggled",
|
||||
"tabs": "Tabs"
|
||||
"tabs": "Tabs",
|
||||
"chat": {
|
||||
"incoming": "Incoming",
|
||||
"outgoing": "Outgoing",
|
||||
"border": "Border"
|
||||
}
|
||||
},
|
||||
"radii": {
|
||||
"_tab_label": "Roundness"
|
||||
|
@ -678,6 +686,7 @@
|
|||
"its_you": "It's you!",
|
||||
"media": "Media",
|
||||
"mention": "Mention",
|
||||
"message": "Message",
|
||||
"mute": "Mute",
|
||||
"muted": "Muted",
|
||||
"per_day": "per day",
|
||||
|
@ -776,5 +785,26 @@
|
|||
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
|
||||
"password_reset_required": "You must reset your password to log in.",
|
||||
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
|
||||
},
|
||||
"chats": {
|
||||
"message_user": "Message {nickname}",
|
||||
"delete": "Delete",
|
||||
"chats": "Chats",
|
||||
"new": "New Chat",
|
||||
"empty_message_error": "Cannot post empty message",
|
||||
"more": "More",
|
||||
"delete_confirm": "Do you really want to delete this message?",
|
||||
"error_loading_chat": "Something went wrong when loading the chat.",
|
||||
"error_sending_message": "Something went wrong when sending the message.",
|
||||
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
|
||||
},
|
||||
"file_type": {
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"image": "Image",
|
||||
"file": "File"
|
||||
},
|
||||
"display_date": {
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js'
|
|||
import reportsModule from './modules/reports.js'
|
||||
import pollsModule from './modules/polls.js'
|
||||
import postStatusModule from './modules/postStatus.js'
|
||||
import chatsModule from './modules/chats.js'
|
||||
|
||||
import VueI18n from 'vue-i18n'
|
||||
|
||||
|
@ -91,7 +92,8 @@ const persistedStateOptions = {
|
|||
oauthTokens: oauthTokensModule,
|
||||
reports: reportsModule,
|
||||
polls: pollsModule,
|
||||
postStatus: postStatusModule
|
||||
postStatus: postStatusModule,
|
||||
chats: chatsModule
|
||||
},
|
||||
plugins,
|
||||
strict: false // Socket modifies itself, let's ignore this for now.
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
|
||||
import { WSConnectionStatus } from '../services/api/api.service.js'
|
||||
import { Socket } from 'phoenix'
|
||||
|
||||
const api = {
|
||||
|
@ -7,6 +8,7 @@ const api = {
|
|||
fetchers: {},
|
||||
socket: null,
|
||||
mastoUserSocket: null,
|
||||
mastoUserSocketStatus: null,
|
||||
followRequests: []
|
||||
},
|
||||
mutations: {
|
||||
|
@ -28,6 +30,9 @@ const api = {
|
|||
},
|
||||
setFollowRequests (state, value) {
|
||||
state.followRequests = value
|
||||
},
|
||||
setMastoUserSocketStatus (state, value) {
|
||||
state.mastoUserSocketStatus = value
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
@ -47,7 +52,7 @@ const api = {
|
|||
startMastoUserSocket (store) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const { state, dispatch, rootState } = store
|
||||
const { state, commit, dispatch, rootState } = store
|
||||
const timelineData = rootState.statuses.timelines.friends
|
||||
state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
|
||||
state.mastoUserSocket.addEventListener(
|
||||
|
@ -66,11 +71,22 @@ const api = {
|
|||
showImmediately: timelineData.visibleStatuses.length === 0,
|
||||
timeline: 'friends'
|
||||
})
|
||||
} else if (message.event === 'pleroma:chat_update') {
|
||||
dispatch('addChatMessages', {
|
||||
chatId: message.chatUpdate.id,
|
||||
messages: [message.chatUpdate.lastMessage]
|
||||
})
|
||||
dispatch('updateChat', { chat: message.chatUpdate })
|
||||
}
|
||||
}
|
||||
)
|
||||
state.mastoUserSocket.addEventListener('open', () => {
|
||||
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
|
||||
})
|
||||
state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
|
||||
console.error('Error in MastoAPI websocket:', error)
|
||||
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
|
||||
dispatch('clearOpenedChats')
|
||||
})
|
||||
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
|
||||
const ignoreCodes = new Set([
|
||||
|
@ -84,8 +100,11 @@ const api = {
|
|||
console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
|
||||
dispatch('startFetchingTimeline', { timeline: 'friends' })
|
||||
dispatch('startFetchingNotifications')
|
||||
dispatch('startFetchingChats')
|
||||
dispatch('restartMastoUserSocket')
|
||||
}
|
||||
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
|
||||
dispatch('clearOpenedChats')
|
||||
})
|
||||
resolve()
|
||||
} catch (e) {
|
||||
|
@ -99,12 +118,13 @@ const api = {
|
|||
return dispatch('startMastoUserSocket').then(() => {
|
||||
dispatch('stopFetchingTimeline', { timeline: 'friends' })
|
||||
dispatch('stopFetchingNotifications')
|
||||
dispatch('stopFetchingChats')
|
||||
})
|
||||
},
|
||||
stopMastoUserSocket ({ state, dispatch }) {
|
||||
dispatch('startFetchingTimeline', { timeline: 'friends' })
|
||||
dispatch('startFetchingNotifications')
|
||||
console.log(state.mastoUserSocket)
|
||||
dispatch('startFetchingChats')
|
||||
state.mastoUserSocket.close()
|
||||
},
|
||||
|
||||
|
|
225
src/modules/chats.js
Normal file
225
src/modules/chats.js
Normal file
|
@ -0,0 +1,225 @@
|
|||
import Vue from 'vue'
|
||||
import { find, omitBy, orderBy, sumBy } from 'lodash'
|
||||
import chatService from '../services/chat_service/chat_service.js'
|
||||
import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
|
||||
|
||||
const emptyChatList = () => ({
|
||||
data: [],
|
||||
idStore: {}
|
||||
})
|
||||
|
||||
const defaultState = {
|
||||
chatList: emptyChatList(),
|
||||
chatListFetcher: null,
|
||||
openedChats: {},
|
||||
openedChatMessageServices: {},
|
||||
fetcher: undefined,
|
||||
currentChatId: 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 chats = {
|
||||
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 })
|
||||
}
|
||||
fetcher()
|
||||
commit('setChatListFetcher', {
|
||||
fetcher: () => setInterval(() => { fetcher() }, 5000)
|
||||
})
|
||||
},
|
||||
stopFetchingChats ({ commit }) {
|
||||
commit('setChatListFetcher', { fetcher: undefined })
|
||||
},
|
||||
fetchChats ({ dispatch, rootState, commit }, params = {}) {
|
||||
return rootState.api.backendInteractor.chats()
|
||||
.then(({ chats }) => {
|
||||
dispatch('addNewChats', { chats })
|
||||
return chats
|
||||
})
|
||||
},
|
||||
addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) {
|
||||
commit('addNewChats', { dispatch, chats, rootGetters })
|
||||
},
|
||||
updateChat ({ commit }, { chat }) {
|
||||
commit('updateChat', { chat })
|
||||
},
|
||||
|
||||
// Opened Chats
|
||||
startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
|
||||
dispatch('setCurrentChatFetcher', { fetcher })
|
||||
},
|
||||
setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
|
||||
commit('setCurrentChatFetcher', { fetcher })
|
||||
},
|
||||
addOpenedChat ({ rootState, 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 ({ rootState, commit, dispatch }, value) {
|
||||
commit('setCurrentChatId', { chatId: undefined })
|
||||
commit('setCurrentChatFetcher', { fetcher: undefined })
|
||||
},
|
||||
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
|
||||
dispatch('resetChatNewMessageCount')
|
||||
commit('readChat', { id })
|
||||
rootState.api.backendInteractor.readChat({ id, lastReadId })
|
||||
},
|
||||
deleteChatMessage ({ rootState, commit }, value) {
|
||||
rootState.api.backendInteractor.deleteChatMessage(value)
|
||||
commit('deleteChatMessage', { commit, ...value })
|
||||
},
|
||||
resetChats ({ commit, dispatch }) {
|
||||
dispatch('clearCurrentChat')
|
||||
commit('resetChats', { commit })
|
||||
},
|
||||
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
|
||||
commit('clearOpenedChats', { commit })
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setChatListFetcher (state, { commit, fetcher }) {
|
||||
const prevFetcher = state.chatListFetcher
|
||||
if (prevFetcher) {
|
||||
clearInterval(prevFetcher)
|
||||
}
|
||||
state.chatListFetcher = fetcher && fetcher()
|
||||
},
|
||||
setCurrentChatFetcher (state, { fetcher }) {
|
||||
const prevFetcher = state.fetcher
|
||||
if (prevFetcher) {
|
||||
clearInterval(prevFetcher)
|
||||
}
|
||||
state.fetcher = fetcher && fetcher()
|
||||
},
|
||||
addOpenedChat (state, { _dispatch, chat }) {
|
||||
state.currentChatId = chat.id
|
||||
Vue.set(state.openedChats, chat.id, chat)
|
||||
|
||||
if (!state.openedChatMessageServices[chat.id]) {
|
||||
Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id))
|
||||
}
|
||||
},
|
||||
setCurrentChatId (state, { chatId }) {
|
||||
state.currentChatId = chatId
|
||||
},
|
||||
addNewChats (state, { _dispatch, chats, _rootGetters }) {
|
||||
chats.forEach((updatedChat) => {
|
||||
const chat = getChatById(state, updatedChat.id)
|
||||
|
||||
if (chat) {
|
||||
chat.lastMessage = updatedChat.lastMessage
|
||||
chat.unread = updatedChat.unread
|
||||
} else {
|
||||
state.chatList.data.push(updatedChat)
|
||||
Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
|
||||
}
|
||||
})
|
||||
},
|
||||
updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
|
||||
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) }
|
||||
Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
|
||||
},
|
||||
deleteChat (state, { _dispatch, id, _rootGetters }) {
|
||||
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])
|
||||
Vue.delete(state.openedChats, chatId)
|
||||
Vue.delete(state.openedChatMessageServices, chatId)
|
||||
}
|
||||
},
|
||||
setChatsLoading (state, { value }) {
|
||||
state.chats.loading = value
|
||||
},
|
||||
addChatMessages (state, { commit, chatId, messages }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
if (chatMessageService) {
|
||||
chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) })
|
||||
commit('refreshLastMessage', { chatId })
|
||||
}
|
||||
},
|
||||
refreshLastMessage (state, { chatId }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
if (chatMessageService) {
|
||||
const chat = getChatById(state, chatId)
|
||||
if (chat) {
|
||||
chat.lastMessage = chatMessageService.lastMessage
|
||||
if (chatMessageService.lastMessage) {
|
||||
chat.updated_at = chatMessageService.lastMessage.created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteChatMessage (state, { commit, chatId, messageId }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
if (chatMessageService) {
|
||||
chatService.deleteMessage(chatMessageService, messageId)
|
||||
commit('refreshLastMessage', { chatId })
|
||||
}
|
||||
},
|
||||
resetChatNewMessageCount (state, _value) {
|
||||
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])
|
||||
Vue.delete(state.openedChats, chatId)
|
||||
Vue.delete(state.openedChatMessageServices, chatId)
|
||||
}
|
||||
}
|
||||
},
|
||||
readChat (state, { id }) {
|
||||
const chat = getChatById(state, id)
|
||||
if (chat) {
|
||||
chat.unread = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default chats
|
|
@ -46,7 +46,8 @@ export const defaultState = {
|
|||
repeats: true,
|
||||
moves: true,
|
||||
emojiReactions: false,
|
||||
followRequest: true
|
||||
followRequest: true,
|
||||
chatMention: true
|
||||
},
|
||||
webPushNotifications: false,
|
||||
muteWords: [],
|
||||
|
|
|
@ -55,6 +55,7 @@ const defaultState = {
|
|||
|
||||
// Feature-set, apparently, not everything here is reported...
|
||||
chatAvailable: false,
|
||||
pleromaChatMessagesAvailable: false,
|
||||
gopherAvailable: false,
|
||||
mediaProxyAvailable: false,
|
||||
suggestionsEnabled: false,
|
||||
|
|
|
@ -15,7 +15,8 @@ const defaultState = {
|
|||
)
|
||||
},
|
||||
mobileLayout: false,
|
||||
globalNotices: []
|
||||
globalNotices: [],
|
||||
layoutHeight: 0
|
||||
}
|
||||
|
||||
const interfaceMod = {
|
||||
|
@ -65,6 +66,9 @@ const interfaceMod = {
|
|||
},
|
||||
removeGlobalNotice (state, notice) {
|
||||
state.globalNotices = state.globalNotices.filter(n => n !== notice)
|
||||
},
|
||||
setLayoutHeight (state, value) {
|
||||
state.layoutHeight = value
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
@ -110,6 +114,9 @@ const interfaceMod = {
|
|||
},
|
||||
removeGlobalNotice ({ commit }, notice) {
|
||||
commit('removeGlobalNotice', notice)
|
||||
},
|
||||
setLayoutHeight ({ commit }, value) {
|
||||
commit('setLayoutHeight', value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -478,7 +478,7 @@ export const mutations = {
|
|||
},
|
||||
setDeleted (state, { status }) {
|
||||
const newStatus = state.allStatusesObject[status.id]
|
||||
newStatus.deleted = true
|
||||
if (newStatus) newStatus.deleted = true
|
||||
},
|
||||
setManyDeleted (state, condition) {
|
||||
Object.values(state.allStatusesObject).forEach(status => {
|
||||
|
@ -521,6 +521,9 @@ export const mutations = {
|
|||
dismissNotification (state, { id }) {
|
||||
state.notifications.data = state.notifications.data.filter(n => n.id !== id)
|
||||
},
|
||||
dismissNotifications (state, { finder }) {
|
||||
state.notifications.data = state.notifications.data.filter(n => finder)
|
||||
},
|
||||
updateNotification (state, { id, updater }) {
|
||||
const notification = find(state.notifications.data, n => n.id === id)
|
||||
notification && updater(notification)
|
||||
|
|
|
@ -498,6 +498,7 @@ const users = {
|
|||
store.dispatch('stopFetchingFollowRequests')
|
||||
store.commit('clearNotifications')
|
||||
store.commit('resetStatuses')
|
||||
store.dispatch('resetChats')
|
||||
})
|
||||
},
|
||||
loginUser (store, accessToken) {
|
||||
|
@ -537,6 +538,9 @@ const users = {
|
|||
|
||||
// Start fetching notifications
|
||||
store.dispatch('startFetchingNotifications')
|
||||
|
||||
// Start fetching chats
|
||||
store.dispatch('startFetchingChats')
|
||||
}
|
||||
|
||||
if (store.getters.mergedConfig.useStreamingApi) {
|
||||
|
@ -544,6 +548,7 @@ const users = {
|
|||
console.error('Failed initializing MastoAPI Streaming socket', error)
|
||||
startPolling()
|
||||
}).then(() => {
|
||||
store.dispatch('fetchChats', { latest: true })
|
||||
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
|
||||
})
|
||||
} else {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { each, map, concat, last, get } from 'lodash'
|
||||
import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
|
||||
import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
|
||||
import { RegistrationError, StatusCodeError } from '../errors/errors'
|
||||
|
||||
/* eslint-env browser */
|
||||
|
@ -81,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
|
|||
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
|
||||
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
|
||||
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
|
||||
const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
|
||||
const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
|
||||
const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
|
||||
const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
|
||||
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
|
||||
|
||||
const oldfetch = window.fetch
|
||||
|
||||
|
@ -1067,6 +1072,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
|
|||
'filters_changed'
|
||||
])
|
||||
|
||||
const PLEROMA_STREAMING_EVENTS = new Set([
|
||||
'pleroma:chat_update'
|
||||
])
|
||||
|
||||
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
|
||||
// Uses EventTarget and a CustomEvent to proxy events
|
||||
export const ProcessedWS = ({
|
||||
|
@ -1123,7 +1132,7 @@ export const handleMastoWS = (wsEvent) => {
|
|||
if (!data) return
|
||||
const parsedEvent = JSON.parse(data)
|
||||
const { event, payload } = parsedEvent
|
||||
if (MASTODON_STREAMING_EVENTS.has(event)) {
|
||||
if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
|
||||
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
|
||||
if (event === 'delete') {
|
||||
return { event, id: payload }
|
||||
|
@ -1133,6 +1142,8 @@ export const handleMastoWS = (wsEvent) => {
|
|||
return { event, status: parseStatus(data) }
|
||||
} else if (event === 'notification') {
|
||||
return { event, notification: parseNotification(data) }
|
||||
} else if (event === 'pleroma:chat_update') {
|
||||
return { event, chatUpdate: parseChat(data) }
|
||||
}
|
||||
} else {
|
||||
console.warn('Unknown event', wsEvent)
|
||||
|
@ -1140,6 +1151,81 @@ export const handleMastoWS = (wsEvent) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const WSConnectionStatus = Object.freeze({
|
||||
'JOINED': 1,
|
||||
'CLOSED': 2,
|
||||
'ERROR': 3
|
||||
})
|
||||
|
||||
const chats = ({ credentials }) => {
|
||||
return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
|
||||
.then((data) => data.json())
|
||||
.then((data) => {
|
||||
return { chats: data.map(parseChat).filter(c => c) }
|
||||
})
|
||||
}
|
||||
|
||||
const getOrCreateChat = ({ accountId, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_URL(accountId),
|
||||
method: 'POST',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
|
||||
let url = PLEROMA_CHAT_MESSAGES_URL(id)
|
||||
const args = [
|
||||
maxId && `max_id=${maxId}`,
|
||||
sinceId && `since_id=${sinceId}`,
|
||||
limit && `limit=${limit}`
|
||||
].filter(_ => _).join('&')
|
||||
|
||||
url = url + (args ? '?' + args : '')
|
||||
|
||||
return promisedRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
|
||||
const payload = {
|
||||
'content': content
|
||||
}
|
||||
|
||||
if (mediaId) {
|
||||
payload['media_id'] = mediaId
|
||||
}
|
||||
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_MESSAGES_URL(id),
|
||||
method: 'POST',
|
||||
payload: payload,
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const readChat = ({ id, lastReadId, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_READ_URL(id),
|
||||
method: 'POST',
|
||||
payload: {
|
||||
'last_read_id': lastReadId
|
||||
},
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const deleteChatMessage = ({ chatId, messageId, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
|
||||
method: 'DELETE',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const apiService = {
|
||||
verifyCredentials,
|
||||
fetchTimeline,
|
||||
|
@ -1218,7 +1304,13 @@ const apiService = {
|
|||
fetchKnownDomains,
|
||||
fetchDomainMutes,
|
||||
muteDomain,
|
||||
unmuteDomain
|
||||
unmuteDomain,
|
||||
chats,
|
||||
getOrCreateChat,
|
||||
chatMessages,
|
||||
sendChatMessage,
|
||||
readChat,
|
||||
deleteChatMessage
|
||||
}
|
||||
|
||||
export default apiService
|
||||
|
|
151
src/services/chat_service/chat_service.js
Normal file
151
src/services/chat_service/chat_service.js
Normal file
|
@ -0,0 +1,151 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
const empty = (chatId) => {
|
||||
return {
|
||||
idIndex: {},
|
||||
messages: [],
|
||||
newMessageCount: 0,
|
||||
lastSeenTimestamp: 0,
|
||||
chatId: chatId,
|
||||
minId: undefined,
|
||||
lastMessage: undefined
|
||||
}
|
||||
}
|
||||
|
||||
const clear = (storage) => {
|
||||
storage.idIndex = {}
|
||||
storage.messages.splice(0, storage.messages.length)
|
||||
storage.newMessageCount = 0
|
||||
storage.lastSeenTimestamp = 0
|
||||
storage.minId = undefined
|
||||
storage.lastMessage = undefined
|
||||
}
|
||||
|
||||
const deleteMessage = (storage, messageId) => {
|
||||
if (!storage) { return }
|
||||
storage.messages = storage.messages.filter(m => m.id !== messageId)
|
||||
delete storage.idIndex[messageId]
|
||||
|
||||
if (storage.lastMessage && (storage.lastMessage.id === messageId)) {
|
||||
storage.lastMessage = _.maxBy(storage.messages, 'id')
|
||||
}
|
||||
|
||||
if (storage.minId === messageId) {
|
||||
const firstMessage = _.minBy(storage.messages, 'id')
|
||||
storage.minId = firstMessage.id
|
||||
}
|
||||
}
|
||||
|
||||
const add = (storage, { messages: newMessages }) => {
|
||||
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 (!storage.minId || message.id < storage.minId) {
|
||||
storage.minId = message.id
|
||||
}
|
||||
|
||||
if (!storage.lastMessage || message.id > storage.lastMessage.id) {
|
||||
storage.lastMessage = message
|
||||
}
|
||||
|
||||
if (!storage.idIndex[message.id]) {
|
||||
if (storage.lastSeenTimestamp < message.created_at) {
|
||||
storage.newMessageCount++
|
||||
}
|
||||
storage.messages.push(message)
|
||||
storage.idIndex[message.id] = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetNewMessageCount = (storage) => {
|
||||
if (!storage) { return }
|
||||
storage.newMessageCount = 0
|
||||
storage.lastSeenTimestamp = new Date()
|
||||
}
|
||||
|
||||
// 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 = _.sortBy(storage.messages, ['id', 'desc'])
|
||||
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,
|
||||
resetNewMessageCount,
|
||||
clear
|
||||
}
|
||||
|
||||
export default ChatService
|
|
@ -183,6 +183,7 @@ export const parseUser = (data) => {
|
|||
output.deactivated = data.pleroma.deactivated
|
||||
|
||||
output.notification_settings = data.pleroma.notification_settings
|
||||
output.unread_chat_count = data.pleroma.unread_chat_count
|
||||
}
|
||||
|
||||
output.tags = output.tags || []
|
||||
|
@ -372,7 +373,7 @@ export const parseNotification = (data) => {
|
|||
? parseStatus(data.notice.favorited_status)
|
||||
: parsedNotice
|
||||
output.action = parsedNotice
|
||||
output.from_profile = parseUser(data.from_profile)
|
||||
output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
|
||||
}
|
||||
|
||||
output.created_at = new Date(data.created_at)
|
||||
|
@ -398,3 +399,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
|
|||
minId: flakeId ? minId : parseInt(minId, 10)
|
||||
}
|
||||
}
|
||||
|
||||
export const parseChat = (chat) => {
|
||||
const output = {}
|
||||
output.id = chat.id
|
||||
output.account = parseUser(chat.account)
|
||||
output.unread = chat.unread
|
||||
output.lastMessage = parseChatMessage(chat.last_message)
|
||||
output.updated_at = new Date(chat.updated_at)
|
||||
return output
|
||||
}
|
||||
|
||||
export const parseChatMessage = (message) => {
|
||||
if (!message) { return }
|
||||
if (message.isNormalized) { return message }
|
||||
const output = message
|
||||
output.id = message.id
|
||||
output.created_at = new Date(message.created_at)
|
||||
output.chat_id = message.chat_id
|
||||
if (message.content) {
|
||||
output.content = addEmojis(message.content, message.emojis)
|
||||
} else {
|
||||
output.content = ''
|
||||
}
|
||||
if (message.attachment) {
|
||||
output.attachments = [parseAttachment(message.attachment)]
|
||||
} else {
|
||||
output.attachments = []
|
||||
}
|
||||
output.isNormalized = true
|
||||
return output
|
||||
}
|
||||
|
|
|
@ -106,7 +106,8 @@ export const generateRadii = (input) => {
|
|||
avatar: 5,
|
||||
avatarAlt: 50,
|
||||
tooltip: 2,
|
||||
attachment: 5
|
||||
attachment: 5,
|
||||
chatMessage: inputRadii.panel
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
@ -23,7 +23,9 @@ export const LAYERS = {
|
|||
inputTopBar: 'topBar',
|
||||
alert: 'bg',
|
||||
alertPanel: 'panel',
|
||||
poll: 'bg'
|
||||
poll: 'bg',
|
||||
chatBg: 'underlay',
|
||||
chatMessage: 'chatBg'
|
||||
}
|
||||
|
||||
/* By default opacity slots have 1 as default opacity
|
||||
|
@ -667,5 +669,54 @@ export const SLOT_INHERITANCE = {
|
|||
layer: 'badge',
|
||||
variant: 'badgeNotification',
|
||||
textColor: 'bw'
|
||||
},
|
||||
|
||||
chatBg: {
|
||||
depends: ['bg']
|
||||
},
|
||||
|
||||
chatMessage: {
|
||||
depends: ['chatBg']
|
||||
},
|
||||
|
||||
chatMessageIncomingBg: {
|
||||
depends: ['chatMessage'],
|
||||
layer: 'chatMessage'
|
||||
},
|
||||
|
||||
chatMessageIncomingText: {
|
||||
depends: ['text'],
|
||||
layer: 'text'
|
||||
},
|
||||
|
||||
chatMessageIncomingLink: {
|
||||
depends: ['link'],
|
||||
layer: 'link'
|
||||
},
|
||||
|
||||
chatMessageIncomingBorder: {
|
||||
depends: ['border'],
|
||||
opacity: 'border',
|
||||
color: (mod, border) => brightness(2 * mod, border).rgb
|
||||
},
|
||||
|
||||
chatMessageOutgoingBg: {
|
||||
depends: ['chatMessage'],
|
||||
color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb
|
||||
},
|
||||
|
||||
chatMessageOutgoingText: {
|
||||
depends: ['text'],
|
||||
layer: 'text'
|
||||
},
|
||||
|
||||
chatMessageOutgoingLink: {
|
||||
depends: ['link'],
|
||||
layer: 'link'
|
||||
},
|
||||
|
||||
chatMessageOutgoingBorder: {
|
||||
depends: ['chatMessage'],
|
||||
opacity: 'chatMessage'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,3 +3,8 @@ export const windowWidth = () =>
|
|||
window.innerWidth ||
|
||||
document.documentElement.clientWidth ||
|
||||
document.body.clientWidth
|
||||
|
||||
export const windowHeight = () =>
|
||||
window.innerHeight ||
|
||||
document.documentElement.clientHeight ||
|
||||
document.body.clientHeight
|
||||
|
|
|
@ -399,6 +399,12 @@
|
|||
"css": "doc",
|
||||
"code": 59433,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "98d9c83c1ee7c2c25af784b518c522c5",
|
||||
"css": "block",
|
||||
"code": 59434,
|
||||
"src": "fontawesome"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,14 +1,22 @@
|
|||
import Vuex from 'vuex'
|
||||
import routes from 'src/boot/routes'
|
||||
import { createLocalVue } from '@vue/test-utils'
|
||||
import VueRouter from 'vue-router'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
localVue.use(Vuex)
|
||||
localVue.use(VueRouter)
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
instance: {}
|
||||
}
|
||||
})
|
||||
|
||||
describe('routes', () => {
|
||||
const router = new VueRouter({
|
||||
mode: 'abstract',
|
||||
routes: routes({})
|
||||
routes: routes(store)
|
||||
})
|
||||
|
||||
it('root path', () => {
|
||||
|
|
89
test/unit/specs/services/chat_service/chat_service.spec.js
Normal file
89
test/unit/specs/services/chat_service/chat_service.spec.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
import chatService from '../../../../../src/services/chat_service/chat_service.js'
|
||||
|
||||
const message1 = {
|
||||
id: '9wLkdcmQXD21Oy8lEX',
|
||||
created_at: (new Date('2020-06-22T18:45:53.000Z'))
|
||||
}
|
||||
|
||||
const message2 = {
|
||||
id: '9wLkdp6ihaOVdNj8Wu',
|
||||
account_id: '9vmRb29zLQReckr5ay',
|
||||
created_at: (new Date('2020-06-22T18:45:56.000Z'))
|
||||
}
|
||||
|
||||
const message3 = {
|
||||
id: '9wLke9zL4Dy4OZR2RM',
|
||||
account_id: '9vmRb29zLQReckr5ay',
|
||||
created_at: (new Date('2020-07-22T18:45:59.000Z'))
|
||||
}
|
||||
|
||||
// TODO: only
|
||||
describe.only('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.lastMessage.id).to.eql(message1.id)
|
||||
expect(chat.minId).to.eql(message1.id)
|
||||
expect(chat.newMessageCount).to.eql(1)
|
||||
|
||||
chatService.add(chat, { messages: [ message2 ] })
|
||||
expect(chat.lastMessage.id).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)
|
||||
|
||||
const createdAt = new Date()
|
||||
createdAt.setSeconds(createdAt.getSeconds() + 10)
|
||||
chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] })
|
||||
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.lastMessage.id).to.eql(message3.id)
|
||||
expect(chat.minId).to.eql(message1.id)
|
||||
|
||||
chatService.deleteMessage(chat, message3.id)
|
||||
expect(chat.lastMessage.id).to.eql(message2.id)
|
||||
expect(chat.minId).to.eql(message1.id)
|
||||
|
||||
chatService.deleteMessage(chat, message1.id)
|
||||
expect(chat.lastMessage.id).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'])
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Add table
Reference in a new issue