commit: 7f74ed9753a6c891a7cbaf3b6eb3d7e12fa0c55f
parent d31a7594e748f1c9379a43568159bc9bafaf8c32
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Fri, 27 Dec 2024 00:10:32 +0000
Merge branch 'tusooa/save-draft' into 'develop'
Drafts
Closes #1123
See merge request pleroma/pleroma-fe!1799
Diffstat:
38 files changed, 855 insertions(+), 114 deletions(-)
diff --git a/changelog.d/drafts.add b/changelog.d/drafts.add
@@ -0,0 +1 @@
+Add draft management system
diff --git a/src/App.scss b/src/App.scss
@@ -748,6 +748,12 @@ option {
margin-left: 0.7em;
margin-top: -1em;
}
+
+ &.-neutral {
+ background-color: var(--badgeNeutral);
+ color: white;
+ color: var(--badgeNeutralText, white);
+ }
}
.alert {
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
@@ -368,6 +368,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
getInstanceConfig({ store })
]).catch(e => Promise.reject(e))
+ await store.dispatch('loadDrafts')
+
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
store.dispatch('startFetchingAnnouncements')
diff --git a/src/boot/routes.js b/src/boot/routes.js
@@ -26,6 +26,7 @@ import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue'
+import Drafts from 'components/drafts/drafts.vue'
import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue'
import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue'
@@ -82,6 +83,7 @@ export default (store) => {
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'announcements', path: '/announcements', component: AnnouncementsPage },
+ { name: 'drafts', path: '/drafts', component: Drafts },
{ name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
@@ -76,6 +76,7 @@
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"
+ :disable-draft="true"
:optimistic-posting="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
diff --git a/src/components/draft/draft.js b/src/components/draft/draft.js
@@ -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
diff --git a/src/components/draft/draft.vue b/src/components/draft/draft.vue
@@ -0,0 +1,100 @@
+<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">
+.Draft {
+ margin: 1em;
+
+ .status-content {
+ border: 1px solid;
+ border-color: var(--faint);
+ border-radius: var(--inputRadius);
+ color: var(--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>
diff --git a/src/components/draft_closer/draft_closer.js b/src/components/draft_closer/draft_closer.js
@@ -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
diff --git a/src/components/draft_closer/draft_closer.vue b/src/components/draft_closer/draft_closer.vue
@@ -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>
diff --git a/src/components/drafts/drafts.js b/src/components/drafts/drafts.js
@@ -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
diff --git a/src/components/drafts/drafts.vue b/src/components/drafts/drafts.vue
@@ -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>
diff --git a/src/components/edit_status_form/edit_status_form.js b/src/components/edit_status_form/edit_status_form.js
@@ -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
diff --git a/src/components/edit_status_form/edit_status_form.vue b/src/components/edit_status_form/edit_status_form.vue
@@ -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>
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
@@ -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 statusPosterService from '../../services/status_poster/status_poster.service.js'
import get from 'lodash/get'
const EditStatusModal = {
components: {
- PostStatusForm,
+ EditStatusForm,
Modal
},
data () {
@@ -43,30 +42,10 @@ const EditStatusModal = {
}
},
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 () {
+ this.$refs.editStatusForm.requestClose()
+ },
+ doCloseModal () {
this.$store.dispatch('closeEditStatusModal')
}
}
diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue
@@ -10,13 +10,12 @@
{{ $t('post_status.edit_status') }}
</h1>
</div>
- <PostStatusForm
+ <EditStatusForm
+ ref="editStatusForm"
class="panel-body"
- v-bind="params"
- :post-handler="doEditStatus"
- :disable-polls="true"
- :disable-visibility-selector="true"
- @posted="closeModal"
+ :params="params"
+ @posted="doCloseModal"
+ @can-close="doCloseModal"
/>
</div>
</Modal>
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
@@ -20,7 +20,8 @@ import {
faInfoCircle,
faStream,
faList,
- faBullhorn
+ faBullhorn,
+ faFilePen
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -35,7 +36,8 @@ library.add(
faInfoCircle,
faStream,
faList,
- faBullhorn
+ faBullhorn,
+ faFilePen
)
const NavPanel = {
props: ['forceExpand', 'forceEditMode'],
diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js
@@ -62,6 +62,7 @@ export const ROOT_ITEMS = {
route: 'chats',
icon: 'comments',
label: 'nav.chats',
+ badgeStyle: 'notification',
badgeGetter: 'unreadChatCount',
criteria: ['chats']
},
@@ -69,6 +70,7 @@ export const ROOT_ITEMS = {
route: 'friend-requests',
icon: 'user-plus',
label: 'nav.friend_requests',
+ badgeStyle: 'notification',
criteria: ['lockedUser'],
badgeGetter: 'followRequestCount'
},
@@ -82,8 +84,16 @@ export const ROOT_ITEMS = {
route: 'announcements',
icon: 'bullhorn',
label: 'nav.announcements',
+ badgeStyle: 'notification',
badgeGetter: 'unreadAnnouncementCount',
criteria: ['announcements']
+ },
+ drafts: {
+ route: 'drafts',
+ icon: 'file-pen',
+ label: 'nav.drafts',
+ badgeStyle: 'neutral',
+ badgeGetter: 'draftCount'
}
}
diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue
@@ -48,7 +48,8 @@
<slot />
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
- class="badge -notification"
+ class="badge"
+ :class="[`-${item.badgeStyle}`]"
>
{{ getters[item.badgeGetter] }}
</div>
diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue
@@ -19,7 +19,8 @@
>{{ item.iconLetter }}</span>
<div
v-if="item.badgeGetter && getters[item.badgeGetter]"
- class="badge -dot -notification"
+ class="badge -dot"
+ :class="[`-${item.badgeStyle}`]"
/>
</router-link>
</span>
@@ -34,6 +35,14 @@
overflow: hidden;
height: 100%;
+ &.alert-dot-notification {
+ background-color: var(--badgeNotification);
+ }
+
+ &.alert-dot-neutral {
+ background-color: var(--badgeNeutral);
+ }
+
.pinned-item {
position: relative;
flex: 1 0 3em;
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
@@ -1,5 +1,5 @@
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 Select from '../select/select.vue'
import {
@@ -17,14 +17,33 @@ export default {
Select
},
name: 'PollForm',
- props: ['visible'],
- data: () => ({
- pollType: 'single',
- options: ['', ''],
- expiryAmount: 10,
- expiryUnit: 'minutes'
- }),
+ props: {
+ visible: {},
+ params: {
+ type: Object,
+ required: true
+ }
+ },
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 () {
return this.$store.state.instance.pollLimits
},
@@ -89,7 +108,6 @@ export default {
deleteOption (index, event) {
if (this.options.length > 2) {
this.options.splice(index, 1)
- this.updatePollToParent()
}
},
convertExpiryToUnit (unit, amount) {
@@ -104,24 +122,6 @@ export default {
Math.max(this.minExpirationInCurrentUnit, this.expiryAmount)
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
- })
}
}
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
@@ -10,11 +10,13 @@ import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.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 suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.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 {
@@ -55,6 +57,18 @@ const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
+const typeAndRefId = ({ replyTo, profileMention, statusId }) => {
+ if (replyTo) {
+ return ['reply', replyTo]
+ } else if (profileMention) {
+ return ['mention', profileMention]
+ } else if (statusId) {
+ return ['edit', statusId]
+ } else {
+ return ['new', '']
+ }
+}
+
const PostStatusForm = {
props: [
'statusId',
@@ -79,6 +93,7 @@ const PostStatusForm = {
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
+ 'disableDraft',
'placeholder',
'maxHeight',
'postHandler',
@@ -88,13 +103,15 @@ const PostStatusForm = {
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting',
- 'profileMention'
+ 'profileMention',
+ 'draftId'
],
emits: [
'posted',
'resize',
'mediaplay',
- 'mediapause'
+ 'mediapause',
+ 'can-close'
],
components: {
MediaUpload,
@@ -105,7 +122,8 @@ const PostStatusForm = {
Select,
Attachment,
StatusContent,
- Gallery
+ Gallery,
+ DraftCloser
},
mounted () {
this.updateIdempotencyKey()
@@ -126,41 +144,54 @@ const PostStatusForm = {
const { scopeCopy } = this.$store.getters.mergedConfig
- if (this.replyTo || this.profileMention) {
- const currentUser = this.$store.state.users.currentUser
- statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
- }
+ const [statusType, refId] = typeAndRefId({ replyTo: this.replyTo, profileMention: this.profileMention && this.repliedUser?.id, statusId: this.statusId })
- const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct')
- ? this.copyMessageScope
- : this.$store.state.users.currentUser.default_scope
-
- const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
-
- let statusParams = {
- spoilerText: this.subject || '',
- status: statusText,
- nsfw: !!sensitiveByDefault,
- files: [],
- poll: {},
- mediaDescriptions: {},
- visibility: scope,
- contentType
- }
+ // If we are starting a new post, do not associate it with old drafts
+ let statusParams = !this.disableDraft && (this.draftId || statusType !== 'new') ? this.getDraft(statusType, refId) : null
+
+ if (!statusParams) {
+ if (statusType === 'reply' || statusType === 'mention') {
+ const currentUser = this.$store.state.users.currentUser
+ statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
+ }
+
+ const scope = ((this.copyMessageScope && scopeCopy) || this.copyMessageScope === 'direct')
+ ? this.copyMessageScope
+ : this.$store.state.users.currentUser.default_scope
+
+ const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
- if (this.statusId) {
- const statusContentType = this.statusContentType || contentType
statusParams = {
+ type: statusType,
+ refId,
spoilerText: this.subject || '',
- status: this.statusText || '',
- nsfw: this.statusIsSensitive || !!sensitiveByDefault,
- files: this.statusFiles || [],
- poll: this.statusPoll || {},
- mediaDescriptions: this.statusMediaDescriptions || {},
- visibility: this.statusScope || scope,
- contentType: statusContentType,
+ status: statusText,
+ nsfw: !!sensitiveByDefault,
+ files: [],
+ poll: {},
+ hasPoll: false,
+ mediaDescriptions: {},
+ visibility: scope,
+ contentType,
quoting: false
}
+
+ if (statusType === 'edit') {
+ const statusContentType = this.statusContentType || contentType
+ statusParams = {
+ type: statusType,
+ refId,
+ spoilerText: this.subject || '',
+ status: this.statusText || '',
+ nsfw: this.statusIsSensitive || !!sensitiveByDefault,
+ files: this.statusFiles || [],
+ poll: this.statusPoll || {},
+ hasPoll: false,
+ mediaDescriptions: this.statusMediaDescriptions || {},
+ visibility: this.statusScope || scope,
+ contentType: statusContentType
+ }
+ }
}
return {
@@ -172,13 +203,14 @@ const PostStatusForm = {
highlighted: 0,
newStatus: statusParams,
caret: 0,
- pollFormVisible: false,
showDropIcon: 'hide',
dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
- idempotencyKey: ''
+ idempotencyKey: '',
+ saveInhibited: true,
+ savable: false
}
},
computed: {
@@ -293,6 +325,24 @@ const PostStatusForm = {
return false
},
+ 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']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
@@ -304,15 +354,32 @@ const PostStatusForm = {
handler () {
this.statusChanged()
}
+ },
+ savable (val) {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
+ // MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
+ if (val) {
+ this.addBeforeUnloadListener()
+ } else {
+ this.removeBeforeUnloadListener()
+ }
}
},
+ beforeUnmount () {
+ this.maybeAutoSaveDraft()
+ this.removeBeforeUnloadListener()
+ },
methods: {
statusChanged () {
this.autoPreview()
this.updateIdempotencyKey()
+ this.debouncedMaybeAutoSaveDraft()
+ this.savable = true
+ this.saveInhibited = false
},
clearStatus () {
const newStatus = this.newStatus
+ this.saveInhibited = true
this.newStatus = {
status: '',
spoilerText: '',
@@ -320,10 +387,10 @@ const PostStatusForm = {
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
+ hasPoll: false,
mediaDescriptions: {},
quoting: false
}
- this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
if (this.preserveFocus) {
@@ -336,6 +403,7 @@ const PostStatusForm = {
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
+ this.savable = false
},
async postStatus (event, newStatus, opts = {}) {
if (this.posting && !this.optimisticPosting) { return }
@@ -353,7 +421,7 @@ const PostStatusForm = {
return
}
- const poll = this.pollFormVisible ? this.newStatus.poll : {}
+ const poll = this.newStatus.hasPoll ? pollFormToMasto(this.newStatus.poll) : {}
if (this.pollContentError) {
this.error = this.pollContentError
return
@@ -388,6 +456,7 @@ const PostStatusForm = {
postHandler(postingOptions).then((data) => {
if (!data.error) {
+ this.abandonDraft()
this.clearStatus()
this.$emit('posted', data)
} else {
@@ -632,7 +701,7 @@ const PostStatusForm = {
this.newStatus.visibility = visibility
},
togglePollForm () {
- this.pollFormVisible = !this.pollFormVisible
+ this.newStatus.hasPoll = !this.newStatus.hasPoll
},
setPoll (poll) {
this.newStatus.poll = poll
@@ -665,6 +734,78 @@ const PostStatusForm = {
},
propsToNative (props) {
return propsToNative(props)
+ },
+ saveDraft () {
+ if (!this.disableDraft &&
+ !this.saveInhibited) {
+ if (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
+ })
+ } else if (this.newStatus.id) {
+ // There is a draft, but there is nothing in it, clear it
+ return this.abandonDraft()
+ .then(() => {
+ 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')
+ })
+ },
+ addBeforeUnloadListener () {
+ this._beforeUnloadListener ||= () => {
+ this.saveDraft()
+ }
+ window.addEventListener('beforeunload', this._beforeUnloadListener)
+ },
+ removeBeforeUnloadListener () {
+ if (this._beforeUnloadListener) {
+ window.removeEventListener('beforeunload', this._beforeUnloadListener)
+ }
}
}
}
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
@@ -262,7 +262,7 @@
v-if="pollsAvailable"
ref="pollForm"
:visible="pollFormVisible"
- @update-poll="setPoll"
+ :params="newStatus.poll"
/>
<div
ref="bottom"
@@ -296,6 +296,19 @@
<FAIcon icon="poll-h" />
</button>
</div>
+ <span
+ v-if="!disableDraft && shouldAutoSaveDraft"
+ class="auto-save-status"
+ >
+ {{ autoSaveState }}
+ </span>
+ <button
+ v-else-if="!disableDraft"
+ class="btn button-default"
+ @click="saveDraft"
+ >
+ {{ $t('post_status.save_to_drafts_button') }}
+ </button>
<button
v-if="posting"
disabled
@@ -368,6 +381,11 @@
</Checkbox>
</div>
</form>
+ <DraftCloser
+ ref="draftCloser"
+ @save="saveAndCloseDraft"
+ @discard="discardAndCloseDraft"
+ />
</div>
</template>
@@ -610,5 +628,9 @@
border-radius: var(--roundness);
border: 2px dashed var(--text);
}
+
+ .auto-save-status {
+ align-self: center;
+ }
}
</style>
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
@@ -45,6 +45,11 @@ const GeneralTab = {
value: 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:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
@@ -476,6 +476,22 @@
{{ $t('settings.autocomplete_select_first') }}
</BooleanSetting>
</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>
</div>
</div>
diff --git a/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue
@@ -72,9 +72,9 @@
:compact="true"
/>
<ColorInput
- name="virtual-directive-color"
v-if="selectedVirtualDirectiveValType === 'color'"
v-model="draftVirtualDirective"
+ name="virtual-directive-color"
:fallback="computeColor(draftVirtualDirective)"
:label="$t('settings.style.themes3.editor.variables.virtual_color')"
:hide-optional-checkbox="true"
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
@@ -17,7 +17,8 @@ import {
faCog,
faInfoCircle,
faCompass,
- faList
+ faList,
+ faFilePen
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -33,7 +34,8 @@ library.add(
faCog,
faInfoCircle,
faCompass,
- faList
+ faList,
+ faFilePen
)
const SideDrawer = {
@@ -98,7 +100,7 @@ const SideDrawer = {
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
supportsAnnouncements: state => state.announcements.supportsAnnouncements
}),
- ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount'])
+ ...mapGetters(['unreadChatCount', 'unreadAnnouncementCount', 'draftCount'])
},
methods: {
toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
@@ -260,6 +260,27 @@
@click="toggleDrawer"
>
<router-link
+ :to="{ name: 'drafts' }"
+ class="menu-item"
+ >
+ <FAIcon
+ fixed-width
+ class="fa-scale-110 fa-old-padding"
+ icon="file-pen"
+ /> {{ $t('nav.drafts') }}
+ <span
+ v-if="draftCount"
+ class="badge -neutral"
+ >
+ {{ draftCount }}
+ </span>
+ </router-link>
+ </li>
+ <li
+ v-if="currentUser"
+ @click="toggleDrawer"
+ >
+ <router-link
:to="{ name: 'edit-navigation' }"
class="menu-item"
>
diff --git a/src/components/status/status.js b/src/components/status/status.js
@@ -473,6 +473,13 @@ const Status = {
},
toggleReplying () {
this.$emit('interacted')
+ if (this.replying) {
+ this.$refs.postStatusForm.requestClose()
+ } else {
+ this.doToggleReplying()
+ }
+ },
+ doToggleReplying () {
controlledOrUncontrolledToggle(this, 'replying')
},
gotoOriginal (id) {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
@@ -319,7 +319,7 @@
v-if="!isPreview"
:status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover"
- style="min-width: 0"
+ style="min-width: 0;"
:class="{ '-strikethrough': !status.parent_visible }"
>
<button
@@ -622,13 +622,15 @@
class="status-container reply-form"
>
<PostStatusForm
+ ref="postStatusForm"
class="reply-body"
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-scope="status.visibility"
:subject="replySubject"
- @posted="toggleReplying"
+ @posted="doToggleReplying"
+ @can-close="doToggleReplying"
/>
</div>
</template>
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -192,7 +192,8 @@
"mobile_notifications_close": "Close notifications",
"mobile_notifications_mark_as_seen": "Mark all as seen",
"announcements": "Announcements",
- "quotes": "Quotes"
+ "quotes": "Quotes",
+ "drafts": "Drafts"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
@@ -311,7 +312,16 @@
"private": "Followers-only - post to followers only",
"public": "Public - 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": {
"bio_optional": "Bio (optional)",
@@ -508,6 +518,11 @@
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"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_scale": "Reactions scale factor",
"absolute_time_format": "Use absolute time format",
@@ -1496,6 +1511,18 @@
"unicode_domain_indicator": {
"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)"
+ },
"splash": {
"loading": "Loading...",
"theme": "Applying theme, please wait warmly...",
diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js
@@ -1,6 +1,6 @@
import merge from 'lodash.merge'
-import localforage from 'localforage'
import { each, get, set, cloneDeep } from 'lodash'
+import { storage } from './storage.js'
let loaded = false
@@ -26,7 +26,7 @@ const saveImmedeatelyActions = [
]
const defaultStorage = (() => {
- return localforage
+ return storage
})()
export default function createPersistedState ({
diff --git a/src/lib/storage.js b/src/lib/storage.js
@@ -0,0 +1,3 @@
+import localforage from 'localforage'
+
+export const storage = localforage
diff --git a/src/main.js b/src/main.js
@@ -24,6 +24,7 @@ import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js'
import editStatusModule from './modules/editStatus.js'
import statusHistoryModule from './modules/statusHistory.js'
+import draftsModule from './modules/drafts.js'
import chatsModule from './modules/chats.js'
import announcementsModule from './modules/announcements.js'
import bookmarkFoldersModule from './modules/bookmark_folders.js'
@@ -124,6 +125,7 @@ const persistedStateOptions = {
postStatus: postStatusModule,
editStatus: editStatusModule,
statusHistory: statusHistoryModule,
+ drafts: draftsModule,
chats: chatsModule,
announcements: announcementsModule,
bookmarkFolders: bookmarkFoldersModule
diff --git a/src/modules/config.js b/src/modules/config.js
@@ -30,7 +30,8 @@ export const multiChoiceProperties = [
'conversationDisplay', // tree | linear
'conversationOtherRepliesButton', // below | inside
'mentionLinkDisplay', // short | full_for_remote | full
- 'userPopoverAvatarAction' // close | zoom | open
+ 'userPopoverAvatarAction', // close | zoom | open
+ 'unsavedPostAction' // save | discard | confirm
]
export const defaultState = {
@@ -185,6 +186,8 @@ export const defaultState = {
closingDrawerMarksAsSeen: undefined, // instance default
unseenAtTop: undefined, // instance default
ignoreInactionableSeen: undefined, // instance default
+ unsavedPostAction: undefined, // instance default
+ autoSaveDraft: undefined, // instance default
useAbsoluteTimeFormat: undefined, // instance default
absoluteTimeFormatMinAge: undefined // instance default
}
diff --git a/src/modules/drafts.js b/src/modules/drafts.js
@@ -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
diff --git a/src/modules/instance.js b/src/modules/instance.js
@@ -124,6 +124,8 @@ const defaultState = {
closingDrawerMarksAsSeen: true,
unseenAtTop: false,
ignoreInactionableSeen: false,
+ unsavedPostAction: 'confirm',
+ autoSaveDraft: false,
useAbsoluteTimeFormat: false,
absoluteTimeFormatMinAge: '0d',
diff --git a/src/services/poll/poll.service.js b/src/services/poll/poll.service.js
@@ -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
+}
diff --git a/src/sw.js b/src/sw.js
@@ -1,6 +1,6 @@
/* 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 { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
import { createI18n } from 'vue-i18n'
@@ -25,7 +25,7 @@ function getWindowClients () {
}
const setSettings = async () => {
- const vuexState = await localForage.getItem('vuex-lz')
+ const vuexState = await storage.getItem('vuex-lz')
const locale = vuexState.config.interfaceLanguage || 'en'
i18n.locale = locale
const notificationsNativeArray = Object.entries(vuexState.config.notificationNative)