feat(note): 予約投稿 (MisskeyIO#890)

This commit is contained in:
あわわわとーにゅ 2025-01-16 22:35:27 +09:00 committed by GitHub
parent 509f385402
commit cbe80fdd26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1633 additions and 166 deletions

View file

@ -10,14 +10,18 @@
<template #header>
{{ i18n.ts.drafts }}
</template>
<div style="display: flex; flex-direction: column">
<MkTab v-if="$i!.policies.canScheduleNote" v-model="tab" style="margin-bottom: var(--margin);">
<option value="unsent">{{ i18n.ts.unsent }}</option>
<option value="scheduled">{{ i18n.ts.scheduled }}</option>
</MkTab>
<div v-if="tab === 'unsent'" style="display: flex; flex-direction: column">
<div v-if="drafts.length === 0" class="empty">
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
<div v-for="draft in drafts" :key="draft.id" :class="$style.draftItem">
<div v-for="draft in drafts" :key="draft.id" :class="[$style.draftItem, $style.draftItemHover]">
<div :class="$style.draftNote" @click="selectDraft(draft.id)">
<div :class="$style.draftNoteHeader">
<div :class="$style.draftNoteDestination">
@ -35,7 +39,13 @@
</span>
</div>
<div :class="$style.draftNoteInfo">
<MkTime :time="draft.createdAt" colored />
<div style="display: flex; gap: 4px">
<div v-if="draft.scheduledAt" style="display: flex; opacity: 0.6">
<span><i class="ti ti-calendar-clock" style="margin-right: 4px;"/></span>
<MkTime :time="draft.scheduledAt"/>
</div>
<MkTime :time="draft.createdAt" colored />
</div>
<span v-if="draft.visibility !== 'public'" :title="i18n.ts._visibility[draft.visibility]" style="margin-left: 0.5em">
<i v-if="draft.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
@ -56,24 +66,94 @@
<MkSubNoteContent :class="$style.draftNoteText" :note="draft" />
</div>
</div>
<button :class="$style.delete" class="_button" @click="removeDraft(draft.id)">
<button v-tooltip="i18n.ts.delete" :class="$style.button" class="_button" @click="removeDraft(draft.id)">
<i class="ti ti-trash"></i>
</button>
</div>
</div>
<MkPagination v-if="tab === 'scheduled'" ref="scheduledPaginationEl" :pagination="scheduledPagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #default="{ items }">
<div v-for="draft in items.map(x => convertNoteDraftToNoteCompat(x))" :key="draft.id" :class="$style.draftItem">
<div :class="$style.draftNote">
<div :class="$style.draftNoteHeader">
<div :class="$style.draftNoteDestination">
<span v-if="draft.channel" style="opacity: 0.7; padding-right: 0.5em">
<i class="ti ti-device-tv"></i> {{ draft.channel.name }}
</span>
<span v-if="draft.renote">
<i class="ti ti-quote"></i> <MkAcct :user="draft.renote.user" /> <span>{{ draft.renote.text }}</span>
</span>
<span v-else-if="draft.reply">
<i class="ti ti-arrow-back-up"></i> <MkAcct :user="draft.reply.user" /> <span>{{ draft.reply.text }}</span>
</span>
<span v-else>
<i class="ti ti-pencil"></i>
</span>
</div>
<div :class="$style.draftNoteInfo">
<div style="display: flex; gap: 4px">
<div v-if="draft.scheduledAt" style="display: flex; opacity: 0.6">
<span><i class="ti ti-calendar-clock" style="margin-right: 4px;"/></span>
<MkTime :time="draft.scheduledAt"/>
</div>
<div v-else style="display: flex; opacity: 0.6">
<span><i class="ti ti-exclamation-circle"/></span>
</div>
<MkTime :time="draft.createdAt" colored />
</div>
<span v-if="draft.visibility !== 'public'" :title="i18n.ts._visibility[draft.visibility]" style="margin-left: 0.5em">
<i v-if="draft.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="draft.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']" style="margin-left: 0.5em">
<i class="ti ti-rocket-off"></i>
</span>
<span v-if="draft.channel" :title="draft.channel.name" style="margin-left: 0.5em">
<i class="ti ti-device-tv"></i>
</span>
</div>
</div>
<div>
<p v-if="!!draft.cw" :class="$style.draftNoteCw">
<Mfm :text="draft.cw" />
</p>
<MkSubNoteContent :class="$style.draftNoteText" :note="draft" />
<div v-if="draft.reason" style="opacity: 0.6; margin-top: 4px">
{{ i18n.ts.error }}: {{ draft.reason }}
</div>
</div>
</div>
<button v-tooltip="i18n.ts.unschedule" :class="$style.button" class="_button" @click="unschedule(draft.id)">
<i class="ti ti-calendar-x"></i>
</button>
<button v-tooltip="i18n.ts.delete" :class="$style.button" class="_button" @click="cancelScheduled(draft.id)">
<i class="ti ti-trash"></i>
</button>
</div>
</template>
</MkPagination>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { onActivated, onMounted, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import type { NoteDraftItem } from '@/types/note-draft-item.js';
import MkPagination from '@/components/MkPagination.vue';
import MkTab from '@/components/MkTab.vue';
const emit = defineEmits<{
(ev: 'done', v: { canceled: true } | { canceled: false; selected: string | undefined }): void;
@ -81,23 +161,30 @@ const emit = defineEmits<{
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const tab = ref('unsent');
const drafts = ref<(Misskey.entities.Note & { useCw: boolean })[]>([]);
const drafts = ref<(Misskey.entities.Note & { useCw: boolean, scheduledAt: string })[]>([]);
onMounted(loadDrafts);
onActivated(loadDrafts);
function loadDrafts() {
const stored = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, NoteDraftItem>;
drafts.value = Object.keys(stored).map((key) => ({
...(stored[key].data as Misskey.entities.Note & { useCw: boolean }),
id: key,
createdAt: stored[key].updatedAt,
channel: stored[key].channel as Misskey.entities.Channel,
renote: stored[key].renote as Misskey.entities.Note,
reply: stored[key].reply as Misskey.entities.Note,
function convertNoteDraftToNoteCompat(draft: Misskey.entities.NoteDraft, key?: string) {
return {
...(draft.data as Misskey.entities.Note & { useCw: boolean }),
id: key ?? draft.id,
createdAt: draft.updatedAt,
scheduledAt: draft.scheduledAt,
reason: draft.reason,
channel: draft.channel as Misskey.entities.Channel,
renote: draft.renote as Misskey.entities.Note,
reply: draft.reply as Misskey.entities.Note,
user: $i as Misskey.entities.User,
}));
};
}
function loadDrafts() {
const stored = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, Misskey.entities.NoteDraft>;
drafts.value = Object.keys(stored).map((key) => convertNoteDraftToNoteCompat(stored[key], key));
}
function selectDraft(draft: string) {
@ -105,7 +192,7 @@ function selectDraft(draft: string) {
}
function removeDraft(draft: string) {
const stored = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, NoteDraftItem>;
const stored = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, Misskey.entities.NoteDraft>;
delete stored[draft];
miLocalStorage.setItem('drafts', JSON.stringify(stored));
@ -113,12 +200,53 @@ function removeDraft(draft: string) {
loadDrafts();
}
function unschedule(draft: string) {
const item = scheduledPaginationEl.value!.items.find(x => x.id === draft);
if (!item) return;
let key = item.channel ? `channel:${item.channel.id}` : '';
if (item.renote) {
key += `renote:${item.renote.id}`;
} else if (item.reply) {
key += `reply:${item.reply.id}`;
} else {
key += `note:${item.id}`;
}
const stored = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, Misskey.entities.NoteDraft>;
stored[key] = item as unknown as Misskey.entities.NoteDraft;
miLocalStorage.setItem('drafts', JSON.stringify(stored));
cancelScheduled(item.id);
loadDrafts();
tab.value = 'unsent';
}
function cancelScheduled(draft: string) {
os.apiWithDialog('notes/scheduled/cancel', {
draftId: draft,
}).then(() => {
scheduledPaginationEl.value?.reload();
});
}
function done(canceled: boolean, selected?: string): void {
emit('done', { canceled, selected } as
| { canceled: true }
| { canceled: false; selected: string | undefined });
dialog.value?.close();
}
const scheduledPaginationEl = ref<InstanceType<typeof MkPagination>>();
const scheduledPagination = {
endpoint: 'notes/scheduled/list' as const,
offsetMode: true,
limit: 10,
params: {},
};
</script>
<style lang="scss" module>
@ -126,10 +254,12 @@ function done(canceled: boolean, selected?: string): void {
display: flex;
padding: 8px 0 8px 0;
border-bottom: 1px solid var(--divider);
}
.draftItemHover {
&:hover {
color: var(--accent);
background-color: var(--accentedBg);
background: var(--accentedBg);
}
}
@ -169,15 +299,19 @@ function done(canceled: boolean, selected?: string): void {
cursor: default;
}
.delete {
.button {
width: 48px;
height: 64px;
display: flex;
align-self: center;
justify-content: center;
align-items: center;
background-color: var(--buttonBg);
background: var(--buttonBg);
border-radius: 4px;
margin-right: 4px;
&:hover {
background: var(--buttonHoverBg);
}
}
</style>

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root">
<div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'noteScheduled', 'scheduledNotePosted', 'scheduledNoteError'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@ -25,6 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_quote]: notification.type === 'quote',
[$style.t_pollEnded]: notification.type === 'pollEnded',
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_noteScheduled]: notification.type === 'noteScheduled',
[$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
[$style.t_scheduledNoteError]: notification.type === 'scheduledNoteError',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
}]"
>
@ -37,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'noteScheduled'" class="ti ti-calendar-clock"></i>
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-calendar-check"></i>
<i v-else-if="notification.type === 'scheduledNoteError'" class="ti ti-calendar-exclamation"></i>
<template v-else-if="notification.type === 'roleAssigned'">
<img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/>
<i v-else class="ti ti-badges"></i>
@ -52,16 +58,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.tail">
<header :class="$style.header">
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<span v-if="notification.type === 'pollEnded'" :class="$style.headerName">{{ i18n.ts._notification.pollEnded }}</span>
<span v-else-if="notification.type === 'note'" :class="$style.headerName">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
<span v-else-if="notification.type === 'roleAssigned'" :class="$style.headerName">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'achievementEarned'" :class="$style.headerName">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'noteScheduled'" :class="$style.headerName">{{ i18n.ts._notification.noteScheduled }}</span>
<span v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.headerName">{{ i18n.ts._notification.scheduledNotePosted }}</span>
<span v-else-if="notification.type === 'scheduledNoteError'" :class="$style.headerName">{{ i18n.ts._notification.scheduledNoteError }}</span>
<span v-else-if="notification.type === 'test'" :class="$style.headerName">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="$style.headerName">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'" :class="$style.headerName">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'" :class="$style.headerName">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
<span v-else-if="notification.type === 'app'" :class="$style.headerName">{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>
<div>
@ -98,6 +107,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA>
<div v-else-if="notification.type === 'noteScheduled'">
<Mfm :class="$style.text" :text="getNoteSummary(notification.draft.data as unknown as Misskey.entities.Note)" :plain="true" :nowrap="true"/>
<div v-if="notification.draft.scheduledAt" :class="$style.text" style="opacity: 0.6;">
<span><i class="ti ti-calendar-clock" style="margin-right: 4px;"/></span>
<MkTime :time="notification.draft.scheduledAt"/>
</div>
</div>
<MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
</MkA>
<div v-else-if="notification.type === 'scheduledNoteError'">
<Mfm :class="$style.text" :text="getNoteSummary(notification.draft.data as unknown as Misskey.entities.Note)" :plain="true" :nowrap="true"/>
<div v-if="notification.draft.reason" :class="$style.text" style="opacity: 0.6;">
<span><i class="ti ti-exclamation-circle" style="margin-right: 4px;"/></span>
{{ notification.draft.reason }}
</div>
</div>
<template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div>
@ -300,6 +326,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none;
}
.t_noteScheduled, .t_scheduledNotePosted, .t_scheduledNoteError {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_roleAssigned {
padding: 3px;
background: var(--eventOther);

View file

@ -81,6 +81,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="mk-input-text" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<div v-if="scheduledTime" :class="$style.scheduledTime">
<div><i class="ti ti-calendar-clock"></i></div>
<span>{{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }}</span>
<button class="_button" style="margin-left: auto" @click="scheduledTime = null"><i class="ti ti-x"></i></button>
</div>
<MkInfo v-if="files.length > 0" warn :class="$style.guidelineInfo" :rounded="false"><Mfm :text="i18n.tsx._postForm.guidelineInfo({ tosUrl: instance.tosUrl, nsfwGuideUrl })"/></MkInfo>
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
@ -94,6 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
<button v-if="$i.policies.canScheduleNote" v-tooltip="i18n.ts.setScheduledTime" class="_button" :class="$style.footerButton" @click="setScheduledTime"><i class="ti ti-calendar-clock"></i></button>
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
@ -115,7 +121,6 @@ import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.js';
import type { NoteDraftItem } from '@/types/note-draft-item.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
@ -138,8 +143,8 @@ import { uploadFile } from '@/scripts/upload.js';
import { deepClone } from '@/scripts/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js';
import { dateTimeFormat } from '@/scripts/intl-const.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
const $i = signinRequired();
@ -211,6 +216,7 @@ if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const scheduledTime = ref<Date | null>(null);
const autocompleteTextareaInput = ref<Autocomplete | null>(null);
const autocompleteCwInput = ref<Autocomplete | null>(null);
const autocompleteHashtagsInput = ref<Autocomplete | null>(null);
@ -259,11 +265,15 @@ const placeholder = computed((): string => {
});
const submitText = computed((): string => {
return renote.value
? i18n.ts.quote
: reply.value
? i18n.ts.reply
: i18n.ts.note;
if (scheduledTime.value) {
return i18n.ts.schedule;
} else if (renote.value) {
return i18n.ts.quote;
} else if (reply.value) {
return i18n.ts.reply;
} else {
return i18n.ts.note;
}
});
const textLength = computed((): number => {
@ -389,6 +399,7 @@ function watchForDraft() {
watch(files, () => saveDraft(), { deep: true });
watch(visibility, () => saveDraft());
watch(localOnly, () => saveDraft());
watch(scheduledTime, () => saveDraft());
}
function checkMissingMention() {
@ -583,10 +594,25 @@ function removeVisibleUser(user) {
visibleUsers.value = erase(user, visibleUsers.value);
}
async function setScheduledTime() {
const { canceled, result: date } = await os.inputDateTime({
title: i18n.ts.setScheduledTime,
});
if (canceled) return;
scheduledTime.value = date;
}
function clear() {
text.value = '';
useCw.value = false;
cw.value = null;
visibility.value = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
localOnly.value = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
files.value = [];
poll.value = null;
visibleUsers.value = [];
scheduledTime.value = null;
quoteId.value = null;
}
@ -694,10 +720,16 @@ function onDrop(ev: DragEvent): void {
function saveDraft() {
if (props.instant || props.mock) return;
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, NoteDraftItem>;
let scheduledAt = scheduledTime.value ?? null;
if (scheduledAt && (isNaN(scheduledAt.getTime()) || scheduledAt.getTime() < Date.now())) {
scheduledAt = null;
}
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, Misskey.entities.NoteDraft>;
draftData[draftKey.value] = {
updatedAt: new Date().toISOString(),
scheduledAt: scheduledAt?.toISOString() ?? null,
channel: channel.value ? {
id: channel.value.id,
name: channel.value.name,
@ -737,7 +769,7 @@ function saveDraft() {
}
function deleteDraft() {
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, NoteDraftItem>;
const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, Misskey.entities.NoteDraft>;
delete draftData[draftKey.value];
@ -777,7 +809,7 @@ async function openDrafts() {
}
function loadDraft(exactMatch = false) {
const drafts = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, NoteDraftItem>;
const drafts = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as Record<string, Misskey.entities.NoteDraft>;
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() } }))
@ -788,7 +820,11 @@ function loadDraft(exactMatch = false) {
draftId.value = draft.key.replace(scope, '');
}
text.value = draft.value.data.text;
scheduledTime.value = draft.value.scheduledAt ? new Date(draft.value.scheduledAt) : null;
if (scheduledTime.value && (isNaN(scheduledTime.value.getTime()) || scheduledTime.value.getTime() < Date.now())) {
scheduledTime.value = null;
}
text.value = draft.value.data.text ?? '';
useCw.value = draft.value.data.useCw;
cw.value = draft.value.data.cw;
visibility.value = draft.value.data.visibility;
@ -872,6 +908,7 @@ async function post(ev?: MouseEvent) {
visibility: visibility.value,
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined,
reactionAcceptance: reactionAcceptance.value,
scheduledAt: scheduledTime.value?.getTime() ?? undefined,
noCreatedNote: true,
};
@ -1079,6 +1116,7 @@ onMounted(() => {
visibility.value = init.visibility;
localOnly.value = init.localOnly ?? false;
quoteId.value = init.renote ? init.renote.id : null;
scheduledTime.value = null;
}
nextTick(() => watchForDraft());
@ -1352,6 +1390,13 @@ defineExpose({
}
}
.scheduledTime {
display: flex;
padding: 8px 24px;
gap: 4px;
background: var(--infoBg);
}
.footer {
display: flex;
padding: 0 16px 16px 16px;

View file

@ -67,6 +67,9 @@ export const notificationTypes = [
'followRequestAccepted',
'roleAssigned',
'achievementEarned',
'noteScheduled',
'scheduledNotePosted',
'scheduledNoteError',
'app',
] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
@ -75,6 +78,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'canScheduleNote',
'canInitiateConversation',
'canCreateContent',
'canUpdateContent',

View file

@ -427,28 +427,36 @@ export function inputNumber(props: {
});
}
export function inputDate(props: {
export function inputDateTime(props: {
title?: string | null;
text?: string | null;
placeholder?: string | null;
default?: string | null;
default?: Date | null;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: Date;
}> {
const defaultValue = props.default ?? new Date();
defaultValue.setMinutes(defaultValue.getMinutes() - defaultValue.getTimezoneOffset());
return new Promise(resolve => {
popup(MkDialog, {
title: props.title ?? undefined,
text: props.text ?? undefined,
input: {
type: 'date',
type: 'datetime-local',
placeholder: props.placeholder,
default: props.default ?? null,
default: defaultValue.toISOString().slice(0, -5),
},
}, {
done: result => {
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
const date = result ? new Date(result.result) : undefined;
if (date && !isNaN(date.getTime())) {
resolve({ result: date, canceled: false });
} else {
resolve({ result: undefined, canceled: true });
}
},
}, 'closed');
});

View file

@ -165,6 +165,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canScheduleNote, 'canScheduleNote'])">
<template #label>{{ i18n.ts._role._options.canScheduleNote }}</template>
<template #suffix>
<span v-if="role.policies.canScheduleNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canScheduleNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canScheduleNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canScheduleNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canScheduleNote.value" :disabled="role.policies.canScheduleNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canScheduleNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInitiateConversation, 'canInitiateConversation'])">
<template #label>{{ i18n.ts._role._options.canInitiateConversation }}</template>
<template #suffix>

View file

@ -48,6 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canScheduleNote, 'canScheduleNote'])">
<template #label>{{ i18n.ts._role._options.canScheduleNote }}</template>
<template #suffix>{{ policies.canScheduleNote ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canScheduleNote">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInitiateConversation, 'canInitiateConversation'])">
<template #label>{{ i18n.ts._role._options.canInitiateConversation }}</template>
<template #suffix>{{ policies.canInitiateConversation ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -57,7 +57,7 @@ function top() {
}
async function timetravel() {
const { canceled, result: date } = await os.inputDate({
const { canceled, result: date } = await os.inputDateTime({
title: i18n.ts.date,
});
if (canceled) return;

View file

@ -221,7 +221,7 @@ function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue:
}
async function timetravel(): Promise<void> {
const { canceled, result: date } = await os.inputDate({
const { canceled, result: date } = await os.inputDateTime({
title: i18n.ts.date,
});
if (canceled) return;

View file

@ -1,38 +0,0 @@
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[];
};
};