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

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のピッカーを表示する
*/
"enableQuickAddMfmFunction": string;
/**
*
*/
"defaultScheduledNoteDeleteTime": string;
/**
*
*/
"scheduledNoteDeleteEnabled": string;
/**
* 1
*/
"cannotScheduleLaterThanOneYear": string;
/**
*
*/
"defaultScheduledNoteDelete": string;
/**
*
*/

View File

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

View File

@ -110,6 +110,12 @@ export const meta = {
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: {
message: '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') {
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;

View File

@ -1,74 +1,108 @@
<template>
<div class="zmdxowus">
<span>{{ i18n.ts.scheduledNoteDelete }}</span>
<section>
<div>
<MkSelect v-model="expiration" small>
<template #label>{{ i18n.ts._poll.expiration }}</template>
<option value="at">{{ i18n.ts._poll.at }}</option>
<option value="after">{{ i18n.ts._poll.after }}</option>
</MkSelect>
<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>
<div :class="[$style.root, { [$style.padding]: !afterOnly }]">
<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>
<MkInfo v-if="!isValid" warn>{{ i18n.ts.cannotScheduleLaterThanOneYear }}</MkInfo>
<section v-if="afterOnly || showDetail">
<div>
<MkSelect v-if="!afterOnly" v-model="expiration" small>
<template #label>{{ i18n.ts._poll.expiration }}</template>
<option value="at">{{ i18n.ts._poll.at }}</option>
<option value="after">{{ i18n.ts._poll.after }}</option>
</MkSelect>
</section>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import MkInput from './MkInput.vue';
import MkSelect from './MkSelect.vue';
import { formatDateTimeString } from '@/scripts/format-time-string.js';
import { addTime } from '@/scripts/time.js';
import { i18n } from '@/i18n.js';
export type DeleteScheduleEditorModelValue = {
<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>
</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;
deleteAfter: number | null;
isValid: boolean;
};
const props = defineProps<{
const props = defineProps<{
modelValue: DeleteScheduleEditorModelValue;
afterOnly?: boolean;
}>();
const emit = defineEmits<{
const emit = defineEmits<{
(ev: 'update:modelValue', v: DeleteScheduleEditorModelValue): void;
}>();
const expiration = ref('at');
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00');
const after = ref(0);
const unit = ref('second');
const expiration = ref<'at' | 'after'>('after');
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00');
const after = ref(0);
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 = () => {
return new Date(`${atDate.value} ${atTime.value}`).getTime();
};
@ -88,20 +122,35 @@ function get(): DeleteScheduleEditorModelValue {
}
};
return {
deleteAt: expiration.value === 'at' ? calcAt() : null,
deleteAfter: expiration.value === 'after' ? calcAfter() : null,
const isValidTime = () => {
if (expiration.value === 'at') {
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()), {
deep: true,
});
</script>
<style lang="scss" scoped>
.zmdxowus {
padding: 8px 16px;
isValid.value = isValidTime();
watch([expiration, atDate, atTime, after, unit, isValid], () => {
const isValidTimeValue = isValidTime();
isValid.value = isValidTimeValue;
emit('update:modelValue', {
deleteAt: expiration.value === 'at' ? calcAt() : null,
deleteAfter: expiration.value === 'after' ? calcAfter() : null,
isValid: isValidTimeValue,
});
}, {
deep: true,
});
</script>
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0px;
>span {
opacity: 0.7;
@ -131,10 +180,7 @@ watch([expiration, atDate, atTime, after, unit], () => emit('update:modelValue',
}
>section {
margin: 16px 0 0 0;
>div {
margin: 0 8px;
display: flex;
flex-direction: row;
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>

View File

@ -185,7 +185,7 @@ const posted = ref(false);
const text = ref(props.initialText ?? '');
const files = ref(props.initialFiles ?? []);
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 showPreview = ref(defaultStore.state.showPreview);
watch(showPreview, () => defaultStore.set('showPreview', showPreview.value));
@ -260,6 +260,7 @@ const maxTextLength = computed((): number => {
const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value &&
(scheduledNoteDelete.value ? scheduledNoteDelete.value.isValid : true) &&
(
1 <= textLength.value ||
1 <= files.value.length ||
@ -433,6 +434,7 @@ function toggleScheduledNoteDelete() {
scheduledNoteDelete.value = {
deleteAt: null,
deleteAfter: null,
isValid: true,
};
}
}
@ -1074,6 +1076,7 @@ onMounted(() => {
scheduledNoteDelete.value = {
deleteAt: init.deleteAt ? (new Date(init.deleteAt)).getTime() : null,
deleteAfter: null,
isValid: true,
};
}
if (init.visibleUserIds) {

View File

@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<FormLink to="/settings/post-form">{{ i18n.ts.defaultScheduledNoteDelete }}</FormLink>
<MkFolder>
<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',
name: 'navbar',
component: page(() => import('@/pages/settings/navbar.vue')),
}, {
path: '/post-form',
name: 'post-form',
component: page(() => import('@/pages/settings/post-form.vue')),
}, {
path: '/statusbar',
name: 'statusbar',

View File

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