saveDraft(false),
+ },
});
watch(text, () => {
@@ -318,83 +320,87 @@ watch(visibleUsers, () => {
deep: true,
});
-if (props.mention) {
- text.value = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
- text.value += ' ';
-}
+function initialize() {
+ if (props.mention) {
+ text.value = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
+ text.value += ' ';
+ }
-if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
- text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
-}
+ if (reply.value && (reply.value.user.username !== $i.username || (reply.value.user.host != null && reply.value.user.host !== host))) {
+ text.value = `@${reply.value.user.username}${reply.value.user.host != null ? '@' + toASCII(reply.value.user.host) : ''} `;
+ }
-if (props.reply && props.reply.text != null) {
- const ast = mfm.parse(props.reply.text);
- const otherHost = props.reply.user.host;
+ if (reply.value && reply.value.text != null) {
+ const ast = mfm.parse(reply.value.text);
+ const otherHost = reply.value.user.host;
- for (const x of extractMentions(ast)) {
- const mention = x.host ?
- `@${x.username}@${toASCII(x.host)}` :
- (otherHost == null || otherHost === host) ?
- `@${x.username}` :
- `@${x.username}@${toASCII(otherHost)}`;
+ for (const x of extractMentions(ast)) {
+ const mention = x.host ?
+ `@${x.username}@${toASCII(x.host)}` :
+ (otherHost == null || otherHost === host) ?
+ `@${x.username}` :
+ `@${x.username}@${toASCII(otherHost)}`;
- // 自分は除外
- if ($i.username === x.username && (x.host == null || x.host === host)) continue;
+ // 自分は除外
+ if ($i.username === x.username && (x.host == null || x.host === host)) continue;
- // 重複は除外
- if (text.value.includes(`${mention} `)) continue;
+ // 重複は除外
+ if (text.value.includes(`${mention} `)) continue;
- text.value += `${mention} `;
+ text.value += `${mention} `;
+ }
}
-}
-if ($i.isSilenced && visibility.value === 'public') {
- visibility.value = 'home';
-}
-
-if (props.channel) {
- visibility.value = 'public';
- localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
-}
+ if ($i.isSilenced && visibility.value === 'public') {
+ visibility.value = 'home';
+ }
-// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
-if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
- if (props.reply.visibility === 'home' && visibility.value === 'followers') {
- visibility.value = 'followers';
- } else if (['home', 'followers'].includes(props.reply.visibility) && visibility.value === 'specified') {
- visibility.value = 'specified';
- } else {
- visibility.value = props.reply.visibility;
+ if (props.channel) {
+ visibility.value = 'public';
+ localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
}
- if (visibility.value === 'specified') {
- if (props.reply.visibleUserIds) {
- misskeyApi('users/show', {
- userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
- }).then(users => {
- users.forEach(pushVisibleUser);
- });
+ // 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+ if (reply.value && ['home', 'followers', 'specified'].includes(reply.value.visibility)) {
+ if (reply.value.visibility === 'home' && visibility.value === 'followers') {
+ visibility.value = 'followers';
+ } else if (['home', 'followers'].includes(reply.value.visibility) && visibility.value === 'specified') {
+ visibility.value = 'specified';
+ } else {
+ visibility.value = reply.value.visibility;
}
- if (props.reply.userId !== $i.id) {
- misskeyApi('users/show', { userId: props.reply.userId }).then(user => {
- pushVisibleUser(user);
- });
+ if (visibility.value === 'specified') {
+ if (reply.value.visibleUserIds) {
+ misskeyApi('users/show', {
+ userIds: reply.value.visibleUserIds.filter(uid => uid !== $i.id && uid !== reply.value?.userId),
+ }).then(users => {
+ users.forEach(pushVisibleUser);
+ });
+ }
+
+ if (reply.value.userId !== $i.id) {
+ misskeyApi('users/show', { userId: reply.value.userId }).then(user => {
+ pushVisibleUser(user);
+ });
+ }
}
}
-}
-if (props.specified) {
- visibility.value = 'specified';
- pushVisibleUser(props.specified);
-}
+ if (props.specified) {
+ visibility.value = 'specified';
+ pushVisibleUser(props.specified);
+ }
-// keep cw when reply
-if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
- useCw.value = true;
- cw.value = props.reply.cw;
+ // keep cw when reply
+ if (defaultStore.state.keepCw && reply.value && reply.value.cw) {
+ useCw.value = true;
+ cw.value = reply.value.cw;
+ }
}
+initialize();
+
function watchForDraft() {
watch(text, () => saveDraft());
watch(useCw, () => saveDraft());
@@ -517,7 +523,7 @@ function setVisibility() {
isSilenced: $i.isSilenced,
localOnly: localOnly.value,
src: visibilityButton.value,
- ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
+ ...(reply.value ? { isReplyVisibilitySpecified: reply.value.visibility === 'specified' } : {}),
}, {
changeVisibility: v => {
visibility.value = v;
@@ -648,7 +654,7 @@ async function onPaste(ev: ClipboardEvent) {
const paste = ev.clipboardData.getData('text');
- if (!props.renote && !quoteId.value && paste.startsWith(url + '/notes/')) {
+ if (!renote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
ev.preventDefault();
os.confirm({
@@ -719,34 +725,91 @@ function onDrop(ev: DragEvent): void {
//#endregion
}
-function saveDraft() {
+async function saveDraft(auto = true) {
if (props.instant || props.mock) return;
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
-
- draftData[draftKey.value] = {
- updatedAt: new Date(),
- data: {
- text: text.value,
- useCw: useCw.value,
- cw: cw.value,
- visibility: visibility.value,
- localOnly: localOnly.value,
- files: files.value,
- poll: poll.value,
- scheduledNoteDelete: scheduledNoteDelete.value,
- },
- };
+ if (auto && defaultStore.state.draftSavingBehavior !== 'auto') return;
- miLocalStorage.setItem('drafts', JSON.stringify(draftData));
+ if (!auto) {
+ // 手動での保存の場合は自動保存したものを削除した上で保存
+ await noteDrafts.remove(draftType.value, $i.id, 'default', draftAuxId.value as string);
+ }
+
+ await noteDrafts.set(draftType.value, $i.id, auto ? 'default' : Date.now().toString(), {
+ text: text.value,
+ useCw: useCw.value,
+ cw: cw.value,
+ visibility: visibility.value,
+ localOnly: localOnly.value,
+ files: files.value,
+ poll: poll.value,
+ scheduledNoteDelete: scheduledNoteDelete.value,
+ }, draftAuxId.value as string);
+
+ if (!auto) {
+ clear();
+ }
}
function deleteDraft() {
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
+ noteDrafts.remove(draftType.value, $i.id, 'default', draftAuxId.value as string);
+}
+
+function chooseDraft() {
+ os.popup(defineAsyncComponent(() => import('@/components/MkPostFormDrafts.vue')), {
+ channelId: props.channel?.id,
+ }, {
+ selected: async (res) => {
+ const draft = await res as noteDrafts.NoteDraft;
+
+ if (text.value !== '' || files.value.length > 0) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.draftOverwriteConfirm,
+ });
+ if (canceled) return;
+ }
+
+ applyDraft(draft);
+ },
+ }, 'closed');
+}
+
+async function applyDraft(draft: noteDrafts.NoteDraft, native = false) {
+ if (!native) {
+ reply.value = undefined;
+ renote.value = undefined;
- delete draftData[draftKey.value];
+ switch (draft.type) {
+ case 'quote': {
+ await os.apiWithDialog('notes/show', { noteId: draft.auxId as string }).then(note => {
+ renote.value = note;
+ });
+ break;
+ }
+ case 'reply': {
+ await os.apiWithDialog('notes/show', { noteId: draft.auxId as string }).then(note => {
+ reply.value = note;
+ });
+ break;
+ }
+ }
- miLocalStorage.setItem('drafts', JSON.stringify(draftData));
+ initialize();
+ }
+
+ text.value = draft.data.text;
+ useCw.value = draft.data.useCw;
+ cw.value = draft.data.cw;
+ visibility.value = draft.data.visibility;
+ localOnly.value = draft.data.localOnly;
+ files.value = (draft.data.files || []).filter(draftFile => draftFile);
+ if (draft.data.poll) {
+ poll.value = draft.data.poll;
+ }
+ if (draft.data.scheduledNoteDelete) {
+ scheduledNoteDelete.value = draft.data.scheduledNoteDelete;
+ }
}
async function post(ev?: MouseEvent) {
@@ -805,8 +868,8 @@ async function post(ev?: MouseEvent) {
let postData = {
text: text.value === '' ? null : text.value,
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
- replyId: props.reply ? props.reply.id : undefined,
- renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined,
+ replyId: reply.value ? reply.value.id : undefined,
+ renoteId: renote.value ? renote.value.id : quoteId.value ? quoteId.value : undefined,
channelId: props.channel ? props.channel.id : undefined,
poll: poll.value,
scheduledDelete: scheduledNoteDelete.value,
@@ -894,7 +957,7 @@ async function post(ev?: MouseEvent) {
claimAchievement('brainDiver');
}
- if (props.renote && (props.renote.userId === $i.id) && text.length > 0) {
+ if (renote.value && (renote.value.userId === $i.id) && text.length > 0) {
claimAchievement('selfQuote');
}
@@ -922,6 +985,18 @@ function cancel() {
emit('cancel');
}
+async function closed() {
+ if (defaultStore.state.draftSavingBehavior === 'manual' && (text.value !== '' || files.value.length > 0)) {
+ os.confirm({
+ type: 'question',
+ text: i18n.ts.saveConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ saveDraft(false);
+ });
+ }
+}
+
function insertMention() {
os.selectUser({ localOnly: localOnly.value, includeSelf: true }).then(user => {
insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' ');
@@ -1002,24 +1077,13 @@ onMounted(() => {
if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw);
if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags);
- nextTick(() => {
+ nextTick(async () => {
+ await noteDrafts.migrate($i.id);
+
// 書きかけの投稿を復元
if (!props.instant && !props.mention && !props.specified && !props.mock && !defaultStore.state.disableNoteDrafting) {
- const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value];
- if (draft) {
- text.value = draft.data.text;
- useCw.value = draft.data.useCw;
- cw.value = draft.data.cw;
- visibility.value = draft.data.visibility;
- localOnly.value = draft.data.localOnly;
- files.value = (draft.data.files || []).filter(draftFile => draftFile);
- if (draft.data.poll) {
- poll.value = draft.data.poll;
- }
- if (draft.data.scheduledNoteDelete) {
- scheduledNoteDelete.value = draft.data.scheduledNoteDelete;
- }
- }
+ const draft = await noteDrafts.get(draftType.value, $i.id, 'default', draftAuxId.value as string);
+ if (draft) applyDraft(draft, true);
}
// 削除して編集
@@ -1055,6 +1119,7 @@ onMounted(() => {
defineExpose({
clear,
+ closed,
});
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 6331dfed2994..0f10e73474fe 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -47,6 +47,7 @@ function onPosted() {
}
function onModalClosed() {
+ form.value?.closed();
emit('closed');
}
diff --git a/packages/frontend/src/components/MkPostFormDrafts.vue b/packages/frontend/src/components/MkPostFormDrafts.vue
new file mode 100644
index 000000000000..cefedbafb733
--- /dev/null
+++ b/packages/frontend/src/components/MkPostFormDrafts.vue
@@ -0,0 +1,156 @@
+
+
+ {{ i18n.ts.drafts }}
+
+
+
{{ i18n.ts.loading }}
+
{{ i18n.ts.nothing }}
+
+
select(note)">
+
{{ i18n.ts.quote }}
+
{{ i18n.ts.reply }}
+
{{ i18n.ts.channel }}
+
+
+
+
{{ note.data.files.length }}
+
+
+
remove(note)">
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue
index ad9ec3c4eece..34e1b26d5d07 100644
--- a/packages/frontend/src/pages/avatar-decorations.vue
+++ b/packages/frontend/src/pages/avatar-decorations.vue
@@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ avatarDecoration.description }}
+ {{ i18n.ts.selectFile }}
{{ i18n.ts.name }}
@@ -39,6 +40,7 @@ import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
+import { selectFile } from '@/scripts/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
@@ -47,6 +49,12 @@ import MkFolder from '@/components/MkFolder.vue';
const avatarDecorations = ref([]);
+async function selectImage(decoration, ev) {
+ const file = await selectFile(ev.currentTarget ?? ev.target, null);
+ decoration.name = file.name.replace(/\.(.+)$/, '');
+ decoration.url = file.url;
+}
+
function add() {
avatarDecorations.value.unshift({
_id: Math.random().toString(36),
diff --git a/packages/frontend/src/pages/settings/post-form.vue b/packages/frontend/src/pages/settings/post-form.vue
index 6a5eed1c30fe..4a04583b626b 100644
--- a/packages/frontend/src/pages/settings/post-form.vue
+++ b/packages/frontend/src/pages/settings/post-form.vue
@@ -25,6 +25,11 @@
{{ i18n.ts.save }}
{{ i18n.ts.postFormBottomSettingsDescription }}
+
+ {{ i18n.ts.draftSavingBehavior }}{{ i18n.ts.originalFeature }}
+
+
+
{{ i18n.ts.disableNoteDraftingDescription }}
{{ i18n.ts.disableNoteDrafting }}
@@ -37,6 +42,7 @@
import { computed, defineAsyncComponent, ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
+import MkSelect from '@/components/MkSelect.vue';
import FormSlot from '@/components/form/slot.vue';
import MkContainer from '@/components/MkContainer.vue';
import { bottomItemDef } from '@/scripts/post-form.js';
@@ -46,6 +52,7 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const disableNoteDrafting = computed(defaultStore.makeGetterSetter('disableNoteDrafting'));
+const draftSavingBehavior = computed(defaultStore.makeGetterSetter('draftSavingBehavior'));
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
diff --git a/packages/frontend/src/scripts/note-drafts.ts b/packages/frontend/src/scripts/note-drafts.ts
new file mode 100644
index 000000000000..67abc004a3ca
--- /dev/null
+++ b/packages/frontend/src/scripts/note-drafts.ts
@@ -0,0 +1,99 @@
+import * as Misskey from 'misskey-js';
+import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
+import type { DeleteScheduleEditorModelValue } from '@/components/MkDeleteScheduleEditor.vue';
+import { miLocalStorage } from '@/local-storage.js';
+import { get as idbGet, set as idbSet } from '@/scripts/idb-proxy.js';
+
+export type NoteDraft = {
+ updatedAt: Date;
+ type: keyof NoteKeys;
+ uniqueId: string;
+ auxId: string | null;
+ data: {
+ text: string;
+ useCw: boolean;
+ cw: string | null;
+ visibility: (typeof Misskey.noteVisibilities)[number];
+ localOnly: boolean;
+ files: Misskey.entities.DriveFile[];
+ poll: PollEditorModelValue | null;
+ scheduledNoteDelete: DeleteScheduleEditorModelValue | null;
+ };
+};
+
+type NoteKeys = {
+ note: () => unknown,
+ reply: (replyId: string) => unknown,
+ quote: (renoteId: string) => unknown,
+ channel: (channelId: string) => unknown,
+}
+
+export async function migrate(userId: string) {
+ const raw = miLocalStorage.getItem('drafts');
+ if (!raw) return;
+
+ const drafts = JSON.parse(raw) as Record;
+ const keys = Object.keys(drafts);
+ const newDrafts: Record = {};
+
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ const [type, id] = key.split(':');
+ if (type === 'note' && id !== userId) continue;
+ const keyType = type === 'renote' ? 'quote' : type as keyof NoteKeys;
+ const keyId = type === 'note' ? null : id;
+ const uniqueId = Date.now().toString() + i.toString();
+ const newKey = getKey(keyType, uniqueId, keyId as string);
+ newDrafts[newKey] = {
+ ...drafts[key],
+ uniqueId,
+ type: keyType,
+ auxId: keyId,
+ };
+ delete drafts[key];
+ }
+
+ if (Object.keys(newDrafts).length === 0) return;
+ await idbSet(`drafts::${userId}`, newDrafts);
+ miLocalStorage.setItem('drafts', JSON.stringify(drafts));
+}
+
+function getKey(type: T, uniqueId: string, ...args: Parameters) {
+ let key = `${type}:${uniqueId}`;
+ for (const arg of args) {
+ if (arg != null) key += `:${arg}`;
+ }
+ return key;
+}
+
+export async function getAll(userId: string) {
+ const drafts = await idbGet(`drafts::${userId}`);
+ return (drafts ?? {}) as Record;
+}
+
+export async function get(type: T, userId: string, uniqueId: string, ...args: Parameters) {
+ const key = getKey(type, uniqueId, ...args);
+ const draft = await getAll(userId);
+ return draft[key] ?? null;
+}
+
+export async function set(type: T, userId: string, uniqueId: string, draft: NoteDraft['data'], ...args: Parameters) {
+ const drafts = await getAll(userId);
+ const key = getKey(type, uniqueId, ...args);
+ drafts[key] = {
+ updatedAt: new Date(),
+ type,
+ uniqueId,
+ auxId: args[0] ?? null,
+ data: JSON.parse(JSON.stringify(draft)) as NoteDraft['data'],
+ };
+ console.log(drafts);
+ await idbSet(`drafts::${userId}`, drafts);
+}
+
+export async function remove(type: T, userId: string, uniqueId: string, ...args: Parameters) {
+ const drafts = await getAll(userId);
+ const key = getKey(type, uniqueId, ...args);
+ delete drafts[key];
+ await idbSet(`drafts::${userId}`, drafts);
+}
diff --git a/packages/frontend/src/scripts/post-form.ts b/packages/frontend/src/scripts/post-form.ts
index 1d998aca65a7..40c63acb393d 100644
--- a/packages/frontend/src/scripts/post-form.ts
+++ b/packages/frontend/src/scripts/post-form.ts
@@ -41,4 +41,8 @@ export const bottomItemDef = {
title: i18n.ts.clearPost,
icon: 'ti-trash',
},
+ saveAsDraft: {
+ title: i18n.ts.saveAsDraft,
+ icon: 'ti-note',
+ },
};
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 6f46b5611226..348e4fc23039 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -101,6 +101,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: false,
},
+ draftSavingBehavior: {
+ where: 'account',
+ default: 'auto' as 'auto' | 'manual',
+ },
rememberNoteVisibility: {
where: 'account',
default: false,
diff --git a/packages/frontend/src/ui/universal-zen.vue b/packages/frontend/src/ui/universal-zen.vue
index 8899544444d3..b17b4b0bf4d7 100644
--- a/packages/frontend/src/ui/universal-zen.vue
+++ b/packages/frontend/src/ui/universal-zen.vue
@@ -24,7 +24,6 @@