WIP: Chats
This commit is contained in:
parent
f288178b67
commit
804cf3abc5
52 changed files with 2374 additions and 31 deletions
12
src/App.js
12
src/App.js
|
@ -41,7 +41,8 @@ export default {
|
|||
window.CSS.supports('-moz-mask-size', 'contain') ||
|
||||
window.CSS.supports('-ms-mask-size', 'contain') ||
|
||||
window.CSS.supports('-o-mask-size', 'contain')
|
||||
)
|
||||
),
|
||||
transitionName: 'fade'
|
||||
}),
|
||||
created () {
|
||||
// Load the locale from the storage
|
||||
|
@ -124,5 +125,14 @@ export default {
|
|||
this.$store.dispatch('setMobileLayout', mobileLayout)
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route' (to, from) {
|
||||
if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) {
|
||||
this.transitionName = 'none'
|
||||
} else {
|
||||
this.transitionName = 'fade'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
22
src/App.scss
22
src/App.scss
|
@ -969,3 +969,25 @@ nav {
|
|||
background-color: $fallback--fg;
|
||||
background-color: var(--panel, $fallback--fg);
|
||||
}
|
||||
|
||||
.alert-dot-number {
|
||||
display: inline-block;
|
||||
border-radius: 1em;
|
||||
min-width: 1.3rem;
|
||||
min-height: 1.3rem;
|
||||
max-height: 1.3rem;
|
||||
font-size: 0.9em;
|
||||
font-weight: bolder;
|
||||
line-height: 1.3rem;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.3em;
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--badgeNotification, $fallback--cRed);
|
||||
color: white;
|
||||
color: var(--badgeNotificationText, white);
|
||||
font-style: normal;
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="app-bg-wrapper app-container-wrapper" />
|
||||
<div
|
||||
id="content"
|
||||
class="container underlay"
|
||||
|
@ -111,7 +112,7 @@
|
|||
{{ $t("login.hint") }}
|
||||
</router-link>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<transition :name="transitionName">
|
||||
<router-view />
|
||||
</transition>
|
||||
</div>
|
||||
|
|
|
@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px;
|
|||
$fallback--avatarRadius: 4px;
|
||||
$fallback--avatarAltRadius: 10px;
|
||||
$fallback--attachmentRadius: 10px;
|
||||
$fallback--chatMessageRadius: 10px;
|
||||
|
||||
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
|
||||
|
|
|
@ -5,6 +5,8 @@ import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
|
|||
import ConversationPage from 'components/conversation-page/conversation-page.vue'
|
||||
import Interactions from 'components/interactions/interactions.vue'
|
||||
import DMs from 'components/dm_timeline/dm_timeline.vue'
|
||||
import ChatList from 'components/chat_list/chat_list.vue'
|
||||
import Chat from 'components/chat/chat.vue'
|
||||
import UserProfile from 'components/user_profile/user_profile.vue'
|
||||
import Search from 'components/search/search.vue'
|
||||
import Settings from 'components/settings/settings.vue'
|
||||
|
@ -56,6 +58,8 @@ export default (store) => {
|
|||
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile },
|
||||
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'chat', path: '/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
|
||||
{ name: 'settings', path: '/settings', component: Settings },
|
||||
{ name: 'registration', path: '/registration', component: Registration },
|
||||
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
|
||||
|
|
|
@ -27,6 +27,12 @@ const AccountActions = {
|
|||
},
|
||||
reportUser () {
|
||||
this.$store.dispatch('openUserReportingModal', this.user.id)
|
||||
},
|
||||
openChat () {
|
||||
this.$router.push({
|
||||
name: 'chat',
|
||||
params: { recipient_id: this.user.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,6 +49,12 @@
|
|||
>
|
||||
{{ $t('user_card.report') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-default btn-block dropdown-item"
|
||||
@click="openChat"
|
||||
>
|
||||
{{ $t('user_card.message') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
337
src/components/chat/chat.js
Normal file
337
src/components/chat/chat.js
Normal file
|
@ -0,0 +1,337 @@
|
|||
import { throttle } from 'lodash'
|
||||
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'
|
||||
|
||||
const Chat = {
|
||||
components: {
|
||||
ChatMessage,
|
||||
ChatTitle,
|
||||
ChatAvatar,
|
||||
PostStatusForm
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loadingMessages: true,
|
||||
loadingChat: false,
|
||||
editedStatusId: undefined,
|
||||
fetcher: undefined,
|
||||
jumpToBottomButtonVisible: false,
|
||||
mobileLayout: this.$store.state.interface.mobileLayout,
|
||||
recipientId: this.$route.params.recipient_id,
|
||||
hoveredSequenceId: undefined,
|
||||
chatViewItems: chatService.getView(this.currentChatMessageService),
|
||||
newMessageCount: this.currentChatMessageService && this.currentChatMessageService.newMessageCount
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.startFetching()
|
||||
window.addEventListener('resize', this.handleLayoutChange)
|
||||
},
|
||||
mounted () {
|
||||
this.$nextTick(() => {
|
||||
let scrollable = this.$refs.scrollable
|
||||
if (scrollable) {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
}
|
||||
this.updateSize()
|
||||
})
|
||||
if (this.isMobileLayout) {
|
||||
this.setMobileChatLayout()
|
||||
}
|
||||
|
||||
if (typeof document.hidden !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
|
||||
this.$store.commit('setChatFocused', !document.hidden)
|
||||
}
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleLayoutChange)
|
||||
this.unsetMobileChatLayout()
|
||||
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
|
||||
this.$store.dispatch('clearCurrentChat')
|
||||
},
|
||||
computed: {
|
||||
chatParticipants () {
|
||||
if (this.currentChat) {
|
||||
return [this.currentChat.account]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
recipient () {
|
||||
return this.currentChat && this.currentChat.account
|
||||
},
|
||||
formPlaceholder () {
|
||||
if (this.recipient) {
|
||||
return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
|
||||
} else {
|
||||
return this.$t('chats.write_message')
|
||||
}
|
||||
},
|
||||
...mapGetters(['currentChat', 'currentChatMessageService', 'findUser']),
|
||||
...mapState({
|
||||
backendInteractor: state => state.api.backendInteractor,
|
||||
currentUser: state => state.users.currentUser,
|
||||
isMobileLayout: state => state.interface.mobileLayout
|
||||
})
|
||||
},
|
||||
watch: {
|
||||
chatViewItems (prev, next) {
|
||||
let bottomedOut = this.bottomedOut(10)
|
||||
this.$nextTick(() => {
|
||||
if (bottomedOut && prev.length !== next.length) {
|
||||
this.newMessageCount = this.currentChatMessageService.newMessageCount
|
||||
this.scrollDown({ forceRead: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
'$route': function (prev, next) {
|
||||
this.recipientId = this.$route.params.recipient_id
|
||||
this.startFetching()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onStatusHover ({ state, sequenceId }) {
|
||||
this.hoveredSequenceId = state ? sequenceId : undefined
|
||||
},
|
||||
onPosted (data) {
|
||||
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
|
||||
this.chatViewItems = chatService.getView(this.currentChatMessageService)
|
||||
this.updateSize()
|
||||
this.scrollDown({ forceRead: true })
|
||||
})
|
||||
},
|
||||
onScopeNoticeDismissed () {
|
||||
this.$nextTick(() => {
|
||||
this.updateSize()
|
||||
})
|
||||
},
|
||||
onFilesDropped () {
|
||||
this.$nextTick(() => {
|
||||
this.updateSize()
|
||||
})
|
||||
},
|
||||
handleVisibilityChange () {
|
||||
this.$store.commit('setChatFocused', !document.hidden)
|
||||
},
|
||||
handleLayoutChange () {
|
||||
this.updateSize()
|
||||
let mobileLayout = this.isMobileLayout
|
||||
if (this.mobileLayout !== mobileLayout) {
|
||||
if (this.mobileLayout === false && mobileLayout === true) {
|
||||
this.setMobileChatLayout()
|
||||
}
|
||||
if (this.mobileLayout === true && mobileLayout === false) {
|
||||
this.unsetMobileChatLayout()
|
||||
}
|
||||
this.mobileLayout = this.isMobileLayout
|
||||
this.$nextTick(() => {
|
||||
this.updateSize()
|
||||
this.scrollDown()
|
||||
})
|
||||
}
|
||||
},
|
||||
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%'
|
||||
body.style.overscrollBehavior = 'none'
|
||||
}
|
||||
|
||||
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.updateSize()
|
||||
})
|
||||
},
|
||||
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'
|
||||
body.style.overscrollBehavior = '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'
|
||||
}
|
||||
},
|
||||
handleResize (newHeight) {
|
||||
this.updateSize(newHeight)
|
||||
},
|
||||
updateSize (newHeight, _diff) {
|
||||
let h = this.$refs.header
|
||||
let s = this.$refs.scrollable
|
||||
let f = this.$refs.footer
|
||||
if (h && s && f) {
|
||||
let height = 0
|
||||
if (this.isMobileLayout) {
|
||||
height = parseFloat(getComputedStyle(window.document.body, null).height.replace('px', ''))
|
||||
let newHeight = (height - h.clientHeight - f.clientHeight)
|
||||
s.style.height = newHeight + 'px'
|
||||
} else {
|
||||
height = parseFloat(getComputedStyle(this.$refs.inner, null).height.replace('px', ''))
|
||||
let newHeight = (height - h.clientHeight - f.clientHeight)
|
||||
s.style.height = newHeight + 'px'
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollDown (options = {}) {
|
||||
let { behavior = 'auto', forceRead = false } = options
|
||||
let container = this.$refs.scrollable
|
||||
let scrollable = this.$refs.scrollable
|
||||
this.doScrollDown(scrollable, container, behavior)
|
||||
if (forceRead || this.newMessageCount > 0) {
|
||||
this.readChat()
|
||||
}
|
||||
},
|
||||
doScrollDown (scrollable, container, behavior) {
|
||||
if (!container) { return }
|
||||
this.$nextTick(() => {
|
||||
scrollable.scrollTo({ top: container.scrollHeight, left: 0, behavior })
|
||||
})
|
||||
},
|
||||
bottomedOut (offset) {
|
||||
let bottomedOut = false
|
||||
|
||||
if (this.$refs.scrollable) {
|
||||
let scrollHeight = this.$refs.scrollable.scrollTop + (offset || 0)
|
||||
let totalHeight = this.$refs.scrollable.scrollHeight - this.$refs.scrollable.offsetHeight
|
||||
bottomedOut = totalHeight <= scrollHeight
|
||||
}
|
||||
|
||||
return bottomedOut
|
||||
},
|
||||
handleScroll: throttle(function () {
|
||||
if (this.bottomedOut(150)) {
|
||||
this.jumpToBottomButtonVisible = false
|
||||
let newMessageCount = this.newMessageCount
|
||||
if (newMessageCount > 0) {
|
||||
this.readChat()
|
||||
}
|
||||
} else {
|
||||
this.jumpToBottomButtonVisible = true
|
||||
}
|
||||
}, 100),
|
||||
goBack () {
|
||||
this.$router.push({ name: 'chats', params: { username: this.screen_name } })
|
||||
},
|
||||
fetchChat (isFirstFetch, chatId) {
|
||||
this.chatViewItems = chatService.getView(this.currentChatMessageService)
|
||||
if (isFirstFetch) {
|
||||
this.scrollDown({ forceRead: true })
|
||||
}
|
||||
this.backendInteractor.chatMessages({ id: chatId })
|
||||
.then((messages) => {
|
||||
let bottomedOut = this.bottomedOut()
|
||||
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
|
||||
this.chatViewItems = chatService.getView(this.currentChatMessageService)
|
||||
this.newMessageCount = this.currentChatMessageService.newMessageCount
|
||||
if (isFirstFetch) {
|
||||
this.$nextTick(() => {
|
||||
this.updateSize()
|
||||
})
|
||||
} else if (bottomedOut) {
|
||||
this.scrollDown()
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.loadingMessages = false
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
},
|
||||
readChat () {
|
||||
if (!this.currentChat.id) { return }
|
||||
this.$store.dispatch('readChat', { id: this.currentChat.id })
|
||||
this.newMessageCount = this.currentChatMessageService.newMessageCount
|
||||
},
|
||||
async startFetching () {
|
||||
const chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
|
||||
this.$store.dispatch('addOpenedChat', { chat })
|
||||
this.doStartFetching()
|
||||
},
|
||||
doStartFetching () {
|
||||
let chatId = this.currentChat.id
|
||||
this.$store.dispatch('startFetchingCurrentChat', {
|
||||
fetcher: () => setInterval(() => this.fetchChat(false, chatId), 5000)
|
||||
})
|
||||
this.fetchChat(true, chatId)
|
||||
},
|
||||
poster ({ status }) {
|
||||
return this.backendInteractor.postChatMessage({
|
||||
id: this.currentChat.id,
|
||||
content: status
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Chat
|
192
src/components/chat/chat.scss
Normal file
192
src/components/chat/chat.scss
Normal file
|
@ -0,0 +1,192 @@
|
|||
.direct-conversation-view {
|
||||
display: flex;
|
||||
height: calc(100vh - 60px);
|
||||
width: 100%;
|
||||
|
||||
.direct-conversation-view-inner {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
margin-top: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
|
||||
.direct-conversation-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 ;
|
||||
|
||||
&.panel {
|
||||
&::after {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.direct-conversation-view-heading {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
border-radius: none;
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
|
||||
.go-back-button {
|
||||
cursor: pointer;
|
||||
margin-right: 0.7em;
|
||||
|
||||
i {
|
||||
color: $fallback--link;
|
||||
color: var(--panelLink, $fallback--link);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
flex-shrink: 1;
|
||||
margin-right: 0em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 28px;
|
||||
|
||||
flex-shrink: 1;
|
||||
margin-right: 0em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 70%;
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
padding: 0 10px;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 800px) {
|
||||
.direct-conversation-view {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.direct-conversation-view-inner {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
|
||||
.direct-conversation-view-body {
|
||||
display: flex;
|
||||
min-height: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0 !important;
|
||||
|
||||
.direct-conversation-view-heading {
|
||||
position: static;
|
||||
z-index: 9999;
|
||||
top: 0;
|
||||
margin-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
display: unset;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
bottom: auto;
|
||||
|
||||
.post-status-form form {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.jump-to-bottom-button {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
border-radius: 100%;
|
||||
position: absolute;
|
||||
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);
|
||||
}
|
||||
|
||||
.new-messages-alert-dot {
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
border-radius: 100%;
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
position: absolute;
|
||||
top: calc(50% - 8px);
|
||||
text-align: center;
|
||||
font-style: normal;;
|
||||
font-weight: bolder;
|
||||
margin-top: -1rem;
|
||||
font-size: 0.8em;
|
||||
background-color: $fallback--cRed;
|
||||
background-color: var(--badgeNotification, $fallback--cRed);
|
||||
color: white;
|
||||
color: var(--badgeNotificationText, white);
|
||||
}
|
||||
}
|
90
src/components/chat/chat.vue
Normal file
90
src/components/chat/chat.vue
Normal file
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<div class="direct-conversation-view">
|
||||
<div class="direct-conversation-view-inner">
|
||||
<div
|
||||
id="nav"
|
||||
ref="inner"
|
||||
class="panel-default panel direct-conversation-view-body"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading direct-conversation-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
|
||||
:users="chatParticipants"
|
||||
:fallback-user="currentUser"
|
||||
/>
|
||||
</div>
|
||||
<ChatAvatar
|
||||
:users="chatParticipants"
|
||||
:fallback-user="currentUser"
|
||||
width="35px"
|
||||
height="35px"
|
||||
/>
|
||||
</div>
|
||||
<template>
|
||||
<div
|
||||
ref="scrollable"
|
||||
class="scrollable"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<ChatMessage
|
||||
v-for="chatViewItem in chatViewItems"
|
||||
:key="chatViewItem.id"
|
||||
:chat-view-item="chatViewItem"
|
||||
:sequence-hovered="chatViewItem.sequenceId === hoveredSequenceId"
|
||||
@hover="onStatusHover"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="footer"
|
||||
class="panel-body footer"
|
||||
>
|
||||
<div
|
||||
class="jump-to-bottom-button"
|
||||
:class="{ 'visible': !loadingMessages && jumpToBottomButtonVisible }"
|
||||
@click="scrollDown({ behavior: 'smooth' })"
|
||||
>
|
||||
<i class="icon-down-open">
|
||||
<div
|
||||
v-if="newMessageCount"
|
||||
class="new-messages-alert-dot"
|
||||
>
|
||||
{{ newMessageCount }}
|
||||
</div>
|
||||
</i>
|
||||
</div>
|
||||
<PostStatusForm
|
||||
:disabled="loadingChat"
|
||||
:disable-subject="true"
|
||||
:disable-scope-selector="true"
|
||||
:disable-notice="true"
|
||||
:disable-attachments="true"
|
||||
:disable-polls="true"
|
||||
:poster="poster"
|
||||
:preserve-focus="true"
|
||||
:polls-available="false"
|
||||
:placeholder="formPlaceholder"
|
||||
max-height="160"
|
||||
@resize="handleResize"
|
||||
@posted="onPosted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat.js"></script>
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import './chat.scss';
|
||||
</style>
|
34
src/components/chat_avatar/chat_avatar.js
Normal file
34
src/components/chat_avatar/chat_avatar.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
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: ['users', 'fallbackUser', 'width', 'height'],
|
||||
components: {
|
||||
StillImage
|
||||
},
|
||||
methods: {
|
||||
getUserProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
firstUser () {
|
||||
return this.users[0] || this.fallbackUser
|
||||
},
|
||||
secondUser () {
|
||||
return this.users[1]
|
||||
},
|
||||
thirdUser () {
|
||||
return this.users[2]
|
||||
},
|
||||
fourthUser () {
|
||||
return this.users[3]
|
||||
},
|
||||
...mapState({
|
||||
betterShadow: state => state.interface.browserSupport.cssFilter
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatAvatar
|
127
src/components/chat_avatar/chat_avatar.vue
Normal file
127
src/components/chat_avatar/chat_avatar.vue
Normal file
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="firstUser && secondUser"
|
||||
class="direct-conversation-multi-user-avatar"
|
||||
:style="{ 'width': width, 'height': height }"
|
||||
>
|
||||
<StillImage
|
||||
v-if="fourthUser"
|
||||
class="avatar avatar-fourth direct-conversation-avatar"
|
||||
:alt="fourthUser.screen_name"
|
||||
:title="fourthUser.screen_name"
|
||||
:src="fourthUser.profile_image_url_original"
|
||||
error-src="/images/avi.png"
|
||||
:class="{ 'better-shadow': betterShadow }"
|
||||
/>
|
||||
<StillImage
|
||||
v-if="thirdUser"
|
||||
class="avatar avatar-third direct-conversation-avatar"
|
||||
:alt="thirdUser.screen_name"
|
||||
:title="thirdUser.screen_name"
|
||||
:src="thirdUser.profile_image_url_original"
|
||||
error-src="/images/avi.png"
|
||||
:class="{ 'better-shadow': betterShadow }"
|
||||
/>
|
||||
<StillImage
|
||||
class="avatar avatar-second direct-conversation-avatar"
|
||||
:alt="secondUser.screen_name"
|
||||
:title="secondUser.screen_name"
|
||||
:src="secondUser.profile_image_url_original"
|
||||
error-src="/images/avi.png"
|
||||
:class="{ 'better-shadow': betterShadow }"
|
||||
:style="{ 'height': fourthUser ? '50%' : '100%' }"
|
||||
/>
|
||||
<StillImage
|
||||
class="avatar avatar-first direct-conversation-avatar"
|
||||
:alt="firstUser.screen_name"
|
||||
:title="firstUser.screen_name"
|
||||
:src="firstUser.profile_image_url_original"
|
||||
error-src="/images/avi.png"
|
||||
:class="{ 'better-shadow': betterShadow }"
|
||||
:style="{ 'height': thirdUser ? '50%' : '100%' }"
|
||||
/>
|
||||
</div>
|
||||
<router-link
|
||||
v-else
|
||||
:to="getUserProfileLink(firstUser)"
|
||||
>
|
||||
<StillImage
|
||||
:style="{ 'width': width, 'height': height }"
|
||||
class="avatar direct-conversation-avatar single-user"
|
||||
:alt="firstUser.screen_name"
|
||||
:title="firstUser.screen_name"
|
||||
:src="firstUser.profile_image_url_original"
|
||||
error-src="/images/avi.png"
|
||||
:class="{ 'better-shadow': betterShadow }"
|
||||
/>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script src="./chat_avatar.js"></script>
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.direct-conversation-multi-user-avatar {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
.avatar.still-image {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-radius: 0;
|
||||
img, canvas {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&.avatar-first {
|
||||
float: right;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.avatar-second {
|
||||
float: right;
|
||||
}
|
||||
|
||||
&.avatar-third {
|
||||
float: right;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.avatar-fourth {
|
||||
float: right;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.direct-conversation-avatar {
|
||||
&.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>
|
44
src/components/chat_list/chat_list.js
Normal file
44
src/components/chat_list/chat_list.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { mapState } 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'
|
||||
import withLoadMore from '../../hocs/with_load_more/with_load_more'
|
||||
|
||||
const Chats = withLoadMore({
|
||||
fetch: (props, $store) => $store.dispatch('fetchChats'),
|
||||
select: (props, $store) => $store.state.chats.chatList.data,
|
||||
destroy: (props, $store) => undefined,
|
||||
childPropName: 'items'
|
||||
})(List)
|
||||
|
||||
const ChatList = {
|
||||
components: {
|
||||
ChatListItem,
|
||||
Chats,
|
||||
ChatNew
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
})
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isNew: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.dispatch('fetchChats', { reset: true })
|
||||
},
|
||||
methods: {
|
||||
cancelNewChat () {
|
||||
this.isNew = false
|
||||
this.$store.dispatch('fetchChats', { reset: true })
|
||||
},
|
||||
newChat () {
|
||||
this.isNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatList
|
56
src/components/chat_list/chat_list.vue
Normal file
56
src/components/chat_list/chat_list.vue
Normal file
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div v-if="isNew">
|
||||
<ChatNew @cancel="cancelNewChat" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="panel panel-default"
|
||||
style="min-height: calc(100vh - 67px); margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0;"
|
||||
>
|
||||
<div class="panel-heading truncated-text-wrapper">
|
||||
<span class="title truncated-text">
|
||||
{{ $t("chats.chats") }}
|
||||
</span>
|
||||
<span style="width: 0.75rem;">{{ ' ' }}</span>
|
||||
<button @click="newChat">
|
||||
{{ $t("chats.new") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="timeline">
|
||||
<Chats>
|
||||
<template
|
||||
slot="item"
|
||||
slot-scope="{item}"
|
||||
>
|
||||
<ChatListItem
|
||||
:key="item.id"
|
||||
:compact="false"
|
||||
:chat="item"
|
||||
/>
|
||||
</template>
|
||||
</Chats>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_list.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.truncated-text-wrapper {
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
|
||||
.truncated-text {
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
word-wrap: break-word;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
38
src/components/chat_list_item/chat_list_item.js
Normal file
38
src/components/chat_list_item/chat_list_item.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { mapState } from 'vuex'
|
||||
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
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
})
|
||||
},
|
||||
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
|
156
src/components/chat_list_item/chat_list_item.scss
Normal file
156
src/components/chat_list_item/chat_list_item.scss
Normal file
|
@ -0,0 +1,156 @@
|
|||
.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;
|
||||
|
||||
.chat-preview {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0.35rem 0;
|
||||
height: 16px;
|
||||
color: $fallback--text;
|
||||
color: var(--faintText, $fallback--text);
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
line-height: 1em;
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.faint-link {
|
||||
color: var(--faintLink, $fallback--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
min-width: 1.6em;
|
||||
margin-right: 0.2em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--faintLink, $fallback--link);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin-right: 0.4em;
|
||||
flex-shrink: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
max-width: 55%;
|
||||
font-weight: bold;
|
||||
|
||||
img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: $fallback--link;
|
||||
color: var(--faintLink, $fallback--link);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
word-wrap: break-word;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
vertical-align: middle;
|
||||
|
||||
&.emoji {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
48
src/components/chat_list_item/chat_list_item.vue
Normal file
48
src/components/chat_list_item/chat_list_item.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div
|
||||
class="chat-list-item"
|
||||
@click.capture.prevent="openChat"
|
||||
>
|
||||
<div class="chat-list-item-left">
|
||||
<ChatAvatar
|
||||
:users="[]"
|
||||
:fallback-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
|
||||
:users="[]"
|
||||
:fallback-user="chat.account"
|
||||
:with-links="false"
|
||||
/>
|
||||
</span>
|
||||
<span class="heading-right" />
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="chat-preview">
|
||||
{{ 'Last message placeholder' }}
|
||||
<div
|
||||
v-if="chat.unread > 0"
|
||||
class="alert-dot-number"
|
||||
>
|
||||
{{ chat.unread }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./chat_list_item.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
@import './chat_list_item.scss';
|
||||
</style>
|
62
src/components/chat_message/chat_message.js
Normal file
62
src/components/chat_message/chat_message.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { mapState, mapGetters } from 'vuex'
|
||||
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'
|
||||
|
||||
const ChatMessage = {
|
||||
name: 'ChatMessage',
|
||||
props: [
|
||||
'edited',
|
||||
'noHeading',
|
||||
'chatViewItem',
|
||||
'sequenceHovered'
|
||||
],
|
||||
components: {
|
||||
Attachment,
|
||||
StatusContent,
|
||||
UserAvatar,
|
||||
Gallery,
|
||||
LinkPreview,
|
||||
ChatMessageDate
|
||||
},
|
||||
computed: {
|
||||
// Returns HH:MM (hours and minutes) in local time.
|
||||
createdAt () {
|
||||
const time = this.chatViewItem.data.created_at
|
||||
const lang = this.mergedConfig.interfaceLanguage
|
||||
return time.toLocaleTimeString(lang, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
},
|
||||
isCurrentUser () {
|
||||
return this.message.account_id === this.currentUser.id
|
||||
},
|
||||
message () {
|
||||
return this.chatViewItem.data
|
||||
},
|
||||
isMessage () {
|
||||
return this.chatViewItem.type === 'message'
|
||||
},
|
||||
messageForStatusContent () {
|
||||
return {
|
||||
summary: '',
|
||||
statusnet_html: this.message.content,
|
||||
text: this.message.content,
|
||||
attachments: []
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
betterShadow: state => state.interface.browserSupport.cssFilter,
|
||||
currentUser: state => state.users.currentUser
|
||||
}),
|
||||
...mapGetters(['mergedConfig'])
|
||||
},
|
||||
methods: {
|
||||
onHover (bool) {
|
||||
this.$emit('hover', { state: bool, sequenceId: this.chatViewItem.sequenceId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatMessage
|
171
src/components/chat_message/chat_message.scss
Normal file
171
src/components/chat_message/chat_message.scss
Normal file
|
@ -0,0 +1,171 @@
|
|||
@import '../../_variables.scss';
|
||||
|
||||
.direct-conversation-status-wrapper {
|
||||
&.sequence-hovered {
|
||||
.animated.avatar {
|
||||
canvas {
|
||||
display: none;
|
||||
}
|
||||
img {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.media-heading {
|
||||
margin-left: 43px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.75em;
|
||||
}
|
||||
|
||||
.direct-conversation {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
border-left-width: 0px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding-bottom: 7px;
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-right: 10px;
|
||||
margin-top: auto;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.link-preview, .attachments {
|
||||
margin-bottom: 0.9em;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.direct-conversation-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: 80%;
|
||||
position: relative;
|
||||
float: right;
|
||||
min-width: 10rem;
|
||||
|
||||
.trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popover {
|
||||
width: 12rem;
|
||||
|
||||
.tooltip-arrow.popover-arrow {
|
||||
// overrides inline html arrow position
|
||||
left: calc(100% - 30px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.poll {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
&.with-media {
|
||||
width: 100%;
|
||||
|
||||
.gallery-row {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
box-sizing: border-box;
|
||||
border-radius: $fallback--chatMessageRadius;
|
||||
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
|
||||
}
|
||||
|
||||
.created-at {
|
||||
float: right;
|
||||
font-size: 0.8em;
|
||||
margin: -10px 0 -5px 4px;
|
||||
position: relative;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
a {
|
||||
color: var(--chatMessageOutgoingText, $fallback--text);
|
||||
}
|
||||
|
||||
::before {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.status-content {
|
||||
white-space: normal;
|
||||
|
||||
&::after {
|
||||
margin-right: 75px;
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.incoming {
|
||||
.status {
|
||||
background-color: var(--chatMessageIncomingBg, $fallback--bg);
|
||||
border: 1px solid var(--chatMessageIncomingBorder, --border);
|
||||
color: var(--chatMessageIncomingText, $fallback--text);
|
||||
|
||||
a {
|
||||
color: var(--chatMessageIncomingLink, $fallback--link);
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
|
||||
color: var(--chatMessageOutgoingText, $fallback--text);
|
||||
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
|
||||
}
|
||||
|
||||
.direct-conversation-inner {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
text-align: center;
|
||||
margin: 1.4em 0;
|
||||
font-size: 0.9em;
|
||||
user-select: none;
|
||||
color: $fallback--text;
|
||||
color: var(--faintedText, $fallback--text);
|
||||
}
|
41
src/components/chat_message/chat_message.vue
Normal file
41
src/components/chat_message/chat_message.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isMessage"
|
||||
class="direct-conversation-status-wrapper"
|
||||
:class="{ 'sequence-hovered': sequenceHovered }"
|
||||
@mouseover="onHover(true)"
|
||||
@mouseleave="onHover(false)"
|
||||
>
|
||||
<div
|
||||
class="direct-conversation"
|
||||
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
|
||||
>
|
||||
<div class="direct-conversation-inner">
|
||||
<div class="media status">
|
||||
<StatusContent
|
||||
:status="messageForStatusContent"
|
||||
:full-content="true">
|
||||
<span
|
||||
slot="footer"
|
||||
class="created-at"
|
||||
>
|
||||
{{ createdAt }}
|
||||
</span>
|
||||
</StatusContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="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 () {
|
||||
let today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (this.date.getTime() === today.getTime()) {
|
||||
return this.$t('display_date.today')
|
||||
} else {
|
||||
const lang = this.$store.getters.mergedConfig.interfaceLanguage
|
||||
return this.date.toLocaleDateString(lang, { 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')
|
||||
},
|
||||
goToNewChat (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
|
87
src/components/chat_new/chat_new.scss
Normal file
87
src/components/chat_new/chat_new.scss
Normal file
|
@ -0,0 +1,87 @@
|
|||
.direct-conversation-new {
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.selected-user-list {
|
||||
padding-left: 0.7em;
|
||||
padding-top: 0.5em;
|
||||
|
||||
.selected-user {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
border: 1px solid;
|
||||
border-color: $fallback--link;
|
||||
border-color: var(--link, $fallback--link);
|
||||
color: $fallback--link;
|
||||
color: var(--link, $fallback--link);
|
||||
padding: 0.5em;
|
||||
border-radius: $fallback--attachmentRadius;
|
||||
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
|
||||
margin-right: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--selectedPost, $fallback--lightBg);
|
||||
}
|
||||
}
|
||||
|
||||
.notice-dismissible {
|
||||
padding-right: 2.3rem;
|
||||
position: relative;
|
||||
|
||||
.dismiss {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: .35em;
|
||||
color: inherit;
|
||||
|
||||
i {
|
||||
color: $fallback--link;
|
||||
color: var(--link, $fallback--link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.member-list {
|
||||
padding-bottom: 0.67rem;
|
||||
|
||||
.user-card-wrap {
|
||||
.basic-user-card {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--selectedPost, $fallback--lightBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
margin: 0.7em 0.5em 0.7em 0.5em;
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.5em;
|
||||
float: right;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0 0.7em;
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
50
src/components/chat_new/chat_new.vue
Normal file
50
src/components/chat_new/chat_new.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div
|
||||
id="nav"
|
||||
class="panel-default panel direct-conversation-new"
|
||||
>
|
||||
<div
|
||||
ref="header"
|
||||
class="panel-heading"
|
||||
>
|
||||
<a
|
||||
class="go-back-button"
|
||||
style="cursor: pointer; margin-right: 0.7em"
|
||||
@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
|
||||
class="user-card-wrap"
|
||||
@click.capture.prevent="goToNewChat(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>
|
39
src/components/chat_title/chat_title.js
Normal file
39
src/components/chat_title/chat_title.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import Vue from 'vue'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
const USER_LIMIT = 10
|
||||
|
||||
export default Vue.component('direct-conversation-title', {
|
||||
name: 'ChatTitle',
|
||||
props: [
|
||||
'users', 'fallbackUser'
|
||||
],
|
||||
computed: {
|
||||
...mapState({
|
||||
currentUser: state => state.users.currentUser
|
||||
}),
|
||||
otherUsersTruncated () {
|
||||
return this.otherUsers.slice(0, USER_LIMIT)
|
||||
},
|
||||
otherUsers () {
|
||||
let otherUsers = this.users.filter(recipient => recipient.id !== this.currentUser.id)
|
||||
if (otherUsers.length === 0) {
|
||||
return [this.fallbackUser]
|
||||
} else {
|
||||
return otherUsers
|
||||
}
|
||||
},
|
||||
restCount () {
|
||||
return this.otherUsers.length - USER_LIMIT
|
||||
},
|
||||
title () {
|
||||
return this.otherUsers.map(u => u.screen_name).join(', ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getUserProfileLink (user) {
|
||||
return generateProfileLink(user.id, user.screen_name)
|
||||
}
|
||||
}
|
||||
})
|
41
src/components/chat_title/chat_title.vue
Normal file
41
src/components/chat_title/chat_title.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="direct-conversation-title"
|
||||
:title="title"
|
||||
>
|
||||
<span
|
||||
v-for="(user, index) in otherUsersTruncated"
|
||||
:key="user.id"
|
||||
class="username"
|
||||
v-html="user.name_html + (index + 1 < otherUsersTruncated.length ? ', ' : '')"
|
||||
/>
|
||||
</div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</template>
|
||||
|
||||
<script src="./chat_title.js"></script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../_variables.scss';
|
||||
|
||||
.direct-conversation-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.username {
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline;
|
||||
word-wrap: break-word;
|
||||
|
||||
img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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 () {
|
||||
|
|
|
@ -17,6 +17,11 @@
|
|||
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li style="position: relative">
|
||||
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
|
||||
<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") }}
|
||||
|
|
|
@ -2,6 +2,7 @@ import Status from '../status/status.vue'
|
|||
import UserAvatar from '../user_avatar/user_avatar.vue'
|
||||
import UserCard from '../user_card/user_card.vue'
|
||||
import Timeago from '../timeago/timeago.vue'
|
||||
import StatusContent from '../status_content/status_content.vue'
|
||||
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
|
||||
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
|
||||
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
|
||||
|
@ -19,7 +20,8 @@ const Notification = {
|
|||
Status,
|
||||
UserAvatar,
|
||||
UserCard,
|
||||
Timeago
|
||||
Timeago,
|
||||
StatusContent
|
||||
},
|
||||
methods: {
|
||||
toggleUserExpanded () {
|
||||
|
@ -79,6 +81,15 @@ const Notification = {
|
|||
},
|
||||
isStatusNotification () {
|
||||
return isStatusNotification(this.notification.type)
|
||||
},
|
||||
// TODO:
|
||||
messageForStatusContent () {
|
||||
return {
|
||||
summary: '',
|
||||
statusnet_html: this.notification.chatMessage.content,
|
||||
text: this.notification.chatMessage.content,
|
||||
attachments: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,6 +89,9 @@
|
|||
</i18n>
|
||||
</small>
|
||||
</span>
|
||||
<span v-if="notification.type === 'pleroma:chat_mention'">
|
||||
<i class="fa icon-chat lit" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isStatusNotification"
|
||||
|
@ -105,6 +108,20 @@
|
|||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'pleroma:chat_mention'"
|
||||
class="timeago"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'chat', params: { recipient_id: notification.chatMessage.account_id } }"
|
||||
class="faint-link"
|
||||
>
|
||||
<Timeago
|
||||
:time="notification.created_at"
|
||||
:auto-update="240"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="timeago"
|
||||
|
@ -156,6 +173,11 @@
|
|||
@{{ notification.target.screen_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else-if="notification.type === 'pleroma:chat_mention'">
|
||||
<StatusContent
|
||||
:status="messageForStatusContent"
|
||||
:full-content="true" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<status
|
||||
class="faint"
|
||||
|
|
|
@ -31,7 +31,16 @@ const PostStatusForm = {
|
|||
'repliedUser',
|
||||
'attentions',
|
||||
'copyMessageScope',
|
||||
'subject'
|
||||
'subject',
|
||||
'disableSubject',
|
||||
'disableScopeSelector',
|
||||
'disableNotice',
|
||||
'disablePolls',
|
||||
'disableAttachments',
|
||||
'placeholder',
|
||||
'maxHeight',
|
||||
'poster',
|
||||
'preserveFocus'
|
||||
],
|
||||
components: {
|
||||
MediaUpload,
|
||||
|
@ -151,10 +160,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 &&
|
||||
|
@ -182,7 +192,8 @@ const PostStatusForm = {
|
|||
}
|
||||
|
||||
this.posting = true
|
||||
statusPoster.postStatus({
|
||||
|
||||
const postingOptions = {
|
||||
status: newStatus.status,
|
||||
spoilerText: newStatus.spoilerText || null,
|
||||
visibility: newStatus.visibility,
|
||||
|
@ -192,7 +203,11 @@ const PostStatusForm = {
|
|||
inReplyToStatusId: this.replyTo,
|
||||
contentType: newStatus.contentType,
|
||||
poll
|
||||
}).then((data) => {
|
||||
}
|
||||
|
||||
const poster = this.poster ? this.poster : statusPoster.postStatus
|
||||
|
||||
poster(postingOptions).then((data) => {
|
||||
if (!data.error) {
|
||||
this.newStatus = {
|
||||
status: '',
|
||||
|
@ -205,7 +220,12 @@ const PostStatusForm = {
|
|||
this.pollFormVisible = false
|
||||
this.$refs.mediaUpload.clearFile()
|
||||
this.clearPollForm()
|
||||
this.$emit('posted')
|
||||
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
|
||||
|
@ -270,6 +290,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
|
||||
}
|
||||
|
@ -322,8 +343,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
|
||||
|
|
|
@ -62,14 +62,13 @@
|
|||
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
|
||||
</p>
|
||||
<EmojiInput
|
||||
v-if="newStatus.spoilerText || alwaysShowSubject"
|
||||
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
|
||||
v-model="newStatus.spoilerText"
|
||||
enable-emoji-picker
|
||||
:suggest="emojiSuggestor"
|
||||
class="form-control"
|
||||
>
|
||||
<input
|
||||
|
||||
v-model="newStatus.spoilerText"
|
||||
type="text"
|
||||
:placeholder="$t('post_status.content_warning')"
|
||||
|
@ -91,10 +90,11 @@
|
|||
<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"
|
||||
:class="{ 'scrollable-form': !!maxHeight }"
|
||||
@keydown.meta.enter="postStatus(newStatus)"
|
||||
@keyup.ctrl.enter="postStatus(newStatus)"
|
||||
@drop="fileDrop"
|
||||
|
@ -111,7 +111,10 @@
|
|||
{{ charactersLeft }}
|
||||
</p>
|
||||
</EmojiInput>
|
||||
<div class="visibility-tray">
|
||||
<div
|
||||
v-if="!disableScopeSelector"
|
||||
class="visibility-tray"
|
||||
>
|
||||
<scope-selector
|
||||
:show-all="showAllScopes"
|
||||
:user-default="userDefaultScope"
|
||||
|
@ -166,6 +169,7 @@
|
|||
>
|
||||
<div class="form-bottom-left">
|
||||
<media-upload
|
||||
v-if="disableAttachments !== true"
|
||||
ref="mediaUpload"
|
||||
class="media-upload-icon"
|
||||
:drop-files="dropFiles"
|
||||
|
@ -175,6 +179,7 @@
|
|||
/>
|
||||
<div
|
||||
class="emoji-icon"
|
||||
:style="{ 'text-align' : (disableAttachments ? 'left' : 'center') }"
|
||||
>
|
||||
<i
|
||||
:title="$t('emoji.add_emoji')"
|
||||
|
@ -295,6 +300,8 @@
|
|||
}
|
||||
|
||||
.post-status-form {
|
||||
position: relative;
|
||||
|
||||
.visibility-tray {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -473,6 +480,10 @@
|
|||
padding-bottom: 1.75em;
|
||||
min-height: 1px;
|
||||
box-sizing: content-box;
|
||||
|
||||
&.scrollable-form {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.main-input {
|
||||
|
|
|
@ -46,6 +46,9 @@
|
|||
<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 } }">
|
||||
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="currentUser"
|
||||
|
|
|
@ -98,7 +98,8 @@ export default {
|
|||
avatarRadiusLocal: '',
|
||||
avatarAltRadiusLocal: '',
|
||||
attachmentRadiusLocal: '',
|
||||
tooltipRadiusLocal: ''
|
||||
tooltipRadiusLocal: '',
|
||||
chatMessageRadiusLocal: ''
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -213,7 +214,8 @@ export default {
|
|||
avatar: this.avatarRadiusLocal,
|
||||
avatarAlt: this.avatarAltRadiusLocal,
|
||||
tooltip: this.tooltipRadiusLocal,
|
||||
attachment: this.attachmentRadiusLocal
|
||||
attachment: this.attachmentRadiusLocal,
|
||||
chatMessage: this.chatMessageRadiusLocal
|
||||
}
|
||||
},
|
||||
preview () {
|
||||
|
|
|
@ -726,6 +726,63 @@
|
|||
/>
|
||||
<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')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingBgColorLocal"
|
||||
name="chatMessageIncomingBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.incoming_background')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingTextColorLocal"
|
||||
name="chatMessageIncomingTextColor"
|
||||
:fallback="previewTheme.colors.text || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.incoming_text')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingLinkColorLocal"
|
||||
name="chatMessageIncomingLinkColor"
|
||||
:fallback="previewTheme.colors.link || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.incoming_link')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageIncomingBorderColorLocal"
|
||||
name="chatMessageIncomingBorderLinkColor"
|
||||
:fallback="previewTheme.colors.fg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.incoming_border')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingBgColorLocal"
|
||||
name="chatMessageOutgoingBgColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.outgoing_background')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingTextColorLocal"
|
||||
name="chatMessageOutgoingTextColor"
|
||||
:fallback="previewTheme.colors.text || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.outgoing_text')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingLinkColorLocal"
|
||||
name="chatMessageOutgoingLinkColor"
|
||||
:fallback="previewTheme.colors.link || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.outgoing_link')"
|
||||
/>
|
||||
<ColorInput
|
||||
v-model="chatMessageOutgoingBorderColorLocal"
|
||||
name="chatMessageOutgoingBorderLinkColor"
|
||||
:fallback="previewTheme.colors.bg || 1"
|
||||
:label="$t('settings.style.advanced_colors.chat.outgoing_border')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -805,6 +862,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
|
||||
|
|
|
@ -12,5 +12,9 @@
|
|||
.error {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -118,7 +118,8 @@
|
|||
"user_search": "User Search",
|
||||
"search": "Search",
|
||||
"who_to_follow": "Who to follow",
|
||||
"preferences": "Preferences"
|
||||
"preferences": "Preferences",
|
||||
"chats": "Chats"
|
||||
},
|
||||
"notifications": {
|
||||
"broken_favorite": "Unknown status, searching for it...",
|
||||
|
@ -272,6 +273,7 @@
|
|||
"change_password": "Change Password",
|
||||
"change_password_error": "There was an issue changing your password.",
|
||||
"changed_password": "Password changed successfully!",
|
||||
"chatMessageRadius": "Chat message",
|
||||
"collapse_subject": "Collapse posts with subjects",
|
||||
"composing": "Composing",
|
||||
"confirm_new_password": "Confirm new password",
|
||||
|
@ -492,7 +494,17 @@
|
|||
"selectedMenu": "Selected menu item",
|
||||
"disabled": "Disabled",
|
||||
"toggled": "Toggled",
|
||||
"tabs": "Tabs"
|
||||
"tabs": "Tabs",
|
||||
"chat": {
|
||||
"incoming_background": "Incoming background",
|
||||
"incoming_text": "Incoming text",
|
||||
"incoming_link": "Incoming link",
|
||||
"incoming_border": "Incoming border",
|
||||
"outgoing_background": "Outgoing background",
|
||||
"outgoing_text": "Outgoing text",
|
||||
"outgoing_link": "Outgoing link",
|
||||
"outgoing_border": "Outgoing border"
|
||||
}
|
||||
},
|
||||
"radii": {
|
||||
"_tab_label": "Roundness"
|
||||
|
@ -642,6 +654,7 @@
|
|||
"its_you": "It's you!",
|
||||
"media": "Media",
|
||||
"mention": "Mention",
|
||||
"message": "Message",
|
||||
"mute": "Mute",
|
||||
"muted": "Muted",
|
||||
"per_day": "per day",
|
||||
|
@ -739,5 +752,15 @@
|
|||
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
|
||||
"password_reset_required": "You must reset your password to log in.",
|
||||
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
|
||||
},
|
||||
"chats": {
|
||||
"message_user": "Message {nickname}",
|
||||
"write_message": "Write a message",
|
||||
"delete": "Delete",
|
||||
"chats": "Chats",
|
||||
"new": "New Chat"
|
||||
},
|
||||
"display_date": {
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js'
|
|||
import reportsModule from './modules/reports.js'
|
||||
import pollsModule from './modules/polls.js'
|
||||
import postStatusModule from './modules/postStatus.js'
|
||||
import chatsModule from './modules/chats.js'
|
||||
|
||||
import VueI18n from 'vue-i18n'
|
||||
|
||||
|
@ -81,7 +82,8 @@ const persistedStateOptions = {
|
|||
oauthTokens: oauthTokensModule,
|
||||
reports: reportsModule,
|
||||
polls: pollsModule,
|
||||
postStatus: postStatusModule
|
||||
postStatus: postStatusModule,
|
||||
chats: chatsModule
|
||||
},
|
||||
plugins: [persistedState, pushNotifications],
|
||||
strict: false // Socket modifies itself, let's ignore this for now.
|
||||
|
|
163
src/modules/chats.js
Normal file
163
src/modules/chats.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { find, omitBy, debounce, last } 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: [],
|
||||
pagination: { maxId: undefined, minId: undefined },
|
||||
idStore: {}
|
||||
})
|
||||
|
||||
const defaultState = {
|
||||
chatList: emptyChatList(),
|
||||
openedChats: {},
|
||||
openedChatMessageServices: {},
|
||||
fetcher: undefined,
|
||||
chatFocused: false,
|
||||
currentChatId: null
|
||||
}
|
||||
|
||||
const chats = {
|
||||
state: { ...defaultState },
|
||||
getters: {
|
||||
currentChat: state => state.openedChats[state.currentChatId],
|
||||
currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId]
|
||||
},
|
||||
actions: {
|
||||
// Chat list
|
||||
startFetchingChats ({ dispatch }) {
|
||||
setInterval(() => {
|
||||
dispatch('fetchChats', { reset: true })
|
||||
// dispatch('refreshCurrentUser')
|
||||
}, 5000)
|
||||
},
|
||||
fetchChats ({ dispatch, rootState, commit }, params = {}) {
|
||||
const pagination = rootState.chats.chatList.pagination
|
||||
const opts = { maxId: params.reset ? undefined : pagination.maxId }
|
||||
|
||||
return rootState.api.backendInteractor.chats(opts)
|
||||
.then(({ chats, pagination }) => {
|
||||
dispatch('addNewChats', { chats, pagination })
|
||||
return chats
|
||||
})
|
||||
},
|
||||
addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats, pagination }) {
|
||||
commit('addNewChats', { dispatch, chats, pagination, rootGetters })
|
||||
},
|
||||
updateChatByAccountId: debounce(({ rootState, commit, dispatch, rootGetters }, { accountId }) => {
|
||||
rootState.api.backendInteractor.getOrCreateChat({ accountId }).then(chat => {
|
||||
commit('updateChat', { dispatch, rootGetters, chat: parseChat(chat) })
|
||||
})
|
||||
}, 100),
|
||||
|
||||
// Opened Chats
|
||||
startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
|
||||
dispatch('setCurrentChatFetcher', { fetcher })
|
||||
},
|
||||
setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
|
||||
commit('setCurrentChatFetcher', { fetcher })
|
||||
},
|
||||
addOpenedChat ({ commit, dispatch }, { chat }) {
|
||||
commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
|
||||
dispatch('addNewUsers', [chat.account])
|
||||
},
|
||||
addChatMessages ({ commit }, value) {
|
||||
commit('addChatMessages', value)
|
||||
},
|
||||
setChatFocused ({ commit }, value) {
|
||||
commit('setChatFocused', 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, dispatch }, { id }) {
|
||||
dispatch('resetChatNewMessageCount')
|
||||
rootState.api.backendInteractor.readChat({ id }).then(() => {
|
||||
// dispatch('refreshCurrentUser')
|
||||
})
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
setCurrentChatFetcher (state, { fetcher }) {
|
||||
let prevFetcher = state.fetcher
|
||||
if (prevFetcher) {
|
||||
clearInterval(prevFetcher)
|
||||
}
|
||||
state.fetcher = fetcher && fetcher()
|
||||
},
|
||||
addOpenedChat (state, { _dispatch, chat }) {
|
||||
state.currentChatId = chat.id
|
||||
state.openedChats[chat.id] = chat
|
||||
|
||||
if (!state.openedChatMessageServices[chat.id]) {
|
||||
state.openedChatMessageServices[chat.id] = chatService.empty(chat.id)
|
||||
}
|
||||
},
|
||||
setCurrentChatId (state, { chatId }) {
|
||||
state.currentChatId = chatId
|
||||
},
|
||||
addNewChats (state, { _dispatch, chats, pagination, _rootGetters }) {
|
||||
if (chats.length > 0) {
|
||||
state.chatList.pagination = { maxId: last(chats).id }
|
||||
}
|
||||
chats.forEach((conversation) => {
|
||||
// This is to prevent duplicate conversations being added
|
||||
// (right now, backend can return the same conversation on different pages)
|
||||
if (!state.chatList.idStore[conversation.id]) {
|
||||
state.chatList.data.push(conversation)
|
||||
state.chatList.idStore[conversation.id] = conversation
|
||||
} else {
|
||||
const chat = find(state.chatList.data, { id: conversation.id })
|
||||
chat.last_status = conversation.last_status
|
||||
chat.unread = conversation.unread
|
||||
state.chatList.idStore[conversation.id] = conversation
|
||||
}
|
||||
})
|
||||
},
|
||||
updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
|
||||
let chat = find(state.chatList.data, { id: updatedChat.id })
|
||||
if (chat) {
|
||||
state.chatList.data = state.chatList.data.filter(d => {
|
||||
return d.id !== updatedChat.id
|
||||
})
|
||||
}
|
||||
state.chatList.data.unshift(updatedChat)
|
||||
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, { _dispatch }) {
|
||||
state.chats.data = []
|
||||
state.chats.idStore = {}
|
||||
},
|
||||
setChatsLoading (state, { value }) {
|
||||
state.chats.loading = value
|
||||
},
|
||||
addChatMessages (state, { chatId, messages }) {
|
||||
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||
if (chatMessageService) {
|
||||
chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) })
|
||||
}
|
||||
},
|
||||
resetChatNewMessageCount (state, _value) {
|
||||
const chatMessageService = state.openedChatMessageServices[state.currentChatId]
|
||||
chatService.resetNewMessageCount(chatMessageService)
|
||||
},
|
||||
setChatFocused (state, value) {
|
||||
state.chatFocused = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default chats
|
|
@ -35,7 +35,8 @@ export const defaultState = {
|
|||
repeats: true,
|
||||
moves: true,
|
||||
emojiReactions: false,
|
||||
followRequest: true
|
||||
followRequest: true,
|
||||
chatMention: true
|
||||
},
|
||||
webPushNotifications: false,
|
||||
muteWords: [],
|
||||
|
|
|
@ -83,7 +83,8 @@ const visibleNotificationTypes = (rootState) => {
|
|||
rootState.config.notificationVisibility.repeats && 'repeat',
|
||||
rootState.config.notificationVisibility.follows && 'follow',
|
||||
rootState.config.notificationVisibility.moves && 'move',
|
||||
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reactions'
|
||||
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reactions',
|
||||
rootState.config.notificationVisibility.chatMention && 'pleroma:chat_mention'
|
||||
].filter(_ => _)
|
||||
}
|
||||
|
||||
|
@ -331,6 +332,11 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
|
|||
dispatch('fetchEmojiReactionsBy', notification.status.id)
|
||||
}
|
||||
|
||||
if (notification.type === 'pleroma:chat_mention') {
|
||||
dispatch('addChatMessages', { chatId: notification.chatMessage.chat_id, messages: [notification.chatMessage] })
|
||||
dispatch('updateChatByAccountId', { accountId: notification.from_profile.id })
|
||||
}
|
||||
|
||||
// Only add a new notification if we don't have one for the same action
|
||||
if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
|
||||
state.notifications.maxId = notification.id > state.notifications.maxId
|
||||
|
@ -373,6 +379,8 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
|
|||
notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
|
||||
} else if (isStatusNotification(notification.type)) {
|
||||
notifObj.body = notification.status.text
|
||||
} else if (notification.type === 'pleroma:chat_mention') {
|
||||
notifObj.body = notification.chatMessage.content
|
||||
}
|
||||
|
||||
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
|
||||
|
@ -489,7 +497,7 @@ export const mutations = {
|
|||
},
|
||||
setDeleted (state, { status }) {
|
||||
const newStatus = state.allStatusesObject[status.id]
|
||||
newStatus.deleted = true
|
||||
if (newStatus) newStatus.deleted = true
|
||||
},
|
||||
setManyDeleted (state, condition) {
|
||||
Object.values(state.allStatusesObject).forEach(status => {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { each, map, concat, last, get } from 'lodash'
|
||||
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
|
||||
import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat } from '../entity_normalizer/entity_normalizer.service.js'
|
||||
import 'whatwg-fetch'
|
||||
import { RegistrationError, StatusCodeError } from '../errors/errors'
|
||||
|
||||
|
@ -78,6 +78,10 @@ const MASTODON_STREAMING = '/api/v1/streaming'
|
|||
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
|
||||
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
|
||||
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
|
||||
const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
|
||||
const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
|
||||
const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
|
||||
const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
|
||||
|
||||
const oldfetch = window.fetch
|
||||
|
||||
|
@ -1119,6 +1123,60 @@ export const handleMastoWS = (wsEvent) => {
|
|||
}
|
||||
}
|
||||
|
||||
const chats = ({ maxId, sinceId, limit = 20, recipients = [], credentials }) => {
|
||||
let url = PLEROMA_CHATS_URL
|
||||
const recipientIds = recipients.map(r => `recipients[]=${r}`).join('&')
|
||||
const args = [
|
||||
maxId && `max_id=${maxId}`,
|
||||
sinceId && `since_id=${sinceId}`,
|
||||
limit && `limit=${limit}`,
|
||||
recipientIds && `${recipientIds}`
|
||||
].filter(_ => _).join('&')
|
||||
|
||||
let pagination = {}
|
||||
url = url + (args ? '?' + args : '')
|
||||
return fetch(url, { headers: authHeaders(credentials) })
|
||||
.then((data) => data.json())
|
||||
.then((data) => {
|
||||
return { chats: data.map(parseChat), pagination }
|
||||
})
|
||||
}
|
||||
|
||||
const getOrCreateChat = ({ accountId, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_URL(accountId),
|
||||
method: 'POST',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const chatMessages = ({ id, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_MESSAGES_URL(id),
|
||||
method: 'GET',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const postChatMessage = ({ id, content, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_MESSAGES_URL(id),
|
||||
method: 'POST',
|
||||
payload: {
|
||||
'content': content
|
||||
},
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const readChat = ({ id, credentials }) => {
|
||||
return promisedRequest({
|
||||
url: PLEROMA_CHAT_READ_URL(id),
|
||||
method: 'POST',
|
||||
credentials
|
||||
})
|
||||
}
|
||||
|
||||
const apiService = {
|
||||
verifyCredentials,
|
||||
fetchTimeline,
|
||||
|
@ -1195,7 +1253,12 @@ const apiService = {
|
|||
searchUsers,
|
||||
fetchDomainMutes,
|
||||
muteDomain,
|
||||
unmuteDomain
|
||||
unmuteDomain,
|
||||
chats,
|
||||
getOrCreateChat,
|
||||
chatMessages,
|
||||
postChatMessage,
|
||||
readChat
|
||||
}
|
||||
|
||||
export default apiService
|
||||
|
|
100
src/services/chat_service/chat_service.js
Normal file
100
src/services/chat_service/chat_service.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
const empty = (chatId) => {
|
||||
return {
|
||||
idIndex: {},
|
||||
messages: [],
|
||||
newMessageCount: 0,
|
||||
lastSeenTimestamp: 0,
|
||||
chatId: chatId
|
||||
}
|
||||
}
|
||||
|
||||
const add = (storage, { messages: newMessages }) => {
|
||||
if (!storage) { return }
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
let message = newMessages[i]
|
||||
|
||||
// sanity check
|
||||
if (message.chat_id !== storage.chatId) { return }
|
||||
|
||||
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 sequence of messages made by the same user
|
||||
const getView = (storage) => {
|
||||
if (!storage) { return [] }
|
||||
let messages = _.sortBy(storage.messages, 'id')
|
||||
|
||||
let res = []
|
||||
|
||||
let prev = messages[messages.length - 1]
|
||||
let currentSequenceId
|
||||
|
||||
let firstMessages = messages[0]
|
||||
|
||||
if (firstMessages) {
|
||||
let date = new Date(firstMessages.created_at)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
res.push({ type: 'date', date: date, id: date.getTime().toString() })
|
||||
}
|
||||
|
||||
let afterDate = false
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
let message = messages[i]
|
||||
let nextMessage = messages[i + 1]
|
||||
|
||||
let date = new Date(message.created_at)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
// insert date separator and start a new sequence
|
||||
if (prev && prev.date < date) {
|
||||
res.push({ type: 'date', date: date, id: date.getTime().toString() })
|
||||
prev['isTail'] = true
|
||||
currentSequenceId = undefined
|
||||
afterDate = true
|
||||
}
|
||||
|
||||
let object = { type: 'message', data: message, date: date, id: message.id, sequenceId: currentSequenceId }
|
||||
|
||||
// end a message sequence
|
||||
if ((nextMessage && nextMessage.account_id) !== message.account_id) {
|
||||
object['isTail'] = true
|
||||
currentSequenceId = undefined
|
||||
}
|
||||
// start a new message sequence
|
||||
if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) {
|
||||
currentSequenceId = _.uniqueId()
|
||||
object['isHead'] = true
|
||||
object['sequenceId'] = currentSequenceId
|
||||
}
|
||||
res.push(object)
|
||||
prev = object
|
||||
afterDate = false
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
const ChatService = {
|
||||
add,
|
||||
empty,
|
||||
getView,
|
||||
resetNewMessageCount
|
||||
}
|
||||
|
||||
export default ChatService
|
|
@ -342,6 +342,7 @@ export const parseNotification = (data) => {
|
|||
output.type = mastoDict[data.type] || data.type
|
||||
output.seen = data.pleroma.is_seen
|
||||
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
|
||||
output.chatMessage = output.type === 'pleroma:chat_mention' ? parseChatMessage(data.chat_message) : null
|
||||
output.action = output.status // TODO: Refactor, this is unneeded
|
||||
output.target = output.type !== 'move'
|
||||
? null
|
||||
|
@ -356,7 +357,7 @@ export const parseNotification = (data) => {
|
|||
? parseStatus(data.notice.favorited_status)
|
||||
: parsedNotice
|
||||
output.action = parsedNotice
|
||||
output.from_profile = parseUser(data.from_profile)
|
||||
output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
|
||||
}
|
||||
|
||||
output.created_at = new Date(data.created_at)
|
||||
|
@ -369,3 +370,19 @@ const isNsfw = (status) => {
|
|||
const nsfwRegex = /#nsfw/i
|
||||
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
|
||||
}
|
||||
|
||||
export const parseChat = (chat) => {
|
||||
let output = chat
|
||||
output.id = parseInt(chat.id, 10)
|
||||
output.account = parseUser(chat.account)
|
||||
output.unread = chat.unread
|
||||
output.lastMessage = undefined
|
||||
return output
|
||||
}
|
||||
|
||||
export const parseChatMessage = (message) => {
|
||||
let output = message
|
||||
output.created_at = new Date(message.created_at)
|
||||
output.chat_id = parseInt(message.chat_id, 10)
|
||||
return output
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ export const visibleTypes = store => ([
|
|||
store.state.config.notificationVisibility.follows && 'follow',
|
||||
store.state.config.notificationVisibility.followRequest && 'follow_request',
|
||||
store.state.config.notificationVisibility.moves && 'move',
|
||||
store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
|
||||
store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
|
||||
store.state.config.notificationVisibility.chatMention && 'pleroma:chat_mention'
|
||||
].filter(_ => _))
|
||||
|
||||
const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction']
|
||||
|
|
|
@ -106,7 +106,8 @@ export const generateRadii = (input) => {
|
|||
avatar: 5,
|
||||
avatarAlt: 50,
|
||||
tooltip: 2,
|
||||
attachment: 5
|
||||
attachment: 5,
|
||||
chatMessage: inputRadii.panel
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
|
@ -627,5 +627,51 @@ export const SLOT_INHERITANCE = {
|
|||
layer: 'badge',
|
||||
variant: 'badgeNotification',
|
||||
textColor: 'bw'
|
||||
},
|
||||
|
||||
chatBg: {
|
||||
depends: ['bg'],
|
||||
layer: 'bg'
|
||||
},
|
||||
|
||||
chatMessageIncomingBg: {
|
||||
depends: ['bg'],
|
||||
layer: 'bg'
|
||||
},
|
||||
|
||||
chatMessageIncomingText: {
|
||||
depends: ['text'],
|
||||
layer: 'text'
|
||||
},
|
||||
|
||||
chatMessageIncomingLink: {
|
||||
depends: ['link'],
|
||||
layer: 'link'
|
||||
},
|
||||
|
||||
chatMessageIncomingBorder: {
|
||||
depends: ['fg'],
|
||||
opacity: 'border',
|
||||
color: (mod, fg) => brightness(2 * mod, fg).rgb
|
||||
},
|
||||
|
||||
chatMessageOutgoingBg: {
|
||||
depends: ['bg'],
|
||||
color: (mod, fg) => brightness(5 * mod, fg).rgb
|
||||
},
|
||||
|
||||
chatMessageOutgoingText: {
|
||||
depends: ['text'],
|
||||
layer: 'text'
|
||||
},
|
||||
|
||||
chatMessageOutgoingLink: {
|
||||
depends: ['link'],
|
||||
layer: 'link'
|
||||
},
|
||||
|
||||
chatMessageOutgoingBorder: {
|
||||
depends: ['bg'],
|
||||
opacity: 'bg'
|
||||
}
|
||||
}
|
||||
|
|
0
static/fontello.json
Executable file → Normal file
0
static/fontello.json
Executable file → Normal file
|
@ -115,7 +115,8 @@
|
|||
"cOrange": "#f67400",
|
||||
"btnPressed": "--accent",
|
||||
"selectedMenu": "--accent",
|
||||
"selectedMenuPopover": "--accent"
|
||||
"selectedMenuPopover": "--accent",
|
||||
"chatMessageIncomingBorder": "#3d4349"
|
||||
},
|
||||
"radii": {
|
||||
"btn": "2",
|
||||
|
|
|
@ -286,7 +286,8 @@
|
|||
"cGreen": "#008000",
|
||||
"cOrange": "#808000",
|
||||
"highlight": "--accent",
|
||||
"selectedPost": "--bg,-10"
|
||||
"selectedPost": "--bg,-10",
|
||||
"chatFg": "#808080"
|
||||
},
|
||||
"radii": {
|
||||
"btn": "0",
|
||||
|
|
|
@ -277,7 +277,8 @@
|
|||
"cGreen": "#008000",
|
||||
"cOrange": "#808000",
|
||||
"highlight": "--accent",
|
||||
"selectedPost": "--bg,-10"
|
||||
"selectedPost": "--bg,-10",
|
||||
"chatMessageIncomingBorder": "#808080"
|
||||
},
|
||||
"radii": {
|
||||
"btn": "0",
|
||||
|
|
|
@ -259,7 +259,8 @@
|
|||
"cGreen": "#669966",
|
||||
"cOrange": "#cc6633",
|
||||
"highlight": "--accent",
|
||||
"selectedPost": "--bg,-10"
|
||||
"selectedPost": "--bg,-10",
|
||||
"chatFg": "#808080"
|
||||
},
|
||||
"radii": {
|
||||
"btn": "0",
|
||||
|
|
Loading…
Reference in a new issue