diff --git a/changelog.d/colorfuncs.fix b/changelog.d/colorfuncs.fix new file mode 100644 index 000000000..18c49e2fc --- /dev/null +++ b/changelog.d/colorfuncs.fix @@ -0,0 +1 @@ +Fix some of the color manipulation functions diff --git a/changelog.d/drafts.add b/changelog.d/drafts.add new file mode 100644 index 000000000..1147016e3 --- /dev/null +++ b/changelog.d/drafts.add @@ -0,0 +1 @@ +Add draft management system diff --git a/src/App.scss b/src/App.scss index f52ba06b9..2afc43908 100644 --- 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 index 65a151785..cf242092a 100644 --- 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 index f87b2ec85..66d937c8e 100644 --- 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 index 6efe576b8..57158abad 100644 --- 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 new file mode 100644 index 000000000..cb07ec5c5 --- /dev/null +++ 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 new file mode 100644 index 000000000..d9d356121 --- /dev/null +++ b/src/components/draft/draft.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/components/draft_closer/draft_closer.js b/src/components/draft_closer/draft_closer.js new file mode 100644 index 000000000..e50ea05ab --- /dev/null +++ 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 new file mode 100644 index 000000000..1afb1f44d --- /dev/null +++ b/src/components/draft_closer/draft_closer.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/drafts/drafts.js b/src/components/drafts/drafts.js new file mode 100644 index 000000000..201417f66 --- /dev/null +++ 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 new file mode 100644 index 000000000..e64a506e5 --- /dev/null +++ b/src/components/drafts/drafts.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/edit_status_form/edit_status_form.js b/src/components/edit_status_form/edit_status_form.js new file mode 100644 index 000000000..323763370 --- /dev/null +++ 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 new file mode 100644 index 000000000..0a7ec760a --- /dev/null +++ b/src/components/edit_status_form/edit_status_form.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js index 75adfea75..0de2e53c7 100644 --- 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 index 5001bd46c..42073d7d3 100644 --- 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') }} - diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index 11863e975..bc962e32f 100644 --- 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 index 156cd8cb3..46ed3f8c2 100644 --- 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 index 024ee3148..4b6c8e299 100644 --- a/src/components/navigation/navigation_entry.vue +++ b/src/components/navigation/navigation_entry.vue @@ -48,7 +48,8 @@
{{ getters[item.badgeGetter] }}
diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue index decd1c04b..37351b918 100644 --- a/src/components/navigation/navigation_pins.vue +++ b/src/components/navigation/navigation_pins.vue @@ -19,7 +19,8 @@ >{{ item.iconLetter }}
@@ -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 index a20701553..29ccbc4bf 100644 --- 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 index 563dfb966..1ae985498 100644 --- 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 + // 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 - const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig + if (!statusParams) { + if (statusType === 'reply' || statusType === 'mention') { + const currentUser = this.$store.state.users.currentUser + statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) + } - let statusParams = { - spoilerText: this.subject || '', - status: statusText, - nsfw: !!sensitiveByDefault, - files: [], - poll: {}, - mediaDescriptions: {}, - visibility: scope, - contentType - } + 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 index e76b52a1c..b7a169c58 100644 --- 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" />
+ + {{ autoSaveState }} + +
+ @@ -610,5 +628,9 @@ border-radius: var(--roundness); border: 2px dashed var(--text); } + + .auto-save-status { + align-self: center; + } } diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js index ba6b1faaf..3ccf025c8 100644 --- 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 index 3cc83b547..cc530996e 100644 --- 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') }} +
  • + + {{ $t('settings.auto_save_draft') }} + +
  • +
  • + + {{ $t('settings.unsaved_post_action') }} + +
  • 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 index f151d3a87..158f0a70c 100644 --- 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" /> 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 index 91b10ea08..7dd6ff28f 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -255,6 +255,27 @@ +
  • + + {{ $t('nav.drafts') }} + + {{ draftCount }} + + +
  • - - - + + - - {{ $t('status.reply_to') }} - - + + {{ $t('status.reply_to') }} + + + + @@ -609,13 +622,15 @@ class="status-container reply-form" > diff --git a/src/i18n/en.json b/src/i18n/en.json index 874620ddd..b4e56bc11 100644 --- 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", @@ -1218,6 +1233,8 @@ "delete_confirm_accept_button": "Delete", "delete_confirm_cancel_button": "Keep", "reply_to": "Reply to", + "reply_to_with_icon": "{icon} {replyTo}", + "reply_to_with_arg": "{replyToWithIcon} {user}", "mentions": "Mentions", "replies_list": "Replies:", "replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):", @@ -1494,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 index 22ba1e7f0..035eee78d 100644 --- 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 new file mode 100644 index 000000000..25a49abc9 --- /dev/null +++ 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 index eee463cc7..6b634e742 100644 --- 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 index 72db9de03..e1e395855 100644 --- 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 new file mode 100644 index 000000000..7a38fe2d5 --- /dev/null +++ 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 index 23d022c6f..72e34628e 100644 --- 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/color_convert/color_convert.js b/src/services/color_convert/color_convert.js index d92bbbe05..15680c7de 100644 --- a/src/services/color_convert/color_convert.js +++ b/src/services/color_convert/color_convert.js @@ -52,15 +52,6 @@ const c2linear = (bit) => { } } -/** - * Converts sRGB into linear RGB - * @param {Object} srgb - sRGB color - * @returns {Object} linear rgb color - */ -const srgbToLinear = (srgb) => { - return 'rgb'.split('').reduce((acc, c) => { acc[c] = c2linear(srgb[c]); return acc }, {}) -} - /** * Calculates relative luminance for given color * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef @@ -70,7 +61,10 @@ const srgbToLinear = (srgb) => { * @returns {Number} relative luminance */ export const relativeLuminance = (srgb) => { - const { r, g, b } = srgbToLinear(srgb) + const r = c2linear(srgb.r) + const g = c2linear(srgb.g) + const b = c2linear(srgb.b) + return 0.2126 * r + 0.7152 * g + 0.0722 * b } @@ -110,13 +104,17 @@ export const getContrastRatioLayers = (text, layers, bedrock) => { * @returns {Object} sRGB of resulting color */ export const alphaBlend = (fg, fga, bg) => { - if (fga === 1 || typeof fga === 'undefined') return fg - return 'rgb'.split('').reduce((acc, c) => { - // Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending - // for opaque bg and transparent fg - acc[c] = (fg[c] * fga + bg[c] * (1 - fga)) - return acc - }, {}) + if (fga === 1 || typeof fga === 'undefined') { + return fg + } + + // Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending + // for opaque bg and transparent fg + return { + r: (fg.r * fga + bg.r * (1 - fga)), + g: (fg.g * fga + bg.g * (1 - fga)), + b: (fg.b * fga + bg.b * (1 - fga)) + } } /** @@ -130,10 +128,11 @@ export const alphaBlendLayers = (bedrock, layers) => layers.reduce((acc, [color, }, bedrock) export const invert = (rgb) => { - return 'rgb'.split('').reduce((acc, c) => { - acc[c] = 255 - rgb[c] - return acc - }, {}) + return { + r: 255 - rgb.r, + g: 255 - rgb.g, + b: 255 - rgb.b + } } /** @@ -144,6 +143,7 @@ export const invert = (rgb) => { */ export const hex2rgb = (hex) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result ? { r: parseInt(result[1], 16), @@ -161,11 +161,13 @@ export const hex2rgb = (hex) => { * @returns {Object} result */ export const mixrgb = (a, b) => { - return 'rgb'.split('').reduce((acc, k) => { - acc[k] = (a[k] + b[k]) / 2 - return acc - }, {}) + return { + r: (a.r + b.r) / 2, + g: (a.g + b.g) / 2, + b: (a.b + b.b) / 2 + } } + /** * Converts rgb object into a CSS rgba() color * @@ -173,7 +175,33 @@ export const mixrgb = (a, b) => { * @returns {String} CSS rgba() color */ export const rgba2css = function (rgba) { - return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a ?? 1})` + const base = { + r: 0, + g: 0, + b: 0, + a: 1 + } + + if (rgba !== null) { + if (rgba.r !== undefined && !isNaN(rgba.r)) { + base.r = rgba.r + } + if (rgba.g !== undefined && !isNaN(rgba.g)) { + base.g = rgba.g + } + if (rgba.b !== undefined && !isNaN(rgba.b)) { + base.b = rgba.b + } + if (rgba.a !== undefined && !isNaN(rgba.a)) { + base.a = rgba.a + } + } else { + base.r = 255 + base.g = 255 + base.b = 255 + } + + return `rgba(${Math.floor(base.r)}, ${Math.floor(base.g)}, ${Math.floor(base.b)}, ${base.a})` } /** diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 550543e0b..cbadac69f 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -442,7 +442,9 @@ export const parseNotification = (data) => { if (masto) { output.type = mastoDict[data.type] || data.type output.seen = data.pleroma.is_seen - output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null + // TODO: null check should be a temporary fix, I guess. + // Investigate why backend does this. + output.status = isStatusNotification(output.type) && data.status !== null ? parseStatus(data.status) : null output.target = output.type !== 'move' ? null : parseUser(data.target) diff --git a/src/services/poll/poll.service.js b/src/services/poll/poll.service.js new file mode 100644 index 000000000..90f8e87ae --- /dev/null +++ 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 index 8d790446a..4a36672c1 100644 --- 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)