diff --git a/src/App.js b/src/App.js
index d4b3b41a..10934010 100644
--- a/src/App.js
+++ b/src/App.js
@@ -3,6 +3,7 @@ import NavPanel from './components/nav_panel/nav_panel.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
+import ShoutPanel from './components/shout_panel/shout_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import ModModal from './components/mod_modal/mod_modal.vue'
@@ -28,6 +29,7 @@ export default {
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
+ ShoutPanel,
MediaModal,
SideDrawer,
MobilePostStatusButton,
@@ -79,17 +81,28 @@ export default {
}
}
},
+ shout () { return this.$store.state.shout.joined },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
+ isChats () {
+ return this.$route.name === 'chat' || this.$route.name === 'chats'
+ },
newPostButtonShown () {
+ if (this.isChats) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
+ shoutboxPosition () {
+ return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
+ },
+ hideShoutbox () {
+ return this.$store.getters.mergedConfig.hideShoutbox
+ },
layoutType () { return this.$store.state.interface.layoutType },
privateMode () { return this.$store.state.instance.private },
reverseLayout () {
diff --git a/src/App.vue b/src/App.vue
index 80ebb525..fb6adca9 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -33,7 +33,7 @@
+
diff --git a/src/_variables.scss b/src/_variables.scss
index 65ce4027..099d3606 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -27,6 +27,7 @@ $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/after_store.js b/src/boot/after_store.js
index de261243..f54537d2 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -274,6 +274,9 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
+ store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
+ store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
+ store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 93a94a9b..347230d8 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -7,6 +7,8 @@ import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue
import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
+import ChatList from 'components/chat_list/chat_list.vue'
+import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
@@ -15,6 +17,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
+import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
@@ -71,6 +74,7 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
+ { name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
@@ -82,5 +86,12 @@ export default (store) => {
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile, meta: { dontScroll: true } }
]
+ if (store.state.instance.pleromaChatMessagesAvailable) {
+ routes = routes.concat([
+ { name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
+ { name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
+ ])
+ }
+
return routes
}
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 0f348474..bbab5af0 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,8 +1,8 @@
+import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { mapState } from 'vuex'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
@@ -68,6 +68,12 @@ const AccountActions = {
unmuteDomain () {
this.$store.dispatch('unmuteDomain', this.user.screen_name.split('@')[1])
.then(() => this.refetchRelationship())
+ },
+ openChat () {
+ this.$router.push({
+ name: 'chat',
+ params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id }
+ })
}
},
computed: {
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 656fe802..7e3b79a0 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -69,6 +69,13 @@
>
{{ $t('user_card.mute_domain') }}
+
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
new file mode 100644
index 00000000..9f6e64e3
--- /dev/null
+++ b/src/components/chat/chat.js
@@ -0,0 +1,346 @@
+import _ from 'lodash'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
+import ChatMessage from '../chat_message/chat_message.vue'
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+import chatService from '../../services/chat_service/chat_service.js'
+import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faChevronDown,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
+
+library.add(
+ faChevronDown,
+ faChevronLeft
+)
+
+const BOTTOMED_OUT_OFFSET = 10
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
+const SAFE_RESIZE_TIME_OFFSET = 100
+const MARK_AS_READ_DELAY = 1500
+const MAX_RETRIES = 10
+
+const Chat = {
+ components: {
+ ChatMessage,
+ ChatTitle,
+ PostStatusForm
+ },
+ data () {
+ return {
+ jumpToBottomButtonVisible: false,
+ hoveredMessageChainId: undefined,
+ lastScrollPosition: {},
+ scrollableContainerHeight: '100%',
+ errorLoadingChat: false,
+ messageRetriers: {}
+ }
+ },
+ created () {
+ this.startFetching()
+ window.addEventListener('resize', this.handleResize)
+ },
+ mounted () {
+ window.addEventListener('scroll', this.handleScroll)
+ if (typeof document.hidden !== 'undefined') {
+ document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
+ }
+
+ this.$nextTick(() => {
+ this.handleResize()
+ })
+ },
+ unmounted () {
+ window.removeEventListener('scroll', this.handleScroll)
+ if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
+ this.$store.dispatch('clearCurrentChat')
+ },
+ computed: {
+ recipient () {
+ return this.currentChat && this.currentChat.account
+ },
+ recipientId () {
+ return this.$route.params.recipient_id
+ },
+ formPlaceholder () {
+ if (this.recipient) {
+ return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui })
+ } else {
+ return ''
+ }
+ },
+ chatViewItems () {
+ return chatService.getView(this.currentChatMessageService)
+ },
+ newMessageCount () {
+ return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
+ },
+ streamingEnabled () {
+ return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+ },
+ ...mapGetters([
+ 'currentChat',
+ 'currentChatMessageService',
+ 'findOpenedChatByRecipientId',
+ 'mergedConfig'
+ ]),
+ ...mapState({
+ backendInteractor: state => state.api.backendInteractor,
+ mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
+ mobileLayout: state => state.interface.layoutType === 'mobile',
+ currentUser: state => state.users.currentUser
+ })
+ },
+ watch: {
+ chatViewItems () {
+ // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
+ // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
+ const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
+ this.$nextTick(() => {
+ if (bottomedOutBeforeUpdate) {
+ this.scrollDown()
+ }
+ })
+ },
+ '$route': function () {
+ this.startFetching()
+ },
+ mastoUserSocketStatus (newValue) {
+ if (newValue === WSConnectionStatus.JOINED) {
+ this.fetchChat({ isFirstFetch: true })
+ }
+ }
+ },
+ methods: {
+ // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
+ onMessageHover ({ isHovered, messageChainId }) {
+ this.hoveredMessageChainId = isHovered ? messageChainId : undefined
+ },
+ onFilesDropped () {
+ this.$nextTick(() => {
+ this.handleResize()
+ })
+ },
+ handleVisibilityChange () {
+ this.$nextTick(() => {
+ if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
+ this.scrollDown({ forceRead: true })
+ }
+ })
+ },
+ // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
+ handleResize (opts = {}) {
+ const { expand = false, delayed = false } = opts
+
+ if (delayed) {
+ setTimeout(() => {
+ this.handleResize({ ...opts, delayed: false })
+ }, SAFE_RESIZE_TIME_OFFSET)
+ return
+ }
+
+ this.$nextTick(() => {
+ const { offsetHeight = undefined } = getScrollPosition()
+ const diff = this.lastScrollPosition.offsetHeight - offsetHeight
+ if (diff !== 0 || (!this.bottomedOut() && expand)) {
+ this.$nextTick(() => {
+ window.scrollTo({ top: window.scrollY + diff })
+ })
+ }
+ this.lastScrollPosition = getScrollPosition()
+ })
+ },
+ scrollDown (options = {}) {
+ const { behavior = 'auto', forceRead = false } = options
+ this.$nextTick(() => {
+ window.scrollTo({ top: document.documentElement.scrollHeight, behavior })
+ })
+ if (forceRead) {
+ this.readChat()
+ }
+ },
+ readChat () {
+ if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
+ if (document.hidden) { return }
+ const lastReadId = this.currentChatMessageService.maxId
+ this.$store.dispatch('readChat', {
+ id: this.currentChat.id,
+ lastReadId
+ })
+ },
+ bottomedOut (offset) {
+ return isBottomedOut(offset)
+ },
+ reachedTop () {
+ return window.scrollY <= 0
+ },
+ cullOlderCheck () {
+ window.setTimeout(() => {
+ if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
+ this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId)
+ }
+ }, 5000)
+ },
+ handleScroll: _.throttle(function () {
+ if (!this.currentChat) { return }
+
+ if (this.reachedTop()) {
+ this.fetchChat({ maxId: this.currentChatMessageService.minId })
+ } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
+ this.jumpToBottomButtonVisible = false
+ this.cullOlderCheck()
+ if (this.newMessageCount > 0) {
+ // Use a delay before marking as read to prevent situation where new messages
+ // arrive just as you're leaving the view and messages that you didn't actually
+ // get to see get marked as read.
+ window.setTimeout(() => {
+ // Don't mark as read if the element doesn't exist, user has left chat view
+ if (this.$el) this.readChat()
+ }, MARK_AS_READ_DELAY)
+ }
+ } else {
+ this.jumpToBottomButtonVisible = true
+ }
+ }, 200),
+ handleScrollUp (positionBeforeLoading) {
+ const positionAfterLoading = getScrollPosition()
+ window.scrollTo({
+ top: getNewTopPosition(positionBeforeLoading, positionAfterLoading)
+ })
+ },
+ fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
+ const chatMessageService = this.currentChatMessageService
+ if (!chatMessageService) { return }
+ if (fetchLatest && this.streamingEnabled) { return }
+
+ const chatId = chatMessageService.chatId
+ const fetchOlderMessages = !!maxId
+ const sinceId = fetchLatest && chatMessageService.maxId
+
+ return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
+ .then((messages) => {
+ // Clear the current chat in case we're recovering from a ws connection loss.
+ if (isFirstFetch) {
+ chatService.clear(chatMessageService)
+ }
+
+ const positionBeforeUpdate = getScrollPosition()
+ this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
+ this.$nextTick(() => {
+ if (fetchOlderMessages) {
+ this.handleScrollUp(positionBeforeUpdate)
+ }
+
+ // In vertical screens, the first batch of fetched messages may not always take the
+ // full height of the scrollable container.
+ // If this is the case, we want to fetch the messages until the scrollable container
+ // is fully populated so that the user has the ability to scroll up and load the history.
+ if (!isScrollable() && messages.length > 0) {
+ this.fetchChat({ maxId: this.currentChatMessageService.minId })
+ }
+ })
+ })
+ })
+ },
+ async startFetching () {
+ let chat = this.findOpenedChatByRecipientId(this.recipientId)
+ if (!chat) {
+ try {
+ chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
+ } catch (e) {
+ console.error('Error creating or getting a chat', e)
+ this.errorLoadingChat = true
+ }
+ }
+ if (chat) {
+ this.$nextTick(() => {
+ this.scrollDown({ forceRead: true })
+ })
+ this.$store.dispatch('addOpenedChat', { chat })
+ this.doStartFetching()
+ }
+ },
+ doStartFetching () {
+ this.$store.dispatch('startFetchingCurrentChat', {
+ fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
+ })
+ this.fetchChat({ isFirstFetch: true })
+ },
+ handleAttachmentPosting () {
+ this.$nextTick(() => {
+ this.handleResize()
+ // When the posting form size changes because of a media attachment, we need an extra resize
+ // to account for the potential delay in the DOM update.
+ this.scrollDown({ forceRead: true })
+ })
+ },
+ sendMessage ({ status, media, idempotencyKey }) {
+ const params = {
+ id: this.currentChat.id,
+ content: status,
+ idempotencyKey
+ }
+
+ if (media[0]) {
+ params.mediaId = media[0].id
+ }
+
+ const fakeMessage = buildFakeMessage({
+ attachments: media,
+ chatId: this.currentChat.id,
+ content: status,
+ userId: this.currentUser.id,
+ idempotencyKey
+ })
+
+ this.$store.dispatch('addChatMessages', {
+ chatId: this.currentChat.id,
+ messages: [fakeMessage]
+ }).then(() => {
+ this.handleAttachmentPosting()
+ })
+
+ return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
+ },
+ doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
+ if (retriesLeft <= 0) return
+
+ this.backendInteractor.sendChatMessage(params)
+ .then(data => {
+ this.$store.dispatch('addChatMessages', {
+ chatId: this.currentChat.id,
+ updateMaxId: false,
+ messages: [{ ...data, fakeId: fakeMessage.id }]
+ })
+
+ return data
+ })
+ .catch(error => {
+ console.error('Error sending message', error)
+ this.$store.dispatch('handleMessageError', {
+ chatId: this.currentChat.id,
+ fakeId: fakeMessage.id,
+ isRetry: retriesLeft !== MAX_RETRIES
+ })
+ if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
+ this.messageRetriers[fakeMessage.id] = setTimeout(() => {
+ this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
+ }, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
+ }
+ return {}
+ })
+
+ return Promise.resolve(fakeMessage)
+ },
+ goBack () {
+ this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
+ }
+ }
+}
+
+export default Chat
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
new file mode 100644
index 00000000..f2e154ab
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -0,0 +1,108 @@
+.chat-view {
+ display: flex;
+ height: 100%;
+
+ .chat-view-inner {
+ height: auto;
+ width: 100%;
+ overflow: visible;
+ display: flex;
+ }
+
+ .chat-view-body {
+ box-sizing: border-box;
+ background-color: var(--chatBg, $fallback--bg);
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: visible;
+ min-height: calc(100vh - var(--navbar-height));
+ margin: 0 0 0 0;
+ border-radius: 10px 10px 0 0;
+ border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0;
+
+ &::after {
+ border-radius: 0;
+ }
+ }
+
+ .message-list {
+ padding: 0 0.8em;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ }
+
+ .footer {
+ position: sticky;
+ bottom: 0;
+ background-color: $fallback--bg;
+ background-color: var(--bg, $fallback--bg);
+ z-index: 1;
+ }
+
+ .chat-view-heading {
+ grid-template-columns: auto minmax(50%, 1fr);
+ }
+
+ .go-back-button {
+ text-align: center;
+ line-height: 1;
+ height: 100%;
+ align-self: start;
+ width: var(--__panel-heading-height-inner);
+ }
+
+ .jump-to-bottom-button {
+ width: 2.5em;
+ height: 2.5em;
+ border-radius: 100%;
+ position: absolute;
+ right: 1.3em;
+ top: -3.2em;
+ background-color: $fallback--fg;
+ background-color: var(--btn, $fallback--fg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3);
+ z-index: 10;
+ transition: 0.35s all;
+ transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+ opacity: 0;
+ visibility: hidden;
+ cursor: pointer;
+
+ &.visible {
+ opacity: 1;
+ visibility: visible;
+ }
+
+ i {
+ font-size: 1em;
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+
+ .unread-message-count {
+ font-size: 0.8em;
+ left: 50%;
+ margin-top: -1rem;
+ padding: 0.1em;
+ border-radius: 50px;
+ position: absolute;
+ }
+
+ .chat-loading-error {
+ width: 100%;
+ display: flex;
+ align-items: flex-end;
+ height: 100%;
+
+ .error {
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
new file mode 100644
index 00000000..2e7df7bd
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('chats.error_loading_chat') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 00000000..c187892d
--- /dev/null
+++ b/src/components/chat/chat_layout_utils.js
@@ -0,0 +1,24 @@
+// Captures a scroll position
+export const getScrollPosition = () => {
+ return {
+ scrollTop: window.scrollY,
+ scrollHeight: document.documentElement.scrollHeight,
+ offsetHeight: window.innerHeight
+ }
+}
+
+// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
+// Takes two scroll positions, before and after the update.
+export const getNewTopPosition = (previousPosition, newPosition) => {
+ return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
+}
+
+export const isBottomedOut = (offset = 0) => {
+ const scrollHeight = window.scrollY + offset
+ const totalHeight = document.documentElement.scrollHeight - window.innerHeight
+ return totalHeight <= scrollHeight
+}
+// Returns whether or not the scrollbar is visible.
+export const isScrollable = () => {
+ return document.documentElement.scrollHeight > window.innerHeight
+}
diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js
new file mode 100644
index 00000000..95708d1d
--- /dev/null
+++ b/src/components/chat_list/chat_list.js
@@ -0,0 +1,37 @@
+import { mapState, mapGetters } from 'vuex'
+import ChatListItem from '../chat_list_item/chat_list_item.vue'
+import ChatNew from '../chat_new/chat_new.vue'
+import List from '../list/list.vue'
+
+const ChatList = {
+ components: {
+ ChatListItem,
+ List,
+ ChatNew
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ ...mapGetters(['sortedChatList'])
+ },
+ data () {
+ return {
+ isNew: false
+ }
+ },
+ created () {
+ this.$store.dispatch('fetchChats', { latest: true })
+ },
+ methods: {
+ cancelNewChat () {
+ this.isNew = false
+ this.$store.dispatch('fetchChats', { latest: true })
+ },
+ newChat () {
+ this.isNew = true
+ }
+ }
+}
+
+export default ChatList
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
new file mode 100644
index 00000000..58e8d0b3
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+ {{ $t("chats.chats") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('chats.empty_chat_list_placeholder') }}
+
+
+
+
+
+
+
+
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..e5032176
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -0,0 +1,69 @@
+import { mapState } from 'vuex'
+import StatusBody from '../status_content/status_content.vue'
+import fileType from 'src/services/file_type/file_type.service'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import AvatarList from '../avatar_list/avatar_list.vue'
+import Timeago from '../timeago/timeago.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+
+const ChatListItem = {
+ name: 'ChatListItem',
+ props: [
+ 'chat'
+ ],
+ components: {
+ UserAvatar,
+ AvatarList,
+ Timeago,
+ ChatTitle,
+ StatusBody
+ },
+ computed: {
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ attachmentInfo () {
+ if (this.chat.lastMessage.attachments.length === 0) { return }
+
+ const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
+ if (types.includes('video')) {
+ return this.$t('file_type.video')
+ } else if (types.includes('audio')) {
+ return this.$t('file_type.audio')
+ } else if (types.includes('image')) {
+ return this.$t('file_type.image')
+ } else {
+ return this.$t('file_type.file')
+ }
+ },
+ messageForStatusContent () {
+ const message = this.chat.lastMessage
+ const messageEmojis = message ? message.emojis : []
+ const isYou = message && message.account_id === this.currentUser.id
+ const content = message ? (this.attachmentInfo || message.content) : ''
+ const messagePreview = isYou ? `${this.$t('chats.you')} ${content}` : content
+ return {
+ summary: '',
+ emojis: messageEmojis,
+ raw_html: messagePreview,
+ text: messagePreview,
+ attachments: []
+ }
+ }
+ },
+ methods: {
+ openChat (_e) {
+ if (this.chat.id) {
+ this.$router.push({
+ name: 'chat',
+ params: {
+ username: this.currentUser.screen_name,
+ recipient_id: this.chat.account.id
+ }
+ })
+ }
+ }
+ }
+}
+
+export default ChatListItem
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..c6b45c34
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -0,0 +1,91 @@
+.chat-list-item {
+ display: flex;
+ flex-direction: row;
+ padding: 0.75em;
+ height: 5em;
+ overflow: hidden;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ :focus {
+ outline: none;
+ }
+
+ &:hover {
+ background-color: var(--selectedPost, $fallback--lightBg);
+ box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
+ }
+
+ .chat-list-item-left {
+ margin-right: 1em;
+ }
+
+ .chat-list-item-center {
+ width: 100%;
+ box-sizing: border-box;
+ overflow: hidden;
+ word-wrap: break-word;
+ }
+
+ .heading {
+ width: 100%;
+ display: inline-flex;
+ justify-content: space-between;
+ line-height: 1em;
+ }
+
+ .heading-right {
+ white-space: nowrap;
+ }
+
+ .name-and-account-name {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ flex-shrink: 1;
+ line-height: var(--post-line-height);
+ }
+
+ .chat-preview {
+ display: inline-flex;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0.35em 0;
+ color: $fallback--text;
+ color: var(--faint, $fallback--text);
+ width: 100%;
+ }
+
+ a {
+ color: var(--faintLink, $fallback--link);
+ text-decoration: none;
+ pointer-events: none;
+ }
+
+ &:hover .animated.avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+
+ .Avatar {
+ border-radius: $fallback--avatarAltRadius;
+ border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+ }
+
+ .chat-preview-body {
+ --emoji-size: 1.4em;
+ }
+
+ .time-wrapper {
+ line-height: var(--post-line-height);
+ }
+
+ .chat-preview-body {
+ padding-right: 1em;
+ }
+}
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..c7c0e878
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+ {{ 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..5bac7736
--- /dev/null
+++ b/src/components/chat_message/chat_message.js
@@ -0,0 +1,108 @@
+import { mapState, mapGetters } from 'vuex'
+import Popover from '../popover/popover.vue'
+import Attachment from '../attachment/attachment.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import Gallery from '../gallery/gallery.vue'
+import LinkPreview from '../link-preview/link-preview.vue'
+import StatusContent from '../status_content/status_content.vue'
+import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faTimes,
+ faEllipsisH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faTimes,
+ faEllipsisH
+)
+
+const ChatMessage = {
+ name: 'ChatMessage',
+ props: [
+ 'author',
+ 'edited',
+ 'noHeading',
+ 'chatViewItem',
+ 'hoveredMessageChain'
+ ],
+ emits: ['hover'],
+ components: {
+ Popover,
+ Attachment,
+ StatusContent,
+ UserAvatar,
+ Gallery,
+ LinkPreview,
+ ChatMessageDate
+ },
+ computed: {
+ // Returns HH:MM (hours and minutes) in local time.
+ createdAt () {
+ const time = this.chatViewItem.data.created_at
+ return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
+ },
+ isCurrentUser () {
+ return this.message.account_id === this.currentUser.id
+ },
+ message () {
+ return this.chatViewItem.data
+ },
+ userProfileLink () {
+ return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
+ },
+ isMessage () {
+ return this.chatViewItem.type === 'message'
+ },
+ messageForStatusContent () {
+ return {
+ summary: '',
+ emojis: this.message.emojis,
+ raw_html: this.message.content || '',
+ text: this.message.content || '',
+ attachments: this.message.attachments
+ }
+ },
+ hasAttachment () {
+ return this.message.attachments.length > 0
+ },
+ ...mapState({
+ betterShadow: state => state.interface.browserSupport.cssFilter,
+ currentUser: state => state.users.currentUser,
+ restrictedNicknames: state => state.instance.restrictedNicknames
+ }),
+ popoverMarginStyle () {
+ if (this.isCurrentUser) {
+ return {}
+ } else {
+ return { left: 50 }
+ }
+ },
+ ...mapGetters(['mergedConfig', 'findUser'])
+ },
+ data () {
+ return {
+ hovered: false,
+ menuOpened: false
+ }
+ },
+ methods: {
+ onHover (bool) {
+ this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
+ },
+ async deleteMessage () {
+ const confirmed = window.confirm(this.$t('chats.delete_confirm'))
+ if (confirmed) {
+ await this.$store.dispatch('deleteChatMessage', {
+ messageId: this.chatViewItem.data.id,
+ chatId: this.chatViewItem.data.chat_id
+ })
+ }
+ this.hovered = false
+ this.menuOpened = false
+ }
+ }
+}
+
+export default ChatMessage
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
new file mode 100644
index 00000000..1913479f
--- /dev/null
+++ b/src/components/chat_message/chat_message.scss
@@ -0,0 +1,179 @@
+@import '../../_variables.scss';
+
+.chat-message-wrapper {
+
+ &.hovered-message-chain {
+ .animated.Avatar {
+ canvas {
+ display: none;
+ }
+ img {
+ visibility: visible;
+ }
+ }
+ }
+
+ .chat-message-menu {
+ transition: opacity 0.1s;
+ opacity: 0;
+ position: absolute;
+ top: -0.8em;
+
+ button {
+ padding-top: 0.2em;
+ padding-bottom: 0.2em;
+ }
+ }
+
+ .menu-icon {
+ cursor: pointer;
+
+ &:hover, .extra-button-popover.open & {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+ }
+
+ .popover {
+ width: 12em;
+ }
+
+ .chat-message {
+ display: flex;
+ padding-bottom: 0.5em;
+
+ .status-body:hover {
+ --_still-image-img-visibility: visible;
+ --_still-image-canvas-visibility: hidden;
+ --_still-image-label-visibility: hidden;
+ }
+ }
+
+ .avatar-wrapper {
+ margin-right: 0.72em;
+ width: 32px;
+ }
+
+ .link-preview, .attachments {
+ margin-bottom: 1em;
+ }
+
+ .chat-message-inner {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ max-width: 80%;
+ min-width: 10em;
+ width: 100%;
+
+ &.with-media {
+ width: 100%;
+
+ .status {
+ width: 100%;
+ }
+ }
+ }
+
+ .status {
+ border-radius: $fallback--chatMessageRadius;
+ border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+ display: flex;
+ padding: 0.75em;
+ }
+
+ .created-at {
+ position: relative;
+ float: right;
+ font-size: 0.8em;
+ margin: -1em 0 -0.5em 0;
+ font-style: italic;
+ opacity: 0.8;
+ }
+
+ .without-attachment {
+ .message-content {
+ // TODO figure out how to do it properly
+ .RichContent::after {
+ margin-right: 5.4em;
+ content: " ";
+ display: inline-block;
+ }
+ }
+ }
+
+ .pending {
+ .status-content.media-body, .created-at {
+ color: var(--faint);
+ }
+ }
+
+ .error {
+ .status-content.media-body, .created-at {
+ color: $fallback--cRed;
+ color: var(--badgeNotification, $fallback--cRed);
+ }
+ }
+
+ .incoming {
+ a {
+ color: var(--chatMessageIncomingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageIncomingText, $fallback--text);
+ background-color: var(--chatMessageIncomingBg, $fallback--bg);
+ border: 1px solid var(--chatMessageIncomingBorder, --border);
+ }
+
+ .created-at {
+ a {
+ color: var(--chatMessageIncomingText, $fallback--text);
+ }
+ }
+
+ .chat-message-menu {
+ left: 0.4rem;
+ }
+ }
+
+ .outgoing {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-content: end;
+ justify-content: flex-end;
+
+ a {
+ color: var(--chatMessageOutgoingLink, $fallback--link);
+ }
+
+ .status {
+ color: var(--chatMessageOutgoingText, $fallback--text);
+ background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
+ border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
+ }
+
+ .chat-message-inner {
+ align-items: flex-end;
+ }
+
+ .chat-message-menu {
+ right: 0.4rem;
+ }
+ }
+
+ .visible {
+ opacity: 1;
+ }
+
+}
+
+.chat-message-date-separator {
+ text-align: center;
+ margin: 1.4em 0;
+ font-size: 0.9em;
+ user-select: none;
+ color: $fallback--text;
+ color: var(--faintedText, $fallback--text);
+}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
new file mode 100644
index 00000000..d62b831d
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ createdAt }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 00000000..71585995
--- /dev/null
+++ b/src/components/chat_new/chat_new.js
@@ -0,0 +1,83 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const chatNew = {
+ components: {
+ BasicUserCard,
+ UserAvatar
+ },
+ data () {
+ return {
+ suggestions: [],
+ userIds: [],
+ loading: false,
+ query: ''
+ }
+ },
+ async created () {
+ const { chats } = await this.backendInteractor.chats()
+ chats.forEach(chat => this.suggestions.push(chat.account))
+ },
+ computed: {
+ users () {
+ return this.userIds.map(userId => this.findUser(userId))
+ },
+ availableUsers () {
+ if (this.query.length !== 0) {
+ return this.users
+ } else {
+ return this.suggestions
+ }
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ backendInteractor: state => state.api.backendInteractor
+ }),
+ ...mapGetters(['findUser'])
+ },
+ methods: {
+ goBack () {
+ this.$emit('cancel')
+ },
+ goToChat (user) {
+ this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
+ },
+ onInput () {
+ this.search(this.query)
+ },
+ addUser (user) {
+ this.selectedUserIds.push(user.id)
+ this.query = ''
+ },
+ removeUser (userId) {
+ this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
+ },
+ search (query) {
+ if (!query) {
+ this.loading = false
+ return
+ }
+
+ this.loading = true
+ this.userIds = []
+ this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
+ .then(data => {
+ this.loading = false
+ this.userIds = data.accounts.map(a => a.id)
+ })
+ }
+ }
+}
+
+export default chatNew
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
new file mode 100644
index 00000000..240e1a38
--- /dev/null
+++ b/src/components/chat_new/chat_new.scss
@@ -0,0 +1,31 @@
+.chat-new {
+ .input-wrap {
+ display: flex;
+ margin: 0.7em 0.5em 0.7em 0.5em;
+
+ input {
+ width: 100%;
+ }
+ }
+
+ .search-icon {
+ margin-right: 0.3em;
+ }
+
+ .member-list {
+ padding-bottom: 0.7rem;
+ }
+
+ .basic-user-card:hover {
+ cursor: pointer;
+ background-color: var(--selectedPost, $fallback--lightBg);
+ }
+
+ .go-back-button {
+ text-align: center;
+ line-height: 1;
+ height: 100%;
+ align-self: start;
+ width: var(--__panel-heading-height-inner);
+ }
+}
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
new file mode 100644
index 00000000..bf09a379
--- /dev/null
+++ b/src/components/chat_new/chat_new.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 00000000..f6e299ad
--- /dev/null
+++ b/src/components/chat_title/chat_title.js
@@ -0,0 +1,27 @@
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+
+export default {
+ name: 'ChatTitle',
+ components: {
+ UserAvatar,
+ RichContent
+ },
+ props: [
+ 'user', 'withAvatar'
+ ],
+ computed: {
+ title () {
+ return this.user ? this.user.screen_name_ui : ''
+ },
+ htmlTitle () {
+ return this.user ? this.user.name_html : ''
+ }
+ },
+ 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..1fafbf9a
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index e8823693..feeca4a2 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -2,6 +2,9 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const FeaturesPanel = {
computed: {
+ shout: function () { return this.$store.state.instance.shoutAvailable },
+ pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
+ gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
textlimit: function () { return this.$store.state.instance.textlimit },
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 0343a82c..4cdf56d0 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -8,6 +8,15 @@
+ -
+ {{ $t('features_panel.shout') }}
+
+ -
+ {{ $t('features_panel.pleroma_chat_messages') }}
+
+ -
+ {{ $t('features_panel.gopher') }}
+
-
{{ $t('features_panel.who_to_follow') }}
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index bba520d4..526ec9ef 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -55,7 +55,11 @@ const MobileNav = {
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
},
- ...mapGetters(['unreadChatCount'])
+ ...mapGetters(['unreadChatCount']),
+ isChat () {
+ return this.$route.name === 'chat'
+ },
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
},
methods: {
toggleMobileSidebar () {
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index e6a8e694..188a95fc 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -17,7 +17,7 @@
icon="bars"
/>
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 d08e4d65..ecf79b64 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
@@ -8,6 +8,11 @@ library.add(
faPen
)
+const HIDDEN_FOR_PAGES = new Set([
+ 'chats',
+ 'chat'
+])
+
const MobilePostStatusButton = {
data () {
return {
@@ -35,6 +40,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)
},
isPersistent () {
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index b0a08a2f..7104c6e0 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -59,9 +59,10 @@ const NavPanel = {
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
- federating: state => state.instance.federating
+ federating: state => state.instance.federating,
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
- ...mapGetters(['unreadAnnouncementCount'])
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
}
}
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index 82995f92..0565aa33 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -52,6 +52,24 @@
/>{{ $t("nav.interactions") }}
+ -
+
+
-
+
+
+ {{ $t('settings.hide_shoutbox') }}
+
+
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index d6ccd5ea..c89e5dab 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -115,7 +115,8 @@ export default {
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
- tooltipRadiusLocal: ''
+ tooltipRadiusLocal: '',
+ chatMessageRadiusLocal: ''
}
},
created () {
@@ -230,7 +231,8 @@ export default {
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
- attachment: this.attachmentRadiusLocal
+ attachment: this.attachmentRadiusLocal,
+ chatMessage: this.chatMessageRadiusLocal
}
},
preview () {
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 8dc64901..ff2fece9 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -748,6 +748,65 @@
/>
+
+
{{ $t('chats.chats') }}
+
+ {{ $t('settings.style.advanced_colors.chat.incoming') }}
+
+
+
+
+ {{ $t('settings.style.advanced_colors.chat.outgoing') }}
+
+
+
+
+
+
scrollEl.scrollHeight) {
+ this.$nextTick(() => {
+ if (!scrollEl) return
+ scrollEl.scrollTop = scrollEl.scrollHeight - scrollEl.offsetHeight
+ })
+ }
+ }
+ }
+}
+
+export default shoutPanel
diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue
new file mode 100644
index 00000000..1eca88a7
--- /dev/null
+++ b/src/components/shout_panel/shout_panel.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+ {{ $t('shoutbox.title') }}
+
+
+
+
+
+
+
+
+
+
+ {{ message.author.username }}
+
+
+
+ {{ message.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('shoutbox.title') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 4e6e9edd..2c30a54d 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,4 +1,4 @@
-import { mapGetters } from 'vuex'
+import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service'
@@ -53,6 +53,7 @@ const SideDrawer = {
currentUser () {
return this.$store.state.users.currentUser
},
+ shout () { return this.$store.state.shout.joined },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
@@ -86,7 +87,10 @@ const SideDrawer = {
}
return this.currentUser ? 'friends' : 'public-timeline'
},
- ...mapGetters(['unreadAnnouncementCount'])
+ ...mapState({
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+ }),
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
},
methods: {
toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 4134482b..a03bbd0f 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -67,6 +67,27 @@
/> {{ $t("nav.lists") }}
+
+
+ {{ $t("nav.chats") }}
+
+ {{ unreadChatCount }}
+
+
+
-
@@ -96,6 +117,18 @@
+ -
+
+ {{ $t("shoutbox.title") }}
+
+
- 1000 * multiplier
@@ -118,6 +120,19 @@ const api = {
})
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
+ } else if (message.event === 'pleroma:chat_update') {
+ // The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
+ // The cause of the duplicates is the WS event arriving earlier than the HTTP response.
+ // This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
+ // (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
+ setTimeout(() => {
+ dispatch('addChatMessages', {
+ chatId: message.chatUpdate.id,
+ messages: [message.chatUpdate.lastMessage]
+ })
+ dispatch('updateChat', { chat: message.chatUpdate })
+ maybeShowChatNotification(store, message.chatUpdate)
+ }, 100)
}
}
)
@@ -137,12 +152,15 @@ const api = {
]).has(state.mastoUserSocketStatus)) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
+ dispatch('stopFetchingChats')
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
+ // TODO is this needed?
+ dispatch('clearOpenedChats')
})
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
const ignoreCodes = new Set([
@@ -162,6 +180,7 @@ const api = {
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
+ dispatch('startFetchingChats')
dispatch('startFetchingAnnouncements')
dispatch('startFetchingReports')
dispatch('pushGlobalNotice', {
@@ -173,6 +192,7 @@ const api = {
}
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
}
+ dispatch('clearOpenedChats')
})
resolve()
} catch (e) {
@@ -183,6 +203,7 @@ const api = {
stopMastoUserSocket ({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
+ dispatch('startFetchingChats')
state.mastoUserSocket.close()
},
@@ -310,6 +331,17 @@ const api = {
setWsToken (store, token) {
store.commit('setWsToken', token)
},
+ initializeSocket ({ dispatch, commit, state, rootState }) {
+ // Set up websocket connection
+ const token = state.wsToken
+ if (rootState.instance.shoutAvailable && typeof token !== 'undefined' && state.socket === null) {
+ const socket = new Socket('/socket', { params: { token } })
+ socket.connect()
+
+ commit('setSocket', socket)
+ dispatch('initializeShout', socket)
+ }
+ },
disconnectFromSocket ({ commit, state }) {
state.socket && state.socket.disconnect()
commit('setSocket', null)
diff --git a/src/modules/chats.js b/src/modules/chats.js
new file mode 100644
index 00000000..b5aa24b9
--- /dev/null
+++ b/src/modules/chats.js
@@ -0,0 +1,240 @@
+import { reactive } from 'vue'
+import { find, omitBy, orderBy, sumBy } from 'lodash'
+import chatService from '../services/chat_service/chat_service.js'
+import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
+import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
+import { promiseInterval } from '../services/promise_interval/promise_interval.js'
+
+const emptyChatList = () => ({
+ data: [],
+ idStore: {}
+})
+
+const defaultState = {
+ chatList: emptyChatList(),
+ chatListFetcher: null,
+ openedChats: reactive({}),
+ openedChatMessageServices: reactive({}),
+ fetcher: undefined,
+ currentChatId: null,
+ lastReadMessageId: null
+}
+
+const getChatById = (state, id) => {
+ return find(state.chatList.data, { id })
+}
+
+const sortedChatList = (state) => {
+ return orderBy(state.chatList.data, ['updated_at'], ['desc'])
+}
+
+const unreadChatCount = (state) => {
+ return sumBy(state.chatList.data, 'unread')
+}
+
+const chats = {
+ state: { ...defaultState },
+ getters: {
+ currentChat: state => state.openedChats[state.currentChatId],
+ currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId],
+ findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId),
+ sortedChatList,
+ unreadChatCount
+ },
+ actions: {
+ // Chat list
+ startFetchingChats ({ dispatch, commit }) {
+ const fetcher = () => dispatch('fetchChats', { latest: true })
+ fetcher()
+ commit('setChatListFetcher', {
+ fetcher: () => promiseInterval(fetcher, 60000)
+ })
+ },
+ stopFetchingChats ({ commit }) {
+ commit('setChatListFetcher', { fetcher: undefined })
+ },
+ fetchChats ({ dispatch, rootState, commit }, params = {}) {
+ return rootState.api.backendInteractor.chats()
+ .then(({ chats }) => {
+ dispatch('addNewChats', { chats })
+ return chats
+ })
+ },
+ addNewChats (store, { chats }) {
+ const { commit, dispatch, rootGetters } = store
+ const newChatMessageSideEffects = (chat) => {
+ maybeShowChatNotification(store, chat)
+ }
+ commit('addNewChats', { dispatch, chats, rootGetters, newChatMessageSideEffects })
+ },
+ updateChat ({ commit }, { chat }) {
+ commit('updateChat', { chat })
+ },
+
+ // Opened Chats
+ startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
+ dispatch('setCurrentChatFetcher', { fetcher })
+ },
+ setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
+ commit('setCurrentChatFetcher', { fetcher })
+ },
+ addOpenedChat ({ rootState, commit, dispatch }, { chat }) {
+ commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
+ dispatch('addNewUsers', [chat.account])
+ },
+ addChatMessages ({ commit }, value) {
+ commit('addChatMessages', { commit, ...value })
+ },
+ resetChatNewMessageCount ({ commit }, value) {
+ commit('resetChatNewMessageCount', value)
+ },
+ clearCurrentChat ({ rootState, commit, dispatch }, value) {
+ commit('setCurrentChatId', { chatId: undefined })
+ commit('setCurrentChatFetcher', { fetcher: undefined })
+ },
+ readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
+ const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
+
+ dispatch('resetChatNewMessageCount')
+ commit('readChat', { id, lastReadId })
+
+ if (isNewMessage) {
+ rootState.api.backendInteractor.readChat({ id, lastReadId })
+ }
+ },
+ deleteChatMessage ({ rootState, commit }, value) {
+ rootState.api.backendInteractor.deleteChatMessage(value)
+ commit('deleteChatMessage', { commit, ...value })
+ },
+ resetChats ({ commit, dispatch }) {
+ dispatch('clearCurrentChat')
+ commit('resetChats', { commit })
+ },
+ clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
+ commit('clearOpenedChats', { commit })
+ },
+ handleMessageError ({ commit }, value) {
+ commit('handleMessageError', { commit, ...value })
+ },
+ cullOlderMessages ({ commit }, chatId) {
+ commit('cullOlderMessages', chatId)
+ }
+ },
+ mutations: {
+ setChatListFetcher (state, { commit, fetcher }) {
+ const prevFetcher = state.chatListFetcher
+ if (prevFetcher) {
+ prevFetcher.stop()
+ }
+ state.chatListFetcher = fetcher && fetcher()
+ },
+ setCurrentChatFetcher (state, { fetcher }) {
+ const prevFetcher = state.fetcher
+ if (prevFetcher) {
+ prevFetcher.stop()
+ }
+ 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, { chats, newChatMessageSideEffects }) {
+ chats.forEach((updatedChat) => {
+ const chat = getChatById(state, updatedChat.id)
+
+ if (chat) {
+ const isNewMessage = (chat.lastMessage && chat.lastMessage.id) !== (updatedChat.lastMessage && updatedChat.lastMessage.id)
+ chat.lastMessage = updatedChat.lastMessage
+ chat.unread = updatedChat.unread
+ chat.updated_at = updatedChat.updated_at
+ if (isNewMessage && chat.unread) {
+ newChatMessageSideEffects(updatedChat)
+ }
+ } else {
+ state.chatList.data.push(updatedChat)
+ state.chatList.idStore[updatedChat.id] = updatedChat
+ }
+ })
+ },
+ updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
+ const chat = getChatById(state, updatedChat.id)
+ if (chat) {
+ chat.lastMessage = updatedChat.lastMessage
+ chat.unread = updatedChat.unread
+ chat.updated_at = updatedChat.updated_at
+ }
+ if (!chat) { state.chatList.data.unshift(updatedChat) }
+ state.chatList.idStore[updatedChat.id] = updatedChat
+ },
+ deleteChat (state, { _dispatch, id, _rootGetters }) {
+ state.chats.data = state.chats.data.filter(conversation =>
+ conversation.last_status.id !== id
+ )
+ state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id)
+ },
+ resetChats (state, { commit }) {
+ state.chatList = emptyChatList()
+ state.currentChatId = null
+ commit('setChatListFetcher', { fetcher: undefined })
+ for (const chatId in state.openedChats) {
+ chatService.clear(state.openedChatMessageServices[chatId])
+ delete state.openedChats[chatId]
+ delete state.openedChatMessageServices[chatId]
+ }
+ },
+ setChatsLoading (state, { value }) {
+ state.chats.loading = value
+ },
+ addChatMessages (state, { chatId, messages, updateMaxId }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ chatService.add(chatMessageService, { messages: messages.map(parseChatMessage), updateMaxId })
+ }
+ },
+ deleteChatMessage (state, { chatId, messageId }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ if (chatMessageService) {
+ chatService.deleteMessage(chatMessageService, messageId)
+ }
+ },
+ resetChatNewMessageCount (state, _value) {
+ const chatMessageService = state.openedChatMessageServices[state.currentChatId]
+ chatService.resetNewMessageCount(chatMessageService)
+ },
+ // Used when a connection loss occurs
+ clearOpenedChats (state) {
+ const currentChatId = state.currentChatId
+ for (const chatId in state.openedChats) {
+ if (currentChatId !== chatId) {
+ chatService.clear(state.openedChatMessageServices[chatId])
+ delete state.openedChats[chatId]
+ delete state.openedChatMessageServices[chatId]
+ }
+ }
+ },
+ readChat (state, { id, lastReadId }) {
+ state.lastReadMessageId = lastReadId
+ const chat = getChatById(state, id)
+ if (chat) {
+ chat.unread = 0
+ }
+ },
+ handleMessageError (state, { chatId, fakeId, isRetry }) {
+ const chatMessageService = state.openedChatMessageServices[chatId]
+ chatService.handleMessageError(chatMessageService, fakeId, isRetry)
+ },
+ cullOlderMessages (state, chatId) {
+ chatService.cullOlderMessages(state.openedChatMessageServices[chatId])
+ }
+ }
+}
+
+export default chats
diff --git a/src/modules/config.js b/src/modules/config.js
index 43b4f802..717c9264 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -33,6 +33,7 @@ export const defaultState = {
customThemeSource: undefined,
hideISP: false,
hideInstanceWallpaper: false,
+ hideShoutbox: false,
// bad name: actually hides posts of muted USERS
hideMutedPosts: undefined, // instance default
hideMutedThreads: undefined, // instance default
@@ -70,6 +71,7 @@ export const defaultState = {
moves: true,
emojiReactions: true,
followRequest: true,
+ chatMention: true,
polls: true
},
webPushNotifications: false,
diff --git a/src/modules/instance.js b/src/modules/instance.js
index c8c718d0..7204634c 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -87,6 +87,9 @@ const defaultState = {
knownDomains: [],
// Feature-set, apparently, not everything here is reported...
+ shoutAvailable: false,
+ pleromaChatMessagesAvailable: false,
+ gopherAvailable: false,
mediaProxyAvailable: false,
suggestionsEnabled: false,
suggestionsWeb: '',
@@ -149,6 +152,11 @@ const instance = {
case 'name':
dispatch('setPageTitle')
break
+ case 'shoutAvailable':
+ if (value) {
+ dispatch('initializeSocket')
+ }
+ break
case 'theme':
dispatch('setTheme', value)
break
diff --git a/src/modules/serverSideConfig.js b/src/modules/serverSideConfig.js
index e7a6c95c..4b73af26 100644
--- a/src/modules/serverSideConfig.js
+++ b/src/modules/serverSideConfig.js
@@ -47,6 +47,10 @@ export const settingsMap = {
},
// Privacy
'locked': 'locked',
+ 'acceptChatMessages': {
+ get: 'pleroma.accepts_chat_messages',
+ set: 'accepts_chat_messages'
+ },
'allowFollowingMove': {
get: 'pleroma.allow_following_move',
set: 'allow_following_move'
diff --git a/src/modules/shout.js b/src/modules/shout.js
new file mode 100644
index 00000000..88aefbfe
--- /dev/null
+++ b/src/modules/shout.js
@@ -0,0 +1,46 @@
+const shout = {
+ state: {
+ messages: [],
+ channel: { state: '' },
+ joined: false
+ },
+ mutations: {
+ setChannel (state, channel) {
+ state.channel = channel
+ },
+ addMessage (state, message) {
+ state.messages.push(message)
+ state.messages = state.messages.slice(-19, 20)
+ },
+ setMessages (state, messages) {
+ state.messages = messages.slice(-19, 20)
+ },
+ setJoined (state, joined) {
+ state.joined = joined
+ }
+ },
+ actions: {
+ initializeShout (store, socket) {
+ const channel = socket.channel('chat:public')
+ channel.joinPush.receive('ok', () => {
+ store.commit('setJoined', true)
+ })
+ channel.onClose(() => {
+ store.commit('setJoined', false)
+ })
+ channel.onError(() => {
+ store.commit('setJoined', false)
+ })
+ channel.on('new_msg', (msg) => {
+ store.commit('addMessage', msg)
+ })
+ channel.on('messages', ({ messages }) => {
+ store.commit('setMessages', messages)
+ })
+ channel.join()
+ store.commit('setChannel', channel)
+ }
+ }
+}
+
+export default shout
diff --git a/src/modules/users.js b/src/modules/users.js
index 5e15d3ce..49b299c4 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -546,6 +546,7 @@ const users = {
store.dispatch('stopFetchingConfig')
store.commit('clearNotifications')
store.commit('resetStatuses')
+ store.dispatch('resetChats')
store.dispatch('setLastTimeline', 'public-timeline')
store.dispatch('setLayoutWidth', windowWidth())
store.dispatch('setLayoutHeight', windowHeight())
@@ -577,6 +578,9 @@ const users = {
if (user.token) {
store.dispatch('setWsToken', user.token)
+
+ // Initialize the shout socket.
+ store.dispatch('initializeSocket')
}
const startPolling = () => {
@@ -585,6 +589,9 @@ const users = {
// Start fetching notifications
store.dispatch('startFetchingNotifications')
+
+ // Start fetching chats
+ store.dispatch('startFetchingChats')
}
if (store.getters.mergedConfig.useStreamingApi) {
@@ -593,6 +600,7 @@ const users = {
store.dispatch('enableMastoSockets', true).catch((error) => {
console.error('Failed initializing MastoAPI Streaming socket', error)
}).then(() => {
+ store.dispatch('fetchChats', { latest: true })
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
})
} else {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index aeb43661..29183acd 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, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseChat, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
@@ -101,6 +101,11 @@ const MASTODON_ANNOUNCEMENTS_DISMISS_URL = id => `/api/v1/announcements/${id}/di
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
+const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
+const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
+const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
+const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
+const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
@@ -1571,6 +1576,7 @@ const MASTODON_STREAMING_EVENTS = new Set([
])
const PLEROMA_STREAMING_EVENTS = new Set([
+ 'pleroma:chat_update'
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
@@ -1652,6 +1658,8 @@ export const handleMastoWS = (wsEvent) => {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
+ } else if (event === 'pleroma:chat_update') {
+ return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
@@ -1668,6 +1676,82 @@ export const WSConnectionStatus = Object.freeze({
'STARTING_INITIAL': 6
})
+const chats = ({ credentials }) => {
+ return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+ .then((data) => {
+ return { chats: data.map(parseChat).filter(c => c) }
+ })
+}
+
+const getOrCreateChat = ({ accountId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_URL(accountId),
+ method: 'POST',
+ credentials
+ })
+}
+
+const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
+ let url = PLEROMA_CHAT_MESSAGES_URL(id)
+ const args = [
+ maxId && `max_id=${maxId}`,
+ sinceId && `since_id=${sinceId}`,
+ limit && `limit=${limit}`
+ ].filter(_ => _).join('&')
+
+ url = url + (args ? '?' + args : '')
+
+ return promisedRequest({
+ url,
+ method: 'GET',
+ credentials
+ })
+}
+
+const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
+ const payload = {
+ 'content': content
+ }
+
+ if (mediaId) {
+ payload['media_id'] = mediaId
+ }
+
+ const headers = {}
+
+ if (idempotencyKey) {
+ headers['idempotency-key'] = idempotencyKey
+ }
+
+ return promisedRequest({
+ url: PLEROMA_CHAT_MESSAGES_URL(id),
+ method: 'POST',
+ payload: payload,
+ credentials,
+ headers
+ })
+}
+
+const readChat = ({ id, lastReadId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_READ_URL(id),
+ method: 'POST',
+ payload: {
+ 'last_read_id': lastReadId
+ },
+ credentials
+ })
+}
+
+const deleteChatMessage = ({ chatId, messageId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
+ method: 'DELETE',
+ credentials
+ })
+}
+
const apiService = {
verifyCredentials,
fetchTimeline,
@@ -1769,6 +1853,12 @@ const apiService = {
fetchDomainMutes,
muteDomain,
unmuteDomain,
+ chats,
+ getOrCreateChat,
+ chatMessages,
+ sendChatMessage,
+ readChat,
+ deleteChatMessage,
fetchAnnouncements,
dismissAnnouncement,
postAnnouncement,
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
new file mode 100644
index 00000000..92ff689d
--- /dev/null
+++ b/src/services/chat_service/chat_service.js
@@ -0,0 +1,226 @@
+import _ from 'lodash'
+
+const empty = (chatId) => {
+ return {
+ idIndex: {},
+ idempotencyKeyIndex: {},
+ messages: [],
+ newMessageCount: 0,
+ lastSeenMessageId: '0',
+ chatId: chatId,
+ minId: undefined,
+ maxId: undefined
+ }
+}
+
+const clear = (storage) => {
+ const failedMessageIds = []
+
+ for (const message of storage.messages) {
+ if (message.error) {
+ failedMessageIds.push(message.id)
+ } else {
+ delete storage.idIndex[message.id]
+ delete storage.idempotencyKeyIndex[message.idempotency_key]
+ }
+ }
+
+ storage.messages = storage.messages.filter(m => failedMessageIds.includes(m.id))
+ storage.newMessageCount = 0
+ storage.lastSeenMessageId = '0'
+ storage.minId = undefined
+ storage.maxId = undefined
+}
+
+const deleteMessage = (storage, messageId) => {
+ if (!storage) { return }
+ storage.messages = storage.messages.filter(m => m.id !== messageId)
+ delete storage.idIndex[messageId]
+
+ if (storage.maxId === messageId) {
+ const lastMessage = _.maxBy(storage.messages, 'id')
+ storage.maxId = lastMessage.id
+ }
+
+ if (storage.minId === messageId) {
+ const firstMessage = _.minBy(storage.messages, 'id')
+ storage.minId = firstMessage.id
+ }
+}
+
+const cullOlderMessages = (storage) => {
+ const maxIndex = storage.messages.length
+ const minIndex = maxIndex - 50
+ if (maxIndex <= 50) return
+
+ storage.messages = _.sortBy(storage.messages, ['id'])
+ storage.minId = storage.messages[minIndex].id
+ for (const message of storage.messages) {
+ if (message.id < storage.minId) {
+ delete storage.idIndex[message.id]
+ delete storage.idempotencyKeyIndex[message.idempotency_key]
+ }
+ }
+ storage.messages = storage.messages.slice(minIndex, maxIndex)
+}
+
+const handleMessageError = (storage, fakeId, isRetry) => {
+ if (!storage) { return }
+ const fakeMessage = storage.idIndex[fakeId]
+ if (fakeMessage) {
+ fakeMessage.error = true
+ fakeMessage.pending = false
+ if (!isRetry) {
+ // Ensure the failed message doesn't stay at the bottom of the list.
+ const lastPersistedMessage = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'desc'])[0]
+ if (lastPersistedMessage) {
+ const oldId = fakeMessage.id
+ fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
+ storage.idIndex[fakeMessage.id] = fakeMessage
+ delete storage.idIndex[oldId]
+ }
+ }
+ }
+}
+
+const add = (storage, { messages: newMessages, updateMaxId = true }) => {
+ if (!storage) { return }
+ for (let i = 0; i < newMessages.length; i++) {
+ const message = newMessages[i]
+
+ // sanity check
+ if (message.chat_id !== storage.chatId) { return }
+
+ if (message.fakeId) {
+ const fakeMessage = storage.idIndex[message.fakeId]
+ if (fakeMessage) {
+ // In case the same id exists (chat update before POST response)
+ // make sure to remove the older duplicate message.
+ if (storage.idIndex[message.id]) {
+ delete storage.idIndex[message.id]
+ storage.messages = storage.messages.filter(msg => msg.id !== message.id)
+ }
+ Object.assign(fakeMessage, message, { error: false })
+ delete fakeMessage['fakeId']
+ storage.idIndex[fakeMessage.id] = fakeMessage
+ delete storage.idIndex[message.fakeId]
+
+ return
+ }
+ }
+
+ if (!storage.minId || (!message.pending && message.id < storage.minId)) {
+ storage.minId = message.id
+ }
+
+ if (!storage.maxId || message.id > storage.maxId) {
+ if (updateMaxId) {
+ storage.maxId = message.id
+ }
+ }
+
+ if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
+ if (storage.lastSeenMessageId < message.id) {
+ storage.newMessageCount++
+ }
+ storage.idIndex[message.id] = message
+ storage.messages.push(storage.idIndex[message.id])
+ storage.idempotencyKeyIndex[message.idempotency_key] = true
+ }
+ }
+}
+
+const isConfirmation = (storage, message) => {
+ if (!message.idempotency_key) return
+ return storage.idempotencyKeyIndex[message.idempotency_key]
+}
+
+const resetNewMessageCount = (storage) => {
+ if (!storage) { return }
+ storage.newMessageCount = 0
+ storage.lastSeenMessageId = storage.maxId
+}
+
+// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
+const getView = (storage) => {
+ if (!storage) { return [] }
+
+ const result = []
+ const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
+ const firstMessage = messages[0]
+ let previousMessage = messages[messages.length - 1]
+ let currentMessageChainId
+
+ if (firstMessage) {
+ const date = new Date(firstMessage.created_at)
+ date.setHours(0, 0, 0, 0)
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+ }
+
+ let afterDate = false
+
+ for (let i = 0; i < messages.length; i++) {
+ const message = messages[i]
+ const nextMessage = messages[i + 1]
+
+ const date = new Date(message.created_at)
+ date.setHours(0, 0, 0, 0)
+
+ // insert date separator and start a new message chain
+ if (previousMessage && previousMessage.date < date) {
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+
+ previousMessage['isTail'] = true
+ currentMessageChainId = undefined
+ afterDate = true
+ }
+
+ const object = {
+ type: 'message',
+ data: message,
+ date,
+ id: message.id,
+ messageChainId: currentMessageChainId
+ }
+
+ // end a message chian
+ if ((nextMessage && nextMessage.account_id) !== message.account_id) {
+ object['isTail'] = true
+ currentMessageChainId = undefined
+ }
+
+ // start a new message chain
+ if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) {
+ currentMessageChainId = _.uniqueId()
+ object['isHead'] = true
+ object['messageChainId'] = currentMessageChainId
+ }
+
+ result.push(object)
+ previousMessage = object
+ afterDate = false
+ }
+
+ return result
+}
+
+const ChatService = {
+ add,
+ empty,
+ getView,
+ deleteMessage,
+ cullOlderMessages,
+ resetNewMessageCount,
+ clear,
+ handleMessageError
+}
+
+export default ChatService
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
new file mode 100644
index 00000000..de6e0625
--- /dev/null
+++ b/src/services/chat_utils/chat_utils.js
@@ -0,0 +1,41 @@
+import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
+
+export const maybeShowChatNotification = (store, chat) => {
+ if (!chat.lastMessage) return
+ if (store.rootState.chats.currentChatId === chat.id && !document.hidden) return
+ if (store.rootState.users.currentUser.id === chat.lastMessage.account_id) return
+
+ const opts = {
+ tag: chat.lastMessage.id,
+ title: chat.account.name,
+ icon: chat.account.profile_image_url,
+ body: chat.lastMessage.content
+ }
+
+ if (chat.lastMessage.attachment && chat.lastMessage.attachment.type === 'image') {
+ opts.image = chat.lastMessage.attachment.preview_url
+ }
+
+ showDesktopNotification(store.rootState, opts)
+}
+
+export const buildFakeMessage = ({ content, chatId, attachments, userId, idempotencyKey }) => {
+ const fakeMessage = {
+ content,
+ chat_id: chatId,
+ created_at: new Date(),
+ id: `${new Date().getTime()}`,
+ attachments: attachments,
+ account_id: userId,
+ idempotency_key: idempotencyKey,
+ emojis: [],
+ pending: true,
+ isNormalized: true
+ }
+
+ if (attachments[0]) {
+ fakeMessage.attachment = attachments[0]
+ }
+
+ return fakeMessage
+}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 465d6fad..f67a8cfe 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -98,6 +98,7 @@ export const parseUser = (data) => {
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
+ output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
@@ -203,6 +204,7 @@ export const parseUser = (data) => {
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
+ output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
@@ -424,7 +426,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)
@@ -468,3 +470,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
minId: flakeId ? minId : parseInt(minId, 10)
}
}
+
+export const parseChat = (chat) => {
+ const output = {}
+ output.id = chat.id
+ output.account = parseUser(chat.account)
+ output.unread = chat.unread
+ output.lastMessage = parseChatMessage(chat.last_message)
+ output.updated_at = new Date(chat.updated_at)
+ return output
+}
+
+export const parseChatMessage = (message) => {
+ if (!message) { return }
+ if (message.isNormalized) { return message }
+ const output = message
+ output.id = message.id
+ output.created_at = new Date(message.created_at)
+ output.chat_id = message.chat_id
+ output.emojis = message.emojis
+ output.content = message.content
+ if (message.attachment) {
+ output.attachments = [parseAttachment(message.attachment)]
+ } else {
+ output.attachments = []
+ }
+ output.pending = !!message.pending
+ output.error = false
+ output.idempotency_key = message.idempotency_key
+ output.isNormalized = true
+ return output
+}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index d5bf8749..543aa874 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 29474512..c2983be7 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -23,7 +23,9 @@ export const LAYERS = {
inputTopBar: 'topBar',
alert: 'bg',
alertPanel: 'panel',
- poll: 'bg'
+ poll: 'bg',
+ chatBg: 'underlay',
+ chatMessage: 'chatBg'
}
/* By default opacity slots have 1 as default opacity
@@ -705,5 +707,58 @@ export const SLOT_INHERITANCE = {
layer: 'badge',
variant: 'badgeNotification',
textColor: 'bw'
+ },
+
+ chatBg: {
+ depends: ['bg']
+ },
+
+ chatMessageIncomingBg: {
+ depends: ['chatBg']
+ },
+
+ chatMessageIncomingText: {
+ depends: ['text'],
+ layer: 'chatMessage',
+ variant: 'chatMessageIncomingBg',
+ textColor: true
+ },
+
+ chatMessageIncomingLink: {
+ depends: ['link'],
+ layer: 'chatMessage',
+ variant: 'chatMessageIncomingBg',
+ textColor: 'preserve'
+ },
+
+ chatMessageIncomingBorder: {
+ depends: ['border'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
+ },
+
+ chatMessageOutgoingBg: {
+ depends: ['chatMessageIncomingBg'],
+ color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb
+ },
+
+ chatMessageOutgoingText: {
+ depends: ['text'],
+ layer: 'chatMessage',
+ variant: 'chatMessageOutgoingBg',
+ textColor: true
+ },
+
+ chatMessageOutgoingLink: {
+ depends: ['link'],
+ layer: 'chatMessage',
+ variant: 'chatMessageOutgoingBg',
+ textColor: 'preserve'
+ },
+
+ chatMessageOutgoingBorder: {
+ depends: ['chatMessageOutgoingBg'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
}
}
diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js
new file mode 100644
index 00000000..fbbca436
--- /dev/null
+++ b/test/unit/specs/services/chat_service/chat_service.spec.js
@@ -0,0 +1,108 @@
+import chatService from '../../../../../src/services/chat_service/chat_service.js'
+
+const message1 = {
+ id: '9wLkdcmQXD21Oy8lEX',
+ idempotency_key: '1',
+ created_at: (new Date('2020-06-22T18:45:53.000Z'))
+}
+
+const message2 = {
+ id: '9wLkdp6ihaOVdNj8Wu',
+ idempotency_key: '2',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: (new Date('2020-06-22T18:45:56.000Z'))
+}
+
+const message3 = {
+ id: '9wLke9zL4Dy4OZR2RM',
+ idempotency_key: '3',
+ account_id: '9vmRb29zLQReckr5ay',
+ created_at: (new Date('2020-07-22T18:45:59.000Z'))
+}
+
+describe('chatService', () => {
+ describe('.add', () => {
+ it("Doesn't add duplicates", () => {
+ const chat = chatService.empty()
+ chatService.add(chat, { messages: [ message1 ] })
+ chatService.add(chat, { messages: [ message1 ] })
+ expect(chat.messages.length).to.eql(1)
+
+ chatService.add(chat, { messages: [ message2 ] })
+ expect(chat.messages.length).to.eql(2)
+ })
+
+ it('Updates minId and lastMessage and newMessageCount', () => {
+ const chat = chatService.empty()
+
+ chatService.add(chat, { messages: [ message1 ] })
+ expect(chat.maxId).to.eql(message1.id)
+ expect(chat.minId).to.eql(message1.id)
+ expect(chat.newMessageCount).to.eql(1)
+
+ chatService.add(chat, { messages: [ message2 ] })
+ expect(chat.maxId).to.eql(message2.id)
+ expect(chat.minId).to.eql(message1.id)
+ expect(chat.newMessageCount).to.eql(2)
+
+ chatService.resetNewMessageCount(chat)
+ expect(chat.newMessageCount).to.eql(0)
+ expect(chat.lastSeenMessageId).to.eql(message2.id)
+
+ // Add message with higher id
+ chatService.add(chat, { messages: [ message3 ] })
+ expect(chat.newMessageCount).to.eql(1)
+ })
+ })
+
+ describe('.delete', () => {
+ it('Updates minId and lastMessage', () => {
+ const chat = chatService.empty()
+
+ chatService.add(chat, { messages: [ message1 ] })
+ chatService.add(chat, { messages: [ message2 ] })
+ chatService.add(chat, { messages: [ message3 ] })
+
+ expect(chat.maxId).to.eql(message3.id)
+ expect(chat.minId).to.eql(message1.id)
+
+ chatService.deleteMessage(chat, message3.id)
+ expect(chat.maxId).to.eql(message2.id)
+ expect(chat.minId).to.eql(message1.id)
+
+ chatService.deleteMessage(chat, message1.id)
+ expect(chat.maxId).to.eql(message2.id)
+ expect(chat.minId).to.eql(message2.id)
+ })
+ })
+
+ describe('.getView', () => {
+ it('Inserts date separators', () => {
+ const chat = chatService.empty()
+
+ chatService.add(chat, { messages: [ message1 ] })
+ chatService.add(chat, { messages: [ message2 ] })
+ chatService.add(chat, { messages: [ message3 ] })
+
+ const view = chatService.getView(chat)
+ expect(view.map(i => i.type)).to.eql(['date', 'message', 'message', 'date', 'message'])
+ })
+ })
+
+ describe('.cullOlderMessages', () => {
+ it('keeps 50 newest messages and idIndex matches', () => {
+ const chat = chatService.empty()
+
+ for (let i = 100; i > 0; i--) {
+ // Use decimal values with toFixed to hack together constant length predictable strings
+ chatService.add(chat, { messages: [{ ...message1, id: 'a' + (i / 1000).toFixed(3), idempotency_key: i }] })
+ }
+ chatService.cullOlderMessages(chat)
+ expect(chat.messages.length).to.eql(50)
+ expect(chat.messages[0].id).to.eql('a0.051')
+ expect(chat.minId).to.eql('a0.051')
+ expect(chat.messages[49].id).to.eql('a0.100')
+ expect(Object.keys(chat.idIndex).length).to.eql(50)
+ })
+ })
+})