resolve merge conflicts

This commit is contained in:
Absturztaube 2020-11-08 14:04:17 +01:00
commit 8065afc4d7
49 changed files with 1018 additions and 605 deletions

View file

@ -6,16 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- 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 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 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.
### Added
- Import/export a muted users
- Proper handling of deletes when using websocket streaming
### 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.1] - 2020-09-08
### Changed

View file

@ -1,7 +1,6 @@
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.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 FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
@ -12,6 +11,7 @@ import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.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 PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
@ -23,7 +23,6 @@ export default {
UserPanel,
NavPanel,
Notifications,
SearchBar,
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
@ -33,22 +32,14 @@ export default {
SideDrawer,
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal,
UserReportingModal,
PostStatusModal,
GlobalNoticeList
},
data: () => ({
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')
),
transitionName: 'fade'
mobileActivePanel: 'timeline'
}),
created () {
// Load the locale from the storage
@ -64,28 +55,6 @@ export default {
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 () {
return {
'background-image': `url(${this.background})`
@ -96,9 +65,7 @@ export default {
'--body-background-image': `url(${this.background})`
}
},
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
hideSitename () { return this.$store.state.instance.hideSitename },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
@ -123,19 +90,6 @@ export default {
}
},
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 () {
const mobileLayout = windowWidth() <= 800
const layoutHeight = windowHeight()

View file

@ -359,119 +359,10 @@ i[class*=icon-], .svg-inline--fa {
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 {
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 {
flex: 1;
}
@ -712,19 +603,24 @@ nav {
flex-grow: 0;
}
}
.badge {
box-sizing: border-box;
display: inline-block;
border-radius: 99px;
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
font-size: 15px;
line-height: 22px;
text-align: center;
max-width: 10em;
min-width: 1.7em;
height: 1.3em;
padding: 0.15em 0.15em;
vertical-align: middle;
font-weight: normal;
font-style: normal;
font-size: 0.9em;
line-height: 1;
text-align: center;
white-space: nowrap;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
&.badge-notification {
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 {
padding: .5em;
border: 1px solid $fallback--faint;
@ -943,22 +829,6 @@ nav {
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 {
// Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
overflow: hidden;

View file

@ -9,80 +9,7 @@
:style="bgStyle"
/>
<MobileNav v-if="isMobileLayout" />
<nav
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>
<DesktopNav v-else />
<div class="app-bg-wrapper app-container-wrapper" />
<div
id="content"

View file

@ -130,6 +130,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
? 0
: config.logoMargin
})
copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod)
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 chatService from '../../services/chat_service/chat_service.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 {
faChevronDown,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
library.add(
faChevronDown,
@ -21,6 +22,8 @@ library.add(
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const Chat = {
components: {
@ -34,7 +37,8 @@ const Chat = {
hoveredMessageChainId: undefined,
lastScrollPosition: {},
scrollableContainerHeight: '100%',
errorLoadingChat: false
errorLoadingChat: false,
messageRetriers: {}
}
},
created () {
@ -104,7 +108,7 @@ const Chat = {
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown({ forceRead: !document.hidden })
this.scrollDown()
}
})
},
@ -210,7 +214,7 @@ const Chat = {
this.$nextTick(() => {
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
})
if (forceRead || this.newMessageCount > 0) {
if (forceRead) {
this.readChat()
}
},
@ -218,7 +222,10 @@ const Chat = {
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
if (document.hidden) { return }
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) {
return isBottomedOut(this.$refs.scrollable, offset)
@ -235,12 +242,18 @@ const Chat = {
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
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 {
this.jumpToBottomButtonVisible = true
}
}, 100),
}, 200),
handleScrollUp (positionBeforeLoading) {
const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
this.$refs.scrollable.scrollTo({
@ -274,6 +287,14 @@ const Chat = {
if (isFirstFetch) {
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,42 +323,74 @@ const Chat = {
})
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 = {
id: this.currentChat.id,
content: status
content: status,
idempotencyKey
}
if (media[0]) {
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 => {
this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id,
messages: [data],
updateMaxId: false
}).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 })
})
updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }]
})
return data
})
.catch(error => {
console.error('Error sending message', error)
return {
error: this.$t('chats.error_sending_message')
this.$store.dispatch('handleMessageError', {
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 () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })

View file

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

View file

@ -80,6 +80,7 @@
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"
:optimistic-posting="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"

View file

@ -24,3 +24,10 @@ export const isBottomedOut = (el, offset = 0) => {
export const scrollableContainerHeight = (inner, header, footer) => {
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 class="heading-right" />
<div class="time-wrapper">
<Timeago
:time="chat.updated_at"
:auto-update="60"
/>
</div>
</div>
<div class="chat-preview">
<StatusContent
@ -35,12 +41,6 @@
</div>
</div>
</div>
<div class="time-wrapper">
<Timeago
:time="chat.updated_at"
:auto-update="60"
/>
</div>
</div>
</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 {
a {
color: var(--chatMessageIncomingLink, $fallback--link);

View file

@ -32,7 +32,7 @@
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment }"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
style="position: relative"
@mouseenter="hovered = true"
@mouseleave="hovered = false"

View file

@ -23,7 +23,9 @@
.go-back-button {
cursor: pointer;
margin-right: 1.7em;
margin-left: 0.3em;
width: 28px;
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"
>
<FAIcon
size="md"
fixed-width
icon="eye-slash"
/><span>{{ $t("status.mute_conversation") }}</span>
@ -27,7 +26,6 @@
@click.prevent="unmuteConversation"
>
<FAIcon
size="md"
fixed-width
icon="eye-slash"
/><span>{{ $t("status.unmute_conversation") }}</span>
@ -39,7 +37,6 @@
@click="close"
>
<FAIcon
size="md"
fixed-width
icon="thumbtack"
/><span>{{ $t("status.pin") }}</span>
@ -51,7 +48,6 @@
@click="close"
>
<FAIcon
size="md"
fixed-width
icon="thumbtack"
/><span>{{ $t("status.unpin") }}</span>
@ -63,7 +59,6 @@
@click="close"
>
<FAIcon
size="md"
fixed-width
:icon="['far', 'bookmark']"
/><span>{{ $t("status.bookmark") }}</span>
@ -75,7 +70,6 @@
@click="close"
>
<FAIcon
size="md"
fixed-width
icon="bookmark"
/><span>{{ $t("status.unbookmark") }}</span>
@ -87,7 +81,6 @@
@click="close"
>
<FAIcon
size="md"
fixed-width
icon="times"
/><span>{{ $t("status.delete") }}</span>
@ -98,7 +91,6 @@
@click="close"
>
<FAIcon
size="md"
fixed-width
icon="share-alt"
/><span>{{ $t("status.copy_link") }}</span>
@ -109,7 +101,6 @@
<FAIcon
class="ExtraButtons fa-scale-110 fa-old-padding"
icon="ellipsis-h"
size="md"
/>
</span>
</Popover>

View file

@ -1,55 +1,53 @@
<template>
<div>
<div
class="MobileNav"
>
<nav
id="nav"
class="nav-bar container"
class="mobile-nav"
:class="{ 'mobile-hidden': isChat }"
@click="scrollToTop()"
>
<div
class="mobile-inner-nav"
@click="scrollToTop()"
>
<div class="item">
<a
href="#"
class="mobile-nav-button"
@click.stop.prevent="toggleMobileSidebar()"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="bars"
/>
<div
v-if="unreadChatCount"
class="alert-dot"
/>
</a>
<router-link
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<div class="item right">
<a
v-if="currentUser"
class="mobile-nav-button"
href="#"
@click.stop.prevent="openMobileNotifications()"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="bell"
/>
<div
v-if="unseenNotificationsCount"
class="alert-dot"
/>
</a>
</div>
<div class="item">
<a
href="#"
class="mobile-nav-button"
@click.stop.prevent="toggleMobileSidebar()"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="bars"
/>
<div
v-if="unreadChatCount"
class="alert-dot"
/>
</a>
<router-link
v-if="!hideSitename"
class="site-name"
:to="{ name: 'root' }"
active-class="home"
>
{{ sitename }}
</router-link>
</div>
<div class="item right">
<a
v-if="currentUser"
class="mobile-nav-button"
href="#"
@click.stop.prevent="openMobileNotifications()"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="bell"
/>
<div
v-if="unseenNotificationsCount"
class="alert-dot"
/>
</a>
</div>
</nav>
<div
@ -93,100 +91,124 @@
<style lang="scss">
@import '../../_variables.scss';
.mobile-inner-nav {
width: 100%;
display: flex;
align-items: center;
}
.mobile-nav-button {
text-align: center;
margin: 0 1em;
position: relative;
cursor: pointer;
}
.alert-dot {
border-radius: 100%;
height: 8px;
width: 8px;
position: absolute;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
}
.mobile-notifications-drawer {
width: 100%;
height: 100vh;
overflow-x: hidden;
position: fixed;
top: 0;
left: 0;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
transition-property: transform;
transition-duration: 0.25s;
transform: translateX(0);
z-index: 1001;
-webkit-overflow-scrolling: touch;
&.closed {
transform: translateX(100%);
.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-notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1;
width: 100%;
height: 50px;
line-height: 50px;
position: absolute;
color: var(--topBarText);
background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
.title {
font-size: 1.3em;
margin-left: 0.6em;
.mobile-inner-nav {
width: 100%;
display: flex;
align-items: center;
}
}
.mobile-notifications {
margin-top: 50px;
width: 100vw;
height: calc(100vh - 50px);
overflow-x: hidden;
overflow-y: scroll;
.mobile-nav-button {
display: inline-block;
text-align: center;
padding: 0 1em;
position: relative;
cursor: pointer;
}
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
.site-name {
padding: 0 .3em;
display: inline-block;
}
.notifications {
padding: 0;
border-radius: 0;
box-shadow: none;
.panel {
border-radius: 0;
margin: 0;
box-shadow: none;
.item {
/* moslty just to get rid of extra whitespaces */
display: flex;
}
.alert-dot {
border-radius: 100%;
height: 8px;
width: 8px;
position: absolute;
left: calc(50% - 4px);
top: calc(50% - 4px);
margin-left: 6px;
margin-top: -6px;
background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed);
}
.mobile-notifications-drawer {
width: 100%;
height: 100vh;
overflow-x: hidden;
position: fixed;
top: 0;
left: 0;
box-shadow: 1px 1px 4px rgba(0,0,0,.6);
box-shadow: var(--panelShadow);
transition-property: transform;
transition-duration: 0.25s;
transform: translateX(0);
z-index: 1001;
-webkit-overflow-scrolling: touch;
&.closed {
transform: translateX(100%);
}
.panel:after {
border-radius: 0;
}
.mobile-notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1;
width: 100%;
height: 50px;
line-height: 50px;
position: absolute;
color: var(--topBarText);
background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg);
box-shadow: 0px 0px 4px rgba(0,0,0,.6);
box-shadow: var(--topBarShadow);
.title {
font-size: 1.3em;
margin-left: 0.6em;
}
.panel .panel-heading {
}
.mobile-notifications {
margin-top: 50px;
width: 100vw;
height: calc(100vh - 50px);
overflow-x: hidden;
overflow-y: scroll;
color: $fallback--text;
color: var(--text, $fallback--text);
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
.notifications {
padding: 0;
border-radius: 0;
box-shadow: none;
.panel {
border-radius: 0;
margin: 0;
box-shadow: none;
}
.panel:after {
border-radius: 0;
}
.panel .panel-heading {
border-radius: 0;
box-shadow: none;
}
}
}
}

View file

@ -27,7 +27,7 @@
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
<div
v-if="unreadChatCount"
class="badge badge-notification unread-chat-count"
class="badge badge-notification"
>
{{ unreadChatCount }}
</div>
@ -47,7 +47,7 @@
/>{{ $t("nav.friend_requests") }}
<span
v-if="followRequestCount > 0"
class="badge follow-request-count"
class="badge badge-notification"
>
{{ followRequestCount }}
</span>
@ -84,13 +84,8 @@
padding: 0;
}
.follow-request-count {
vertical-align: baseline;
background-color: $fallback--bg;
background-color: var(--input, $fallback--faint);
}
li {
position: relative;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
@ -154,5 +149,11 @@
.fa-scale-110 {
margin-right: 0.8em;
}
.badge {
position: absolute;
right: 0.6rem;
top: 1.25em;
}
}
</style>

View file

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

View file

@ -75,7 +75,8 @@ const PostStatusForm = {
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement'
'emojiPickerPlacement',
'optimisticPosting'
],
components: {
MediaUpload,
@ -272,7 +273,7 @@ const PostStatusForm = {
if (this.preview) this.previewStatus()
},
async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return }
if (this.posting && !this.optimisticPosting) { return }
if (this.disableSubmit) { return }
if (this.emojiInputShown) { return }
if (this.submitOnEnter) {
@ -280,6 +281,8 @@ const PostStatusForm = {
event.preventDefault()
}
if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
if (this.emptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return

View file

@ -85,11 +85,16 @@
{{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a>
<FAIcon
<div
v-show="previewLoading"
spin
icon="circle-notch"
/>
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</div>
</div>
<div
v-if="showPreview"
@ -124,7 +129,7 @@
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting"
:disabled="posting && !optimisticPosting"
size="1"
class="form-post-subject"
>
@ -150,7 +155,7 @@
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting"
:disabled="posting && !optimisticPosting"
class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@ -383,12 +388,12 @@
}
.preview-heading {
padding-left: 0.5em;
display: flex;
width: 100%;
padding-left: 0.5em;
}
.preview-toggle {
flex: 1;
cursor: pointer;
user-select: none;

View file

@ -12,6 +12,7 @@
<div class="reaction-picker-filter">
<input
v-model="filterWord"
size="1"
:placeholder="$t('emoji.search_emoji')"
>
</div>

View file

@ -1,46 +1,47 @@
<template>
<div>
<div class="search-bar-container">
<a
v-if="hidden"
href="#"
class="nav-icon"
:title="$t('nav.search')"
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="search"
@click.prevent.stop="toggleHidden"
/></a>
<template v-else>
<input
id="search-bar-input"
ref="searchInput"
v-model="searchTerm"
class="search-bar-input"
:placeholder="$t('nav.search')"
type="text"
@keyup.enter="find(searchTerm)"
>
<button
class="btn search-button"
@click="find(searchTerm)"
>
<FAIcon
fixed-width
icon="search"
/>
</button>
<span>
<FAIcon
fixed-width
icon="times"
class="cancel-icon fa-scale-110 fa-old-padding"
@click.prevent.stop="toggleHidden"
/>
</span>
</template>
</div>
<div
class="SearchBar"
:class="{ '-expanded': !hidden }"
>
<a
v-if="hidden"
href="#"
class="nav-icon"
:title="$t('nav.search')"
><FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="search"
@click.prevent.stop="toggleHidden"
/></a>
<template v-else>
<input
id="search-bar-input"
ref="searchInput"
v-model="searchTerm"
class="search-bar-input"
:placeholder="$t('nav.search')"
type="text"
@keyup.enter="find(searchTerm)"
>
<button
class="btn search-button"
@click="find(searchTerm)"
>
<FAIcon
fixed-width
icon="search"
/>
</button>
<span>
<FAIcon
fixed-width
icon="times"
class="cancel-icon fa-scale-110 fa-old-padding"
@click.prevent.stop="toggleHidden"
/>
</span>
</template>
</div>
</template>
@ -49,21 +50,23 @@
<style lang="scss">
@import '../../_variables.scss';
.search-bar-container {
max-width: 100%;
.SearchBar {
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
justify-content: flex-end;
&.-expanded {
width: 100%;
}
.search-bar-input,
.search-button {
height: 29px;
}
.search-bar-input {
// TODO: do this properly without a rough guesstimate of 2 icons + paddings
max-width: calc(100% - 30px - 30px - 20px);
flex: 1 0 auto;
}
.cancel-icon {

View file

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

View file

@ -70,7 +70,7 @@
/> {{ $t("nav.chats") }}
<span
v-if="unreadChatCount"
class="badge badge-notification unread-chat-count"
class="badge badge-notification"
>
{{ unreadChatCount }}
</span>
@ -99,7 +99,7 @@
/> {{ $t("nav.friend_requests") }}
<span
v-if="followRequestCount > 0"
class="badge follow-request-count"
class="badge badge-notification"
>
{{ followRequestCount }}
</span>
@ -272,12 +272,11 @@
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
.follow-request-count {
vertical-align: baseline;
background-color: $fallback--bg;
background-color: var(--input, $fallback--faint);
.badge {
position: absolute;
right: 0.7rem;
top: 1em;
}
}
.side-drawer-logo-wrapper {

View file

@ -7,8 +7,9 @@ $status-margin: 0.75em;
min-width: 0;
&:hover {
--still-image-img: visible;
--still-image-canvas: hidden;
--_still-image-img-visibility: visible;
--_still-image-canvas-visibility: hidden;
--_still-image-label-visibility: hidden;
}
&.-focused {
@ -58,6 +59,15 @@ $status-margin: 0.75em;
justify-content: flex-end;
}
._misclick-prevention & {
pointer-events: none;
.attachments {
pointer-events: initial;
cursor: initial;
}
}
.left-side {
margin-right: $status-margin;
}

View file

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

View file

@ -4,8 +4,7 @@
display: flex;
.tab-icon {
width: 100%;
margin: 0.2em 0;
margin: 0.2em auto;
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 Conversation from '../conversation/conversation.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 { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
@ -40,7 +40,8 @@ const Timeline = {
paused: false,
unfocused: false,
bottomedOut: false,
virtualScrollIndex: 0
virtualScrollIndex: 0,
blockingClicks: false
}
},
components: {
@ -70,8 +71,10 @@ const Timeline = {
}
},
classes () {
let rootClasses = !this.embedded ? ['panel', 'panel-default'] : []
if (this.blockingClicks) rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: ['timeline'].concat(!this.embedded ? ['panel', 'panel-default'] : []),
root: rootClasses,
header: ['timeline-heading'].concat(!this.embedded ? ['panel-heading'] : []),
body: ['timeline-body'].concat(!this.embedded ? ['panel-body'] : []),
footer: ['timeline-footer'].concat(!this.embedded ? ['panel-footer'] : [])
@ -130,6 +133,15 @@ const Timeline = {
this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
},
methods: {
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily () {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey (e) {
// Ignore when input fields are focused
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.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false
}

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
"features_panel": {
"chat": "Babilejo",
"gopher": "Gopher",
"media_proxy": "Vidaŭdaĵa prokurilo",
"media_proxy": "Vidaŭdaĵa retperilo",
"scope_options": "Agordoj de amplekso",
"text_limit": "Limo de teksto",
"title": "Funkcioj",
@ -33,7 +33,8 @@
"show_more": "Montri plion",
"retry": "Reprovi",
"error_retry": "Bonvolu reprovi",
"loading": "Enlegante…"
"loading": "Enlegante…",
"peek": "Antaŭmontri"
},
"image_cropper": {
"crop_picture": "Tondi bildon",
@ -70,9 +71,9 @@
"friend_requests": "Petoj pri abono",
"mentions": "Mencioj",
"dms": "Rektaj mesaĝoj",
"public_tl": "Publika tempolinio",
"timeline": "Tempolinio",
"twkn": "La tuta konata reto",
"public_tl": "Publika historio",
"timeline": "Historio",
"twkn": "Konata reto",
"user_search": "Serĉi uzantojn",
"who_to_follow": "Kiun aboni",
"preferences": "Agordoj",
@ -80,7 +81,8 @@
"search": "Serĉi",
"interactions": "Interagoj",
"administration": "Administrado",
"bookmarks": "Legosignoj"
"bookmarks": "Legosignoj",
"timelines": "Historioj"
},
"notifications": {
"broken_favorite": "Nekonata stato, serĉante ĝin…",
@ -107,14 +109,14 @@
"text/html": "HTML"
},
"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.",
"posting": "Afiŝante",
"scope": {
"direct": "Rekta Afiŝi nur al menciitaj uzantoj",
"private": "Nur abonantoj Afiŝi nur al abonantoj",
"public": "Publika Afiŝi al publikaj tempolinioj",
"unlisted": "Nelistigita Ne afiŝi al publikaj tempolinioj"
"public": "Publika Afiŝi al publikaj historioj",
"unlisted": "Nelistigita Ne afiŝi al publikaj historioj"
},
"scope_notice": {
"unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto",
@ -193,7 +195,7 @@
"foreground": "Malfono",
"general": "Ĝenerala",
"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",
"hide_isp": "Kaŝi breton propran al nodo",
"preload_images": "Antaŭ-enlegi bildojn",
@ -246,7 +248,7 @@
"profile_banner": "Rubando de profilo",
"profile_tab": "Profilo",
"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_following": "Montri nur respondojn por mi aŭ miaj abonatoj",
"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.",
"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.",
"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_snapshot": "Malnova versio",
@ -352,10 +359,11 @@
"icons": "Bildsimboloj",
"poll": "Grafo de enketo",
"underlay": "Subtavolo",
"popover": "Ŝpruchelpiloj, menuoj",
"popover": "Ŝprucoj, menuoj",
"post": "Afiŝoj/Priskriboj de uzantoj",
"alert_neutral": "Neŭtrala",
"alert_warning": "Averto"
"alert_warning": "Averto",
"toggled": "Ŝaltita"
},
"radii": {
"_tab_label": "Rondeco"
@ -388,7 +396,8 @@
"buttonPressed": "Butono (premita)",
"buttonPressedHover": "Butono (premita kaj je ŝvebo)",
"input": "Eniga kampo"
}
},
"hintV3": "Kolorojn de ombroj vi ankaŭ povas skribi per la sistemo {0}."
},
"fonts": {
"_tab_label": "Tiparoj",
@ -411,7 +420,7 @@
"button": "Butono",
"text": "Kelko da pliaj {0} kaj {1}",
"mono": "enhavo",
"input": "Ĵus alvenis al la Universala Kongreso!",
"input": "Ĵus alvenis Esperantujon!",
"faint_link": "helpan manlibron",
"fine_print": "Legu nian {0} por nenion utilan ekscii!",
"header_faint": "Tio estas en ordo",
@ -420,7 +429,7 @@
}
},
"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",
"changed_email": "Retpoŝtadreso sukcese ŝanĝiĝis!",
"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.",
"generate_new_recovery_codes": "Estigi novajn rehavajn kodojn",
"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",
"security": "Sekureco",
@ -480,11 +492,11 @@
},
"import_blocks_from_a_csv_file": "Enporti blokitojn el CSV-dosiero",
"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",
"domain_mutes": "Retnomoj",
"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_privacy": "Privateco",
"notification_setting_block_from_strangers": "Bloki sciigojn de uzantoj, kiujn vi ne abonas",
@ -495,7 +507,14 @@
"backend_version": "Versio de internaĵo",
"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": {
"collapse": "Maletendi",
@ -503,7 +522,7 @@
"error_fetching": "Eraris ĝisdatigo",
"load_older": "Montri pli malnovajn statojn",
"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",
"up_to_date": "Ĝisdata",
"no_more_statuses": "Neniuj pliaj statoj",
@ -648,21 +667,22 @@
"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": "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": "Kvaranteno",
"reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:",
"reject": "Rifuzi",
"accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:",
"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)",
"keyword": {
"is_replaced_by": "→",
"replace": "Anstataŭigi",
"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"
},
"federation": "Federado",
@ -707,7 +727,8 @@
"pin": "Fiksi al profilo",
"delete": "Forigi staton",
"repeats": "Ripetoj",
"favorites": "Ŝatataj"
"favorites": "Ŝatoj",
"status_deleted": "Ĉi tiu afiŝo foriĝis"
},
"time": {
"years_short": "{0}j",
@ -769,7 +790,8 @@
"new": "Nova babilo",
"chats": "Babiloj",
"delete": "Forigi",
"you": "Vi:"
"you": "Vi:",
"message_user": "Mesaĝi al {nickname}"
},
"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.",
@ -791,5 +813,8 @@
"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:",
"title": "Raportante {0}"
},
"shoutbox": {
"title": "Kriujo"
}
}

View file

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

View file

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

View file

@ -75,12 +75,18 @@ const api = {
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage]
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
// 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', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage]
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
}, 100)
}
}
)

View file

@ -16,7 +16,8 @@ const defaultState = {
openedChats: {},
openedChatMessageServices: {},
fetcher: undefined,
currentChatId: null
currentChatId: null,
lastReadMessageId: null
}
const getChatById = (state, id) => {
@ -92,9 +93,14 @@ const chats = {
commit('setCurrentChatFetcher', { fetcher: undefined })
},
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
dispatch('resetChatNewMessageCount')
commit('readChat', { id })
rootState.api.backendInteractor.readChat({ id, lastReadId })
commit('readChat', { id, lastReadId })
if (isNewMessage) {
rootState.api.backendInteractor.readChat({ id, lastReadId })
}
},
deleteChatMessage ({ rootState, commit }, value) {
rootState.api.backendInteractor.deleteChatMessage(value)
@ -106,6 +112,9 @@ const chats = {
},
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
commit('clearOpenedChats', { commit })
},
handleMessageError ({ commit }, value) {
commit('handleMessageError', { commit, ...value })
}
},
mutations: {
@ -208,11 +217,16 @@ const chats = {
}
}
},
readChat (state, { id }) {
readChat (state, { id, lastReadId }) {
state.lastReadMessageId = lastReadId
const chat = getChatById(state, id)
if (chat) {
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,
hideUserStats: false,
loginMethod: 'password',
logo: '/static/logo.png',
logo: '/static/logo.svg',
logoMargin: '.2em',
logoMask: true,
logoLeft: false,
minimalScopesMode: false,
nsfwCensorImage: undefined,
postContentType: 'text/plain',

View file

@ -129,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
return reject(new StatusCodeError(response.status, json, { url, options }, response))
}
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 = {
'content': content
}
@ -1219,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
payload['media_id'] = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload: payload,
credentials
credentials,
headers
})
}

View file

@ -3,9 +3,10 @@ import _ from 'lodash'
const empty = (chatId) => {
return {
idIndex: {},
idempotencyKeyIndex: {},
messages: [],
newMessageCount: 0,
lastSeenTimestamp: 0,
lastSeenMessageId: '0',
chatId: chatId,
minId: undefined,
maxId: undefined
@ -13,10 +14,20 @@ const empty = (chatId) => {
}
const clear = (storage) => {
storage.idIndex = {}
storage.messages.splice(0, storage.messages.length)
const failedMessageIds = []
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.lastSeenTimestamp = 0
storage.lastSeenMessageId = '0'
storage.minId = 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 }) => {
if (!storage) { return }
for (let i = 0; i < newMessages.length; i++) {
@ -45,7 +75,25 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
// sanity check
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
}
@ -55,20 +103,26 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
}
}
if (!storage.idIndex[message.id]) {
if (storage.lastSeenTimestamp < message.created_at) {
if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
if (storage.lastSeenMessageId < message.id) {
storage.newMessageCount++
}
storage.messages.push(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) => {
if (!storage) { return }
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
@ -76,7 +130,7 @@ const getView = (storage) => {
if (!storage) { return [] }
const result = []
const messages = _.sortBy(storage.messages, ['id', 'desc'])
const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
const firstMessage = messages[0]
let previousMessage = messages[messages.length - 1]
let currentMessageChainId
@ -148,7 +202,8 @@ const ChatService = {
getView,
deleteMessage,
resetNewMessageCount,
clear
clear,
handleMessageError
}
export default ChatService

View file

@ -3,7 +3,7 @@ import { showDesktopNotification } from '../desktop_notification_utils/desktop_n
export const maybeShowChatNotification = (store, chat) => {
if (!chat.lastMessage) 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 = {
tag: chat.lastMessage.id,
@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => {
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

@ -430,6 +430,9 @@ export const parseChatMessage = (message) => {
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}

View file

@ -10,7 +10,14 @@ export const promiseInterval = (promiseCall, interval) => {
let timeout = null
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
timeout = window.setTimeout(func, interval)
})

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 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

@ -6,4 +6,4 @@
Pleroma install containing the real ToS for your instance.</p>
<p>See the <a href='https://docs.pleroma.social/backend/configuration/static_dir/'>Pleroma documentation</a> for more information.</p>
<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 = {
id: '9wLkdcmQXD21Oy8lEX',
idempotency_key: '1',
created_at: (new Date('2020-06-22T18:45:53.000Z'))
}
const message2 = {
id: '9wLkdp6ihaOVdNj8Wu',
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay',
created_at: (new Date('2020-06-22T18:45:56.000Z'))
}
const message3 = {
id: '9wLke9zL4Dy4OZR2RM',
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay',
created_at: (new Date('2020-07-22T18:45:59.000Z'))
}
@ -44,10 +47,10 @@ describe('chatService', () => {
chatService.resetNewMessageCount(chat)
expect(chat.newMessageCount).to.eql(0)
expect(chat.lastSeenMessageId).to.eql(message2.id)
const createdAt = new Date()
createdAt.setSeconds(createdAt.getSeconds() + 10)
chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] })
// Add message with higher id
chatService.add(chat, { messages: [ message3 ] })
expect(chat.newMessageCount).to.eql(1)
})
})