@@ -110,6 +113,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue';
+import MkDraftsDialog from '@/components/MkDraftsDialog.vue';
import { host, url } from '@/config.js';
import { erase, unique } from '@/scripts/array.js';
import { extractMentions } from '@/scripts/extract-mentions.js';
@@ -130,6 +134,7 @@ import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
+import type { NoteDraftItem } from '@/types/note-draft-item.js';
const $i = signinRequired();
@@ -180,6 +185,10 @@ const visibilityButton = shallowRef
();
const posting = ref(false);
const posted = ref(false);
+const draftId = ref(Date.now().toString());
+const reply = ref(props.reply ?? null);
+const renote = ref(props.renote ?? null);
+const channel = ref(props.channel ?? null);
const text = ref(props.initialText ?? '');
const files = ref(props.initialFiles ?? []);
const poll = ref(null);
@@ -210,25 +219,25 @@ const textAreaReadOnly = ref(false);
const nsfwGuideUrl = 'https://go.misskey.io/media-guideline';
const draftKey = computed((): string => {
- let key = props.channel ? `channel:${props.channel.id}` : '';
+ let key = channel.value ? `channel:${channel.value.id}` : '';
- if (props.renote) {
- key += `renote:${props.renote.id}`;
- } else if (props.reply) {
- key += `reply:${props.reply.id}`;
+ if (renote.value) {
+ key += `renote:${renote.value.id}`;
+ } else if (reply.value) {
+ key += `reply:${reply.value.id}`;
} else {
- key += `note:${$i.id}`;
+ key += `note:${draftId.value}`;
}
return key;
});
const placeholder = computed((): string => {
- if (props.renote) {
+ if (renote.value) {
return i18n.ts._postForm.quotePlaceholder;
- } else if (props.reply) {
+ } else if (reply.value) {
return i18n.ts._postForm.replyPlaceholder;
- } else if (props.channel) {
+ } else if (channel.value) {
return i18n.ts._postForm.channelPlaceholder;
} else {
const xs = [
@@ -244,9 +253,9 @@ const placeholder = computed((): string => {
});
const submitText = computed((): string => {
- return props.renote
+ return renote.value
? i18n.ts.quote
- : props.reply
+ : reply.value
? i18n.ts.reply
: i18n.ts.note;
});
@@ -265,8 +274,8 @@ const canPost = computed((): boolean => {
1 <= textLength.value ||
1 <= files.value.length ||
poll.value != null ||
- props.renote != null ||
- (props.reply != null && quoteId.value != null)
+ renote.value != null ||
+ (reply.value != null && quoteId.value != null)
) &&
(textLength.value <= maxTextLength.value) &&
(!poll.value || poll.value.choices.length >= 2);
@@ -294,13 +303,13 @@ if (props.mention) {
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 ?
@@ -323,32 +332,32 @@ if ($i.isSilenced && visibility.value === 'public') {
visibility.value = 'home';
}
-if (props.channel) {
+if (channel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
}
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
-if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
- if (props.reply.visibility === 'home' && visibility.value === 'followers') {
+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(props.reply.visibility) && visibility.value === 'specified') {
+ } else if (['home', 'followers'].includes(reply.value.visibility) && visibility.value === 'specified') {
visibility.value = 'specified';
} else {
- visibility.value = props.reply.visibility;
+ visibility.value = reply.value.visibility;
}
if (visibility.value === 'specified') {
- if (props.reply.visibleUserIds) {
+ if (reply.value.visibleUserIds) {
misskeyApi('users/show', {
- userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
+ userIds: reply.value.visibleUserIds.filter(uid => uid !== $i.id && uid !== reply.value?.userId),
}).then(users => {
users.forEach(u => pushVisibleUser(u));
});
}
- if (props.reply.userId !== $i.id) {
- misskeyApi('users/show', { userId: props.reply.userId }).then(user => {
+ if (reply.value.userId !== $i.id) {
+ misskeyApi('users/show', { userId: reply.value.userId }).then(user => {
pushVisibleUser(user);
});
}
@@ -361,9 +370,9 @@ if (props.specified) {
}
// keep cw when reply
-if (defaultStore.state.keepCw && props.reply?.cw) {
+if (defaultStore.state.keepCw && reply.value?.cw) {
useCw.value = true;
- cw.value = props.reply.cw;
+ cw.value = reply.value.cw;
}
function watchForDraft() {
@@ -465,7 +474,7 @@ function upload(file: File, name?: string): void {
}
function setVisibility() {
- if (props.channel) {
+ if (channel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
return;
@@ -476,7 +485,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;
@@ -488,7 +497,7 @@ function setVisibility() {
}
async function toggleLocalOnly() {
- if (props.channel) {
+ if (channel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
return;
@@ -605,7 +614,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({
@@ -679,10 +688,32 @@ function onDrop(ev: DragEvent): void {
function saveDraft() {
if (props.instant || props.mock) return;
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
+ const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
draftData[draftKey.value] = {
- updatedAt: new Date(),
+ updatedAt: new Date().toISOString(),
+ channel: channel.value ? {
+ id: channel.value.id,
+ name: channel.value.name,
+ } : undefined,
+ renote: renote.value ? {
+ id: renote.value.id,
+ text: (renote.value.cw ?? renote.value.text)?.substring(0, 100),
+ user: {
+ id: renote.value.userId,
+ username: renote.value.user.username,
+ host: renote.value.user.host,
+ },
+ } : undefined,
+ reply: reply.value ? {
+ id: reply.value.id,
+ text: (reply.value.cw ?? reply.value.text)?.substring(0, 100),
+ user: {
+ id: reply.value.userId,
+ username: reply.value.user.username,
+ host: reply.value.user.host,
+ },
+ } : undefined,
data: {
text: text.value,
useCw: useCw.value,
@@ -700,13 +731,75 @@ function saveDraft() {
}
function deleteDraft() {
- const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
+ const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
delete draftData[draftKey.value];
+ draftId.value = Date.now().toString();
+
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
}
+async function openDrafts() {
+ const { canceled, selected } = await new Promise<{canceled: boolean, selected: string | undefined}>(resolve => {
+ os.popup(MkDraftsDialog, {}, {
+ done: result => {
+ resolve(typeof result.selected === 'string' ? result : { canceled: true, selected: undefined });
+ },
+ }, 'closed');
+ });
+
+ if (canceled) return;
+
+ if (selected) {
+ const channelId = selected.startsWith('channel:') ? selected.match(/channel:(.+?)(renote|reply|note):/)?.[1] : undefined;
+ const renoteId = selected.includes('renote:') ? selected.match(/renote:(.+)/)?.[1] : undefined;
+ const replyId = selected.includes('reply:') ? selected.match(/reply:(.+)/)?.[1] : undefined;
+
+ channel.value = channelId ? await misskeyApi('channels/show', { channelId }) : null;
+ renote.value = renoteId ? await misskeyApi('notes/show', { noteId: renoteId }) : null;
+ reply.value = replyId ? await misskeyApi('notes/show', { noteId: replyId }) : null;
+
+ if (!renote.value && !reply.value) {
+ draftId.value = selected.match(/note:(.+)/)?.[1] ?? Date.now().toString();
+ } else {
+ draftId.value = Date.now().toString();
+ }
+
+ loadDraft(true);
+ }
+}
+
+function loadDraft(exactMatch: boolean = false) {
+ const drafts = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record;
+ const scope = exactMatch ? draftKey.value : draftKey.value.replace(`note:${draftId.value}`, 'note:');
+ const draft = Object.entries(drafts).filter(([k]) => k.startsWith(scope))
+ .map(r => ({ key: r[0], value: { ...r[1], updatedAt: new Date(r[1].updatedAt).getTime() } }))
+ .sort((a, b) => b.value.updatedAt - a.value.updatedAt).at(0);
+
+ if (draft) {
+ if (scope !== draft.key) {
+ draftId.value = draft.key.replace(scope, '');
+ }
+
+ text.value = draft.value.data.text;
+ useCw.value = draft.value.data.useCw;
+ cw.value = draft.value.data.cw;
+ visibility.value = draft.value.data.visibility;
+ localOnly.value = draft.value.data.localOnly;
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ files.value = draft.value.data.files?.filter(f => f?.id && f.type && f.name) || [];
+ if (draft.value.data.poll) {
+ poll.value = draft.value.data.poll;
+ }
+ if (draft.value.data.visibleUserIds) {
+ misskeyApi('users/show', { userIds: draft.value.data.visibleUserIds }).then(users => {
+ users.forEach(u => pushVisibleUser(u));
+ });
+ }
+ }
+}
+
async function post(ev?: MouseEvent) {
if (useCw.value && (cw.value == null || cw.value.trim() === '')) {
os.alert({
@@ -764,9 +857,9 @@ async function post(ev?: MouseEvent) {
text: text.value === '' ? null : text.value,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
fileIds: files.value.length > 0 ? files.value.filter(f => f?.id).map(f => f.id) : undefined,
- replyId: props.reply ? props.reply.id : undefined,
- renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined,
- channelId: props.channel ? props.channel.id : undefined,
+ replyId: reply.value ? reply.value.id : undefined,
+ renoteId: renote.value ? renote.value.id : quoteId.value ? quoteId.value : undefined,
+ channelId: channel.value ? channel.value.id : undefined,
poll: poll.value,
cw: useCw.value ? cw.value ?? '' : null,
localOnly: localOnly.value,
@@ -854,7 +947,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');
}
@@ -957,25 +1050,8 @@ onMounted(() => {
nextTick(() => {
// 書きかけの投稿を復元
- if (!props.instant && !props.mention && !props.specified && !props.mock) {
- 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;
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- files.value = draft.data.files?.filter(f => f?.id && f.type && f.name) || [];
- if (draft.data.poll) {
- poll.value = draft.data.poll;
- }
- if (draft.data.visibleUserIds) {
- misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => {
- users.forEach(u => pushVisibleUser(u));
- });
- }
- }
+ if (!props.instant && !props.mention && !props.specified && !props.mock && defaultStore.state.autoloadDrafts) {
+ loadDraft();
}
// 削除して編集
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index db7d5ddcf..978175d52 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -42,6 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.add }}
{{ i18n.ts.remove }}
+ {{ i18n.ts.autoloadDrafts }}
@@ -302,6 +303,7 @@ const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
+const autoloadDrafts = computed(defaultStore.makeGetterSetter('autoloadDrafts'));
const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index da6fb5f6b..999e9a89f 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -84,6 +84,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useBlurEffect',
'showFixedPostForm',
'showFixedPostFormInChannel',
+ 'autoloadDrafts',
'enableInfiniteScroll',
'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline',
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 81b21dd5e..d2e73dd8a 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -279,6 +279,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ autoloadDrafts: {
+ where: 'device',
+ default: true,
+ },
enableInfiniteScroll: {
where: 'device',
default: true,
diff --git a/packages/frontend/src/types/note-draft-item.ts b/packages/frontend/src/types/note-draft-item.ts
new file mode 100644
index 000000000..a7ec5962e
--- /dev/null
+++ b/packages/frontend/src/types/note-draft-item.ts
@@ -0,0 +1,38 @@
+import * as Misskey from 'misskey-js';
+import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
+
+export type NoteDraftItem = {
+ updatedAt: string;
+ channel?: {
+ id: string;
+ name: string;
+ };
+ renote?: {
+ id: string;
+ text: string | null;
+ user: {
+ id: string;
+ username: string;
+ host: string | null;
+ };
+ };
+ reply?: {
+ id: string;
+ text: string | null;
+ user: {
+ id: string;
+ username: string;
+ host: string | null;
+ };
+ };
+ data: {
+ text: string;
+ useCw: boolean;
+ cw: string | null;
+ visibility: 'public' | 'followers' | 'home' | 'specified';
+ localOnly: boolean;
+ files: Misskey.entities.DriveFile[];
+ poll: PollEditorModelValue | null;
+ visibleUserIds?: string[];
+ };
+};