Merge remote-tracking branch 'origin/develop' into shigusegubu
* origin/develop: Fix the chat scroll behavior for vertical screens. fix mobile badge alignment change approach to disable all, enable some fix mobile navbar hitboxes Instead of blocking all interaction, only block interaction in places that matter added comment fix chat heading not being aligned and using wrong styles fix chat badge and unify styles across all badges including follow request count. change logo to svg Update CHANGELOG.md update changelog for optimistic chat posting fix test move from using timestamps to ids when tracking last seen in chats Apply 1 suggestion(s) to 1 file(s) Optimistic message sending for chat
This commit is contained in:
commit
893741c363
26 changed files with 309 additions and 102 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -6,21 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- New option to optimize timeline rendering to make the site more responsive (enabled by default)
|
- New option to optimize timeline rendering to make the site more responsive (enabled by default)
|
||||||
|
- New instance option `logoLeft` to move logo to the left side in desktop nav bar
|
||||||
|
- Import/export a muted users
|
||||||
|
- Proper handling of deletes when using websocket streaming
|
||||||
|
- Added optimistic chat message sending, so you can start writing next message before the previous one has been sent
|
||||||
|
|
||||||
## [Unreleased patch]
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed chats list not updating its order when new messages come in
|
- Fixed chats list not updating its order when new messages come in
|
||||||
- Fixed chat messages sometimes getting lost when you receive a message at the same time
|
- Fixed chat messages sometimes getting lost when you receive a message at the same time
|
||||||
- Fixed clicking NSFW hider through status popover
|
- Fixed clicking NSFW hider through status popover
|
||||||
- Fixed chat-view back button being hard to click
|
- Fixed chat-view back button being hard to click
|
||||||
- Fixed fresh chat notifications being cleared immediately while leaving the chat view and not having time to actually see the messages
|
- Fixed fresh chat notifications being cleared immediately while leaving the chat view and not having time to actually see the messages
|
||||||
|
- Fixed multiple regressions in CSS styles
|
||||||
|
- Fixed multiple issues with input fields when using CJK font as default
|
||||||
|
- Fixed search field in navbar infringing into logo in some cases
|
||||||
|
- Fixed not being able to load the chat history in vertical screens when the message list doesn't take the full height of the scrollable container on the first fetch.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Clicking immediately when timeline shifts is now blocked to prevent misclicks
|
- Clicking immediately when timeline shifts is now blocked to prevent misclicks
|
||||||
|
- Icons changed from fontello (FontAwesome 4 + others) to FontAwesome 5 due to problems with fontello.
|
||||||
### Added
|
- Some icons changed for better accessibility (lock, globe)
|
||||||
- Import/export a muted users
|
- Logo is now clickable
|
||||||
- Proper handling of deletes when using websocket streaming
|
- Changed default logo to SVG version
|
||||||
|
|
||||||
## [2.1.1] - 2020-09-08
|
## [2.1.1] - 2020-09-08
|
||||||
### Changed
|
### Changed
|
||||||
|
|
21
src/App.scss
21
src/App.scss
|
@ -603,19 +603,24 @@ nav {
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
|
box-sizing: border-box;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
min-width: 22px;
|
max-width: 10em;
|
||||||
max-width: 22px;
|
min-width: 1.7em;
|
||||||
min-height: 22px;
|
height: 1.3em;
|
||||||
max-height: 22px;
|
padding: 0.15em 0.15em;
|
||||||
font-size: 15px;
|
|
||||||
line-height: 22px;
|
|
||||||
text-align: center;
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
&.badge-notification {
|
&.badge-notification {
|
||||||
background-color: $fallback--cRed;
|
background-color: $fallback--cRed;
|
||||||
|
|
|
@ -6,12 +6,13 @@ 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 { promiseInterval } from '../../services/promise_interval/promise_interval.js'
|
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
|
||||||
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
|
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight, isScrollable } from './chat_layout_utils.js'
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
import {
|
import {
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
faChevronLeft
|
faChevronLeft
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
|
||||||
|
|
||||||
library.add(
|
library.add(
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
|
@ -22,6 +23,7 @@ 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 SAFE_RESIZE_TIME_OFFSET = 100
|
||||||
const MARK_AS_READ_DELAY = 1500
|
const MARK_AS_READ_DELAY = 1500
|
||||||
|
const MAX_RETRIES = 10
|
||||||
|
|
||||||
const Chat = {
|
const Chat = {
|
||||||
components: {
|
components: {
|
||||||
|
@ -35,7 +37,8 @@ const Chat = {
|
||||||
hoveredMessageChainId: undefined,
|
hoveredMessageChainId: undefined,
|
||||||
lastScrollPosition: {},
|
lastScrollPosition: {},
|
||||||
scrollableContainerHeight: '100%',
|
scrollableContainerHeight: '100%',
|
||||||
errorLoadingChat: false
|
errorLoadingChat: false,
|
||||||
|
messageRetriers: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
|
@ -219,7 +222,10 @@ const Chat = {
|
||||||
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
|
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
|
||||||
if (document.hidden) { return }
|
if (document.hidden) { return }
|
||||||
const lastReadId = this.currentChatMessageService.maxId
|
const lastReadId = this.currentChatMessageService.maxId
|
||||||
this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
|
this.$store.dispatch('readChat', {
|
||||||
|
id: this.currentChat.id,
|
||||||
|
lastReadId
|
||||||
|
})
|
||||||
},
|
},
|
||||||
bottomedOut (offset) {
|
bottomedOut (offset) {
|
||||||
return isBottomedOut(this.$refs.scrollable, offset)
|
return isBottomedOut(this.$refs.scrollable, offset)
|
||||||
|
@ -281,6 +287,14 @@ const Chat = {
|
||||||
if (isFirstFetch) {
|
if (isFirstFetch) {
|
||||||
this.updateScrollableContainerHeight()
|
this.updateScrollableContainerHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In vertical screens, the first batch of fetched messages may not always take the
|
||||||
|
// full height of the scrollable container.
|
||||||
|
// If this is the case, we want to fetch the messages until the scrollable container
|
||||||
|
// is fully populated so that the user has the ability to scroll up and load the history.
|
||||||
|
if (!isScrollable(this.$refs.scrollable) && messages.length > 0) {
|
||||||
|
this.fetchChat({ maxId: this.currentChatMessageService.minId })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -309,42 +323,74 @@ const Chat = {
|
||||||
})
|
})
|
||||||
this.fetchChat({ isFirstFetch: true })
|
this.fetchChat({ isFirstFetch: true })
|
||||||
},
|
},
|
||||||
sendMessage ({ status, media }) {
|
handleAttachmentPosting () {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.handleResize()
|
||||||
|
// When the posting form size changes because of a media attachment, we need an extra resize
|
||||||
|
// to account for the potential delay in the DOM update.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateScrollableContainerHeight()
|
||||||
|
}, SAFE_RESIZE_TIME_OFFSET)
|
||||||
|
this.scrollDown({ forceRead: true })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendMessage ({ status, media, idempotencyKey }) {
|
||||||
const params = {
|
const params = {
|
||||||
id: this.currentChat.id,
|
id: this.currentChat.id,
|
||||||
content: status
|
content: status,
|
||||||
|
idempotencyKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media[0]) {
|
if (media[0]) {
|
||||||
params.mediaId = media[0].id
|
params.mediaId = media[0].id
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.backendInteractor.sendChatMessage(params)
|
const fakeMessage = buildFakeMessage({
|
||||||
|
attachments: media,
|
||||||
|
chatId: this.currentChat.id,
|
||||||
|
content: status,
|
||||||
|
userId: this.currentUser.id,
|
||||||
|
idempotencyKey
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$store.dispatch('addChatMessages', {
|
||||||
|
chatId: this.currentChat.id,
|
||||||
|
messages: [fakeMessage]
|
||||||
|
}).then(() => {
|
||||||
|
this.handleAttachmentPosting()
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
|
||||||
|
},
|
||||||
|
doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
|
||||||
|
if (retriesLeft <= 0) return
|
||||||
|
|
||||||
|
this.backendInteractor.sendChatMessage(params)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.$store.dispatch('addChatMessages', {
|
this.$store.dispatch('addChatMessages', {
|
||||||
chatId: this.currentChat.id,
|
chatId: this.currentChat.id,
|
||||||
messages: [data],
|
updateMaxId: false,
|
||||||
updateMaxId: false
|
messages: [{ ...data, fakeId: fakeMessage.id }]
|
||||||
}).then(() => {
|
|
||||||
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()
|
|
||||||
}, SAFE_RESIZE_TIME_OFFSET)
|
|
||||||
this.scrollDown({ forceRead: true })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error sending message', error)
|
console.error('Error sending message', error)
|
||||||
return {
|
this.$store.dispatch('handleMessageError', {
|
||||||
error: this.$t('chats.error_sending_message')
|
chatId: this.currentChat.id,
|
||||||
|
fakeId: fakeMessage.id,
|
||||||
|
isRetry: retriesLeft !== MAX_RETRIES
|
||||||
|
})
|
||||||
|
if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
|
||||||
|
this.messageRetriers[fakeMessage.id] = setTimeout(() => {
|
||||||
|
this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
|
||||||
|
}, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
|
||||||
}
|
}
|
||||||
|
return {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return Promise.resolve(fakeMessage)
|
||||||
},
|
},
|
||||||
goBack () {
|
goBack () {
|
||||||
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
|
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
|
||||||
|
|
|
@ -138,11 +138,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-view-heading {
|
.chat-view-heading {
|
||||||
|
box-sizing: border-box;
|
||||||
position: static;
|
position: static;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
top: 0;
|
top: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|
||||||
|
/* This practically overlays the panel heading color over panel background
|
||||||
|
* color. This is needed because we allow transparent panel background and
|
||||||
|
* it doesn't work well in this "disjointed panel header" case
|
||||||
|
*/
|
||||||
|
background:
|
||||||
|
linear-gradient(to top, var(--panel), var(--panel)),
|
||||||
|
linear-gradient(to top, var(--bg), var(--bg));
|
||||||
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable-message-list {
|
.scrollable-message-list {
|
||||||
|
|
|
@ -80,6 +80,7 @@
|
||||||
:disable-sensitivity-checkbox="true"
|
:disable-sensitivity-checkbox="true"
|
||||||
:disable-submit="errorLoadingChat || !currentChat"
|
:disable-submit="errorLoadingChat || !currentChat"
|
||||||
:disable-preview="true"
|
:disable-preview="true"
|
||||||
|
:optimistic-posting="true"
|
||||||
:post-handler="sendMessage"
|
:post-handler="sendMessage"
|
||||||
:submit-on-enter="!mobileLayout"
|
:submit-on-enter="!mobileLayout"
|
||||||
:preserve-focus="!mobileLayout"
|
:preserve-focus="!mobileLayout"
|
||||||
|
|
|
@ -24,3 +24,10 @@ export const isBottomedOut = (el, offset = 0) => {
|
||||||
export const scrollableContainerHeight = (inner, header, footer) => {
|
export const scrollableContainerHeight = (inner, header, footer) => {
|
||||||
return inner.offsetHeight - header.clientHeight - footer.clientHeight
|
return inner.offsetHeight - header.clientHeight - footer.clientHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns whether or not the scrollbar is visible.
|
||||||
|
export const isScrollable = (el) => {
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
return el.scrollHeight > el.clientHeight
|
||||||
|
}
|
||||||
|
|
|
@ -21,6 +21,12 @@
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="heading-right" />
|
<span class="heading-right" />
|
||||||
|
<div class="time-wrapper">
|
||||||
|
<Timeago
|
||||||
|
:time="chat.updated_at"
|
||||||
|
:auto-update="60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-preview">
|
<div class="chat-preview">
|
||||||
<StatusContent
|
<StatusContent
|
||||||
|
@ -35,12 +41,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="time-wrapper">
|
|
||||||
<Timeago
|
|
||||||
:time="chat.updated_at"
|
|
||||||
:auto-update="60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -101,6 +101,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
.status-content.media-body, .created-at {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
.status-content.media-body, .created-at {
|
||||||
|
color: $fallback--cRed;
|
||||||
|
color: var(--badgeNotification, $fallback--cRed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.incoming {
|
.incoming {
|
||||||
a {
|
a {
|
||||||
color: var(--chatMessageIncomingLink, $fallback--link);
|
color: var(--chatMessageIncomingLink, $fallback--link);
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="media status"
|
class="media status"
|
||||||
:class="{ 'without-attachment': !hasAttachment }"
|
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
|
||||||
style="position: relative"
|
style="position: relative"
|
||||||
@mouseenter="hovered = true"
|
@mouseenter="hovered = true"
|
||||||
@mouseleave="hovered = false"
|
@mouseleave="hovered = false"
|
||||||
|
|
|
@ -110,12 +110,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-button {
|
.mobile-nav-button {
|
||||||
|
display: inline-block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 1em;
|
padding: 0 1em;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
padding: 0 .3em;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
/* moslty just to get rid of extra whitespaces */
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-dot {
|
.alert-dot {
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
|
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
|
||||||
<div
|
<div
|
||||||
v-if="unreadChatCount"
|
v-if="unreadChatCount"
|
||||||
class="badge badge-notification unread-chat-count"
|
class="badge badge-notification"
|
||||||
>
|
>
|
||||||
{{ unreadChatCount }}
|
{{ unreadChatCount }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
/>{{ $t("nav.friend_requests") }}
|
/>{{ $t("nav.friend_requests") }}
|
||||||
<span
|
<span
|
||||||
v-if="followRequestCount > 0"
|
v-if="followRequestCount > 0"
|
||||||
class="badge follow-request-count"
|
class="badge badge-notification"
|
||||||
>
|
>
|
||||||
{{ followRequestCount }}
|
{{ followRequestCount }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -84,12 +84,6 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.follow-request-count {
|
|
||||||
vertical-align: baseline;
|
|
||||||
background-color: $fallback--bg;
|
|
||||||
background-color: var(--input, $fallback--faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
li {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-bottom: 1px solid;
|
border-bottom: 1px solid;
|
||||||
|
@ -156,21 +150,10 @@
|
||||||
margin-right: 0.8em;
|
margin-right: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-chat-count {
|
.badge {
|
||||||
font-size: 0.9em;
|
|
||||||
font-weight: bolder;
|
|
||||||
font-style: normal;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.6rem;
|
right: 0.6rem;
|
||||||
top: 1.25em;
|
top: 1.25em;
|
||||||
padding: 0 0.3em;
|
|
||||||
min-width: 1.3rem;
|
|
||||||
min-height: 1.3rem;
|
|
||||||
max-height: 1.3rem;
|
|
||||||
line-height: 1.3rem;
|
|
||||||
max-width: 10em;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -75,7 +75,8 @@ const PostStatusForm = {
|
||||||
'autoFocus',
|
'autoFocus',
|
||||||
'fileLimit',
|
'fileLimit',
|
||||||
'submitOnEnter',
|
'submitOnEnter',
|
||||||
'emojiPickerPlacement'
|
'emojiPickerPlacement',
|
||||||
|
'optimisticPosting'
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
MediaUpload,
|
MediaUpload,
|
||||||
|
@ -272,7 +273,7 @@ const PostStatusForm = {
|
||||||
if (this.preview) this.previewStatus()
|
if (this.preview) this.previewStatus()
|
||||||
},
|
},
|
||||||
async postStatus (event, newStatus, opts = {}) {
|
async postStatus (event, newStatus, opts = {}) {
|
||||||
if (this.posting) { return }
|
if (this.posting && !this.optimisticPosting) { return }
|
||||||
if (this.disableSubmit) { return }
|
if (this.disableSubmit) { return }
|
||||||
if (this.emojiInputShown) { return }
|
if (this.emojiInputShown) { return }
|
||||||
if (this.submitOnEnter) {
|
if (this.submitOnEnter) {
|
||||||
|
@ -280,6 +281,8 @@ const PostStatusForm = {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { 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')
|
||||||
return
|
return
|
||||||
|
|
|
@ -129,7 +129,7 @@
|
||||||
v-model="newStatus.spoilerText"
|
v-model="newStatus.spoilerText"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('post_status.content_warning')"
|
:placeholder="$t('post_status.content_warning')"
|
||||||
:disabled="posting"
|
:disabled="posting && !optimisticPosting"
|
||||||
size="1"
|
size="1"
|
||||||
class="form-post-subject"
|
class="form-post-subject"
|
||||||
>
|
>
|
||||||
|
@ -155,7 +155,7 @@
|
||||||
:placeholder="placeholder || $t('post_status.default')"
|
:placeholder="placeholder || $t('post_status.default')"
|
||||||
rows="1"
|
rows="1"
|
||||||
cols="1"
|
cols="1"
|
||||||
:disabled="posting"
|
:disabled="posting && !optimisticPosting"
|
||||||
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)"
|
||||||
|
|
|
@ -70,7 +70,7 @@
|
||||||
/> {{ $t("nav.chats") }}
|
/> {{ $t("nav.chats") }}
|
||||||
<span
|
<span
|
||||||
v-if="unreadChatCount"
|
v-if="unreadChatCount"
|
||||||
class="badge badge-notification unread-chat-count"
|
class="badge badge-notification"
|
||||||
>
|
>
|
||||||
{{ unreadChatCount }}
|
{{ unreadChatCount }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -99,7 +99,7 @@
|
||||||
/> {{ $t("nav.friend_requests") }}
|
/> {{ $t("nav.friend_requests") }}
|
||||||
<span
|
<span
|
||||||
v-if="followRequestCount > 0"
|
v-if="followRequestCount > 0"
|
||||||
class="badge follow-request-count"
|
class="badge badge-notification"
|
||||||
>
|
>
|
||||||
{{ followRequestCount }}
|
{{ followRequestCount }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -272,12 +272,11 @@
|
||||||
--lightText: var(--popoverLightText, $fallback--lightText);
|
--lightText: var(--popoverLightText, $fallback--lightText);
|
||||||
--icon: var(--popoverIcon, $fallback--icon);
|
--icon: var(--popoverIcon, $fallback--icon);
|
||||||
|
|
||||||
.follow-request-count {
|
.badge {
|
||||||
vertical-align: baseline;
|
position: absolute;
|
||||||
background-color: $fallback--bg;
|
right: 0.7rem;
|
||||||
background-color: var(--input, $fallback--faint);
|
top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-drawer-logo-wrapper {
|
.side-drawer-logo-wrapper {
|
||||||
|
|
|
@ -59,6 +59,15 @@ $status-margin: 0.75em;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
._misclick-prevention & {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
pointer-events: initial;
|
||||||
|
cursor: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.left-side {
|
.left-side {
|
||||||
margin-right: $status-margin;
|
margin-right: $status-margin;
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,7 +72,7 @@ const Timeline = {
|
||||||
},
|
},
|
||||||
classes () {
|
classes () {
|
||||||
let rootClasses = !this.embedded ? ['panel', 'panel-default'] : []
|
let rootClasses = !this.embedded ? ['panel', 'panel-default'] : []
|
||||||
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked'])
|
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
|
||||||
return {
|
return {
|
||||||
root: rootClasses,
|
root: rootClasses,
|
||||||
header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
|
header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
|
||||||
|
|
|
@ -113,7 +113,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.-blocked {
|
&.-blocked {
|
||||||
pointer-events: none;
|
cursor: progress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,12 +75,18 @@ const api = {
|
||||||
} else if (message.event === 'delete') {
|
} else if (message.event === 'delete') {
|
||||||
dispatch('deleteStatusById', message.id)
|
dispatch('deleteStatusById', message.id)
|
||||||
} else if (message.event === 'pleroma:chat_update') {
|
} else if (message.event === 'pleroma:chat_update') {
|
||||||
dispatch('addChatMessages', {
|
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
|
||||||
chatId: message.chatUpdate.id,
|
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
|
||||||
messages: [message.chatUpdate.lastMessage]
|
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
|
||||||
})
|
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
|
||||||
dispatch('updateChat', { chat: message.chatUpdate })
|
setTimeout(() => {
|
||||||
maybeShowChatNotification(store, message.chatUpdate)
|
dispatch('addChatMessages', {
|
||||||
|
chatId: message.chatUpdate.id,
|
||||||
|
messages: [message.chatUpdate.lastMessage]
|
||||||
|
})
|
||||||
|
dispatch('updateChat', { chat: message.chatUpdate })
|
||||||
|
maybeShowChatNotification(store, message.chatUpdate)
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,7 +16,8 @@ const defaultState = {
|
||||||
openedChats: {},
|
openedChats: {},
|
||||||
openedChatMessageServices: {},
|
openedChatMessageServices: {},
|
||||||
fetcher: undefined,
|
fetcher: undefined,
|
||||||
currentChatId: null
|
currentChatId: null,
|
||||||
|
lastReadMessageId: null
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChatById = (state, id) => {
|
const getChatById = (state, id) => {
|
||||||
|
@ -92,9 +93,14 @@ const chats = {
|
||||||
commit('setCurrentChatFetcher', { fetcher: undefined })
|
commit('setCurrentChatFetcher', { fetcher: undefined })
|
||||||
},
|
},
|
||||||
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
|
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
|
||||||
|
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
|
||||||
|
|
||||||
dispatch('resetChatNewMessageCount')
|
dispatch('resetChatNewMessageCount')
|
||||||
commit('readChat', { id })
|
commit('readChat', { id, lastReadId })
|
||||||
rootState.api.backendInteractor.readChat({ id, lastReadId })
|
|
||||||
|
if (isNewMessage) {
|
||||||
|
rootState.api.backendInteractor.readChat({ id, lastReadId })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
deleteChatMessage ({ rootState, commit }, value) {
|
deleteChatMessage ({ rootState, commit }, value) {
|
||||||
rootState.api.backendInteractor.deleteChatMessage(value)
|
rootState.api.backendInteractor.deleteChatMessage(value)
|
||||||
|
@ -106,6 +112,9 @@ const chats = {
|
||||||
},
|
},
|
||||||
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
|
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
|
||||||
commit('clearOpenedChats', { commit })
|
commit('clearOpenedChats', { commit })
|
||||||
|
},
|
||||||
|
handleMessageError ({ commit }, value) {
|
||||||
|
commit('handleMessageError', { commit, ...value })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
@ -208,11 +217,16 @@ const chats = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
readChat (state, { id }) {
|
readChat (state, { id, lastReadId }) {
|
||||||
|
state.lastReadMessageId = lastReadId
|
||||||
const chat = getChatById(state, id)
|
const chat = getChatById(state, id)
|
||||||
if (chat) {
|
if (chat) {
|
||||||
chat.unread = 0
|
chat.unread = 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
handleMessageError (state, { chatId, fakeId, isRetry }) {
|
||||||
|
const chatMessageService = state.openedChatMessageServices[chatId]
|
||||||
|
chatService.handleMessageError(chatMessageService, fakeId, isRetry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ const defaultState = {
|
||||||
hideSitename: false,
|
hideSitename: false,
|
||||||
hideUserStats: false,
|
hideUserStats: false,
|
||||||
loginMethod: 'password',
|
loginMethod: 'password',
|
||||||
logo: '/static/logo.png',
|
logo: '/static/logo.svg',
|
||||||
logoMargin: '.2em',
|
logoMargin: '.2em',
|
||||||
logoMask: true,
|
logoMask: true,
|
||||||
logoLeft: false,
|
logoLeft: false,
|
||||||
|
|
|
@ -129,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
|
||||||
return reject(new StatusCodeError(response.status, json, { url, options }, response))
|
return reject(new StatusCodeError(response.status, json, { url, options }, response))
|
||||||
}
|
}
|
||||||
return resolve(json)
|
return resolve(json)
|
||||||
}))
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return reject(new StatusCodeError(response.status, error, { url, options }, response))
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1210,7 +1214,7 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
|
const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
'content': content
|
'content': content
|
||||||
}
|
}
|
||||||
|
@ -1219,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
|
||||||
payload['media_id'] = mediaId
|
payload['media_id'] = mediaId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = {}
|
||||||
|
|
||||||
|
if (idempotencyKey) {
|
||||||
|
headers['idempotency-key'] = idempotencyKey
|
||||||
|
}
|
||||||
|
|
||||||
return promisedRequest({
|
return promisedRequest({
|
||||||
url: PLEROMA_CHAT_MESSAGES_URL(id),
|
url: PLEROMA_CHAT_MESSAGES_URL(id),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
payload: payload,
|
payload: payload,
|
||||||
credentials
|
credentials,
|
||||||
|
headers
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,10 @@ import _ from 'lodash'
|
||||||
const empty = (chatId) => {
|
const empty = (chatId) => {
|
||||||
return {
|
return {
|
||||||
idIndex: {},
|
idIndex: {},
|
||||||
|
idempotencyKeyIndex: {},
|
||||||
messages: [],
|
messages: [],
|
||||||
newMessageCount: 0,
|
newMessageCount: 0,
|
||||||
lastSeenTimestamp: 0,
|
lastSeenMessageId: '0',
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
minId: undefined,
|
minId: undefined,
|
||||||
maxId: undefined
|
maxId: undefined
|
||||||
|
@ -13,10 +14,20 @@ const empty = (chatId) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = (storage) => {
|
const clear = (storage) => {
|
||||||
storage.idIndex = {}
|
const failedMessageIds = []
|
||||||
storage.messages.splice(0, storage.messages.length)
|
|
||||||
|
for (const message of storage.messages) {
|
||||||
|
if (message.error) {
|
||||||
|
failedMessageIds.push(message.id)
|
||||||
|
} else {
|
||||||
|
delete storage.idIndex[message.id]
|
||||||
|
delete storage.idempotencyKeyIndex[message.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.messages = storage.messages.filter(m => failedMessageIds.includes(m.id))
|
||||||
storage.newMessageCount = 0
|
storage.newMessageCount = 0
|
||||||
storage.lastSeenTimestamp = 0
|
storage.lastSeenMessageId = '0'
|
||||||
storage.minId = undefined
|
storage.minId = undefined
|
||||||
storage.maxId = undefined
|
storage.maxId = undefined
|
||||||
}
|
}
|
||||||
|
@ -37,6 +48,25 @@ const deleteMessage = (storage, messageId) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMessageError = (storage, fakeId, isRetry) => {
|
||||||
|
if (!storage) { return }
|
||||||
|
const fakeMessage = storage.idIndex[fakeId]
|
||||||
|
if (fakeMessage) {
|
||||||
|
fakeMessage.error = true
|
||||||
|
fakeMessage.pending = false
|
||||||
|
if (!isRetry) {
|
||||||
|
// Ensure the failed message doesn't stay at the bottom of the list.
|
||||||
|
const lastPersistedMessage = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'desc'])[0]
|
||||||
|
if (lastPersistedMessage) {
|
||||||
|
const oldId = fakeMessage.id
|
||||||
|
fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
|
||||||
|
storage.idIndex[fakeMessage.id] = fakeMessage
|
||||||
|
delete storage.idIndex[oldId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
||||||
if (!storage) { return }
|
if (!storage) { return }
|
||||||
for (let i = 0; i < newMessages.length; i++) {
|
for (let i = 0; i < newMessages.length; i++) {
|
||||||
|
@ -45,7 +75,25 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
||||||
// sanity check
|
// sanity check
|
||||||
if (message.chat_id !== storage.chatId) { return }
|
if (message.chat_id !== storage.chatId) { return }
|
||||||
|
|
||||||
if (!storage.minId || message.id < storage.minId) {
|
if (message.fakeId) {
|
||||||
|
const fakeMessage = storage.idIndex[message.fakeId]
|
||||||
|
if (fakeMessage) {
|
||||||
|
// In case the same id exists (chat update before POST response)
|
||||||
|
// make sure to remove the older duplicate message.
|
||||||
|
if (storage.idIndex[message.id]) {
|
||||||
|
delete storage.idIndex[message.id]
|
||||||
|
storage.messages = storage.messages.filter(msg => msg.id !== message.id)
|
||||||
|
}
|
||||||
|
Object.assign(fakeMessage, message, { error: false })
|
||||||
|
delete fakeMessage['fakeId']
|
||||||
|
storage.idIndex[fakeMessage.id] = fakeMessage
|
||||||
|
delete storage.idIndex[message.fakeId]
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storage.minId || (!message.pending && message.id < storage.minId)) {
|
||||||
storage.minId = message.id
|
storage.minId = message.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,20 +103,26 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storage.idIndex[message.id]) {
|
if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
|
||||||
if (storage.lastSeenTimestamp < message.created_at) {
|
if (storage.lastSeenMessageId < message.id) {
|
||||||
storage.newMessageCount++
|
storage.newMessageCount++
|
||||||
}
|
}
|
||||||
storage.messages.push(message)
|
|
||||||
storage.idIndex[message.id] = message
|
storage.idIndex[message.id] = message
|
||||||
|
storage.messages.push(storage.idIndex[message.id])
|
||||||
|
storage.idempotencyKeyIndex[message.idempotency_key] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isConfirmation = (storage, message) => {
|
||||||
|
if (!message.idempotency_key) return
|
||||||
|
return storage.idempotencyKeyIndex[message.idempotency_key]
|
||||||
|
}
|
||||||
|
|
||||||
const resetNewMessageCount = (storage) => {
|
const resetNewMessageCount = (storage) => {
|
||||||
if (!storage) { return }
|
if (!storage) { return }
|
||||||
storage.newMessageCount = 0
|
storage.newMessageCount = 0
|
||||||
storage.lastSeenTimestamp = new Date()
|
storage.lastSeenMessageId = storage.maxId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
|
// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
|
||||||
|
@ -76,7 +130,7 @@ const getView = (storage) => {
|
||||||
if (!storage) { return [] }
|
if (!storage) { return [] }
|
||||||
|
|
||||||
const result = []
|
const result = []
|
||||||
const messages = _.sortBy(storage.messages, ['id', 'desc'])
|
const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
|
||||||
const firstMessage = messages[0]
|
const firstMessage = messages[0]
|
||||||
let previousMessage = messages[messages.length - 1]
|
let previousMessage = messages[messages.length - 1]
|
||||||
let currentMessageChainId
|
let currentMessageChainId
|
||||||
|
@ -148,7 +202,8 @@ const ChatService = {
|
||||||
getView,
|
getView,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
resetNewMessageCount,
|
resetNewMessageCount,
|
||||||
clear
|
clear,
|
||||||
|
handleMessageError
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ChatService
|
export default ChatService
|
||||||
|
|
|
@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => {
|
||||||
|
|
||||||
showDesktopNotification(store.rootState, opts)
|
showDesktopNotification(store.rootState, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const buildFakeMessage = ({ content, chatId, attachments, userId, idempotencyKey }) => {
|
||||||
|
const fakeMessage = {
|
||||||
|
content,
|
||||||
|
chat_id: chatId,
|
||||||
|
created_at: new Date(),
|
||||||
|
id: `${new Date().getTime()}`,
|
||||||
|
attachments: attachments,
|
||||||
|
account_id: userId,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
emojis: [],
|
||||||
|
pending: true,
|
||||||
|
isNormalized: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments[0]) {
|
||||||
|
fakeMessage.attachment = attachments[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fakeMessage
|
||||||
|
}
|
||||||
|
|
|
@ -429,6 +429,9 @@ export const parseChatMessage = (message) => {
|
||||||
} else {
|
} else {
|
||||||
output.attachments = []
|
output.attachments = []
|
||||||
}
|
}
|
||||||
|
output.pending = !!message.pending
|
||||||
|
output.error = false
|
||||||
|
output.idempotency_key = message.idempotency_key
|
||||||
output.isNormalized = true
|
output.isNormalized = true
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
BIN
static/logo.png
BIN
static/logo.png
Binary file not shown.
Before Width: | Height: | Size: 1.3 KiB |
|
@ -2,17 +2,20 @@ import chatService from '../../../../../src/services/chat_service/chat_service.j
|
||||||
|
|
||||||
const message1 = {
|
const message1 = {
|
||||||
id: '9wLkdcmQXD21Oy8lEX',
|
id: '9wLkdcmQXD21Oy8lEX',
|
||||||
|
idempotency_key: '1',
|
||||||
created_at: (new Date('2020-06-22T18:45:53.000Z'))
|
created_at: (new Date('2020-06-22T18:45:53.000Z'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const message2 = {
|
const message2 = {
|
||||||
id: '9wLkdp6ihaOVdNj8Wu',
|
id: '9wLkdp6ihaOVdNj8Wu',
|
||||||
|
idempotency_key: '2',
|
||||||
account_id: '9vmRb29zLQReckr5ay',
|
account_id: '9vmRb29zLQReckr5ay',
|
||||||
created_at: (new Date('2020-06-22T18:45:56.000Z'))
|
created_at: (new Date('2020-06-22T18:45:56.000Z'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const message3 = {
|
const message3 = {
|
||||||
id: '9wLke9zL4Dy4OZR2RM',
|
id: '9wLke9zL4Dy4OZR2RM',
|
||||||
|
idempotency_key: '3',
|
||||||
account_id: '9vmRb29zLQReckr5ay',
|
account_id: '9vmRb29zLQReckr5ay',
|
||||||
created_at: (new Date('2020-07-22T18:45:59.000Z'))
|
created_at: (new Date('2020-07-22T18:45:59.000Z'))
|
||||||
}
|
}
|
||||||
|
@ -44,10 +47,10 @@ describe('chatService', () => {
|
||||||
|
|
||||||
chatService.resetNewMessageCount(chat)
|
chatService.resetNewMessageCount(chat)
|
||||||
expect(chat.newMessageCount).to.eql(0)
|
expect(chat.newMessageCount).to.eql(0)
|
||||||
|
expect(chat.lastSeenMessageId).to.eql(message2.id)
|
||||||
|
|
||||||
const createdAt = new Date()
|
// Add message with higher id
|
||||||
createdAt.setSeconds(createdAt.getSeconds() + 10)
|
chatService.add(chat, { messages: [ message3 ] })
|
||||||
chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] })
|
|
||||||
expect(chat.newMessageCount).to.eql(1)
|
expect(chat.newMessageCount).to.eql(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Reference in a new issue