diff --git a/Dockerfile b/Dockerfile
index 3528847454..acda90fe88 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -38,7 +38,7 @@ ARG UID="991"
ARG GID="991"
ENV COREPACK_DEFAULT_TO_LATEST=0
-RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng \
+RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng librsvg font-noto font-noto-cjk font-noto-thai \
&& corepack enable \
&& addgroup -g "${GID}" sharkey \
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 382cd5a114..4314b1c468 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -12986,6 +12986,10 @@ export interface Locale extends ILocale {
* Unable to process quote. This post may be missing context.
*/
"quoteUnavailable": string;
+ /**
+ * One or more media attachments are unavailable and cannot be shown.
+ */
+ "attachmentFailed": string;
};
/**
* Authorized Fetch
@@ -13289,6 +13293,10 @@ export interface Locale extends ILocale {
* ActivityPub user data in its raw form. These fields are public and accessible to other instances.
*/
"rawApDescription": string;
+ /**
+ * Signup Reason
+ */
+ "signupReason": string;
}
declare const locales: {
[lang: string]: Locale;
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 331cddae78..88244db346 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -90,7 +90,7 @@
"@simplewebauthn/server": "12.0.0",
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
- "mfm-js": "npm:@transfem-org/sfm-js@0.24.6",
+ "mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"@twemoji/parser": "15.1.1",
"accepts": "1.3.8",
"ajv": "8.17.1",
diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts
index 9bca795479..307f22586e 100644
--- a/packages/backend/src/core/AbuseReportNotificationService.ts
+++ b/packages/backend/src/core/AbuseReportNotificationService.ts
@@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
}
}
+ /**
+ * Collects all email addresses that a abuse report should be sent to.
+ */
+ @bindThis
+ public async getRecipientEMailAddresses(): Promise {
+ const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
+ .filter(it => it.isActive && it.userProfile?.emailVerified)
+ .map(it => it.userProfile?.email)
+ .filter(x => x != null),
+ );
+
+ if (this.meta.email) {
+ recipientEMailAddresses.push(this.meta.email);
+ }
+
+ if (this.meta.maintainerEmail) {
+ recipientEMailAddresses.push(this.meta.maintainerEmail);
+ }
+
+ return recipientEMailAddresses;
+ }
+
/**
* Mailを用いて{@link abuseReports}の内容を管理者各位に通知する.
* メールアドレスの送信先は以下の通り.
@@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
return;
}
- const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
- .filter(it => it.isActive && it.userProfile?.emailVerified)
- .map(it => it.userProfile?.email)
- .filter(x => x != null),
- );
-
- recipientEMailAddresses.push(
- ...(this.meta.email ? [this.meta.email] : []),
- );
+ const recipientEMailAddresses = await this.getRecipientEMailAddresses();
if (recipientEMailAddresses.length <= 0) {
return;
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index 1f15b16617..b9be4e3039 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -164,7 +164,7 @@ export class DriveService {
try {
await this.videoProcessingService.webOptimizeVideo(path, type);
} catch (err) {
- this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err });
+ this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
}
}
@@ -367,7 +367,7 @@ export class DriveService {
this.registerLogger.debug('web image not created (not an required image)');
}
} catch (err) {
- this.registerLogger.warn('web image not created (an error occurred)', err as Error);
+ this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`);
}
} else {
if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)');
@@ -386,7 +386,7 @@ export class DriveService {
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
}
} catch (err) {
- this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
+ this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`);
}
// #endregion thumbnail
@@ -420,27 +420,21 @@ export class DriveService {
);
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
- if (this.bunnyService.usingBunnyCDN(this.meta)) {
- await this.bunnyService.upload(this.meta, key, stream).catch(
- err => {
- this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
- },
- );
- } else {
- await this.s3Service.upload(this.meta, params)
- .then(
- result => {
- if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
- this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
- } else { // AbortMultipartUploadCommandOutput
- this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
- }
- })
- .catch(
- err => {
- this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
- },
- );
+ try {
+ if (this.bunnyService.usingBunnyCDN(this.meta)) {
+ await this.bunnyService.upload(this.meta, key, stream);
+ } else {
+ const result = await this.s3Service.upload(this.meta, params);
+ if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
+ this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
+ } else { // AbortMultipartUploadCommandOutput
+ this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
+ throw new Error('S3 upload aborted');
+ }
+ }
+ } catch (err) {
+ this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`);
+ throw err;
}
}
@@ -857,7 +851,7 @@ export class DriveService {
}
} catch (err: any) {
if (err.name === 'NoSuchKey') {
- this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
+ this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`);
return;
} else {
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts
index 551b25394a..4f9f553e7e 100644
--- a/packages/backend/src/core/MfmService.ts
+++ b/packages/backend/src/core/MfmService.ts
@@ -75,9 +75,8 @@ export class MfmService {
switch (node.tagName) {
case 'br': {
text += '\n';
- break;
+ return;
}
-
case 'a': {
const txt = getText(node);
const rel = node.attribs.rel;
@@ -123,9 +122,16 @@ export class MfmService {
text += generateLink();
}
- break;
+ return;
}
+ }
+ // Don't produce invalid empty MFM
+ if (node.childNodes.length < 1) {
+ return;
+ }
+
+ switch (node.tagName) {
case 'h1': {
text += '**【';
appendChildren(node.childNodes);
@@ -329,6 +335,38 @@ export class MfmService {
break;
}
+ // Replace iframe with link so we can generate previews.
+ // We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it.
+ case 'iframe': {
+ const txt: string | undefined = node.attribs.title || node.attribs.alt;
+ const href: string | undefined = node.attribs.src;
+ if (href) {
+ if (href.match(/[\s>]/)) {
+ if (txt) {
+ // href is invalid + has a label => render a pseudo-link
+ text += `${text} (${href})`;
+ } else {
+ // href is invalid + no label => render plain text
+ text += href;
+ }
+ } else {
+ if (txt) {
+ // href is valid + has a label => render a link
+ const label = txt
+ .replaceAll('[', '(')
+ .replaceAll(']', ')')
+ .replaceAll(/\r?\n/, ' ')
+ .replaceAll('`', '\'');
+ text += `[${label}](<${href}>)`;
+ } else {
+ // href is valid + no label => render a plain URL
+ text += `<${href}>`;
+ }
+ }
+ }
+ break;
+ }
+
default: // includes inline elements
{
appendChildren(node.childNodes);
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 0dd0a9b822..f4159facc3 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -731,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => {
- const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
+ const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@@ -874,17 +874,6 @@ export class NoteCreateService implements OnApplicationShutdown {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
}
- @bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
- if (data.localOnly) return null;
-
- const content = this.isRenote(data) && !this.isQuote(data)
- ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
- : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
-
- return this.apRendererService.addContext(content);
- }
-
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return;
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 533ee7942d..4be097465d 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -675,7 +675,7 @@ export class NoteEditService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => {
- const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
+ const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@@ -770,17 +770,6 @@ export class NoteEditService implements OnApplicationShutdown {
(note.files != null && note.files.length > 0);
}
- @bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
- if (data.localOnly) return null;
-
- const content = this.isRenote(data) && !this.isQuote(data)
- ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
- : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
-
- return this.apRendererService.addContext(content);
- }
-
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return;
diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts
index f375dff862..ddadab7022 100644
--- a/packages/backend/src/core/UserSuspendService.ts
+++ b/packages/backend/src/core/UserSuspendService.ts
@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
-import type { MiUser } from '@/models/User.js';
+import { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@@ -17,9 +17,15 @@ import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
import { CacheService } from '@/core/CacheService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import type Logger from '@/logger.js';
+import { renderInlineError } from '@/misc/render-inline-error.js';
+import { trackPromise } from '@/misc/promise-tracker.js';
@Injectable()
export class UserSuspendService {
+ private readonly logger: Logger;
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -36,7 +42,10 @@ export class UserSuspendService {
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
+
+ loggerService: LoggerService,
) {
+ this.logger = loggerService.getLogger('user-suspend');
}
@bindThis
@@ -47,16 +56,16 @@ export class UserSuspendService {
isSuspended: true,
});
- this.moderationLogService.log(moderator, 'suspend', {
+ await this.moderationLogService.log(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
- (async () => {
- await this.postSuspend(user).catch(e => {});
- await this.unFollowAll(user).catch(e => {});
- })();
+ trackPromise((async () => {
+ await this.postSuspend(user);
+ await this.freezeAll(user);
+ })().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
}
@bindThis
@@ -65,33 +74,36 @@ export class UserSuspendService {
isSuspended: false,
});
- this.moderationLogService.log(moderator, 'unsuspend', {
+ await this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
- (async () => {
- await this.postUnsuspend(user).catch(e => {});
- })();
+ trackPromise((async () => {
+ await this.postUnsuspend(user);
+ await this.unFreezeAll(user);
+ })().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
+ /*
this.followRequestsRepository.delete({
followeeId: user.id,
});
this.followRequestsRepository.delete({
followerId: user.id,
});
+ */
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
- const queue: string[] = [];
+ const queue = new Map();
const followings = await this.followingsRepository.find({
where: [
@@ -104,12 +116,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
- if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ if (inbox != null) {
+ queue.set(inbox, true);
+ }
}
- for (const inbox of queue) {
- this.queueService.deliver(user, content, inbox, true);
- }
+ await this.queueService.deliverMany(user, content, queue);
}
}
@@ -121,7 +133,7 @@ export class UserSuspendService {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
- const queue: string[] = [];
+ const queue = new Map();
const followings = await this.followingsRepository.find({
where: [
@@ -134,12 +146,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
- if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ if (inbox != null) {
+ queue.set(inbox, true);
+ }
}
- for (const inbox of queue) {
- this.queueService.deliver(user as any, content, inbox, true);
- }
+ await this.queueService.deliverMany(user, content, queue);
}
}
@@ -160,4 +172,36 @@ export class UserSuspendService {
}
this.queueService.createUnfollowJob(jobs);
}
+
+ @bindThis
+ private async freezeAll(user: MiUser): Promise {
+ // Freeze follow relations with all remote users
+ await this.followingsRepository
+ .createQueryBuilder('following')
+ .orWhere({
+ followeeId: user.id,
+ followerHost: Not(IsNull()),
+ })
+ .update({
+ isFollowerHibernated: true,
+ })
+ .execute();
+ }
+
+ @bindThis
+ private async unFreezeAll(user: MiUser): Promise {
+ // Restore follow relations with all remote users
+ await this.followingsRepository
+ .createQueryBuilder('following')
+ .innerJoin(MiUser, 'follower', 'user.id = following.followerId')
+ .andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
+ .andWhere({
+ followeeId: user.id,
+ followerHost: Not(IsNull()),
+ })
+ .update({
+ isFollowerHibernated: false,
+ })
+ .execute();
+ }
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index aee16a74bb..6ae1f689ba 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
+import { IdService } from '@/core/IdService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
@@ -166,6 +167,7 @@ export class WebhookTestService {
private userWebhookService: UserWebhookService,
private systemWebhookService: SystemWebhookService,
private queueService: QueueService,
+ private readonly idService: IdService,
) {
}
@@ -451,6 +453,8 @@ export class WebhookTestService {
offsetX: it.offsetX,
offsetY: it.offsetY,
})),
+ createdAt: this.idService.parse(user.id).date.toISOString(),
+ description: '',
isBot: user.isBot,
isCat: user.isCat,
emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host),
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 08a8f30049..623e7002cd 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -32,6 +32,8 @@ import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { QueryService } from '@/core/QueryService.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@@ -75,6 +77,7 @@ export class ApRendererService {
private idService: IdService,
private readonly queryService: QueryService,
private utilityService: UtilityService,
+ private readonly cacheService: CacheService,
) {
}
@@ -232,7 +235,7 @@ export class ApRendererService {
*/
@bindThis
public async renderFollowUser(id: MiUser['id']): Promise {
- const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser;
+ const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser;
return this.userEntityService.getUserUri(user);
}
@@ -402,7 +405,7 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) {
- const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
+ const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
if (inReplyToUser) {
if (inReplyToNote.uri) {
@@ -422,7 +425,7 @@ export class ApRendererService {
let quote: string | undefined = undefined;
- if (note.renoteId) {
+ if (isRenote(note) && isQuote(note)) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
if (renote) {
@@ -542,6 +545,7 @@ export class ApRendererService {
attributedTo,
summary: summary ?? undefined,
content: content ?? undefined,
+ updated: note.updatedAt?.toISOString() ?? undefined,
_misskey_content: text,
source: {
content: text,
@@ -756,176 +760,6 @@ export class ApRendererService {
};
}
- @bindThis
- public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise {
- const getPromisedFiles = async (ids: string[]): Promise => {
- if (ids.length === 0) return [];
- const items = await this.driveFilesRepository.findBy({ id: In(ids) });
- return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
- };
-
- let inReplyTo;
- let inReplyToNote: MiNote | null;
-
- if (note.replyId) {
- inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
-
- if (inReplyToNote != null) {
- const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
-
- if (inReplyToUser) {
- if (inReplyToNote.uri) {
- inReplyTo = inReplyToNote.uri;
- } else {
- if (dive) {
- inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
- } else {
- inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
- }
- }
- }
- }
- } else {
- inReplyTo = null;
- }
-
- let quote: string | undefined = undefined;
-
- if (note.renoteId) {
- const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
-
- if (renote) {
- quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
- }
- }
-
- const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
-
- const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
-
- let to: string[] = [];
- let cc: string[] = [];
-
- if (note.visibility === 'public') {
- to = ['https://www.w3.org/ns/activitystreams#Public'];
- cc = [`${attributedTo}/followers`].concat(mentions);
- } else if (note.visibility === 'home') {
- to = [`${attributedTo}/followers`];
- cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
- } else if (note.visibility === 'followers') {
- to = [`${attributedTo}/followers`];
- cc = mentions;
- } else {
- to = mentions;
- }
-
- const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({
- id: In(note.mentions),
- }) : [];
-
- const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
- const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser));
-
- const files = await getPromisedFiles(note.fileIds);
-
- const text = note.text ?? '';
- let poll: MiPoll | null = null;
-
- if (note.hasPoll) {
- poll = await this.pollsRepository.findOneBy({ noteId: note.id });
- }
-
- const apAppend: Appender[] = [];
-
- if (quote) {
- // Append quote link as `
RE: ...`
- // the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
- // For compatibility, the span part should be kept as possible.
- apAppend.push((doc, body) => {
- body.childNodes.push(new Element('br', {}));
- body.childNodes.push(new Element('br', {}));
- const span = new Element('span', {
- class: 'quote-inline',
- });
- span.childNodes.push(new Text('RE: '));
- const link = new Element('a', {
- href: quote,
- });
- link.childNodes.push(new Text(quote));
- span.childNodes.push(link);
- body.childNodes.push(span);
- });
- }
-
- let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
-
- // Apply mandatory CW, if applicable
- if (author.mandatoryCW) {
- summary = appendContentWarning(summary, author.mandatoryCW);
- }
-
- const { content } = this.apMfmService.getNoteHtml(note, apAppend);
-
- const emojis = await this.getEmojis(note.emojis);
- const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
-
- const tag: IObject[] = [
- ...hashtagTags,
- ...mentionTags,
- ...apemojis,
- ];
-
- // https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
- if (quote) {
- tag.push({
- type: 'Link',
- mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- rel: 'https://misskey-hub.net/ns#_misskey_quote',
- href: quote,
- } satisfies ILink);
- }
-
- const asPoll = poll ? {
- type: 'Question',
- [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
- [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
- type: 'Note',
- name: text,
- replies: {
- type: 'Collection',
- totalItems: poll!.votes[i],
- },
- })),
- } as const : {};
-
- return {
- id: `${this.config.url}/notes/${note.id}`,
- type: 'Note',
- attributedTo,
- summary: summary ?? undefined,
- content: content ?? undefined,
- updated: note.updatedAt?.toISOString(),
- _misskey_content: text,
- source: {
- content: text,
- mediaType: 'text/x.misskeymarkdown',
- },
- _misskey_quote: quote,
- quoteUrl: quote,
- quoteUri: quote,
- // https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
- quote: quote,
- published: this.idService.parse(note.id).date.toISOString(),
- to,
- cc,
- inReplyTo,
- attachment: files.map(x => this.renderDocument(x)),
- sensitive: note.cw != null || files.some(file => file.isSensitive),
- tag,
- ...asPoll,
- };
- }
-
@bindThis
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
return {
@@ -1079,6 +913,27 @@ export class ApRendererService {
};
}
+ @bindThis
+ public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) {
+ if (note.localOnly) return null;
+
+ if (isPureRenote(note)) {
+ const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
+ const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note);
+ return this.addContext(apAnnounce);
+ }
+
+ const apNote = await this.renderNote(note, user, false);
+
+ if (note.updatedAt != null) {
+ const apUpdate = this.renderUpdate(apNote, user);
+ return this.addContext(apUpdate);
+ } else {
+ const apCreate = this.renderCreate(apNote, note);
+ return this.addContext(apCreate);
+ }
+ }
+
@bindThis
private async getEmojis(names: string[]): Promise {
if (names.length === 0) return [];
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index 201920612c..d53e265d36 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -21,6 +21,8 @@ import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.js';
+import { isPureRenote } from '@/misc/is-renote.js';
+import { CacheService } from '@/core/CacheService.js';
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
@@ -49,6 +51,7 @@ export class Resolver {
private loggerService: LoggerService,
private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
+ private readonly cacheService: CacheService,
private recursionLimit = 256,
) {
this.history = new Set();
@@ -355,18 +358,20 @@ export class Resolver {
switch (parsed.type) {
case 'notes':
- return this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() })
+ return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } })
.then(async note => {
- const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
+ const author = note.user ?? await this.cacheService.findUserById(note.userId);
if (parsed.rest === 'activity') {
- // this refers to the create activity and not the note itself
- return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
+ return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
+ } else if (!isPureRenote(note)) {
+ const apNote = await this.apRendererService.renderNote(note, author);
+ return this.apRendererService.addContext(apNote);
} else {
- return this.apRendererService.renderNote(note, author);
+ throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`);
}
}) as Promise;
case 'users':
- return this.usersRepository.findOneByOrFail({ id: parsed.id, host: IsNull() })
+ return this.cacheService.findLocalUserById(parsed.id)
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
case 'questions':
// Polls are indexed by the note they are attached to.
@@ -387,14 +392,8 @@ export class Resolver {
.then(async followRequest => {
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`);
const [follower, followee] = await Promise.all([
- this.usersRepository.findOneBy({
- id: followRequest.followerId,
- host: IsNull(),
- }),
- this.usersRepository.findOneBy({
- id: followRequest.followeeId,
- host: Not(IsNull()),
- }),
+ this.cacheService.findLocalUserById(followRequest.followerId),
+ this.cacheService.findLocalUserById(followRequest.followeeId),
]);
if (follower == null || followee == null) {
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`);
@@ -440,6 +439,7 @@ export class ApResolverService {
private loggerService: LoggerService,
private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
+ private readonly cacheService: CacheService,
) {
}
@@ -465,6 +465,7 @@ export class ApResolverService {
this.loggerService,
this.apLogService,
this.apUtilityService,
+ this.cacheService,
opts?.recursionLimit,
);
}
diff --git a/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts b/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts
new file mode 100644
index 0000000000..3816479fd3
--- /dev/null
+++ b/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts
@@ -0,0 +1,83 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { load as cheerio } from 'cheerio/slim';
+import type { IApDocument } from '@/core/activitypub/type.js';
+import type { CheerioAPI } from 'cheerio/slim';
+
+/**
+ * Finds HTML elements representing inline media and returns them as simulated AP documents.
+ * Returns an empty array if the input cannot be parsed, or no media was found.
+ * @param html Input HTML to analyze.
+ */
+export function extractMediaFromHtml(html: string): IApDocument[] {
+ const $ = parseHtml(html);
+ if (!$) return [];
+
+ const attachments = new Map();
+
+ //
tags, including and
({{ i18n.ts.private }})
-
-
-
RN:
+
+
+
+
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index bf0cb45108..0938b26e21 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -112,7 +112,7 @@
"@vue/compiler-core": "3.5.14",
"@vue/compiler-sfc": "3.5.14",
"@vue/runtime-core": "3.5.14",
- "mfm-js": "npm:@transfem-org/sfm-js@0.24.6",
+ "mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"acorn": "8.14.1",
"astring": "1.9.0",
"cross-env": "7.0.3",
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index ef9d4c68fa..56bfa5de94 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -72,20 +72,21 @@ SPDX-License-Identifier: AGPL-3.0-only
({{ i18n.ts.private }})
-
-
+
+
+
+
{{ i18n.ts._animatedMFM.play }}
{{ i18n.ts._animatedMFM.stop }}
@@ -305,7 +306,7 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : []);
+const urls = computed(() => parsed.value ? extractPreviewUrls(appearNote.value, parsed.value) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index b941d776de..7f38b9ec02 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -89,21 +89,21 @@ SPDX-License-Identifier: AGPL-3.0-only
({{ i18n.ts.private }})
-
-
-
RN:
+
+
+
+
{{ i18n.ts._animatedMFM.play }}
{{ i18n.ts._animatedMFM.stop }}
@@ -414,6 +414,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab);
const reactionTabType = ref
(null);
+// Auto-select the first page of reactions
+watch(appearNote, n => {
+ reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null;
+}, { immediate: true });
+
const renotesPagination = computed(() => ({
endpoint: 'notes/renotes',
limit: 10,
diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue
index 6b70fddecf..58de5bd5a7 100644
--- a/packages/frontend/src/components/MkNoteSub.vue
+++ b/packages/frontend/src/components/MkNoteSub.vue
@@ -114,6 +114,7 @@ import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance, policies } from '@/instance';
+import { getAppearNote } from '@/utility/get-appear-note';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -128,7 +129,9 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined,
});
-const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
+const appearNote = computed(() => getAppearNote(props.note));
+
+const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const el = shallowRef();
const translation = ref(null);
@@ -144,19 +147,11 @@ const likeButton = shallowRef();
const renoteTooltip = computeRenoteTooltip(renoted);
-const appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
-const isRenote = (
- props.note.renote != null &&
- props.note.text == null &&
- props.note.fileIds && props.note.fileIds.length === 0 &&
- props.note.poll == null
-);
-
const pleaseLoginContext = computed(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@@ -206,8 +201,8 @@ async function reply(viaKeyboard = false): Promise {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
await os.post({
- reply: props.note,
- channel: props.note.channel ?? undefined,
+ reply: appearNote.value,
+ channel: appearNote.value.channel ?? undefined,
animation: !viaKeyboard,
});
focus();
@@ -217,9 +212,9 @@ function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
sound.playMisskeySfx('reaction');
- if (props.note.reactionAcceptance === 'likeOnly') {
+ if (appearNote.value.reactionAcceptance === 'likeOnly') {
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = reactButton.value as HTMLElement | null | undefined;
@@ -233,12 +228,12 @@ function react(): void {
}
} else {
blur();
- reactionPicker.show(reactButton.value ?? null, props.note, reaction => {
+ reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => {
misskeyApi('notes/reactions/create', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
reaction: reaction,
});
- if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
+ if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
@@ -252,7 +247,7 @@ function like(): void {
showMovedDialog();
sound.playMisskeySfx('reaction');
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = likeButton.value as HTMLElement | null | undefined;
@@ -361,7 +356,7 @@ function quote() {
}).then((cancelled) => {
if (cancelled) return;
misskeyApi('notes/renotes', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
userId: $i?.id,
limit: 1,
quote: true,
@@ -383,12 +378,12 @@ function quote() {
}
function menu(): void {
- const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted });
+ const { menu, cleanup } = getNoteMenu({ note: appearNote.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise {
- os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
+ os.popupMenu(await getNoteClipMenu({ note: appearNote.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
@@ -397,7 +392,7 @@ async function translate() {
if (props.detail) {
misskeyApi('notes/children', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
limit: prefer.s.numberOfReplies,
showQuotes: false,
}).then(res => {
diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue
index b7eddb9536..60d303f937 100644
--- a/packages/frontend/src/components/MkSubNoteContent.vue
+++ b/packages/frontend/src/components/MkSubNoteContent.vue
@@ -8,8 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
({{ i18n.ts.private }})
({{ i18n.ts.deletedNote }})
-
-
+
+
+
+
{{ i18n.ts._animatedMFM.play }}
{{ i18n.ts._animatedMFM.stop }}
diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue
index 310d044387..3f5bd345f2 100644
--- a/packages/frontend/src/components/SkApprovalUser.vue
+++ b/packages/frontend/src/components/SkApprovalUser.vue
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ email }}
-
Reason
+
{{ i18n.ts.signupReason }}
{{ reason }}
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue
index c554319c30..4d6d080ddf 100644
--- a/packages/frontend/src/components/SkNote.vue
+++ b/packages/frontend/src/components/SkNote.vue
@@ -305,7 +305,7 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
-const urls = computed(() => parsed.value ? extractPreviewUrls(props.note, parsed.value) : []);
+const urls = computed(() => parsed.value ? extractPreviewUrls(appearNote.value, parsed.value) : []);
const selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue
index f678c85431..f761029cfb 100644
--- a/packages/frontend/src/components/SkNoteDetailed.vue
+++ b/packages/frontend/src/components/SkNoteDetailed.vue
@@ -108,7 +108,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:isBlock="true"
class="_selectable"
/>
-
RN:
{{ i18n.ts._animatedMFM.play }}
{{ i18n.ts._animatedMFM.stop }}
@@ -420,6 +419,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab);
const reactionTabType = ref
(null);
+// Auto-select the first page of reactions
+watch(appearNote, n => {
+ reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null;
+}, { immediate: true });
+
const renotesPagination = computed(() => ({
endpoint: 'notes/renotes',
limit: 10,
diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue
index a520c9744e..4e8a3147ad 100644
--- a/packages/frontend/src/components/SkNoteSub.vue
+++ b/packages/frontend/src/components/SkNoteSub.vue
@@ -122,6 +122,7 @@ import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance, policies } from '@/instance';
+import { getAppearNote } from '@/utility/get-appear-note';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -141,7 +142,9 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined,
});
-const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i?.id);
+const appearNote = computed(() => getAppearNote(props.note));
+
+const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const hideLine = computed(() => props.detail);
const el = shallowRef();
@@ -158,19 +161,11 @@ const likeButton = shallowRef();
const renoteTooltip = computeRenoteTooltip(renoted);
-let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
const defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value));
-const isRenote = (
- props.note.renote != null &&
- props.note.text == null &&
- props.note.fileIds && props.note.fileIds.length === 0 &&
- props.note.poll == null
-);
-
const pleaseLoginContext = computed(() => ({
type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@@ -220,8 +215,8 @@ async function reply(viaKeyboard = false): Promise {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
await os.post({
- reply: props.note,
- channel: props.note.channel ?? undefined,
+ reply: appearNote.value,
+ channel: appearNote.value.channel ?? undefined,
animation: !viaKeyboard,
});
focus();
@@ -231,9 +226,9 @@ function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog();
sound.playMisskeySfx('reaction');
- if (props.note.reactionAcceptance === 'likeOnly') {
+ if (appearNote.value.reactionAcceptance === 'likeOnly') {
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = reactButton.value as HTMLElement | null | undefined;
@@ -247,12 +242,12 @@ function react(): void {
}
} else {
blur();
- reactionPicker.show(reactButton.value ?? null, props.note, reaction => {
+ reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => {
misskeyApi('notes/reactions/create', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
reaction: reaction,
});
- if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
+ if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
@@ -266,7 +261,7 @@ function like(): void {
showMovedDialog();
sound.playMisskeySfx('reaction');
misskeyApi('notes/like', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
override: defaultLike.value,
});
const el = likeButton.value as HTMLElement | null | undefined;
@@ -375,7 +370,7 @@ function quote() {
}).then((cancelled) => {
if (cancelled) return;
misskeyApi('notes/renotes', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
userId: $i?.id,
limit: 1,
quote: true,
@@ -397,12 +392,12 @@ function quote() {
}
function menu(): void {
- const { menu, cleanup } = getNoteMenu({ note: props.note, translating, translation, isDeleted });
+ const { menu, cleanup } = getNoteMenu({ note: appearNote.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip(): Promise {
- os.popupMenu(await getNoteClipMenu({ note: props.note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
+ os.popupMenu(await getNoteClipMenu({ note: appearNote.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate() {
@@ -411,7 +406,7 @@ async function translate() {
if (props.detail) {
misskeyApi('notes/children', {
- noteId: props.note.id,
+ noteId: appearNote.value.id,
limit: prefer.s.numberOfReplies,
showQuotes: false,
}).then(res => {
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 840eff77dd..dc29ae2f80 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -130,6 +130,11 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts.signupReason }}
+ {{ info.signupReason }}
+
+
{{ i18n.ts.silence }}
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index ac7bd8d5c7..3532d16c47 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -203,7 +203,7 @@ SPDX-License-Identifier: AGPL-3.0-only