diff --git a/locales/en-US.yml b/locales/en-US.yml index 00f348cebf..2117fb056e 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -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" diff --git a/locales/index.d.ts b/locales/index.d.ts index 757baea220..dce1d15b80 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -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)を必ずお読みください。 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f53803b3a4..e5038ead05 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -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: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index d51745adb8..6bc34827ae 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -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: "이름" diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 71e430c2d4..8a43e100d9 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -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!); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 1b8091cc24..60c935887f 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -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)), diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 6cbea17089..69b68c4149 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index c90c9daf96..ddccbdae5f 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -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 { // 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 { // 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; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 8ec021d872..baca7f9f5c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -82,8 +82,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- {{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }} +
+
+ + + {{ i18n.tsx.willBePostedAt({ x: dateTimeFormat.format(scheduledTime) }) }} + +
+
+ + +
+
@@ -219,6 +229,9 @@ if (props.initialVisibleUsers) { } const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); const scheduledTime = ref(null); +const scheduledTimeExceededPolicy = computed(() => + scheduledTime.value ? (scheduledTime.value.getTime() - Date.now()) / 86_400_000 > $i!.policies.scheduleNoteMaxDays : false +); const autocompleteTextareaInput = ref(null); const autocompleteCwInput = ref(null); const autocompleteHashtagsInput = ref(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); } diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 1483b9d0c0..e84958a69b 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -79,6 +79,8 @@ export const ROLE_POLICIES = [ 'ltlAvailable', 'canPublicNote', 'canScheduleNote', + 'scheduleNoteLimit', + 'scheduleNoteMaxDays', 'canInitiateConversation', 'canCreateContent', 'canUpdateContent', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index d4d5b50500..f40d1b7424 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -185,6 +185,44 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + +
+
+ + + + +
+ + + + + + + + +
+
+