Merge remote-tracking branch 'misskey-mattyatea/schedule-note' into develop

# Conflicts:
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/backend/src/core/RoleService.ts
#	packages/frontend/src/components/MkNoteHeader.vue
#	packages/frontend/src/components/MkPostForm.vue
#	packages/frontend/src/const.ts
#	packages/frontend/src/navbar.ts
#	packages/frontend/src/pages/admin/roles.editor.vue
#	packages/frontend/src/pages/admin/roles.vue
This commit is contained in:
mattyatea 2023-12-02 00:50:04 +09:00
commit b7f9ad1944
39 changed files with 900 additions and 74 deletions

View file

@ -488,9 +488,9 @@ onBeforeUnmount(() => {
.indicator {
position: absolute;
top: 5px;
left: 13px;
right: 18px;
color: var(--indicator);
font-size: 12px;
font-size: 8px;
animation: blink 1s infinite;
}

View file

@ -18,7 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="mock">
<MkTime :time="note.createdAt" colored/>
</div>
<MkA v-else :to="notePage(note)">
<MkTime v-else-if="note.isSchedule" mode="absolute" :time="note.createdAt" colored/>
<MkA v-else :to="notePage(note)">
<MkTime :time="note.createdAt" colored/>
</MkA>
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
@ -40,7 +41,8 @@ import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
const mock = inject<boolean>('mock', false);
defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note & {isSchedule? : boolean};
scheduled?: boolean;
}>();
</script>

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div v-show="!isDeleted" :class="$style.root" :tabindex="!isDeleted ? '-1' : undefined">
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
@ -16,23 +16,72 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-show="note.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>
</div>
<div v-if="note.isSchedule" style="margin-top: 10px;">
<MkButton :class="$style.button" inline @click="editScheduleNote()"><i class="ti ti-pencil"></i> {{ i18n.ts.deleteAndEdit }}</MkButton>
<MkButton :class="$style.button" inline danger @click="deleteScheduleNote()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import { ref } from 'vue';
import { i18n } from '../i18n.js';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { $i } from '@/account.js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
const isDeleted = ref(false);
const props = defineProps<{
note: Misskey.entities.Note;
note: Misskey.entities.Note & {
id: string | null;
isSchedule?: boolean;
scheduledNoteId?: string;
};
}>();
const emit = defineEmits<{
(ev: 'editScheduleNote'): void;
}>();
async function deleteScheduleNote() {
if (!props.note.isSchedule || !props.note.scheduledNoteId) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._schedulePost.deleteAreYouSure,
});
if (canceled) return;
await os.apiWithDialog('notes/schedule/delete', { scheduledNoteId: props.note.scheduledNoteId })
.then(() => {
isDeleted.value = true;
});
}
async function editScheduleNote() {
if (!props.note.isSchedule || !props.note.scheduledNoteId) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._schedulePost.deleteAndEditConfirm,
});
if (canceled) return;
await os.api('notes/schedule/delete', { scheduledNoteId: props.note.scheduledNoteId })
.then(() => {
isDeleted.value = true;
});
await os.post({ initialNote: props.note, renote: props.note.renote, reply: props.note.reply, channel: props.note.channel });
emit('editScheduleNote');
}
const showContent = $ref(false);
</script>
@ -42,8 +91,12 @@ const showContent = $ref(false);
margin: 0;
padding: 0;
font-size: 0.95em;
border-bottom: solid 0.5px var(--divider);
}
.button{
margin-right: var(--margin);
margin-bottom: var(--margin);
}
.avatar {
flex-shrink: 0;
display: block;

View file

@ -36,17 +36,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
<button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance">
<span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span>
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
<span v-else><i class="ti ti-icons"></i></span>
</button>
<button v-tooltip="i18n.ts.otherSettings" class="_button" :class="[$style.headerRightItem]" @click="openOtherSettingsMenu"><i class="ti ti-dots"></i></button>
<button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
<div :class="[$style.submitInner ,{ [$style.gamingDark]: gamingType === 'dark',[$style.gamingLight]: gamingType === 'light' }]">
<template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template>
<template v-else>{{ submitText }}</template>
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i>
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : schedule ? 'ti ti-clock-hour-4' : 'ti ti-send'"></i>
</div>
</button>
</div>
@ -72,7 +68,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<div :class="$style.postOptionsRoot">
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkScheduleEditor v-if="schedule" v-model="schedule" @destroyed="schedule = null"/>
</div>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
@ -116,6 +115,7 @@ import { formatTimeString } from '@/scripts/format-time-string.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
import * as os from '@/os.js';
import { selectFiles } from '@/scripts/select-file.js';
import { dateTimeFormat } from '@/scripts/intl-const.js';
import {
bannerDark,
bannerLight,
@ -134,6 +134,9 @@ import { deepClone } from '@/scripts/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement } from '@/scripts/achievements.js';
import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
import { listSchedulePost } from '@/os.js';
const modal = inject('modal');
let gamingType = computed(defaultStore.makeGetterSetter('gamingType'));
@ -188,6 +191,9 @@ let poll = $ref<{
expiresAt: string | null;
expiredAfter: string | null;
} | null>(null);
let schedule = $ref<{
scheduledAt: string | null;
}| null>(null);
let useCw = $ref(false);
let showPreview = $ref(defaultStore.state.showPreview);
watch($$(showPreview), () => defaultStore.set('showPreview', showPreview));
@ -246,7 +252,9 @@ const submitText = $computed((): string => {
? i18n.ts.quote
: props.reply
? i18n.ts.reply
: i18n.ts.note;
: schedule
? i18n.ts._schedulePost.addSchedule
: i18n.ts.note;
});
const textLength = $computed((): number => {
@ -407,6 +415,16 @@ function togglePoll() {
}
}
function toggleSchedule() {
if (schedule) {
schedule = null;
} else {
schedule = {
scheduledAt: null,
};
}
}
function addTag(tag: string) {
insertTextAtCursor(textareaEl, ` #${tag} `);
}
@ -552,7 +570,7 @@ function removeVisibleUser(user) {
function clear() {
text = '';
files = [];
schedule = null;files = [];
poll = null;
quoteId = null;
}
@ -735,7 +753,7 @@ async function post(ev?: MouseEvent) {
replyId: props.reply ? props.reply.id : undefined,
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
channelId: props.channel ? props.channel.id : undefined,
poll: poll,
schedule, poll,
cw: useCw ? cw ?? '' : null,
localOnly: localOnly,
visibility: visibility,
@ -765,7 +783,7 @@ async function post(ev?: MouseEvent) {
if (postAccount) {
const storedAccounts = await getAccounts();
token = storedAccounts.find(x => x.id === postAccount.id)?.token;
token = storedAccounts.find(x => x.id === postAccount?.id)?.token;
}
posting = true;
@ -790,6 +808,13 @@ async function post(ev?: MouseEvent) {
if (notesCount === 1) {
claimAchievement('notes1');
}
poll = null;
if (postData.schedule?.scheduledAt) {
const d = new Date(postData.schedule.scheduledAt);
const str = dateTimeFormat.format(d);
os.toast(i18n.t('_schedulePost.willBePostedAtX', { date: str }));
}
const text = postData.text ?? '';
const lowerCase = text.toLowerCase();
@ -888,6 +913,45 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
function openOtherSettingsMenu(ev: MouseEvent) {
let reactionAcceptanceIcon: string;
switch (reactionAcceptance) {
case 'likeOnly':
reactionAcceptanceIcon = 'ti ti-heart';
break;
case 'likeOnlyForRemote':
reactionAcceptanceIcon = 'ti ti-heart-plus';
break;
default:
reactionAcceptanceIcon = 'ti ti-icons';
break;
}
os.popupMenu([{
type: 'button',
text: i18n.ts.reactionAcceptance,
icon: reactionAcceptanceIcon,
action: toggleReactionAcceptance,
}, ($i.policies?.canScheduleNote) ? {
type: 'button',
text: i18n.ts.schedulePost,
icon: 'ti ti-calendar-time',
indicate: (schedule != null),
action: toggleSchedule,
} : undefined, ...(($i.policies?.canScheduleNote) ? [null, {
type: 'button',
text: i18n.ts._schedulePost.list,
icon: 'ti ti-calendar-event',
action: () => {
// 稿
emit('cancel');
listSchedulePost();
},
}] : [])], ev.currentTarget ?? ev.target, {
align: 'right',
});
}
onMounted(() => {
if (props.autofocus) {
focus();
@ -926,7 +990,12 @@ onMounted(() => {
files = init.files;
cw = init.cw;
useCw = init.cw != null;
if (init.poll) {
if (init.isSchedule) {
schedule = {
scheduledAt: init.createdAt,
};
}
if (init.poll) {
poll = {
choices: init.poll.choices.map(x => x.text),
multiple: init.poll.multiple,
@ -1069,7 +1138,11 @@ defineExpose({
background: none;
}
&.danger {
&.headerRightButtonActive {
color: var(--accent);
}
&.danger {
color: #ff2a2a;
}
}
@ -1160,6 +1233,15 @@ defineExpose({
border-bottom: solid 0.5px var(--divider);
}
.postOptionsRoot {
>* {
border-bottom: solid 0.5px var(--divider);
}
>:last-child {
border-bottom: none;
}
}
.hashtags {
z-index: 1;
padding-top: 8px;

View file

@ -0,0 +1,61 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div style="padding: 8px 16px">
<section class="_gaps_s">
<MkInput v-model="atDate" small type="date" class="input">
<template #label>{{ i18n.ts._schedulePost.postDate }}</template>
</MkInput>
<MkInput v-model="atTime" small type="time" class="input">
<template #label>{{ i18n.ts._schedulePost.postTime }}</template>
<template #caption>{{ i18n.ts._schedulePost.localTime }}</template>
</MkInput>
</section>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import MkInput from './MkInput.vue';
import { formatDateTimeString } from '@/scripts/format-time-string.js';
import { addTime } from '@/scripts/time.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
modelValue: {
scheduledAt: string;
};
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: {
scheduledAt: string;
}): void;
}>();
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00');
if ( props.modelValue.scheduledAt) {
const date = new Date(props.modelValue.scheduledAt);
atDate.value = formatDateTimeString(date, 'yyyy-MM-dd');
atTime.value = formatDateTimeString(date, 'HH:mm');
}
function get() {
const calcAt = () => {
return new Date(`${atDate.value}T${atTime.value}`).toISOString();
};
return {
...(
props.modelValue ? { scheduledAt: calcAt() } : ''
),
};
}
watch([atDate, atTime], () => emit('update:modelValue', get()), {
immediate: true,
});
</script>

View file

@ -0,0 +1,64 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialogEl"
:withOkButton="false"
@click="cancel()"
@close="cancel()"
>
<template #header>{{ i18n.ts._schedulePost.list }}</template>
<MkSpacer :marginMin="14" :marginMax="16">
<MkPagination ref="paginationEl" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #default="{ items }">
<div class="_gaps">
<MkNoteSimple v-for="item in items" :key="item.id" :scheduled="true" :note="item.note" @editScheduleNote="listUpdate"/>
</div>
</template>
</MkPagination>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import type { Paging } from '@/components/MkPagination.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
const emit = defineEmits<{
(ev: 'cancel'): void;
}>();
const dialogEl = ref();
const cancel = () => {
emit('cancel');
dialogEl.value.close();
};
const paginationEl = ref();
const pagination: Paging = {
endpoint: 'notes/schedule/list',
limit: 10,
};
function listUpdate() {
paginationEl.value.reload();
}
</script>
<style lang="scss" module>
</style>