diff --git a/locales/en-US.yml b/locales/en-US.yml index 9cb58e6ef..cff6d0c7d 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2008,3 +2008,10 @@ _deck: list: "List" mentions: "Mentions" direct: "Direct messages" +_experiments: + title: "Experiments" + alpha: "Alpha" + beta: "Beta" + release: "Release" + enablePostEditing: "Enable post editing" + postEditingCaption: "Shows the option for users to edit their existing posts via the post options menu" diff --git a/packages/backend/migration/1683980686995-ExperimentalFeatures.js b/packages/backend/migration/1683980686995-ExperimentalFeatures.js new file mode 100644 index 000000000..a289a9ecd --- /dev/null +++ b/packages/backend/migration/1683980686995-ExperimentalFeatures.js @@ -0,0 +1,16 @@ +export class ExperimentalFeatures1683980686995 { + name = "ExperimentalFeatures1683980686995"; + + async up(queryRunner) { + await queryRunner.query(` + ALTER TABLE "meta" + ADD "experimentalFeatures" jsonb NOT NULL DEFAULT '{}' + `); + } + + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE "meta" DROP COLUMN "experimentalFeatures" + `); + } +} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index 84f9af479..3a3c50d4a 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -516,4 +516,9 @@ export class Meta { default: true, }) public enableActiveEmailValidation: boolean; + + @Column('jsonb', { + default: {}, + }) + public experimentalFeatures: Record; } diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 6a98fdfb2..e0b513298 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -243,6 +243,7 @@ import * as ep___notes_clips from "./endpoints/notes/clips.js"; import * as ep___notes_conversation from "./endpoints/notes/conversation.js"; import * as ep___notes_create from "./endpoints/notes/create.js"; import * as ep___notes_delete from "./endpoints/notes/delete.js"; +import * as ep___notes_edit from "./endpoints/notes/edit.js"; import * as ep___notes_favorites_create from "./endpoints/notes/favorites/create.js"; import * as ep___notes_favorites_delete from "./endpoints/notes/favorites/delete.js"; import * as ep___notes_featured from "./endpoints/notes/featured.js"; @@ -592,6 +593,7 @@ const eps = [ ["notes/conversation", ep___notes_conversation], ["notes/create", ep___notes_create], ["notes/delete", ep___notes_delete], + ["notes/edit", ep___notes_edit], ["notes/favorites/create", ep___notes_favorites_create], ["notes/favorites/delete", ep___notes_favorites_delete], ["notes/featured", ep___notes_featured], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 89928af11..803021152 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -2,6 +2,7 @@ import config from "@/config/index.js"; import { fetchMeta } from "@/misc/fetch-meta.js"; import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js"; import define from "../../define.js"; +import { Exp } from "@tensorflow/tfjs"; export const meta = { tags: ["meta"], @@ -470,10 +471,20 @@ export const meta = { optional: false, nullable: false, }, + experimentalFeatures: { + type: "object", + optional: true, + nullable: true, + ref: "MetaExperimentalFeatures", + }, }, }, } as const; +export type MetaExperimentalFeatures = { + postEditing: boolean; +}; + export const paramDef = { type: "object", properties: {}, @@ -580,5 +591,6 @@ export default define(meta, paramDef, async (ps, me) => { libreTranslateApiKey: instance.libreTranslateApiKey, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, + experimentalFeatures: instance.experimentalFeatures, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 7f92e5e29..c5d534879 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -170,6 +170,13 @@ export const paramDef = { objectStorageS3ForcePathStyle: { type: "boolean" }, enableIpLogging: { type: "boolean" }, enableActiveEmailValidation: { type: "boolean" }, + experimentalFeatures: { + type: "object", + nullable: true, + properties: { + postEditing: { type: "boolean" }, + }, + }, }, required: [], } as const; @@ -557,6 +564,10 @@ export default define(meta, paramDef, async (ps, me) => { set.enableActiveEmailValidation = ps.enableActiveEmailValidation; } + if (ps.experimentalFeatures !== undefined) { + set.experimentalFeatures = ps.experimentalFeatures || undefined; + } + await db.transaction(async (transactionalEntityManager) => { const metas = await transactionalEntityManager.find(Meta, { order: { diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 23989750f..2f409eb19 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -523,6 +523,7 @@ export default define(meta, paramDef, async (ps, me) => { github: instance.enableGithubIntegration, discord: instance.enableDiscordIntegration, serviceWorker: instance.enableServiceWorker, + postEditing: instance.experimentalFeatures?.postEditing || false, miauth: true, }; } diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts new file mode 100644 index 000000000..7cddb7d21 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -0,0 +1,643 @@ +import { In } from "typeorm"; +import create, { index } from "@/services/note/create.js"; +import type { IRemoteUser, User } from "@/models/entities/user.js"; +import { + Users, + DriveFiles, + Notes, + Channels, + Blockings, + UserProfiles, + Polls, + NoteEdits, +} from "@/models/index.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import type { IMentionedRemoteUsers, Note } from "@/models/entities/note.js"; +import type { Channel } from "@/models/entities/channel.js"; +import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import { noteVisibilities } from "../../../../types.js"; +import { ApiError } from "../../error.js"; +import define from "../../define.js"; +import { HOUR } from "@/const.js"; +import { getNote } from "../../common/getters.js"; +import { Poll } from "@/models/entities/poll.js"; +import * as mfm from "mfm-js"; +import { concat } from "@/prelude/array.js"; +import { extractHashtags } from "@/misc/extract-hashtags.js"; +import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js"; +import { extractMentionedUsers } from "@/services/note/create.js"; +import { genId } from "@/misc/gen-id.js"; +import { publishNoteStream } from "@/services/stream.js"; +import DeliverManager from "@/remote/activitypub/deliver-manager.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderNote from "@/remote/activitypub/renderer/note.js"; +import renderUpdate from "@/remote/activitypub/renderer/update.js"; +import { deliverToRelays } from "@/services/relay.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + limit: { + duration: HOUR, + max: 300, + }, + + kind: "write:notes", + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + createdNote: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + }, + + errors: { + noSuchRenoteTarget: { + message: "No such renote target.", + code: "NO_SUCH_RENOTE_TARGET", + id: "b5c90186-4ab0-49c8-9bba-a1f76c282ba4", + }, + + cannotReRenote: { + message: "You can not Renote a pure Renote.", + code: "CANNOT_RENOTE_TO_A_PURE_RENOTE", + id: "fd4cc33e-2a37-48dd-99cc-9b806eb2031a", + }, + + noSuchReplyTarget: { + message: "No such reply target.", + code: "NO_SUCH_REPLY_TARGET", + id: "749ee0f6-d3da-459a-bf02-282e2da4292c", + }, + + cannotReplyToPureRenote: { + message: "You can not reply to a pure Renote.", + code: "CANNOT_REPLY_TO_A_PURE_RENOTE", + id: "3ac74a84-8fd5-4bb0-870f-01804f82ce15", + }, + + cannotCreateAlreadyExpiredPoll: { + message: "Poll is already expired.", + code: "CANNOT_CREATE_ALREADY_EXPIRED_POLL", + id: "04da457d-b083-4055-9082-955525eda5a5", + }, + + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "b1653923-5453-4edc-b786-7c4f39bb0bbb", + }, + + youHaveBeenBlocked: { + message: "You have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "b390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + + accountLocked: { + message: "You migrated. Your account is now locked.", + code: "ACCOUNT_LOCKED", + id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + + needsEditId: { + message: "You need to specify `editId`.", + code: "NEEDS_EDIT_ID", + id: "d697edc8-8c73-4de8-bded-35fd198b79e5", + }, + + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "eef6c173-3010-4a23-8674-7c4fcaeba719", + }, + + youAreNotTheAuthor: { + message: "You are not the author of this note.", + code: "YOU_ARE_NOT_THE_AUTHOR", + id: "c6e61685-411d-43d0-b90a-a448d2539001", + }, + + cannotPrivateRenote: { + message: "You can not perform a private renote.", + code: "CANNOT_PRIVATE_RENOTE", + id: "19a50f1c-84fa-4e33-81d3-17834ccc0ad8", + }, + + notLocalUser: { + message: "You are not a local user.", + code: "NOT_LOCAL_USER", + id: "b907f407-2aa0-4283-800b-a2c56290b822", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + editId: { type: "string", format: "misskey:id" }, + visibility: { type: "string", enum: noteVisibilities, default: "public" }, + visibleUserIds: { + type: "array", + uniqueItems: true, + items: { + type: "string", + format: "misskey:id", + }, + }, + text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + cw: { type: "string", nullable: true, maxLength: 250 }, + localOnly: { type: "boolean", default: false }, + noExtractMentions: { type: "boolean", default: false }, + noExtractHashtags: { type: "boolean", default: false }, + noExtractEmojis: { type: "boolean", default: false }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + mediaIds: { + deprecated: true, + description: + "Use `fileIds` instead. If both are specified, this property is discarded.", + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + replyId: { type: "string", format: "misskey:id", nullable: true }, + renoteId: { type: "string", format: "misskey:id", nullable: true }, + channelId: { type: "string", format: "misskey:id", nullable: true }, + poll: { + type: "object", + nullable: true, + properties: { + choices: { + type: "array", + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: "string", minLength: 1, maxLength: 50 }, + }, + multiple: { type: "boolean", default: false }, + expiresAt: { type: "integer", nullable: true }, + expiredAfter: { type: "integer", nullable: true, minimum: 1 }, + }, + required: ["choices"], + }, + }, + anyOf: [ + { + // (re)note with text, files and poll are optional + properties: { + text: { + type: "string", + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + }, + required: ["text"], + }, + { + // (re)note with files, text and poll are optional + required: ["fileIds"], + }, + { + // (re)note with files, text and poll are optional + required: ["mediaIds"], + }, + { + // (re)note with poll, text and files are optional + properties: { + poll: { type: "object", nullable: false }, + }, + required: ["poll"], + }, + { + // pure renote + required: ["renoteId"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); + + if (!Users.isLocalUser(user)) { + throw new ApiError(meta.errors.notLocalUser); + } + + if (!ps.editId) { + throw new ApiError(meta.errors.needsEditId); + } + + let publishing = false; + let note = await Notes.findOneBy({ + id: ps.editId, + }); + + if (note == null) { + throw new ApiError(meta.errors.noSuchNote); + } + + if (note.userId !== user.id) { + throw new ApiError(meta.errors.youAreNotTheAuthor); + } + + let renote: Note | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await getNote(ps.renoteId, user).catch((e) => { + if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchRenoteTarget); + throw e; + }); + + if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: renote.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let reply: Note | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await getNote(ps.replyId, user).catch((e) => { + if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchReplyTarget); + throw e; + }); + + if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: reply.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let channel: Channel | null = null; + if (ps.channelId != null) { + channel = await Channels.findOneBy({ id: ps.channelId }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + } + + // enforce silent clients on server + if (user.isSilenced && ps.visibility === "public" && ps.channelId == null) { + ps.visibility = "home"; + } + + // Reject if the target of the renote is a public range other than "Home or Entire". + if ( + renote && + renote.visibility !== "public" && + renote.visibility !== "home" && + renote.userId !== user.id + ) { + throw new ApiError(meta.errors.cannotPrivateRenote); + } + + // If the target of the renote is not public, make it home. + if (renote && renote.visibility !== "public" && ps.visibility === "public") { + ps.visibility = "home"; + } + + // If the reply target is not public, make it home. + if (reply && reply.visibility !== "public" && ps.visibility === "public") { + ps.visibility = "home"; + } + + // Renote local only if you Renote local only. + if (renote?.localOnly && ps.channelId == null) { + ps.localOnly = true; + } + + // If you reply to local only, make it local only. + if (reply?.localOnly && ps.channelId == null) { + ps.localOnly = true; + } + + if (ps.text) { + ps.text = ps.text.trim(); + } else { + ps.text = null; + } + + let tags = []; + let emojis = []; + let mentionedUsers = []; + + const tokens = ps.text ? mfm.parse(ps.text) : []; + const cwTokens = ps.cw ? mfm.parse(ps.cw) : []; + const choiceTokens = ps.poll?.choices + ? concat(ps.poll.choices.map((choice) => mfm.parse(choice))) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = extractHashtags(combinedTokens); + + emojis = extractCustomEmojisFromMfm(combinedTokens); + + mentionedUsers = await extractMentionedUsers(user, combinedTokens); + + tags = [...new Set(tags)] + .sort() + .filter((tag) => Array.from(tag || "").length <= 128) + .splice(0, 32); + + emojis = [...new Set(emojis)].sort(); + + if ( + reply && + user.id !== reply.userId && + !mentionedUsers.some((u) => u.id === reply?.userId) + ) { + mentionedUsers.push(await Users.findOneByOrFail({ id: reply.userId })); + } + + let visibleUsers: User[] = []; + if (ps.visibleUserIds) { + visibleUsers = await Users.findBy({ + id: In(ps.visibleUserIds), + }); + } + + if (ps.visibility === "specified") { + if (visibleUsers == null) throw new Error("invalid param"); + + for (const u of visibleUsers) { + if (!mentionedUsers.some((x) => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (reply && !visibleUsers.some((x) => x.id === reply?.userId)) { + visibleUsers.push(await Users.findOneByOrFail({ id: reply.userId })); + } + } + + let files: DriveFile[] = []; + const fileIds = + ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; + if (fileIds != null) { + files = await DriveFiles.createQueryBuilder("file") + .where("file.userId = :userId AND file.id IN (:...fileIds)", { + userId: user.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + } + + if (ps.poll) { + let expires = ps.poll.expiresAt; + if (typeof expires === "number") { + if (expires < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === "number") { + expires = Date.now() + ps.poll.expiredAfter; + } + + let poll = await Polls.findOneBy({ noteId: note.id }); + const pp = ps.poll; + if (!poll && pp) { + poll = new Poll({ + noteId: note.id, + choices: pp.choices, + expiresAt: expires ? new Date(expires) : null, + multiple: pp.multiple, + votes: new Array(pp.choices.length).fill(0), + noteVisibility: ps.visibility, + userId: user.id, + userHost: user.host, + }); + await Polls.insert(poll); + publishing = true; + } else if (poll && !pp) { + await Polls.remove(poll); + publishing = true; + } else if (poll && pp) { + const pollUpdate: Partial = {}; + if (poll.expiresAt !== expires) { + pollUpdate.expiresAt = expires ? new Date(expires) : null; + } + if (poll.multiple !== pp.multiple) { + pollUpdate.multiple = pp.multiple; + } + if (poll.noteVisibility !== ps.visibility) { + pollUpdate.noteVisibility = ps.visibility; + } + // We can't do an unordered equal check because the order of choices + // is important and if it changes, we need to reset the votes. + if (JSON.stringify(poll.choices) !== JSON.stringify(pp.choices)) { + pollUpdate.choices = pp.choices; + pollUpdate.votes = new Array(pp.choices.length).fill(0); + } + if (notEmpty(pollUpdate)) { + await Polls.update(note.id, pollUpdate); + } + publishing = true; + } + } + + const mentionedUserLookup: Record = {}; + mentionedUsers.forEach((u) => { + mentionedUserLookup[u.id] = u; + }); + + const mentionedUserIds = [...new Set(mentionedUsers.map((u) => u.id))].sort(); + + const remoteUsers = mentionedUserIds + .map((id) => mentionedUserLookup[id]) + .filter((u) => u.host != null); + + const remoteUserIds = remoteUsers.map((user) => user.id); + const remoteProfiles = await UserProfiles.findBy({ + userId: In(remoteUserIds), + }); + const mentionedRemoteUsers = remoteUsers.map((user) => { + const profile = remoteProfiles.find( + (profile) => profile.userId === user.id, + ); + return { + username: user.username, + host: user.host ?? null, + uri: user.uri, + url: profile ? profile.url : undefined, + } as IMentionedRemoteUsers[0]; + }); + + const update: Partial = {}; + if (ps.text !== note.text) { + update.text = ps.text; + } + if (ps.cw !== note.cw) { + update.cw = ps.cw; + } + if (ps.visibility !== note.visibility) { + update.visibility = ps.visibility; + } + if (ps.localOnly !== note.localOnly) { + update.localOnly = ps.localOnly; + } + if (ps.visibleUserIds !== note.visibleUserIds) { + update.visibleUserIds = ps.visibleUserIds; + } + if (!unorderedEqual(mentionedUserIds, note.mentions)) { + update.mentions = mentionedUserIds; + update.mentionedRemoteUsers = JSON.stringify(mentionedRemoteUsers); + } + if (ps.channelId !== note.channelId) { + update.channelId = ps.channelId; + } + if (ps.replyId !== note.replyId) { + update.replyId = ps.replyId; + } + if (ps.renoteId !== note.renoteId) { + update.renoteId = ps.renoteId; + } + if (note.hasPoll !== !!ps.poll) { + update.hasPoll = !!ps.poll; + } + if (!unorderedEqual(emojis, note.emojis)) { + update.emojis = emojis; + } + if (!unorderedEqual(tags, note.tags)) { + update.tags = tags; + } + if (!unorderedEqual(ps.fileIds || [], note.fileIds)) { + update.fileIds = fileIds || undefined; + + if (fileIds) { + // Get attachedFileTypes for each file with fileId from fileIds + const attachedFiles = fileIds.map((fileId) => + files.find((file) => file.id === fileId), + ); + update.attachedFileTypes = attachedFiles.map( + (file) => file?.type || "application/octet-stream", + ); + } else { + update.attachedFileTypes = undefined; + } + } + + if (notEmpty(update)) { + update.updatedAt = new Date(); + await Notes.update(note.id, update); + + // Add NoteEdit history + await NoteEdits.insert({ + id: genId(), + noteId: note.id, + text: ps.text || undefined, + cw: ps.cw, + fileIds: ps.fileIds, + updatedAt: new Date(), + }); + + publishing = true; + } + + note = await Notes.findOneBy({ id: note.id }); + if (!note) { + throw new ApiError(meta.errors.noSuchNote); + } + + if (publishing) { + index(note); + + // Publish update event for the updated note details + publishNoteStream(note.id, "updated", { + updatedAt: update.updatedAt, + }); + + (async () => { + const noteActivity = await renderNote(note, false); + noteActivity.updated = note.updatedAt.toISOString(); + const updateActivity = renderUpdate(noteActivity, user); + updateActivity.to = noteActivity.to; + updateActivity.cc = noteActivity.cc; + const activity = renderActivity(updateActivity); + const dm = new DeliverManager(user, activity); + + // Delivery to remote mentioned users + for (const u of mentionedUsers.filter((u) => Users.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + + // Post is a reply and remote user is the contributor of the original post + if (note.reply && note.reply.userHost !== null) { + const u = await Users.findOneBy({ id: note.reply.userId }); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // Post is a renote and remote user is the contributor of the original post + if (note.renote && note.renote.userHost !== null) { + const u = await Users.findOneBy({ id: note.renote.userId }); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // Deliver to followers for non-direct posts. + if (["public", "home", "followers"].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + // Deliver to relays for public posts. + if (["public"].includes(note.visibility)) { + deliverToRelays(user, activity); + } + + // GO! + dm.execute(); + })(); + } + + return { + createdNote: await Notes.pack(note, user), + }; +}); + +function unorderedEqual(a: T[], b: T[]) { + return a.length === b.length && a.every((v) => b.includes(v)); +} + +function notEmpty(partial: Partial) { + return Object.keys(partial).length > 0; +} diff --git a/packages/calckey-js/src/api.types.ts b/packages/calckey-js/src/api.types.ts index 267f2ba0a..9f16056da 100644 --- a/packages/calckey-js/src/api.types.ts +++ b/packages/calckey-js/src/api.types.ts @@ -43,6 +43,26 @@ type NoParams = Record; type ShowUserReq = { username: string; host?: string } | { userId: User["id"] }; +type NoteSubmitReq = { + editId?: null | Note["id"]; + visibility?: "public" | "home" | "followers" | "specified"; + visibleUserIds?: User["id"][]; + text?: null | string; + cw?: null | string; + viaMobile?: boolean; + localOnly?: boolean; + fileIds?: DriveFile["id"][]; + replyId?: null | Note["id"]; + renoteId?: null | Note["id"]; + channelId?: null | Channel["id"]; + poll?: null | { + choices: string[]; + multiple?: boolean; + expiresAt?: null | number; + expiredAfter?: null | number; + }; +}; + export type Endpoints = { // admin "admin/abuse-user-reports": { req: TODO; res: TODO }; @@ -791,27 +811,14 @@ export type Endpoints = { "notes/clips": { req: TODO; res: TODO }; "notes/conversation": { req: TODO; res: TODO }; "notes/create": { - req: { - visibility?: "public" | "home" | "followers" | "specified"; - visibleUserIds?: User["id"][]; - text?: null | string; - cw?: null | string; - viaMobile?: boolean; - localOnly?: boolean; - fileIds?: DriveFile["id"][]; - replyId?: null | Note["id"]; - renoteId?: null | Note["id"]; - channelId?: null | Channel["id"]; - poll?: null | { - choices: string[]; - multiple?: boolean; - expiresAt?: null | number; - expiredAfter?: null | number; - }; - }; + req: NoteSubmitReq; res: { createdNote: Note }; }; "notes/delete": { req: { noteId: Note["id"] }; res: null }; + "notes/edit": { + req: NoteSubmitReq; + res: { createdNote: Note }; + }; "notes/favorites/create": { req: { noteId: Note["id"] }; res: null }; "notes/favorites/delete": { req: { noteId: Note["id"] }; res: null }; "notes/featured": { req: TODO; res: Note[] }; diff --git a/packages/calckey-js/src/entities.ts b/packages/calckey-js/src/entities.ts index f6bbda728..5a581a54c 100644 --- a/packages/calckey-js/src/entities.ts +++ b/packages/calckey-js/src/entities.ts @@ -145,6 +145,7 @@ export type Note = { visibility: "public" | "home" | "followers" | "specified"; visibleUserIds?: User["id"][]; localOnly?: boolean; + channel?: Channel["id"]; myReaction?: string; reactions: Record; renoteCount: number; @@ -164,6 +165,7 @@ export type Note = { }[]; uri?: string; url?: string; + updatedAt?: DateString; isHidden?: boolean; }; diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue index 970c6c35f..3be9140fb 100644 --- a/packages/client/src/components/MkPostForm.vue +++ b/packages/client/src/components/MkPostForm.vue @@ -275,6 +275,7 @@ const props = withDefaults( instant?: boolean; fixed?: boolean; autofocus?: boolean; + editId?: misskey.entities.Note["id"]; }>(), { initialVisibleUsers: () => [], @@ -335,6 +336,10 @@ const typing = throttle(3000, () => { }); const draftKey = $computed((): string => { + if (props.editId) { + return `edit:${props.editId}`; + } + let key = props.channel ? `channel:${props.channel.id}` : ""; if (props.renote) { @@ -369,7 +374,9 @@ const placeholder = $computed((): string => { }); const submitText = $computed((): string => { - return props.renote + return props.editId + ? i18n.ts.edit + : props.renote ? i18n.ts.quote : props.reply ? i18n.ts.reply @@ -810,6 +817,7 @@ async function post() { const processedText = preprocess(text); let postData = { + editId: props.editId ? props.editId : undefined, text: processedText === "" ? undefined : processedText, fileIds: files.length > 0 ? files.map((f) => f.id) : undefined, replyId: props.reply ? props.reply.id : undefined, @@ -855,7 +863,7 @@ async function post() { } posting = true; - os.api("notes/create", postData, token) + os.api(postData.editId ? "notes/edit" : "notes/create", postData, token) .then(() => { clear(); nextTick(() => { diff --git a/packages/client/src/components/MkPostFormDialog.vue b/packages/client/src/components/MkPostFormDialog.vue index c96a94c0c..b95bf35fa 100644 --- a/packages/client/src/components/MkPostFormDialog.vue +++ b/packages/client/src/components/MkPostFormDialog.vue @@ -39,6 +39,7 @@ const props = defineProps<{ instant?: boolean; fixed?: boolean; autofocus?: boolean; + editId?: misskey.entities.Note["id"]; }>(); const emit = defineEmits<{ diff --git a/packages/client/src/components/MkRenoteButton.vue b/packages/client/src/components/MkRenoteButton.vue index b86ca535b..631c7415a 100644 --- a/packages/client/src/components/MkRenoteButton.vue +++ b/packages/client/src/components/MkRenoteButton.vue @@ -25,6 +25,7 @@ import { $i } from "@/account"; import { useTooltip } from "@/scripts/use-tooltip"; import { i18n } from "@/i18n"; import { defaultStore } from "@/store"; +import { MenuItem } from "@/types/menu"; const props = defineProps<{ note: misskey.entities.Note; @@ -73,7 +74,7 @@ const renote = async (viaKeyboard = false, ev?: MouseEvent) => { const users = renotes.map((x) => x.user.id); const hasRenotedBefore = users.includes($i.id); - let buttonActions = []; + let buttonActions: Array = []; if (props.note.visibility === "public") { buttonActions.push({ diff --git a/packages/client/src/instance.ts b/packages/client/src/instance.ts index ad83528e1..3381684a0 100644 --- a/packages/client/src/instance.ts +++ b/packages/client/src/instance.ts @@ -8,7 +8,7 @@ const instanceData = localStorage.getItem("instance"); // TODO: instanceをリアクティブにするかは再考の余地あり -export const instance: Misskey.entities.InstanceMetadata = reactive( +export const instance: Misskey.entities.DetailedInstanceMetadata = reactive( instanceData ? JSON.parse(instanceData) : { @@ -18,7 +18,7 @@ export const instance: Misskey.entities.InstanceMetadata = reactive( export async function fetchInstance() { const meta = await api("meta", { - detail: false, + detail: true, }); for (const [k, v] of Object.entries(meta)) { diff --git a/packages/client/src/pages/admin/experiments.vue b/packages/client/src/pages/admin/experiments.vue new file mode 100644 index 000000000..f540cea9d --- /dev/null +++ b/packages/client/src/pages/admin/experiments.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue index 34d0404fc..ddc5fc680 100644 --- a/packages/client/src/pages/admin/index.vue +++ b/packages/client/src/pages/admin/index.vue @@ -300,6 +300,12 @@ const menuDef = $computed(() => [ to: "/admin/database", active: currentPage?.route.name === "database", }, + { + icon: "ph-flask ph-bold ph-lg", + text: i18n.ts._experiments.title, + to: "/admin/experiments", + active: currentPage?.route.name === "experiments", + }, ], }, ] diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 7f03279dd..a84c2c8b3 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -540,6 +540,11 @@ export const routes = [ name: "other-settings", component: page(() => import("./pages/admin/custom-css.vue")), }, + { + path: "/experiments", + name: "experiments", + component: page(() => import("./pages/admin/experiments.vue")), + }, { path: "/", component: page(() => import("./pages/_empty_.vue")), diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index 03c35e132..1ec36d1ef 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -1,6 +1,5 @@ import { defineAsyncComponent, Ref, inject } from "vue"; import * as misskey from "calckey-js"; -import { pleaseLogin } from "./please-login"; import { $i } from "@/account"; import { i18n } from "@/i18n"; import { instance } from "@/instance"; @@ -12,7 +11,7 @@ import { shareAvailable } from "@/scripts/share-available"; export function getNoteMenu(props: { note: misskey.entities.Note; - menuButton: Ref; + menuButton: Ref; translation: Ref; translating: Ref; isDeleted: Ref; @@ -61,6 +60,25 @@ export function getNoteMenu(props: { }); } + function edit(): void { + os.post({ + initialNote: appearNote, + renote: appearNote.renote, + reply: appearNote.reply, + channel: appearNote.channel, + editId: appearNote.id, + }); + } + + function duplicate(): void { + os.post({ + initialNote: appearNote, + renote: appearNote.renote, + reply: appearNote.reply, + channel: appearNote.channel, + }); + } + function toggleFavorite(favorite: boolean): void { os.apiWithDialog( favorite ? "notes/favorites/create" : "notes/favorites/delete", @@ -251,6 +269,9 @@ export function getNoteMenu(props: { noteId: appearNote.id, }); + const isAppearAuthor = appearNote.userId === $i.id; + const isModerator = $i.isAdmin || $i.isModerator; + menu = [ ...(props.currentClipPage?.value.userId === $i.id ? [ @@ -320,7 +341,7 @@ export function getNoteMenu(props: { text: i18n.ts.clip, action: () => clip(), }, - appearNote.userId !== $i.id + !isAppearAuthor ? statePromise.then((state) => state.isWatching ? { @@ -348,7 +369,7 @@ export function getNoteMenu(props: { action: () => toggleThreadMute(true), }, ), - appearNote.userId === $i.id + isAppearAuthor ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { icon: "ph-push-pin ph-bold ph-lg", @@ -371,7 +392,7 @@ export function getNoteMenu(props: { }] : [] ),*/ - ...(appearNote.userId !== $i.id + ...(!isAppearAuthor ? [ null, { @@ -397,24 +418,43 @@ export function getNoteMenu(props: { }, ] : []), - ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin - ? [ - null, - appearNote.userId === $i.id - ? { - icon: "ph-eraser ph-bold ph-lg", - text: i18n.ts.deleteAndEdit, - action: delEdit, - } - : undefined, - { - icon: "ph-trash ph-bold ph-lg", - text: i18n.ts.delete, - danger: true, - action: del, - }, - ] - : []), + + null, + + instance.features.postEditing && isAppearAuthor + ? { + icon: "ph-pencil-line ph-bold ph-lg", + text: i18n.ts.edit, + textStyle: "color: var(--accent)", + action: edit, + } + : undefined, + + instance.features.postEditing + ? { + icon: "ph-copy ph-bold ph-lg", + text: i18n.ts.duplicate, + textStyle: "color: var(--accent)", + action: duplicate, + } + : undefined, + + isAppearAuthor || isModerator + ? { + icon: "ph-trash ph-bold ph-lg", + text: i18n.ts.delete, + danger: true, + action: del, + } + : undefined, + + isAppearAuthor + ? { + icon: "ph-eraser ph-bold ph-lg", + text: i18n.ts.deleteAndEdit, + action: delEdit, + } + : undefined, ].filter((x) => x !== undefined); } else { menu = [