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:
naskya 2024-05-20 12:58:49 +00:00
commit ff27c6789d
32 changed files with 546 additions and 77 deletions

11
.gitignore vendored
View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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 '[]';

View file

@ -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"

View file

@ -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: "合并同一个帖子的转发"

View file

@ -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',

View file

@ -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;

View file

@ -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()

View file

@ -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;

View 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 {}

View file

@ -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()

View file

@ -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,
];

View file

@ -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"`);
}
}

View 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
}

View file

@ -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);

View file

@ -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,

View file

@ -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",

View file

@ -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>

View 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();
}

View file

@ -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>;

View file

@ -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),
};

View file

@ -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) => {

View file

@ -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) &&

View file

@ -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="

View file

@ -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;

View file

@ -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>();

View file

@ -52,47 +52,48 @@ const relative = computed<string>(() => {
if (props.mode === "absolute") return ""; // absoluterelative使
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);

View file

@ -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";

View file

@ -69,6 +69,7 @@ export type NoteSubmitReq = {
expiredAfter: number | null;
};
lang?: string;
scheduledAt?: number | null;
};
export type Endpoints = {

View file

@ -195,6 +195,7 @@ export type Note = {
url?: string;
updatedAt?: DateString;
isHidden?: boolean;
scheduledAt?: DateString;
/** if the note is a history */
historyId?: ID;
};

View file

@ -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,