From de291e2e33f1d9e04b27ed30ba3b012d73178e63 Mon Sep 17 00:00:00 2001 From: Eugenij Date: Fri, 3 Jul 2020 19:45:49 +0000 Subject: Add bookmarks Co-authored-by: jared --- .../entity_normalizer/entity_normalizer.spec.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) (limited to 'test/unit/specs/services') diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index ccb57942..e1f7a958 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -1,4 +1,4 @@ -import { parseStatus, parseUser, parseNotification, addEmojis } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' import mastoapidata from '../../../../fixtures/mastoapi.json' import qvitterapidata from '../../../../fixtures/statuses.json' @@ -383,4 +383,24 @@ describe('API Entities normalizer', () => { expect(result).to.include('title=\':[a-z] {|}*:\'') }) }) + + describe('Link header pagination', () => { + it('Parses min and max ids as integers', () => { + const linkHeader = '; rel="next", ; rel="prev"' + const result = parseLinkHeaderPagination(linkHeader) + expect(result).to.eql({ + 'maxId': 861676, + 'minId': 861741 + }) + }) + + it('Parses min and max ids as flakes', () => { + const linkHeader = '; rel="next", ; rel="prev"' + const result = parseLinkHeaderPagination(linkHeader, { flakeId: true }) + expect(result).to.eql({ + 'maxId': '9waQx5IIS48qVue2Ai', + 'minId': '9wi61nIPnfn674xgie' + }) + }) + }) }) -- cgit v1.2.3-70-g09d2 From f05f832bff58034d78de9478ae2dbb06284dea75 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Sun, 21 Jun 2020 17:13:29 +0300 Subject: Address feedback Use more specific css rules for the emoji dimensions in the chat list status preview. Use more round em value for chat list item height. Add global html overflow and height for smoother chat navigation in the desktop Safari. Use offsetHeight instad of a computed style when setting the window height on resize. Remove margin-bottom from the last message to avoid occasional layout shift in the desktop Safari Use break-word to prevent chat message text overflow Resize and scroll the textarea when inserting a new line on ctrl+enter Remove fade transition on route change Ensure proper border radius at the bottom of the chat, remove unused border-radius Prevent the chat header "jumping" on the avatar load. --- src/App.js | 12 +-- src/App.scss | 39 +++++++- src/App.vue | 4 +- src/components/chat/chat.js | 63 +++++++++---- src/components/chat/chat.scss | 32 +++---- src/components/chat/chat.vue | 2 +- src/components/chat/chat_layout.js | 100 --------------------- src/components/chat/chat_layout_utils.js | 3 +- src/components/chat_avatar/chat_avatar.js | 23 ----- src/components/chat_avatar/chat_avatar.vue | 53 ----------- src/components/chat_list_item/chat_list_item.js | 4 +- src/components/chat_list_item/chat_list_item.scss | 48 +++++----- src/components/chat_list_item/chat_list_item.vue | 2 +- src/components/chat_message/chat_message.js | 4 +- src/components/chat_message/chat_message.scss | 27 +++--- src/components/chat_new/chat_new.js | 5 +- src/components/chat_new/chat_new.scss | 2 +- src/components/chat_title/chat_title.js | 10 ++- src/components/chat_title/chat_title.vue | 39 ++++---- src/components/emoji_input/emoji_input.js | 23 ++++- src/components/media_upload/media_upload.vue | 13 --- .../post_status_form/post_status_form.js | 19 ++-- .../post_status_form/post_status_form.vue | 20 ++++- src/services/chat_service/chat_service.js | 19 ++-- .../services/chat_service/chat_service.spec.js | 89 ++++++++++++++++++ 25 files changed, 317 insertions(+), 338 deletions(-) delete mode 100644 src/components/chat/chat_layout.js delete mode 100644 src/components/chat_avatar/chat_avatar.js delete mode 100644 src/components/chat_avatar/chat_avatar.vue create mode 100644 test/unit/specs/services/chat_service/chat_service.spec.js (limited to 'test/unit/specs/services') diff --git a/src/App.js b/src/App.js index 84300e00..ded772fa 100644 --- a/src/App.js +++ b/src/App.js @@ -45,8 +45,7 @@ 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 @@ -135,14 +134,5 @@ export default { } this.$store.dispatch('setLayoutHeight', layoutHeight) } - }, - 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 29ce73a8..e2e2d079 100644 --- a/src/App.scss +++ b/src/App.scss @@ -47,6 +47,7 @@ html { } body { + overscroll-behavior-y: none; font-family: sans-serif; font-family: var(--interfaceFont, sans-serif); margin: 0; @@ -56,7 +57,6 @@ body { overflow-x: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - overscroll-behavior: none; &.hidden { display: none; @@ -320,7 +320,7 @@ option { i[class*=icon-] { color: $fallback--icon; - color: var(--icon, $fallback--icon) + color: var(--icon, $fallback--icon); } .btn-block { @@ -942,3 +942,38 @@ nav { max-height: 1.3rem; line-height: 1.3rem; } + +.chat-layout { + // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). + overflow: hidden; + height: 100%; + + // Ensures the fixed position of the mobile browser bars on scroll up / down events. + // Prevents the mobile browser bars from overlapping or hiding the message posting form. + @media all and (max-width: 800px) { + body { + height: 100%; + } + + #app { + height: 100%; + overflow: hidden; + min-height: auto; + } + + #app_bg_wrapper { + overflow: hidden; + } + + .main { + overflow: hidden; + height: 100%; + } + + #content { + padding-top: 0; + height: 100%; + overflow: visible; + } + } +} diff --git a/src/App.vue b/src/App.vue index 5d429934..0276c6a6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -113,9 +113,7 @@ {{ $t("login.hint") }} - - - + diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index 6e23c20c..9c4e5b05 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -2,29 +2,26 @@ import _ from 'lodash' import { WSConnectionStatus } from '../../services/api/api.service.js' import { mapGetters, mapState } from 'vuex' import ChatMessage from '../chat_message/chat_message.vue' -import ChatAvatar from '../chat_avatar/chat_avatar.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import ChatTitle from '../chat_title/chat_title.vue' import chatService from '../../services/chat_service/chat_service.js' -import ChatLayout from './chat_layout.js' import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js' const BOTTOMED_OUT_OFFSET = 10 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150 +const SAFE_RESIZE_TIME_OFFSET = 100 const Chat = { components: { ChatMessage, ChatTitle, - ChatAvatar, PostStatusForm }, - mixins: [ChatLayout], data () { return { jumpToBottomButtonVisible: false, hoveredMessageChainId: undefined, - scrollPositionBeforeResize: {}, + lastScrollPosition: {}, scrollableContainerHeight: '100%', errorLoadingChat: false } @@ -119,6 +116,7 @@ const Chat = { }, onFilesDropped () { this.$nextTick(() => { + this.handleResize() this.updateScrollableContainerHeight() }) }, @@ -129,13 +127,30 @@ const Chat = { } }) }, - handleLayoutChange () { - this.updateScrollableContainerHeight() - if (this.mobileLayout) { - this.setMobileChatLayout() - } else { - this.unsetMobileChatLayout() + setChatLayout () { + // 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.classList.add('chat-layout') } + + this.$nextTick(() => { + this.updateScrollableContainerHeight() + }) + }, + unsetChatLayout () { + let html = document.querySelector('html') + if (html) { + html.classList.remove('chat-layout') + } + }, + handleLayoutChange () { this.$nextTick(() => { this.updateScrollableContainerHeight() this.scrollDown() @@ -149,15 +164,24 @@ const Chat = { this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px' }, // Preserves the scroll position when OSK appears or the posting form changes its height. - handleResize (opts) { + handleResize (opts = {}) { + const { expand = false, delayed = false } = opts + + if (delayed) { + setTimeout(() => { + this.handleResize({ ...opts, delayed: false }) + }, SAFE_RESIZE_TIME_OFFSET) + return + } + this.$nextTick(() => { this.updateScrollableContainerHeight() - const { offsetHeight = undefined } = this.scrollPositionBeforeResize - this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable) + const { offsetHeight = undefined } = this.lastScrollPosition + this.lastScrollPosition = getScrollPosition(this.$refs.scrollable) - const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight - if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) { + const diff = this.lastScrollPosition.offsetHeight - offsetHeight + if (diff < 0 || (!this.bottomedOut() && expand)) { this.$nextTick(() => { this.updateScrollableContainerHeight() this.$refs.scrollable.scrollTo({ @@ -281,7 +305,12 @@ const Chat = { .then(data => { this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => { this.$nextTick(() => { - this.updateScrollableContainerHeight() + 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. + setTimeout(() => { + this.updateScrollableContainerHeight() + }, SAFE_RESIZE_TIME_OFFSET) this.scrollDown({ forceRead: true }) }) }) diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss index 13c52ea3..6ae7ebc9 100644 --- a/src/components/chat/chat.scss +++ b/src/components/chat/chat.scss @@ -3,14 +3,17 @@ height: calc(100vh - 60px); width: 100%; + .chat-title { + // prevents chat header jumping on when the user avatar loads + height: 28px; + } + .chat-view-inner { height: auto; width: 100%; overflow: visible; display: flex; - margin-top: 0.5em; - margin-left: 0.5em; - margin-right: 0.5em; + margin: 0.5em 0.5em 0 0.5em; } .chat-view-body { @@ -19,23 +22,18 @@ 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; + margin: 0 0 0 0; border-radius: 10px 10px 0 0; border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ; &::after { - border-radius: none; - box-shadow: none; + border-radius: 0; } } .scrollable-message-list { - padding: 0 10px; + padding: 0 0.8em; height: 100%; overflow-y: scroll; overflow-x: hidden; @@ -45,7 +43,7 @@ .footer { position: sticky; - bottom: 0px; + bottom: 0; } .chat-view-heading { @@ -54,15 +52,19 @@ top: 50px; display: flex; z-index: 2; - border-radius: none; position: sticky; display: flex; overflow: hidden; } .go-back-button { - margin-right: 1.2em; cursor: pointer; + margin-right: 1.4em; + + i { + display: flex; + align-items: center; + } } .jump-to-bottom-button { @@ -135,7 +137,7 @@ overflow: hidden; height: 100%; margin: 0; - border-radius: 0 !important; + border-radius: 0; } .chat-view-heading { diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue index d8c91dbe..62b72e14 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -75,7 +75,7 @@ :disable-polls="true" :disable-sensitivity-checkbox="true" :disable-submit="errorLoadingChat || !currentChat" - :request="sendMessage" + :post-handler="sendMessage" :submit-on-enter="!mobileLayout" :preserve-focus="!mobileLayout" :auto-focus="!mobileLayout" diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js deleted file mode 100644 index 07ae3abf..00000000 --- a/src/components/chat/chat_layout.js +++ /dev/null @@ -1,100 +0,0 @@ -const ChatLayout = { - methods: { - setChatLayout () { - if (this.mobileLayout) { - this.setMobileChatLayout() - } - }, - unsetChatLayout () { - this.unsetMobileChatLayout() - }, - setMobileChatLayout () { - // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app). - // This layout prevents empty spaces from being visible at the bottom - // of the chat on iOS Safari (`safe-area-inset`) when - // - the on-screen keyboard appears and the user starts typing - // - the user selects the text inside the input area - // - the user selects and deletes the text that is multiple lines long - // TODO: unify the chat layout with the global layout. - - let html = document.querySelector('html') - if (html) { - html.style.overflow = 'hidden' - html.style.height = '100%' - } - - let body = document.querySelector('body') - if (body) { - body.style.height = '100%' - } - - let app = document.getElementById('app') - if (app) { - app.style.height = '100%' - app.style.overflow = 'hidden' - app.style.minHeight = 'auto' - } - - let appBgWrapper = window.document.getElementById('app_bg_wrapper') - if (appBgWrapper) { - appBgWrapper.style.overflow = 'hidden' - } - - let main = document.getElementsByClassName('main')[0] - if (main) { - main.style.overflow = 'hidden' - main.style.height = '100%' - } - - let content = document.getElementById('content') - if (content) { - content.style.paddingTop = '0' - content.style.height = '100%' - content.style.overflow = 'visible' - } - - this.$nextTick(() => { - this.updateScrollableContainerHeight() - }) - }, - unsetMobileChatLayout () { - let html = document.querySelector('html') - if (html) { - html.style.overflow = 'visible' - html.style.height = 'unset' - } - - let body = document.querySelector('body') - if (body) { - body.style.height = 'unset' - } - - let app = document.getElementById('app') - if (app) { - app.style.height = '100%' - app.style.overflow = 'visible' - app.style.minHeight = '100vh' - } - - let appBgWrapper = document.getElementById('app_bg_wrapper') - if (appBgWrapper) { - appBgWrapper.style.overflow = 'visible' - } - - let main = document.getElementsByClassName('main')[0] - if (main) { - main.style.overflow = 'visible' - main.style.height = 'unset' - } - - let content = document.getElementById('content') - if (content) { - content.style.paddingTop = '60px' - content.style.height = 'unset' - content.style.overflow = 'unset' - } - } - } -} - -export default ChatLayout diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js index f07ba2a1..609dc0c9 100644 --- a/src/components/chat/chat_layout_utils.js +++ b/src/components/chat/chat_layout_utils.js @@ -22,6 +22,5 @@ export const isBottomedOut = (el, offset = 0) => { // Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form. export const scrollableContainerHeight = (inner, header, footer) => { - const height = parseFloat(getComputedStyle(inner, null).height.replace('px', '')) - return height - header.clientHeight - footer.clientHeight + return inner.offsetHeight - header.clientHeight - footer.clientHeight } diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js deleted file mode 100644 index 7b26e07c..00000000 --- a/src/components/chat_avatar/chat_avatar.js +++ /dev/null @@ -1,23 +0,0 @@ -import StillImage from '../still-image/still-image.vue' -import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' -import { mapState } from 'vuex' - -const ChatAvatar = { - props: ['user', 'width', 'height'], - components: { - StillImage - }, - methods: { - getUserProfileLink (user) { - if (!user) { return } - return generateProfileLink(user.id, user.screen_name) - } - }, - computed: { - ...mapState({ - betterShadow: state => state.interface.browserSupport.cssFilter - }) - } -} - -export default ChatAvatar diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue deleted file mode 100644 index f54a7151..00000000 --- a/src/components/chat_avatar/chat_avatar.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - - diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js index 1c27088c..b6b0519a 100644 --- a/src/components/chat_list_item/chat_list_item.js +++ b/src/components/chat_list_item/chat_list_item.js @@ -1,7 +1,7 @@ import { mapState } from 'vuex' import StatusContent from '../status_content/status_content.vue' import fileType from 'src/services/file_type/file_type.service' -import ChatAvatar from '../chat_avatar/chat_avatar.vue' +import 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' @@ -12,7 +12,7 @@ const ChatListItem = { 'chat' ], components: { - ChatAvatar, + UserAvatar, AvatarList, Timeago, ChatTitle, diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss index 12269f89..3ec59ea2 100644 --- a/src/components/chat_list_item/chat_list_item.scss +++ b/src/components/chat_list_item/chat_list_item.scss @@ -1,17 +1,8 @@ .chat-list-item { - &:hover .animated.avatar { - canvas { - display: none; - } - img { - visibility: visible; - } - } - display: flex; flex-direction: row; padding: 0.75em; - height: 4.85em; + height: 5em; overflow: hidden; box-sizing: border-box; cursor: pointer; @@ -22,7 +13,7 @@ &:hover { background-color: var(--selectedPost, $fallback--lightBg); - box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1); + box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1); } .chat-list-item-left { @@ -47,12 +38,6 @@ 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; @@ -65,7 +50,7 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - margin: 0.35rem 0; + margin: 0.35em 0; height: 1.2em; line-height: 1.2em; color: $fallback--text; @@ -78,17 +63,24 @@ pointer-events: none; } - .unread-indicator-wrapper { - display: flex; - align-items: center; - margin-left: 10px; + &:hover .animated.avatar { + canvas { + display: none; + } + img { + visibility: visible; + } } - .unread-indicator { - border-radius: 100%; - height: 8px; - width: 8px; - background-color: $fallback--link; - background-color: var(--link, $fallback--link); + .avatar.still-image { + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } + + .status-body { + img.emoji { + width: 1.4em; + height: 1.4em; + } } } diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue index 26ad581b..640426b8 100644 --- a/src/components/chat_list_item/chat_list_item.vue +++ b/src/components/chat_list_item/chat_list_item.vue @@ -4,7 +4,7 @@ @click.capture.prevent="openChat" >
- id !== userId) }, - search: throttle(function (query) { + search (query) { if (!query) { this.loading = false return @@ -67,7 +66,7 @@ const chatNew = { this.loading = false this.userIds = data.accounts.map(a => a.id) }) - }) + } } } diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss index 39216677..11305444 100644 --- a/src/components/chat_new/chat_new.scss +++ b/src/components/chat_new/chat_new.scss @@ -15,7 +15,7 @@ } .member-list { - padding-bottom: 0.67rem; + padding-bottom: 0.7rem; } .basic-user-card:hover { diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js index 2723d5f5..e424bb1f 100644 --- a/src/components/chat_title/chat_title.js +++ b/src/components/chat_title/chat_title.js @@ -1,10 +1,11 @@ import Vue from 'vue' -import ChatAvatar from '../chat_avatar/chat_avatar.vue' +import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import UserAvatar from '../user_avatar/user_avatar.vue' export default Vue.component('chat-title', { name: 'ChatTitle', components: { - ChatAvatar + UserAvatar }, props: [ 'user', 'withAvatar' @@ -16,5 +17,10 @@ export default Vue.component('chat-title', { 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 index fd42d125..cfd1e6d1 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -4,16 +4,16 @@ class="chat-title" :title="title" > - - + + + diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index a27da090..f0123447 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -88,6 +88,11 @@ const EmojiInput = { required: false, type: String, // 'auto', 'top', 'bottom' default: 'auto' + }, + newlineOnCtrlEnter: { + required: false, + type: Boolean, + default: false } }, data () { @@ -204,7 +209,7 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 }, - insert ({ insertion, keepOpen }) { + insert ({ insertion, keepOpen, surroundingSpace = true }) { const before = this.value.substring(0, this.caret) || '' const after = this.value.substring(this.caret) || '' @@ -223,8 +228,8 @@ const EmojiInput = { * them, masto seem to be rendering :emoji::emoji: correctly now so why not */ const isSpaceRegex = /\s/ - const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' - const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' + const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : '' + const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : '' const newValue = [ before, @@ -381,6 +386,18 @@ const EmojiInput = { }, onKeyDown (e) { const { ctrlKey, shiftKey, key } = e + if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') { + this.insert({ insertion: '\n', surroundingSpace: false }) + // Ensure only one new line is added on macos + e.stopPropagation() + e.preventDefault() + + // Scroll the input element to the position of the cursor + this.$nextTick(() => { + this.input.elm.blur() + this.input.elm.focus() + }) + } // Disable suggestions hotkeys if suggestions are hidden if (!this.temporarilyHideSuggestions) { if (key === 'Tab') { diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index d719eae1..c8865d77 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -33,19 +33,6 @@ @import '../../_variables.scss'; .media-upload { - &.disabled { - .new-icon { - cursor: not-allowed; - } - - &:hover { - i, label { - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - } - } - .label { display: inline-block; } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 90d0fa81..59e4dc26 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -43,7 +43,7 @@ const PostStatusForm = { 'disableSubmit', 'placeholder', 'maxHeight', - 'request', + 'postHandler', 'preserveFocus', 'autoFocus', 'fileLimit', @@ -221,10 +221,6 @@ const PostStatusForm = { event.stopPropagation() event.preventDefault() } - if (opts.control && this.submitOnEnter) { - newStatus.status = `${newStatus.status}\n` - return - } if (this.emptyStatus) { this.error = this.$t('post_status.empty_status_error') @@ -259,9 +255,9 @@ const PostStatusForm = { poll } - const request = this.request ? this.request : statusPoster.postStatus + const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus - request(postingOptions).then((data) => { + postHandler(postingOptions).then((data) => { if (!data.error) { this.newStatus = { status: '', @@ -345,11 +341,7 @@ const PostStatusForm = { }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) - - // TODO: use fixed dimensions instead so relying on timeout - setTimeout(() => { - this.$emit('resize') - }, 150) + this.$emit('resize', { delayed: true }) }, removeMediaFile (fileInfo) { let index = this.newStatus.files.indexOf(fileInfo) @@ -364,6 +356,7 @@ const PostStatusForm = { this.uploadingFiles = true }, finishedUploadingFiles () { + this.$emit('resize') this.uploadingFiles = false }, type (fileInfo) { @@ -417,7 +410,7 @@ const PostStatusForm = { // Reset to default height for empty form, nothing else to do here. if (target.value === '') { target.style.height = null - this.$emit('resize', null) + this.$emit('resize') this.$refs['emoji-input'].resize() return } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index d8df68d6..7454958b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -131,6 +131,7 @@ class="form-control main-input" enable-emoji-picker hide-emoji-button + :newline-on-ctrl-enter="submitOnEnter" enable-sticker-picker @input="onEmojiInputInput" @sticker-uploaded="addMediaFile" @@ -146,8 +147,8 @@ class="form-post-body" :class="{ 'scrollable-form': !!maxHeight }" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" - @keydown.meta.enter="postStatus($event, newStatus, { control: true })" - @keydown.ctrl.enter="postStatus($event, newStatus)" + @keydown.meta.enter="postStatus($event, newStatus)" + @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @input="resize" @compositionupdate="resize" @paste="paste" @@ -435,6 +436,19 @@ color: var(--lightText, $fallback--lightText); } } + + &.disabled { + i { + cursor: not-allowed; + color: $fallback--icon; + color: var(--btnDisabledText, $fallback--icon); + + &:hover { + color: $fallback--icon; + color: var(--btnDisabledText, $fallback--icon); + } + } + } } // Order is not necessary but a good indicator @@ -628,7 +642,7 @@ } // todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before) -img.media-upload { +img.media-upload, .media-upload-container > video { line-height: 0; max-height: 200px; max-width: 100%; diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js index 763a7607..b60a889b 100644 --- a/src/services/chat_service/chat_service.js +++ b/src/services/chat_service/chat_service.js @@ -31,7 +31,8 @@ const deleteMessage = (storage, messageId) => { } if (storage.minId === messageId) { - storage.minId = _.minBy(storage.messages, 'id') + const firstMessage = _.minBy(storage.messages, 'id') + storage.minId = firstMessage.id } } @@ -73,12 +74,12 @@ const getView = (storage) => { const result = [] const messages = _.sortBy(storage.messages, ['id', 'desc']) - const firstMessages = messages[0] - let prev = messages[messages.length - 1] + const firstMessage = messages[0] + let previousMessage = messages[messages.length - 1] let currentMessageChainId - if (firstMessages) { - const date = new Date(firstMessages.created_at) + if (firstMessage) { + const date = new Date(firstMessage.created_at) date.setHours(0, 0, 0, 0) result.push({ type: 'date', @@ -97,14 +98,14 @@ const getView = (storage) => { date.setHours(0, 0, 0, 0) // insert date separator and start a new message chain - if (prev && prev.date < date) { + if (previousMessage && previousMessage.date < date) { result.push({ type: 'date', date, id: date.getTime().toString() }) - prev['isTail'] = true + previousMessage['isTail'] = true currentMessageChainId = undefined afterDate = true } @@ -124,14 +125,14 @@ const getView = (storage) => { } // start a new message chain - if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) { + if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) { currentMessageChainId = _.uniqueId() object['isHead'] = true object['messageChainId'] = currentMessageChainId } result.push(object) - prev = object + previousMessage = object afterDate = false } 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..4e8e566b --- /dev/null +++ b/test/unit/specs/services/chat_service/chat_service.spec.js @@ -0,0 +1,89 @@ +import chatService from '../../../../../src/services/chat_service/chat_service.js' + +const message1 = { + id: '9wLkdcmQXD21Oy8lEX', + created_at: (new Date('2020-06-22T18:45:53.000Z')) +} + +const message2 = { + id: '9wLkdp6ihaOVdNj8Wu', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-06-22T18:45:56.000Z')) +} + +const message3 = { + id: '9wLke9zL4Dy4OZR2RM', + account_id: '9vmRb29zLQReckr5ay', + created_at: (new Date('2020-07-22T18:45:59.000Z')) +} + +// TODO: only +describe.only('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.lastMessage.id).to.eql(message1.id) + expect(chat.minId).to.eql(message1.id) + expect(chat.newMessageCount).to.eql(1) + + chatService.add(chat, { messages: [ message2 ] }) + expect(chat.lastMessage.id).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) + + const createdAt = new Date() + createdAt.setSeconds(createdAt.getSeconds() + 10) + chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] }) + 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.lastMessage.id).to.eql(message3.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message3.id) + expect(chat.lastMessage.id).to.eql(message2.id) + expect(chat.minId).to.eql(message1.id) + + chatService.deleteMessage(chat, message1.id) + expect(chat.lastMessage.id).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']) + }) + }) +}) -- cgit v1.2.3-70-g09d2 From a0c17e1fd0f63e3c7bf827f415caf94e5d5bf95d Mon Sep 17 00:00:00 2001 From: Shpuld Shpuldson Date: Wed, 19 Aug 2020 16:19:36 +0300 Subject: fix tests by removing only and adding empty func for notification tests --- test/unit/specs/modules/statuses.spec.js | 8 +++++--- test/unit/specs/services/chat_service/chat_service.spec.js | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) (limited to 'test/unit/specs/services') diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js index fe42e85b..b790b231 100644 --- a/test/unit/specs/modules/statuses.spec.js +++ b/test/unit/specs/modules/statuses.spec.js @@ -330,7 +330,7 @@ describe('Statuses module', () => { const deletion = makeMockStatus({ id: '4', type: 'deletion' }) deletion.text = 'Dolus deleted notice {{tag:gs.smuglo.li,2016-11-18:noticeId=1038007:objectType=note}}.' deletion.uri = 'xxx' - + const newNotificationSideEffects = () => {} mutations.addNewStatuses(state, { statuses: [status, otherStatus], user }) mutations.addNewNotifications( state, @@ -342,7 +342,8 @@ describe('Statuses module', () => { status: otherStatus, action: otherStatus, seen: false - }] + }], + newNotificationSideEffects }) expect(state.notifications.data.length).to.eql(1) @@ -356,7 +357,8 @@ describe('Statuses module', () => { status: mentionedStatus, action: mentionedStatus, seen: false - }] + }], + newNotificationSideEffects }) mutations.addNewStatuses(state, { statuses: [mentionedStatus], user }) diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js index 4e8e566b..3ee9839d 100644 --- a/test/unit/specs/services/chat_service/chat_service.spec.js +++ b/test/unit/specs/services/chat_service/chat_service.spec.js @@ -17,8 +17,7 @@ const message3 = { created_at: (new Date('2020-07-22T18:45:59.000Z')) } -// TODO: only -describe.only('chatService', () => { +describe('chatService', () => { describe('.add', () => { it("Doesn't add duplicates", () => { const chat = chatService.empty() -- cgit v1.2.3-70-g09d2 From 0347d79bda97a90eab3afaf9ab6aafb784b2e10d Mon Sep 17 00:00:00 2001 From: Shpuld Shpuldson Date: Fri, 28 Aug 2020 12:02:52 +0300 Subject: Rewrite word split imperatively for control --- src/services/completion/completion.js | 53 ++++++++++------------ .../specs/services/completion/completion.spec.js | 43 ++++++++++++------ 2 files changed, 54 insertions(+), 42 deletions(-) (limited to 'test/unit/specs/services') diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js index f74f8048..8a6eba7e 100644 --- a/src/services/completion/completion.js +++ b/src/services/completion/completion.js @@ -4,15 +4,13 @@ export const replaceWord = (str, toReplace, replacement) => { return str.slice(0, toReplace.start) + replacement + str.slice(toReplace.end) } -// This seems to work fine export const wordAtPosition = (str, pos) => { - const words = splitIntoWords(str) + const words = splitByWhitespaceBoundary(str) const wordsWithPosition = addPositionToWords(words) return find(wordsWithPosition, ({ start, end }) => start <= pos && end > pos) } -// This works fine export const addPositionToWords = (words) => { return reduce(words, (result, word) => { const data = { @@ -36,37 +34,36 @@ export const addPositionToWords = (words) => { }, []) } -// This needs to be altered, split words at space -export const splitIntoWords = (str) => { - // Split at word boundaries - const regex = /\b/ - const triggers = /[@#:]+$/ - - let split = str.split(regex) - - // Add trailing @ and # to the following word. - const words = reduce(split, (result, word) => { - if (result.length > 0) { - let previous = result.pop() - const matches = previous.match(triggers) - if (matches) { - previous = previous.replace(triggers, '') - word = matches[0] + word - } - result.push(previous) +export const splitByWhitespaceBoundary = (str) => { + let result = [] + let currentWord = '' + for (let i = 0; i < str.length; i++) { + const currentChar = str[i] + // Starting a new word + if (!currentWord) { + currentWord = currentChar + continue } - result.push(word) - - return result - }, []) - - return words + // current character is whitespace while word isn't, or vice versa: + // add our current word to results, start over the current word. + if (!!currentChar.trim() !== !!currentWord.trim()) { + result.push(currentWord) + currentWord = currentChar + continue + } + currentWord += currentChar + } + // Add the last word we were working on + if (currentWord) { + result.push(currentWord) + } + return result } const completion = { wordAtPosition, addPositionToWords, - splitIntoWords, + splitByWhitespaceBoundary, replaceWord } diff --git a/test/unit/specs/services/completion/completion.spec.js b/test/unit/specs/services/completion/completion.spec.js index 8a41c653..81d3a26a 100644 --- a/test/unit/specs/services/completion/completion.spec.js +++ b/test/unit/specs/services/completion/completion.spec.js @@ -1,8 +1,8 @@ -import { replaceWord, addPositionToWords, wordAtPosition, splitIntoWords } from '../../../../../src/services/completion/completion.js' +import { replaceWord, addPositionToWords, wordAtPosition, splitByWhitespaceBoundary } from '../../../../../src/services/completion/completion.js' describe('addPositiontoWords', () => { it('adds the position to a word list', () => { - const words = ['hey', 'this', 'is', 'fun'] + const words = ['hey', ' ', 'this', ' ', 'is', ' ', 'fun'] const expected = [ { @@ -11,19 +11,34 @@ describe('addPositiontoWords', () => { end: 3 }, { - word: 'this', + word: ' ', start: 3, - end: 7 + end: 4 }, { - word: 'is', - start: 7, + word: 'this', + start: 4, + end: 8 + }, + { + word: ' ', + start: 8, end: 9 }, { - word: 'fun', + word: 'is', start: 9, + end: 11 + }, + { + word: ' ', + start: 11, end: 12 + }, + { + word: 'fun', + start: 12, + end: 15 } ] @@ -33,11 +48,11 @@ describe('addPositiontoWords', () => { }) }) -describe('splitIntoWords', () => { +describe('splitByWhitespaceBoundary', () => { it('splits at whitespace boundaries', () => { - const str = 'This is a #nice @test for you, @idiot.' - const expected = ['This', ' ', 'is', ' ', 'a', ' ', '#nice', ' ', '@test', ' ', 'for', ' ', 'you', ', ', '@idiot', '.'] - const res = splitIntoWords(str) + const str = 'This is a #nice @test for you, @idiot@idiot.com' + const expected = ['This', ' ', 'is', ' ', 'a', ' ', '#nice', ' ', '@test', ' ', 'for', ' ', 'you,', ' ', '@idiot@idiot.com'] + const res = splitByWhitespaceBoundary(str) expect(res).to.eql(expected) }) @@ -57,13 +72,13 @@ describe('wordAtPosition', () => { describe('replaceWord', () => { it('replaces a word (with start and end) with another word in a given string', () => { - const str = 'hey @take, how are you' - const wordsWithPosition = addPositionToWords(splitIntoWords(str)) + const str = 'hey @take , how are you' + const wordsWithPosition = addPositionToWords(splitByWhitespaceBoundary(str)) const toReplace = wordsWithPosition[2] expect(toReplace.word).to.eql('@take') - const expected = 'hey @takeshitakenji, how are you' + const expected = 'hey @takeshitakenji , how are you' const res = replaceWord(str, toReplace, '@takeshitakenji') expect(res).to.eql(expected) }) -- cgit v1.2.3-70-g09d2