Add Chats
This commit is contained in:
parent
a0ddcbdf5b
commit
aa2cf51c05
69 changed files with 2794 additions and 161 deletions
|
@ -1,3 +1,4 @@
|
|||
import { mapState } from 'vuex'
|
||||
import ProgressButton from '../progress_button/progress_button.vue'
|
||||
import Popover from '../popover/popover.vue'
|
||||
|
||||
|
@ -27,7 +28,18 @@ const AccountActions = {
|
|||
},
|
||||
reportUser () {
|
||||
this.$store.dispatch('openUserReportingModal', this.user.id)
|
||||
},
|
||||
openChat () {
|
||||
this.$router.push({
|
||||
name: 'chat',
|
||||
params: { recipient_id: this.user.id }
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,13 @@
|
|||
>
|
||||
{{ $t('user_card.report') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pleromaChatMessagesAvailable"
|
||||
class="btn btn-default btn-block dropdown-item"
|
||||
@click="openChat"
|
||||
>
|
||||
{{ $t('user_card.message') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
304
src/components/chat/chat.js
Normal file
304
src/components/chat/chat.js
Normal 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
|
161
src/components/chat/chat.scss
Normal file
161
src/components/chat/chat.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
99
src/components/chat/chat.vue
Normal file
99
src/components/chat/chat.vue
Normal 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>
|
100
src/components/chat/chat_layout.js
Normal file
100
src/components/chat/chat_layout.js
Normal 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
|
27
src/components/chat/chat_layout_utils.js
Normal file
27
src/components/chat/chat_layout_utils.js
Normal 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
|
||||
}
|
23
src/components/chat_avatar/chat_avatar.js
Normal file
23
src/components/chat_avatar/chat_avatar.js
Normal 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
|
53
src/components/chat_avatar/chat_avatar.vue
Normal file
53
src/components/chat_avatar/chat_avatar.vue
Normal 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>
|
37
src/components/chat_list/chat_list.js
Normal file
37
src/components/chat_list/chat_list.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
import ChatListItem from '../chat_list_item/chat_list_item.vue'
|
||||
import ChatNew from '../chat_new/chat_new.vue'
|
||||
import List from '../list/list.vue'
|
||||
|
||||
const ChatList = {
|
||||
components: {
|
||||
ChatListItem,
|
||||
List,
|
||||
ChatNew
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
}),
|
||||
...mapGetters(['sortedChatList'])
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isNew: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('fetchChats', { latest: true })
|
||||
},
|
||||
methods: {
|
||||
cancelNewChat () {
|
||||
this.isNew = false
|
||||
this.$store.dispatch('fetchChats', { latest: true })
|
||||
},
|
||||
newChat () {
|
||||
this.isNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatList
|
48
src/components/chat_list/chat_list.vue
Normal file
48
src/components/chat_list/chat_list.vue
Normal 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>
|
65
src/components/chat_list_item/chat_list_item.js
Normal file
65
src/components/chat_list_item/chat_list_item.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { mapState } from 'vuex'
|
||||
import StatusContent from '../status_content/status_content.vue'
|
||||
import fileType from 'src/services/file_type/file_type.service'
|
||||
import 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
|
94
src/components/chat_list_item/chat_list_item.scss
Normal file
94
src/components/chat_list_item/chat_list_item.scss
Normal file
|
@ -0,0 +1,94 @@
|
|||
.chat-list-item {
|
||||
&: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);
|
||||
}
|
||||
}
|
49
src/components/chat_list_item/chat_list_item.vue
Normal file
49
src/components/chat_list_item/chat_list_item.vue
Normal 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>
|
109
src/components/chat_message/chat_message.js
Normal file
109
src/components/chat_message/chat_message.js
Normal 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
|
157
src/components/chat_message/chat_message.scss
Normal file
157
src/components/chat_message/chat_message.scss
Normal 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);
|
||||
}
|
99
src/components/chat_message/chat_message.vue
Normal file
99
src/components/chat_message/chat_message.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isMessage"
|
||||
class="chat-message-wrapper"
|
||||
:class="{ 'hovered-message-chain': hoveredMessageChain }"
|
||||
@mouseover="onHover(true)"
|
||||
@mouseleave="onHover(false)"
|
||||
>
|
||||
<div
|
||||
class="chat-message"
|
||||
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
|
||||
>
|
||||
<div
|
||||
v-if="!isCurrentUser"
|
||||
class="avatar-wrapper"
|
||||
>
|
||||
<router-link
|
||||
v-if="chatViewItem.isHead"
|
||||
:to="userProfileLink"
|
||||
>
|
||||
<UserAvatar
|
||||
:compact="true"
|
||||
:better-shadow="betterShadow"
|
||||
:user="author"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="chat-message-inner">
|
||||
<div
|
||||
class="status-body"
|
||||
:style="{ 'min-width': message.attachment ? '80%' : '' }"
|
||||
>
|
||||
<div
|
||||
class="media status"
|
||||
:class="{ 'without-attachment': !hasAttachment }"
|
||||
style="position: relative"
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<div
|
||||
class="chat-message-menu"
|
||||
: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>
|
24
src/components/chat_message_date/chat_message_date.vue
Normal file
24
src/components/chat_message_date/chat_message_date.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<time>
|
||||
{{ displayDate }}
|
||||
</time>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Timeago',
|
||||
props: ['date'],
|
||||
computed: {
|
||||
displayDate () {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (this.date.getTime() === today.getTime()) {
|
||||
return this.$t('display_date.today')
|
||||
} else {
|
||||
return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
74
src/components/chat_new/chat_new.js
Normal file
74
src/components/chat_new/chat_new.js
Normal 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
|
29
src/components/chat_new/chat_new.scss
Normal file
29
src/components/chat_new/chat_new.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
.chat-new {
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
margin: 0.7em 0.5em 0.7em 0.5em;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-search {
|
||||
font-size: 1.5em;
|
||||
float: right;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
padding-bottom: 0.67rem;
|
||||
}
|
||||
|
||||
.basic-user-card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--selectedPost, $fallback--lightBg);
|
||||
}
|
||||
|
||||
.go-back-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
46
src/components/chat_new/chat_new.vue
Normal file
46
src/components/chat_new/chat_new.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div
|
||||
id="nav"
|
||||
class="panel-default panel chat-new"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading"
|
||||
>
|
||||
<a
|
||||
class="go-back-button"
|
||||
@click="goBack"
|
||||
>
|
||||
<i class="button-icon icon-left-open" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<div class="input-search">
|
||||
<i class="button-icon icon-search" />
|
||||
</div>
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
placeholder="Search people"
|
||||
@input="onInput"
|
||||
>
|
||||
</div>
|
||||
<div class="member-list">
|
||||
<div
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
class="member"
|
||||
>
|
||||
<div @click.capture.prevent="goToChat(user)">
|
||||
<BasicUserCard :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_new.js"></script>
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import './chat_new.scss';
|
||||
</style>
|
|
@ -84,54 +84,56 @@
|
|||
max-width: 25em;
|
||||
}
|
||||
|
||||
.chat-heading {
|
||||
cursor: pointer;
|
||||
.icon-comment-empty {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
.chat-window-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
padding: 0.2em 0.5em
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: $fallback--avatarRadius;
|
||||
border-radius: var(--avatarRadius, $fallback--avatarRadius);
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
textarea {
|
||||
flex: 1;
|
||||
margin: 0.6em;
|
||||
min-height: 3.5em;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.title {
|
||||
.chat-heading {
|
||||
cursor: pointer;
|
||||
.icon-comment-empty {
|
||||
color: $fallback--text;
|
||||
color: var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
.chat-window-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.2em 0.5em
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: $fallback--avatarRadius;
|
||||
border-radius: var(--avatarRadius, $fallback--avatarRadius);
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
textarea {
|
||||
flex: 1;
|
||||
margin: 0.6em;
|
||||
min-height: 3.5em;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
20
src/components/chat_title/chat_title.js
Normal file
20
src/components/chat_title/chat_title.js
Normal 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 : ''
|
||||
}
|
||||
}
|
||||
})
|
56
src/components/chat_title/chat_title.vue
Normal file
56
src/components/chat_title/chat_title.vue
Normal 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>
|
|
@ -79,6 +79,15 @@ const EmojiInput = {
|
|||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placement: {
|
||||
/**
|
||||
* Forces the panel to take a specific position relative to the input element.
|
||||
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
|
||||
*/
|
||||
required: false,
|
||||
type: String, // 'auto', 'top', 'bottom'
|
||||
default: 'auto'
|
||||
}
|
||||
},
|
||||
data () {
|
||||
|
@ -162,6 +171,11 @@ const EmojiInput = {
|
|||
input.elm.removeEventListener('input', this.onInput)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showSuggestions: function (newValue) {
|
||||
this.$emit('shown', newValue)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
triggerShowPicker () {
|
||||
this.showPicker = true
|
||||
|
@ -425,15 +439,29 @@ const EmojiInput = {
|
|||
this.caret = selectionStart
|
||||
},
|
||||
resize () {
|
||||
const { panel, picker } = this.$refs
|
||||
const panel = this.$refs.panel
|
||||
if (!panel) return
|
||||
const picker = this.$refs.picker.$el
|
||||
const panelBody = this.$refs['panel-body']
|
||||
const { offsetHeight, offsetTop } = this.input.elm
|
||||
const offsetBottom = offsetTop + offsetHeight
|
||||
|
||||
panel.style.top = offsetBottom + 'px'
|
||||
if (!picker) return
|
||||
picker.$el.style.top = offsetBottom + 'px'
|
||||
picker.$el.style.bottom = 'auto'
|
||||
this.setPlacement(panelBody, panel, offsetBottom)
|
||||
this.setPlacement(picker, picker, offsetBottom)
|
||||
},
|
||||
setPlacement (container, target, offsetBottom) {
|
||||
if (!container || !target) return
|
||||
|
||||
target.style.top = offsetBottom + 'px'
|
||||
target.style.bottom = 'auto'
|
||||
|
||||
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
|
||||
target.style.top = 'auto'
|
||||
target.style.bottom = this.input.elm.offsetHeight + 'px'
|
||||
}
|
||||
},
|
||||
overflowsBottom (el) {
|
||||
return el.getBoundingClientRect().bottom > window.innerHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,10 @@
|
|||
class="autocomplete-panel"
|
||||
:class="{ hide: !showSuggestions }"
|
||||
>
|
||||
<div class="autocomplete-panel-body">
|
||||
<div
|
||||
ref="panel-body"
|
||||
class="autocomplete-panel-body"
|
||||
>
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="index"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const FeaturesPanel = {
|
||||
computed: {
|
||||
chat: function () { return this.$store.state.instance.chatAvailable },
|
||||
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
|
||||
gopher: function () { return this.$store.state.instance.gopherAvailable },
|
||||
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
|
||||
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
<li v-if="chat">
|
||||
{{ $t('features_panel.chat') }}
|
||||
</li>
|
||||
<li v-if="pleromaChatMessages">
|
||||
{{ $t('features_panel.pleroma_chat_messages') }}
|
||||
</li>
|
||||
<li v-if="gopher">
|
||||
{{ $t('features_panel.gopher') }}
|
||||
</li>
|
||||
|
|
|
@ -61,7 +61,8 @@ const mediaUpload = {
|
|||
}
|
||||
},
|
||||
props: [
|
||||
'dropFiles'
|
||||
'dropFiles',
|
||||
'disabled'
|
||||
],
|
||||
watch: {
|
||||
'dropFiles': function (fileInfos) {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<div class="media-upload">
|
||||
<div
|
||||
class="media-upload"
|
||||
:class="{ disabled: disabled }"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
:title="$t('tool_tip.media_upload')"
|
||||
|
@ -14,6 +17,7 @@
|
|||
/>
|
||||
<input
|
||||
v-if="uploadReady"
|
||||
:disabled="disabled"
|
||||
type="file"
|
||||
style="position: fixed; top: -100em"
|
||||
multiple="true"
|
||||
|
@ -26,7 +30,22 @@
|
|||
<script src="./media_upload.js" ></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.media-upload {
|
||||
&.disabled {
|
||||
.new-icon {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
i, label {
|
||||
color: $fallback--faint;
|
||||
color: var(--faint, $fallback--faint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
|
@ -30,7 +30,10 @@ const MobileNav = {
|
|||
return this.unseenNotifications.length
|
||||
},
|
||||
hideSitename () { return this.$store.state.instance.hideSitename },
|
||||
sitename () { return this.$store.state.instance.name }
|
||||
sitename () { return this.$store.state.instance.name },
|
||||
navBarStyle () {
|
||||
return { 'visibility': this.$route.name === 'chat' ? 'hidden' : 'visible' }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleMobileSidebar () {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
<nav
|
||||
id="nav"
|
||||
class="nav-bar container"
|
||||
:style="navBarStyle"
|
||||
>
|
||||
<div
|
||||
class="mobile-inner-nav"
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { debounce } from 'lodash'
|
||||
|
||||
const HIDDEN_FOR_PAGES = new Set([
|
||||
'chats',
|
||||
'chat'
|
||||
])
|
||||
|
||||
const MobilePostStatusButton = {
|
||||
data () {
|
||||
return {
|
||||
|
@ -27,6 +32,8 @@ const MobilePostStatusButton = {
|
|||
return !!this.$store.state.users.currentUser
|
||||
},
|
||||
isHidden () {
|
||||
if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
|
||||
|
||||
return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
|
||||
},
|
||||
autohideFloatingPostButton () {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { mapState } from 'vuex'
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
|
||||
const NavPanel = {
|
||||
created () {
|
||||
|
@ -6,13 +6,17 @@ const NavPanel = {
|
|||
this.$store.dispatch('startFetchingFollowRequests')
|
||||
}
|
||||
},
|
||||
computed: mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
chat: state => state.chat.channel,
|
||||
followRequestCount: state => state.api.followRequests.length,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating
|
||||
})
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser,
|
||||
chat: state => state.chat.channel,
|
||||
followRequestCount: state => state.api.followRequests.length,
|
||||
privateMode: state => state.instance.private,
|
||||
federating: state => state.instance.federating,
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
}),
|
||||
...mapGetters(['unreadChatCount'])
|
||||
}
|
||||
}
|
||||
|
||||
export default NavPanel
|
||||
|
|
|
@ -22,6 +22,17 @@
|
|||
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && pleromaChatMessagesAvailable">
|
||||
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
|
||||
<div
|
||||
v-if="unreadChatCount"
|
||||
class="badge badge-notification unread-chat-count"
|
||||
>
|
||||
{{ unreadChatCount }}
|
||||
</div>
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li v-if="currentUser && currentUser.locked">
|
||||
<router-link :to="{ name: 'friend-requests' }">
|
||||
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import StatusContent from '../status_content/status_content.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import Status from '../status/status.vue'
|
||||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import UserCard from '../user_card/user_card.vue'
|
||||
|
@ -81,7 +82,10 @@ const Notification = {
|
|||
},
|
||||
isStatusNotification () {
|
||||
return isStatusNotification(this.notification.type)
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { mapGetters } from 'vuex'
|
||||
import Notification from '../notification/notification.vue'
|
||||
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
|
||||
import {
|
||||
|
@ -51,18 +52,22 @@ const Notifications = {
|
|||
unseenCount () {
|
||||
return this.unseenNotifications.length
|
||||
},
|
||||
unseenCountTitle () {
|
||||
return this.unseenCount + (this.unreadChatCount)
|
||||
},
|
||||
loading () {
|
||||
return this.$store.state.statuses.notifications.loading
|
||||
},
|
||||
notificationsToDisplay () {
|
||||
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
|
||||
}
|
||||
},
|
||||
...mapGetters(['unreadChatCount'])
|
||||
},
|
||||
components: {
|
||||
Notification
|
||||
},
|
||||
watch: {
|
||||
unseenCount (count) {
|
||||
unseenCountTitle (count) {
|
||||
if (count > 0) {
|
||||
this.$store.dispatch('setPageTitle', `(${count})`)
|
||||
} else {
|
||||
|
|
|
@ -9,7 +9,7 @@ import fileTypeService from '../../services/file_type/file_type.service.js'
|
|||
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
|
||||
import { reject, map, uniqBy, debounce } from 'lodash'
|
||||
import suggestor from '../emoji_input/suggestor.js'
|
||||
import { mapGetters } from 'vuex'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import Checkbox from '../checkbox/checkbox.vue'
|
||||
|
||||
const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
|
||||
|
@ -33,7 +33,22 @@ const PostStatusForm = {
|
|||
'repliedUser',
|
||||
'attentions',
|
||||
'copyMessageScope',
|
||||
'subject'
|
||||
'subject',
|
||||
'disableSubject',
|
||||
'disableScopeSelector',
|
||||
'disableNotice',
|
||||
'disableLockWarning',
|
||||
'disablePolls',
|
||||
'disableSensitivityCheckbox',
|
||||
'disableSubmit',
|
||||
'placeholder',
|
||||
'maxHeight',
|
||||
'request',
|
||||
'preserveFocus',
|
||||
'autoFocus',
|
||||
'fileLimit',
|
||||
'submitOnEnter',
|
||||
'emojiPickerPlacement'
|
||||
],
|
||||
components: {
|
||||
MediaUpload,
|
||||
|
@ -46,10 +61,13 @@ const PostStatusForm = {
|
|||
},
|
||||
mounted () {
|
||||
this.resize(this.$refs.textarea)
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
|
||||
if (this.replyTo) {
|
||||
const textLength = this.$refs.textarea.value.length
|
||||
this.$refs.textarea.setSelectionRange(textLength, textLength)
|
||||
}
|
||||
|
||||
if (this.replyTo || this.autoFocus) {
|
||||
this.$refs.textarea.focus()
|
||||
}
|
||||
},
|
||||
|
@ -72,7 +90,7 @@ const PostStatusForm = {
|
|||
|
||||
return {
|
||||
dropFiles: [],
|
||||
submitDisabled: false,
|
||||
uploadingFiles: false,
|
||||
error: null,
|
||||
posting: false,
|
||||
highlighted: 0,
|
||||
|
@ -91,7 +109,8 @@ const PostStatusForm = {
|
|||
showDropIcon: 'hide',
|
||||
dropStopTimeout: null,
|
||||
preview: null,
|
||||
previewLoading: false
|
||||
previewLoading: false,
|
||||
emojiInputShown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -160,10 +179,11 @@ const PostStatusForm = {
|
|||
},
|
||||
pollsAvailable () {
|
||||
return this.$store.state.instance.pollsAvailable &&
|
||||
this.$store.state.instance.pollLimits.max_options >= 2
|
||||
this.$store.state.instance.pollLimits.max_options >= 2 &&
|
||||
this.disablePolls !== true
|
||||
},
|
||||
hideScopeNotice () {
|
||||
return this.$store.getters.mergedConfig.hideScopeNotice
|
||||
return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
|
||||
},
|
||||
pollContentError () {
|
||||
return this.pollFormVisible &&
|
||||
|
@ -176,7 +196,13 @@ const PostStatusForm = {
|
|||
emptyStatus () {
|
||||
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
|
||||
},
|
||||
...mapGetters(['mergedConfig'])
|
||||
uploadFileLimitReached () {
|
||||
return this.newStatus.files.length >= this.fileLimit
|
||||
},
|
||||
...mapGetters(['mergedConfig']),
|
||||
...mapState({
|
||||
mobileLayout: state => state.interface.mobileLayout
|
||||
})
|
||||
},
|
||||
watch: {
|
||||
'newStatus.contentType': function () {
|
||||
|
@ -187,9 +213,19 @@ const PostStatusForm = {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
async postStatus (newStatus) {
|
||||
async postStatus (event, newStatus, opts = {}) {
|
||||
if (this.posting) { return }
|
||||
if (this.submitDisabled) { return }
|
||||
if (this.emojiInputShown) { return }
|
||||
if (this.submitOnEnter) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
if (opts.control && this.submitOnEnter) {
|
||||
newStatus.status = `${newStatus.status}\n`
|
||||
return
|
||||
}
|
||||
|
||||
if (this.emptyStatus) {
|
||||
this.error = this.$t('post_status.empty_status_error')
|
||||
return
|
||||
|
@ -211,7 +247,7 @@ const PostStatusForm = {
|
|||
return
|
||||
}
|
||||
|
||||
const data = await statusPoster.postStatus({
|
||||
const postingOptions = {
|
||||
status: newStatus.status,
|
||||
spoilerText: newStatus.spoilerText || null,
|
||||
visibility: newStatus.visibility,
|
||||
|
@ -221,32 +257,40 @@ const PostStatusForm = {
|
|||
inReplyToStatusId: this.replyTo,
|
||||
contentType: newStatus.contentType,
|
||||
poll
|
||||
})
|
||||
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {},
|
||||
mediaDescriptions: {}
|
||||
}
|
||||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted')
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
if (this.preview) this.previewStatus()
|
||||
} else {
|
||||
this.error = data.error
|
||||
}
|
||||
|
||||
this.posting = false
|
||||
const request = this.request ? this.request : statusPoster.postStatus
|
||||
|
||||
request(postingOptions).then((data) => {
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
spoilerText: '',
|
||||
files: [],
|
||||
visibility: newStatus.visibility,
|
||||
contentType: newStatus.contentType,
|
||||
poll: {},
|
||||
mediaDescriptions: {}
|
||||
}
|
||||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted', data)
|
||||
if (this.preserveFocus) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textarea.focus()
|
||||
})
|
||||
}
|
||||
let el = this.$el.querySelector('textarea')
|
||||
el.style.height = 'auto'
|
||||
el.style.height = undefined
|
||||
this.error = null
|
||||
if (this.preview) this.previewStatus()
|
||||
} else {
|
||||
this.error = data.error
|
||||
}
|
||||
this.posting = false
|
||||
})
|
||||
},
|
||||
previewStatus () {
|
||||
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
|
||||
|
@ -301,20 +345,26 @@ const PostStatusForm = {
|
|||
},
|
||||
addMediaFile (fileInfo) {
|
||||
this.newStatus.files.push(fileInfo)
|
||||
|
||||
// TODO: use fixed dimensions instead so relying on timeout
|
||||
setTimeout(() => {
|
||||
this.$emit('resize')
|
||||
}, 150)
|
||||
},
|
||||
removeMediaFile (fileInfo) {
|
||||
let index = this.newStatus.files.indexOf(fileInfo)
|
||||
this.newStatus.files.splice(index, 1)
|
||||
this.$emit('resize')
|
||||
},
|
||||
uploadFailed (errString, templateArgs) {
|
||||
templateArgs = templateArgs || {}
|
||||
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
|
||||
},
|
||||
disableSubmit () {
|
||||
this.submitDisabled = true
|
||||
startedUploadingFiles () {
|
||||
this.uploadingFiles = true
|
||||
},
|
||||
enableSubmit () {
|
||||
this.submitDisabled = false
|
||||
finishedUploadingFiles () {
|
||||
this.uploadingFiles = false
|
||||
},
|
||||
type (fileInfo) {
|
||||
return fileTypeService.fileType(fileInfo.mimetype)
|
||||
|
@ -348,7 +398,7 @@ const PostStatusForm = {
|
|||
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
|
||||
},
|
||||
fileDrag (e) {
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
clearTimeout(this.dropStopTimeout)
|
||||
this.showDropIcon = 'show'
|
||||
|
@ -367,6 +417,7 @@ const PostStatusForm = {
|
|||
// Reset to default height for empty form, nothing else to do here.
|
||||
if (target.value === '') {
|
||||
target.style.height = null
|
||||
this.$emit('resize', null)
|
||||
this.$refs['emoji-input'].resize()
|
||||
return
|
||||
}
|
||||
|
@ -419,8 +470,10 @@ const PostStatusForm = {
|
|||
|
||||
// BEGIN content size update
|
||||
target.style.height = 'auto'
|
||||
const newHeight = target.scrollHeight - vertPadding
|
||||
const heightWithoutPadding = target.scrollHeight - vertPadding
|
||||
const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
|
||||
target.style.height = `${newHeight}px`
|
||||
this.$emit('resize', newHeight)
|
||||
// END content size update
|
||||
|
||||
// We check where the bottom border of form-bottom element is, this uses findOffset
|
||||
|
@ -480,6 +533,9 @@ const PostStatusForm = {
|
|||
setAllMediaDescriptions () {
|
||||
const ids = this.newStatus.files.map(file => file.id)
|
||||
return Promise.all(ids.map(id => this.setMediaDescription(id)))
|
||||
},
|
||||
handleEmojiInputShow (value) {
|
||||
this.emojiInputShown = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,19 +5,20 @@
|
|||
>
|
||||
<form
|
||||
autocomplete="off"
|
||||
@submit.prevent="postStatus(newStatus)"
|
||||
@submit.prevent
|
||||
@dragover.prevent="fileDrag"
|
||||
>
|
||||
<div
|
||||
v-show="showDropIcon !== 'hide'"
|
||||
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
|
||||
class="drop-indicator icon-upload"
|
||||
class="drop-indicator"
|
||||
:class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
|
||||
@dragleave="fileDragStop"
|
||||
@drop.stop="fileDrop"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<i18n
|
||||
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
|
||||
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
|
||||
path="post_status.account_not_locked_warning"
|
||||
tag="p"
|
||||
class="visibility-notice"
|
||||
|
@ -108,7 +109,7 @@
|
|||
/>
|
||||
</div>
|
||||
<EmojiInput
|
||||
v-if="newStatus.spoilerText || alwaysShowSubject"
|
||||
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
|
||||
v-model="newStatus.spoilerText"
|
||||
enable-emoji-picker
|
||||
:suggest="emojiSuggestor"
|
||||
|
@ -126,6 +127,7 @@
|
|||
ref="emoji-input"
|
||||
v-model="newStatus.status"
|
||||
:suggest="emojiUserSuggestor"
|
||||
:placement="emojiPickerPlacement"
|
||||
class="form-control main-input"
|
||||
enable-emoji-picker
|
||||
hide-emoji-button
|
||||
|
@ -133,16 +135,19 @@
|
|||
@input="onEmojiInputInput"
|
||||
@sticker-uploaded="addMediaFile"
|
||||
@sticker-upload-failed="uploadFailed"
|
||||
@shown="handleEmojiInputShow"
|
||||
>
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="newStatus.status"
|
||||
:placeholder="$t('post_status.default')"
|
||||
:placeholder="placeholder || $t('post_status.default')"
|
||||
rows="1"
|
||||
:disabled="posting"
|
||||
class="form-post-body"
|
||||
@keydown.meta.enter="postStatus(newStatus)"
|
||||
@keydown.ctrl.enter="postStatus(newStatus)"
|
||||
:class="{ 'scrollable-form': !!maxHeight }"
|
||||
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
|
||||
@keydown.meta.enter="postStatus($event, newStatus, { control: true })"
|
||||
@keydown.ctrl.enter="postStatus($event, newStatus)"
|
||||
@input="resize"
|
||||
@compositionupdate="resize"
|
||||
@paste="paste"
|
||||
|
@ -155,7 +160,10 @@
|
|||
{{ charactersLeft }}
|
||||
</p>
|
||||
</EmojiInput>
|
||||
<div class="visibility-tray">
|
||||
<div
|
||||
v-if="!disableScopeSelector"
|
||||
class="visibility-tray"
|
||||
>
|
||||
<scope-selector
|
||||
:show-all="showAllScopes"
|
||||
:user-default="userDefaultScope"
|
||||
|
@ -213,10 +221,11 @@
|
|||
ref="mediaUpload"
|
||||
class="media-upload-icon"
|
||||
:drop-files="dropFiles"
|
||||
@uploading="disableSubmit"
|
||||
:disabled="uploadFileLimitReached"
|
||||
@uploading="startedUploadingFiles"
|
||||
@uploaded="addMediaFile"
|
||||
@upload-failed="uploadFailed"
|
||||
@all-uploaded="enableSubmit"
|
||||
@all-uploaded="finishedUploadingFiles"
|
||||
/>
|
||||
<div
|
||||
class="emoji-icon"
|
||||
|
@ -253,11 +262,13 @@
|
|||
>
|
||||
{{ $t('general.submit') }}
|
||||
</button>
|
||||
<!-- touchstart is used to keep the OSK at the same position after a message send -->
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitDisabled"
|
||||
type="submit"
|
||||
:disabled="uploadingFiles || disableSubmit"
|
||||
class="btn btn-default"
|
||||
@touchstart.stop.prevent="postStatus($event, newStatus)"
|
||||
@click.stop.prevent="postStatus($event, newStatus)"
|
||||
>
|
||||
{{ $t('general.submit') }}
|
||||
</button>
|
||||
|
@ -297,7 +308,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="newStatus.files.length > 0"
|
||||
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
|
||||
class="upload_settings"
|
||||
>
|
||||
<Checkbox v-model="newStatus.nsfw">
|
||||
|
@ -331,6 +342,8 @@
|
|||
}
|
||||
|
||||
.post-status-form {
|
||||
position: relative;
|
||||
|
||||
.form-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -547,6 +560,10 @@
|
|||
padding-bottom: 1.75em;
|
||||
min-height: 1px;
|
||||
box-sizing: content-box;
|
||||
|
||||
&.scrollable-form {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.main-input {
|
||||
|
@ -609,4 +626,11 @@
|
|||
border: 2px dashed var(--text, $fallback--text);
|
||||
}
|
||||
}
|
||||
|
||||
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
|
||||
img.media-upload {
|
||||
line-height: 0;
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -99,7 +99,8 @@ export default {
|
|||
avatarRadiusLocal: '',
|
||||
avatarAltRadiusLocal: '',
|
||||
attachmentRadiusLocal: '',
|
||||
tooltipRadiusLocal: ''
|
||||
tooltipRadiusLocal: '',
|
||||
chatMessageRadiusLocal: ''
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -214,7 +215,8 @@ export default {
|
|||
avatar: this.avatarRadiusLocal,
|
||||
avatarAlt: this.avatarAltRadiusLocal,
|
||||
tooltip: this.tooltipRadiusLocal,
|
||||
attachment: this.attachmentRadiusLocal
|
||||
attachment: this.attachmentRadiusLocal,
|
||||
chatMessage: this.chatMessageRadiusLocal
|
||||
}
|
||||
},
|
||||
preview () {
|
||||
|
|
|
@ -735,6 +735,65 @@
|
|||
/>
|
||||
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
|
||||
</div>
|
||||
<div class="color-item">
|
||||
<h4>{{ $t('chats.chats') }}</h4>
|
||||
<ColorInput
|
||||
v-model="chatBgColorLocal"
|
||||
name="chatBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.background')"
|
||||
/>
|
||||
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingBgColorLocal"
|
||||
name="chatMessageIncomingBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.background')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingTextColorLocal"
|
||||
name="chatMessageIncomingTextColor"
|
||||
:fallback="previewTheme.colors.text || 1"
|
||||
:label="$t('settings.text')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingLinkColorLocal"
|
||||
name="chatMessageIncomingLinkColor"
|
||||
:fallback="previewTheme.colors.link || 1"
|
||||
:label="$t('settings.links')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingBorderColorLocal"
|
||||
name="chatMessageIncomingBorderLinkColor"
|
||||
:fallback="previewTheme.colors.fg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.border')"
|
||||
/>
|
||||
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingBgColorLocal"
|
||||
name="chatMessageOutgoingBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.background')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingTextColorLocal"
|
||||
name="chatMessageOutgoingTextColor"
|
||||
:fallback="previewTheme.colors.text || 1"
|
||||
:label="$t('settings.text')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingLinkColorLocal"
|
||||
name="chatMessageOutgoingLinkColor"
|
||||
:fallback="previewTheme.colors.link || 1"
|
||||
:label="$t('settings.links')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingBorderColorLocal"
|
||||
name="chatMessageOutgoingBorderLinkColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.border')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -814,6 +873,14 @@
|
|||
max="50"
|
||||
hard-min="0"
|
||||
/>
|
||||
<RangeInput
|
||||
v-model="chatMessageRadiusLocal"
|
||||
name="chatMessageRadius"
|
||||
:label="$t('settings.chatMessageRadius')"
|
||||
:fallback="previewTheme.radii.chatMessage || 2"
|
||||
max="50"
|
||||
hard-min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
import UserCard from '../user_card/user_card.vue'
|
||||
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
|
||||
import GestureService from '../../services/gesture_service/gesture_service'
|
||||
|
@ -47,7 +48,11 @@ const SideDrawer = {
|
|||
},
|
||||
federating () {
|
||||
return this.$store.state.instance.federating
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
|
||||
}),
|
||||
...mapGetters(['unreadChatCount'])
|
||||
},
|
||||
methods: {
|
||||
toggleDrawer () {
|
||||
|
|
|
@ -40,12 +40,24 @@
|
|||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
v-if="currentUser && pleromaChatMessagesAvailable"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
|
||||
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
|
||||
style="position: relative"
|
||||
>
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
|
||||
<span
|
||||
v-if="unreadChatCount"
|
||||
class="badge badge-notification unread-chat-count"
|
||||
>
|
||||
{{ unreadChatCount }}
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
|
@ -103,14 +115,6 @@
|
|||
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser && chat"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<router-link :to="{ name: 'chat' }">
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chat") }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li
|
||||
|
|
|
@ -18,7 +18,7 @@ const StatusContent = {
|
|||
],
|
||||
data () {
|
||||
return {
|
||||
showingTall: this.inConversation && this.focused,
|
||||
showingTall: this.fullContent || (this.inConversation && this.focused),
|
||||
showingLongSubject: false,
|
||||
// not as computed because it sets the initial state which will be changed later
|
||||
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
|
||||
|
|
|
@ -76,7 +76,7 @@
|
|||
/>
|
||||
</a>
|
||||
<a
|
||||
v-if="showingMore"
|
||||
v-if="showingMore && !fullContent"
|
||||
href="#"
|
||||
class="status-unhider"
|
||||
@click.prevent="toggleShowMore"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue