...
 
Commits (174)
...@@ -4,18 +4,37 @@ All notable changes to this project will be documented in this file. ...@@ -4,18 +4,37 @@ 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]
### Changed ### Changed
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications - Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
- Multiple issues with muted statuses/notifications
## [Unreleased patch] ## [Unreleased patch]
### Add ### Add
- Added private notifications option for push notifications - Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu) - 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances
### Changed ### Changed
- Registration page no longer requires email if the server is configured not to require it - Registration page no longer requires email if the server is configured not to require it
- Change heart to thumbs up in reaction picker
- Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files
### Fixed ### Fixed
- Custom Emoji will display in poll options now.
- Status ellipsis menu closes properly when selecting certain options - Status ellipsis menu closes properly when selecting certain options
- Cropped images look correct in Chrome
- Newlines in the muted words settings work again
- Clicking on non-latin hashtags won't open a new window
- Uploading and drag-dropping multiple files works correctly now.
- Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results
## [2.0.3] - 2020-05-02 ## [2.0.3] - 2020-05-02
### Fixed ### Fixed
......
# pleroma_fe # Pleroma-FE
> A single column frontend for both Pleroma and GS servers. > A single column frontend designed for Pleroma.
![screenshot](https://i.imgur.com/DJVqSJ0.png) ![screenshot](https://i.imgur.com/DJVqSJ0.png)
...@@ -11,7 +11,6 @@ To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git ...@@ -11,7 +11,6 @@ To translate Pleroma-FE, add your language to [src/i18n/messages.js](https://git
# FOR ADMINS # FOR ADMINS
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box. You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box.
For the GNU social backend, check out https://git.pleroma.social/pleroma/pleroma-fe/wikis/dual-boot-with-qvitter to see how to run Pleroma-FE and Qvitter at the same time.
## Build Setup ## Build Setup
......
...@@ -33,7 +33,7 @@ will become ...@@ -33,7 +33,7 @@ will become
Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours. Note that you can only use emoji defined on your instance, you cannot "copy" someone else's emoji, and will have to ask your administrator to copy emoji from other instance to yours.
Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text. Lastly, there's two convenience options for emoji: an emoji picker (smiley face to the right of "submit" button) and autocomplete suggestions - when you start typing :shortcode: it will automatically try to suggest you emoj and complete the shortcode for you if you select one. **Note** that if emoji doesn't show up in suggestions nor in emoji picker it means there's no such emoji on your instance, if shortcode doesn't match any defined emoji it will appear as text.
* **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly. * **Attachments** are fairly simple - you can attach any file to a post as long as the file is within maximum size limits. If you're uploading explicit material you can mark all of your attachments as sensitive (or add `#nsfw` tag) - it will hide the images and videos behind a warning so that it won't be displayed instantly.
* **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. As a side-effect using subject line will also mark your images as sensitive (see above). * **Subject line** also known as **CW** (Content Warning) could be used as a header to the post and/or to warn others about contents of the post having something that might upset somebody or something among those lines. Several applications allow to hide post content leaving only subject line visible. Using a subject line will not mark your images as sensitive, you will have to do that explicitly (see above).
* **Visiblity scope** controls who will be able to see your posts. There are four scopes available: * **Visiblity scope** controls who will be able to see your posts. There are four scopes available:
1. `Public`: This is the default, and some fediverse software like GNU Social only supports this. This means that your post is accessible by anyone and will be shown in the public timelines. 1. `Public`: This is the default, and some fediverse software like GNU Social only supports this. This means that your post is accessible by anyone and will be shown in the public timelines.
......
...@@ -6,6 +6,7 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance ...@@ -6,6 +6,7 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
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'
import ChatPanel from './components/chat_panel/chat_panel.vue' import ChatPanel from './components/chat_panel/chat_panel.vue'
import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue' 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'
...@@ -29,6 +30,7 @@ export default { ...@@ -29,6 +30,7 @@ export default {
SideDrawer, SideDrawer,
MobilePostStatusButton, MobilePostStatusButton,
MobileNav, MobileNav,
SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal PostStatusModal
}, },
...@@ -46,7 +48,8 @@ export default { ...@@ -46,7 +48,8 @@ export default {
}), }),
created () { created () {
// Load the locale from the storage // Load the locale from the storage
this.$i18n.locale = this.$store.getters.mergedConfig.interfaceLanguage const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState) window.addEventListener('resize', this.updateMobileState)
}, },
destroyed () { destroyed () {
...@@ -118,6 +121,9 @@ export default { ...@@ -118,6 +121,9 @@ export default {
onSearchBarToggled (hidden) { onSearchBarToggled (hidden) {
this.searchBarHidden = 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()
......
...@@ -56,6 +56,7 @@ body { ...@@ -56,6 +56,7 @@ body {
overflow-x: hidden; overflow-x: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
&.hidden { &.hidden {
display: none; display: none;
...@@ -566,7 +567,7 @@ main-router { ...@@ -566,7 +567,7 @@ main-router {
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
margin-left: .25em; margin-left: .5em;
min-width: 1px; min-width: 1px;
align-self: stretch; align-self: stretch;
} }
...@@ -860,51 +861,6 @@ nav { ...@@ -860,51 +861,6 @@ nav {
} }
} }
.setting-item {
border-bottom: 2px solid var(--fg, $fallback--fg);
margin: 1em 1em 1.4em;
padding-bottom: 1.4em;
> div {
margin-bottom: .5em;
&:last-child {
margin-bottom: 0;
}
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 1em;
}
select {
min-width: 10em;
}
textarea {
width: 100%;
max-width: 100%;
height: 100px;
}
.unavailable,
.unavailable i {
color: var(--cRed, $fallback--cRed);
color: $fallback--cRed;
}
.btn {
min-height: 28px;
min-width: 10em;
padding: 0 2em;
}
.number-input {
max-width: 6em;
}
}
.select-multiple { .select-multiple {
display: flex; display: flex;
.option-list { .option-list {
...@@ -970,24 +926,15 @@ nav { ...@@ -970,24 +926,15 @@ nav {
background-color: var(--panel, $fallback--fg); background-color: var(--panel, $fallback--fg);
} }
.alert-dot-number { .unread-chat-count {
display: inline-block;
border-radius: 1em;
min-width: 1.3rem;
min-height: 1.3rem;
max-height: 1.3rem;
font-size: 0.9em; font-size: 0.9em;
font-weight: bolder; font-weight: bolder;
line-height: 1.3rem; font-style: normal;
text-align: center;
vertical-align: middle;
white-space: nowrap;
padding: 0 0.3em;
position: absolute; position: absolute;
right: 0.6rem; right: 0.6rem;
background-color: $fallback--cRed; padding: 0 0.3em;
background-color: var(--badgeNotification, $fallback--cRed); min-width: 1.3rem;
color: white; min-height: 1.3rem;
color: var(--badgeNotificationText, white); max-height: 1.3rem;
font-style: normal; line-height: 1.3rem;
} }
...@@ -46,15 +46,16 @@ ...@@ -46,15 +46,16 @@
@toggled="onSearchBarToggled" @toggled="onSearchBarToggled"
@click.stop.native @click.stop.native
/> />
<router-link <a
href="#"
class="mobile-hidden" class="mobile-hidden"
:to="{ name: 'settings'}" @click.stop="openSettingsModal"
> >
<i <i
class="button-icon icon-cog nav-icon" class="button-icon icon-cog nav-icon"
:title="$t('nav.preferences')" :title="$t('nav.preferences')"
/> />
</router-link> </a>
<a <a
v-if="currentUser && currentUser.role === 'admin'" v-if="currentUser && currentUser.role === 'admin'"
href="/pleroma/admin/#/login-pleroma" href="/pleroma/admin/#/login-pleroma"
...@@ -126,6 +127,7 @@ ...@@ -126,6 +127,7 @@
<MobilePostStatusButton /> <MobilePostStatusButton />
<UserReportingModal /> <UserReportingModal />
<PostStatusModal /> <PostStatusModal />
<SettingsModal />
<portal-target name="modal" /> <portal-target name="modal" />
</div> </div>
</template> </template>
......
...@@ -202,6 +202,7 @@ const getNodeInfo = async ({ store }) => { ...@@ -202,6 +202,7 @@ const getNodeInfo = async ({ store }) => {
const features = metadata.features const features = metadata.features
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
......
...@@ -9,10 +9,8 @@ import ChatList from 'components/chat_list/chat_list.vue' ...@@ -9,10 +9,8 @@ import ChatList from 'components/chat_list/chat_list.vue'
import Chat from 'components/chat/chat.vue' import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue' import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue' import Search from 'components/search/search.vue'
import Settings from 'components/settings/settings.vue'
import Registration from 'components/registration/registration.vue' import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue' import PasswordReset from 'components/password_reset/password_reset.vue'
import UserSettings from 'components/user_settings/user_settings.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue' import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue' import Notifications from 'components/notifications/notifications.vue'
...@@ -31,7 +29,7 @@ export default (store) => { ...@@ -31,7 +29,7 @@ export default (store) => {
} }
} }
return [ let routes = [
{ name: 'root', { name: 'root',
path: '/', path: '/',
redirect: _to => { redirect: _to => {
...@@ -58,21 +56,26 @@ export default (store) => { ...@@ -58,21 +56,26 @@ export default (store) => {
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile }, { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute }, { name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute }, { name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration }, { name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true }, { name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration }, { name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'user-settings', path: '/user-settings', component: UserSettings, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm }, { name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile } { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) {
routes = routes.concat([
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
])
}
return routes
} }
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
...@@ -34,6 +35,11 @@ const AccountActions = { ...@@ -34,6 +35,11 @@ const AccountActions = {
params: { recipient_id: this.user.id } params: { recipient_id: this.user.id }
}) })
} }
},
computed: {
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
} }
} }
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
<Popover <Popover
trigger="click" trigger="click"
placement="bottom" placement="bottom"
:bound-to="{ x: 'container' }"
> >
<div <div
slot="content" slot="content"
...@@ -50,6 +51,7 @@ ...@@ -50,6 +51,7 @@
{{ $t('user_card.report') }} {{ $t('user_card.report') }}
</button> </button>
<button <button
v-if="pleromaChatMessagesAvailable"
class="btn btn-default btn-block dropdown-item" class="btn btn-default btn-block dropdown-item"
@click="openChat" @click="openChat"
> >
......
<template>
<div class="async-component-error">
<div>
<h4>
{{ $t('general.generic_error') }}
</h4>
<p>
{{ $t('general.error_retry') }}
</p>
<button
class="btn"
@click="retry"
>
{{ $t('general.retry') }}
</button>
</div>
</div>
</template>
<script>
export default {
methods: {
retry () {
this.$emit('resetAsyncComponent')
}
}
}
</script>
<style lang="scss">
.async-component-error {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
.btn {
margin: .5em;
padding: .5em 2em;
}
}
</style>
...@@ -27,8 +27,7 @@ const Attachment = { ...@@ -27,8 +27,7 @@ const Attachment = {
}, },
components: { components: {
StillImage, StillImage,
VideoAttachment, VideoAttachment
AudioPlayer: () => import('../audio_player/audio_player.vue')
}, },
computed: { computed: {
usePlaceHolder () { usePlaceHolder () {
......
...@@ -81,16 +81,11 @@ ...@@ -81,16 +81,11 @@
</a> </a>
<audio <audio
v-if="type === 'audio' && !mergedConfig.useWavesurfer" v-if="type === 'audio'"
:src="attachment.url" :src="attachment.url"
controls controls
/> />
<audio-player
v-if="type === 'audio' && mergedConfig.useWavesurfer"
:src="attachment.url"
/>
<div <div
v-if="type === 'html' && attachment.oembed" v-if="type === 'html' && attachment.oembed"
class="oembed" class="oembed"
......
import _ from 'lodash' import _ from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import ChatMessage from '../chat_message/chat_message.vue' import ChatMessage from '../chat_message/chat_message.vue'
import ChatAvatar from '../chat_avatar/chat_avatar.vue' import ChatAvatar from '../chat_avatar/chat_avatar.vue'
...@@ -6,6 +7,10 @@ import PostStatusForm from '../post_status_form/post_status_form.vue' ...@@ -6,6 +7,10 @@ import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue' import ChatTitle from '../chat_title/chat_title.vue'
import chatService from '../../services/chat_service/chat_service.js' import chatService from '../../services/chat_service/chat_service.js'
import ChatLayout from './chat_layout.js' import ChatLayout from './chat_layout.js'
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const Chat = { const Chat = {
components: { components: {
...@@ -17,16 +22,11 @@ const Chat = { ...@@ -17,16 +22,11 @@ const Chat = {
mixins: [ChatLayout], mixins: [ChatLayout],
data () { data () {
return { return {
loadingOlderMessages: false,
loadingMessages: true,
loadingChat: false,
editedStatusId: undefined,
fetcher: undefined,
jumpToBottomButtonVisible: false, jumpToBottomButtonVisible: false,
mobileLayout: this.$store.state.interface.mobileLayout, hoveredMessageChainId: undefined,
recipientId: this.$route.params.recipient_id, scrollPositionBeforeResize: {},
hoveredSequenceId: undefined, scrollableContainerHeight: '100%',
lastPosition: undefined errorLoadingChat: false
} }
}, },
created () { created () {
...@@ -34,20 +34,16 @@ const Chat = { ...@@ -34,20 +34,16 @@ const Chat = {
window.addEventListener('resize', this.handleLayoutChange) window.addEventListener('resize', this.handleLayoutChange)
}, },
mounted () { mounted () {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
}
this.$nextTick(() => { this.$nextTick(() => {
let scrollable = this.$refs.scrollable this.updateScrollableContainerHeight()
if (scrollable) {
window.addEventListener('scroll', this.handleScroll)
}
this.updateSize()
this.handleResize() this.handleResize()
}) })
this.setChatLayout() this.setChatLayout()
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.commit('setChatFocused', !document.hidden)
}
}, },
destroyed () { destroyed () {
window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('scroll', this.handleScroll)
...@@ -57,26 +53,17 @@ const Chat = { ...@@ -57,26 +53,17 @@ const Chat = {
this.$store.dispatch('clearCurrentChat') this.$store.dispatch('clearCurrentChat')
}, },
computed: { computed: {
chatParticipants () {
if (this.currentChat) {
return [this.currentChat.account]
} else {
const user = this.findUser(this.recipientId)
if (user) {
return [user]
} else {
return []
}
}
},
recipient () { recipient () {
return this.currentChat && this.currentChat.account return this.currentChat && this.currentChat.account
}, },
recipientId () {
return this.$route.params.recipient_id
},
formPlaceholder () { formPlaceholder () {
if (this.recipient) { if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name }) return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
} else { } else {
return this.$t('chats.write_message') return ''
} }
}, },
chatViewItems () { chatViewItems () {
...@@ -85,155 +72,121 @@ const Chat = { ...@@ -85,155 +72,121 @@ const Chat = {
newMessageCount () { newMessageCount () {
return this.currentChatMessageService && this.currentChatMessageService.newMessageCount return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
}, },
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
...mapGetters([ ...mapGetters([
'currentChat', 'currentChat',
'currentChatMessageService', 'currentChatMessageService',
'findUser',
'findOpenedChatByRecipientId', 'findOpenedChatByRecipientId',
'mergedConfig' 'mergedConfig'
]), ]),
...mapState({ ...mapState({
backendInteractor: state => state.api.backendInteractor, backendInteractor: state => state.api.backendInteractor,
currentUser: state => state.users.currentUser, mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
isMobileLayout: state => state.interface.mobileLayout, mobileLayout: state => state.interface.mobileLayout,
openedChats: state => state.chats.openedChats, layoutHeight: state => state.interface.layoutHeight,
openedChatMessageServices: state => state.chats.openedChatMessageServices, currentUser: state => state.users.currentUser
windowHeight: state => state.interface.layoutHeight
}) })
}, },
watch: { watch: {
chatViewItems (prev, next) { chatViewItems () {
let bottomedOut = this.bottomedOut(10) // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => { this.$nextTick(() => {
if (bottomedOut && prev.length !== next.length) { if (bottomedOutBeforeUpdate) {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: !document.hidden })
} }
}) })
}, },
'$route': function (prev, next) { '$route': function () {
this.recipientId = this.$route.params.recipient_id
this.startFetching() this.startFetching()
}, },
windowHeight () { layoutHeight () {
this.handleResize({ expand: true }) this.handleResize({ expand: true })
},
mastoUserSocketStatus (newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
} }
}, },
methods: { methods: {
onStatusHover ({ state, sequenceId }) { // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
this.hoveredSequenceId = state ? sequenceId : undefined onMessageHover ({ isHovered, messageChainId }) {
}, this.hoveredMessageChainId = isHovered ? messageChainId : undefined
onPosted (data) {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => {
this.updateSize()
this.scrollDown({ forceRead: true })
})
})
}, },
onFilesDropped () { onFilesDropped () {
this.$nextTick(() => { this.$nextTick(() => {
this.updateSize() this.updateScrollableContainerHeight()
}) })
}, },
handleVisibilityChange () { handleVisibilityChange () {
this.$store.commit('setChatFocused', !document.hidden) this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
}, },
handleLayoutChange () { handleLayoutChange () {
this.updateSize() this.updateScrollableContainerHeight()
let mobileLayout = this.isMobileLayout if (this.mobileLayout) {
if (this.mobileLayout !== mobileLayout) { this.setMobileChatLayout()
if (this.mobileLayout === false && mobileLayout === true) { } else {
this.setMobileChatLayout() this.unsetMobileChatLayout()
}
if (this.mobileLayout === true && mobileLayout === false) {
this.unsetMobileChatLayout()
}
this.mobileLayout = this.isMobileLayout
this.$nextTick(() => {
this.updateSize()
this.scrollDown()
})
} }
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.scrollDown()
})
},
// Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
updateScrollableContainerHeight () {
const header = this.$refs.header
const footer = this.$refs.footer
const inner = this.mobileLayout ? window.document.body : this.$refs.inner
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
}, },
// Preserves the scroll position when OSK appears or the posting form changes its height.
handleResize (opts) { handleResize (opts) {
this.$nextTick(() => { this.$nextTick(() => {
this.updateSize() this.updateScrollableContainerHeight()
let prevOffsetHeight const { offsetHeight = undefined } = this.scrollPositionBeforeResize
if (this.lastPosition) { this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
prevOffsetHeight = this.lastPosition.offsetHeight
}
this.lastPosition = {
scrollTop: this.$refs.scrollable.scrollTop,
totalHeight: this.$refs.scrollable.scrollHeight,
offsetHeight: this.$refs.scrollable.offsetHeight
}
if (this.lastPosition) { const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
const diff = this.lastPosition.offsetHeight - prevOffsetHeight if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) {
const bottomedOut = this.bottomedOut() this.$nextTick(() => {
if (diff < 0 || (!bottomedOut && opts && opts.expand)) { this.updateScrollableContainerHeight()
this.$nextTick(() => { this.$refs.scrollable.scrollTo({
this.updateSize() top: this.$refs.scrollable.scrollTop - diff,
this.$nextTick(() => { left: 0
this.$refs.scrollable.scrollTo({
top: this.$refs.scrollable.scrollTop - diff,
left: 0
})
})
}) })
} })
} }
}) })
}, },
updateSize (newHeight, _diff) {
let h = this.$refs.header
let s = this.$refs.scrollable
let f = this.$refs.footer
if (h && s && f) {
let height = 0
if (this.isMobileLayout) {
height = parseFloat(getComputedStyle(window.document.body, null).height.replace('px', ''))
let newHeight = (height - h.clientHeight - f.clientHeight)
s.style.height = newHeight + 'px'
} else {
height = parseFloat(getComputedStyle(this.$refs.inner, null).height.replace('px', ''))
let newHeight = (height - h.clientHeight - f.clientHeight)
s.style.height = newHeight + 'px'
}
}
},
scrollDown (options = {}) { scrollDown (options = {}) {
let { behavior = 'auto', forceRead = false } = options const { behavior = 'auto', forceRead = false } = options
let container = this.$refs.scrollable const scrollable = this.$refs.scrollable
let scrollable = this.$refs.scrollable if (!scrollable) { return }
this.doScrollDown(scrollable, container, behavior) this.$nextTick(() => {
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
})
if (forceRead || this.newMessageCount > 0) { if (forceRead || this.newMessageCount > 0) {
this.readChat() this.readChat()
} }
}, },
doScrollDown (scrollable, container, behavior) { readChat () {
if (!container) { return } if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
this.$nextTick(() => { if (document.hidden) { return }
scrollable.scrollTo({ top: container.scrollHeight, left: 0, behavior }) const lastReadId = this.currentChatMessageService.lastMessage.id
}) this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
}, },
bottomedOut (offset) { bottomedOut (offset) {
let bottomedOut = false return isBottomedOut(this.$refs.scrollable, offset)
if (this.$refs.scrollable) {
let scrollHeight = this.$refs.scrollable.scrollTop + (offset || 0)
let totalHeight = this.$refs.scrollable.scrollHeight - this.$refs.scrollable.offsetHeight
bottomedOut = totalHeight <= scrollHeight
}
return bottomedOut
},
getPosition () {
let scrollHeight = this.$refs.scrollable.scrollTop
let totalHeight = this.$refs.scrollable.scrollHeight - this.$refs.scrollable.offsetHeight
return { scrollHeight, totalHeight }
}, },
reachedTop () { reachedTop () {
const scrollable = this.$refs.scrollable const scrollable = this.$refs.scrollable
...@@ -243,10 +196,8 @@ const Chat = { ...@@ -243,10 +196,8 @@ const Chat = {
if (!this.currentChat) { return } if (!this.currentChat) { return }
if (this.reachedTop()) { if (this.reachedTop()) {
this.fetchChat(false, this.currentChat.id, { this.fetchChat({ maxId: this.currentChatMessageService.minId })
maxId: this.currentChatMessageService.minId } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
})
} else if (this.bottomedOut(150)) {
this.jumpToBottomButtonVisible = false this.jumpToBottomButtonVisible = false
if (this.newMessageCount > 0) { if (this.newMessageCount > 0) {
this.readChat() this.readChat()
...@@ -255,95 +206,97 @@ const Chat = { ...@@ -255,95 +206,97 @@ const Chat = {
this.jumpToBottomButtonVisible = true this.jumpToBottomButtonVisible = true
} }
}, 100), }, 100),
goBack () { handleScrollUp (positionBeforeLoading) {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
this.$refs.scrollable.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
left: 0
})
}, },
fetchChat (isFirstFetch, chatId, opts = {}) { fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.openedChatMessageServices[chatId] const chatMessageService = this.currentChatMessageService
let maxId = opts.maxId if (!chatMessageService) { return }
let sinceId if (fetchLatest && this.streamingEnabled) { return }
if (opts.sinceId && this.mergedConfig.useStreamingApi) {
return const chatId = chatMessageService.chatId
} const fetchOlderMessages = !!maxId
if (opts.sinceId) { const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id
sinceId = chatMessageService.lastMessage && chatMessageService.lastMessage.id
}
if (isFirstFetch) {
this.scrollDown({ forceRead: true })
}
let positionBeforeLoading = null
let previousScrollTop
if (maxId) {
this.loadingOlderMessages = true
positionBeforeLoading = this.getPosition()
previousScrollTop = this.$refs.scrollable.scrollTop
}
this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
.then((messages) => { .then((messages) => {
let bottomedOut = this.bottomedOut() // Clear the current chat in case we're recovering from a ws connection loss.
this.loadingOlderMessages = false if (isFirstFetch) {
chatService.clear(chatMessageService)
}
const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
if (positionBeforeLoading) { this.$nextTick(() => {
this.$nextTick(() => { if (fetchOlderMessages) {
let positionAfterLoading = this.getPosition() this.handleScrollUp(positionBeforeUpdate)
let scrollable = this.$refs.scrollable }
scrollable.scrollTo({
top: previousScrollTop + (positionAfterLoading.totalHeight - positionBeforeLoading.totalHeight),
left: 0
})
})
}
if (isFirstFetch) { if (isFirstFetch) {
this.$nextTick(() => { this.updateScrollableContainerHeight()
this.updateSize() }
}) })
} else if (bottomedOut) {
this.scrollDown()
}
setTimeout(() => {
this.loadingMessages = false
}, 1000)
}) })
}) })
}, },
readChat () {
if (!(this.currentChat && this.currentChat.id)) { return }
this.$store.dispatch('readChat', { id: this.currentChat.id })
},
async startFetching () { async startFetching () {
let chat = this.findOpenedChatByRecipientId(this.recipientId) let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) { if (!chat) {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId }) try {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
} }
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}, },
doStartFetching () { doStartFetching () {
let chatId = this.currentChat.id
this.$store.dispatch('startFetchingCurrentChat', { this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () => setInterval(() => this.fetchChat(false, chatId, { sinceId: true }), 5000) fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
}) })
this.fetchChat(true, chatId) this.fetchChat({ isFirstFetch: true })
}, },
poster (opts) { sendMessage ({ status, media }) {
const status = opts.status const params = {
let params = {
id: this.currentChat.id, id: this.currentChat.id,
content: status content: status
} }
if (opts.media && opts.media[0]) { if (media[0]) {
params.mediaId = opts.media[0].id params.mediaId = media[0].id
} }
return this.backendInteractor.postChatMessage(params) return this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.scrollDown({ forceRead: true })
})
})
return data
})
.catch(error => {
console.error('Error sending message', error)
return {
error: this.$t('chats.error_sending_message')
}
})
},
goBack () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
} }
} }
} }
......
.direct-conversation-view { .chat-view {
display: flex; display: flex;
height: calc(100vh - 60px); height: calc(100vh - 60px);
width: 100%; width: 100%;
.direct-conversation-view-inner { .chat-view-inner {
height: auto; height: auto;
width: 100%; width: 100%;
overflow: visible; overflow: visible;
...@@ -11,208 +11,151 @@ ...@@ -11,208 +11,151 @@
margin-top: 0.5em; margin-top: 0.5em;
margin-left: 0.5em; margin-left: 0.5em;
margin-right: 0.5em; margin-right: 0.5em;
}
.direct-conversation-view-body { .chat-view-body {
background-color: var(--chatBg, $fallback--bg); background-color: var(--chatBg, $fallback--bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
overflow: visible; overflow: visible;
border-radius: none;
min-height: 100%;
margin-left: 0;
margin-right: 0;
margin-bottom: 0em;
margin-top: 0em;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&::after {
border-radius: none; border-radius: none;
min-height: 100%; box-shadow: none;
margin-left: 0; }
margin-right: 0; }
margin-bottom: 0em;
margin-top: 0em;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&.panel {
&::after {
border-radius: 0;
box-shadow: none;
}
}
.direct-conversation-view-heading { .scrollable-message-list {
align-items: center; padding: 0 10px;
justify-content: space-between; height: 100%;
top: 50px; overflow-y: scroll;
display: flex; overflow-x: hidden;
z-index: 2; display: flex;
border-radius: none; flex-direction: column;
position: -webkit-sticky; }
position: sticky;
.direct-conversation-title {
display: flex;
a {
display: flex;
align-items: center;
}
}
.go-back-button-wrapper {
display: flex;
width: 100%;
}
.button-icon {
cursor: pointer;
display: flex;
align-content: center;
align-items: center;
}
.go-back-button {
cursor: pointer;
margin-right: 1.2em;
i {
color: $fallback--link;
color: var(--panelLink, $fallback--link);
}
}
.title {
flex-shrink: 1;
margin-right: 0em;
overflow: hidden;
text-overflow: ellipsis;
line-height: 28px;
flex-shrink: 1;
margin-right: 0em;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 80%;
display: grid;
display: flex;
}
}
.scrollable { .footer {
padding: 0 10px; position: sticky;
height: 100%; bottom: 0px;
overflow-y: scroll; }
overflow-x: hidden;
display: flex; .chat-view-heading {
flex-direction: column; align-items: center;
} justify-content: space-between;
top: 50px;
display: flex;
z-index: 2;
border-radius: none;
position: sticky;
display: flex;