feat: scheduled note creation
This commit is contained in:
parent
f85e6ebb19
commit
3061147bd3
22 changed files with 414 additions and 50 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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: "发送日期"
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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>;
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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) &&
|
||||
|
|
|
@ -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="
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -69,6 +69,7 @@ export type NoteSubmitReq = {
|
|||
expiredAfter: number | null;
|
||||
};
|
||||
lang?: string;
|
||||
scheduledAt?: number | null;
|
||||
};
|
||||
|
||||
export type Endpoints = {
|
||||
|
|
|
@ -193,6 +193,7 @@ export type Note = {
|
|||
url?: string;
|
||||
updatedAt?: DateString;
|
||||
isHidden?: boolean;
|
||||
scheduledAt?: DateString;
|
||||
/** if the note is a history */
|
||||
historyId?: ID;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue