Compare commits

...

25 Commits

Author SHA1 Message Date
tusooa e5382781a4
Fix save draft button 2023-05-02 08:08:33 -04:00
tusooa 81cfcae433
Fix adding poll options not working 2023-05-02 08:06:55 -04:00
tusooa a73f9771ee
Do not popup dialog if autosave is on 2023-05-01 22:05:38 -04:00
tusooa 7ec3f49c3e
Give the option to auto save drafts 2023-05-01 22:05:38 -04:00
tusooa c9cacc32a4
Allow confirmation on closing edit form 2023-05-01 22:05:38 -04:00
tusooa f2bdc1c563
Allow confirmation on closing reply form 2023-05-01 22:05:38 -04:00
tusooa aa59adc81f
Use neutral badge for drafts 2023-05-01 22:05:38 -04:00
tusooa b3236e707c
Prevent main post form from being associated with draft on load 2023-05-01 22:05:38 -04:00
tusooa bae81f8d39
Fix poll duration unit 2023-05-01 22:05:38 -04:00
tusooa d884082761
Fix adding/removing poll options 2023-05-01 22:05:38 -04:00
tusooa 3c39269be8
Save only if status has something to save 2023-05-01 22:05:38 -04:00
tusooa 5e08c4537b
Clean up debug statements 2023-05-01 22:05:38 -04:00
tusooa 0256ff5f63
Save drafts permanently in local storage 2023-05-01 22:05:38 -04:00
tusooa 39b42da724
Add drafts to side drawer 2023-05-01 22:05:37 -04:00
tusooa 19b5bb06a0
Add draft count to nav panel 2023-05-01 22:05:37 -04:00
tusooa 3c828f3575
Use the word compose instead of edit in drafts 2023-05-01 22:05:37 -04:00
tusooa 5cddf18a4e
Handle polls in drafts 2023-05-01 22:05:37 -04:00
tusooa 4a044d067a
Handle situations where draft should be saved 2023-05-01 22:05:37 -04:00
tusooa 050266ee39
Abandon draft after all postings 2023-05-01 22:05:37 -04:00
tusooa 8d27c68d5f
Support editing 2023-05-01 22:05:37 -04:00
tusooa a9f374b18f
Display information about replied-to/edited status 2023-05-01 22:05:37 -04:00
tusooa f13444a3c8
Make it possible to abandon draft 2023-05-01 22:05:37 -04:00
tusooa dcd4587525
Add minimal draft management tool 2023-05-01 22:05:37 -04:00
tusooa d4444cd0b1
Save draft immediately before unmount 2023-05-01 22:05:37 -04:00
tusooa 4daa272fdf
Add basic draft saving 2023-05-01 22:05:14 -04:00
35 changed files with 825 additions and 113 deletions

View File

@ -703,6 +703,13 @@ option {
color: white; color: white;
color: var(--badgeNotificationText, white); color: var(--badgeNotificationText, white);
} }
&.badge-neutral {
background-color: $fallback--cGreen;
background-color: var(--badgeNeutral, $fallback--cGreen);
color: white;
color: var(--badgeNeutralText, white);
}
} }
.alert { .alert {

View File

@ -377,6 +377,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
getInstanceConfig({ store }) getInstanceConfig({ store })
]) ])
await store.dispatch('loadDrafts')
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
store.dispatch('startFetchingAnnouncements') store.dispatch('startFetchingAnnouncements')

View File

@ -25,6 +25,7 @@ import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue' import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue' import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue' import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import Drafts from 'components/drafts/drafts.vue'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { const validateAuthenticatedRoute = (to, from, next) => {
@ -78,6 +79,7 @@ export default (store) => {
{ 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: 'announcements', path: '/announcements', component: AnnouncementsPage }, { name: 'announcements', path: '/announcements', component: AnnouncementsPage },
{ name: 'drafts', path: '/drafts', component: Drafts },
{ name: 'user-profile', path: '/users/:name', component: UserProfile }, { name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile }, { name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists }, { name: 'lists', path: '/lists', component: Lists },

View File

@ -0,0 +1,64 @@
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import EditStatusForm from 'src/components/edit_status_form/edit_status_form.vue'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
const Draft = {
components: {
PostStatusForm,
EditStatusForm,
ConfirmModal,
StatusContent
},
props: {
draft: {
type: Object,
required: true
}
},
data () {
return {
editing: false,
showingConfirmDialog: false
}
},
computed: {
relAttrs () {
if (this.draft.type === 'edit') {
return { statusId: this.draft.refId }
} else if (this.draft.type === 'reply') {
return { replyTo: this.draft.refId }
} else {
return {}
}
},
postStatusFormProps () {
return {
draftId: this.draft.id,
...this.relAttrs
}
},
refStatus () {
return this.draft.refId ? this.$store.state.statuses.allStatusesObject[this.draft.refId] : undefined
}
},
methods: {
toggleEditing () {
this.editing = !this.editing
},
abandon () {
this.showingConfirmDialog = true
},
doAbandon () {
this.$store.dispatch('abandonDraft', { id: this.draft.id })
.then(() => {
this.hideConfirmDialog()
})
},
hideConfirmDialog () {
this.showingConfirmDialog = false
}
}
}
export default Draft

View File

@ -0,0 +1,104 @@
<template>
<article class="Draft">
<div class="actions">
<button
class="btn button-default"
:class="{ toggled: editing }"
:aria-expanded="editing"
@click.prevent.stop="toggleEditing"
>
{{ $t('drafts.continue') }}
</button>
<button
class="btn button-default"
@click.prevent.stop="abandon"
>
{{ $t('drafts.abandon') }}
</button>
</div>
<div
v-if="!editing"
class="status-content"
>
<div>
<i18n-t
v-if="draft.type === 'reply' || draft.type === 'edit'"
tag="span"
:keypath="draft.type === 'reply' ? 'drafts.replying' : 'drafts.editing'"
>
<template #statusLink>
<router-link
class="faint-link"
:to="{ name: 'conversation', params: { id: draft.refId } }"
>
{{ refStatus ? refStatus.external_url : $t('drafts.unavailable') }}
</router-link>
</template>
</i18n-t>
<StatusContent
v-if="draft.refId && refStatus"
class="status-content"
:status="refStatus"
:compact="true"
/>
</div>
<p>{{ draft.status }}</p>
</div>
<div v-if="editing">
<PostStatusForm
v-if="draft.type !== 'edit'"
v-bind="postStatusFormProps"
/>
<EditStatusForm
v-else
:params="postStatusFormProps"
/>
</div>
<teleport to="#modal">
<confirm-modal
v-if="showingConfirmDialog"
:title="$t('drafts.abandon_confirm_title')"
:confirm-text="$t('drafts.abandon_confirm_accept_button')"
:cancel-text="$t('drafts.abandon_confirm_cancel_button')"
@accepted="doAbandon"
@cancelled="hideConfirmDialog"
>
{{ $t('drafts.abandon_confirm') }}
</confirm-modal>
</teleport>
</article>
</template>
<script src="./draft.js"></script>
<style lang="scss">
@import "src/variables";
.Draft {
margin: 1em;
.status-content {
border: 1px solid $fallback--faint;
border-color: var(--faint, $fallback--faint);
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
color: $fallback--text;
color: var(--text, $fallback--text);
padding: 0.5em;
margin: 0.5em 0;
}
.actions {
display: flex;
flex-direction: row;
justify-content: space-evenly;
.btn {
flex: 1;
margin-left: 1em;
margin-right: 1em;
max-width: 10em;
}
}
}
</style>

View File

@ -0,0 +1,52 @@
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
const DraftCloser = {
data () {
return {
showing: false
}
},
components: {
DialogModal
},
emits: [
'save',
'discard'
],
computed: {
action () {
if (this.$store.getters.mergedConfig.autoSaveDraft) {
return 'save'
} else {
return this.$store.getters.mergedConfig.unsavedPostAction
}
},
shouldConfirm () {
return this.action === 'confirm'
}
},
methods: {
requestClose () {
if (this.shouldConfirm) {
this.showing = true
} else if (this.action === 'save') {
this.save()
} else {
this.discard()
}
},
save () {
this.$emit('save')
this.showing = false
},
discard () {
this.$emit('discard')
this.showing = false
},
cancel () {
this.showing = false
}
}
}
export default DraftCloser

View File

@ -0,0 +1,43 @@
<template>
<teleport to="#modal">
<dialog-modal
v-if="showing"
v-body-scroll-lock="true"
class="confirm-modal"
:on-cancel="cancel"
>
<template #header>
<span>
{{ $t('post_status.close_confirm_title') }}
</span>
</template>
{{ $t('post_status.close_confirm') }}
<template #footer>
<button
class="btn button-default"
@click.prevent="save"
>
{{ $t('post_status.close_confirm_save_button') }}
</button>
<button
class="btn button-default"
@click.prevent="discard"
>
{{ $t('post_status.close_confirm_discard_button') }}
</button>
<button
class="btn button-default"
@click.prevent="cancel"
>
{{ $t('post_status.close_confirm_continue_composing_button') }}
</button>
</template>
</dialog-modal>
</teleport>
</template>
<script src="./draft_closer.js"></script>

View File

@ -0,0 +1,16 @@
import Draft from 'src/components/draft/draft.vue'
import List from 'src/components/list/list.vue'
const Drafts = {
components: {
Draft,
List
},
computed: {
drafts () {
return this.$store.getters.draftsArray
}
}
}
export default Drafts

View File

@ -0,0 +1,24 @@
<template>
<div class="Drafts">
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('drafts.drafts') }}
</div>
</div>
<div class="panel-body">
<List
:items="drafts"
>
<template #item="{ item: draft }">
<Draft
:draft="draft"
/>
</template>
</List>
</div>
</div>
</div>
</template>
<script src="./drafts.js"></script>

View File

@ -0,0 +1,44 @@
import PostStatusForm from '../post_status_form/post_status_form.vue'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
const EditStatusForm = {
components: {
PostStatusForm
},
props: {
params: {
type: Object,
required: true
}
},
methods: {
requestClose () {
this.$refs.postStatusForm.requestClose()
},
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.params.statusId,
status,
spoilerText,
sensitive,
poll,
media,
contentType
}
return statusPosterService.editStatus(params)
.then((data) => {
return data
})
.catch((err) => {
console.error('Error editing status', err)
return {
error: err.message
}
})
}
}
}
export default EditStatusForm

View File

@ -0,0 +1,11 @@
<template>
<PostStatusForm
ref="postStatusForm"
v-bind="params"
:post-handler="doEditStatus"
:disable-polls="true"
:disable-visibility-selector="true"
/>
</template>
<script src="./edit_status_form.js"></script>

View File

@ -1,11 +1,10 @@
import PostStatusForm from '../post_status_form/post_status_form.vue' import EditStatusForm from '../edit_status_form/edit_status_form.vue'
import Modal from '../modal/modal.vue' import Modal from '../modal/modal.vue'
import statusPosterService from '../../services/status_poster/status_poster.service.js'
import get from 'lodash/get' import get from 'lodash/get'
const EditStatusModal = { const EditStatusModal = {
components: { components: {
PostStatusForm, EditStatusForm,
Modal Modal
}, },
data () { data () {
@ -43,30 +42,10 @@ const EditStatusModal = {
} }
}, },
methods: { methods: {
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.$store.state.editStatus.params.statusId,
status,
spoilerText,
sensitive,
poll,
media,
contentType
}
return statusPosterService.editStatus(params)
.then((data) => {
return data
})
.catch((err) => {
console.error('Error editing status', err)
return {
error: err.message
}
})
},
closeModal () { closeModal () {
this.$refs.editStatusForm.requestClose()
},
doCloseModal () {
this.$store.dispatch('closeEditStatusModal') this.$store.dispatch('closeEditStatusModal')
} }
} }

View File

@ -8,13 +8,12 @@
<div class="panel-heading"> <div class="panel-heading">
{{ $t('post_status.edit_status') }} {{ $t('post_status.edit_status') }}
</div> </div>
<PostStatusForm <EditStatusForm
ref="editStatusForm"
class="panel-body" class="panel-body"
v-bind="params" :params="params"
:post-handler="doEditStatus" @posted="doCloseModal"
:disable-polls="true" @can-close="doCloseModal"
:disable-visibility-selector="true"
@posted="closeModal"
/> />
</div> </div>
</Modal> </Modal>

View File

@ -19,7 +19,8 @@ import {
faInfoCircle, faInfoCircle,
faStream, faStream,
faList, faList,
faBullhorn faBullhorn,
faFilePen
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -34,7 +35,8 @@ library.add(
faInfoCircle, faInfoCircle,
faStream, faStream,
faList, faList,
faBullhorn faBullhorn,
faFilePen
) )
const NavPanel = { const NavPanel = {
props: ['forceExpand', 'forceEditMode'], props: ['forceExpand', 'forceEditMode'],

View File

@ -56,6 +56,7 @@ export const ROOT_ITEMS = {
route: 'chats', route: 'chats',
icon: 'comments', icon: 'comments',
label: 'nav.chats', label: 'nav.chats',
badgeStyle: 'notification',
badgeGetter: 'unreadChatCount', badgeGetter: 'unreadChatCount',
criteria: ['chats'] criteria: ['chats']
}, },
@ -63,6 +64,7 @@ export const ROOT_ITEMS = {
route: 'friend-requests', route: 'friend-requests',
icon: 'user-plus', icon: 'user-plus',
label: 'nav.friend_requests', label: 'nav.friend_requests',
badgeStyle: 'notification',
criteria: ['lockedUser'], criteria: ['lockedUser'],
badgeGetter: 'followRequestCount' badgeGetter: 'followRequestCount'
}, },
@ -76,8 +78,16 @@ export const ROOT_ITEMS = {
route: 'announcements', route: 'announcements',
icon: 'bullhorn', icon: 'bullhorn',
label: 'nav.announcements', label: 'nav.announcements',
badgeStyle: 'notification',
badgeGetter: 'unreadAnnouncementCount', badgeGetter: 'unreadAnnouncementCount',
criteria: ['announcements'] criteria: ['announcements']
},
drafts: {
route: 'drafts',
icon: 'file-pen',
label: 'nav.drafts',
badgeStyle: 'neutral',
badgeGetter: 'draftCount'
} }
} }

View File

@ -35,7 +35,8 @@
<slot /> <slot />
<div <div
v-if="item.badgeGetter && getters[item.badgeGetter]" v-if="item.badgeGetter && getters[item.badgeGetter]"
class="badge badge-notification" class="badge"
:class="[`badge-${item.badgeStyle}`]"
> >
{{ getters[item.badgeGetter] }} {{ getters[item.badgeGetter] }}
</div> </div>

View File

@ -19,6 +19,7 @@
<div <div
v-if="item.badgeGetter && getters[item.badgeGetter]" v-if="item.badgeGetter && getters[item.badgeGetter]"
class="alert-dot" class="alert-dot"
:class="[`alert-dot-${item.badgeStyle}`]"
/> />
</router-link> </router-link>
</span> </span>
@ -42,10 +43,18 @@
position: absolute; position: absolute;
right: calc(50% - 0.75em); right: calc(50% - 0.75em);
top: calc(50% - 0.5em); top: calc(50% - 0.5em);
&.alert-dot-notification {
background-color: $fallback--cRed; background-color: $fallback--cRed;
background-color: var(--badgeNotification, $fallback--cRed); background-color: var(--badgeNotification, $fallback--cRed);
} }
&.alert-dot-neutral {
background-color: $fallback--cGreen;
background-color: var(--badgeNeutral, $fallback--cGreen);
}
}
.pinned-item { .pinned-item {
position: relative; position: relative;
flex: 1 0 3em; flex: 1 0 3em;

View File

@ -1,5 +1,5 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js' import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { uniq } from 'lodash' import { pollFallback } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import Select from '../select/select.vue' import Select from '../select/select.vue'
import { import {
@ -17,14 +17,33 @@ export default {
Select Select
}, },
name: 'PollForm', name: 'PollForm',
props: ['visible'], props: {
data: () => ({ visible: {},
pollType: 'single', params: {
options: ['', ''], type: Object,
expiryAmount: 10, required: true
expiryUnit: 'minutes' }
}), },
computed: { computed: {
pollType: {
get () { return pollFallback(this.params, 'pollType') },
set (newVal) { this.params.pollType = newVal }
},
options () {
const hasOptions = !!this.params.options
if (!hasOptions) {
this.params.options = pollFallback(this.params, 'options')
}
return this.params.options
},
expiryAmount: {
get () { return pollFallback(this.params, 'expiryAmount') },
set (newVal) { this.params.expiryAmount = newVal }
},
expiryUnit: {
get () { return pollFallback(this.params, 'expiryUnit') },
set (newVal) { this.params.expiryUnit = newVal }
},
pollLimits () { pollLimits () {
return this.$store.state.instance.pollLimits return this.$store.state.instance.pollLimits
}, },
@ -89,7 +108,6 @@ export default {
deleteOption (index, event) { deleteOption (index, event) {
if (this.options.length > 2) { if (this.options.length > 2) {
this.options.splice(index, 1) this.options.splice(index, 1)
this.updatePollToParent()
} }
}, },
convertExpiryToUnit (unit, amount) { convertExpiryToUnit (unit, amount) {
@ -104,24 +122,6 @@ export default {
Math.max(this.minExpirationInCurrentUnit, this.expiryAmount) Math.max(this.minExpirationInCurrentUnit, this.expiryAmount)
this.expiryAmount = this.expiryAmount =
Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount) Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount)
this.updatePollToParent()
},
updatePollToParent () {
const expiresIn = this.convertExpiryFromUnit(
this.expiryUnit,
this.expiryAmount
)
const options = uniq(this.options.filter(option => option !== ''))
if (options.length < 2) {
this.$emit('update-poll', { error: this.$t('polls.not_enough_options') })
return
}
this.$emit('update-poll', {
options,
multiple: this.pollType === 'multiple',
expiresIn
})
} }
} }
} }

View File

@ -9,11 +9,13 @@ import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js' import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { reject, map, uniqBy, debounce } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import Select from '../select/select.vue' import Select from '../select/select.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -54,6 +56,16 @@ const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2)) return Number(str.substring(0, str.length - 2))
} }
const typeAndRefId = ({ replyTo, statusId }) => {
if (replyTo) {
return ['reply', replyTo]
} else if (statusId) {
return ['edit', statusId]
} else {
return ['new', '']
}
}
const PostStatusForm = { const PostStatusForm = {
props: [ props: [
'statusId', 'statusId',
@ -86,13 +98,15 @@ const PostStatusForm = {
'fileLimit', 'fileLimit',
'submitOnEnter', 'submitOnEnter',
'emojiPickerPlacement', 'emojiPickerPlacement',
'optimisticPosting' 'optimisticPosting',
'draftId'
], ],
emits: [ emits: [
'posted', 'posted',
'resize', 'resize',
'mediaplay', 'mediaplay',
'mediapause' 'mediapause',
'can-close'
], ],
components: { components: {
MediaUpload, MediaUpload,
@ -103,7 +117,8 @@ const PostStatusForm = {
Select, Select,
Attachment, Attachment,
StatusContent, StatusContent,
Gallery Gallery,
DraftCloser
}, },
mounted () { mounted () {
this.updateIdempotencyKey() this.updateIdempotencyKey()
@ -124,7 +139,13 @@ const PostStatusForm = {
const { scopeCopy } = this.$store.getters.mergedConfig const { scopeCopy } = this.$store.getters.mergedConfig
if (this.replyTo) { const [statusType, refId] = typeAndRefId({ replyTo: this.replyTo, statusId: this.statusId })
// If we are starting a new post, do not associate it with old drafts
let statusParams = (this.draftId || statusType !== 'new') ? this.getDraft(statusType, refId) : null
if (!statusParams) {
if (statusType === 'reply') {
const currentUser = this.$store.state.users.currentUser const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
} }
@ -135,30 +156,37 @@ const PostStatusForm = {
const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
let statusParams = { statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '', spoilerText: this.subject || '',
status: statusText, status: statusText,
nsfw: !!sensitiveByDefault, nsfw: !!sensitiveByDefault,
files: [], files: [],
poll: {}, poll: {},
hasPoll: false,
mediaDescriptions: {}, mediaDescriptions: {},
visibility: scope, visibility: scope,
contentType contentType
} }
if (this.statusId) { if (statusType === 'edit') {
const statusContentType = this.statusContentType || contentType const statusContentType = this.statusContentType || contentType
statusParams = { statusParams = {
type: statusType,
refId,
spoilerText: this.subject || '', spoilerText: this.subject || '',
status: this.statusText || '', status: this.statusText || '',
nsfw: this.statusIsSensitive || !!sensitiveByDefault, nsfw: this.statusIsSensitive || !!sensitiveByDefault,
files: this.statusFiles || [], files: this.statusFiles || [],
poll: this.statusPoll || {}, poll: this.statusPoll || {},
hasPoll: false,
mediaDescriptions: this.statusMediaDescriptions || {}, mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || scope, visibility: this.statusScope || scope,
contentType: statusContentType contentType: statusContentType
} }
} }
}
return { return {
dropFiles: [], dropFiles: [],
@ -168,13 +196,14 @@ const PostStatusForm = {
highlighted: 0, highlighted: 0,
newStatus: statusParams, newStatus: statusParams,
caret: 0, caret: 0,
pollFormVisible: false,
showDropIcon: 'hide', showDropIcon: 'hide',
dropStopTimeout: null, dropStopTimeout: null,
preview: null, preview: null,
previewLoading: false, previewLoading: false,
emojiInputShown: false, emojiInputShown: false,
idempotencyKey: '' idempotencyKey: '',
saveInhibited: true,
savable: false
} }
}, },
computed: { computed: {
@ -265,6 +294,24 @@ const PostStatusForm = {
isEdit () { isEdit () {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
}, },
debouncedMaybeAutoSaveDraft () {
return debounce(this.maybeAutoSaveDraft, 3000)
},
pollFormVisible () {
return this.newStatus.hasPoll
},
shouldAutoSaveDraft () {
return this.$store.getters.mergedConfig.autoSaveDraft
},
autoSaveState () {
if (this.savable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
...mapGetters(['mergedConfig']), ...mapGetters(['mergedConfig']),
...mapState({ ...mapState({
mobileLayout: state => state.interface.mobileLayout mobileLayout: state => state.interface.mobileLayout
@ -278,13 +325,20 @@ const PostStatusForm = {
} }
} }
}, },
beforeUnmount () {
this.maybeAutoSaveDraft()
},
methods: { methods: {
statusChanged () { statusChanged () {
this.autoPreview() this.autoPreview()
this.updateIdempotencyKey() this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.savable = true
this.saveInhibited = false
}, },
clearStatus () { clearStatus () {
const newStatus = this.newStatus const newStatus = this.newStatus
this.saveInhibited = true
this.newStatus = { this.newStatus = {
status: '', status: '',
spoilerText: '', spoilerText: '',
@ -292,9 +346,9 @@ const PostStatusForm = {
visibility: newStatus.visibility, visibility: newStatus.visibility,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll: {}, poll: {},
hasPoll: false,
mediaDescriptions: {} mediaDescriptions: {}
} }
this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm() this.clearPollForm()
if (this.preserveFocus) { if (this.preserveFocus) {
@ -307,6 +361,7 @@ const PostStatusForm = {
el.style.height = undefined el.style.height = undefined
this.error = null this.error = null
if (this.preview) this.previewStatus() if (this.preview) this.previewStatus()
this.savable = false
}, },
async postStatus (event, newStatus, opts = {}) { async postStatus (event, newStatus, opts = {}) {
if (this.posting && !this.optimisticPosting) { return } if (this.posting && !this.optimisticPosting) { return }
@ -324,7 +379,7 @@ const PostStatusForm = {
return return
} }
const poll = this.pollFormVisible ? this.newStatus.poll : {} const poll = this.newStatus.hasPoll ? pollFormToMasto(this.newStatus.poll) : {}
if (this.pollContentError) { if (this.pollContentError) {
this.error = this.pollContentError this.error = this.pollContentError
return return
@ -357,6 +412,7 @@ const PostStatusForm = {
postHandler(postingOptions).then((data) => { postHandler(postingOptions).then((data) => {
if (!data.error) { if (!data.error) {
this.abandonDraft()
this.clearStatus() this.clearStatus()
this.$emit('posted', data) this.$emit('posted', data)
} else { } else {
@ -600,7 +656,7 @@ const PostStatusForm = {
this.newStatus.visibility = visibility this.newStatus.visibility = visibility
}, },
togglePollForm () { togglePollForm () {
this.pollFormVisible = !this.pollFormVisible this.newStatus.hasPoll = !this.newStatus.hasPoll
}, },
setPoll (poll) { setPoll (poll) {
this.newStatus.poll = poll this.newStatus.poll = poll
@ -633,6 +689,60 @@ const PostStatusForm = {
}, },
propsToNative (props) { propsToNative (props) {
return propsToNative(props) return propsToNative(props)
},
saveDraft () {
if (!this.saveInhibited &&
(this.newStatus.status ||
this.newStatus.files?.length ||
this.newStatus.hasPoll
)) {
return this.$store.dispatch('addOrSaveDraft', { draft: this.newStatus })
.then(id => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.savable = false
})
}
return Promise.resolve()
},
maybeAutoSaveDraft () {
if (this.shouldAutoSaveDraft) {
this.saveDraft()
}
},
abandonDraft () {
return this.$store.dispatch('abandonDraft', { id: this.newStatus.id })
},
getDraft (statusType, refId) {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(statusType, refId)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose () {
if (!this.savable) {
this.$emit('can-close')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft () {
this.saveDraft().then(() => {
this.$emit('can-close')
})
},
discardAndCloseDraft () {
this.abandonDraft().then(() => {
this.$emit('can-close')
})
} }
} }
} }

View File

@ -233,7 +233,7 @@
v-if="pollsAvailable" v-if="pollsAvailable"
ref="pollForm" ref="pollForm"
:visible="pollFormVisible" :visible="pollFormVisible"
@update-poll="setPoll" :params="newStatus.poll"
/> />
<div <div
ref="bottom" ref="bottom"
@ -267,6 +267,19 @@
<FAIcon icon="poll-h" /> <FAIcon icon="poll-h" />
</button> </button>
</div> </div>
<span
v-if="shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<button
v-else
class="btn button-default"
@click="saveDraft"
>
{{ $t('post_status.save_to_drafts_button') }}
</button>
<button <button
v-if="posting" v-if="posting"
disabled disabled
@ -339,6 +352,11 @@
</Checkbox> </Checkbox>
</div> </div>
</form> </form>
<DraftCloser
ref="draftCloser"
@save="saveAndCloseDraft"
@discard="discardAndCloseDraft"
/>
</div> </div>
</template> </template>
@ -608,5 +626,9 @@
border: 2px dashed $fallback--text; border: 2px dashed $fallback--text;
border: 2px dashed var(--text, $fallback--text); border: 2px dashed var(--text, $fallback--text);
} }
.auto-save-status {
align-self: center;
}
} }
</style> </style>

View File

@ -50,6 +50,11 @@ const GeneralTab = {
value: mode, value: mode,
label: this.$t(`settings.user_popover_avatar_action_${mode}`) label: this.$t(`settings.user_popover_avatar_action_${mode}`)
})), })),
unsavedPostActionOptions: ['save', 'discard', 'confirm'].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.unsaved_post_action_${mode}`)
})),
loopSilentAvailable: loopSilentAvailable:
// Firefox // Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||

View File

@ -518,6 +518,22 @@
{{ $t('settings.autocomplete_select_first') }} {{ $t('settings.autocomplete_select_first') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting
path="autoSaveDraft"
>
{{ $t('settings.auto_save_draft') }}
</BooleanSetting>
</li>
<li v-if="!autoSaveDraft">
<ChoiceSetting
id="unsavedPostAction"
path="unsavedPostAction"
:options="unsavedPostActionOptions"
>
{{ $t('settings.unsaved_post_action') }}
</ChoiceSetting>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -17,7 +17,8 @@ import {
faCog, faCog,
faInfoCircle, faInfoCircle,
faCompass, faCompass,
faList faList,
faFilePen
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -33,7 +34,8 @@ library.add(
faCog, faCog,
faInfoCircle, faInfoCircle,
faCompass, faCompass,
faList faList,
faFilePen
) )
const SideDrawer = { const SideDrawer = {
@ -98,7 +100,7 @@ const SideDrawer = {
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
supportsAnnouncements: state => state.announcements.supportsAnnouncements supportsAnnouncements: state => state.announcements.supportsAnnouncements
}), }),
...mapGetters(['unreadChatCount', 'unreadAnnouncementCount']) ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount', 'draftCount'])
}, },
methods: { methods: {
toggleDrawer () { toggleDrawer () {

View File

@ -211,6 +211,26 @@
</span> </span>
</router-link> </router-link>
</li> </li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link
:to="{ name: 'drafts' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="file-pen"
/> {{ $t('nav.drafts') }}
<span
v-if="draftCount"
class="badge badge-neutral"
>
{{ draftCount }}
</span>
</router-link>
</li>
<li <li
v-if="currentUser" v-if="currentUser"
@click="toggleDrawer" @click="toggleDrawer"

View File

@ -423,6 +423,13 @@ const Status = {
this.error = undefined this.error = undefined
}, },
toggleReplying () { toggleReplying () {
if (this.replying) {
this.$refs.postStatusForm.requestClose()
} else {
this.doToggleReplying()
}
},
doToggleReplying () {
controlledOrUncontrolledToggle(this, 'replying') controlledOrUncontrolledToggle(this, 'replying')
}, },
gotoOriginal (id) { gotoOriginal (id) {

View File

@ -496,13 +496,15 @@
class="status-container reply-form" class="status-container reply-form"
> >
<PostStatusForm <PostStatusForm
ref="postStatusForm"
class="reply-body" class="reply-body"
:reply-to="status.id" :reply-to="status.id"
:attentions="status.attentions" :attentions="status.attentions"
:replied-user="status.user" :replied-user="status.user"
:copy-message-scope="status.visibility" :copy-message-scope="status.visibility"
:subject="replySubject" :subject="replySubject"
@posted="toggleReplying" @posted="doToggleReplying"
@can-close="doToggleReplying"
/> />
</div> </div>
</template> </template>

View File

@ -189,7 +189,8 @@
"mobile_notifications": "Open notifications", "mobile_notifications": "Open notifications",
"mobile_notifications": "Open notifications (there are unread ones)", "mobile_notifications": "Open notifications (there are unread ones)",
"mobile_notifications_close": "Close notifications", "mobile_notifications_close": "Close notifications",
"announcements": "Announcements" "announcements": "Announcements",
"drafts": "Drafts"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unknown status, searching for it…", "broken_favorite": "Unknown status, searching for it…",
@ -295,7 +296,16 @@
"private": "Followers-only - post to followers only", "private": "Followers-only - post to followers only",
"public": "Public - post to public timelines", "public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines" "unlisted": "Unlisted - do not post to public timelines"
} },
"close_confirm_title": "Closing post form",
"close_confirm": "What do you want to do with your current writing?",
"close_confirm_save_button": "Save",
"close_confirm_discard_button": "Discard",
"close_confirm_continue_composing_button": "Continue composing",
"auto_save_nothing_new": "Nothing new to save.",
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"save_to_drafts_button": "Save to drafts"
}, },
"registration": { "registration": {
"bio_optional": "Bio (optional)", "bio_optional": "Bio (optional)",
@ -466,6 +476,11 @@
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.", "avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"pad_emoji": "Pad emoji with spaces when adding from picker", "pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available", "autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
"unsaved_post_action_save": "Save it to drafts",
"unsaved_post_action_discard": "Discard it",
"unsaved_post_action_confirm": "Ask every time",
"auto_save_draft": "Save drafts as you compose",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline", "emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor", "emoji_reactions_scale": "Reactions scale factor",
"export_theme": "Save preset", "export_theme": "Save preset",
@ -1154,5 +1169,17 @@
}, },
"unicode_domain_indicator": { "unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters." "tooltip": "This domain contains non-ascii characters."
},
"drafts": {
"drafts": "Drafts",
"continue": "Continue composing",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",
"abandon_confirm_accept_button": "Abandon",
"abandon_confirm_cancel_button": "Keep",
"replying": "Replying to {statusLink}",
"editing": "Editing {statusLink}",
"unavailable": "(unavailable)"
} }
} }

View File

@ -1,6 +1,6 @@
import merge from 'lodash.merge' import merge from 'lodash.merge'
import localforage from 'localforage'
import { each, get, set, cloneDeep } from 'lodash' import { each, get, set, cloneDeep } from 'lodash'
import { storage } from './storage.js'
let loaded = false let loaded = false
@ -26,7 +26,7 @@ const saveImmedeatelyActions = [
] ]
const defaultStorage = (() => { const defaultStorage = (() => {
return localforage return storage
})() })()
export default function createPersistedState ({ export default function createPersistedState ({

3
src/lib/storage.js Normal file
View File

@ -0,0 +1,3 @@
import localforage from 'localforage'
export const storage = localforage

View File

@ -22,6 +22,7 @@ import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js' import postStatusModule from './modules/postStatus.js'
import editStatusModule from './modules/editStatus.js' import editStatusModule from './modules/editStatus.js'
import statusHistoryModule from './modules/statusHistory.js' import statusHistoryModule from './modules/statusHistory.js'
import draftsModule from './modules/drafts.js'
import chatsModule from './modules/chats.js' import chatsModule from './modules/chats.js'
import announcementsModule from './modules/announcements.js' import announcementsModule from './modules/announcements.js'
@ -92,6 +93,7 @@ const persistedStateOptions = {
postStatus: postStatusModule, postStatus: postStatusModule,
editStatus: editStatusModule, editStatus: editStatusModule,
statusHistory: statusHistoryModule, statusHistory: statusHistoryModule,
drafts: draftsModule,
chats: chatsModule, chats: chatsModule,
announcements: announcementsModule announcements: announcementsModule
}, },

View File

@ -18,7 +18,8 @@ export const multiChoiceProperties = [
'conversationDisplay', // tree | linear 'conversationDisplay', // tree | linear
'conversationOtherRepliesButton', // below | inside 'conversationOtherRepliesButton', // below | inside
'mentionLinkDisplay', // short | full_for_remote | full 'mentionLinkDisplay', // short | full_for_remote | full
'userPopoverAvatarAction' // close | zoom | open 'userPopoverAvatarAction', // close | zoom | open
'unsavedPostAction' // save | discard | confirm
] ]
export const defaultState = { export const defaultState = {
@ -117,7 +118,9 @@ export const defaultState = {
conversationOtherRepliesButton: undefined, // instance default conversationOtherRepliesButton: undefined, // instance default
conversationTreeFadeAncestors: undefined, // instance default conversationTreeFadeAncestors: undefined, // instance default
maxDepthInThread: undefined, // instance default maxDepthInThread: undefined, // instance default
autocompleteSelect: undefined // instance default autocompleteSelect: undefined, // instance default
unsavedPostAction: undefined, // instance default
autoSaveDraft: undefined // instance default
} }
// caching the instance default properties // caching the instance default properties

86
src/modules/drafts.js Normal file
View File

@ -0,0 +1,86 @@
import { storage } from 'src/lib/storage.js'
export const defaultState = {
drafts: {}
}
export const mutations = {
addOrSaveDraft (state, { draft }) {
state.drafts[draft.id] = draft
},
abandonDraft (state, { id }) {
delete state.drafts[id]
},
loadDrafts (state, data) {
state.drafts = data
}
}
const storageKey = 'pleroma-fe-drafts'
/*
* Note: we do not use the persist state plugin because
* it is not impossible for a user to have two windows at
* the same time. The persist state plugin is just overriding
* everything with the current state. This isn't good because
* if a draft is created in one window and another draft is
* created in another, the draft in the first window will just
* be overriden.
* Here, we can't guarantee 100% atomicity unless one uses
* different keys, which will just pollute the whole storage.
* It is indeed best to have backend support for this.
*/
const getStorageData = async () => ((await storage.getItem(storageKey)) || {})
const saveDraftToStorage = async (draft) => {
const currentData = await getStorageData()
currentData[draft.id] = JSON.parse(JSON.stringify(draft))
await storage.setItem(storageKey, currentData)
}
const deleteDraftFromStorage = async (id) => {
const currentData = await getStorageData()
delete currentData[id]
await storage.setItem(storageKey, currentData)
}
export const actions = {
async addOrSaveDraft (store, { draft }) {
const id = draft.id || (new Date().getTime()).toString()
const draftWithId = { ...draft, id }
store.commit('addOrSaveDraft', { draft: draftWithId })
await saveDraftToStorage(draftWithId)
return id
},
async abandonDraft (store, { id }) {
store.commit('abandonDraft', { id })
await deleteDraftFromStorage(id)
},
async loadDrafts (store) {
const currentData = await getStorageData()
store.commit('loadDrafts', currentData)
}
}
export const getters = {
draftsByTypeAndRefId (state) {
return (type, refId) => {
return Object.values(state.drafts).filter(draft => draft.type === type && draft.refId === refId)
}
},
draftsArray (state) {
return Object.values(state.drafts)
},
draftCount (state) {
return Object.values(state.drafts).length
}
}
const drafts = {
state: defaultState,
mutations,
getters,
actions
}
export default drafts

View File

@ -105,6 +105,8 @@ const defaultState = {
conversationTreeFadeAncestors: false, conversationTreeFadeAncestors: false,
maxDepthInThread: 6, maxDepthInThread: 6,
autocompleteSelect: false, autocompleteSelect: false,
unsavedPostAction: 'confirm',
autoSaveDraft: false,
// Nasty stuff // Nasty stuff
customEmoji: [], customEmoji: [],

View File

@ -0,0 +1,36 @@
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { uniq } from 'lodash'
const pollFallbackValues = {
pollType: 'single',
options: ['', ''],
expiryAmount: 10,
expiryUnit: 'minutes'
}
const pollFallback = (object, attr) => {
return object[attr] !== undefined ? object[attr] : pollFallbackValues[attr]
}
const pollFormToMasto = (poll) => {
const expiresIn = DateUtils.unitToSeconds(
pollFallback(poll, 'expiryUnit'),
pollFallback(poll, 'expiryAmount')
)
const options = uniq(pollFallback(poll, 'options').filter(option => option !== ''))
if (options.length < 2) {
return { errorKey: 'polls.not_enough_options' }
}
return {
options,
multiple: pollFallback(poll, 'pollType') === 'multiple',
expiresIn
}
}
export {
pollFallback,
pollFormToMasto
}

View File

@ -1,6 +1,6 @@
/* eslint-env serviceworker */ /* eslint-env serviceworker */
import localForage from 'localforage' import { storage } from 'src/lib/storage.js'
import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js' import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
import { prepareNotificationObject } from './services/notification_utils/notification_utils.js' import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@ -14,7 +14,7 @@ const i18n = createI18n({
}) })
function isEnabled () { function isEnabled () {
return localForage.getItem('vuex-lz') return storage.getItem('vuex-lz')
.then(data => data.config.webPushNotifications) .then(data => data.config.webPushNotifications)
} }
@ -24,7 +24,7 @@ function getWindowClients () {
} }
const setLocale = async () => { const setLocale = async () => {
const state = await localForage.getItem('vuex-lz') const state = await storage.getItem('vuex-lz')
const locale = state.config.interfaceLanguage || 'en' const locale = state.config.interfaceLanguage || 'en'
i18n.locale = locale i18n.locale = locale
} }