feat: 投稿フォームに下書きを適用できるように
This commit is contained in:
parent
07ebc6e5e1
commit
d564bd73bc
|
@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<span :class="$style.headerRightButtonText">{{ channel.name }}</span>
|
<span :class="$style.headerRightButtonText">{{ channel.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
<button v-click-anime v-tooltip="i18n.ts.drafts" class="_button" :class="$style.headerRightItem" @click="chooseDraft"><i class="ti ti-note"></i></button>
|
||||||
<button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
|
<button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
|
||||||
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
|
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
|
||||||
<span v-else><i class="ti ti-rocket-off"></i></span>
|
<span v-else><i class="ti ti-rocket-off"></i></span>
|
||||||
|
@ -115,6 +116,7 @@ import { extractMentions } from '@/scripts/extract-mentions.js';
|
||||||
import { formatTimeString } from '@/scripts/format-time-string.js';
|
import { formatTimeString } from '@/scripts/format-time-string.js';
|
||||||
import { Autocomplete } from '@/scripts/autocomplete.js';
|
import { Autocomplete } from '@/scripts/autocomplete.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
import * as noteDrafts from '@/scripts/note-drafts.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { selectFiles } from '@/scripts/select-file.js';
|
import { selectFiles } from '@/scripts/select-file.js';
|
||||||
import { dateTimeFormat } from '@/scripts/intl-const.js';
|
import { dateTimeFormat } from '@/scripts/intl-const.js';
|
||||||
|
@ -197,6 +199,8 @@ let schedule = ref<{
|
||||||
scheduledAt: string | null;
|
scheduledAt: string | null;
|
||||||
}| null>(null);
|
}| null>(null);
|
||||||
const useCw = ref<boolean>(!!props.initialCw);
|
const useCw = ref<boolean>(!!props.initialCw);
|
||||||
|
const renote = ref(props.renote);
|
||||||
|
const reply = ref(props.reply);
|
||||||
const showPreview = ref(defaultStore.state.showPreview);
|
const showPreview = ref(defaultStore.state.showPreview);
|
||||||
watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
|
watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
|
||||||
const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction);
|
const showAddMfmFunction = ref(defaultStore.state.enableQuickAddMfmFunction);
|
||||||
|
@ -219,24 +223,19 @@ const imeText = ref('');
|
||||||
const showingOptions = ref(false);
|
const showingOptions = ref(false);
|
||||||
const textAreaReadOnly = ref(false);
|
const textAreaReadOnly = ref(false);
|
||||||
|
|
||||||
const draftKey = computed((): string => {
|
const draftType = computed(() => {
|
||||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
if (props.channel) return 'channel';
|
||||||
|
if (renote.value) return 'quote';
|
||||||
if (props.renote) {
|
if (reply.value) return 'reply';
|
||||||
key += `renote:${props.renote.id}`;
|
return 'note';
|
||||||
} else if (props.reply) {
|
|
||||||
key += `reply:${props.reply.id}`;
|
|
||||||
} else {
|
|
||||||
key += `note:${$i.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return key;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const draftAuxId = computed<string | null>(() => props.channel ? props.channel.id : renote.value ? renote.value.id : reply.value ? reply.value.id : null);
|
||||||
|
|
||||||
const placeholder = computed((): string => {
|
const placeholder = computed((): string => {
|
||||||
if (props.renote) {
|
if (renote.value) {
|
||||||
return i18n.ts._postForm.quotePlaceholder;
|
return i18n.ts._postForm.quotePlaceholder;
|
||||||
} else if (props.reply) {
|
} else if (reply.value) {
|
||||||
return i18n.ts._postForm.replyPlaceholder;
|
return i18n.ts._postForm.replyPlaceholder;
|
||||||
} else if (props.channel) {
|
} else if (props.channel) {
|
||||||
return i18n.ts._postForm.channelPlaceholder;
|
return i18n.ts._postForm.channelPlaceholder;
|
||||||
|
@ -254,9 +253,9 @@ const placeholder = computed((): string => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitText = computed((): string => {
|
const submitText = computed((): string => {
|
||||||
return props.renote
|
return renote.value
|
||||||
? i18n.ts.quote
|
? i18n.ts.quote
|
||||||
: props.reply
|
: reply.value
|
||||||
? i18n.ts.reply
|
? i18n.ts.reply
|
||||||
: schedule.value
|
: schedule.value
|
||||||
? i18n.ts._schedulePost.addSchedule
|
? i18n.ts._schedulePost.addSchedule
|
||||||
|
@ -273,13 +272,7 @@ const maxTextLength = computed((): number => {
|
||||||
|
|
||||||
const canPost = computed((): boolean => {
|
const canPost = computed((): boolean => {
|
||||||
return !props.mock && !posting.value && !posted.value &&
|
return !props.mock && !posting.value && !posted.value &&
|
||||||
(
|
(1 <= textLength.value || 1 <= files.value.length || !!poll.value || !!renote.value) &&
|
||||||
1 <= textLength.value ||
|
|
||||||
1 <= files.value.length ||
|
|
||||||
poll.value != null ||
|
|
||||||
props.renote != null ||
|
|
||||||
(props.reply != null && quoteId.value != null)
|
|
||||||
) &&
|
|
||||||
(textLength.value <= maxTextLength.value) &&
|
(textLength.value <= maxTextLength.value) &&
|
||||||
(!poll.value || poll.value.choices.length >= 2);
|
(!poll.value || poll.value.choices.length >= 2);
|
||||||
});
|
});
|
||||||
|
@ -313,18 +306,19 @@ watch(visibleUsers, () => {
|
||||||
deep: true,
|
deep: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (props.mention) {
|
function initialize() {
|
||||||
|
if (props.mention) {
|
||||||
text.value = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
|
text.value = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
|
||||||
text.value += ' ';
|
text.value += ' ';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
|
if (reply.value && (reply.value.user.username !== $i.username || (reply.value.user.host != null && reply.value.user.host !== host))) {
|
||||||
text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
|
text.value = `@${reply.value.user.username}${reply.value.user.host != null ? '@' + toASCII(reply.value.user.host) : ''} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.reply && props.reply.text != null) {
|
if (reply.value && reply.value.text != null) {
|
||||||
const ast = mfm.parse(props.reply.text);
|
const ast = mfm.parse(reply.value.text);
|
||||||
const otherHost = props.reply.user.host;
|
const otherHost = reply.value.user.host;
|
||||||
|
|
||||||
for (const x of extractMentions(ast)) {
|
for (const x of extractMentions(ast)) {
|
||||||
const mention = x.host ?
|
const mention = x.host ?
|
||||||
|
@ -341,54 +335,57 @@ if (props.reply && props.reply.text != null) {
|
||||||
|
|
||||||
text.value += `${mention} `;
|
text.value += `${mention} `;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($i.isSilenced && visibility.value === 'public') {
|
if ($i.isSilenced && visibility.value === 'public') {
|
||||||
visibility.value = 'home';
|
visibility.value = 'home';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.channel) {
|
if (props.channel) {
|
||||||
visibility.value = 'public';
|
visibility.value = 'public';
|
||||||
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
|
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
|
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
|
||||||
if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
|
if (reply.value && ['home', 'followers', 'specified'].includes(reply.value.visibility)) {
|
||||||
if (props.reply.visibility === 'home' && visibility.value === 'followers') {
|
if (reply.value.visibility === 'home' && visibility.value === 'followers') {
|
||||||
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';
|
visibility.value = 'specified';
|
||||||
} else {
|
} else {
|
||||||
visibility.value = props.reply.visibility;
|
visibility.value = reply.value.visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility.value === 'specified') {
|
if (visibility.value === 'specified') {
|
||||||
if (props.reply.visibleUserIds) {
|
if (reply.value.visibleUserIds) {
|
||||||
misskeyApi('users/show', {
|
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 => {
|
}).then(users => {
|
||||||
users.forEach(pushVisibleUser);
|
users.forEach(pushVisibleUser);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.reply.userId !== $i.id) {
|
if (reply.value.userId !== $i.id) {
|
||||||
misskeyApi('users/show', { userId: props.reply.userId }).then(user => {
|
misskeyApi('users/show', { userId: reply.value.userId }).then(user => {
|
||||||
pushVisibleUser(user);
|
pushVisibleUser(user);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.specified) {
|
if (props.specified) {
|
||||||
visibility.value = 'specified';
|
visibility.value = 'specified';
|
||||||
pushVisibleUser(props.specified);
|
pushVisibleUser(props.specified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep cw when reply
|
||||||
|
if (defaultStore.state.keepCw && reply.value && reply.value.cw) {
|
||||||
|
useCw.value = true;
|
||||||
|
cw.value = reply.value.cw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// keep cw when reply
|
initialize();
|
||||||
if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
|
|
||||||
useCw.value = true;
|
|
||||||
cw.value = props.reply.cw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchForDraft() {
|
function watchForDraft() {
|
||||||
watch(text, () => saveDraft());
|
watch(text, () => saveDraft());
|
||||||
|
@ -508,7 +505,7 @@ function setVisibility() {
|
||||||
currentVisibility: visibility.value,
|
currentVisibility: visibility.value,
|
||||||
isSilenced: $i.isSilenced, localOnly: localOnly.value,
|
isSilenced: $i.isSilenced, localOnly: localOnly.value,
|
||||||
src: visibilityButton.value,
|
src: visibilityButton.value,
|
||||||
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
|
...(reply.value ? { isReplyVisibilitySpecified: reply.value.visibility === 'specified' } : {}),
|
||||||
}, {
|
}, {
|
||||||
changeVisibility: v => {
|
changeVisibility: v => {
|
||||||
visibility.value = v;
|
visibility.value = v;
|
||||||
|
@ -635,7 +632,7 @@ async function onPaste(ev: ClipboardEvent) {
|
||||||
|
|
||||||
const paste = ev.clipboardData.getData('text');
|
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();
|
ev.preventDefault();
|
||||||
|
|
||||||
os.confirm({
|
os.confirm({
|
||||||
|
@ -706,14 +703,15 @@ function onDrop(ev: DragEvent): void {
|
||||||
//#endregion
|
//#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDraft() {
|
function saveDraft(auto = true) {
|
||||||
if (props.instant || props.mock) return;
|
if (props.instant || props.mock) return;
|
||||||
|
|
||||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
|
if (!auto) {
|
||||||
|
// 手動での保存の場合は自動保存したものを削除した上で保存
|
||||||
|
noteDrafts.remove(draftType.value, $i.id, 'default', draftAuxId.value as string);
|
||||||
|
}
|
||||||
|
|
||||||
draftData[draftKey.value] = {
|
noteDrafts.set(draftType.value, $i.id, auto ? 'default' : Date.now().toString(), {
|
||||||
updatedAt: new Date(),
|
|
||||||
data: {
|
|
||||||
text: text.value,
|
text: text.value,
|
||||||
useCw: useCw.value,
|
useCw: useCw.value,
|
||||||
cw: cw.value,
|
cw: cw.value,
|
||||||
|
@ -722,18 +720,56 @@ function saveDraft() {
|
||||||
files: files.value,
|
files: files.value,
|
||||||
poll: poll.value,
|
poll: poll.value,
|
||||||
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
|
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
|
||||||
},
|
}, draftAuxId.value as string);
|
||||||
};
|
|
||||||
|
|
||||||
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteDraft() {
|
function deleteDraft() {
|
||||||
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}');
|
noteDrafts.remove(draftType.value, $i.id, 'default', draftAuxId.value as string);
|
||||||
|
}
|
||||||
|
|
||||||
delete draftData[draftKey.value];
|
function chooseDraft() {
|
||||||
|
os.popup(defineAsyncComponent(() => import('@/components/MkPostFormDrafts.vue')), {
|
||||||
|
channelId: props.channel?.id,
|
||||||
|
}, {
|
||||||
|
selected: async (res) => {
|
||||||
|
const draft = await res as noteDrafts.NoteDraft;
|
||||||
|
applyDraft(draft);
|
||||||
|
},
|
||||||
|
}, 'closed');
|
||||||
|
}
|
||||||
|
|
||||||
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
|
async function applyDraft(draft: noteDrafts.NoteDraft, native = false) {
|
||||||
|
if (!native) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
async function post(ev?: MouseEvent) {
|
||||||
|
@ -788,8 +824,8 @@ async function post(ev?: MouseEvent) {
|
||||||
let postData = {
|
let postData = {
|
||||||
text: text.value === '' ? null : text.value,
|
text: text.value === '' ? null : text.value,
|
||||||
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
|
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
|
||||||
replyId: props.reply ? props.reply.id : undefined,
|
replyId: reply.value ? reply.value.id : undefined,
|
||||||
renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined,
|
renoteId: renote.value ? renote.value.id : quoteId.value ? quoteId.value : undefined,
|
||||||
channelId: props.channel ? props.channel.id : undefined,
|
channelId: props.channel ? props.channel.id : undefined,
|
||||||
schedule: schedule.value,
|
schedule: schedule.value,
|
||||||
poll: poll.value,
|
poll: poll.value,
|
||||||
|
@ -887,7 +923,7 @@ async function post(ev?: MouseEvent) {
|
||||||
claimAchievement('brainDiver');
|
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');
|
claimAchievement('selfQuote');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1033,30 +1069,13 @@ onMounted(() => {
|
||||||
if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw);
|
if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw);
|
||||||
if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags);
|
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) {
|
if (!props.instant && !props.mention && !props.specified && !props.mock && !defaultStore.state.disableNoteDrafting) {
|
||||||
const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value];
|
const draft = await noteDrafts.get(draftType.value, $i.id, 'default', draftAuxId.value as string);
|
||||||
if (draft) {
|
if (draft) applyDraft(draft, true);
|
||||||
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.visibleUserIds) {
|
|
||||||
misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => {
|
|
||||||
for (let i = 0; i < users.length; i++) {
|
|
||||||
if (users[i].id === draft.data.visibleUserIds[i]) {
|
|
||||||
pushVisibleUser(users[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 削除して編集
|
// 削除して編集
|
||||||
|
|
|
@ -90,10 +90,6 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||||
where: 'account',
|
where: 'account',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
rememberNoteVisibility: {
|
|
||||||
where: 'account',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
defaultNoteVisibility: {
|
defaultNoteVisibility: {
|
||||||
where: 'account',
|
where: 'account',
|
||||||
default: 'public' as (typeof Misskey.noteVisibilities)[number],
|
default: 'public' as (typeof Misskey.noteVisibilities)[number],
|
||||||
|
|
Loading…
Reference in New Issue