From 3061147bd3fe180847508e75d95acfc216e78bc1 Mon Sep 17 00:00:00 2001 From: Lhcfl <Lhcfl@outlook.com> Date: Fri, 3 May 2024 21:42:40 +0800 Subject: [PATCH] feat: scheduled note creation --- .gitignore | 3 - locales/en-US.yml | 12 ++ locales/zh-CN.yml | 12 ++ packages/backend/src/db/postgre.ts | 2 + ...28200194-create-scheduled-note-creation.ts | 54 +++++++++ .../entities/scheduled-note-creation.ts | 44 +++++++ packages/backend/src/models/index.ts | 2 + .../backend/src/models/repositories/note.ts | 11 ++ packages/backend/src/queue/index.ts | 13 +- .../backend/src/queue/processors/db/index.ts | 2 + .../processors/db/scheduled-create-note.ts | 66 +++++++++++ packages/backend/src/queue/types.ts | 14 ++- .../src/server/api/endpoints/notes/create.ts | 112 ++++++++++++++---- packages/backend/src/services/note/create.ts | 3 + packages/client/src/components/MkNote.vue | 14 ++- .../client/src/components/MkNoteHeader.vue | 3 +- packages/client/src/components/MkPostForm.vue | 52 ++++++++ .../client/src/components/MkVisibility.vue | 13 +- .../client/src/components/global/MkTime.vue | 26 ++-- packages/client/src/types/form.ts | 4 +- packages/firefish-js/src/api.types.ts | 1 + packages/firefish-js/src/entities.ts | 1 + 22 files changed, 414 insertions(+), 50 deletions(-) create mode 100644 packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts create mode 100644 packages/backend/src/models/entities/scheduled-note-creation.ts create mode 100644 packages/backend/src/queue/processors/db/scheduled-create-note.ts diff --git a/.gitignore b/.gitignore index beb0b8df5c..8469fff2e8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,6 @@ coverage # misskey built -db elasticsearch redis npm-debug.log @@ -56,8 +55,6 @@ packages/backend/assets/instance.css packages/backend/assets/sounds/None.mp3 packages/backend/assets/LICENSE -!/packages/backend/queue/processors/db -!/packages/backend/src/db !/packages/backend/src/server/api/endpoints/drive/files packages/megalodon/lib diff --git a/locales/en-US.yml b/locales/en-US.yml index 08fcc490ea..bf7ae0e67a 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1583,6 +1583,16 @@ _ago: weeksAgo: "{n}w ago" monthsAgo: "{n}mo ago" yearsAgo: "{n}y ago" +_later: + future: "Future" + justNow: "Immediate" + secondsAgo: "{n}s later" + minutesAgo: "{n}m later" + hoursAgo: "{n}h later" + daysAgo: "{n}d later" + weeksAgo: "{n}w later" + monthsAgo: "{n}mo later" + yearsAgo: "{n}y later" _time: second: "Second(s)" minute: "Minute(s)" @@ -2241,3 +2251,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel noteEditHistory: "Post edit history" slashQuote: "Chain quote" foldNotification: "Group similar notifications" +scheduledPost: "Scheduled post" +scheduledDate: "Scheduled date" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 2b326c4066..511abb0ff6 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1230,6 +1230,16 @@ _ago: weeksAgo: "{n} 周前" monthsAgo: "{n} 月前" yearsAgo: "{n} 年前" +_later: + future: "将来" + justNow: "马上" + secondsAgo: "{n} 秒后" + minutesAgo: "{n} 分后" + hoursAgo: "{n} 时后" + daysAgo: "{n} 天后" + weeksAgo: "{n} 周后" + monthsAgo: "{n} 月后" + yearsAgo: "{n} 年后" _time: second: "秒" minute: "分" @@ -2068,3 +2078,5 @@ noteEditHistory: "帖子编辑历史" media: 媒体 slashQuote: "斜杠引用" foldNotification: "将通知按同类型分组" +scheduledPost: "定时发送" +scheduledDate: "发送日期" diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 360ccfa38c..96059f9567 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -77,6 +77,7 @@ import { NoteFile } from "@/models/entities/note-file.js"; import { entities as charts } from "@/services/chart/entities.js"; import { dbLogger } from "./logger.js"; +import { ScheduledNoteCreation } from "@/models/entities/scheduled-note-creation.js"; const sqlLogger = dbLogger.createSubLogger("sql", "gray", false); @@ -182,6 +183,7 @@ export const entities = [ UserPending, Webhook, UserIp, + ScheduledNoteCreation, ...charts, ]; diff --git a/packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts b/packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts new file mode 100644 index 0000000000..1af13a229b --- /dev/null +++ b/packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts @@ -0,0 +1,54 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateScheduledNoteCreation1714728200194 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "scheduled_note_creation" ( + "id" character varying(32) NOT NULL, + "noteId" character varying(32) NOT NULL, + "userId" character varying(32) NOT NULL, + "scheduledAt" TIMESTAMP WITHOUT TIME ZONE NOT NULL, + CONSTRAINT "PK_id_ScheduledNoteCreation" PRIMARY KEY ("id") + )`, + ); + await queryRunner.query(` + COMMENT ON COLUMN "scheduled_note_creation"."noteId" IS 'The ID of note scheduled.' + `); + await queryRunner.query(` + CREATE INDEX "IDX_noteId_ScheduledNoteCreation" ON "scheduled_note_creation" ("noteId") + `); + await queryRunner.query(` + CREATE INDEX "IDX_userId_ScheduledNoteCreation" ON "scheduled_note_creation" ("userId") + `); + await queryRunner.query(` + ALTER TABLE "scheduled_note_creation" + ADD CONSTRAINT "FK_noteId_ScheduledNoteCreation" + FOREIGN KEY ("noteId") + REFERENCES "note"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "scheduled_note_creation" + ADD CONSTRAINT "FK_userId_ScheduledNoteCreation" + FOREIGN KEY ("userId") + REFERENCES "user"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_noteId_ScheduledNoteCreation" + `); + await queryRunner.query(` + ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_userId_ScheduledNoteCreation" + `); + await queryRunner.query(` + DROP TABLE "scheduled_note_creation" + `); + } +} diff --git a/packages/backend/src/models/entities/scheduled-note-creation.ts b/packages/backend/src/models/entities/scheduled-note-creation.ts new file mode 100644 index 0000000000..4e3b484326 --- /dev/null +++ b/packages/backend/src/models/entities/scheduled-note-creation.ts @@ -0,0 +1,44 @@ +import { + Entity, + JoinColumn, + Column, + ManyToOne, + PrimaryColumn, + Index, +} from "typeorm"; +import { Note } from "./note.js"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity() +export class ScheduledNoteCreation { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: "The ID of note scheduled.", + }) + public noteId: Note["id"]; + + @Index() + @Column(id()) + public userId: User["id"]; + + @Column("timestamp without time zone") + public scheduledAt: Date; + + //#region Relations + @ManyToOne(() => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note; + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User; + //#endregion +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index c578d9d409..adf73e0571 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -67,6 +67,7 @@ import { Webhook } from "./entities/webhook.js"; import { UserIp } from "./entities/user-ip.js"; import { NoteFileRepository } from "./repositories/note-file.js"; import { NoteEditRepository } from "./repositories/note-edit.js"; +import { ScheduledNoteCreation } from "./entities/scheduled-note-creation.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -135,3 +136,4 @@ export const RegistryItems = db.getRepository(RegistryItem); export const Webhooks = db.getRepository(Webhook); export const Ads = db.getRepository(Ad); export const PasswordResetRequests = db.getRepository(PasswordResetRequest); +export const ScheduledNoteCreations = db.getRepository(ScheduledNoteCreation); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index 7fa26373b8..33304e4e6c 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -11,6 +11,7 @@ import { Polls, Channels, Notes, + ScheduledNoteCreations, } from "../index.js"; import type { Packed } from "@/misc/schema.js"; import { countReactions, decodeReaction, nyaify } from "backend-rs"; @@ -198,6 +199,15 @@ export const NoteRepository = db.getRepository(Note).extend({ host, ); + let scheduledAt: string | undefined; + if (note.visibility === "specified" && note.visibleUserIds.length === 0) { + scheduledAt = ( + await ScheduledNoteCreations.findOneBy({ + noteId: note.id, + }) + )?.scheduledAt?.toISOString(); + } + const reactionEmoji = await populateEmojis(reactionEmojiNames, host); const packed: Packed<"Note"> = await awaitAll({ id: note.id, @@ -231,6 +241,7 @@ export const NoteRepository = db.getRepository(Note).extend({ }, }) : undefined, + scheduledAt, reactions: countReactions(note.reactions), reactionEmojis: reactionEmoji, emojis: noteEmoji, diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts index e2fb0febe6..d2fd2ae8e9 100644 --- a/packages/backend/src/queue/index.ts +++ b/packages/backend/src/queue/index.ts @@ -24,7 +24,7 @@ import { endedPollNotificationQueue, webhookDeliverQueue, } from "./queues.js"; -import type { ThinUser } from "./types.js"; +import type { DbUserScheduledCreateNoteData, ThinUser } from "./types.js"; import type { Note } from "@/models/entities/note.js"; function renderError(e: Error): any { @@ -455,6 +455,17 @@ export function createDeleteAccountJob( ); } +export function createScheduledCreateNoteJob( + options: DbUserScheduledCreateNoteData, + delay: number, +) { + return dbQueue.add("scheduledCreateNote", options, { + delay, + removeOnComplete: true, + removeOnFail: true, + }); +} + export function createDeleteObjectStorageFileJob(key: string) { return objectStorageQueue.add( "deleteFile", diff --git a/packages/backend/src/queue/processors/db/index.ts b/packages/backend/src/queue/processors/db/index.ts index d20fc2c71a..351e6fdf49 100644 --- a/packages/backend/src/queue/processors/db/index.ts +++ b/packages/backend/src/queue/processors/db/index.ts @@ -16,6 +16,7 @@ import { importMastoPost } from "./import-masto-post.js"; import { importCkPost } from "./import-firefish-post.js"; import { importBlocking } from "./import-blocking.js"; import { importCustomEmojis } from "./import-custom-emojis.js"; +import { scheduledCreateNote } from "./scheduled-create-note.js"; const jobs = { deleteDriveFiles, @@ -34,6 +35,7 @@ const jobs = { importCkPost, importCustomEmojis, deleteAccount, + scheduledCreateNote, } as Record< string, | Bull.ProcessCallbackFunction<DbJobData> diff --git a/packages/backend/src/queue/processors/db/scheduled-create-note.ts b/packages/backend/src/queue/processors/db/scheduled-create-note.ts new file mode 100644 index 0000000000..4c29b1a061 --- /dev/null +++ b/packages/backend/src/queue/processors/db/scheduled-create-note.ts @@ -0,0 +1,66 @@ +import { Users, Notes, ScheduledNoteCreations } from "@/models/index.js"; +import type { DbUserScheduledCreateNoteData } from "@/queue/types.js"; +import { queueLogger } from "../../logger.js"; +import type Bull from "bull"; +import deleteNote from "@/services/note/delete.js"; +import createNote from "@/services/note/create.js"; +import { In } from "typeorm"; + +const logger = queueLogger.createSubLogger("scheduled-post"); + +export async function scheduledCreateNote( + job: Bull.Job<DbUserScheduledCreateNoteData>, + done: () => void, +): Promise<void> { + logger.info("Scheduled creating note..."); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + const note = await Notes.findOneBy({ id: job.data.noteId }); + if (note == null) { + done(); + return; + } + + if (user.isSuspended) { + deleteNote(user, note); + done(); + return; + } + + await ScheduledNoteCreations.delete({ + noteId: note.id, + userId: user.id, + }); + + const visibleUsers = job.data.option.visibleUserIds + ? await Users.findBy({ + id: In(job.data.option.visibleUserIds), + }) + : []; + + await createNote(user, { + createdAt: new Date(), + files: note.files, + poll: job.data.option.poll, + text: note.text || undefined, + lang: note.lang, + reply: note.reply, + renote: note.renote, + cw: note.cw, + localOnly: note.localOnly, + visibility: job.data.option.visibility, + visibleUsers, + channel: note.channel, + }); + + await deleteNote(user, note); + + logger.info("Success"); + + done(); +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 6383f3fdd5..7af8687e23 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -1,5 +1,6 @@ import type { DriveFile } from "@/models/entities/drive-file.js"; import type { Note } from "@/models/entities/note"; +import type { IPoll } from "@/models/entities/poll"; import type { User } from "@/models/entities/user.js"; import type { Webhook } from "@/models/entities/webhook"; import type { IActivity } from "@/remote/activitypub/type.js"; @@ -24,7 +25,8 @@ export type DbJobData = | DbUserImportPostsJobData | DbUserImportJobData | DbUserDeleteJobData - | DbUserImportMastoPostJobData; + | DbUserImportMastoPostJobData + | DbUserScheduledCreateNoteData; export type DbUserJobData = { user: ThinUser; @@ -55,6 +57,16 @@ export type DbUserImportMastoPostJobData = { parent: Note | null; }; +export type DbUserScheduledCreateNoteData = { + user: ThinUser; + option: { + visibility: string; + visibleUserIds?: string[] | null; + poll?: IPoll; + }; + noteId: Note["id"]; +}; + export type ObjectStorageJobData = | ObjectStorageFileJobData | Record<string, unknown>; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index eb4d9ca5a2..d78bab954f 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -7,6 +7,7 @@ import { Notes, Channels, Blockings, + ScheduledNoteCreations, } from "@/models/index.js"; import type { DriveFile } from "@/models/entities/drive-file.js"; import type { Note } from "@/models/entities/note.js"; @@ -15,9 +16,10 @@ import { config } from "@/config.js"; import { noteVisibilities } from "@/types.js"; import { ApiError } from "@/server/api/error.js"; import define from "@/server/api/define.js"; -import { HOUR } from "backend-rs"; +import { HOUR, genId } from "backend-rs"; import { getNote } from "@/server/api/common/getters.js"; import { langmap } from "@/misc/langmap.js"; +import { createScheduledCreateNoteJob } from "@/queue"; export const meta = { tags: ["notes"], @@ -156,6 +158,7 @@ export const paramDef = { }, required: ["choices"], }, + scheduledAt: { type: "integer", nullable: true }, }, anyOf: [ { @@ -274,8 +277,20 @@ export default define(meta, paramDef, async (ps, user) => { if (ps.poll.expiresAt < Date.now()) { throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); } + if ( + ps.poll.expiresAt && + ps.scheduledAt && + ps.poll.expiresAt < Number(new Date(ps.scheduledAt)) + ) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } } else if (typeof ps.poll.expiredAfter === "number") { - ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + if (ps.scheduledAt) { + ps.poll.expiresAt = + Number(new Date(ps.scheduledAt)) + ps.poll.expiredAfter; + } else { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } } } @@ -288,31 +303,80 @@ export default define(meta, paramDef, async (ps, user) => { } } + let delay: number | null = null; + if (ps.scheduledAt) { + delay = Number(ps.scheduledAt) - Number(new Date()); + if (delay < 0) { + delay = null; + } + } + // Create a post - const note = await create(user, { - createdAt: new Date(), - files: files, - poll: ps.poll - ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + const note = await create( + user, + { + createdAt: new Date(), + files: files, + poll: ps.poll + ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } + : undefined, + text: ps.text || undefined, + lang: ps.lang, + reply, + renote, + cw: ps.cw, + localOnly: ps.localOnly, + ...(delay != null + ? { + visibility: "specified", + visibleUsers: [], + } + : { + visibility: ps.visibility, + visibleUsers, + }), + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }, + false, + delay + ? async (note) => { + await ScheduledNoteCreations.insert({ + id: genId(), + noteId: note.id, + userId: user.id, + scheduledAt: new Date(ps.scheduledAt as number), + }); + + createScheduledCreateNoteJob( + { + user: { id: user.id }, + noteId: note.id, + option: { + poll: ps.poll + ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple, + expiresAt: ps.poll.expiresAt + ? new Date(ps.poll.expiresAt) + : null, + } + : undefined, + visibility: ps.visibility, + visibleUserIds: ps.visibleUserIds, + }, + }, + delay, + ); } : undefined, - text: ps.text || undefined, - lang: ps.lang, - reply, - renote, - cw: ps.cw, - localOnly: ps.localOnly, - visibility: ps.visibility, - visibleUsers, - channel, - apMentions: ps.noExtractMentions ? [] : undefined, - apHashtags: ps.noExtractHashtags ? [] : undefined, - apEmojis: ps.noExtractEmojis ? [] : undefined, - }); - + ); return { createdNote: await Notes.pack(note, user), }; diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 679a2f886e..de162c44ce 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -175,6 +175,7 @@ export default async ( }, data: Option, silent = false, + waitToPublish?: (note: Note) => Promise<void>, ) => // biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME new Promise<Note>(async (res, rej) => { @@ -356,6 +357,8 @@ export default async ( res(note); + if (waitToPublish) await waitToPublish(note); + // Register host if (Users.isRemoteUser(user)) { registerOrFetchInstanceDoc(user.host).then((i) => { diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue index 3d5d6d59b5..22131de344 100644 --- a/packages/client/src/components/MkNote.vue +++ b/packages/client/src/components/MkNote.vue @@ -65,7 +65,8 @@ v-if="isMyRenote" :class="icon('ph-dots-three-outline dropdownIcon')" ></i> - <MkTime :time="note.createdAt" /> + <MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/> + <MkTime v-else :time="note.createdAt" /> </button> <MkVisibility :note="note" /> </div> @@ -147,7 +148,8 @@ class="created-at" :to="notePage(appearNote)" > - <MkTime :time="appearNote.createdAt" mode="absolute" /> + <MkTime v-if="appearNote.scheduledAt != null" :time="appearNote.scheduledAt"/> + <MkTime v-else :time="appearNote.createdAt" mode="absolute" /> </MkA> <MkA v-if="appearNote.channel && !inChannel" @@ -173,6 +175,7 @@ v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click.stop="reply()" + :disabled="note.scheduledAt != null" > <i :class="icon('ph-arrow-u-up-left')"></i> <template @@ -187,6 +190,7 @@ :note="appearNote" :count="appearNote.renoteCount" :detailed-view="detailedView" + :disabled="note.scheduledAt != null" /> <XStarButtonNoEmoji v-if="!enableEmojiReactions" @@ -194,6 +198,7 @@ :note="appearNote" :count="reactionCount" :reacted="appearNote.myReaction != null" + :disabled="note.scheduledAt != null" /> <XStarButton v-if=" @@ -203,6 +208,7 @@ ref="starButton" class="button" :note="appearNote" + :disabled="note.scheduledAt != null" /> <button v-if=" @@ -213,6 +219,7 @@ v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click.stop="react()" + :disabled="note.scheduledAt != null" > <i :class="icon('ph-smiley')"></i> <p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p> @@ -226,11 +233,12 @@ v-tooltip.noDelay.bottom="i18n.ts.removeReaction" class="button _button reacted" @click.stop="undoReact(appearNote)" + :disabled="note.scheduledAt != null" > <i :class="icon('ph-minus')"></i> <p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p> </button> - <XQuoteButton class="button" :note="appearNote" /> + <XQuoteButton class="button" :note="appearNote" :disabled="note.scheduledAt != null"/> <button v-if=" isSignedIn(me) && diff --git a/packages/client/src/components/MkNoteHeader.vue b/packages/client/src/components/MkNoteHeader.vue index 80ee12d9e6..cb87c8df0a 100644 --- a/packages/client/src/components/MkNoteHeader.vue +++ b/packages/client/src/components/MkNoteHeader.vue @@ -17,7 +17,8 @@ <div> <div class="info"> <MkA class="created-at" :to="notePage(note)"> - <MkTime :time="note.createdAt" /> + <MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/> + <MkTime v-else :time="note.createdAt" /> <i v-if="note.updatedAt" v-tooltip.noDelay=" diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue index 78cb6350f7..8a530dc0d9 100644 --- a/packages/client/src/components/MkPostForm.vue +++ b/packages/client/src/components/MkPostForm.vue @@ -54,6 +54,15 @@ ><i :class="icon('ph-eye-slash')"></i ></span> </button> + <button + v-if="editId == null" + v-tooltip="i18n.ts.scheduledPost" + class="_button schedule" + :class="{ active: scheduledAt }" + @click="setScheduledAt" + > + <i :class="icon('ph-clock')"></i> + </button> <button ref="languageButton" v-tooltip="i18n.ts.language" @@ -432,6 +441,7 @@ const recentHashtags = ref( JSON.parse(localStorage.getItem("hashtags") || "[]"), ); const imeText = ref(""); +const scheduledAt = ref<number | null>(null); const typing = throttle(3000, () => { if (props.channel) { @@ -772,6 +782,38 @@ function setVisibility() { ); } +async function setScheduledAt() { + function getDateStr(type: "date" | "time", value: number) { + const tmp = document.createElement("input"); + tmp.type = type; + tmp.valueAsNumber = value - new Date().getTimezoneOffset() * 60000; + return tmp.value; + } + + const at = scheduledAt.value ?? Date.now(); + + const result = await os.form(i18n.ts.scheduledPost, { + at_date: { + type: "date", + label: i18n.ts.scheduledDate, + default: getDateStr("date", at), + }, + at_time: { + type: "time", + label: i18n.ts._poll.deadlineTime, + default: getDateStr("time", at), + }, + }); + + if (!result.canceled && result.result) { + scheduledAt.value = Number( + new Date(`${result.result.at_date}T${result.result.at_time}`), + ); + } else { + scheduledAt.value = null; + } +} + const language = ref<string | null>( props.initialLanguage ?? defaultStore.state.recentlyUsedPostLanguages[0] ?? @@ -1176,6 +1218,7 @@ async function post() { : visibility.value === "specified" ? visibleUsers.value.map((u) => u.id) : undefined, + scheduledAt: scheduledAt.value, }; if (withHashtags.value && hashtags.value && hashtags.value.trim() !== "") { @@ -1224,6 +1267,7 @@ async function post() { } posting.value = false; postAccount.value = null; + scheduledAt.value = null; nextTick(() => autosize.update(textareaEl.value!)); }); }) @@ -1434,6 +1478,14 @@ onMounted(() => { display: flex; align-items: center; + > .schedule { + width: 34px; + height: 34px; + &.active { + color: var(--accent); + } + } + > .text-count { opacity: 0.7; line-height: 66px; diff --git a/packages/client/src/components/MkVisibility.vue b/packages/client/src/components/MkVisibility.vue index 1feafb21f1..9903abfd03 100644 --- a/packages/client/src/components/MkVisibility.vue +++ b/packages/client/src/components/MkVisibility.vue @@ -10,6 +10,12 @@ v-tooltip="i18n.ts._visibility.followers" :class="icon('ph-lock')" ></i> + <i + v-else-if="note.visibility === 'specified' && note.scheduledAt" + ref="specified" + v-tooltip="`scheduled at ${note.scheduledAt}`" + :class="icon('ph-clock')" + ></i> <i v-else-if=" note.visibility === 'specified' && @@ -41,13 +47,10 @@ import * as os from "@/os"; import { useTooltip } from "@/scripts/use-tooltip"; import { i18n } from "@/i18n"; import icon from "@/scripts/icon"; +import type { entities } from "firefish-js"; const props = defineProps<{ - note: { - visibility: string; - localOnly?: boolean; - visibleUserIds?: string[]; - }; + note: entities.Note; }>(); const specified = ref<HTMLElement>(); diff --git a/packages/client/src/components/global/MkTime.vue b/packages/client/src/components/global/MkTime.vue index 1f9333cd74..b90179d99f 100644 --- a/packages/client/src/components/global/MkTime.vue +++ b/packages/client/src/components/global/MkTime.vue @@ -42,36 +42,42 @@ const relative = computed<string>(() => { if (props.mode === "absolute") return ""; // absoluteではrelativeを使わないので計算しない if (invalid) return i18n.ts._ago.invalid; - const ago = (now.value - _time) / 1000; /* ms */ + let ago = (now.value - _time) / 1000; /* ms */ + + const agoType = ago > 0 ? "_ago" : "_later"; + ago = Math.abs(ago); + return ago >= 31536000 - ? i18n.t("_ago.yearsAgo", { n: Math.floor(ago / 31536000).toString() }) + ? i18n.t(`${agoType}.yearsAgo`, { + n: Math.floor(ago / 31536000).toString(), + }) : ago >= 2592000 - ? i18n.t("_ago.monthsAgo", { + ? i18n.t(`${agoType}.monthsAgo`, { n: Math.floor(ago / 2592000).toString(), }) : ago >= 604800 - ? i18n.t("_ago.weeksAgo", { + ? i18n.t(`${agoType}.weeksAgo`, { n: Math.floor(ago / 604800).toString(), }) : ago >= 86400 - ? i18n.t("_ago.daysAgo", { + ? i18n.t(`${agoType}.daysAgo`, { n: Math.floor(ago / 86400).toString(), }) : ago >= 3600 - ? i18n.t("_ago.hoursAgo", { + ? i18n.t(`${agoType}.hoursAgo`, { n: Math.floor(ago / 3600).toString(), }) : ago >= 60 - ? i18n.t("_ago.minutesAgo", { + ? i18n.t(`${agoType}.minutesAgo`, { n: (~~(ago / 60)).toString(), }) : ago >= 10 - ? i18n.t("_ago.secondsAgo", { + ? i18n.t(`${agoType}.secondsAgo`, { n: (~~(ago % 60)).toString(), }) : ago >= -1 - ? i18n.ts._ago.justNow - : i18n.ts._ago.future; + ? i18n.ts[agoType].justNow + : i18n.ts[agoType].future; }); let tickId: number; diff --git a/packages/client/src/types/form.ts b/packages/client/src/types/form.ts index c5e169c465..4fd283fd7f 100644 --- a/packages/client/src/types/form.ts +++ b/packages/client/src/types/form.ts @@ -38,11 +38,11 @@ export type FormItemUrl = BaseFormItem & { }; export type FormItemDate = BaseFormItem & { type: "date"; - default?: Date | null; + default?: string | Date | null; }; export type FormItemTime = BaseFormItem & { type: "time"; - default?: number | Date | null; + default?: string | Date | null; }; export type FormItemSearch = BaseFormItem & { type: "search"; diff --git a/packages/firefish-js/src/api.types.ts b/packages/firefish-js/src/api.types.ts index 1ee94b9954..fe47460fb8 100644 --- a/packages/firefish-js/src/api.types.ts +++ b/packages/firefish-js/src/api.types.ts @@ -69,6 +69,7 @@ export type NoteSubmitReq = { expiredAfter: number | null; }; lang?: string; + scheduledAt?: number | null; }; export type Endpoints = { diff --git a/packages/firefish-js/src/entities.ts b/packages/firefish-js/src/entities.ts index 9ab3c2fff6..3c63d26530 100644 --- a/packages/firefish-js/src/entities.ts +++ b/packages/firefish-js/src/entities.ts @@ -193,6 +193,7 @@ export type Note = { url?: string; updatedAt?: DateString; isHidden?: boolean; + scheduledAt?: DateString; /** if the note is a history */ historyId?: ID; };