デフォルトでノート自動削除できる機能

This commit is contained in:
hijiki 2024-10-25 00:22:06 +09:00
parent f0797b556b
commit 26da54d372
9 changed files with 244 additions and 79 deletions

16
locales/index.d.ts vendored
View File

@ -5213,6 +5213,22 @@ export interface Locale extends ILocale {
* MFMのピッカーを表示する * MFMのピッカーを表示する
*/ */
"enableQuickAddMfmFunction": string; "enableQuickAddMfmFunction": string;
/**
*
*/
"defaultScheduledNoteDeleteTime": string;
/**
*
*/
"scheduledNoteDeleteEnabled": string;
/**
* 1
*/
"cannotScheduleLaterThanOneYear": string;
/**
*
*/
"defaultScheduledNoteDelete": string;
/** /**
* *
*/ */

View File

@ -1344,6 +1344,10 @@ _delivery:
autoSuspendedForNotResponding: "サーバー応答なしのため停止中" autoSuspendedForNotResponding: "サーバー応答なしのため停止中"
scheduledNoteDelete: "時限爆弾" scheduledNoteDelete: "時限爆弾"
noteDeletationAt: "このノートは{time}に削除されます" noteDeletationAt: "このノートは{time}に削除されます"
defaultScheduledNoteDeleteTime: "ノートの自己消滅の初期値"
scheduledNoteDeleteEnabled: "ノートの自己消滅が有効になっています"
cannotScheduleLaterThanOneYear: "1年以上先の日時を指定することはできません"
defaultScheduledNoteDelete: "デフォルトでノートが自己消滅するように"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"

View File

@ -110,6 +110,12 @@ export const meta = {
id: '9576c3c8-d8f3-11ee-ac15-00155d19d35d', id: '9576c3c8-d8f3-11ee-ac15-00155d19d35d',
}, },
cannotScheduleDeleteLaterThanOneYear: {
message: 'Scheduled delete time is later than one year.',
code: 'CANNOT_SCHEDULE_DELETE_LATER_THAN_ONE_YEAR',
id: 'b02b5edb-2741-4841-b692-d9893f1e6515',
},
noSuchChannel: { noSuchChannel: {
message: 'No such channel.', message: 'No such channel.',
code: 'NO_SUCH_CHANNEL', code: 'NO_SUCH_CHANNEL',
@ -387,6 +393,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else if (typeof ps.scheduledDelete.deleteAfter === 'number') { } else if (typeof ps.scheduledDelete.deleteAfter === 'number') {
ps.scheduledDelete.deleteAt = Date.now() + ps.scheduledDelete.deleteAfter; ps.scheduledDelete.deleteAt = Date.now() + ps.scheduledDelete.deleteAfter;
} }
if (ps.scheduledDelete.deleteAt && ps.scheduledDelete.deleteAt > Date.now() + ms('1year')) {
throw new ApiError(meta.errors.cannotScheduleDeleteLaterThanOneYear);
}
} }
let channel: MiChannel | null = null; let channel: MiChannel | null = null;

View File

@ -1,74 +1,108 @@
<template> <template>
<div class="zmdxowus"> <div :class="[$style.root, { [$style.padding]: !afterOnly }]">
<span>{{ i18n.ts.scheduledNoteDelete }}</span> <div v-if="!afterOnly" :class="[$style.label, { [$style.withAccent]: !showDetail }]" @click="showDetail = !showDetail"><i class="ti" :class="showDetail ? 'ti-chevron-up' : 'ti-chevron-down'"></i> {{ summaryText }}</div>
<section> <MkInfo v-if="!isValid" warn>{{ i18n.ts.cannotScheduleLaterThanOneYear }}</MkInfo>
<div> <section v-if="afterOnly || showDetail">
<MkSelect v-model="expiration" small> <div>
<template #label>{{ i18n.ts._poll.expiration }}</template> <MkSelect v-if="!afterOnly" v-model="expiration" small>
<option value="at">{{ i18n.ts._poll.at }}</option> <template #label>{{ i18n.ts._poll.expiration }}</template>
<option value="after">{{ i18n.ts._poll.after }}</option> <option value="at">{{ i18n.ts._poll.at }}</option>
</MkSelect> <option value="after">{{ i18n.ts._poll.after }}</option>
<section v-if="expiration === 'at'">
<MkInput v-model="atDate" small type="date" class="input">
<template #label>{{ i18n.ts._poll.deadlineDate }}</template>
</MkInput>
<MkInput v-model="atTime" small type="time" class="input">
<template #label>{{ i18n.ts._poll.deadlineTime }}</template>
</MkInput>
</section>
<section v-else-if="expiration === 'after'">
<MkInput v-model="after" small type="number" class="input">
<template #label>{{ i18n.ts._poll.duration }}</template>
</MkInput>
<MkSelect v-model="unit" small>
<option value="second">{{ i18n.ts._time.second }}</option>
<option value="minute">{{ i18n.ts._time.minute }}</option>
<option value="hour">{{ i18n.ts._time.hour }}</option>
<option value="day">{{ i18n.ts._time.day }}</option>
</MkSelect> </MkSelect>
</section> <section v-if="expiration === 'at'">
</div> <MkInput v-model="atDate" small type="date" class="input">
</section> <template #label>{{ i18n.ts._poll.deadlineDate }}</template>
</div> </MkInput>
</template> <MkInput v-model="atTime" small type="time" class="input">
<template #label>{{ i18n.ts._poll.deadlineTime }}</template>
<script lang="ts" setup> </MkInput>
import { ref, watch } from 'vue'; </section>
import MkInput from './MkInput.vue'; <section v-else-if="expiration === 'after'">
import MkSelect from './MkSelect.vue'; <MkInput v-model="after" small type="number" class="input">
import { formatDateTimeString } from '@/scripts/format-time-string.js'; <template #label>{{ i18n.ts._poll.duration }}</template>
import { addTime } from '@/scripts/time.js'; </MkInput>
import { i18n } from '@/i18n.js'; <MkSelect v-model="unit" small>
<option value="second">{{ i18n.ts._time.second }}</option>
export type DeleteScheduleEditorModelValue = { <option value="minute">{{ i18n.ts._time.minute }}</option>
<option value="hour">{{ i18n.ts._time.hour }}</option>
<option value="day">{{ i18n.ts._time.day }}</option>
</MkSelect>
</section>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import MkInput from './MkInput.vue';
import MkSelect from './MkSelect.vue';
import MkInfo from './MkInfo.vue';
import { formatDateTimeString } from '@/scripts/format-time-string.js';
import { addTime } from '@/scripts/time.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
export type DeleteScheduleEditorModelValue = {
deleteAt: number | null; deleteAt: number | null;
deleteAfter: number | null; deleteAfter: number | null;
isValid: boolean;
}; };
const props = defineProps<{ const props = defineProps<{
modelValue: DeleteScheduleEditorModelValue; modelValue: DeleteScheduleEditorModelValue;
afterOnly?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update:modelValue', v: DeleteScheduleEditorModelValue): void; (ev: 'update:modelValue', v: DeleteScheduleEditorModelValue): void;
}>(); }>();
const expiration = ref('at'); const expiration = ref<'at' | 'after'>('after');
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00'); const atTime = ref('00:00');
const after = ref(0); const after = ref(0);
const unit = ref('second'); const unit = ref<'second' | 'minute' | 'hour' | 'day'>('second');
const isValid = ref(true);
const showDetail = ref(!defaultStore.state.defaultScheduledNoteDelete);
const summaryText = computed(() => {
if (showDetail.value) {
return i18n.ts.scheduledNoteDelete;
}
if (expiration.value === 'at') {
return `${i18n.ts.scheduledNoteDeleteEnabled} (${formatDateTimeString(new Date(calcAt()), 'yyyy/MM/dd HH:mm')})`;
} else {
const time = unit.value === 'second' ? i18n.tsx._timeIn.seconds({ n: (after.value).toString() })
: unit.value === 'minute' ? i18n.tsx._timeIn.minutes({ n: (after.value).toString() })
: unit.value === 'hour' ? i18n.tsx._timeIn.hours({ n: (after.value).toString() })
: i18n.tsx._timeIn.days({ n: (after.value).toString() });
return `${i18n.ts.scheduledNoteDeleteEnabled} (${time})`;
}
});
const beautifyAfter = (base: number) => {
let time = base;
if (time % 60 === 0) {
unit.value = 'minute';
time /= 60;
}
if (time % 60 === 0) {
unit.value = 'hour';
time /= 60;
}
if (time % 24 === 0) {
unit.value = 'day';
time /= 24;
}
after.value = time;
};
beautifyAfter(defaultStore.state.defaultScheduledNoteDeleteTime / 1000);
if (props.modelValue.deleteAt) {
expiration.value = 'at';
const deleteAt = new Date(props.modelValue.deleteAt);
atDate.value = formatDateTimeString(deleteAt, 'yyyy-MM-dd');
atTime.value = formatDateTimeString(deleteAt, 'HH:mm');
} else if (typeof props.modelValue.deleteAfter === 'number') {
expiration.value = 'after';
beautifyAfter(props.modelValue.deleteAfter / 1000);
}
if (props.modelValue.deleteAt) {
expiration.value = 'at';
const deleteAt = new Date(props.modelValue.deleteAt);
atDate.value = formatDateTimeString(deleteAt, 'yyyy-MM-dd');
atTime.value = formatDateTimeString(deleteAt, 'HH:mm');
} else if (typeof props.modelValue.deleteAfter === 'number') {
expiration.value = 'after';
after.value = props.modelValue.deleteAfter / 1000;
}
function get(): DeleteScheduleEditorModelValue {
const calcAt = () => { const calcAt = () => {
return new Date(`${atDate.value} ${atTime.value}`).getTime(); return new Date(`${atDate.value} ${atTime.value}`).getTime();
}; };
@ -88,20 +122,35 @@ function get(): DeleteScheduleEditorModelValue {
} }
}; };
return { const isValidTime = () => {
deleteAt: expiration.value === 'at' ? calcAt() : null, if (expiration.value === 'at') {
deleteAfter: expiration.value === 'after' ? calcAfter() : null, return calcAt() < Date.now() + (1000 * 60 * 60 * 24 * 365);
} else {
const afterMs = calcAfter();
if (afterMs === null) return false;
return afterMs < 1000 * 60 * 60 * 24 * 365;
}
}; };
}
watch([expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), { isValid.value = isValidTime();
deep: true, watch([expiration, atDate, atTime, after, unit, isValid], () => {
}); const isValidTimeValue = isValidTime();
</script> isValid.value = isValidTimeValue;
emit('update:modelValue', {
<style lang="scss" scoped> deleteAt: expiration.value === 'at' ? calcAt() : null,
.zmdxowus { deleteAfter: expiration.value === 'after' ? calcAfter() : null,
padding: 8px 16px; isValid: isValidTimeValue,
});
}, {
deep: true,
});
</script>
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0px;
>span { >span {
opacity: 0.7; opacity: 0.7;
@ -131,10 +180,7 @@ watch([expiration, atDate, atTime, after, unit], () => emit('update:modelValue',
} }
>section { >section {
margin: 16px 0 0 0;
>div { >div {
margin: 0 8px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
@ -162,4 +208,19 @@ watch([expiration, atDate, atTime, after, unit], () => emit('update:modelValue',
} }
} }
} }
.padding {
padding: 8px 24px;
}
.label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
}
.withAccent {
color: var(--MI_THEME-accent);
}
.chevronOpening {
transform: rotateX(180deg);
}
</style> </style>

View File

@ -185,7 +185,7 @@ const posted = ref(false);
const text = ref(props.initialText ?? ''); const text = ref(props.initialText ?? '');
const files = ref(props.initialFiles ?? []); const files = ref(props.initialFiles ?? []);
const poll = ref<PollEditorModelValue | null>(null); const poll = ref<PollEditorModelValue | null>(null);
const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(null); const scheduledNoteDelete = ref<DeleteScheduleEditorModelValue | null>(defaultStore.state.defaultScheduledNoteDelete ? { deleteAt: null, deleteAfter: defaultStore.state.defaultScheduledNoteDeleteTime, isValid: true } : null);
const useCw = ref<boolean>(!!props.initialCw); const useCw = ref<boolean>(!!props.initialCw);
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));
@ -260,6 +260,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 &&
(scheduledNoteDelete.value ? scheduledNoteDelete.value.isValid : true) &&
( (
1 <= textLength.value || 1 <= textLength.value ||
1 <= files.value.length || 1 <= files.value.length ||
@ -433,6 +434,7 @@ function toggleScheduledNoteDelete() {
scheduledNoteDelete.value = { scheduledNoteDelete.value = {
deleteAt: null, deleteAt: null,
deleteAfter: null, deleteAfter: null,
isValid: true,
}; };
} }
} }
@ -1074,6 +1076,7 @@ onMounted(() => {
scheduledNoteDelete.value = { scheduledNoteDelete.value = {
deleteAt: init.deleteAt ? (new Date(init.deleteAt)).getTime() : null, deleteAt: init.deleteAt ? (new Date(init.deleteAt)).getTime() : null,
deleteAfter: null, deleteAfter: null,
isValid: true,
}; };
} }
if (init.visibleUserIds) { if (init.visibleUserIds) {

View File

@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s"> <div class="_gaps_s">
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<FormLink to="/settings/post-form">{{ i18n.ts.defaultScheduledNoteDelete }}</FormLink>
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts.pinnedList }}</template> <template #label>{{ i18n.ts.pinnedList }}</template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ --> <!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->

View File

@ -0,0 +1,62 @@
<template>
<div class="_gaps_m">
<!-- <MkSwitch v-model="disableNoteDrafting">
<template #caption>{{ i18n.ts.disableNoteDraftingDescription }}</template>
{{ i18n.ts.disableNoteDrafting }}
<span class="_beta">{{ i18n.ts.originalFeature }}</span>
</MkSwitch> -->
<div>
<div :class="$style.label">
{{ i18n.ts.defaultScheduledNoteDeleteTime }}
<span class="_beta">{{ i18n.ts.originalFeature }}</span>
</div>
<MkDeleteScheduleEditor v-model="scheduledNoteDelete" :afterOnly="true"/>
</div>
<MkSwitch v-model="defaultScheduledNoteDelete">
{{ i18n.ts.defaultScheduledNoteDelete }}
<span class="_beta">{{ i18n.ts.originalFeature }}</span>
</MkSwitch>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkDeleteScheduleEditor from '@/components/MkDeleteScheduleEditor.vue';
import FormSlot from '@/components/form/slot.vue';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
// const disableNoteDrafting = computed(defaultStore.makeGetterSetter('disableNoteDrafting'));
const defaultScheduledNoteDelete = computed(defaultStore.makeGetterSetter('defaultScheduledNoteDelete'));
const scheduledNoteDelete = ref({ deleteAt: null, deleteAfter: defaultStore.state.defaultScheduledNoteDeleteTime, isValid: true });
watch(scheduledNoteDelete, () => {
if (!scheduledNoteDelete.value.isValid) return;
defaultStore.set('defaultScheduledNoteDeleteTime', scheduledNoteDelete.value.deleteAfter);
});
</script>
<style lang="scss" module>
.items {
padding: 8px;
flex: 1;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
grid-auto-rows: 40px;
}
.item {
display: inline-block;
padding: 0;
margin: 0;
font-size: 1em;
width: auto;
height: 100%;
border-radius: 6px;
&:hover {
background: var(--X5);
}
}
.label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
}
</style>

View File

@ -116,6 +116,10 @@ const routes: RouteDef[] = [{
path: '/navbar', path: '/navbar',
name: 'navbar', name: 'navbar',
component: page(() => import('@/pages/settings/navbar.vue')), component: page(() => import('@/pages/settings/navbar.vue')),
}, {
path: '/post-form',
name: 'post-form',
component: page(() => import('@/pages/settings/post-form.vue')),
}, { }, {
path: '/statusbar', path: '/statusbar',
name: 'statusbar', name: 'statusbar',

View File

@ -117,6 +117,14 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account', where: 'account',
default: true, default: true,
}, },
defaultScheduledNoteDelete: {
where: 'account',
default: false,
},
defaultScheduledNoteDeleteTime: {
where: 'account',
default: 86400000,
},
uploadFolder: { uploadFolder: {
where: 'account', where: 'account',
default: null as string | null, default: null as string | null,
@ -569,10 +577,6 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device', where: 'device',
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
}, },
searchEngine: {
where: 'device',
default: 'https://google.com/search?q=',
},
reactionChecksMuting: { reactionChecksMuting: {
where: 'device', where: 'device',
default: true, default: true,