Commit 1981d72c authored by feld's avatar feld

Merge branch 'mfc-develop' into bikeshed-develop

parents 0f775ea8 b7d4372a
Pipeline #21853 passed with stages
in 10 minutes and 17 seconds
......@@ -5,12 +5,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Icons in nav panel
- Private mode support
- Support for 'Move' type notifications
- Pleroma AMOLED dark theme
### Changed
- Captcha now resets on failed registrations
- Notifications column now cleans itself up to optimize performance when tab is left open for a long time
- 403 messaging
### Fixed
- Single notifications left unread when hitting read on another device/tab
- Registration fixed
- Deactivation of remote accounts from frontend
## [1.1.7 and earlier] - 2019-12-14
### Added
- Ability to hide/show repeats from user
- User profile button clutter organized into a menu
- Emoji picker
- Started changelog anew
- Ability to change user's email
- About page
- Added remote user redirect
### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed
......
......@@ -3,7 +3,7 @@ import MultiUserAvatar from '../multi_user_avatar/multi_user_avatar.vue'
import UserTags from '../user_tags/user_tags.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import StatusHeader from '../status_header/status_header'
import DirectConversationTitle from '../direct_conversation_title/direct_conversation_title.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
......@@ -111,7 +111,7 @@ const DirectConversationItem = {
UserTags,
AvatarList,
Timeago,
StatusHeader
DirectConversationTitle
},
methods: {
openConversation (e) {
......
......@@ -34,10 +34,9 @@
v-if="conversation_recipients"
class="name-and-account-name"
>
<StatusHeader
:recipients="conversation_recipients"
<DirectConversationTitle
:users="conversation_recipients"
:fallback="currentUser"
:nolink="true"
/>
</span>
</div>
......
......@@ -10,7 +10,6 @@ import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import DirectConversationDate from '../direct_conversation_date/direct_conversation_date.vue'
import StatusHeader from '../status_header/status_header'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import fileType from 'src/services/file_type/file_type.service'
import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
......@@ -138,8 +137,7 @@ const Status = {
Gallery,
LinkPreview,
AvatarList,
DirectConversationDate,
StatusHeader
DirectConversationDate
},
methods: {
toggleMenu (e) {
......
import Vue from 'vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapState } from 'vuex'
const USER_LIMIT = 10
export default Vue.component('status-header', {
name: 'DirectConversationTitle',
props: [
'users', 'fallback'
],
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
otherUsersTruncated () {
return this.otherUsers.slice(0, USER_LIMIT)
},
otherUsers () {
let otherUsers = this.users.filter(recipient => recipient.id !== this.currentUser.id)
if (otherUsers.length === 0) {
return [this.fallback]
} else {
return otherUsers
}
},
restCount () {
return this.otherUsers.length - USER_LIMIT
},
title () {
return this.otherUsers.map(u => u.screen_name).join(', ')
}
},
methods: {
getUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name)
}
}
})
<template>
<!-- eslint-disable vue/no-v-html -->
<div
class="direct-conversation-title"
:title="title"
>
<span
v-for="(user, index) in otherUsersTruncated"
:key="user.id"
class="username"
v-html="user.name_html + (index + 1 < otherUsersTruncated.length ? ', ' : '')"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./direct_conversation_title.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.direct-conversation-title {
overflow: hidden;
text-overflow: ellipsis;
.username {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
}
</style>
import { throttle } from 'lodash'
import DirectConversationStatus from '../direct_conversation_status/direct_conversation_status.vue'
import StatusHeader from '../status_header/status_header'
import DirectConversationTitle from '../direct_conversation_title/direct_conversation_title.vue'
import MultiUserAvatar from '../multi_user_avatar/multi_user_avatar.vue'
import DirectConversationPostingForm from '../direct_conversation_posting_form/direct_conversation_posting_form.vue'
import DialogModal from '../dialog_modal/dialog_modal.vue'
......@@ -116,7 +116,7 @@ const conversation = {
},
components: {
DirectConversationStatus,
StatusHeader,
DirectConversationTitle,
MultiUserAvatar,
UserAvatar,
DirectConversationPostingForm,
......@@ -129,7 +129,7 @@ const conversation = {
if (!id || id === 'new') { return }
let bottomedOut
this.$store.state.api.backendInteractor.fetchDirectConversationTimeline({ id })
this.$store.state.api.backendInteractor.directConversationTimeline({ id })
.then((statuses) => {
bottomedOut = this.bottomedOut()
this.$store.dispatch('addNewStatuses', { statuses })
......@@ -396,7 +396,7 @@ const conversation = {
const id = this.$route.params.id
if (!id || id === 'new') { return }
this.$store.state.api.backendInteractor.fetchDirectConversation({ id })
this.$store.state.api.backendInteractor.directConversation({ id })
.then(resp => {
if (resp.accounts.length > 0) {
this.chatParticipants = resp.accounts
......@@ -419,7 +419,7 @@ const conversation = {
let users = this.$route.query.users.map(userId => this.$store.getters.findUser(userId))
this.chatParticipants = users
let recipients = users.map(u => u.id)
this.$store.state.api.backendInteractor.fetchDirectConversations({ recipients, limit: 1 }).then(resp => {
this.$store.state.api.backendInteractor.directConversations({ recipients, limit: 1 }).then(resp => {
let conversation = resp[0]
if (conversation && conversation.id) {
const currentUser = this.$store.state.users.currentUser
......
......@@ -16,8 +16,8 @@
<i class="button-icon icon-left-open" />
</a>
<div class="title text-center">
<StatusHeader
:recipients="chatParticipants"
<DirectConversationTitle
:users="chatParticipants"
:fallback="currentUser"
/>
</div>
......
......@@ -7,11 +7,11 @@ const FollowRequestCard = {
},
methods: {
approveUser () {
this.$store.state.api.backendInteractor.approveUser(this.user.id)
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
},
denyUser () {
this.$store.state.api.backendInteractor.denyUser(this.user.id)
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
}
}
......
......@@ -3,7 +3,8 @@ import Notifications from '../notifications/notifications.vue'
const tabModeDict = {
mentions: ['mention'],
'likes+repeats': ['repeat', 'like'],
follows: ['follow']
follows: ['follow'],
moves: ['move']
}
const Interactions = {
......
......@@ -21,6 +21,10 @@
key="follows"
:label="$t('interactions.follows')"
/>
<span
key="moves"
:label="$t('interactions.moves')"
/>
</tab-switcher>
<Notifications
ref="notifications"
......
......@@ -45,12 +45,12 @@ const ModerationTools = {
toggleTag (tag) {
const store = this.$store
if (this.tagsSet.has(tag)) {
store.state.api.backendInteractor.untagUser(this.user, tag).then(response => {
store.state.api.backendInteractor.untagUser({ user: this.user, tag }).then(response => {
if (!response.ok) { return }
store.commit('untagUser', { user: this.user, tag })
})
} else {
store.state.api.backendInteractor.tagUser(this.user, tag).then(response => {
store.state.api.backendInteractor.tagUser({ user: this.user, tag }).then(response => {
if (!response.ok) { return }
store.commit('tagUser', { user: this.user, tag })
})
......@@ -59,19 +59,19 @@ const ModerationTools = {
toggleRight (right) {
const store = this.$store
if (this.user.rights[right]) {
store.state.api.backendInteractor.deleteRight(this.user, right).then(response => {
store.state.api.backendInteractor.deleteRight({ user: this.user, right }).then(response => {
if (!response.ok) { return }
store.commit('updateRight', { user: this.user, right: right, value: false })
store.commit('updateRight', { user: this.user, right, value: false })
})
} else {
store.state.api.backendInteractor.addRight(this.user, right).then(response => {
store.state.api.backendInteractor.addRight({ user: this.user, right }).then(response => {
if (!response.ok) { return }
store.commit('updateRight', { user: this.user, right: right, value: true })
store.commit('updateRight', { user: this.user, right, value: true })
})
}
},
toggleActivationStatus () {
this.$store.dispatch('toggleActivationStatus', this.user)
this.$store.dispatch('toggleActivationStatus', { user: this.user })
},
deleteUserDialog (show) {
this.showDeleteUserDialog = show
......@@ -80,7 +80,7 @@ const ModerationTools = {
const store = this.$store
const user = this.user
const { id, name } = user
store.state.api.backendInteractor.deleteUser(user)
store.state.api.backendInteractor.deleteUser({ user })
.then(e => {
this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
......
......@@ -34,18 +34,18 @@ const Notification = {
const user = this.notification.from_profile
return highlightStyle(highlight[user.screen_name])
},
userInStore () {
return this.$store.getters.findUser(this.notification.from_profile.id)
},
user () {
if (this.userInStore) {
return this.userInStore
}
return this.notification.from_profile
return this.$store.getters.findUser(this.notification.from_profile.id)
},
userProfileLink () {
return this.generateUserProfileLink(this.user)
},
targetUser () {
return this.$store.getters.findUser(this.notification.target.id)
},
targetUserProfileLink () {
return this.generateUserProfileLink(this.targetUser)
},
needMute () {
return this.user.muted
}
......
......@@ -67,9 +67,13 @@
<i class="fa icon-user-plus lit" />
<small>{{ $t('notifications.followed_you') }}</small>
</span>
<span v-if="notification.type === 'move'">
<i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small>
</span>
</div>
<div
v-if="notification.type === 'follow'"
v-if="notification.type === 'follow' || notification.type === 'move'"
class="timeago"
>
<span class="faint">
......@@ -108,6 +112,14 @@
@{{ notification.from_profile.screen_name }}
</router-link>
</div>
<div
v-else-if="notification.type === 'move'"
class="move-text"
>
<router-link :to="targetUserProfileLink">
@{{ notification.target.screen_name }}
</router-link>
</div>
<template v-else>
<status
class="faint"
......
......@@ -2,10 +2,12 @@ import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import {
notificationsFromStore,
visibleNotificationsFromStore,
filteredNotificationsFromStore,
unseenNotificationsFromStore
} from '../../services/notification_utils/notification_utils.js'
const DEFAULT_SEEN_TO_DISPLAY_COUNT = 30
const Notifications = {
props: {
// Disables display of panel header
......@@ -18,7 +20,11 @@ const Notifications = {
},
data () {
return {
bottomedOut: false
bottomedOut: false,
// How many seen notifications to display in the list. The more there are,
// the heavier the page becomes. This count is increased when loading
// older notifications, and cut back to default whenever hitting "Read!".
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
}
},
computed: {
......@@ -34,19 +40,27 @@ const Notifications = {
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
visibleNotifications () {
return visibleNotificationsFromStore(this.$store, this.filterMode)
filteredNotifications () {
return filteredNotificationsFromStore(this.$store, this.filterMode)
},
unseenCount () {
return this.unseenNotifications.length
},
loading () {
return this.$store.state.statuses.notifications.loading
},
notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
}
},
components: {
Notification
},
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: {
unseenCount (count) {
if (count > 0) {
......@@ -59,12 +73,21 @@ const Notifications = {
methods: {
markAsSeen () {
this.$store.dispatch('markNotificationsAsSeen')
this.seenToDisplayCount = DEFAULT_SEEN_TO_DISPLAY_COUNT
},
fetchOlderNotifications () {
if (this.loading) {
return
}
const seenCount = this.filteredNotifications.length - this.unseenCount
if (this.seenToDisplayCount < seenCount) {
this.seenToDisplayCount = Math.min(this.seenToDisplayCount + 20, seenCount)
return
} else if (this.seenToDisplayCount > seenCount) {
this.seenToDisplayCount = seenCount
}
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setNotificationsLoading', { value: true })
......@@ -77,6 +100,7 @@ const Notifications = {
if (notifs.length === 0) {
this.bottomedOut = true
}
this.seenToDisplayCount += notifs.length
})
}
}
......
......@@ -76,7 +76,7 @@
}
}
.follow-text {
.follow-text, .move-text {
padding: 0.5em 0;
}
......@@ -151,6 +151,11 @@
color: var(--cOrange, $fallback--cOrange);
}
.icon-arrow-curved.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.status-content {
margin: 0;
max-height: 300px;
......
......@@ -32,7 +32,7 @@
</div>
<div class="panel-body">
<div
v-for="notification in visibleNotifications"
v-for="notification in notificationsToDisplay"
:key="notification.id"
class="notification"
:class="{&quot;unseen&quot;: !minimalMode && !notification.seen}"
......
......@@ -10,7 +10,7 @@ const PublicAndExternalTimeline = {
this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' })
},
destroyed () {
this.$store.dispatch('stopFetching', 'publicAndExternal')
this.$store.dispatch('stopFetchingTimeline', 'publicAndExternal')
}
}
......
......@@ -17,7 +17,7 @@ const PublicTimeline = {
this.$store.dispatch('startFetchingTimeline', { timeline: 'public' })
},
destroyed () {
this.$store.dispatch('stopFetching', 'public')
this.$store.dispatch('stopFetchingTimeline', 'public')
}
}
......
......@@ -63,7 +63,8 @@ const registration = {
await this.signUp(this.user)
this.$router.push({ name: 'friends' })
} catch (error) {
console.warn('Registration failed: ' + error)
console.warn('Registration failed: ', error)
this.setCaptcha()
}
}
},
......
......@@ -170,7 +170,7 @@
<label
class="form--label"
for="captcha-label"
>{{ $t('captcha') }}</label>
>{{ $t('registration.captcha') }}</label>
<template v-if="['kocaptcha', 'native'].includes(captcha.type)">
<img
......
......@@ -84,7 +84,7 @@ const settings = {
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
// Special cases (need to transform values)
// Special cases (need to transform values or perform actions first)
muteWordsString: {
get () { return this.$store.getters.mergedConfig.muteWords.join('\n') },
set (value) {
......@@ -93,6 +93,22 @@ const settings = {
value: filter(value.split('\n'), (word) => trim(word).length > 0)
})
}
},
useStreamingApi: {
get () { return this.$store.getters.mergedConfig.useStreamingApi },
set (value) {
const promise = value
? this.$store.dispatch('enableMastoSockets')
: this.$store.dispatch('disableMastoSockets')
promise.then(() => {
this.$store.dispatch('setOption', { name: 'useStreamingApi', value })
}).catch((e) => {
console.error('Failed starting MastoAPI Streaming socket', e)
this.$store.dispatch('disableMastoSockets')
this.$store.dispatch('setOption', { name: 'useStreamingApi', value: false })
})
}
}
},
// Updating nested properties
......
......@@ -73,6 +73,15 @@
</li>
</ul>
</li>
<li>
<Checkbox v-model="useStreamingApi">
{{ $t('settings.useStreamingApi') }}
<br>
<small>
{{ $t('settings.useStreamingApiWarning') }}
</small>
</Checkbox>
</li>
<li>
<Checkbox v-model="autoLoad">
{{ $t('settings.autoload') }}
......@@ -316,6 +325,11 @@
{{ $t('settings.notification_visibility_mentions') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationVisibility.moves">
{{ $t('settings.notification_visibility_moves') }}
</Checkbox>
</li>
</ul>
</div>
<div>
......
import Vue from 'vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
export default Vue.component('status-header', {
name: 'StatusHeader',
props: [
'recipients', 'fallback', 'nolink'
],
computed: {
currentUser () {
return this.$store.state.users.currentUser
}
},
methods: {
getUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name)
}
},
render () {
let others = this.recipients.filter(recipient => recipient.id !== this.currentUser.id)
const maxNames = 2
const rest = others.length - maxNames
if (others.length === 0) {
others = [this.fallback]
}
let otherNameList = others.slice(0, maxNames).map((recipient) => {
if (this.nolink) {
return (
<a>
{recipient.screen_name}
</a>
)
} else {
return (
<router-link to={this.getUserProfileLink(recipient)}>
{recipient.screen_name}
</router-link>
)
}
}).reduce((acc, current) => acc.concat([', ', current]), [])
otherNameList = otherNameList.slice(1, otherNameList.length + 1)
if (rest > 0) {
otherNameList.push(` +${rest}`)
}
return (
<span>{otherNameList}</span>
)
}
})
......@@ -19,7 +19,7 @@ const TagTimeline = {
}
},
destroyed () {
this.$store.dispatch('stopFetching', 'tag')
this.$store.dispatch('stopFetchingTimeline', 'tag')
}
}
......
......@@ -112,9 +112,9 @@ const UserProfile = {
}
},
stopFetching () {
this.$store.dispatch('stopFetching', 'user')
this.$store.dispatch('stopFetching', 'favorites')
this.$store.dispatch('stopFetching', 'media')
this.$store.dispatch('stopFetchingTimeline', 'user')
this.$store.dispatch('stopFetchingTimeline', 'favorites')
this.$store.dispatch('stopFetchingTimeline', 'media')
},
switchUser (userNameOrId) {
this.stopFetching()
......
......@@ -64,7 +64,7 @@ const UserReportingModal = {
forward: this.forward,
statusIds: this.statusIdsToReport
}
this.$store.state.api.backendInteractor.reportUser(params)
this.$store.state.api.backendInteractor.reportUser({ ...params })
.then(() => {
this.processing = false
this.resetState()
......
......@@ -139,7 +139,7 @@ const Mfa = {
// fetch settings from server
async fetchSettings () {
let result = await this.backendInteractor.fetchSettingsMFA()
let result = await this.backendInteractor.settingsMFA()
if (result.error) return
this.settings = result.settings
this.settings.available = true
......
......@@ -393,7 +393,7 @@ const UserSettings = {
})
},
importFollows (file) {
return