spec(role/ScheduledNote): ロールで予約投稿の個数・予約の最大日数を制御できるように (MisskeyIO#906)

This commit is contained in:
あわわわとーにゅ 2025-01-17 17:08:13 +09:00 committed by GitHub
parent f0f86f1121
commit 8821e3e81b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 148 additions and 23 deletions

View File

@ -1794,6 +1794,8 @@ _role:
ltlAvailable: "Can view the local timeline"
canPublicNote: "Can send public notes"
canScheduleNote: "Can schedule notes"
scheduleNoteLimit: "Maximum number of scheduled notes"
scheduleNoteMaxDays: "Maximum number of days that note can be scheduled"
canInitiateConversation: "Can mention, reply or quote"
canCreateContent: "Can create contents"
canUpdateContent: "Can edit contents"
@ -2324,6 +2326,7 @@ _postForm:
d: "What do you want to say?"
e: "Start writing..."
f: "Waiting for you to write..."
policyScheduleNoteMaxDaysExceeded: "The maximum number of days you can schedule notes for with your current support plan is {max}.\nYou can upgrade your plan [here](https://go.misskey.io/donate)."
tosAndGuidelinesInfo: "Before posting, please read the [Terms of Service]({tosUrl}) and [NSFW Guidelines](https://go.misskey.io/media-guideline)."
_profile:
name: "Name"

13
locales/index.d.ts vendored
View File

@ -7019,6 +7019,14 @@ export interface Locale extends ILocale {
* 稿
*/
"canScheduleNote": string;
/**
* 稿
*/
"scheduleNoteLimit": string;
/**
* 稿
*/
"scheduleNoteMaxDays": string;
/**
*
*/
@ -9067,6 +9075,11 @@ export interface Locale extends ILocale {
*/
"f": string;
};
/**
* {max}
* [](https://go.misskey.io/donate)からプランをアップグレードできます。
*/
"policyScheduleNoteMaxDaysExceeded": ParameterizedString<"max">;
/**
* 稿[]({tosUrl})[NSFWガイドライン](https://go.misskey.io/media-guideline)を必ずお読みください。
*/

View File

@ -1808,6 +1808,8 @@ _role:
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
canScheduleNote: "予約投稿の許可"
scheduleNoteLimit: "予約投稿の最大数"
scheduleNoteMaxDays: "予約投稿の最大日数"
canInitiateConversation: "メンション、リプライ、引用の許可"
canCreateContent: "コンテンツの作成"
canUpdateContent: "コンテンツの編集"
@ -2377,6 +2379,7 @@ _postForm:
d: "言いたいことは?"
e: "ここに書いてください"
f: "あなたが書くのを待っています..."
policyScheduleNoteMaxDaysExceeded: "現在の支援プランで予約できる日数の上限は{max}日です。\n[ここ](https://go.misskey.io/donate)からプランをアップグレードできます。"
tosAndGuidelinesInfo: "投稿する前に、[利用規約]({tosUrl})と[NSFWガイドライン](https://go.misskey.io/media-guideline)を必ずお読みください。"
_profile:

View File

@ -1791,6 +1791,8 @@ _role:
ltlAvailable: "로컬 타임라인 보이기"
canPublicNote: "공개 노트 허용"
canScheduleNote: "노트 예약 허용"
scheduleNoteLimit: "노트 예약 한도"
scheduleNoteMaxDays: "노트 예약 최대 일수"
mentionMax: "노트에 넣을 수 있는 멘션 수"
canCreateContent: "컨텐츠 생성 허용"
canUpdateContent: "컨텐츠 수정 허용"
@ -2310,6 +2312,7 @@ _postForm:
d: "말하고 싶은 게 있나요?"
e: "여기에 적어 주세요"
f: "글 쓰기를 기다려요…"
policyScheduleNoteMaxDaysExceeded: "현재 지원 플랜의 예약 가능한 최대 일수는 {max}일입니다.\n[여기](https://go.misskey.io/donate)에서 플랜을 업그레이드할 수 있습니다."
tosAndGuidelinesInfo: "노트를 게시하기 전에 [이용약관]({tosUrl})과 [NSFW 가이드라인](https://go.misskey.io/media-guideline)을 반드시 읽어 주세요."
_profile:
name: "이름"

View File

@ -425,6 +425,15 @@ export class NoteCreateService implements OnApplicationShutdown {
throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.');
}
if ((data.scheduledAt.getTime() - Date.now()) / 86_400_000 > policies.scheduleNoteMaxDays) {
throw new IdentifiableError('506006cf-3092-4ae1-8145-b025001c591f', `User can schedule notes up to ${policies.scheduleNoteMaxDays} days in the future.`);
}
const scheduledCount = await this.scheduledNotesRepository.countBy({ userId: user.id });
if (scheduledCount >= policies.scheduleNoteLimit) {
throw new IdentifiableError('7fc78d25-d947-45c1-9547-02257b98cab3', `User can schedule up to ${policies.scheduleNoteLimit} notes.`);
}
const draft = await this.insertScheduledNote(user, data);
await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!);

View File

@ -37,6 +37,8 @@ export type RolePolicies = {
ltlAvailable: boolean;
canPublicNote: boolean;
canScheduleNote: boolean;
scheduleNoteLimit: number;
scheduleNoteMaxDays: number;
canInitiateConversation: boolean;
canCreateContent: boolean;
canUpdateContent: boolean;
@ -79,6 +81,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true,
canPublicNote: true,
canScheduleNote: true,
scheduleNoteLimit: 10,
scheduleNoteMaxDays: 365,
canInitiateConversation: true,
canCreateContent: true,
canUpdateContent: true,
@ -392,6 +396,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)),
scheduleNoteLimit: calc('scheduleNoteLimit', vs => Math.max(...vs)),
scheduleNoteMaxDays: calc('scheduleNoteMaxDays', vs => Math.max(...vs)),
canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)),
canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)),
canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)),

View File

@ -184,6 +184,14 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
scheduleNoteLimit: {
type: 'integer',
optional: false, nullable: false,
},
scheduleNoteMaxDays: {
type: 'integer',
optional: false, nullable: false,
},
canInitiateConversation: {
type: 'boolean',
optional: false, nullable: false,

View File

@ -155,18 +155,26 @@ export const meta = {
id: 'e577d185-8179-4a17-b47f-6093985558e6',
},
cannotScheduleToFarFuture: {
message: 'Cannot schedule to the far future.',
code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE',
id: 'ea102856-e8da-4ae9-a98a-0326821bd177',
},
cannotScheduleSameTime: {
message: 'Cannot schedule multiple notes at the same time.',
code: 'CANNOT_SCHEDULE_SAME_TIME',
id: '187a8fab-fd83-4ae6-a46c-0f6f07784634',
},
tooManyScheduledNotes: {
message: 'You cannot schedule notes any more.',
code: 'TOO_MANY_SCHEDULED_NOTES',
kind: 'permission',
id: '9e33041f-f6fb-414d-98c1-591466e55287'
},
cannotScheduleToFarFuture: {
message: 'Cannot schedule to the far future.',
code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE',
kind: 'permission',
id: 'ea102856-e8da-4ae9-a98a-0326821bd177',
},
rolePermissionDenied: {
message: 'You are not assigned to a required role.',
code: 'ROLE_PERMISSION_DENIED',
@ -462,11 +470,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
logger.error('Cannot schedule to the past.', { scheduledAt });
throw new ApiError(meta.errors.cannotScheduleToPast);
}
if (scheduledAt.getTime() - now.getTime() > ms('1year')) {
logger.error('Cannot schedule to the far future.', { scheduledAt });
throw new ApiError(meta.errors.cannotScheduleToFarFuture);
}
}
// 投稿を作成
@ -517,10 +520,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
logger.error('Failed to create a note.', { error: err });
if (err instanceof IdentifiableError) {
if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords);
if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions);
if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied);
if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime);
if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords, { message: err.message });
if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions, { message: err.message });
if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime, { message: err.message });
if (err.id === '7fc78d25-d947-45c1-9547-02257b98cab3') throw new ApiError(meta.errors.tooManyScheduledNotes, { message: err.message });
if (err.id === '506006cf-3092-4ae1-8145-b025001c591f') throw new ApiError(meta.errors.cannotScheduleToFarFuture, { message: err.message });
if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied, { message: err.message });
}
throw err;

View File

@ -82,8 +82,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</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>
<div>
<div style="display: flex; gap: 4px" :style="scheduledTimeExceededPolicy ? 'color: var(--error)' : undefined">
<span style="margin-right: 4px"><i class="ti ti-calendar-clock"></i></span>
<component :is="scheduledTimeExceededPolicy ? 'del' : 'span'" :style="scheduledTimeExceededPolicy ? 'opacity: 0.6' : undefined">
{{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }}
</component>
</div>
<div v-if="scheduledTimeExceededPolicy" style="display: flex; gap: 4px; margin-top: 4px; color: var(--infoWarnFg)">
<span style="margin-right: 4px"><i class="ti ti-exclamation-circle"></i></span>
<Mfm :text="i18n.tsx._postForm.policyScheduleNoteMaxDaysExceeded({ max: $i.policies.scheduleNoteMaxDays })"/>
</div>
</div>
<button class="_button" style="margin-left: auto" @click="scheduledTime = null"><i class="ti ti-x"></i></button>
</div>
<MkInfo v-if="files.length > 0 && instance.tosUrl" warn style="margin-top: 8px;" :rounded="false">
@ -219,6 +229,9 @@ if (props.initialVisibleUsers) {
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const scheduledTime = ref<Date | null>(null);
const scheduledTimeExceededPolicy = computed(() =>
scheduledTime.value ? (scheduledTime.value.getTime() - Date.now()) / 86_400_000 > $i!.policies.scheduleNoteMaxDays : false
);
const autocompleteTextareaInput = ref<Autocomplete | null>(null);
const autocompleteCwInput = ref<Autocomplete | null>(null);
const autocompleteHashtagsInput = ref<Autocomplete | null>(null);
@ -285,16 +298,20 @@ const maxTextLength = computed((): number => {
});
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 != null ||
renote.value != null ||
(reply.value != null && quoteId.value != null)
) &&
(textLength.value <= maxTextLength.value) &&
(!poll.value || poll.value.choices.length >= 2);
)
&& (textLength.value <= maxTextLength.value)
&& (!poll.value || poll.value.choices.length >= 2)
&& !scheduledTimeExceededPolicy.value
;
});
const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
@ -1393,8 +1410,10 @@ defineExpose({
.scheduledTime {
display: flex;
padding: 8px 24px;
padding: 8px 12px;
gap: 4px;
align-items: center;
font-size: 90%;
background: var(--infoBg);
}

View File

@ -79,6 +79,8 @@ export const ROLE_POLICIES = [
'ltlAvailable',
'canPublicNote',
'canScheduleNote',
'scheduleNoteLimit',
'scheduleNoteMaxDays',
'canInitiateConversation',
'canCreateContent',
'canUpdateContent',

View File

@ -185,6 +185,44 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteLimit, 'scheduleNoteLimit'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteLimit }}</template>
<template #suffix>
<span v-if="role.policies.scheduleNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.scheduleNoteLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduleNoteLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.scheduleNoteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.scheduleNoteLimit.value" :disabled="role.policies.scheduleNoteLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.scheduleNoteLimit.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.scheduleNoteMaxDays, 'scheduleNoteMaxDays'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteMaxDays }}</template>
<template #suffix>
<span v-if="role.policies.scheduleNoteMaxDays.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.scheduleNoteMaxDays.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduleNoteMaxDays)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.scheduleNoteMaxDays.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.scheduleNoteMaxDays.value" :disabled="role.policies.scheduleNoteMaxDays.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.scheduleNoteMaxDays.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

@ -56,6 +56,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteLimit, 'scheduleNoteLimit'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteLimit }}</template>
<template #suffix>{{ policies.scheduleNoteLimit }}</template>
<MkInput v-model="policies.scheduleNoteLimit" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMaxDays, 'scheduleNoteMaxDays'])">
<template #label>{{ i18n.ts._role._options.scheduleNoteMaxDays }}</template>
<template #suffix>{{ policies.scheduleNoteMaxDays }}</template>
<MkInput v-model="policies.scheduleNoteMaxDays" type="number">
</MkInput>
</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

@ -5078,6 +5078,8 @@ export type components = {
ltlAvailable: boolean;
canPublicNote: boolean;
canScheduleNote: boolean;
scheduleNoteLimit: number;
scheduleNoteMaxDays: number;
canInitiateConversation: boolean;
canCreateContent: boolean;
canUpdateContent: boolean;