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.
This commit is contained in:
eugenijm 2020-06-21 17:13:29 +03:00
parent aa2cf51c05
commit f05f832bff
25 changed files with 317 additions and 338 deletions

View file

@ -45,8 +45,7 @@ export default {
window.CSS.supports('-moz-mask-size', 'contain') || window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') || window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain') window.CSS.supports('-o-mask-size', 'contain')
), )
transitionName: 'fade'
}), }),
created () { created () {
// Load the locale from the storage // Load the locale from the storage
@ -135,14 +134,5 @@ export default {
} }
this.$store.dispatch('setLayoutHeight', layoutHeight) 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'
}
}
} }
} }

View file

@ -47,6 +47,7 @@ html {
} }
body { body {
overscroll-behavior-y: none;
font-family: sans-serif; font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif); font-family: var(--interfaceFont, sans-serif);
margin: 0; margin: 0;
@ -56,7 +57,6 @@ body {
overflow-x: hidden; overflow-x: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
&.hidden { &.hidden {
display: none; display: none;
@ -320,7 +320,7 @@ option {
i[class*=icon-] { i[class*=icon-] {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon) color: var(--icon, $fallback--icon);
} }
.btn-block { .btn-block {
@ -942,3 +942,38 @@ nav {
max-height: 1.3rem; max-height: 1.3rem;
line-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;
}
}
}

View file

@ -113,9 +113,7 @@
{{ $t("login.hint") }} {{ $t("login.hint") }}
</router-link> </router-link>
</div> </div>
<transition :name="transitionName">
<router-view /> <router-view />
</transition>
</div> </div>
<media-modal /> <media-modal />
</div> </div>

View file

@ -2,29 +2,26 @@ import _ from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js' import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import ChatMessage from '../chat_message/chat_message.vue' 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 PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue' import ChatTitle from '../chat_title/chat_title.vue'
import chatService from '../../services/chat_service/chat_service.js' 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' import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
const BOTTOMED_OUT_OFFSET = 10 const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const SAFE_RESIZE_TIME_OFFSET = 100
const Chat = { const Chat = {
components: { components: {
ChatMessage, ChatMessage,
ChatTitle, ChatTitle,
ChatAvatar,
PostStatusForm PostStatusForm
}, },
mixins: [ChatLayout],
data () { data () {
return { return {
jumpToBottomButtonVisible: false, jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined, hoveredMessageChainId: undefined,
scrollPositionBeforeResize: {}, lastScrollPosition: {},
scrollableContainerHeight: '100%', scrollableContainerHeight: '100%',
errorLoadingChat: false errorLoadingChat: false
} }
@ -119,6 +116,7 @@ const Chat = {
}, },
onFilesDropped () { onFilesDropped () {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize()
this.updateScrollableContainerHeight() this.updateScrollableContainerHeight()
}) })
}, },
@ -129,13 +127,30 @@ const Chat = {
} }
}) })
}, },
handleLayoutChange () { setChatLayout () {
this.updateScrollableContainerHeight() // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
if (this.mobileLayout) { // This layout prevents empty spaces from being visible at the bottom
this.setMobileChatLayout() // of the chat on iOS Safari (`safe-area-inset`) when
} else { // - the on-screen keyboard appears and the user starts typing
this.unsetMobileChatLayout() // - 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.$nextTick(() => {
this.updateScrollableContainerHeight() this.updateScrollableContainerHeight()
this.scrollDown() this.scrollDown()
@ -149,15 +164,24 @@ const Chat = {
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px' this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
}, },
// Preserves the scroll position when OSK appears or the posting form changes its height. // 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.$nextTick(() => {
this.updateScrollableContainerHeight() this.updateScrollableContainerHeight()
const { offsetHeight = undefined } = this.scrollPositionBeforeResize const { offsetHeight = undefined } = this.lastScrollPosition
this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable) this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight const diff = this.lastScrollPosition.offsetHeight - offsetHeight
if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) { if (diff < 0 || (!this.bottomedOut() && expand)) {
this.$nextTick(() => { this.$nextTick(() => {
this.updateScrollableContainerHeight() this.updateScrollableContainerHeight()
this.$refs.scrollable.scrollTo({ this.$refs.scrollable.scrollTo({
@ -281,7 +305,12 @@ const Chat = {
.then(data => { .then(data => {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => { this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => { 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.
setTimeout(() => {
this.updateScrollableContainerHeight() this.updateScrollableContainerHeight()
}, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
}) })

View file

@ -3,14 +3,17 @@
height: calc(100vh - 60px); height: calc(100vh - 60px);
width: 100%; width: 100%;
.chat-title {
// prevents chat header jumping on when the user avatar loads
height: 28px;
}
.chat-view-inner { .chat-view-inner {
height: auto; height: auto;
width: 100%; width: 100%;
overflow: visible; overflow: visible;
display: flex; display: flex;
margin-top: 0.5em; margin: 0.5em 0.5em 0 0.5em;
margin-left: 0.5em;
margin-right: 0.5em;
} }
.chat-view-body { .chat-view-body {
@ -19,23 +22,18 @@
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
overflow: visible; overflow: visible;
border-radius: none;
min-height: 100%; min-height: 100%;
margin-left: 0; margin: 0 0 0 0;
margin-right: 0;
margin-bottom: 0em;
margin-top: 0em;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ; border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&::after { &::after {
border-radius: none; border-radius: 0;
box-shadow: none;
} }
} }
.scrollable-message-list { .scrollable-message-list {
padding: 0 10px; padding: 0 0.8em;
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
@ -45,7 +43,7 @@
.footer { .footer {
position: sticky; position: sticky;
bottom: 0px; bottom: 0;
} }
.chat-view-heading { .chat-view-heading {
@ -54,15 +52,19 @@
top: 50px; top: 50px;
display: flex; display: flex;
z-index: 2; z-index: 2;
border-radius: none;
position: sticky; position: sticky;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.go-back-button { .go-back-button {
margin-right: 1.2em;
cursor: pointer; cursor: pointer;
margin-right: 1.4em;
i {
display: flex;
align-items: center;
}
} }
.jump-to-bottom-button { .jump-to-bottom-button {
@ -135,7 +137,7 @@
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
margin: 0; margin: 0;
border-radius: 0 !important; border-radius: 0;
} }
.chat-view-heading { .chat-view-heading {

View file

@ -75,7 +75,7 @@
:disable-polls="true" :disable-polls="true"
:disable-sensitivity-checkbox="true" :disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat" :disable-submit="errorLoadingChat || !currentChat"
:request="sendMessage" :post-handler="sendMessage"
:submit-on-enter="!mobileLayout" :submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout" :preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout" :auto-focus="!mobileLayout"

View file

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

View file

@ -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. // 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) => { export const scrollableContainerHeight = (inner, header, footer) => {
const height = parseFloat(getComputedStyle(inner, null).height.replace('px', '')) return inner.offsetHeight - header.clientHeight - footer.clientHeight
return height - header.clientHeight - footer.clientHeight
} }

View file

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

View file

@ -1,53 +0,0 @@
<template>
<router-link
:to="getUserProfileLink(user) || ''"
>
<StillImage
v-if="user"
:style="{ 'width': width, 'height': height }"
class="avatar chat-avatar single-user"
:alt="user.screen_name"
:title="user.screen_name"
:src="user.profile_image_url_original"
error-src="/images/avi.png"
:class="{ 'better-shadow': betterShadow }"
/>
<div
v-else
class="avatar chat-avatar single-user"
:style="{ 'width': width, 'height': height }"
/>
</router-link>
</template>
<script src="./chat_avatar.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-avatar {
display: inline-block;
vertical-align: middle;
&.single-user {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.avatar.still-image {
width: 48px;
height: 48px;
box-shadow: var(--avatarStatusShadow);
border-radius: 0;
&.better-shadow {
box-shadow: var(--avatarStatusShadowInset);
filter: var(--avatarStatusShadowFilter)
}
&.animated::before {
display: none;
}
}
}
</style>

View file

@ -1,7 +1,7 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service' 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 AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import ChatTitle from '../chat_title/chat_title.vue' import ChatTitle from '../chat_title/chat_title.vue'
@ -12,7 +12,7 @@ const ChatListItem = {
'chat' 'chat'
], ],
components: { components: {
ChatAvatar, UserAvatar,
AvatarList, AvatarList,
Timeago, Timeago,
ChatTitle, ChatTitle,

View file

@ -1,17 +1,8 @@
.chat-list-item { .chat-list-item {
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding: 0.75em; padding: 0.75em;
height: 4.85em; height: 5em;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
@ -22,7 +13,7 @@
&:hover { &:hover {
background-color: var(--selectedPost, $fallback--lightBg); 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 { .chat-list-item-left {
@ -47,12 +38,6 @@
white-space: nowrap; white-space: nowrap;
} }
.member-count {
color: $fallback--text;
color: var(--faintText, $fallback--text);
margin-right: 2px;
}
.name-and-account-name { .name-and-account-name {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -65,7 +50,7 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
margin: 0.35rem 0; margin: 0.35em 0;
height: 1.2em; height: 1.2em;
line-height: 1.2em; line-height: 1.2em;
color: $fallback--text; color: $fallback--text;
@ -78,17 +63,24 @@
pointer-events: none; pointer-events: none;
} }
.unread-indicator-wrapper { &:hover .animated.avatar {
display: flex; canvas {
align-items: center; display: none;
margin-left: 10px; }
img {
visibility: visible;
}
} }
.unread-indicator { .avatar.still-image {
border-radius: 100%; border-radius: $fallback--avatarAltRadius;
height: 8px; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
width: 8px; }
background-color: $fallback--link;
background-color: var(--link, $fallback--link); .status-body {
img.emoji {
width: 1.4em;
height: 1.4em;
}
} }
} }

View file

@ -4,7 +4,7 @@
@click.capture.prevent="openChat" @click.capture.prevent="openChat"
> >
<div class="chat-list-item-left"> <div class="chat-list-item-left">
<ChatAvatar <UserAvatar
:user="chat.account" :user="chat.account"
height="48px" height="48px"
width="48px" width="48px"

View file

@ -66,9 +66,9 @@ const ChatMessage = {
} }
if (this.isCurrentUser) { if (this.isCurrentUser) {
res.right = '5px' res.right = '0.4rem'
} else { } else {
res.left = '5px' res.left = '0.4rem'
} }
return res return res

View file

@ -12,19 +12,15 @@
} }
} }
&:last-child {
margin-bottom: 16px;
}
.chat-message-menu { .chat-message-menu {
transition: opacity 0.1s; transition: opacity 0.1s;
opacity: 0; opacity: 0;
position: absolute; position: absolute;
top: -10px; top: -0.8em;
button { button {
padding-top: 3px; padding-top: 0.2em;
padding-bottom: 3px; padding-bottom: 0.2em;
} }
} }
@ -41,21 +37,21 @@
} }
.popover { .popover {
width: 12rem; width: 12em;
} }
.chat-message { .chat-message {
display: flex; display: flex;
padding-bottom: 7px; padding-bottom: 0.5em;
} }
.avatar-wrapper { .avatar-wrapper {
margin-right: 10px; margin-right: 0.72em;
width: 32px; width: 32px;
} }
.link-preview, .attachments { .link-preview, .attachments {
margin-bottom: 0.9em; margin-bottom: 1em;
} }
.chat-message-inner { .chat-message-inner {
@ -63,7 +59,7 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
max-width: 80%; max-width: 80%;
min-width: 10rem; min-width: 10em;
width: 100%; width: 100%;
&.with-media { &.with-media {
@ -87,19 +83,18 @@
} }
.created-at { .created-at {
position: relative;
float: right; float: right;
font-size: 0.8em; font-size: 0.8em;
margin: -10px 0 -5px 4px; margin: -1em 0 -0.5em 0;
font-style: italic; font-style: italic;
opacity: 0.8; opacity: 0.8;
} }
.without-attachment { .without-attachment {
.status-content { .status-content {
white-space: normal;
&::after { &::after {
margin-right: 75px; margin-right: 5.4em;
content: " "; content: " ";
display: inline-block; display: inline-block;
} }

View file

@ -1,4 +1,3 @@
import { throttle } from 'lodash'
import { mapState, mapGetters } from 'vuex' import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
@ -54,7 +53,7 @@ const chatNew = {
removeUser (userId) { removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
}, },
search: throttle(function (query) { search (query) {
if (!query) { if (!query) {
this.loading = false this.loading = false
return return
@ -67,7 +66,7 @@ const chatNew = {
this.loading = false this.loading = false
this.userIds = data.accounts.map(a => a.id) this.userIds = data.accounts.map(a => a.id)
}) })
}) }
} }
} }

View file

@ -15,7 +15,7 @@
} }
.member-list { .member-list {
padding-bottom: 0.67rem; padding-bottom: 0.7rem;
} }
.basic-user-card:hover { .basic-user-card:hover {

View file

@ -1,10 +1,11 @@
import Vue from 'vue' 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', { export default Vue.component('chat-title', {
name: 'ChatTitle', name: 'ChatTitle',
components: { components: {
ChatAvatar UserAvatar
}, },
props: [ props: [
'user', 'withAvatar' 'user', 'withAvatar'
@ -16,5 +17,10 @@ export default Vue.component('chat-title', {
htmlTitle () { htmlTitle () {
return this.user ? this.user.name_html : '' return this.user ? this.user.name_html : ''
} }
},
methods: {
getUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name)
}
} }
}) })

View file

@ -4,16 +4,16 @@
class="chat-title" class="chat-title"
:title="title" :title="title"
> >
<ChatAvatar <router-link
v-if="withAvatar" v-if="withAvatar && user"
:to="getUserProfileLink(user)"
>
<UserAvatar
:user="user" :user="user"
width="23px" width="23px"
height="23px" height="23px"
/> />
<span </router-link>
v-if="withAvatar"
style="margin-right: 0.5em"
/>
<span <span
class="username" class="username"
v-html="htmlTitle" v-html="htmlTitle"
@ -32,11 +32,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
a {
display: flex;
align-items: center; align-items: center;
}
.username { .username {
max-width: 100%; max-width: 100%;
@ -52,5 +48,18 @@
object-fit: contain object-fit: contain
} }
} }
.still-image.avatar {
width: 23px;
height: 23px;
margin-right: 0.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
&.animated::before {
display: none;
}
}
} }
</style> </style>

View file

@ -88,6 +88,11 @@ const EmojiInput = {
required: false, required: false,
type: String, // 'auto', 'top', 'bottom' type: String, // 'auto', 'top', 'bottom'
default: 'auto' default: 'auto'
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false
} }
}, },
data () { data () {
@ -204,7 +209,7 @@ const EmojiInput = {
this.$emit('input', newValue) this.$emit('input', newValue)
this.caret = 0 this.caret = 0
}, },
insert ({ insertion, keepOpen }) { insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.value.substring(0, this.caret) || '' const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(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 * them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/ */
const isSpaceRegex = /\s/ const isSpaceRegex = /\s/
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [ const newValue = [
before, before,
@ -381,6 +386,18 @@ const EmojiInput = {
}, },
onKeyDown (e) { onKeyDown (e) {
const { ctrlKey, shiftKey, key } = 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 // Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) { if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') { if (key === 'Tab') {

View file

@ -33,19 +33,6 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.media-upload { .media-upload {
&.disabled {
.new-icon {
cursor: not-allowed;
}
&:hover {
i, label {
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
}
}
.label { .label {
display: inline-block; display: inline-block;
} }

View file

@ -43,7 +43,7 @@ const PostStatusForm = {
'disableSubmit', 'disableSubmit',
'placeholder', 'placeholder',
'maxHeight', 'maxHeight',
'request', 'postHandler',
'preserveFocus', 'preserveFocus',
'autoFocus', 'autoFocus',
'fileLimit', 'fileLimit',
@ -221,10 +221,6 @@ const PostStatusForm = {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
} }
if (opts.control && this.submitOnEnter) {
newStatus.status = `${newStatus.status}\n`
return
}
if (this.emptyStatus) { if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error') this.error = this.$t('post_status.empty_status_error')
@ -259,9 +255,9 @@ const PostStatusForm = {
poll 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) { if (!data.error) {
this.newStatus = { this.newStatus = {
status: '', status: '',
@ -345,11 +341,7 @@ const PostStatusForm = {
}, },
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
// TODO: use fixed dimensions instead so relying on timeout
setTimeout(() => {
this.$emit('resize')
}, 150)
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
@ -364,6 +356,7 @@ const PostStatusForm = {
this.uploadingFiles = true this.uploadingFiles = true
}, },
finishedUploadingFiles () { finishedUploadingFiles () {
this.$emit('resize')
this.uploadingFiles = false this.uploadingFiles = false
}, },
type (fileInfo) { type (fileInfo) {
@ -417,7 +410,7 @@ const PostStatusForm = {
// Reset to default height for empty form, nothing else to do here. // Reset to default height for empty form, nothing else to do here.
if (target.value === '') { if (target.value === '') {
target.style.height = null target.style.height = null
this.$emit('resize', null) this.$emit('resize')
this.$refs['emoji-input'].resize() this.$refs['emoji-input'].resize()
return return
} }

View file

@ -131,6 +131,7 @@
class="form-control main-input" class="form-control main-input"
enable-emoji-picker enable-emoji-picker
hide-emoji-button hide-emoji-button
:newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker enable-sticker-picker
@input="onEmojiInputInput" @input="onEmojiInputInput"
@sticker-uploaded="addMediaFile" @sticker-uploaded="addMediaFile"
@ -146,8 +147,8 @@
class="form-post-body" class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight }" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus, { control: true })" @keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="postStatus($event, newStatus)" @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@ -435,6 +436,19 @@
color: var(--lightText, $fallback--lightText); 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 // 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) // 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; line-height: 0;
max-height: 200px; max-height: 200px;
max-width: 100%; max-width: 100%;

View file

@ -31,7 +31,8 @@ const deleteMessage = (storage, messageId) => {
} }
if (storage.minId === 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 result = []
const messages = _.sortBy(storage.messages, ['id', 'desc']) const messages = _.sortBy(storage.messages, ['id', 'desc'])
const firstMessages = messages[0] const firstMessage = messages[0]
let prev = messages[messages.length - 1] let previousMessage = messages[messages.length - 1]
let currentMessageChainId let currentMessageChainId
if (firstMessages) { if (firstMessage) {
const date = new Date(firstMessages.created_at) const date = new Date(firstMessage.created_at)
date.setHours(0, 0, 0, 0) date.setHours(0, 0, 0, 0)
result.push({ result.push({
type: 'date', type: 'date',
@ -97,14 +98,14 @@ const getView = (storage) => {
date.setHours(0, 0, 0, 0) date.setHours(0, 0, 0, 0)
// insert date separator and start a new message chain // insert date separator and start a new message chain
if (prev && prev.date < date) { if (previousMessage && previousMessage.date < date) {
result.push({ result.push({
type: 'date', type: 'date',
date, date,
id: date.getTime().toString() id: date.getTime().toString()
}) })
prev['isTail'] = true previousMessage['isTail'] = true
currentMessageChainId = undefined currentMessageChainId = undefined
afterDate = true afterDate = true
} }
@ -124,14 +125,14 @@ const getView = (storage) => {
} }
// start a new message chain // 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() currentMessageChainId = _.uniqueId()
object['isHead'] = true object['isHead'] = true
object['messageChainId'] = currentMessageChainId object['messageChainId'] = currentMessageChainId
} }
result.push(object) result.push(object)
prev = object previousMessage = object
afterDate = false afterDate = false
} }

View file

@ -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'])
})
})
})