Add Chats

This commit is contained in:
eugenijm 2020-05-07 16:10:53 +03:00
parent a0ddcbdf5b
commit aa2cf51c05
69 changed files with 2794 additions and 161 deletions

View file

@ -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 UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_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 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 { export default {
name: 'app', name: 'app',
@ -45,7 +45,8 @@ export default {
window.CSS.supports('-moz-mask-size', 'contain') || window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') || window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain') window.CSS.supports('-o-mask-size', 'contain')
) ),
transitionName: 'fade'
}), }),
created () { created () {
// Load the locale from the storage // Load the locale from the storage
@ -127,10 +128,21 @@ export default {
}, },
updateMobileState () { updateMobileState () {
const mobileLayout = windowWidth() <= 800 const mobileLayout = windowWidth() <= 800
const layoutHeight = windowHeight()
const changed = mobileLayout !== this.isMobileLayout const changed = mobileLayout !== this.isMobileLayout
if (changed) { if (changed) {
this.$store.dispatch('setMobileLayout', mobileLayout) this.$store.dispatch('setMobileLayout', mobileLayout)
} }
this.$store.dispatch('setLayoutHeight', layoutHeight)
}
},
watch: {
'$route' (to, from) {
if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) {
this.transitionName = 'none'
} else {
this.transitionName = 'fade'
}
} }
} }
} }

View file

@ -56,6 +56,7 @@ body {
overflow-x: hidden; overflow-x: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
&.hidden { &.hidden {
display: none; display: none;
@ -928,3 +929,16 @@ nav {
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--panel, $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;
}

View file

@ -77,6 +77,7 @@
</div> </div>
</div> </div>
</nav> </nav>
<div class="app-bg-wrapper app-container-wrapper" />
<div <div
id="content" id="content"
class="container underlay" class="container underlay"
@ -112,7 +113,7 @@
{{ $t("login.hint") }} {{ $t("login.hint") }}
</router-link> </router-link>
</div> </div>
<transition name="fade"> <transition :name="transitionName">
<router-view /> <router-view />
</transition> </transition>
</div> </div>

View file

@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px; $fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 10px; $fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 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; $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;

View file

@ -230,6 +230,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) 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: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) 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: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })

View file

@ -6,6 +6,8 @@ import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue
import ConversationPage from 'components/conversation-page/conversation-page.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue' import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.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 UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue' import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue' import Registration from 'components/registration/registration.vue'
@ -28,7 +30,7 @@ export default (store) => {
} }
} }
return [ let routes = [
{ name: 'root', { name: 'root',
path: '/', path: '/',
redirect: _to => { redirect: _to => {
@ -62,11 +64,20 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm }, { 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: '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: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile } { 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
} }

View file

@ -1,3 +1,4 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
@ -27,7 +28,18 @@ const AccountActions = {
}, },
reportUser () { reportUser () {
this.$store.dispatch('openUserReportingModal', this.user.id) 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
})
} }
} }

View file

@ -50,6 +50,13 @@
> >
{{ $t('user_card.report') }} {{ $t('user_card.report') }}
</button> </button>
<button
v-if="pleromaChatMessagesAvailable"
class="btn btn-default btn-block dropdown-item"
@click="openChat"
>
{{ $t('user_card.message') }}
</button>
</div> </div>
</div> </div>
<div <div

304
src/components/chat/chat.js Normal file
View file

@ -0,0 +1,304 @@
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 ChatAvatar from '../chat_avatar/chat_avatar.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 ChatLayout from './chat_layout.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 Chat = {
components: {
ChatMessage,
ChatTitle,
ChatAvatar,
PostStatusForm
},
mixins: [ChatLayout],
data () {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
scrollPositionBeforeResize: {},
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.updateScrollableContainerHeight()
})
},
handleVisibilityChange () {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
handleLayoutChange () {
this.updateScrollableContainerHeight()
if (this.mobileLayout) {
this.setMobileChatLayout()
} else {
this.unsetMobileChatLayout()
}
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) {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
const { offsetHeight = undefined } = this.scrollPositionBeforeResize
this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
if (diff < 0 || (!this.bottomedOut() && opts && opts.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.updateScrollableContainerHeight()
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

View file

@ -0,0 +1,161 @@
.chat-view {
display: flex;
height: calc(100vh - 60px);
width: 100%;
.chat-view-inner {
height: auto;
width: 100%;
overflow: visible;
display: flex;
margin-top: 0.5em;
margin-left: 0.5em;
margin-right: 0.5em;
}
.chat-view-body {
background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
border-radius: none;
min-height: 100%;
margin-left: 0;
margin-right: 0;
margin-bottom: 0em;
margin-top: 0em;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&::after {
border-radius: none;
box-shadow: none;
}
}
.scrollable-message-list {
padding: 0 10px;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.footer {
position: sticky;
bottom: 0px;
}
.chat-view-heading {
align-items: center;
justify-content: space-between;
top: 50px;
display: flex;
z-index: 2;
border-radius: none;
position: sticky;
display: flex;
overflow: hidden;
}
.go-back-button {
margin-right: 1.2em;
cursor: pointer;
}
.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 !important;
}
.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;
}
}
}

View file

@ -0,0 +1,99 @@
<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"
:request="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>

View file

@ -0,0 +1,100 @@
const ChatLayout = {
methods: {
setChatLayout () {
if (this.mobileLayout) {
this.setMobileChatLayout()
}
},
unsetChatLayout () {
this.unsetMobileChatLayout()
},
setMobileChatLayout () {
// 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.style.overflow = 'hidden'
html.style.height = '100%'
}
let body = document.querySelector('body')
if (body) {
body.style.height = '100%'
}
let app = document.getElementById('app')
if (app) {
app.style.height = '100%'
app.style.overflow = 'hidden'
app.style.minHeight = 'auto'
}
let appBgWrapper = window.document.getElementById('app_bg_wrapper')
if (appBgWrapper) {
appBgWrapper.style.overflow = 'hidden'
}
let main = document.getElementsByClassName('main')[0]
if (main) {
main.style.overflow = 'hidden'
main.style.height = '100%'
}
let content = document.getElementById('content')
if (content) {
content.style.paddingTop = '0'
content.style.height = '100%'
content.style.overflow = 'visible'
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
})
},
unsetMobileChatLayout () {
let html = document.querySelector('html')
if (html) {
html.style.overflow = 'visible'
html.style.height = 'unset'
}
let body = document.querySelector('body')
if (body) {
body.style.height = 'unset'
}
let app = document.getElementById('app')
if (app) {
app.style.height = '100%'
app.style.overflow = 'visible'
app.style.minHeight = '100vh'
}
let appBgWrapper = document.getElementById('app_bg_wrapper')
if (appBgWrapper) {
appBgWrapper.style.overflow = 'visible'
}
let main = document.getElementsByClassName('main')[0]
if (main) {
main.style.overflow = 'visible'
main.style.height = 'unset'
}
let content = document.getElementById('content')
if (content) {
content.style.paddingTop = '60px'
content.style.height = 'unset'
content.style.overflow = 'unset'
}
}
}
}
export default ChatLayout

View file

@ -0,0 +1,27 @@
// 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) => {
const height = parseFloat(getComputedStyle(inner, null).height.replace('px', ''))
return height - header.clientHeight - footer.clientHeight
}

View file

@ -0,0 +1,23 @@
import StillImage from '../still-image/still-image.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapState } from 'vuex'
const ChatAvatar = {
props: ['user', 'width', 'height'],
components: {
StillImage
},
methods: {
getUserProfileLink (user) {
if (!user) { return }
return generateProfileLink(user.id, user.screen_name)
}
},
computed: {
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter
})
}
}
export default ChatAvatar

View file

@ -0,0 +1,53 @@
<template>
<router-link
:to="getUserProfileLink(user) || ''"
>
<StillImage
v-if="user"
:style="{ 'width': width, 'height': height }"
class="avatar chat-avatar single-user"
:alt="user.screen_name"
:title="user.screen_name"
:src="user.profile_image_url_original"
error-src="/images/avi.png"
:class="{ 'better-shadow': betterShadow }"
/>
<div
v-else
class="avatar chat-avatar single-user"
:style="{ 'width': width, 'height': height }"
/>
</router-link>
</template>
<script src="./chat_avatar.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-avatar {
display: inline-block;
vertical-align: middle;
&.single-user {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.avatar.still-image {
width: 48px;
height: 48px;
box-shadow: var(--avatarStatusShadow);
border-radius: 0;
&.better-shadow {
box-shadow: var(--avatarStatusShadowInset);
filter: var(--avatarStatusShadowFilter)
}
&.animated::before {
display: none;
}
}
}
</style>

View 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

View file

@ -0,0 +1,48 @@
<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 class="timeline">
<List :items="sortedChatList">
<template
slot="item"
slot-scope="{item}"
>
<ChatListItem
:key="item.id"
:compact="false"
:chat="item"
/>
</template>
</List>
</div>
</div>
</div>
</template>
<script src="./chat_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-list {
min-height: calc(100vh - 67px);
margin-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View 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 ChatAvatar from '../chat_avatar/chat_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: {
ChatAvatar,
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

View file

@ -0,0 +1,94 @@
.chat-list-item {
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
display: flex;
flex-direction: row;
padding: 0.75em;
height: 4.85em;
overflow: hidden;
box-sizing: border-box;
cursor: pointer;
:focus {
outline: none;
}
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
box-shadow: 0 0px 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;
}
.member-count {
color: $fallback--text;
color: var(--faintText, $fallback--text);
margin-right: 2px;
}
.name-and-account-name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-shrink: 1;
}
.chat-preview {
display: inline-flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0.35rem 0;
height: 1.2em;
line-height: 1.2em;
color: $fallback--text;
color: var(--faint, $fallback--text);
}
a {
color: var(--faintLink, $fallback--link);
text-decoration: none;
pointer-events: none;
}
.unread-indicator-wrapper {
display: flex;
align-items: center;
margin-left: 10px;
}
.unread-indicator {
border-radius: 100%;
height: 8px;
width: 8px;
background-color: $fallback--link;
background-color: var(--link, $fallback--link);
}
}

View file

@ -0,0 +1,49 @@
<template>
<div
class="chat-list-item"
@click.capture.prevent="openChat"
>
<div class="chat-list-item-left">
<ChatAvatar
: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" />
<div
v-if="chat.unread > 0"
class="badge badge-notification unread-chat-count"
>
{{ chat.unread }}
</div>
</div>
</div>
<div>
<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>

View file

@ -0,0 +1,109 @@
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
}),
ellipsisButtonWrapperStyle () {
let res = {
'opacity': this.hovered || this.menuOpened ? '1' : '0'
}
if (this.isCurrentUser) {
res.right = '5px'
} else {
res.left = '5px'
}
return res
},
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

View file

@ -0,0 +1,157 @@
@import '../../_variables.scss';
.chat-message-wrapper {
&.hovered-message-chain {
.animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
}
&:last-child {
margin-bottom: 16px;
}
.chat-message-menu {
transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -10px;
button {
padding-top: 3px;
padding-bottom: 3px;
}
}
.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: 12rem;
}
.chat-message {
display: flex;
padding-bottom: 7px;
}
.avatar-wrapper {
margin-right: 10px;
width: 32px;
}
.link-preview, .attachments {
margin-bottom: 0.9em;
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10rem;
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 {
float: right;
font-size: 0.8em;
margin: -10px 0 -5px 4px;
font-style: italic;
opacity: 0.8;
}
.without-attachment {
.status-content {
white-space: normal;
&::after {
margin-right: 75px;
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);
}
}
}
.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-date-separator {
text-align: center;
margin: 1.4em 0;
font-size: 0.9em;
user-select: none;
color: $fallback--text;
color: var(--faintedText, $fallback--text);
}

View 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"
:style="ellipsisButtonWrapperStyle"
>
<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>

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

View file

@ -0,0 +1,74 @@
import { throttle } from 'lodash'
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: throttle(function (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

View 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.67rem;
}
.basic-user-card:hover {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
cursor: pointer;
}
}

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

View file

@ -84,30 +84,31 @@
max-width: 25em; max-width: 25em;
} }
.chat-heading { .chat-panel {
.chat-heading {
cursor: pointer; cursor: pointer;
.icon-comment-empty { .icon-comment-empty {
color: $fallback--text; color: $fallback--text;
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
.chat-window { .chat-window {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
max-height: 20em; max-height: 20em;
} }
.chat-window-container { .chat-window-container {
height: 100%; height: 100%;
} }
.chat-message { .chat-message {
display: flex; display: flex;
padding: 0.2em 0.5em padding: 0.2em 0.5em
} }
.chat-avatar { .chat-avatar {
img { img {
height: 24px; height: 24px;
width: 24px; width: 24px;
@ -116,9 +117,9 @@
margin-right: 0.5em; margin-right: 0.5em;
margin-top: 0.25em; margin-top: 0.25em;
} }
} }
.chat-input { .chat-input {
display: flex; display: flex;
textarea { textarea {
flex: 1; flex: 1;
@ -126,12 +127,13 @@
min-height: 3.5em; min-height: 3.5em;
resize: none; resize: none;
} }
} }
.chat-panel { .chat-panel {
.title { .title {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
}
} }
</style> </style>

View file

@ -0,0 +1,20 @@
import Vue from 'vue'
import ChatAvatar from '../chat_avatar/chat_avatar.vue'
export default Vue.component('chat-title', {
name: 'ChatTitle',
components: {
ChatAvatar
},
props: [
'user', 'withAvatar'
],
computed: {
title () {
return this.user ? this.user.screen_name : ''
},
htmlTitle () {
return this.user ? this.user.name_html : ''
}
}
})

View file

@ -0,0 +1,56 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
class="chat-title"
:title="title"
>
<ChatAvatar
v-if="withAvatar"
:user="user"
width="23px"
height="23px"
/>
<span
v-if="withAvatar"
style="margin-right: 0.5em"
/>
<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;
a {
display: flex;
align-items: center;
}
.username {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
}
</style>

View file

@ -79,6 +79,15 @@ const EmojiInput = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false 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'
} }
}, },
data () { data () {
@ -162,6 +171,11 @@ const EmojiInput = {
input.elm.removeEventListener('input', this.onInput) input.elm.removeEventListener('input', this.onInput)
} }
}, },
watch: {
showSuggestions: function (newValue) {
this.$emit('shown', newValue)
}
},
methods: { methods: {
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true this.showPicker = true
@ -425,15 +439,29 @@ const EmojiInput = {
this.caret = selectionStart this.caret = selectionStart
}, },
resize () { resize () {
const { panel, picker } = this.$refs const panel = this.$refs.panel
if (!panel) return if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input.elm const { offsetHeight, offsetTop } = this.input.elm
const offsetBottom = offsetTop + offsetHeight const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px' this.setPlacement(panelBody, panel, offsetBottom)
if (!picker) return this.setPlacement(picker, picker, offsetBottom)
picker.$el.style.top = offsetBottom + 'px' },
picker.$el.style.bottom = 'auto' 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
} }
} }
} }

View file

@ -29,7 +29,10 @@
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showSuggestions }" :class="{ hide: !showSuggestions }"
> >
<div class="autocomplete-panel-body"> <div
ref="panel-body"
class="autocomplete-panel-body"
>
<div <div
v-for="(suggestion, index) in suggestions" v-for="(suggestion, index) in suggestions"
:key="index" :key="index"

View file

@ -1,6 +1,7 @@
const FeaturesPanel = { const FeaturesPanel = {
computed: { computed: {
chat: function () { return this.$store.state.instance.chatAvailable }, chat: function () { return this.$store.state.instance.chatAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },

View file

@ -11,6 +11,9 @@
<li v-if="chat"> <li v-if="chat">
{{ $t('features_panel.chat') }} {{ $t('features_panel.chat') }}
</li> </li>
<li v-if="pleromaChatMessages">
{{ $t('features_panel.pleroma_chat_messages') }}
</li>
<li v-if="gopher"> <li v-if="gopher">
{{ $t('features_panel.gopher') }} {{ $t('features_panel.gopher') }}
</li> </li>

View file

@ -61,7 +61,8 @@ const mediaUpload = {
} }
}, },
props: [ props: [
'dropFiles' 'dropFiles',
'disabled'
], ],
watch: { watch: {
'dropFiles': function (fileInfos) { 'dropFiles': function (fileInfos) {

View file

@ -1,5 +1,8 @@
<template> <template>
<div class="media-upload"> <div
class="media-upload"
:class="{ disabled: disabled }"
>
<label <label
class="label" class="label"
:title="$t('tool_tip.media_upload')" :title="$t('tool_tip.media_upload')"
@ -14,6 +17,7 @@
/> />
<input <input
v-if="uploadReady" v-if="uploadReady"
:disabled="disabled"
type="file" type="file"
style="position: fixed; top: -100em" style="position: fixed; top: -100em"
multiple="true" multiple="true"
@ -26,7 +30,22 @@
<script src="./media_upload.js" ></script> <script src="./media_upload.js" ></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss';
.media-upload { .media-upload {
&.disabled {
.new-icon {
cursor: not-allowed;
}
&:hover {
i, label {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
}
.label { .label {
display: inline-block; display: inline-block;
} }

View file

@ -30,7 +30,10 @@ const MobileNav = {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
hideSitename () { return this.$store.state.instance.hideSitename }, hideSitename () { return this.$store.state.instance.hideSitename },
sitename () { return this.$store.state.instance.name } sitename () { return this.$store.state.instance.name },
navBarStyle () {
return { 'visibility': this.$route.name === 'chat' ? 'hidden' : 'visible' }
}
}, },
methods: { methods: {
toggleMobileSidebar () { toggleMobileSidebar () {

View file

@ -3,6 +3,7 @@
<nav <nav
id="nav" id="nav"
class="nav-bar container" class="nav-bar container"
:style="navBarStyle"
> >
<div <div
class="mobile-inner-nav" class="mobile-inner-nav"

View file

@ -1,5 +1,10 @@
import { debounce } from 'lodash' import { debounce } from 'lodash'
const HIDDEN_FOR_PAGES = new Set([
'chats',
'chat'
])
const MobilePostStatusButton = { const MobilePostStatusButton = {
data () { data () {
return { return {
@ -27,6 +32,8 @@ const MobilePostStatusButton = {
return !!this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
}, },
isHidden () { isHidden () {
if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
return this.autohideFloatingPostButton && (this.hidden || this.inputActive) return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
}, },
autohideFloatingPostButton () { autohideFloatingPostButton () {

View file

@ -1,4 +1,4 @@
import { mapState } from 'vuex' import { mapState, mapGetters } from 'vuex'
const NavPanel = { const NavPanel = {
created () { created () {
@ -6,13 +6,17 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests') this.$store.dispatch('startFetchingFollowRequests')
} }
}, },
computed: mapState({ computed: {
...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
chat: state => state.chat.channel, chat: state => state.chat.channel,
followRequestCount: state => state.api.followRequests.length, followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating federating: state => state.instance.federating,
}) pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
...mapGetters(['unreadChatCount'])
}
} }
export default NavPanel export default NavPanel

View file

@ -22,6 +22,17 @@
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }} <i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link> </router-link>
</li> </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"> <li v-if="currentUser && currentUser.locked">
<router-link :to="{ name: 'friend-requests' }"> <router-link :to="{ name: 'friend-requests' }">
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }} <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}

View file

@ -1,4 +1,5 @@
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import { mapState } from 'vuex'
import Status from '../status/status.vue' import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
@ -81,7 +82,10 @@ const Notification = {
}, },
isStatusNotification () { isStatusNotification () {
return isStatusNotification(this.notification.type) return isStatusNotification(this.notification.type)
} },
...mapState({
currentUser: state => state.users.currentUser
})
} }
} }

View file

@ -1,3 +1,4 @@
import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue' import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import { import {
@ -51,18 +52,22 @@ const Notifications = {
unseenCount () { unseenCount () {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
unseenCountTitle () {
return this.unseenCount + (this.unreadChatCount)
},
loading () { loading () {
return this.$store.state.statuses.notifications.loading return this.$store.state.statuses.notifications.loading
}, },
notificationsToDisplay () { notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
} },
...mapGetters(['unreadChatCount'])
}, },
components: { components: {
Notification Notification
}, },
watch: { watch: {
unseenCount (count) { unseenCountTitle (count) {
if (count > 0) { if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`) this.$store.dispatch('setPageTitle', `(${count})`)
} else { } else {

View file

@ -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 { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy, debounce } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters } from 'vuex' import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
const buildMentionsString = ({ user, attentions = [] }, currentUser) => { const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@ -33,7 +33,22 @@ const PostStatusForm = {
'repliedUser', 'repliedUser',
'attentions', 'attentions',
'copyMessageScope', 'copyMessageScope',
'subject' 'subject',
'disableSubject',
'disableScopeSelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
'placeholder',
'maxHeight',
'request',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement'
], ],
components: { components: {
MediaUpload, MediaUpload,
@ -46,10 +61,13 @@ const PostStatusForm = {
}, },
mounted () { mounted () {
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) { 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() this.$refs.textarea.focus()
} }
}, },
@ -72,7 +90,7 @@ const PostStatusForm = {
return { return {
dropFiles: [], dropFiles: [],
submitDisabled: false, uploadingFiles: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0, highlighted: 0,
@ -91,7 +109,8 @@ const PostStatusForm = {
showDropIcon: 'hide', showDropIcon: 'hide',
dropStopTimeout: null, dropStopTimeout: null,
preview: null, preview: null,
previewLoading: false previewLoading: false,
emojiInputShown: false
} }
}, },
computed: { computed: {
@ -160,10 +179,11 @@ const PostStatusForm = {
}, },
pollsAvailable () { pollsAvailable () {
return this.$store.state.instance.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 () { hideScopeNotice () {
return this.$store.getters.mergedConfig.hideScopeNotice return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
}, },
pollContentError () { pollContentError () {
return this.pollFormVisible && return this.pollFormVisible &&
@ -176,7 +196,13 @@ const PostStatusForm = {
emptyStatus () { emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0 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: { watch: {
'newStatus.contentType': function () { 'newStatus.contentType': function () {
@ -187,9 +213,19 @@ const PostStatusForm = {
} }
}, },
methods: { methods: {
async postStatus (newStatus) { async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return } if (this.posting) { return }
if (this.submitDisabled) { return } if (this.submitDisabled) { return }
if (this.emojiInputShown) { return }
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (opts.control && this.submitOnEnter) {
newStatus.status = `${newStatus.status}\n`
return
}
if (this.emptyStatus) { if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error') this.error = this.$t('post_status.empty_status_error')
return return
@ -211,7 +247,7 @@ const PostStatusForm = {
return return
} }
const data = await statusPoster.postStatus({ const postingOptions = {
status: newStatus.status, status: newStatus.status,
spoilerText: newStatus.spoilerText || null, spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility, visibility: newStatus.visibility,
@ -221,8 +257,11 @@ const PostStatusForm = {
inReplyToStatusId: this.replyTo, inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll poll
}) }
const request = this.request ? this.request : statusPoster.postStatus
request(postingOptions).then((data) => {
if (!data.error) { if (!data.error) {
this.newStatus = { this.newStatus = {
status: '', status: '',
@ -234,9 +273,14 @@ const PostStatusForm = {
mediaDescriptions: {} mediaDescriptions: {}
} }
this.pollFormVisible = false this.pollFormVisible = false
this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm() this.clearPollForm()
this.$emit('posted') this.$emit('posted', data)
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
let el = this.$el.querySelector('textarea') let el = this.$el.querySelector('textarea')
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = undefined el.style.height = undefined
@ -245,8 +289,8 @@ const PostStatusForm = {
} else { } else {
this.error = data.error this.error = data.error
} }
this.posting = false this.posting = false
})
}, },
previewStatus () { previewStatus () {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') { if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
@ -301,20 +345,26 @@ const PostStatusForm = {
}, },
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
// TODO: use fixed dimensions instead so relying on timeout
setTimeout(() => {
this.$emit('resize')
}, 150)
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1) this.newStatus.files.splice(index, 1)
this.$emit('resize')
}, },
uploadFailed (errString, templateArgs) { uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {} templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
}, },
disableSubmit () { startedUploadingFiles () {
this.submitDisabled = true this.uploadingFiles = true
}, },
enableSubmit () { finishedUploadingFiles () {
this.submitDisabled = false this.uploadingFiles = false
}, },
type (fileInfo) { type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype) return fileTypeService.fileType(fileInfo.mimetype)
@ -348,7 +398,7 @@ const PostStatusForm = {
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500) this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
}, },
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout) clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show' this.showDropIcon = 'show'
@ -367,6 +417,7 @@ const PostStatusForm = {
// Reset to default height for empty form, nothing else to do here. // Reset to default height for empty form, nothing else to do here.
if (target.value === '') { if (target.value === '') {
target.style.height = null target.style.height = null
this.$emit('resize', null)
this.$refs['emoji-input'].resize() this.$refs['emoji-input'].resize()
return return
} }
@ -419,8 +470,10 @@ const PostStatusForm = {
// BEGIN content size update // BEGIN content size update
target.style.height = 'auto' 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` target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update // END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset // We check where the bottom border of form-bottom element is, this uses findOffset
@ -480,6 +533,9 @@ const PostStatusForm = {
setAllMediaDescriptions () { setAllMediaDescriptions () {
const ids = this.newStatus.files.map(file => file.id) const ids = this.newStatus.files.map(file => file.id)
return Promise.all(ids.map(id => this.setMediaDescription(id))) return Promise.all(ids.map(id => this.setMediaDescription(id)))
},
handleEmojiInputShow (value) {
this.emojiInputShown = value
} }
} }
} }

View file

@ -5,19 +5,20 @@
> >
<form <form
autocomplete="off" autocomplete="off"
@submit.prevent="postStatus(newStatus)" @submit.prevent
@dragover.prevent="fileDrag" @dragover.prevent="fileDrag"
> >
<div <div
v-show="showDropIcon !== 'hide'" v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator icon-upload" class="drop-indicator"
:class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
@dragleave="fileDragStop" @dragleave="fileDragStop"
@drop.stop="fileDrop" @drop.stop="fileDrop"
/> />
<div class="form-group"> <div class="form-group">
<i18n <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" path="post_status.account_not_locked_warning"
tag="p" tag="p"
class="visibility-notice" class="visibility-notice"
@ -108,7 +109,7 @@
/> />
</div> </div>
<EmojiInput <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
enable-emoji-picker enable-emoji-picker
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
@ -126,6 +127,7 @@
ref="emoji-input" ref="emoji-input"
v-model="newStatus.status" v-model="newStatus.status"
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="form-control main-input" class="form-control main-input"
enable-emoji-picker enable-emoji-picker
hide-emoji-button hide-emoji-button
@ -133,16 +135,19 @@
@input="onEmojiInputInput" @input="onEmojiInputInput"
@sticker-uploaded="addMediaFile" @sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed" @sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
> >
<textarea <textarea
ref="textarea" ref="textarea"
v-model="newStatus.status" v-model="newStatus.status"
:placeholder="$t('post_status.default')" :placeholder="placeholder || $t('post_status.default')"
rows="1" rows="1"
:disabled="posting" :disabled="posting"
class="form-post-body" class="form-post-body"
@keydown.meta.enter="postStatus(newStatus)" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.ctrl.enter="postStatus(newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus, { control: true })"
@keydown.ctrl.enter="postStatus($event, newStatus)"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@ -155,7 +160,10 @@
{{ charactersLeft }} {{ charactersLeft }}
</p> </p>
</EmojiInput> </EmojiInput>
<div class="visibility-tray"> <div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector <scope-selector
:show-all="showAllScopes" :show-all="showAllScopes"
:user-default="userDefaultScope" :user-default="userDefaultScope"
@ -213,10 +221,11 @@
ref="mediaUpload" ref="mediaUpload"
class="media-upload-icon" class="media-upload-icon"
:drop-files="dropFiles" :drop-files="dropFiles"
@uploading="disableSubmit" :disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile" @uploaded="addMediaFile"
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
@all-uploaded="enableSubmit" @all-uploaded="finishedUploadingFiles"
/> />
<div <div
class="emoji-icon" class="emoji-icon"
@ -253,11 +262,13 @@
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
<!-- touchstart is used to keep the OSK at the same position after a message send -->
<button <button
v-else v-else
:disabled="submitDisabled" :disabled="uploadingFiles || disableSubmit"
type="submit"
class="btn btn-default" class="btn btn-default"
@touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
@ -297,7 +308,7 @@
</div> </div>
</div> </div>
<div <div
v-if="newStatus.files.length > 0" v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings" class="upload_settings"
> >
<Checkbox v-model="newStatus.nsfw"> <Checkbox v-model="newStatus.nsfw">
@ -331,6 +342,8 @@
} }
.post-status-form { .post-status-form {
position: relative;
.form-bottom { .form-bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -547,6 +560,10 @@
padding-bottom: 1.75em; padding-bottom: 1.75em;
min-height: 1px; min-height: 1px;
box-sizing: content-box; box-sizing: content-box;
&.scrollable-form {
overflow-y: auto;
}
} }
.main-input { .main-input {
@ -609,4 +626,11 @@
border: 2px dashed var(--text, $fallback--text); 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 {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
</style> </style>

View file

@ -99,7 +99,8 @@ export default {
avatarRadiusLocal: '', avatarRadiusLocal: '',
avatarAltRadiusLocal: '', avatarAltRadiusLocal: '',
attachmentRadiusLocal: '', attachmentRadiusLocal: '',
tooltipRadiusLocal: '' tooltipRadiusLocal: '',
chatMessageRadiusLocal: ''
} }
}, },
created () { created () {
@ -214,7 +215,8 @@ export default {
avatar: this.avatarRadiusLocal, avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal, avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal, tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal attachment: this.attachmentRadiusLocal,
chatMessage: this.chatMessageRadiusLocal
} }
}, },
preview () { preview () {

View file

@ -735,6 +735,65 @@
/> />
<ContrastRatio :contrast="previewContrast.selectedMenuLink" /> <ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div> </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>
<div <div
@ -814,6 +873,14 @@
max="50" max="50"
hard-min="0" 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>
<div <div

View file

@ -1,3 +1,4 @@
import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
@ -47,7 +48,11 @@ const SideDrawer = {
}, },
federating () { federating () {
return this.$store.state.instance.federating return this.$store.state.instance.federating
} },
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
...mapGetters(['unreadChatCount'])
}, },
methods: { methods: {
toggleDrawer () { toggleDrawer () {

View file

@ -40,12 +40,24 @@
</router-link> </router-link>
</li> </li>
<li <li
v-if="currentUser" v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</router-link> </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>
<li <li
v-if="currentUser" v-if="currentUser"
@ -103,14 +115,6 @@
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link> </router-link>
</li> </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>
<ul> <ul>
<li <li

View file

@ -18,7 +18,7 @@ const StatusContent = {
], ],
data () { data () {
return { return {
showingTall: this.inConversation && this.focused, showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false, showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later // not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject

View file

@ -76,7 +76,7 @@
/> />
</a> </a>
<a <a
v-if="showingMore" v-if="showingMore && !fullContent"
href="#" href="#"
class="status-unhider" class="status-unhider"
@click.prevent="toggleShowMore" @click.prevent="toggleShowMore"

View file

@ -12,5 +12,9 @@
.error { .error {
font-size: 14px; font-size: 14px;
} }
a {
cursor: pointer;
}
} }
} }

View file

@ -44,6 +44,7 @@
}, },
"features_panel": { "features_panel": {
"chat": "Chat", "chat": "Chat",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher", "gopher": "Gopher",
"media_proxy": "Media proxy", "media_proxy": "Media proxy",
"scope_options": "Scope options", "scope_options": "Scope options",
@ -124,7 +125,8 @@
"user_search": "User Search", "user_search": "User Search",
"search": "Search", "search": "Search",
"who_to_follow": "Who to follow", "who_to_follow": "Who to follow",
"preferences": "Preferences" "preferences": "Preferences",
"chats": "Chats"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unknown status, searching for it…", "broken_favorite": "Unknown status, searching for it…",
@ -287,6 +289,7 @@
"change_password": "Change Password", "change_password": "Change Password",
"change_password_error": "There was an issue changing your password.", "change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!", "changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects", "collapse_subject": "Collapse posts with subjects",
"composing": "Composing", "composing": "Composing",
"confirm_new_password": "Confirm new password", "confirm_new_password": "Confirm new password",
@ -518,7 +521,12 @@
"selectedMenu": "Selected menu item", "selectedMenu": "Selected menu item",
"disabled": "Disabled", "disabled": "Disabled",
"toggled": "Toggled", "toggled": "Toggled",
"tabs": "Tabs" "tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
}, },
"radii": { "radii": {
"_tab_label": "Roundness" "_tab_label": "Roundness"
@ -677,6 +685,7 @@
"its_you": "It's you!", "its_you": "It's you!",
"media": "Media", "media": "Media",
"mention": "Mention", "mention": "Mention",
"message": "Message",
"mute": "Mute", "mute": "Mute",
"muted": "Muted", "muted": "Muted",
"per_day": "per day", "per_day": "per day",
@ -775,5 +784,25 @@
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.", "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": "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." "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."
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
} }
} }

View file

@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js' import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js' import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js' import postStatusModule from './modules/postStatus.js'
import chatsModule from './modules/chats.js'
import VueI18n from 'vue-i18n' import VueI18n from 'vue-i18n'
@ -91,7 +92,8 @@ const persistedStateOptions = {
oauthTokens: oauthTokensModule, oauthTokens: oauthTokensModule,
reports: reportsModule, reports: reportsModule,
polls: pollsModule, polls: pollsModule,
postStatus: postStatusModule postStatus: postStatusModule,
chats: chatsModule
}, },
plugins, plugins,
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.

View file

@ -1,4 +1,5 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { WSConnectionStatus } from '../services/api/api.service.js'
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
const api = { const api = {
@ -7,6 +8,7 @@ const api = {
fetchers: {}, fetchers: {},
socket: null, socket: null,
mastoUserSocket: null, mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [] followRequests: []
}, },
mutations: { mutations: {
@ -28,6 +30,9 @@ const api = {
}, },
setFollowRequests (state, value) { setFollowRequests (state, value) {
state.followRequests = value state.followRequests = value
},
setMastoUserSocketStatus (state, value) {
state.mastoUserSocketStatus = value
} }
}, },
actions: { actions: {
@ -47,7 +52,7 @@ const api = {
startMastoUserSocket (store) { startMastoUserSocket (store) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const { state, dispatch, rootState } = store const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends const timelineData = rootState.statuses.timelines.friends
state.mastoUserSocket = state.backendInteractor.startUserSocket({ store }) state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
state.mastoUserSocket.addEventListener( state.mastoUserSocket.addEventListener(
@ -66,11 +71,22 @@ const api = {
showImmediately: timelineData.visibleStatuses.length === 0, showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends' 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 }) => { state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error) console.error('Error in MastoAPI websocket:', error)
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
dispatch('clearOpenedChats')
}) })
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => { state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
const ignoreCodes = new Set([ const ignoreCodes = new Set([
@ -84,8 +100,11 @@ const api = {
console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`) console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications') dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
dispatch('restartMastoUserSocket') dispatch('restartMastoUserSocket')
} }
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
dispatch('clearOpenedChats')
}) })
resolve() resolve()
} catch (e) { } catch (e) {
@ -99,12 +118,13 @@ const api = {
return dispatch('startMastoUserSocket').then(() => { return dispatch('startMastoUserSocket').then(() => {
dispatch('stopFetchingTimeline', { timeline: 'friends' }) dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications') dispatch('stopFetchingNotifications')
dispatch('stopFetchingChats')
}) })
}, },
stopMastoUserSocket ({ state, dispatch }) { stopMastoUserSocket ({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications') dispatch('startFetchingNotifications')
console.log(state.mastoUserSocket) dispatch('startFetchingChats')
state.mastoUserSocket.close() state.mastoUserSocket.close()
}, },

228
src/modules/chats.js Normal file
View file

@ -0,0 +1,228 @@
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)
},
removeFromCurrentChatStatuses ({ commit }, { id }) {
commit('removeFromCurrentChatStatuses', id)
},
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

View file

@ -46,7 +46,8 @@ export const defaultState = {
repeats: true, repeats: true,
moves: true, moves: true,
emojiReactions: false, emojiReactions: false,
followRequest: true followRequest: true,
chatMention: true
}, },
webPushNotifications: false, webPushNotifications: false,
muteWords: [], muteWords: [],

View file

@ -55,6 +55,7 @@ const defaultState = {
// Feature-set, apparently, not everything here is reported... // Feature-set, apparently, not everything here is reported...
chatAvailable: false, chatAvailable: false,
pleromaChatMessagesAvailable: false,
gopherAvailable: false, gopherAvailable: false,
mediaProxyAvailable: false, mediaProxyAvailable: false,
suggestionsEnabled: false, suggestionsEnabled: false,

View file

@ -15,7 +15,8 @@ const defaultState = {
) )
}, },
mobileLayout: false, mobileLayout: false,
globalNotices: [] globalNotices: [],
layoutHeight: 0
} }
const interfaceMod = { const interfaceMod = {
@ -65,6 +66,9 @@ const interfaceMod = {
}, },
removeGlobalNotice (state, notice) { removeGlobalNotice (state, notice) {
state.globalNotices = state.globalNotices.filter(n => n !== notice) state.globalNotices = state.globalNotices.filter(n => n !== notice)
},
setLayoutHeight (state, value) {
state.layoutHeight = value
} }
}, },
actions: { actions: {
@ -110,6 +114,9 @@ const interfaceMod = {
}, },
removeGlobalNotice ({ commit }, notice) { removeGlobalNotice ({ commit }, notice) {
commit('removeGlobalNotice', notice) commit('removeGlobalNotice', notice)
},
setLayoutHeight ({ commit }, value) {
commit('setLayoutHeight', value)
} }
} }
} }

View file

@ -478,7 +478,7 @@ export const mutations = {
}, },
setDeleted (state, { status }) { setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]
newStatus.deleted = true if (newStatus) newStatus.deleted = true
}, },
setManyDeleted (state, condition) { setManyDeleted (state, condition) {
Object.values(state.allStatusesObject).forEach(status => { Object.values(state.allStatusesObject).forEach(status => {
@ -521,6 +521,9 @@ export const mutations = {
dismissNotification (state, { id }) { dismissNotification (state, { id }) {
state.notifications.data = state.notifications.data.filter(n => n.id !== 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 }) { updateNotification (state, { id, updater }) {
const notification = find(state.notifications.data, n => n.id === id) const notification = find(state.notifications.data, n => n.id === id)
notification && updater(notification) notification && updater(notification)

View file

@ -498,6 +498,7 @@ const users = {
store.dispatch('stopFetchingFollowRequests') store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications') store.commit('clearNotifications')
store.commit('resetStatuses') store.commit('resetStatuses')
store.dispatch('resetChats')
}) })
}, },
loginUser (store, accessToken) { loginUser (store, accessToken) {
@ -537,6 +538,9 @@ const users = {
// Start fetching notifications // Start fetching notifications
store.dispatch('startFetchingNotifications') store.dispatch('startFetchingNotifications')
// Start fetching chats
store.dispatch('startFetchingChats')
} }
if (store.getters.mergedConfig.useStreamingApi) { if (store.getters.mergedConfig.useStreamingApi) {
@ -544,6 +548,7 @@ const users = {
console.error('Failed initializing MastoAPI Streaming socket', error) console.error('Failed initializing MastoAPI Streaming socket', error)
startPolling() startPolling()
}).then(() => { }).then(() => {
store.dispatch('fetchChats', { latest: true })
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
}) })
} else { } else {

View file

@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash' 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' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* 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_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_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_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 const oldfetch = window.fetch
@ -117,13 +122,18 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
} }
return fetch(url, options) return fetch(url, options)
.then((response) => { .then((response) => {
return new Promise((resolve, reject) => response.json() return new Promise((resolve, reject) => {
response.json()
.then((json) => { .then((json) => {
if (!response.ok) { if (!response.ok) {
return reject(new StatusCodeError(response.status, json, { url, options }, response)) return reject(new StatusCodeError(response.status, json, { url, options }, response))
} }
return resolve(json) return resolve(json)
})) })
.catch((error) => {
return reject(new StatusCodeError(response.status, error.message, { url, options }, response))
})
})
}) })
} }
@ -1067,6 +1077,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
'filters_changed' '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 // A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events // Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({ export const ProcessedWS = ({
@ -1123,7 +1137,7 @@ export const handleMastoWS = (wsEvent) => {
if (!data) return if (!data) return
const parsedEvent = JSON.parse(data) const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent 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 // MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') { if (event === 'delete') {
return { event, id: payload } return { event, id: payload }
@ -1133,6 +1147,8 @@ export const handleMastoWS = (wsEvent) => {
return { event, status: parseStatus(data) } return { event, status: parseStatus(data) }
} else if (event === 'notification') { } else if (event === 'notification') {
return { event, notification: parseNotification(data) } return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { event, chatUpdate: parseChat(data) }
} }
} else { } else {
console.warn('Unknown event', wsEvent) console.warn('Unknown event', wsEvent)
@ -1140,6 +1156,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 = { const apiService = {
verifyCredentials, verifyCredentials,
fetchTimeline, fetchTimeline,
@ -1218,7 +1309,13 @@ const apiService = {
fetchKnownDomains, fetchKnownDomains,
fetchDomainMutes, fetchDomainMutes,
muteDomain, muteDomain,
unmuteDomain unmuteDomain,
chats,
getOrCreateChat,
chatMessages,
sendChatMessage,
readChat,
deleteChatMessage
} }
export default apiService export default apiService

View file

@ -0,0 +1,150 @@
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) {
storage.minId = _.minBy(storage.messages, '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 firstMessages = messages[0]
let prev = messages[messages.length - 1]
let currentMessageChainId
if (firstMessages) {
const date = new Date(firstMessages.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 (prev && prev.date < date) {
result.push({
type: 'date',
date,
id: date.getTime().toString()
})
prev['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 ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) {
currentMessageChainId = _.uniqueId()
object['isHead'] = true
object['messageChainId'] = currentMessageChainId
}
result.push(object)
prev = object
afterDate = false
}
return result
}
const ChatService = {
add,
empty,
getView,
deleteMessage,
resetNewMessageCount,
clear
}
export default ChatService

View file

@ -183,6 +183,7 @@ export const parseUser = (data) => {
output.deactivated = data.pleroma.deactivated output.deactivated = data.pleroma.deactivated
output.notification_settings = data.pleroma.notification_settings output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
} }
output.tags = output.tags || [] output.tags = output.tags || []
@ -372,7 +373,7 @@ export const parseNotification = (data) => {
? parseStatus(data.notice.favorited_status) ? parseStatus(data.notice.favorited_status)
: parsedNotice : parsedNotice
output.action = 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) output.created_at = new Date(data.created_at)
@ -398,3 +399,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
minId: flakeId ? minId : parseInt(minId, 10) 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
}

View file

@ -106,7 +106,8 @@ export const generateRadii = (input) => {
avatar: 5, avatar: 5,
avatarAlt: 50, avatarAlt: 50,
tooltip: 2, tooltip: 2,
attachment: 5 attachment: 5,
chatMessage: inputRadii.panel
}) })
return { return {

View file

@ -23,7 +23,9 @@ export const LAYERS = {
inputTopBar: 'topBar', inputTopBar: 'topBar',
alert: 'bg', alert: 'bg',
alertPanel: 'panel', alertPanel: 'panel',
poll: 'bg' poll: 'bg',
chatBg: 'underlay',
chatMessage: 'chatBg'
} }
/* By default opacity slots have 1 as default opacity /* By default opacity slots have 1 as default opacity
@ -667,5 +669,54 @@ export const SLOT_INHERITANCE = {
layer: 'badge', layer: 'badge',
variant: 'badgeNotification', variant: 'badgeNotification',
textColor: 'bw' 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'
} }
} }

View file

@ -3,3 +3,8 @@ export const windowWidth = () =>
window.innerWidth || window.innerWidth ||
document.documentElement.clientWidth || document.documentElement.clientWidth ||
document.body.clientWidth document.body.clientWidth
export const windowHeight = () =>
window.innerHeight ||
document.documentElement.clientHeight ||
document.body.clientHeight

View file

@ -399,6 +399,12 @@
"css": "doc", "css": "doc",
"code": 59433, "code": 59433,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "98d9c83c1ee7c2c25af784b518c522c5",
"css": "block",
"code": 59434,
"src": "fontawesome"
} }
] ]
} }

View file

@ -1,14 +1,22 @@
import Vuex from 'vuex'
import routes from 'src/boot/routes' import routes from 'src/boot/routes'
import { createLocalVue } from '@vue/test-utils' import { createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router' import VueRouter from 'vue-router'
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(VueRouter) localVue.use(VueRouter)
const store = new Vuex.Store({
state: {
instance: {}
}
})
describe('routes', () => { describe('routes', () => {
const router = new VueRouter({ const router = new VueRouter({
mode: 'abstract', mode: 'abstract',
routes: routes({}) routes: routes(store)
}) })
it('root path', () => { it('root path', () => {