Merge branch 'feat/schedule-create' into 'develop'
feat: scheduled note creation [phase 1] Co-authored-by: Lhcfl <Lhcfl@outlook.com> See merge request firefish/firefish!10789
This commit is contained in:
commit
ff27c6789d
32 changed files with 546 additions and 77 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
@ -40,9 +40,8 @@ coverage
|
|||
|
||||
# misskey
|
||||
built
|
||||
db
|
||||
elasticsearch
|
||||
redis
|
||||
/db
|
||||
/redis
|
||||
npm-debug.log
|
||||
*.pem
|
||||
run.bat
|
||||
|
@ -50,16 +49,12 @@ api-docs.json
|
|||
*.log
|
||||
*.code-workspace
|
||||
.DS_Store
|
||||
files/
|
||||
/files
|
||||
ormconfig.json
|
||||
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
|
||||
packages/megalodon/.idea
|
||||
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
Breaking changes are indicated by the :warning: icon.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added `scheduledAt` optional parameter to `notes/create` (!10789)
|
||||
|
||||
## v20240516
|
||||
|
||||
- :warning: `server-info` (an endpoint to get server hardware information) now requires credentials.
|
||||
|
|
|
@ -7,6 +7,7 @@ Critical security updates are indicated by the :warning: icon.
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Add scheduled posts
|
||||
- Fix bugs
|
||||
|
||||
## [v20240516](https://firefish.dev/firefish/firefish/-/merge_requests/10854/commits)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
BEGIN;
|
||||
|
||||
DELETE FROM "migrations" WHERE name IN (
|
||||
'CreateScheduledNote1714728200194',
|
||||
'AddBackTimezone1715351290096',
|
||||
'UserprofileJsonbToArray1714270605574',
|
||||
'DropUnusedUserprofileColumns1714259023878',
|
||||
|
@ -31,6 +32,9 @@ DELETE FROM "migrations" WHERE name IN (
|
|||
'RemoveNativeUtilsMigration1705877093218'
|
||||
);
|
||||
|
||||
-- create-scheduled-note
|
||||
DROP TABLE "scheduled_note";
|
||||
|
||||
-- userprofile-jsonb-to-array
|
||||
ALTER TABLE "user_profile" RENAME COLUMN "mutedInstances" TO "mutedInstances_old";
|
||||
ALTER TABLE "user_profile" ADD COLUMN "mutedInstances" jsonb NOT NULL DEFAULT '[]';
|
||||
|
|
|
@ -1577,8 +1577,8 @@ _sfx:
|
|||
antenna: "Antennas"
|
||||
channel: "Channel notifications"
|
||||
_ago:
|
||||
future: "Future"
|
||||
justNow: "Just now"
|
||||
future: "future"
|
||||
justNow: "just now"
|
||||
secondsAgo: "{n}s ago"
|
||||
minutesAgo: "{n}m ago"
|
||||
hoursAgo: "{n}h ago"
|
||||
|
@ -1586,6 +1586,16 @@ _ago:
|
|||
weeksAgo: "{n}w ago"
|
||||
monthsAgo: "{n}mo ago"
|
||||
yearsAgo: "{n}y ago"
|
||||
_later:
|
||||
future: "future"
|
||||
justNow: "right now"
|
||||
secondsAgo: "in {n}s"
|
||||
minutesAgo: "in {n}m"
|
||||
hoursAgo: "in {n}h"
|
||||
daysAgo: "in {n}d"
|
||||
weeksAgo: "in {n}w"
|
||||
monthsAgo: "in {n}mo"
|
||||
yearsAgo: "in {n}y"
|
||||
_time:
|
||||
second: "Second(s)"
|
||||
minute: "Minute(s)"
|
||||
|
@ -2244,5 +2254,7 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
|
|||
noteEditHistory: "Post edit history"
|
||||
slashQuote: "Chain quote"
|
||||
foldNotification: "Group similar notifications"
|
||||
scheduledPost: "Schedule this post"
|
||||
scheduledDate: "Scheduled date"
|
||||
mergeThreadInTimeline: "Merge multiple posts in the same thread in timelines"
|
||||
mergeRenotesInTimeline: "Group multiple boosts of the same post"
|
||||
|
|
|
@ -1233,6 +1233,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: "分"
|
||||
|
@ -2071,5 +2081,7 @@ noteEditHistory: "帖子编辑历史"
|
|||
media: 媒体
|
||||
slashQuote: "斜杠引用"
|
||||
foldNotification: "将通知按同类型分组"
|
||||
scheduledPost: "定时发送"
|
||||
scheduledDate: "发送日期"
|
||||
mergeThreadInTimeline: "将时间线内的连续回复合并成一串"
|
||||
mergeRenotesInTimeline: "合并同一个帖子的转发"
|
||||
|
|
6
packages/backend-rs/index.d.ts
vendored
6
packages/backend-rs/index.d.ts
vendored
|
@ -901,6 +901,12 @@ export interface ReplyMuting {
|
|||
muteeId: string
|
||||
muterId: string
|
||||
}
|
||||
export interface ScheduledNote {
|
||||
id: string
|
||||
noteId: string
|
||||
userId: string
|
||||
scheduledAt: DateTimeWithTimeZone
|
||||
}
|
||||
export enum AntennaSrcEnum {
|
||||
All = 'all',
|
||||
Group = 'group',
|
||||
|
|
|
@ -53,6 +53,7 @@ pub mod registry_item;
|
|||
pub mod relay;
|
||||
pub mod renote_muting;
|
||||
pub mod reply_muting;
|
||||
pub mod scheduled_note;
|
||||
pub mod sea_orm_active_enums;
|
||||
pub mod signin;
|
||||
pub mod sw_subscription;
|
||||
|
|
|
@ -124,6 +124,8 @@ pub enum Relation {
|
|||
PromoNote,
|
||||
#[sea_orm(has_many = "super::promo_read::Entity")]
|
||||
PromoRead,
|
||||
#[sea_orm(has_many = "super::scheduled_note::Entity")]
|
||||
ScheduledNote,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
|
@ -226,6 +228,12 @@ impl Related<super::promo_read::Entity> for Entity {
|
|||
}
|
||||
}
|
||||
|
||||
impl Related<super::scheduled_note::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ScheduledNote.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
|
|
|
@ -51,6 +51,7 @@ pub use super::registry_item::Entity as RegistryItem;
|
|||
pub use super::relay::Entity as Relay;
|
||||
pub use super::renote_muting::Entity as RenoteMuting;
|
||||
pub use super::reply_muting::Entity as ReplyMuting;
|
||||
pub use super::scheduled_note::Entity as ScheduledNote;
|
||||
pub use super::signin::Entity as Signin;
|
||||
pub use super::sw_subscription::Entity as SwSubscription;
|
||||
pub use super::used_username::Entity as UsedUsername;
|
||||
|
|
55
packages/backend-rs/src/model/entity/scheduled_note.rs
Normal file
55
packages/backend-rs/src/model/entity/scheduled_note.rs
Normal file
|
@ -0,0 +1,55 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[sea_orm(table_name = "scheduled_note")]
|
||||
#[cfg_attr(
|
||||
feature = "napi",
|
||||
napi_derive::napi(object, js_name = "ScheduledNote", use_nullable = true)
|
||||
)]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
#[sea_orm(column_name = "noteId")]
|
||||
pub note_id: String,
|
||||
#[sea_orm(column_name = "userId")]
|
||||
pub user_id: String,
|
||||
#[sea_orm(column_name = "scheduledAt")]
|
||||
pub scheduled_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::note::Entity",
|
||||
from = "Column::NoteId",
|
||||
to = "super::note::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Note,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::note::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Note.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
|
@ -153,6 +153,8 @@ pub enum Relation {
|
|||
PromoRead,
|
||||
#[sea_orm(has_many = "super::registry_item::Entity")]
|
||||
RegistryItem,
|
||||
#[sea_orm(has_many = "super::scheduled_note::Entity")]
|
||||
ScheduledNote,
|
||||
#[sea_orm(has_many = "super::signin::Entity")]
|
||||
Signin,
|
||||
#[sea_orm(has_many = "super::sw_subscription::Entity")]
|
||||
|
@ -345,6 +347,12 @@ impl Related<super::registry_item::Entity> for Entity {
|
|||
}
|
||||
}
|
||||
|
||||
impl Related<super::scheduled_note::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ScheduledNote.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::signin::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Signin.def()
|
||||
|
|
|
@ -74,6 +74,7 @@ import { Webhook } from "@/models/entities/webhook.js";
|
|||
import { UserIp } from "@/models/entities/user-ip.js";
|
||||
import { NoteEdit } from "@/models/entities/note-edit.js";
|
||||
import { NoteFile } from "@/models/entities/note-file.js";
|
||||
import { ScheduledNote } from "@/models/entities/scheduled-note.js";
|
||||
|
||||
import { entities as charts } from "@/services/chart/entities.js";
|
||||
import { dbLogger } from "./logger.js";
|
||||
|
@ -182,6 +183,7 @@ export const entities = [
|
|||
UserPending,
|
||||
Webhook,
|
||||
UserIp,
|
||||
ScheduledNote,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import type { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateScheduledNote1714728200194 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "scheduled_note" (
|
||||
"id" character varying(32) NOT NULL PRIMARY KEY,
|
||||
"noteId" character varying(32) NOT NULL,
|
||||
"userId" character varying(32) NOT NULL,
|
||||
"scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
)`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "scheduled_note"
|
||||
ADD FOREIGN KEY ("noteId") REFERENCES "note"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "scheduled_note"
|
||||
ADD FOREIGN KEY ("userId") REFERENCES "user"("id")
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE NO ACTION
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "scheduled_note"`);
|
||||
}
|
||||
}
|
48
packages/backend/src/models/entities/scheduled-note.ts
Normal file
48
packages/backend/src/models/entities/scheduled-note.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
Entity,
|
||||
JoinColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
PrimaryColumn,
|
||||
Index,
|
||||
type Relation,
|
||||
} from "typeorm";
|
||||
import { Note } from "./note.js";
|
||||
import { id } from "../id.js";
|
||||
import { User } from "./user.js";
|
||||
|
||||
@Entity()
|
||||
export class ScheduledNote {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment:
|
||||
"The ID of the temporarily created note that corresponds to the schedule.",
|
||||
})
|
||||
public noteId: Note["id"];
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User["id"];
|
||||
|
||||
@Column("timestamp with time zone")
|
||||
public scheduledAt: Date;
|
||||
|
||||
//#region Relations
|
||||
@OneToOne(() => Note, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Relation<Note>;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: Relation<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 { ScheduledNote } from "./entities/scheduled-note.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 ScheduledNotes = db.getRepository(ScheduledNote);
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Polls,
|
||||
Channels,
|
||||
Notes,
|
||||
ScheduledNotes,
|
||||
} 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 ScheduledNotes.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 { DbUserScheduledNoteData, 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 createScheduledNoteJob(
|
||||
options: DbUserScheduledNoteData,
|
||||
delay: number,
|
||||
) {
|
||||
return dbQueue.add("scheduledNote", 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 { scheduledNote } from "./scheduled-note.js";
|
||||
|
||||
const jobs = {
|
||||
deleteDriveFiles,
|
||||
|
@ -34,6 +35,7 @@ const jobs = {
|
|||
importCkPost,
|
||||
importCustomEmojis,
|
||||
deleteAccount,
|
||||
scheduledNote,
|
||||
} as Record<
|
||||
string,
|
||||
| Bull.ProcessCallbackFunction<DbJobData>
|
||||
|
|
66
packages/backend/src/queue/processors/db/scheduled-note.ts
Normal file
66
packages/backend/src/queue/processors/db/scheduled-note.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { Users, Notes, ScheduledNotes } from "@/models/index.js";
|
||||
import type { DbUserScheduledNoteData } 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 scheduledNote(
|
||||
job: Bull.Job<DbUserScheduledNoteData>,
|
||||
done: () => void,
|
||||
): Promise<void> {
|
||||
logger.info(`Creating: ${job.data.noteId}`);
|
||||
|
||||
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 ScheduledNotes.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
|
||||
| DbUserScheduledNoteData;
|
||||
|
||||
export type DbUserJobData = {
|
||||
user: ThinUser;
|
||||
|
@ -55,6 +57,16 @@ export type DbUserImportMastoPostJobData = {
|
|||
parent: Note | null;
|
||||
};
|
||||
|
||||
export type DbUserScheduledNoteData = {
|
||||
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,
|
||||
ScheduledNotes,
|
||||
} 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 "firefish-js";
|
||||
import { createScheduledNoteJob } from "@/queue/index.js";
|
||||
|
||||
export const meta = {
|
||||
tags: ["notes"],
|
||||
|
@ -156,6 +158,7 @@ export const paramDef = {
|
|||
},
|
||||
required: ["choices"],
|
||||
},
|
||||
scheduledAt: { type: "integer", nullable: true },
|
||||
},
|
||||
anyOf: [
|
||||
{
|
||||
|
@ -230,7 +233,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
|
||||
// Check blocking
|
||||
if (renote.userId !== user.id) {
|
||||
const isBlocked = await Blockings.exist({
|
||||
const isBlocked = await Blockings.exists({
|
||||
where: {
|
||||
blockerId: renote.userId,
|
||||
blockeeId: user.id,
|
||||
|
@ -257,7 +260,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
|
||||
// Check blocking
|
||||
if (reply.userId !== user.id) {
|
||||
const isBlocked = await Blockings.exist({
|
||||
const isBlocked = await Blockings.exists({
|
||||
where: {
|
||||
blockerId: reply.userId,
|
||||
blockeeId: user.id,
|
||||
|
@ -274,8 +277,19 @@ 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 < 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 != null) {
|
||||
ps.poll.expiresAt = ps.scheduledAt + ps.poll.expiredAfter;
|
||||
} else {
|
||||
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -288,31 +302,80 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}
|
||||
}
|
||||
|
||||
let delay: number | null = null;
|
||||
if (ps.scheduledAt) {
|
||||
delay = ps.scheduledAt - Date.now();
|
||||
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 ScheduledNotes.insert({
|
||||
id: genId(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
scheduledAt: new Date(ps.scheduledAt as number),
|
||||
});
|
||||
|
||||
createScheduledNoteJob(
|
||||
{
|
||||
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),
|
||||
};
|
||||
|
|
|
@ -174,6 +174,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) => {
|
||||
|
@ -355,6 +356,8 @@ export default async (
|
|||
|
||||
res(note);
|
||||
|
||||
if (waitToPublish) await waitToPublish(note);
|
||||
|
||||
// Register host
|
||||
if (Users.isRemoteUser(user)) {
|
||||
registerOrFetchInstanceDoc(user.host).then((i) => {
|
||||
|
|
|
@ -48,6 +48,36 @@
|
|||
<div v-if="pinned" class="info">
|
||||
<i :class="icon('ph-push-pin')"></i>{{ i18n.ts.pinnedNote }}
|
||||
</div>
|
||||
<div v-if="isRenote" class="renote">
|
||||
<i :class="icon('ph-rocket-launch')"></i>
|
||||
<I18n :src="i18n.ts.renotedBy" tag="span">
|
||||
<template #user>
|
||||
<MkA
|
||||
v-user-preview="note.userId"
|
||||
class="name"
|
||||
:to="userPage(note.user)"
|
||||
@click.stop
|
||||
>
|
||||
<MkUserName :user="note.user" />
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<div class="info">
|
||||
<button
|
||||
ref="renoteTime"
|
||||
class="_button time"
|
||||
@click.stop="showRenoteMenu()"
|
||||
>
|
||||
<i
|
||||
v-if="isMyRenote"
|
||||
:class="icon('ph-dots-three-outline dropdownIcon')"
|
||||
></i>
|
||||
<MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/>
|
||||
<MkTime v-else :time="note.createdAt" />
|
||||
</button>
|
||||
<MkVisibility :note="note" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="collapsedReply && appearNote.reply" class="info">
|
||||
<MkAvatar class="avatar" :user="appearNote.reply.user" />
|
||||
<MkUserName
|
||||
|
@ -194,7 +224,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"
|
||||
|
@ -220,6 +251,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
|
||||
|
@ -234,6 +266,7 @@
|
|||
:note="appearNote"
|
||||
:count="appearNote.renoteCount"
|
||||
:detailed-view="detailedView"
|
||||
:disabled="note.scheduledAt != null"
|
||||
/>
|
||||
<XStarButtonNoEmoji
|
||||
v-if="!enableEmojiReactions"
|
||||
|
@ -241,6 +274,7 @@
|
|||
:note="appearNote"
|
||||
:count="reactionCount"
|
||||
:reacted="appearNote.myReaction != null"
|
||||
:disabled="note.scheduledAt != null"
|
||||
/>
|
||||
<XStarButton
|
||||
v-if="
|
||||
|
@ -250,6 +284,7 @@
|
|||
ref="starButton"
|
||||
class="button"
|
||||
:note="appearNote"
|
||||
:disabled="note.scheduledAt != null"
|
||||
/>
|
||||
<button
|
||||
v-if="
|
||||
|
@ -260,6 +295,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>
|
||||
|
@ -273,11 +309,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] ??
|
||||
|
@ -1180,6 +1222,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() !== "") {
|
||||
|
@ -1228,6 +1271,7 @@ async function post() {
|
|||
}
|
||||
posting.value = false;
|
||||
postAccount.value = null;
|
||||
scheduledAt.value = null;
|
||||
nextTick(() => autosize.update(textareaEl.value!));
|
||||
});
|
||||
})
|
||||
|
@ -1438,6 +1482,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>();
|
||||
|
|
|
@ -52,47 +52,48 @@ const relative = computed<string>(() => {
|
|||
if (props.mode === "absolute") return ""; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid.value) return i18n.ts._ago.invalid;
|
||||
|
||||
const ago = (now.value - _time.value) / 1000; /* ms */
|
||||
const time = Math.abs(now.value - _time.value) / 1000; /* ms */
|
||||
const agoOrLater = now.value > _time.value ? "_ago" : "_later";
|
||||
|
||||
if (ago >= 31536000) {
|
||||
return i18n.t("_ago.yearsAgo", {
|
||||
n: Math.floor(ago / 31536000).toString(),
|
||||
if (time >= 31536000) {
|
||||
return i18n.t(`${agoOrLater}.yearsAgo`, {
|
||||
n: Math.floor(time / 31536000).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= 2592000) {
|
||||
return i18n.t("_ago.monthsAgo", {
|
||||
n: Math.floor(ago / 2592000).toString(),
|
||||
if (time >= 2592000) {
|
||||
return i18n.t(`${agoOrLater}.monthsAgo`, {
|
||||
n: Math.floor(time / 2592000).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= 604800) {
|
||||
return i18n.t("_ago.weeksAgo", {
|
||||
n: Math.floor(ago / 604800).toString(),
|
||||
if (time >= 604800) {
|
||||
return i18n.t(`${agoOrLater}.weeksAgo`, {
|
||||
n: Math.floor(time / 604800).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= 86400) {
|
||||
return i18n.t("_ago.daysAgo", {
|
||||
n: Math.floor(ago / 86400).toString(),
|
||||
if (time >= 86400) {
|
||||
return i18n.t(`${agoOrLater}.daysAgo`, {
|
||||
n: Math.floor(time / 86400).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= 3600) {
|
||||
return i18n.t("_ago.hoursAgo", {
|
||||
n: Math.floor(ago / 3600).toString(),
|
||||
if (time >= 3600) {
|
||||
return i18n.t(`${agoOrLater}.hoursAgo`, {
|
||||
n: Math.floor(time / 3600).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= 60) {
|
||||
return i18n.t("_ago.minutesAgo", {
|
||||
n: (~~(ago / 60)).toString(),
|
||||
if (time >= 60) {
|
||||
return i18n.t(`${agoOrLater}.minutesAgo`, {
|
||||
n: (~~(time / 60)).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= 10) {
|
||||
return i18n.t("_ago.secondsAgo", {
|
||||
n: (~~(ago % 60)).toString(),
|
||||
if (time >= 10) {
|
||||
return i18n.t(`${agoOrLater}.secondsAgo`, {
|
||||
n: (~~(time % 60)).toString(),
|
||||
});
|
||||
}
|
||||
if (ago >= -1) {
|
||||
return i18n.ts._ago.justNow;
|
||||
if (time >= -1) {
|
||||
return i18n.ts[agoOrLater].justNow;
|
||||
}
|
||||
return i18n.ts._ago.future;
|
||||
return i18n.ts[agoOrLater].future;
|
||||
});
|
||||
|
||||
let tickId: number | undefined;
|
||||
|
@ -109,13 +110,14 @@ function tick(forceUpdateTicker = false) {
|
|||
}
|
||||
|
||||
const _now = Date.now();
|
||||
const agoPrev = (now.value - _time.value) / 1000; /* ms */ // 現状のinterval
|
||||
const currentInterval = (now.value - _time.value) / 1000; /* ms */
|
||||
|
||||
now.value = _now;
|
||||
|
||||
const ago = (now.value - _time.value) / 1000; /* ms */ // 次のinterval
|
||||
const prev = agoPrev < 60 ? 10000 : agoPrev < 3600 ? 60000 : 180000;
|
||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
const newInterval = (now.value - _time.value) / 1000; /* ms */
|
||||
const prev =
|
||||
currentInterval < 60 ? 10000 : currentInterval < 3600 ? 60000 : 180000;
|
||||
const next = newInterval < 60 ? 10000 : newInterval < 3600 ? 60000 : 180000;
|
||||
|
||||
if (!tickId) {
|
||||
tickId = window.setInterval(tick, next);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -195,6 +195,7 @@ export type Note = {
|
|||
url?: string;
|
||||
updatedAt?: DateString;
|
||||
isHidden?: boolean;
|
||||
scheduledAt?: DateString;
|
||||
/** if the note is a history */
|
||||
historyId?: ID;
|
||||
};
|
||||
|
|
|
@ -16,6 +16,12 @@ export const packedNoteSchema = {
|
|||
nullable: false,
|
||||
format: "date-time",
|
||||
},
|
||||
scheduledAt: {
|
||||
type: "string",
|
||||
optional: true,
|
||||
nullable: false,
|
||||
format: "date-time",
|
||||
},
|
||||
text: {
|
||||
type: "string",
|
||||
optional: false,
|
||||
|
|
Loading…
Reference in a new issue