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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + 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 @@ + + +
  • +
    + + + +
    +
    + +