From 804cf3abc53f3108cf0f7f92db18dc1ad733fdec Mon Sep 17 00:00:00 2001
From: eugenijm
Date: Thu, 7 May 2020 16:10:53 +0300
Subject: [PATCH] WIP: Chats
---
src/App.js | 12 +-
src/App.scss | 22 ++
src/App.vue | 3 +-
src/_variables.scss | 1 +
src/boot/routes.js | 4 +
.../account_actions/account_actions.js | 6 +
.../account_actions/account_actions.vue | 6 +
src/components/chat/chat.js | 337 ++++++++++++++++++
src/components/chat/chat.scss | 192 ++++++++++
src/components/chat/chat.vue | 90 +++++
src/components/chat_avatar/chat_avatar.js | 34 ++
src/components/chat_avatar/chat_avatar.vue | 127 +++++++
src/components/chat_list/chat_list.js | 44 +++
src/components/chat_list/chat_list.vue | 56 +++
.../chat_list_item/chat_list_item.js | 38 ++
.../chat_list_item/chat_list_item.scss | 156 ++++++++
.../chat_list_item/chat_list_item.vue | 48 +++
src/components/chat_message/chat_message.js | 62 ++++
src/components/chat_message/chat_message.scss | 171 +++++++++
src/components/chat_message/chat_message.vue | 41 +++
.../chat_message_date/chat_message_date.vue | 24 ++
src/components/chat_new/chat_new.js | 74 ++++
src/components/chat_new/chat_new.scss | 87 +++++
src/components/chat_new/chat_new.vue | 50 +++
src/components/chat_title/chat_title.js | 39 ++
src/components/chat_title/chat_title.vue | 41 +++
.../mobile_post_status_button.js | 7 +
src/components/nav_panel/nav_panel.vue | 5 +
src/components/notification/notification.js | 13 +-
src/components/notification/notification.vue | 22 ++
.../post_status_form/post_status_form.js | 37 +-
.../post_status_form/post_status_form.vue | 19 +-
src/components/side_drawer/side_drawer.vue | 3 +
.../style_switcher/style_switcher.js | 6 +-
.../style_switcher/style_switcher.vue | 65 ++++
src/hocs/with_load_more/with_load_more.scss | 4 +
src/i18n/en.json | 27 +-
src/main.js | 4 +-
src/modules/chats.js | 163 +++++++++
src/modules/config.js | 3 +-
src/modules/statuses.js | 12 +-
src/services/api/api.service.js | 67 +++-
src/services/chat_service/chat_service.js | 100 ++++++
.../entity_normalizer.service.js | 19 +-
.../notification_utils/notification_utils.js | 3 +-
src/services/style_setter/style_setter.js | 3 +-
src/services/theme_data/pleromafe.js | 46 +++
static/fontello.json | 0
static/themes/breezy-dark.json | 3 +-
static/themes/redmond-xx-se.json | 3 +-
static/themes/redmond-xx.json | 3 +-
static/themes/redmond-xxi.json | 3 +-
52 files changed, 2374 insertions(+), 31 deletions(-)
create mode 100644 src/components/chat/chat.js
create mode 100644 src/components/chat/chat.scss
create mode 100644 src/components/chat/chat.vue
create mode 100644 src/components/chat_avatar/chat_avatar.js
create mode 100644 src/components/chat_avatar/chat_avatar.vue
create mode 100644 src/components/chat_list/chat_list.js
create mode 100644 src/components/chat_list/chat_list.vue
create mode 100644 src/components/chat_list_item/chat_list_item.js
create mode 100644 src/components/chat_list_item/chat_list_item.scss
create mode 100644 src/components/chat_list_item/chat_list_item.vue
create mode 100644 src/components/chat_message/chat_message.js
create mode 100644 src/components/chat_message/chat_message.scss
create mode 100644 src/components/chat_message/chat_message.vue
create mode 100644 src/components/chat_message_date/chat_message_date.vue
create mode 100644 src/components/chat_new/chat_new.js
create mode 100644 src/components/chat_new/chat_new.scss
create mode 100644 src/components/chat_new/chat_new.vue
create mode 100644 src/components/chat_title/chat_title.js
create mode 100644 src/components/chat_title/chat_title.vue
create mode 100644 src/modules/chats.js
create mode 100644 src/services/chat_service/chat_service.js
mode change 100755 => 100644 static/fontello.json
diff --git a/src/App.js b/src/App.js
index bbb41409..6a8e74a6 100644
--- a/src/App.js
+++ b/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'
+ }
+ }
}
}
diff --git a/src/App.scss b/src/App.scss
index 89aa3215..c39a3b07 100644
--- a/src/App.scss
+++ b/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;
+}
diff --git a/src/App.vue b/src/App.vue
index 7018a5a4..fe353eb5 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -76,6 +76,7 @@
+
-
+
diff --git a/src/_variables.scss b/src/_variables.scss
index 30dc3e42..9004d551 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -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;
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 7400a682..48b8c9da 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -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 },
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 0826c275..9ad8bd1d 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -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 }
+ })
}
}
}
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 744b77d5..308f1d6e 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -49,6 +49,12 @@
>
{{ $t('user_card.report') }}
+
{
+ 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
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
new file mode 100644
index 00000000..25e27cf5
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -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);
+ }
+}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
new file mode 100644
index 00000000..02b03609
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js
new file mode 100644
index 00000000..8603a2cc
--- /dev/null
+++ b/src/components/chat_avatar/chat_avatar.js
@@ -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
diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue
new file mode 100644
index 00000000..f76039e2
--- /dev/null
+++ b/src/components/chat_avatar/chat_avatar.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js
new file mode 100644
index 00000000..d150cc96
--- /dev/null
+++ b/src/components/chat_list/chat_list.js
@@ -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
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
new file mode 100644
index 00000000..a88c87a1
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+ {{ $t("chats.chats") }}
+
+ {{ ' ' }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
new file mode 100644
index 00000000..72399782
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -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
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
new file mode 100644
index 00000000..ee9054bb
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
new file mode 100644
index 00000000..1acc49a5
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'Last message placeholder' }}
+
+ {{ chat.unread }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
new file mode 100644
index 00000000..7d3d27e2
--- /dev/null
+++ b/src/components/chat_message/chat_message.js
@@ -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
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
new file mode 100644
index 00000000..1859dbc9
--- /dev/null
+++ b/src/components/chat_message/chat_message.scss
@@ -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);
+}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
new file mode 100644
index 00000000..79b775cc
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ {{ createdAt }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue
new file mode 100644
index 00000000..24060f41
--- /dev/null
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 00000000..c0571542
--- /dev/null
+++ b/src/components/chat_new/chat_new.js
@@ -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
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
new file mode 100644
index 00000000..780e307b
--- /dev/null
+++ b/src/components/chat_new/chat_new.scss
@@ -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%;
+ }
+ }
+}
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
new file mode 100644
index 00000000..9d8839ac
--- /dev/null
+++ b/src/components/chat_new/chat_new.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 00000000..07590d58
--- /dev/null
+++ b/src/components/chat_title/chat_title.js
@@ -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)
+ }
+ }
+})
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
new file mode 100644
index 00000000..b8bbf171
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 0ad12bb1..6348277b 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -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 () {
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 8cd04dc7..1543c15c 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -17,6 +17,11 @@
{{ $t("nav.dms") }}
+
+
+ {{ $t("nav.chats") }}
+
+
{{ $t("nav.friend_requests") }}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 1cf4c9bc..246e698d 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -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: []
+ }
}
}
}
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 0e46a2a7..3a8eaf26 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -89,6 +89,9 @@
+
+
+
+
+
+
+
+
+
+
+
= 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
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 9789a481..bba79989 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -62,14 +62,13 @@
{{ $t('post_status.direct_warning_to_all') }}
-
+
({
+ 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
diff --git a/src/modules/config.js b/src/modules/config.js
index 8f4638f5..024f257b 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -35,7 +35,8 @@ export const defaultState = {
repeats: true,
moves: true,
emojiReactions: false,
- followRequest: true
+ followRequest: true,
+ chatMention: true
},
webPushNotifications: false,
muteWords: [],
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index cd8c1dba..ed78cffd 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -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 => {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 9c7530a2..04c8f214 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -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
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
new file mode 100644
index 00000000..b570a065
--- /dev/null
+++ b/src/services/chat_service/chat_service.js
@@ -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
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 6dac7c15..20e7a616 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -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
+}
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index eb479227..d73f747c 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -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']
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index fbdcf562..07425abd 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -106,7 +106,8 @@ export const generateRadii = (input) => {
avatar: 5,
avatarAlt: 50,
tooltip: 2,
- attachment: 5
+ attachment: 5,
+ chatMessage: inputRadii.panel
})
return {
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index 0c1fe543..7f0f6078 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -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'
}
}
diff --git a/static/fontello.json b/static/fontello.json
old mode 100755
new mode 100644
diff --git a/static/themes/breezy-dark.json b/static/themes/breezy-dark.json
index 76b962c5..6fb9d09c 100644
--- a/static/themes/breezy-dark.json
+++ b/static/themes/breezy-dark.json
@@ -115,7 +115,8 @@
"cOrange": "#f67400",
"btnPressed": "--accent",
"selectedMenu": "--accent",
- "selectedMenuPopover": "--accent"
+ "selectedMenuPopover": "--accent",
+ "chatMessageIncomingBorder": "#3d4349"
},
"radii": {
"btn": "2",
diff --git a/static/themes/redmond-xx-se.json b/static/themes/redmond-xx-se.json
index 7a4a29da..22d0d184 100644
--- a/static/themes/redmond-xx-se.json
+++ b/static/themes/redmond-xx-se.json
@@ -286,7 +286,8 @@
"cGreen": "#008000",
"cOrange": "#808000",
"highlight": "--accent",
- "selectedPost": "--bg,-10"
+ "selectedPost": "--bg,-10",
+ "chatFg": "#808080"
},
"radii": {
"btn": "0",
diff --git a/static/themes/redmond-xx.json b/static/themes/redmond-xx.json
index ff95b1e0..33544cbe 100644
--- a/static/themes/redmond-xx.json
+++ b/static/themes/redmond-xx.json
@@ -277,7 +277,8 @@
"cGreen": "#008000",
"cOrange": "#808000",
"highlight": "--accent",
- "selectedPost": "--bg,-10"
+ "selectedPost": "--bg,-10",
+ "chatMessageIncomingBorder": "#808080"
},
"radii": {
"btn": "0",
diff --git a/static/themes/redmond-xxi.json b/static/themes/redmond-xxi.json
index f788bdb8..0bff9fe1 100644
--- a/static/themes/redmond-xxi.json
+++ b/static/themes/redmond-xxi.json
@@ -259,7 +259,8 @@
"cGreen": "#669966",
"cOrange": "#cc6633",
"highlight": "--accent",
- "selectedPost": "--bg,-10"
+ "selectedPost": "--bg,-10",
+ "chatFg": "#808080"
},
"radii": {
"btn": "0",