:Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop

This commit is contained in:
sadposter 2020-11-15 19:45:52 +00:00
commit 7e1b1ec990
50 changed files with 1054 additions and 621 deletions

View file

@ -3,19 +3,48 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- Fixed the occasional bug where screen would scroll 1px when typing into a reply form
- Fixed custom emoji not working in profile field names
## [2.2.1] - 2020-11-11
### Fixed
- Fixed regression in react popup alignment and overflowing
## [2.2.0] - 2020-11-06
### 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 clicking NSFW hider through status popover
- 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 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
- 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.
- Some icons changed for better accessibility (lock, globe)
- Logo is now clickable
- Changed default logo to SVG version
## [2.1.2] - 2020-09-17
### 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
### Added
- Import/export a muted users
- Proper handling of deletes when using websocket streaming
## [2.1.1] - 2020-09-08 ## [2.1.1] - 2020-09-08
### Changed ### Changed
@ -142,8 +171,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to change user's email - Ability to change user's email
- About page - About page
- Added remote user redirect - Added remote user redirect
- Bookmarks
### Changed ### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes - changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed ### Fixed
- improved hotkey behavior on autocomplete popup - improved hotkey behavior on autocomplete popup

View file

@ -1,7 +1,6 @@
import UserPanel from './components/user_panel/user_panel.vue' import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue'
import Notifications from './components/notifications/notifications.vue' import Notifications from './components/notifications/notifications.vue'
import SearchBar from './components/search_bar/search_bar.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue' import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
@ -11,6 +10,7 @@ import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue' import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
@ -22,7 +22,6 @@ export default {
UserPanel, UserPanel,
NavPanel, NavPanel,
Notifications, Notifications,
SearchBar,
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
WhoToFollowPanel, WhoToFollowPanel,
@ -31,21 +30,14 @@ export default {
SideDrawer, SideDrawer,
MobilePostStatusButton, MobilePostStatusButton,
MobileNav, MobileNav,
DesktopNav,
SettingsModal, SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal, PostStatusModal,
GlobalNoticeList GlobalNoticeList
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline'
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
)
}), }),
created () { created () {
// Load the locale from the storage // Load the locale from the storage
@ -61,28 +53,6 @@ export default {
background () { background () {
return this.currentUser.background_image || this.$store.state.instance.background return this.currentUser.background_image || this.$store.state.instance.background
}, },
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
'visibility': this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo () { return this.$store.state.instance.logo },
bgStyle () { bgStyle () {
return { return {
'background-image': `url(${this.background})` 'background-image': `url(${this.background})`
@ -93,9 +63,7 @@ export default {
'--body-background-image': `url(${this.background})` '--body-background-image': `url(${this.background})`
} }
}, },
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' }, chat () { return this.$store.state.chat.channel.state === 'joined' },
hideSitename () { return this.$store.state.instance.hideSitename },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel && return this.$store.state.instance.showInstanceSpecificPanel &&
@ -112,19 +80,6 @@ export default {
} }
}, },
methods: { methods: {
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
},
updateMobileState () { updateMobileState () {
const mobileLayout = windowWidth() <= 800 const mobileLayout = windowWidth() <= 800
const layoutHeight = windowHeight() const layoutHeight = windowHeight()

View file

@ -359,119 +359,10 @@ i[class*=icon-], .svg-inline--fa {
padding: 0 10px 0 10px; padding: 0 10px 0 10px;
} }
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
.nav-icon {
margin-left: 0.2em;
width: 2em;
text-align: center;
}
&.right {
justify-content: flex-end;
}
}
.auto-size { .auto-size {
flex: 1 flex: 1
} }
.nav-bar {
padding: 0;
width: 100%;
align-items: center;
position: fixed;
height: 50px;
box-sizing: border-box;
button {
&, i[class*=icon-], svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedTopBar, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedTopBarText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledTopBarText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg)
}
}
.logo {
display: flex;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
align-items: stretch;
justify-content: center;
flex: 0 0 auto;
z-index: -1;
transition: opacity;
transition-timing-function: ease-out;
transition-duration: 100ms;
.mask {
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
background-color: var(--topBarText, $fallback--fg);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
img {
height: 100%;
object-fit: contain;
display: block;
flex: 0;
}
}
.inner-nav {
position: relative;
margin: auto;
box-sizing: border-box;
padding-left: 10px;
padding-right: 10px;
display: flex;
align-items: center;
flex-basis: 970px;
height: 50px;
a, a i, a svg {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
}
}
main-router { main-router {
flex: 1; flex: 1;
} }
@ -712,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;
@ -781,16 +677,6 @@ nav {
} }
} }
@media all and (min-width: 800px) {
.logo {
opacity: 1 !important;
}
}
.item.right {
text-align: right;
}
.visibility-notice { .visibility-notice {
padding: .5em; padding: .5em;
border: 1px solid $fallback--faint; border: 1px solid $fallback--faint;
@ -943,22 +829,6 @@ nav {
background-color: var(--panel, $fallback--fg); background-color: var(--panel, $fallback--fg);
} }
.unread-chat-count {
font-size: 0.9em;
font-weight: bolder;
font-style: normal;
position: absolute;
right: 0.6rem;
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;
}
.chat-layout { .chat-layout {
// Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens). // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
overflow: hidden; overflow: hidden;

View file

@ -9,80 +9,7 @@
:style="bgStyle" :style="bgStyle"
/> />
<MobileNav v-if="isMobileLayout" /> <MobileNav v-if="isMobileLayout" />
<nav <DesktopNav v-else />
v-else
id="nav"
class="nav-bar container"
@click="scrollToTop()"
>
<div class="inner-nav">
<div
class="logo"
:style="logoBgStyle"
>
<div
class="mask"
:style="logoMaskStyle"
/>
<img
:src="logo"
:style="logoStyle"
>
</div>
<div class="item">
<router-link
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<div class="item right">
<search-bar
v-if="currentUser || !privateMode"
class="mobile-hidden"
@toggled="onSearchBarToggled"
@click.stop.native
/>
<a
href="#"
class="mobile-hidden nav-icon"
@click.stop="openSettingsModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
:title="$t('nav.preferences')"
/>
</a>
<a
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
class="mobile-hidden nav-icon"
target="_blank"
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
:title="$t('nav.administration')"
/></a>
<a
v-if="currentUser"
href="#"
class="mobile-hidden nav-icon"
@click.prevent="logout"
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
:title="$t('login.logout')"
/></a>
</div>
</div>
</nav>
<div class="app-bg-wrapper app-container-wrapper" /> <div class="app-bg-wrapper app-container-wrapper" />
<div <div
id="content" id="content"

View file

@ -130,6 +130,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
? 0 ? 0
: config.logoMargin : config.logoMargin
}) })
copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod) store.commit('authFlow/setInitialStrategy', config.loginMethod)
copyInstanceOption('redirectRootNoLogin') copyInstanceOption('redirectRootNoLogin')

View file

@ -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,
@ -21,6 +22,8 @@ library.add(
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 SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const Chat = { const Chat = {
components: { components: {
@ -34,7 +37,8 @@ const Chat = {
hoveredMessageChainId: undefined, hoveredMessageChainId: undefined,
lastScrollPosition: {}, lastScrollPosition: {},
scrollableContainerHeight: '100%', scrollableContainerHeight: '100%',
errorLoadingChat: false errorLoadingChat: false,
messageRetriers: {}
} }
}, },
created () { created () {
@ -104,7 +108,7 @@ const Chat = {
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => { this.$nextTick(() => {
if (bottomedOutBeforeUpdate) { if (bottomedOutBeforeUpdate) {
this.scrollDown({ forceRead: !document.hidden }) this.scrollDown()
} }
}) })
}, },
@ -210,7 +214,7 @@ const Chat = {
this.$nextTick(() => { this.$nextTick(() => {
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior }) scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
}) })
if (forceRead || this.newMessageCount > 0) { if (forceRead) {
this.readChat() this.readChat()
} }
}, },
@ -218,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)
@ -235,12 +242,18 @@ const Chat = {
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false this.jumpToBottomButtonVisible = false
if (this.newMessageCount > 0) { if (this.newMessageCount > 0) {
this.readChat() // Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
} }
} else { } else {
this.jumpToBottomButtonVisible = true this.jumpToBottomButtonVisible = true
} }
}, 100), }, 200),
handleScrollUp (positionBeforeLoading) { handleScrollUp (positionBeforeLoading) {
const positionAfterLoading = getScrollPosition(this.$refs.scrollable) const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
this.$refs.scrollable.scrollTo({ this.$refs.scrollable.scrollTo({
@ -274,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 })
}
}) })
}) })
}) })
@ -302,23 +323,7 @@ const Chat = {
}) })
this.fetchChat({ isFirstFetch: true }) this.fetchChat({ isFirstFetch: true })
}, },
sendMessage ({ status, media }) { handleAttachmentPosting () {
const params = {
id: this.currentChat.id,
content: status
}
if (media[0]) {
params.mediaId = media[0].id
}
return this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [data],
updateMaxId: false
}).then(() => {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize // When the posting form size changes because of a media attachment, we need an extra resize
@ -328,16 +333,64 @@ const Chat = {
}, SAFE_RESIZE_TIME_OFFSET) }, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
},
sendMessage ({ status, media, idempotencyKey }) {
const params = {
id: this.currentChat.id,
content: status,
idempotencyKey
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.currentChat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey
})
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [fakeMessage]
}).then(() => {
this.handleAttachmentPosting()
})
return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
},
doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }]
}) })
return data 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 } })

View file

@ -25,7 +25,7 @@
min-height: 100%; min-height: 100%;
margin: 0 0 0 0; margin: 0 0 0 0;
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: 0; border-radius: 0;
@ -58,8 +58,10 @@
.go-back-button { .go-back-button {
cursor: pointer; cursor: pointer;
margin-right: 1.7em; width: 28px;
margin-left: 0.3em; text-align: center;
padding: 0.6em;
margin: -0.6em 0.6em -0.6em -0.6em;
} }
.jump-to-bottom-button { .jump-to-bottom-button {
@ -74,7 +76,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 10; z-index: 10;
transition: 0.35s all; transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1); transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
@ -136,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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,7 +23,9 @@
.go-back-button { .go-back-button {
cursor: pointer; cursor: pointer;
margin-right: 1.7em; width: 28px;
margin-left: 0.3em; text-align: center;
padding: 0.6em;
margin: -0.6em 0.6em -0.6em -0.6em;
} }
} }

View file

@ -0,0 +1,89 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle
)
export default {
components: {
SearchBar
},
data: () => ({
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
)
}),
computed: {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
'visibility': this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo () { return this.$store.state.instance.logo },
sitename () { return this.$store.state.instance.name },
hideSitename () { return this.$store.state.instance.hideSitename },
logoLeft () { return this.$store.state.instance.logoLeft },
currentUser () { return this.$store.state.users.currentUser },
privateMode () { return this.$store.state.instance.private }
},
methods: {
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
}
}
}

View file

@ -0,0 +1,112 @@
@import '../../_variables.scss';
.DesktopNav {
height: 50px;
width: 100%;
position: fixed;
.inner-nav {
display: grid;
grid-template-rows: 50px;
grid-template-columns: 2fr auto 2fr;
grid-template-areas: "sitename logo actions";
box-sizing: border-box;
padding: 0 1.2em;
margin: auto;
max-width: 980px;
}
&.-logoLeft {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: "logo sitename actions";
}
button {
&, svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
&:active {
background-color: $fallback--fg;
background-color: var(--btnPressedTopBar, $fallback--fg);
color: $fallback--text;
color: var(--btnPressedTopBarText, $fallback--text);
}
&:disabled {
color: $fallback--text;
color: var(--btnDisabledTopBarText, $fallback--text);
}
&.toggled {
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg)
}
}
.logo {
grid-area: logo;
position: relative;
transition: opacity;
transition-timing-function: ease-out;
transition-duration: 100ms;
@media all and (min-width: 800px) {
opacity: 1 !important;
}
.mask {
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
background-color: $fallback--fg;
background-color: var(--topBarText, $fallback--fg);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
img {
display: inline-block;
height: 50px;
}
}
.nav-icon {
margin-left: 0.2em;
width: 2em;
text-align: center;
}
a, a svg {
color: $fallback--link;
color: var(--topBarLink, $fallback--link);
}
.sitename {
grid-area: sitename;
}
.actions {
grid-area: actions;
}
.item {
flex: 1;
line-height: 50px;
height: 50px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
&.right {
justify-content: flex-end;
text-align: right;
}
}
}

View file

@ -0,0 +1,79 @@
<template>
<nav
id="nav"
class="DesktopNav"
:class="{ '-logoLeft': logoLeft }"
@click="scrollToTop()"
>
<div class="inner-nav">
<div class="item sitename">
<router-link
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<router-link
class="logo"
:to="{ name: 'root' }"
:style="logoBgStyle"
>
<div
class="mask"
:style="logoMaskStyle"
/>
<img
:src="logo"
:style="logoStyle"
>
</router-link>
<div class="item right actions">
<search-bar
v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
@click.stop.native
/>
<a
href="#"
class="nav-icon"
@click.stop="openSettingsModal"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="cog"
:title="$t('nav.preferences')"
/>
</a>
<a
v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma"
class="nav-icon"
target="_blank"
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="tachometer-alt"
:title="$t('nav.administration')"
/></a>
<a
v-if="currentUser"
href="#"
class="nav-icon"
@click.prevent="logout"
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="sign-out-alt"
:title="$t('login.logout')"
/></a>
</div>
</div>
</nav>
</template>
<script src="./desktop_nav.js"></script>
<style src="./desktop_nav.scss" lang="scss"></style>

View file

@ -16,7 +16,6 @@
@click.prevent="muteConversation" @click.prevent="muteConversation"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="eye-slash" icon="eye-slash"
/><span>{{ $t("status.mute_conversation") }}</span> /><span>{{ $t("status.mute_conversation") }}</span>
@ -27,7 +26,6 @@
@click.prevent="unmuteConversation" @click.prevent="unmuteConversation"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="eye-slash" icon="eye-slash"
/><span>{{ $t("status.unmute_conversation") }}</span> /><span>{{ $t("status.unmute_conversation") }}</span>
@ -39,7 +37,6 @@
@click="close" @click="close"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="thumbtack" icon="thumbtack"
/><span>{{ $t("status.pin") }}</span> /><span>{{ $t("status.pin") }}</span>
@ -51,7 +48,6 @@
@click="close" @click="close"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="thumbtack" icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span> /><span>{{ $t("status.unpin") }}</span>
@ -63,7 +59,6 @@
@click="close" @click="close"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
:icon="['far', 'bookmark']" :icon="['far', 'bookmark']"
/><span>{{ $t("status.bookmark") }}</span> /><span>{{ $t("status.bookmark") }}</span>
@ -75,7 +70,6 @@
@click="close" @click="close"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="bookmark" icon="bookmark"
/><span>{{ $t("status.unbookmark") }}</span> /><span>{{ $t("status.unbookmark") }}</span>
@ -87,7 +81,6 @@
@click="close" @click="close"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="times" icon="times"
/><span>{{ $t("status.delete") }}</span> /><span>{{ $t("status.delete") }}</span>
@ -98,7 +91,6 @@
@click="close" @click="close"
> >
<FAIcon <FAIcon
size="md"
fixed-width fixed-width
icon="share-alt" icon="share-alt"
/><span>{{ $t("status.copy_link") }}</span> /><span>{{ $t("status.copy_link") }}</span>
@ -109,7 +101,6 @@
<FAIcon <FAIcon
class="ExtraButtons fa-scale-110 fa-old-padding" class="ExtraButtons fa-scale-110 fa-old-padding"
icon="ellipsis-h" icon="ellipsis-h"
size="md"
/> />
</span> </span>
</Popover> </Popover>

View file

@ -1,12 +1,11 @@
<template> <template>
<div> <div
class="MobileNav"
>
<nav <nav
id="nav" id="nav"
class="nav-bar container" class="mobile-nav"
:class="{ 'mobile-hidden': isChat }" :class="{ 'mobile-hidden': isChat }"
>
<div
class="mobile-inner-nav"
@click="scrollToTop()" @click="scrollToTop()"
> >
<div class="item"> <div class="item">
@ -50,7 +49,6 @@
/> />
</a> </a>
</div> </div>
</div>
</nav> </nav>
<div <div
v-if="currentUser" v-if="currentUser"
@ -93,20 +91,43 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.mobile-inner-nav { .MobileNav {
.mobile-nav {
display: grid;
line-height: 50px;
height: 50px;
grid-template-rows: 50px;
grid-template-columns: 2fr auto;
width: 100%;
position: fixed;
box-sizing: border-box;
}
.mobile-inner-nav {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.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;
} }
.alert-dot { .site-name {
padding: 0 .3em;
display: inline-block;
}
.item {
/* moslty just to get rid of extra whitespaces */
display: flex;
}
.alert-dot {
border-radius: 100%; border-radius: 100%;
height: 8px; height: 8px;
width: 8px; width: 8px;
@ -117,9 +138,9 @@
margin-top: -6px; margin-top: -6px;
background-color: $fallback--cRed; background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed); background-color: var(--badgeNotification, $fallback--cRed);
} }
.mobile-notifications-drawer { .mobile-notifications-drawer {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
overflow-x: hidden; overflow-x: hidden;
@ -137,9 +158,9 @@
&.closed { &.closed {
transform: translateX(100%); transform: translateX(100%);
} }
} }
.mobile-notifications-header { .mobile-notifications-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -158,9 +179,9 @@
font-size: 1.3em; font-size: 1.3em;
margin-left: 0.6em; margin-left: 0.6em;
} }
} }
.mobile-notifications { .mobile-notifications {
margin-top: 50px; margin-top: 50px;
width: 100vw; width: 100vw;
height: calc(100vh - 50px); height: calc(100vh - 50px);
@ -189,6 +210,7 @@
box-shadow: none; box-shadow: none;
} }
} }
}
} }
</style> </style>

View file

@ -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,13 +84,8 @@
padding: 0; padding: 0;
} }
.follow-request-count {
vertical-align: baseline;
background-color: $fallback--bg;
background-color: var(--input, $fallback--faint);
}
li { li {
position: relative;
border-bottom: 1px solid; border-bottom: 1px solid;
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
@ -154,5 +149,11 @@
.fa-scale-110 { .fa-scale-110 {
margin-right: 0.8em; margin-right: 0.8em;
} }
.badge {
position: absolute;
right: 0.6rem;
top: 1.25em;
}
} }
</style> </style>

View file

@ -18,6 +18,8 @@ import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faCircleNotch faCircleNotch
) )
export default {}
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -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
@ -528,7 +531,7 @@ const PostStatusForm = {
!(isFormBiggerThanScroller && !(isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !== this.$refs.textarea.value.length) this.$refs.textarea.selectionStart !== this.$refs.textarea.value.length)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0 const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = currentScroll + totalDelta const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) { if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll) scrollerRef.scroll(0, targetScroll)

View file

@ -85,12 +85,17 @@
{{ $t('post_status.preview') }} {{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" /> <FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a> </a>
<FAIcon <div
v-show="previewLoading" v-show="previewLoading"
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin spin
icon="circle-notch" icon="circle-notch"
/> />
</div> </div>
</div>
<div <div
v-if="showPreview" v-if="showPreview"
class="preview-container" class="preview-container"
@ -124,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"
> >
@ -150,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)"
@ -383,12 +388,12 @@
} }
.preview-heading { .preview-heading {
padding-left: 0.5em;
display: flex; display: flex;
width: 100%; padding-left: 0.5em;
} }
.preview-toggle { .preview-toggle {
flex: 1;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;

View file

@ -4,6 +4,7 @@
placement="top" placement="top"
:offset="{ y: 5 }" :offset="{ y: 5 }"
class="react-button-popover" class="react-button-popover"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"
@ -12,6 +13,7 @@
<div class="reaction-picker-filter"> <div class="reaction-picker-filter">
<input <input
v-model="filterWord" v-model="filterWord"
size="1"
:placeholder="$t('emoji.search_emoji')" :placeholder="$t('emoji.search_emoji')"
> >
</div> </div>
@ -36,12 +38,13 @@
<div class="reaction-bottom-fader" /> <div class="reaction-bottom-fader" />
</div> </div>
</div> </div>
<span slot="trigger">
<FAIcon <FAIcon
slot="trigger"
class="fa-scale-110 fa-old-padding add-reaction-button" class="fa-scale-110 fa-old-padding add-reaction-button"
:icon="['far', 'smile-beam']" :icon="['far', 'smile-beam']"
:title="$t('tool_tip.add_reaction')" :title="$t('tool_tip.add_reaction')"
/> />
</span>
</Popover> </Popover>
</template> </template>

View file

@ -1,6 +1,8 @@
<template> <template>
<div> <div
<div class="search-bar-container"> class="SearchBar"
:class="{ '-expanded': !hidden }"
>
<a <a
v-if="hidden" v-if="hidden"
href="#" href="#"
@ -41,7 +43,6 @@
</span> </span>
</template> </template>
</div> </div>
</div>
</template> </template>
<script src="./search_bar.js"></script> <script src="./search_bar.js"></script>
@ -49,21 +50,23 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.search-bar-container { .SearchBar {
max-width: 100%;
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
vertical-align: baseline; vertical-align: baseline;
justify-content: flex-end; justify-content: flex-end;
&.-expanded {
width: 100%;
}
.search-bar-input, .search-bar-input,
.search-button { .search-button {
height: 29px; height: 29px;
} }
.search-bar-input { .search-bar-input {
// TODO: do this properly without a rough guesstimate of 2 icons + paddings flex: 1 0 auto;
max-width: calc(100% - 30px - 30px - 20px);
} }
.cancel-icon { .cancel-icon {

View file

@ -126,6 +126,8 @@ library.add(
faRetweet, faRetweet,
faReply faReply
) )
export default {}
</script> </script>
<style lang="scss"> <style lang="scss">

View file

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

View file

@ -7,8 +7,9 @@ $status-margin: 0.75em;
min-width: 0; min-width: 0;
&:hover { &:hover {
--still-image-img: visible; --_still-image-img-visibility: visible;
--still-image-canvas: hidden; --_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
} }
&.-focused { &.-focused {
@ -58,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;
} }

View file

@ -42,7 +42,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
visibility: var(--still-image-canvas, visible); visibility: var(--_still-image-canvas-visibility, visible);
} }
img { img {
@ -66,16 +66,19 @@
border-radius: $fallback--tooltipRadius; border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2; z-index: 2;
visibility: var(--still-image-label-visibility, visible); visibility: var(--_still-image-label-visibility, visible);
} }
&:hover canvas { &:hover canvas {
display: none; display: none;
} }
&:hover::before, &:hover::before {
visibility: var(--_still-image-label-visibility, hidden);
}
img { img {
visibility: var(--still-image-img, hidden); visibility: var(--_still-image-img-visibility, hidden);
} }
&:hover img { &:hover img {

View file

@ -4,8 +4,7 @@
display: flex; display: flex;
.tab-icon { .tab-icon {
width: 100%; margin: 0.2em auto;
margin: 0.2em 0;
display: block; display: block;
} }

View file

@ -2,7 +2,7 @@ import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import TimelineMenu from '../timeline_menu/timeline_menu.vue' import TimelineMenu from '../timeline_menu/timeline_menu.vue'
import { throttle, keyBy } from 'lodash' import { debounce, throttle, keyBy } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
@ -40,7 +40,8 @@ const Timeline = {
paused: false, paused: false,
unfocused: false, unfocused: false,
bottomedOut: false, bottomedOut: false,
virtualScrollIndex: 0 virtualScrollIndex: 0,
blockingClicks: false
} }
}, },
components: { components: {
@ -70,8 +71,10 @@ const Timeline = {
} }
}, },
classes () { classes () {
let rootClasses = !this.embedded ? ['panel', 'panel-default'] : []
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return { return {
root: ['timeline'].concat(!this.embedded ? ['panel', 'panel-default'] : []), root: rootClasses,
header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []), header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []), body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []),
footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : []) footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : [])
@ -130,6 +133,15 @@ const Timeline = {
this.$store.commit('setLoading', { timeline: this.timelineName, value: false }) this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
}, },
methods: { methods: {
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily () {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey (e) { handleShortKey (e) {
// Ignore when input fields are focused // Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
@ -141,6 +153,7 @@ const Timeline = {
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
this.fetchOlderStatuses() this.fetchOlderStatuses()
} else { } else {
this.blockClicksTemporarily()
this.$store.commit('showNewStatuses', { timeline: this.timelineName }) this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false this.paused = false
} }

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="[classes.root, 'timeline']"> <div :class="[classes.root, 'Timeline']">
<div :class="classes.header"> <div :class="classes.header">
<TimelineMenu v-if="!embedded" /> <TimelineMenu v-if="!embedded" />
<div <div
@ -107,10 +107,14 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.timeline { .Timeline {
.loadmore-text { .loadmore-text {
opacity: 1; opacity: 1;
} }
&.-blocked {
cursor: progress;
}
} }
.timeline-heading { .timeline-heading {

View file

@ -20,11 +20,14 @@
@import '../../_variables.scss'; @import '../../_variables.scss';
.Avatar { .Avatar {
--still-image-label-visibility: hidden; --_avatarShadowBox: var(--avatarStatusShadow);
--_avatarShadowFilter: var(--avatarStatusShadowFilter);
--_avatarShadowInset: var(--avatarStatusShadowInset);
--_still-image-label-visibility: hidden;
width: 48px; width: 48px;
height: 48px; height: 48px;
box-shadow: var(--avatarStatusShadow); box-shadow: var(--_avatarShadowBox);
border-radius: $fallback--avatarRadius; border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius); border-radius: var(--avatarRadius, $fallback--avatarRadius);
@ -34,8 +37,8 @@
} }
&.better-shadow { &.better-shadow {
box-shadow: var(--avatarStatusShadowInset); box-shadow: var(--_avatarShadowInset);
filter: var(--avatarStatusShadowFilter) filter: var(--_avatarShadowFilter);
} }
&.animated::before { &.animated::before {

View file

@ -282,6 +282,11 @@
.user-card { .user-card {
position: relative; position: relative;
&:hover .Avatar {
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
}
.panel-heading { .panel-heading {
padding: .5em 0; padding: .5em 0;
text-align: center; text-align: center;
@ -382,20 +387,17 @@
max-height: 56px; max-height: 56px;
.Avatar { .Avatar {
--_avatarShadowBox: var(--avatarShadow);
--_avatarShadowFilter: var(--avatarShadowFilter);
--_avatarShadowInset: var(--avatarShadowInset);
flex: 1 0 100%; flex: 1 0 100%;
width: 56px; width: 56px;
height: 56px; height: 56px;
box-shadow: 0px 1px 8px rgba(0,0,0,0.75);
box-shadow: var(--avatarShadow);
object-fit: cover; object-fit: cover;
} }
} }
&:hover .Avatar {
--still-image-img: visible;
--still-image-canvas: hidden;
}
&-avatar-link { &-avatar-link {
position: relative; position: relative;
cursor: pointer; cursor: pointer;

View file

@ -20,14 +20,13 @@
:key="index" :key="index"
class="user-profile-field" class="user-profile-field"
> >
<!-- eslint-disable vue/no-v-html -->
<dt <dt
:title="user.fields_text[index].name" :title="user.fields_text[index].name"
class="user-profile-field-name" class="user-profile-field-name"
@click.prevent="linkClicked" @click.prevent="linkClicked"
> v-html="field.name"
{{ field.name }} />
</dt>
<!-- eslint-disable vue/no-v-html -->
<dd <dd
:title="user.fields_text[index].value" :title="user.fields_text[index].value"
class="user-profile-field-value" class="user-profile-field-value"

View file

@ -5,7 +5,7 @@
"features_panel": { "features_panel": {
"chat": "Babilejo", "chat": "Babilejo",
"gopher": "Gopher", "gopher": "Gopher",
"media_proxy": "Vidaŭdaĵa prokurilo", "media_proxy": "Vidaŭdaĵa retperilo",
"scope_options": "Agordoj de amplekso", "scope_options": "Agordoj de amplekso",
"text_limit": "Limo de teksto", "text_limit": "Limo de teksto",
"title": "Funkcioj", "title": "Funkcioj",
@ -33,7 +33,8 @@
"show_more": "Montri plion", "show_more": "Montri plion",
"retry": "Reprovi", "retry": "Reprovi",
"error_retry": "Bonvolu reprovi", "error_retry": "Bonvolu reprovi",
"loading": "Enlegante…" "loading": "Enlegante…",
"peek": "Antaŭmontri"
}, },
"image_cropper": { "image_cropper": {
"crop_picture": "Tondi bildon", "crop_picture": "Tondi bildon",
@ -70,9 +71,9 @@
"friend_requests": "Petoj pri abono", "friend_requests": "Petoj pri abono",
"mentions": "Mencioj", "mentions": "Mencioj",
"dms": "Rektaj mesaĝoj", "dms": "Rektaj mesaĝoj",
"public_tl": "Publika tempolinio", "public_tl": "Publika historio",
"timeline": "Tempolinio", "timeline": "Historio",
"twkn": "La tuta konata reto", "twkn": "Konata reto",
"user_search": "Serĉi uzantojn", "user_search": "Serĉi uzantojn",
"who_to_follow": "Kiun aboni", "who_to_follow": "Kiun aboni",
"preferences": "Agordoj", "preferences": "Agordoj",
@ -80,7 +81,8 @@
"search": "Serĉi", "search": "Serĉi",
"interactions": "Interagoj", "interactions": "Interagoj",
"administration": "Administrado", "administration": "Administrado",
"bookmarks": "Legosignoj" "bookmarks": "Legosignoj",
"timelines": "Historioj"
}, },
"notifications": { "notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝin…", "broken_favorite": "Nekonata stato, serĉante ĝin…",
@ -107,14 +109,14 @@
"text/html": "HTML" "text/html": "HTML"
}, },
"content_warning": "Temo (malnepra)", "content_warning": "Temo (malnepra)",
"default": "Ĵus alvenis al la Universala Kongreso!", "default": "Ĵus alvenis Esperantujon!",
"direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.", "direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.",
"posting": "Afiŝante", "posting": "Afiŝante",
"scope": { "scope": {
"direct": "Rekta Afiŝi nur al menciitaj uzantoj", "direct": "Rekta Afiŝi nur al menciitaj uzantoj",
"private": "Nur abonantoj Afiŝi nur al abonantoj", "private": "Nur abonantoj Afiŝi nur al abonantoj",
"public": "Publika Afiŝi al publikaj tempolinioj", "public": "Publika Afiŝi al publikaj historioj",
"unlisted": "Nelistigita Ne afiŝi al publikaj tempolinioj" "unlisted": "Nelistigita Ne afiŝi al publikaj historioj"
}, },
"scope_notice": { "scope_notice": {
"unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto", "unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto",
@ -193,7 +195,7 @@
"foreground": "Malfono", "foreground": "Malfono",
"general": "Ĝenerala", "general": "Ĝenerala",
"hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj", "hide_attachments_in_convo": "Kaŝi kunsendaĵojn en interparoloj",
"hide_attachments_in_tl": "Kaŝi kunsendaĵojn en tempolinio", "hide_attachments_in_tl": "Kaŝi kunsendaĵojn en historioj",
"max_thumbnails": "Maksimuma nombro da bildetoj en afiŝo", "max_thumbnails": "Maksimuma nombro da bildetoj en afiŝo",
"hide_isp": "Kaŝi breton propran al nodo", "hide_isp": "Kaŝi breton propran al nodo",
"preload_images": "Antaŭ-enlegi bildojn", "preload_images": "Antaŭ-enlegi bildojn",
@ -246,7 +248,7 @@
"profile_banner": "Rubando de profilo", "profile_banner": "Rubando de profilo",
"profile_tab": "Profilo", "profile_tab": "Profilo",
"radii_help": "Agordi fasadan rondigon de randoj (bildere)", "radii_help": "Agordi fasadan rondigon de randoj (bildere)",
"replies_in_timeline": "Respondoj en tempolinio", "replies_in_timeline": "Respondoj en historioj",
"reply_visibility_all": "Montri ĉiujn respondojn", "reply_visibility_all": "Montri ĉiujn respondojn",
"reply_visibility_following": "Montri nur respondojn por mi aŭ miaj abonatoj", "reply_visibility_following": "Montri nur respondojn por mi aŭ miaj abonatoj",
"reply_visibility_self": "Montri nur respondojn por mi", "reply_visibility_self": "Montri nur respondojn por mi",
@ -297,7 +299,12 @@
"older_version_imported": "La enportita dosiero estis farita per pli malnova versio de PleromaFE.", "older_version_imported": "La enportita dosiero estis farita per pli malnova versio de PleromaFE.",
"future_version_imported": "La enportita dosiero estis farita per pli nova versio de PleromaFE.", "future_version_imported": "La enportita dosiero estis farita per pli nova versio de PleromaFE.",
"v2_imported": "La dosiero, kiun vi enportis, estis farita por malnova versio de PleromaFE. Ni provas maksimumigi interkonformecon, sed tamen eble montriĝos misoj.", "v2_imported": "La dosiero, kiun vi enportis, estis farita por malnova versio de PleromaFE. Ni provas maksimumigi interkonformecon, sed tamen eble montriĝos misoj.",
"upgraded_from_v2": "PleromaFE estis ĝisdatigita; la haŭto eble aspektos malsame ol kiel vi ĝin memoras." "upgraded_from_v2": "PleromaFE estis ĝisdatigita; la haŭto eble aspektos malsame ol kiel vi ĝin memoras.",
"snapshot_missing": "Neniu momentokopio de haŭto estis en la dosiero, ĝi povas aspekti iom malsame ol oni intencis.",
"snapshot_present": "Ĉiuj valoroj estas transpasataj, ĉar momentokopio de haŭto estas enlegita. Vi povas enlegi anstataŭe la aktualajn datumojn de haŭto.",
"snapshot_source_mismatch": "Versioj konfliktas: plej probable la fasado estis reirigita kaj ree ĝisdatigita; se vi ŝanĝis la haŭton per pli malnova versio de la fasado, vi probable volas uzi la malnovan version. Alie uzu la novan.",
"migration_napshot_gone": "Ial mankis momentokopio; io povus aspekti malsame ol en via memoro.",
"migration_snapshot_ok": "Certige, momentokopio de la haŭto enlegiĝis. Vi povas provi enlegi datumojn de la haŭto."
}, },
"use_source": "Nova versio", "use_source": "Nova versio",
"use_snapshot": "Malnova versio", "use_snapshot": "Malnova versio",
@ -352,10 +359,11 @@
"icons": "Bildsimboloj", "icons": "Bildsimboloj",
"poll": "Grafo de enketo", "poll": "Grafo de enketo",
"underlay": "Subtavolo", "underlay": "Subtavolo",
"popover": "Ŝpruchelpiloj, menuoj", "popover": "Ŝprucoj, menuoj",
"post": "Afiŝoj/Priskriboj de uzantoj", "post": "Afiŝoj/Priskriboj de uzantoj",
"alert_neutral": "Neŭtrala", "alert_neutral": "Neŭtrala",
"alert_warning": "Averto" "alert_warning": "Averto",
"toggled": "Ŝaltita"
}, },
"radii": { "radii": {
"_tab_label": "Rondeco" "_tab_label": "Rondeco"
@ -388,7 +396,8 @@
"buttonPressed": "Butono (premita)", "buttonPressed": "Butono (premita)",
"buttonPressedHover": "Butono (premita kaj je ŝvebo)", "buttonPressedHover": "Butono (premita kaj je ŝvebo)",
"input": "Eniga kampo" "input": "Eniga kampo"
} },
"hintV3": "Kolorojn de ombroj vi ankaŭ povas skribi per la sistemo {0}."
}, },
"fonts": { "fonts": {
"_tab_label": "Tiparoj", "_tab_label": "Tiparoj",
@ -411,7 +420,7 @@
"button": "Butono", "button": "Butono",
"text": "Kelko da pliaj {0} kaj {1}", "text": "Kelko da pliaj {0} kaj {1}",
"mono": "enhavo", "mono": "enhavo",
"input": "Ĵus alvenis al la Universala Kongreso!", "input": "Ĵus alvenis Esperantujon!",
"faint_link": "helpan manlibron", "faint_link": "helpan manlibron",
"fine_print": "Legu nian {0} por nenion utilan ekscii!", "fine_print": "Legu nian {0} por nenion utilan ekscii!",
"header_faint": "Tio estas en ordo", "header_faint": "Tio estas en ordo",
@ -420,7 +429,7 @@
} }
}, },
"discoverable": "Permesi trovon de ĉi tiu konto en serĉrezultoj kaj aliaj servoj", "discoverable": "Permesi trovon de ĉi tiu konto en serĉrezultoj kaj aliaj servoj",
"mutes_and_blocks": "Silentigitoj kaj blokitoj", "mutes_and_blocks": "Blokado kaj silentigoj",
"chatMessageRadius": "Babileja mesaĝo", "chatMessageRadius": "Babileja mesaĝo",
"changed_email": "Retpoŝtadreso sukcese ŝanĝiĝis!", "changed_email": "Retpoŝtadreso sukcese ŝanĝiĝis!",
"change_email_error": "Eraris ŝanĝo de via retpoŝtadreso.", "change_email_error": "Eraris ŝanĝo de via retpoŝtadreso.",
@ -448,7 +457,10 @@
"warning_of_generate_new_codes": "Kiam vi estigos novajn rehavajn kodojn, viaj malnovaj ne plu funkcios.", "warning_of_generate_new_codes": "Kiam vi estigos novajn rehavajn kodojn, viaj malnovaj ne plu funkcios.",
"generate_new_recovery_codes": "Estigi novajn rehavajn kodojn", "generate_new_recovery_codes": "Estigi novajn rehavajn kodojn",
"title": "Duobla aŭtentikigo", "title": "Duobla aŭtentikigo",
"otp": "OTP" "otp": "OTP",
"wait_pre_setup_otp": "antaŭagordante OTP",
"setup_otp": "Agordi OTP",
"confirm_and_enable": "Konfirmi kaj ŝalti OTP"
}, },
"enter_current_password_to_confirm": "Enigu vian pasvorton por konfirmi vian identecon", "enter_current_password_to_confirm": "Enigu vian pasvorton por konfirmi vian identecon",
"security": "Sekureco", "security": "Sekureco",
@ -480,11 +492,11 @@
}, },
"import_blocks_from_a_csv_file": "Enporti blokitojn el CSV-dosiero", "import_blocks_from_a_csv_file": "Enporti blokitojn el CSV-dosiero",
"hide_muted_posts": "Kaŝi afiŝojn de silentigitaj uzantoj", "hide_muted_posts": "Kaŝi afiŝojn de silentigitaj uzantoj",
"emoji_reactions_on_timeline": "Montri bildosignajn reagojn en la tempolinio", "emoji_reactions_on_timeline": "Montri bildosignajn reagojn en historioj",
"pad_emoji": "Meti spacetojn ĉirkaŭ bildosigno post ties elekto", "pad_emoji": "Meti spacetojn ĉirkaŭ bildosigno post ties elekto",
"domain_mutes": "Retnomoj", "domain_mutes": "Retnomoj",
"notification_blocks": "Blokinte uzanton vi malabonos ĝin kaj haltigos ĉiujn sciigojn.", "notification_blocks": "Blokinte uzanton vi malabonos ĝin kaj haltigos ĉiujn sciigojn.",
"notification_mutes": "Por ne plu ricevi sciigojn de certa uzanto, silentigu.", "notification_mutes": "Por ne plu ricevi sciigojn de certa uzanto, silentigu ĝin.",
"notification_setting_hide_notification_contents": "Kaŝi la sendinton kaj la enhavojn de pasivaj sciigoj", "notification_setting_hide_notification_contents": "Kaŝi la sendinton kaj la enhavojn de pasivaj sciigoj",
"notification_setting_privacy": "Privateco", "notification_setting_privacy": "Privateco",
"notification_setting_block_from_strangers": "Bloki sciigojn de uzantoj, kiujn vi ne abonas", "notification_setting_block_from_strangers": "Bloki sciigojn de uzantoj, kiujn vi ne abonas",
@ -495,7 +507,14 @@
"backend_version": "Versio de internaĵo", "backend_version": "Versio de internaĵo",
"title": "Versio" "title": "Versio"
}, },
"accent": "Emfazo" "accent": "Emfazo",
"virtual_scrolling": "Optimumigi bildigon de historioj",
"import_mutes_from_a_csv_file": "Enporti silentigojn el CSV-dosiero",
"mutes_imported": "Silentigoj enportiĝis! Traktado daŭros iom da tempo.",
"mute_import_error": "Eraris enporto de silentigoj",
"mute_import": "Enporto de silentigoj",
"mute_export_button": "Elportu viajn silentigojn al CSV-dosiero",
"mute_export": "Elporto de silentigoj"
}, },
"timeline": { "timeline": {
"collapse": "Maletendi", "collapse": "Maletendi",
@ -503,7 +522,7 @@
"error_fetching": "Eraris ĝisdatigo", "error_fetching": "Eraris ĝisdatigo",
"load_older": "Montri pli malnovajn statojn", "load_older": "Montri pli malnovajn statojn",
"no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti", "no_retweet_hint": "Afiŝo estas markita kiel rekta aŭ nur por abonantoj, kaj ne eblas ĝin ripeti",
"repeated": "ripetita", "repeated": "ripetis",
"show_new": "Montri novajn", "show_new": "Montri novajn",
"up_to_date": "Ĝisdata", "up_to_date": "Ĝisdata",
"no_more_statuses": "Neniuj pliaj statoj", "no_more_statuses": "Neniuj pliaj statoj",
@ -648,21 +667,22 @@
"media_nsfw": "Devige marki vidaŭdaĵojn konsternaj", "media_nsfw": "Devige marki vidaŭdaĵojn konsternaj",
"media_removal_desc": "Ĉi tiu nodo forigas vidaŭdaĵojn de afiŝoj el la jenaj nodoj:", "media_removal_desc": "Ĉi tiu nodo forigas vidaŭdaĵojn de afiŝoj el la jenaj nodoj:",
"media_removal": "Forigo de vidaŭdaĵoj", "media_removal": "Forigo de vidaŭdaĵoj",
"ftl_removal": "Forigo de la historio de «La tuta konata reto»", "ftl_removal": "Forigo el la historio de «La tuta konata reto»",
"quarantine_desc": "Ĉi tiu nodo sendos nur publikajn afiŝojn al la jenaj nodoj:", "quarantine_desc": "Ĉi tiu nodo sendos nur publikajn afiŝojn al la jenaj nodoj:",
"quarantine": "Kvaranteno", "quarantine": "Kvaranteno",
"reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:", "reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:",
"reject": "Rifuzi", "reject": "Rifuzi",
"accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:", "accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:",
"accept": "Akcepti", "accept": "Akcepti",
"simple_policies": "Specialaj politikoj de la nodo" "simple_policies": "Specialaj politikoj de la nodo",
"ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de «La tuta konata reto»:"
}, },
"mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)", "mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)",
"keyword": { "keyword": {
"is_replaced_by": "→", "is_replaced_by": "→",
"replace": "Anstataŭigi", "replace": "Anstataŭigi",
"reject": "Rifuzi", "reject": "Rifuzi",
"ftl_removal": "Forigo de la historio de «La tuta konata reto»", "ftl_removal": "Forigo el la historio de «La tuta konata reto»",
"keyword_policies": "Politiko pri ŝlosilvortoj" "keyword_policies": "Politiko pri ŝlosilvortoj"
}, },
"federation": "Federado", "federation": "Federado",
@ -707,7 +727,8 @@
"pin": "Fiksi al profilo", "pin": "Fiksi al profilo",
"delete": "Forigi staton", "delete": "Forigi staton",
"repeats": "Ripetoj", "repeats": "Ripetoj",
"favorites": "Ŝatataj" "favorites": "Ŝatoj",
"status_deleted": "Ĉi tiu afiŝo foriĝis"
}, },
"time": { "time": {
"years_short": "{0}j", "years_short": "{0}j",
@ -769,7 +790,8 @@
"new": "Nova babilo", "new": "Nova babilo",
"chats": "Babiloj", "chats": "Babiloj",
"delete": "Forigi", "delete": "Forigi",
"you": "Vi:" "you": "Vi:",
"message_user": "Mesaĝi al {nickname}"
}, },
"password_reset": { "password_reset": {
"password_reset_required_but_mailer_is_disabled": "Vi devas restarigi vian pasvorton, sed restarigado de pasvortoj estas malŝaltita. Bonvolu kontakti la administranton de via nodo.", "password_reset_required_but_mailer_is_disabled": "Vi devas restarigi vian pasvorton, sed restarigado de pasvortoj estas malŝaltita. Bonvolu kontakti la administranton de via nodo.",
@ -791,5 +813,8 @@
"additional_comments": "Aldonaj komentoj", "additional_comments": "Aldonaj komentoj",
"add_comment_description": "Ĉi tiu raporto sendiĝos al reguligistoj de via nodo. Vi povas komprenigi kial vi raportas ĉi tiun konton sube:", "add_comment_description": "Ĉi tiu raporto sendiĝos al reguligistoj de via nodo. Vi povas komprenigi kial vi raportas ĉi tiun konton sube:",
"title": "Raportante {0}" "title": "Raportante {0}"
},
"shoutbox": {
"title": "Kriujo"
} }
} }

View file

@ -103,7 +103,7 @@
"repeated_you": "转发了你的状态", "repeated_you": "转发了你的状态",
"no_more_notifications": "没有更多的通知", "no_more_notifications": "没有更多的通知",
"reacted_with": "作出了 {0} 的反应", "reacted_with": "作出了 {0} 的反应",
"migrated_to": "迁移到", "migrated_to": "迁移到",
"follow_request": "想要关注你" "follow_request": "想要关注你"
}, },
"polls": { "polls": {
@ -165,7 +165,7 @@
"registration": { "registration": {
"bio": "简介", "bio": "简介",
"email": "电子邮箱", "email": "电子邮箱",
"fullname": "全名", "fullname": "显示名称",
"password_confirm": "确认密码", "password_confirm": "确认密码",
"registration": "注册", "registration": "注册",
"token": "邀请码", "token": "邀请码",
@ -322,7 +322,7 @@
"search_user_to_mute": "搜索你想要隐藏的用户", "search_user_to_mute": "搜索你想要隐藏的用户",
"security_tab": "安全", "security_tab": "安全",
"scope_copy": "回复时的复制范围(私信是总是复制的)", "scope_copy": "回复时的复制范围(私信是总是复制的)",
"minimal_scopes_mode": "最小发文范围", "minimal_scopes_mode": "使发文可见范围的选项最少化",
"set_new_avatar": "设置新头像", "set_new_avatar": "设置新头像",
"set_new_profile_background": "设置新的个人资料背景", "set_new_profile_background": "设置新的个人资料背景",
"set_new_profile_banner": "设置新的横幅图片", "set_new_profile_banner": "设置新的横幅图片",

View file

@ -680,7 +680,7 @@
"fullname": "顯示名稱", "fullname": "顯示名稱",
"bio_placeholder": "例如:\n你好我是玲音。\n我是一個住在日本郊區的動畫少女。你可能在 Wired 見過我。", "bio_placeholder": "例如:\n你好我是玲音。\n我是一個住在日本郊區的動畫少女。你可能在 Wired 見過我。",
"fullname_placeholder": "例如:岩倉玲音", "fullname_placeholder": "例如:岩倉玲音",
"username_placeholder": "例如:玲音", "username_placeholder": "例如:lain",
"new_captcha": "點擊圖片獲取新的驗證碼", "new_captcha": "點擊圖片獲取新的驗證碼",
"captcha": "CAPTCHA", "captcha": "CAPTCHA",
"token": "邀請碼", "token": "邀請碼",

View file

@ -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') {
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
setTimeout(() => {
dispatch('addChatMessages', { dispatch('addChatMessages', {
chatId: message.chatUpdate.id, chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage] messages: [message.chatUpdate.lastMessage]
}) })
dispatch('updateChat', { chat: message.chatUpdate }) dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate) maybeShowChatNotification(store, message.chatUpdate)
}, 100)
} }
} }
) )

View file

@ -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 })
if (isNewMessage) {
rootState.api.backendInteractor.readChat({ id, lastReadId }) 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)
} }
} }
} }

View file

@ -27,9 +27,10 @@ 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,
minimalScopesMode: false, minimalScopesMode: false,
nsfwCensorImage: undefined, nsfwCensorImage: undefined,
postContentType: 'text/plain', postContentType: 'text/plain',

View file

@ -130,7 +130,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))
})
)
}) })
} }
@ -1225,7 +1229,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
} }
@ -1234,11 +1238,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
}) })
} }

View file

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

View file

@ -3,7 +3,7 @@ import { showDesktopNotification } from '../desktop_notification_utils/desktop_n
export const maybeShowChatNotification = (store, chat) => { export const maybeShowChatNotification = (store, chat) => {
if (!chat.lastMessage) return if (!chat.lastMessage) return
if (store.rootState.chats.currentChatId === chat.id && !document.hidden) return if (store.rootState.chats.currentChatId === chat.id && !document.hidden) return
if (store.rootState.users.currentUser.id === chat.lastMessage.account.id) return if (store.rootState.users.currentUser.id === chat.lastMessage.account_id) return
const opts = { const opts = {
tag: chat.lastMessage.id, tag: chat.lastMessage.id,
@ -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
}

View file

@ -53,7 +53,7 @@ export const parseUser = (data) => {
output.fields = data.fields output.fields = data.fields
output.fields_html = data.fields.map(field => { output.fields_html = data.fields.map(field => {
return { return {
name: addEmojis(field.name, data.emojis), name: addEmojis(escape(field.name), data.emojis),
value: addEmojis(field.value, data.emojis) value: addEmojis(field.value, data.emojis)
} }
}) })
@ -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
} }

View file

@ -10,7 +10,14 @@ export const promiseInterval = (promiseCall, interval) => {
let timeout = null let timeout = null
const func = () => { const func = () => {
promiseCall().finally(() => { const promise = promiseCall()
// something unexpected happened and promiseCall did not
// return a promise, abort the loop.
if (!(promise && promise.finally)) {
console.warn('promiseInterval: promise call did not return a promise, stopping interval.')
return
}
promise.finally(() => {
if (stopped) return if (stopped) return
timeout = window.setTimeout(func, interval) timeout = window.setTimeout(func, interval)
}) })

View file

@ -10,9 +10,10 @@
"hideSitename": false, "hideSitename": false,
"hideUserStats": false, "hideUserStats": false,
"loginMethod": "password", "loginMethod": "password",
"logo": "/static/logo.png", "logo": "/static/logo.svg",
"logoMargin": ".1em", "logoMargin": ".1em",
"logoMask": true, "logoMask": true,
"logoLeft": false,
"minimalScopesMode": false, "minimalScopesMode": false,
"nsfwCensorImage": "", "nsfwCensorImage": "",
"postContentType": "text/plain", "postContentType": "text/plain",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

71
static/logo.svg Normal file
View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg4485"
width="512"
height="512"
viewBox="0 0 512 512"
sodipodi:docname="logo.svg"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
<metadata
id="metadata4491">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4489" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1274"
inkscape:window-height="1410"
id="namedview4487"
showgrid="false"
inkscape:zoom="1.2636719"
inkscape:cx="305.99333"
inkscape:cy="304.30809"
inkscape:window-x="1280"
inkscape:window-y="22"
inkscape:window-maximized="0"
inkscape:current-layer="g4612"
inkscape:document-rotation="0" />
<g
id="g4612">
<g
id="g850"
transform="matrix(0.99659595,0,0,0.99659595,0.37313949,0.87143746)">
<path
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#009bff;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.175879"
d="m 194.75841,124.65165 a 20.449443,20.449443 0 0 0 -20.44944,20.44945 v 242.24725 h 65.28091 v -262.6967 z"
id="path4497" />
<path
style="fill:#fba457;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 272.6236,124.65165 V 256 h 45.61799 a 20.449443,20.449443 0 0 0 20.44944,-20.44945 v -110.8989 z"
id="path4516" />
<path
style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 272.6236,322.06744 v 65.28091 h 45.61799 a 20.449443,20.449443 0 0 0 20.44944,-20.44945 v -44.83146 z"
id="path4516-5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -42,4 +42,4 @@
<p>So I guess yeah, that's about it. Try to be nice, eh? We're probably all sad here.</p> <p>So I guess yeah, that's about it. Try to be nice, eh? We're probably all sad here.</p>
<br> <br>
<img src="/static/logo.png" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" /> <img src="/static/logo.svg" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" />

View file

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