logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/
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:

Achangelog.d/drafts.add1+
Msrc/App.scss6++++++
Msrc/boot/after_store.js2++
Msrc/boot/routes.js2++
Msrc/components/chat/chat.vue1+
Asrc/components/draft/draft.js64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/draft/draft.vue100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/draft_closer/draft_closer.js52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/draft_closer/draft_closer.vue43+++++++++++++++++++++++++++++++++++++++++++
Asrc/components/drafts/drafts.js16++++++++++++++++
Asrc/components/drafts/drafts.vue24++++++++++++++++++++++++
Asrc/components/edit_status_form/edit_status_form.js44++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/edit_status_form/edit_status_form.vue11+++++++++++
Msrc/components/edit_status_modal/edit_status_modal.js31+++++--------------------------
Msrc/components/edit_status_modal/edit_status_modal.vue11+++++------
Msrc/components/nav_panel/nav_panel.js6++++--
Msrc/components/navigation/navigation.js10++++++++++
Msrc/components/navigation/navigation_entry.vue3++-
Msrc/components/navigation/navigation_pins.vue11++++++++++-
Msrc/components/poll/poll_form.js54+++++++++++++++++++++++++++---------------------------
Msrc/components/post_status_form/post_status_form.js215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/components/post_status_form/post_status_form.vue24+++++++++++++++++++++++-
Msrc/components/settings_modal/tabs/general_tab.js5+++++
Msrc/components/settings_modal/tabs/general_tab.vue16++++++++++++++++
Msrc/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue2+-
Msrc/components/side_drawer/side_drawer.js8+++++---
Msrc/components/side_drawer/side_drawer.vue21+++++++++++++++++++++
Msrc/components/status/status.js7+++++++
Msrc/components/status/status.vue6++++--
Msrc/i18n/en.json31+++++++++++++++++++++++++++++--
Msrc/lib/persisted_state.js4++--
Asrc/lib/storage.js3+++
Msrc/main.js2++
Msrc/modules/config.js5++++-
Asrc/modules/drafts.js86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/modules/instance.js2++
Asrc/services/poll/poll.service.js36++++++++++++++++++++++++++++++++++++
Msrc/sw.js4++--
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)