Merge branch 'develop' into upstream/2025.5.0

This commit is contained in:
dakkar 2025-06-20 10:44:34 +01:00
commit 886160bdec
52 changed files with 1519 additions and 630 deletions

View file

@ -38,7 +38,7 @@ ARG UID="991"
ARG GID="991" ARG GID="991"
ENV COREPACK_DEFAULT_TO_LATEST=0 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 \ && corepack enable \
&& addgroup -g "${GID}" sharkey \ && addgroup -g "${GID}" sharkey \
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \ && adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \

8
locales/index.d.ts vendored
View file

@ -12986,6 +12986,10 @@ export interface Locale extends ILocale {
* Unable to process quote. This post may be missing context. * Unable to process quote. This post may be missing context.
*/ */
"quoteUnavailable": string; "quoteUnavailable": string;
/**
* One or more media attachments are unavailable and cannot be shown.
*/
"attachmentFailed": string;
}; };
/** /**
* Authorized Fetch * 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. * ActivityPub user data in its raw form. These fields are public and accessible to other instances.
*/ */
"rawApDescription": string; "rawApDescription": string;
/**
* Signup Reason
*/
"signupReason": string;
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -90,7 +90,7 @@
"@simplewebauthn/server": "12.0.0", "@simplewebauthn/server": "12.0.0",
"@sinonjs/fake-timers": "11.3.1", "@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0", "@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", "@twemoji/parser": "15.1.1",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.17.1", "ajv": "8.17.1",

View file

@ -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<string[]> {
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}. * Mailを用いて{@link abuseReports}.
* . * .
@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
return; return;
} }
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it const recipientEMailAddresses = await this.getRecipientEMailAddresses();
.filter(it => it.isActive && it.userProfile?.emailVerified)
.map(it => it.userProfile?.email)
.filter(x => x != null),
);
recipientEMailAddresses.push(
...(this.meta.email ? [this.meta.email] : []),
);
if (recipientEMailAddresses.length <= 0) { if (recipientEMailAddresses.length <= 0) {
return; return;

View file

@ -164,7 +164,7 @@ export class DriveService {
try { try {
await this.videoProcessingService.webOptimizeVideo(path, type); await this.videoProcessingService.webOptimizeVideo(path, type);
} catch (err) { } 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)'); this.registerLogger.debug('web image not created (not an required image)');
} }
} catch (err) { } 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 { } else {
if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)'); 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); thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
} }
} catch (err) { } catch (err) {
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error); this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`);
} }
// #endregion thumbnail // #endregion thumbnail
@ -420,27 +420,21 @@ export class DriveService {
); );
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read'; if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
if (this.bunnyService.usingBunnyCDN(this.meta)) { try {
await this.bunnyService.upload(this.meta, key, stream).catch( if (this.bunnyService.usingBunnyCDN(this.meta)) {
err => { await this.bunnyService.upload(this.meta, key, stream);
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); } else {
}, const result = await this.s3Service.upload(this.meta, params);
); if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
} else { this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
await this.s3Service.upload(this.meta, params) } else { // AbortMultipartUploadCommandOutput
.then( this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
result => { throw new Error('S3 upload aborted');
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput }
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); }
} else { // AbortMultipartUploadCommandOutput } catch (err) {
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`);
} throw err;
})
.catch(
err => {
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
},
);
} }
} }
@ -857,7 +851,7 @@ export class DriveService {
} }
} catch (err: any) { } catch (err: any) {
if (err.name === 'NoSuchKey') { 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; return;
} else { } else {
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {

View file

@ -75,9 +75,8 @@ export class MfmService {
switch (node.tagName) { switch (node.tagName) {
case 'br': { case 'br': {
text += '\n'; text += '\n';
break; return;
} }
case 'a': { case 'a': {
const txt = getText(node); const txt = getText(node);
const rel = node.attribs.rel; const rel = node.attribs.rel;
@ -123,9 +122,16 @@ export class MfmService {
text += generateLink(); text += generateLink();
} }
break; return;
} }
}
// Don't produce invalid empty MFM
if (node.childNodes.length < 1) {
return;
}
switch (node.tagName) {
case 'h1': { case 'h1': {
text += '**【'; text += '**【';
appendChildren(node.childNodes); appendChildren(node.childNodes);
@ -329,6 +335,38 @@ export class MfmService {
break; 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 default: // includes inline elements
{ {
appendChildren(node.childNodes); appendChildren(node.childNodes);

View file

@ -731,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver //#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) { if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => { 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); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送 // メンションされたリモートユーザーに配送
@ -874,17 +874,6 @@ export class NoteCreateService implements OnApplicationShutdown {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1); 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 @bindThis
private index(note: MiNote) { private index(note: MiNote) {
if (note.text == null && note.cw == null) return; if (note.text == null && note.cw == null) return;

View file

@ -675,7 +675,7 @@ export class NoteEditService implements OnApplicationShutdown {
//#region AP deliver //#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) { if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => { 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); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送 // メンションされたリモートユーザーに配送
@ -770,17 +770,6 @@ export class NoteEditService implements OnApplicationShutdown {
(note.files != null && note.files.length > 0); (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 @bindThis
private index(note: MiNote) { private index(note: MiNote) {
if (note.text == null && note.cw == null) return; if (note.text == null && note.cw == null) return;

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm'; import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; 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 { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -17,9 +17,15 @@ import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isSystemAccount } from '@/misc/is-system-account.js'; import { isSystemAccount } from '@/misc/is-system-account.js';
import { CacheService } from '@/core/CacheService.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() @Injectable()
export class UserSuspendService { export class UserSuspendService {
private readonly logger: Logger;
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -36,7 +42,10 @@ export class UserSuspendService {
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
loggerService: LoggerService,
) { ) {
this.logger = loggerService.getLogger('user-suspend');
} }
@bindThis @bindThis
@ -47,16 +56,16 @@ export class UserSuspendService {
isSuspended: true, isSuspended: true,
}); });
this.moderationLogService.log(moderator, 'suspend', { await this.moderationLogService.log(moderator, 'suspend', {
userId: user.id, userId: user.id,
userUsername: user.username, userUsername: user.username,
userHost: user.host, userHost: user.host,
}); });
(async () => { trackPromise((async () => {
await this.postSuspend(user).catch(e => {}); await this.postSuspend(user);
await this.unFollowAll(user).catch(e => {}); await this.freezeAll(user);
})(); })().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
} }
@bindThis @bindThis
@ -65,33 +74,36 @@ export class UserSuspendService {
isSuspended: false, isSuspended: false,
}); });
this.moderationLogService.log(moderator, 'unsuspend', { await this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id, userId: user.id,
userUsername: user.username, userUsername: user.username,
userHost: user.host, userHost: user.host,
}); });
(async () => { trackPromise((async () => {
await this.postUnsuspend(user).catch(e => {}); await this.postUnsuspend(user);
})(); await this.unFreezeAll(user);
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
} }
@bindThis @bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> { private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
/*
this.followRequestsRepository.delete({ this.followRequestsRepository.delete({
followeeId: user.id, followeeId: user.id,
}); });
this.followRequestsRepository.delete({ this.followRequestsRepository.delete({
followerId: user.id, followerId: user.id,
}); });
*/
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信 // 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = []; const queue = new Map<string, boolean>();
const followings = await this.followingsRepository.find({ const followings = await this.followingsRepository.find({
where: [ where: [
@ -104,12 +116,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) { 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) { await this.queueService.deliverMany(user, content, queue);
this.queueService.deliver(user, content, inbox, true);
}
} }
} }
@ -121,7 +133,7 @@ export class UserSuspendService {
// 知り得る全SharedInboxにUndo Delete配信 // 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); 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<string, boolean>();
const followings = await this.followingsRepository.find({ const followings = await this.followingsRepository.find({
where: [ where: [
@ -134,12 +146,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) { 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) { await this.queueService.deliverMany(user, content, queue);
this.queueService.deliver(user as any, content, inbox, true);
}
} }
} }
@ -160,4 +172,36 @@ export class UserSuspendService {
} }
this.queueService.createUnfollowJob(jobs); this.queueService.createUnfollowJob(jobs);
} }
@bindThis
private async freezeAll(user: MiUser): Promise<void> {
// 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<void> {
// 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();
}
} }

View file

@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js'; import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { IdService } from '@/core/IdService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000; const oneDayMillis = 24 * 60 * 60 * 1000;
@ -166,6 +167,7 @@ export class WebhookTestService {
private userWebhookService: UserWebhookService, private userWebhookService: UserWebhookService,
private systemWebhookService: SystemWebhookService, private systemWebhookService: SystemWebhookService,
private queueService: QueueService, private queueService: QueueService,
private readonly idService: IdService,
) { ) {
} }
@ -451,6 +453,8 @@ export class WebhookTestService {
offsetX: it.offsetX, offsetX: it.offsetX,
offsetY: it.offsetY, offsetY: it.offsetY,
})), })),
createdAt: this.idService.parse(user.id).date.toISOString(),
description: '',
isBot: user.isBot, isBot: user.isBot,
isCat: user.isCat, isCat: user.isCat,
emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host), emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host),

View file

@ -32,6 +32,8 @@ import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js'; import { appendContentWarning } from '@/misc/append-content-warning.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { UtilityService } from '@/core/UtilityService.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 { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js'; import { CONTEXT } from './misc/contexts.js';
@ -75,6 +77,7 @@ export class ApRendererService {
private idService: IdService, private idService: IdService,
private readonly queryService: QueryService, private readonly queryService: QueryService,
private utilityService: UtilityService, private utilityService: UtilityService,
private readonly cacheService: CacheService,
) { ) {
} }
@ -232,7 +235,7 @@ export class ApRendererService {
*/ */
@bindThis @bindThis
public async renderFollowUser(id: MiUser['id']): Promise<string> { public async renderFollowUser(id: MiUser['id']): Promise<string> {
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); return this.userEntityService.getUserUri(user);
} }
@ -402,7 +405,7 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) { if (inReplyToNote != null) {
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
if (inReplyToUser) { if (inReplyToUser) {
if (inReplyToNote.uri) { if (inReplyToNote.uri) {
@ -422,7 +425,7 @@ export class ApRendererService {
let quote: string | undefined = undefined; let quote: string | undefined = undefined;
if (note.renoteId) { if (isRenote(note) && isQuote(note)) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
if (renote) { if (renote) {
@ -542,6 +545,7 @@ export class ApRendererService {
attributedTo, attributedTo,
summary: summary ?? undefined, summary: summary ?? undefined,
content: content ?? undefined, content: content ?? undefined,
updated: note.updatedAt?.toISOString() ?? undefined,
_misskey_content: text, _misskey_content: text,
source: { source: {
content: text, content: text,
@ -756,176 +760,6 @@ export class ApRendererService {
}; };
} }
@bindThis
public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
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 `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
// 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 @bindThis
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate { public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
return { 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 @bindThis
private async getEmojis(names: string[]): Promise<MiEmoji[]> { private async getEmojis(names: string[]): Promise<MiEmoji[]> {
if (names.length === 0) return []; if (names.length === 0) return [];

View file

@ -21,6 +21,8 @@ import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.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 { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
@ -49,6 +51,7 @@ export class Resolver {
private loggerService: LoggerService, private loggerService: LoggerService,
private readonly apLogService: ApLogService, private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService, private readonly apUtilityService: ApUtilityService,
private readonly cacheService: CacheService,
private recursionLimit = 256, private recursionLimit = 256,
) { ) {
this.history = new Set(); this.history = new Set();
@ -355,18 +358,20 @@ export class Resolver {
switch (parsed.type) { switch (parsed.type) {
case 'notes': 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 => { .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') { if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note)); } else if (!isPureRenote(note)) {
const apNote = await this.apRendererService.renderNote(note, author);
return this.apRendererService.addContext(apNote);
} else { } 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<IObjectWithId>; }) as Promise<IObjectWithId>;
case 'users': 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)); .then(user => this.apRendererService.renderPerson(user as MiLocalUser));
case 'questions': case 'questions':
// Polls are indexed by the note they are attached to. // Polls are indexed by the note they are attached to.
@ -387,14 +392,8 @@ export class Resolver {
.then(async followRequest => { .then(async followRequest => {
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`); 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([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({ this.cacheService.findLocalUserById(followRequest.followerId),
id: followRequest.followerId, this.cacheService.findLocalUserById(followRequest.followeeId),
host: IsNull(),
}),
this.usersRepository.findOneBy({
id: followRequest.followeeId,
host: Not(IsNull()),
}),
]); ]);
if (follower == null || followee == null) { if (follower == null || followee == null) {
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`); 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 loggerService: LoggerService,
private readonly apLogService: ApLogService, private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService, private readonly apUtilityService: ApUtilityService,
private readonly cacheService: CacheService,
) { ) {
} }
@ -465,6 +465,7 @@ export class ApResolverService {
this.loggerService, this.loggerService,
this.apLogService, this.apLogService,
this.apUtilityService, this.apUtilityService,
this.cacheService,
opts?.recursionLimit, opts?.recursionLimit,
); );
} }

View file

@ -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<string, IApDocument>();
// <img> tags, including <picture> and <object> fallback elements
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img
$('img[src]')
.toArray()
.forEach(img => attachments.set(img.attribs.src, {
type: 'Image',
url: img.attribs.src,
name: img.attribs.alt || img.attribs.title || null,
}));
// <object> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/object
$('object[data]')
.toArray()
.forEach(object => attachments.set(object.attribs.data, {
type: 'Document',
url: object.attribs.data,
name: object.attribs.alt || object.attribs.title || null,
}));
// <embed> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/embed
$('embed[src]')
.toArray()
.forEach(embed => attachments.set(embed.attribs.src, {
type: 'Document',
url: embed.attribs.src,
name: embed.attribs.alt || embed.attribs.title || null,
}));
// <audio> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/audio
$('audio[src]')
.toArray()
.forEach(audio => attachments.set(audio.attribs.src, {
type: 'Audio',
url: audio.attribs.src,
name: audio.attribs.alt || audio.attribs.title || null,
}));
// <video> tags
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video
$('video[src]')
.toArray()
.forEach(audio => attachments.set(audio.attribs.src, {
type: 'Video',
url: audio.attribs.src,
name: audio.attribs.alt || audio.attribs.title || null,
}));
// TODO support <svg>? We would need to extract it directly from the HTML and save to a temp file.
return Array.from(attachments.values());
}
function parseHtml(html: string): CheerioAPI | null {
try {
return cheerio(html);
} catch {
// Don't worry about invalid HTML
return null;
}
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parse, inspect, extract } from 'mfm-js';
import type { IApDocument } from '@/core/activitypub/type.js';
import type { MfmNode, MfmText } from 'mfm-js';
/**
* Finds MFM notes 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 mfm Input MFM to analyze.
*/
export function extractMediaFromMfm(mfm: string): IApDocument[] {
const nodes = parseMfm(mfm);
if (nodes == null) return [];
const attachments = new Map<string, IApDocument>();
inspect(nodes, node => {
if (node.type === 'link' && node.props.image) {
const alt: string[] = [];
inspect(node.children, node => {
switch (node.type) {
case 'text':
alt.push(node.props.text);
break;
case 'unicodeEmoji':
alt.push(node.props.emoji);
break;
case 'emojiCode':
alt.push(':');
alt.push(node.props.name);
alt.push(':');
break;
}
});
attachments.set(node.props.url, {
type: 'Image',
url: node.props.url,
name: alt.length > 0
? alt.join('')
: null,
});
}
});
return Array.from(attachments.values());
}
function parseMfm(mfm: string): MfmNode[] | null {
try {
return parse(mfm);
} catch {
// Don't worry about invalid MFM
return null;
}
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { IPost } from '@/core/activitypub/type.js';
import { toArray } from '@/misc/prelude/array.js';
/**
* Gets content of a specified media type from a provided object.
*
* Optionally supports a "permissive" mode which enables the following changes:
* 1. MIME types are checked in a case-insensitive manner.
* 2. MIME types are matched based on inclusion, not strict equality.
* 3. A candidate content is considered to match if it has no specified MIME type.
*
* Note: this method is written defensively to protect against malform remote objects.
* When extending or modifying it, please be sure to work with "unknown" type and validate everything.
*
* Note: the logic in this method is carefully ordered to match the selection priority of existing code in ApNoteService.
* Please do not re-arrange it without testing!
* New checks can be added to the end of the method to safely extend the existing logic.
*
* @param object AP object to extract content from.
* @param mimeType MIME type to look for.
* @param permissive Enables permissive mode, as described above. Defaults to false (disabled).
*/
export function getContentByType(object: IPost | Record<string, unknown>, mimeType: string, permissive = false): string | null {
// Case 1: Extended "source" property
if (object.source && typeof(object.source) === 'object') {
// "source" is permitted to be an array, though no implementations are known to do this yet.
const sources = toArray(object.source) as Record<string, unknown>[];
for (const source of sources) {
if (typeof (source.content) === 'string' && checkMediaType(source.mediaType)) {
return source.content;
}
}
}
// Case 2: Special case for MFM
if (typeof(object._misskey_content) === 'string' && mimeType === 'text/x.misskeymarkdown') {
return object._misskey_content;
}
// Case 3: AP native "content" property
if (typeof(object.content) === 'string' && checkMediaType(object.mediaType)) {
return object.content;
}
return null;
// Checks if the provided media type matches the input parameters.
function checkMediaType(mediaType: unknown): boolean {
if (typeof(mediaType) === 'string') {
// Strict match
if (mediaType === mimeType) {
return true;
}
// Permissive match
if (permissive && mediaType.toLowerCase().includes(mimeType.toLowerCase())) {
return true;
}
}
// Permissive fallback match
if (permissive && mediaType == null) {
return true;
}
// No match
return false;
}
}

View file

@ -86,7 +86,7 @@ export class ApImageService {
uri: image.url, uri: image.url,
sensitive: !!(image.sensitive), sensitive: !!(image.sensitive),
isLink: !shouldBeCached, isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength), comment: truncate(image.summary || image.name || undefined, this.config.maxRemoteAltTextLength),
}); });
if (!file.isLink || file.url === image.url) return file; if (!file.isLink || file.url === image.url) return file;

View file

@ -6,6 +6,7 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { UnrecoverableError } from 'bullmq'; import { UnrecoverableError } from 'bullmq';
import promiseLimit from 'promise-limit';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js'; import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -27,6 +28,9 @@ import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRetryableError } from '@/misc/is-retryable-error.js'; import { isRetryableError } from '@/misc/is-retryable-error.js';
import { renderInlineError } from '@/misc/render-inline-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js';
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js'; import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js'; import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js'; import { ApMfmService } from '../ApMfmService.js';
@ -206,12 +210,10 @@ export class ApNoteService {
const cw = note.summary === '' ? null : note.summary; const cw = note.summary === '' ? null : note.summary;
// テキストのパース // テキストのパース
let text: string | null = null; let text =
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { getContentByType(note, 'text/x.misskeymarkdown') ??
text = note.source.content; getContentByType(note, 'text/markdown');
} else if (typeof note._misskey_content !== 'undefined') { if (text == null && typeof note.content === 'string') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag); text = this.apMfmService.htmlToMfm(note.content, note.tag);
} }
@ -248,21 +250,14 @@ export class ApNoteService {
} }
} }
const processErrors: string[] = [];
// 添付ファイル // 添付ファイル
const files: MiDriveFile[] = []; // Note: implementation moved to getAttachment function to avoid duplication.
// Please copy any upstream changes to that method! (It's in the bottom of this class)
for (const attach of toArray(note.attachment)) { const { files, hasFileError } = await this.getAttachments(note, actor);
attach.sensitive ??= note.sensitive; if (hasFileError) {
const file = await this.apImageService.resolveImage(actor, attach); processErrors.push('attachmentFailed');
if (file) files.push(file);
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
icon.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, icon);
if (file) files.push(file);
} }
// リプライ // リプライ
@ -284,7 +279,9 @@ export class ApNoteService {
// 引用 // 引用
const quote = await this.getQuote(note, entryUri, resolver); const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null; if (quote === null) {
processErrors.push('quoteUnavailable');
}
if (reply && reply.userHost == null && reply.localOnly) { if (reply && reply.userHost == null && reply.localOnly) {
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note'); throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
@ -328,7 +325,7 @@ export class ApNoteService {
files, files,
reply, reply,
renote: quote ?? null, renote: quote ?? null,
processErrors, processErrors: processErrors.length > 0 ? processErrors : null,
name: note.name, name: note.name,
cw, cw,
text, text,
@ -412,12 +409,10 @@ export class ApNoteService {
const cw = note.summary === '' ? null : note.summary; const cw = note.summary === '' ? null : note.summary;
// テキストのパース // テキストのパース
let text: string | null = null; let text =
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { getContentByType(note, 'text/x.misskeymarkdown') ??
text = note.source.content; getContentByType(note, 'text/markdown');
} else if (typeof note._misskey_content !== 'undefined') { if (text == null && typeof note.content === 'string') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag); text = this.apMfmService.htmlToMfm(note.content, note.tag);
} }
@ -446,21 +441,12 @@ export class ApNoteService {
} }
} }
const processErrors: string[] = [];
// 添付ファイル // 添付ファイル
const files: MiDriveFile[] = []; const { files, hasFileError } = await this.getAttachments(note, actor);
if (hasFileError) {
for (const attach of toArray(note.attachment)) { processErrors.push('attachmentFailed');
attach.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, attach);
if (file) files.push(file);
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
icon.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, icon);
if (file) files.push(file);
} }
// リプライ // リプライ
@ -482,7 +468,9 @@ export class ApNoteService {
// 引用 // 引用
const quote = await this.getQuote(note, entryUri, resolver); const quote = await this.getQuote(note, entryUri, resolver);
const processErrors = quote === null ? ['quoteUnavailable'] : null; if (quote === null) {
processErrors.push('quoteUnavailable');
}
if (quote && quote.userHost == null && quote.localOnly) { if (quote && quote.userHost == null && quote.localOnly) {
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note'); throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
@ -523,7 +511,7 @@ export class ApNoteService {
files, files,
reply, reply,
renote: quote ?? null, renote: quote ?? null,
processErrors, processErrors: processErrors.length > 0 ? processErrors : null,
name: note.name, name: note.name,
cw, cw,
text, text,
@ -722,10 +710,95 @@ export class ApNoteService {
// Permanent error - return null // Permanent error - return null
return null; return null;
} }
/**
* Extracts and saves all media attachments from the provided note.
* Returns an array of all the created files.
*/
private async getAttachments(note: IPost, actor: MiRemoteUser): Promise<{ files: MiDriveFile[], hasFileError: boolean }> {
const attachments = new Map<string, IApDocument & { url: string }>();
// Extract inline media from HTML content.
// Don't use source.content, _misskey_content, or anything else because those aren't HTML.
const htmlContent = getContentByType(note, 'text/html', true);
if (htmlContent) {
for (const attach of extractMediaFromHtml(htmlContent)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
}
// Extract inline media from MFM / markdown content.
const mfmContent =
getContentByType(note, 'text/x.misskeymarkdown') ??
getContentByType(note, 'text/markdown');
if (mfmContent) {
for (const attach of extractMediaFromMfm(mfmContent)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
}
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
const icon = getBestIcon(note);
if (icon) {
if (hasUrl(icon)) {
attachments.set(icon.url, icon);
}
}
// Populate AP attachments last, to overwrite any "fallback" elements that may have been inlined in HTML.
// AP attachments should be considered canonical.
for (const attach of toArray(note.attachment)) {
if (hasUrl(attach)) {
attachments.set(attach.url, attach);
}
}
// Resolve all files w/ concurrency 2.
// This prevents one big file from blocking the others.
const limiter = promiseLimit<MiDriveFile | null>(2);
const results = await Promise
.all(Array
.from(attachments.values())
.map(attach => limiter(async () => {
attach.sensitive ??= note.sensitive;
return await this.resolveImage(actor, attach);
})));
// Process results
let hasFileError = false;
const files: MiDriveFile[] = [];
for (const result of results) {
if (result != null) {
files.push(result);
} else {
hasFileError = true;
}
}
return { files, hasFileError };
}
private async resolveImage(actor: MiRemoteUser, attachment: IApDocument & { url: string }): Promise<MiDriveFile | null> {
try {
return await this.apImageService.resolveImage(actor, attachment);
} catch (err) {
if (isRetryableError(err)) {
this.logger.warn(`Temporary failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
throw err;
} else {
this.logger.warn(`Permanent failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
return null;
}
}
}
} }
function getBestIcon(note: IObject): IObject | null { function getBestIcon(note: IObject): IApDocument | null {
const icons: IObject[] = toArray(note.icon); const icons: IApDocument[] = toArray(note.icon);
if (icons.length < 2) { if (icons.length < 2) {
return icons[0] ?? null; return icons[0] ?? null;
} }
@ -741,3 +814,8 @@ function getBestIcon(note: IObject): IObject | null {
return best; return best;
}, null as IApDocument | null) ?? null; }, null as IApDocument | null) ?? null;
} }
// Need this to make TypeScript happy...
function hasUrl<T extends IObject>(object: T): object is T & { url: string } {
return typeof(object.url) === 'string';
}

View file

@ -24,7 +24,7 @@ export interface IObject {
cc?: ApObject; cc?: ApObject;
to?: ApObject; to?: ApObject;
attributedTo?: ApObject; attributedTo?: ApObject;
attachment?: any[]; attachment?: IApDocument[];
inReplyTo?: any; inReplyTo?: any;
replies?: ICollection | IOrderedCollection | string; replies?: ICollection | IOrderedCollection | string;
content?: string | null; content?: string | null;

View file

@ -87,6 +87,16 @@ export const packedUserLiteSchema = {
type: 'string', type: 'string',
nullable: true, optional: false, nullable: true, optional: false,
}, },
description: {
type: 'string',
nullable: true, optional: false,
example: 'Hi masters, I am Ai!',
},
createdAt: {
type: 'string',
nullable: false, optional: false,
format: 'date-time',
},
avatarDecorations: { avatarDecorations: {
type: 'array', type: 'array',
nullable: false, optional: false, nullable: false, optional: false,

View file

@ -33,7 +33,7 @@ import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js'; import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
@ -571,7 +571,7 @@ export class ActivityPubServerService {
const pinnedNotes = (await Promise.all(pinings.map(pining => const pinnedNotes = (await Promise.all(pinings.map(pining =>
this.notesRepository.findOneByOrFail({ id: pining.noteId })))) this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility) && !isPureRenote(note));
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user))); const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user)));
@ -791,6 +791,10 @@ export class ActivityPubServerService {
reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Expose-Headers', 'Vary'); reply.header('Access-Control-Expose-Headers', 'Vary');
// Tell crawlers not to index AP endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
/* tell any caching proxy that they should not cache these /* tell any caching proxy that they should not cache these
responses: we wouldn't want the proxy to return a 403 to responses: we wouldn't want the proxy to return a 403 to
someone presenting a valid signature, or return a cached someone presenting a valid signature, or return a cached
@ -838,6 +842,11 @@ export class ActivityPubServerService {
return; return;
} }
// Boosts don't federate directly - they should only be referenced as an activity
if (isPureRenote(note)) {
return 404;
}
this.setResponseType(request, reply); this.setResponseType(request, reply);
const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); const author = await this.usersRepository.findOneByOrFail({ id: note.userId });

View file

@ -70,6 +70,10 @@ export class FileServerService {
fastify.addHook('onRequest', (request, reply, done) => { fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Origin', '*');
// Tell crawlers not to index files endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
done(); done();
}); });

View file

@ -148,6 +148,10 @@ export class ApiCallService implements OnApplicationShutdown {
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply, reply: FastifyReply,
): void { ): void {
// Tell crawlers not to index API endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
const body = request.method === 'GET' const body = request.method === 'GET'
? request.query ? request.query
: request.body; : request.body;

View file

@ -221,6 +221,10 @@ export const meta = {
}, },
}, },
}, },
signupReason: {
type: 'string',
optional: false, nullable: true,
},
}, },
}, },
} as const; } as const;

View file

@ -793,9 +793,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const after = await this.metaService.fetch(true); const after = await this.metaService.fetch(true);
this.moderationLogService.log(me, 'updateServerSettings', { this.moderationLogService.log(me, 'updateServerSettings', {
before, before: sanitize(before),
after, after: sanitize(after),
}); });
}); });
} }
} }
function sanitize(meta: Partial<MiMeta>): Partial<MiMeta> {
return {
...meta,
hcaptchaSecretKey: '<redacted>',
mcaptchaSecretKey: '<redacted>',
recaptchaSecretKey: '<redacted>',
turnstileSecretKey: '<redacted>',
fcSecretKey: '<redacted>',
smtpPass: '<redacted>',
swPrivateKey: '<redacted>',
objectStorageAccessKey: '<redacted>',
objectStorageSecretKey: '<redacted>',
deeplAuthKey: '<redacted>',
libreTranslateKey: '<redacted>',
verifymailAuthKey: '<redacted>',
truemailAuthKey: '<redacted>',
};
}

View file

@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { getJsonSchema } from '@/core/chart/core.js'; import { getJsonSchema } from '@/core/chart/core.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { schema } from '@/core/chart/charts/entities/per-user-following.js'; import { schema } from '@/core/chart/charts/entities/per-user-following.js';
import { CacheService } from '@/core/CacheService.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = { export const meta = {
tags: ['charts', 'users', 'following'], tags: ['charts', 'users', 'following'],
@ -40,9 +42,84 @@ export const paramDef = {
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
private readonly cacheService: CacheService,
private readonly roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId); const profile = await this.cacheService.userProfileCache.fetch(ps.userId);
// These are structured weird to avoid un-necessary calls to roleService and cacheService
const iAmModeratorOrTarget = me && (me.id === ps.userId || await this.roleService.isModerator(me));
const iAmFollowingOrTarget = me && (me.id === ps.userId || await this.cacheService.isFollowing(me.id, ps.userId));
const canViewFollowing =
profile.followingVisibility === 'public'
|| iAmModeratorOrTarget
|| (profile.followingVisibility === 'followers' && iAmFollowingOrTarget);
const canViewFollowers =
profile.followersVisibility === 'public'
|| iAmModeratorOrTarget
|| (profile.followersVisibility === 'followers' && iAmFollowingOrTarget);
if (!canViewFollowing && !canViewFollowers) {
return {
local: {
followings: {
total: [],
inc: [],
dec: [],
},
followers: {
total: [],
inc: [],
dec: [],
},
},
remote: {
followings: {
total: [],
inc: [],
dec: [],
},
followers: {
total: [],
inc: [],
dec: [],
},
},
};
}
const chart = await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
if (!canViewFollowers) {
chart.local.followers = {
total: [],
inc: [],
dec: [],
};
chart.remote.followers = {
total: [],
inc: [],
dec: [],
};
}
if (!canViewFollowing) {
chart.local.followings = {
total: [],
inc: [],
dec: [],
};
chart.remote.followings = {
total: [],
inc: [],
dec: [],
};
}
return chart;
}); });
} }
} }

View file

@ -104,53 +104,88 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// grouping // grouping
let groupedNotifications = [notifications[0]] as MiGroupedNotification[]; const groupedNotifications : MiGroupedNotification[] = [];
for (let i = 1; i < notifications.length; i++) { // keep track of where reaction / renote notifications are, by note id
const notification = notifications[i]; const reactionIdxByNoteId = new Map<string, number>();
const prev = notifications[i - 1]; const renoteIdxByNoteId = new Map<string, number>();
let prevGroupedNotification = groupedNotifications.at(-1)!;
if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) { // group notifications by type+note; notice that we don't try to
if (prevGroupedNotification.type !== 'reaction:grouped') { // split groups if they span a long stretch of time, because
groupedNotifications[groupedNotifications.length - 1] = { // it's probably overkill: if the user has very few
// notifications, there should be very little difference; if the
// user has many notifications, the pagination will break the
// groups
// scan `notifications` newest-to-oldest
for (let i = 0; i < notifications.length; i++) {
const notification = notifications[i];
if (notification.type === 'reaction') {
const reactionIdx = reactionIdxByNoteId.get(notification.noteId);
if (reactionIdx === undefined) {
// first reaction to this note that we see, add it as-is
// and remember where we put it
groupedNotifications.push(notification);
reactionIdxByNoteId.set(notification.noteId, groupedNotifications.length - 1);
continue;
}
let prevReaction = groupedNotifications[reactionIdx] as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'> | FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction'>;
// if the previous reaction is not a group, make it into one
if (prevReaction.type !== 'reaction:grouped') {
prevReaction = groupedNotifications[reactionIdx] = {
type: 'reaction:grouped', type: 'reaction:grouped',
id: '', id: prevReaction.id, // this will be the newest id in this group
createdAt: prev.createdAt, createdAt: prevReaction.createdAt,
noteId: prev.noteId!, noteId: prevReaction.noteId!,
reactions: [{ reactions: [{
userId: prev.notifierId!, userId: prevReaction.notifierId!,
reaction: prev.reaction!, reaction: prevReaction.reaction!,
}], }],
}; };
prevGroupedNotification = groupedNotifications.at(-1)!;
} }
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'>).reactions.push({ // add this new reaction to the existing group
(prevReaction as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'>).reactions.push({
userId: notification.notifierId!, userId: notification.notifierId!,
reaction: notification.reaction!, reaction: notification.reaction!,
}); });
prevGroupedNotification.id = notification.id;
continue;
}
if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) {
if (prevGroupedNotification.type !== 'renote:grouped') {
groupedNotifications[groupedNotifications.length - 1] = {
type: 'renote:grouped',
id: '',
createdAt: notification.createdAt,
noteId: prev.noteId!,
userIds: [prev.notifierId!],
};
prevGroupedNotification = groupedNotifications.at(-1)!;
}
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
prevGroupedNotification.id = notification.id;
continue; continue;
} }
if (notification.type === 'renote') {
const renoteIdx = renoteIdxByNoteId.get(notification.targetNoteId);
if (renoteIdx === undefined) {
// first renote of this note that we see, add it as-is and
// remember where we put it
groupedNotifications.push(notification);
renoteIdxByNoteId.set(notification.targetNoteId, groupedNotifications.length - 1);
continue;
}
let prevRenote = groupedNotifications[renoteIdx] as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'> | FilterUnionByProperty<MiGroupedNotification, 'type', 'renote'>;
// if the previous renote is not a group, make it into one
if (prevRenote.type !== 'renote:grouped') {
prevRenote = groupedNotifications[renoteIdx] = {
type: 'renote:grouped',
id: prevRenote.id, // this will be the newest id in this group
createdAt: prevRenote.createdAt,
noteId: prevRenote.noteId!,
userIds: [prevRenote.notifierId!],
};
}
// add this new renote to the existing group
(prevRenote as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
continue;
}
// not a groupable notification, just push it
groupedNotifications.push(notification); groupedNotifications.push(notification);
} }
groupedNotifications = groupedNotifications.slice(0, ps.limit); // sort the groups by their id, newest first
groupedNotifications.sort(
(a, b) => a.id < b.id ? 1 : a.id > b.id ? -1 : 0,
);
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
}); });

View file

@ -71,6 +71,13 @@ export class MastodonApiServerService {
done(); done();
}); });
// Tell crawlers not to index API endpoints.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('X-Robots-Tag', 'noindex');
done();
});
// External endpoints // External endpoints
this.apiAccountMastodon.register(fastify); this.apiAccountMastodon.register(fastify);
this.apiAppsMastodon.register(fastify); this.apiAppsMastodon.register(fastify);

View file

@ -125,6 +125,10 @@ export class UrlPreviewService {
reply: FastifyReply, reply: FastifyReply,
): Promise<void> { ): Promise<void> {
if (!this.meta.urlPreviewEnabled) { if (!this.meta.urlPreviewEnabled) {
// Tell crawlers not to index URL previews.
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
reply.header('X-Robots-Tag', 'noindex');
return reply.code(403).send({ return reply.code(403).send({
error: { error: {
message: 'URL preview is disabled', message: 'URL preview is disabled',

View file

@ -19,6 +19,7 @@ import type {
PollsRepository, PollsRepository,
UsersRepository, UsersRepository,
} from '@/models/_.js'; } from '@/models/_.js';
import type { CacheService } from '@/core/CacheService.js';
import { ApLogService } from '@/core/ApLogService.js'; import { ApLogService } from '@/core/ApLogService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { fromTuple } from '@/misc/from-tuple.js'; import { fromTuple } from '@/misc/from-tuple.js';
@ -53,6 +54,7 @@ export class MockResolver extends Resolver {
loggerService, loggerService,
{} as ApLogService, {} as ApLogService,
{} as ApUtilityService, {} as ApUtilityService,
{} as CacheService,
); );
} }

View file

@ -11,6 +11,7 @@ import {
AbuseReportNotificationRecipientRepository, AbuseReportNotificationRecipientRepository,
MiAbuseReportNotificationRecipient, MiAbuseReportNotificationRecipient,
MiAbuseUserReport, MiAbuseUserReport,
MiMeta,
MiSystemWebhook, MiSystemWebhook,
MiUser, MiUser,
SystemWebhooksRepository, SystemWebhooksRepository,
@ -56,6 +57,15 @@ describe('AbuseReportNotificationService', () => {
// -------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------
const meta = {} as MiMeta;
function updateMeta(newMeta: Partial<MiMeta>): void {
for (const key in meta) {
delete (meta as any)[key];
}
Object.assign(meta, newMeta);
}
async function createUser(data: Partial<MiUser> = {}) { async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository const user = await usersRepository
.insert({ .insert({
@ -66,6 +76,8 @@ describe('AbuseReportNotificationService', () => {
await userProfilesRepository.insert({ await userProfilesRepository.insert({
userId: user.id, userId: user.id,
email: user.username + '@example.com',
emailVerified: true,
}); });
return user; return user;
@ -130,6 +142,9 @@ describe('AbuseReportNotificationService', () => {
{ {
provide: GlobalEventService, useFactory: () => ({ publishAdminStream: jest.fn() }), provide: GlobalEventService, useFactory: () => ({ publishAdminStream: jest.fn() }),
}, },
{
provide: DI.meta, useFactory: () => meta,
},
], ],
}) })
.compile(); .compile();
@ -156,6 +171,8 @@ describe('AbuseReportNotificationService', () => {
systemWebhook2 = await createWebhook(); systemWebhook2 = await createWebhook();
roleService.getModeratorIds.mockResolvedValue([root.id, alice.id, bob.id]); roleService.getModeratorIds.mockResolvedValue([root.id, alice.id, bob.id]);
updateMeta({} as MiMeta);
}); });
afterEach(async () => { afterEach(async () => {
@ -392,4 +409,59 @@ describe('AbuseReportNotificationService', () => {
expect(webhookService.enqueueSystemWebhook.mock.calls[0][2]).toEqual({ excludes: [systemWebhook2.id] }); expect(webhookService.enqueueSystemWebhook.mock.calls[0][2]).toEqual({ excludes: [systemWebhook2.id] });
}); });
}); });
describe('collection of recipient-mails', () => {
async function create() {
const recipient = await createRecipient({
method: 'email',
userId: alice.id,
});
return recipient;
}
test('with nothing set', async () => {
const mails = await service.getRecipientEMailAddresses();
expect(mails).toEqual([]);
});
test('with maintainer mail set', async () => {
updateMeta({ maintainerEmail: 'maintainer_mail' });
const mails = await service.getRecipientEMailAddresses();
expect(mails).toEqual(['maintainer_mail']);
});
test('with smtp mail set', async () => {
updateMeta({ email: 'smtp_mail' });
const mails = await service.getRecipientEMailAddresses();
expect(mails).toEqual(['smtp_mail']);
});
test('with maintainer mail and smtp mail set', async () => {
updateMeta({ email: 'smtp_mail', maintainerEmail: 'maintainer_mail' });
const mails = await service.getRecipientEMailAddresses();
expect(mails).toEqual(['smtp_mail', 'maintainer_mail']);
});
test('with recipients', async () => {
await create();
const mails = await service.getRecipientEMailAddresses();
expect(mails).toEqual([
'alice@example.com',
]);
});
test('with recipients and maintainer mail set and smtp mail set', async () => {
await create();
updateMeta({ maintainerEmail: 'maintainer_mail', email: 'smtp_mail' });
const mails = await service.getRecipientEMailAddresses();
expect(mails).toEqual([
'alice@example.com',
'smtp_mail',
'maintainer_mail',
]);
});
});
}); });

View file

@ -674,59 +674,6 @@ describe('ActivityPub', () => {
}); });
}); });
describe('renderUpnote', () => {
describe('summary', () => {
// I actually don't know why it does this, but the logic was already there so I've preserved it.
it('should be zero-width space when CW is empty string', async () => {
note.cw = '';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe(String.fromCharCode(0x200B));
});
it('should be undefined when CW is null', async () => {
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBeUndefined();
});
it('should be CW when present without mandatoryCW', async () => {
note.cw = 'original';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original');
});
it('should be mandatoryCW when present without CW', async () => {
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('mandatory');
});
it('should be merged when CW and mandatoryCW are both present', async () => {
note.cw = 'original';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original, mandatory');
});
it('should be CW when CW includes mandatoryCW', async () => {
note.cw = 'original and mandatory';
author.mandatoryCW = 'mandatory';
const result = await rendererService.renderUpNote(note, author, false);
expect(result.summary).toBe('original and mandatory');
});
});
});
describe('renderPersonRedacted', () => { describe('renderPersonRedacted', () => {
it('should include minimal properties', async () => { it('should include minimal properties', async () => {
const result = await rendererService.renderPersonRedacted(author); const result = await rendererService.renderPersonRedacted(author);

View file

@ -0,0 +1,297 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
describe(extractMediaFromHtml, () => {
it('should return empty for invalid input', () => {
const result = extractMediaFromHtml('<broken html');
expect(result).toEqual([]);
});
it('should return empty for empty input', () => {
const result = extractMediaFromHtml('');
expect(result).toEqual([]);
});
it('should return empty for input without attachments', () => {
const result = extractMediaFromHtml('<div>No media here!</div>');
expect(result).toEqual([]);
});
it('should extract img tags', () => {
const result = extractMediaFromHtml('<img src="https://example.com/img.png" alt=""/>');
expect(result).toEqual([{
type: 'Image',
url: 'https://example.com/img.png',
name: null,
}]);
});
it('should ignore img tags without src', () => {
const result = extractMediaFromHtml('<img alt=""/>');
expect(result).toEqual([]);
});
it('should extract picture tags with img', () => {
const result = extractMediaFromHtml('<picture><img src="https://example.com/picture.png" alt=""/></picture>');
expect(result).toEqual([{
type: 'Image',
url: 'https://example.com/picture.png',
name: null,
}]);
});
it('should ignore picture tags without img', () => {
const result = extractMediaFromHtml('<picture><source src="https://example.com/picture.png"/></picture>');
expect(result).toEqual([]);
});
it('should ignore picture tags without src', () => {
const result = extractMediaFromHtml('<picture><source/><img alt=""/></picture>');
expect(result).toEqual([]);
});
it('should extract object tags', () => {
const result = extractMediaFromHtml('<object data="https://example.com/object.dat"></object>');
expect(result).toEqual([{
type: 'Document',
url: 'https://example.com/object.dat',
name: null,
}]);
});
it('should ignore object tags without data', () => {
const result = extractMediaFromHtml('<object></object>');
expect(result).toEqual([]);
});
it('should extract object tags with img fallback', () => {
const result = extractMediaFromHtml('<object><img src="https://example.com/object.png" alt=""/></object>');
expect(result).toEqual([{
type: 'Image',
url: 'https://example.com/object.png',
name: null,
}]);
});
it('should ignore object tags with empty img fallback', () => {
const result = extractMediaFromHtml('<object><img alt=""/></object>');
expect(result).toEqual([]);
});
it('should extract embed tags', () => {
const result = extractMediaFromHtml('<embed src="https://example.com/embed.dat"/>');
expect(result).toEqual([{
type: 'Document',
url: 'https://example.com/embed.dat',
name: null,
}]);
});
it('should ignore embed tags without src', () => {
const result = extractMediaFromHtml('<embed/>');
expect(result).toEqual([]);
});
it('should extract audio tags', () => {
const result = extractMediaFromHtml('<audio src="https://example.com/audio.mp3"></audio>');
expect(result).toEqual([{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: null,
}]);
});
it('should ignore audio tags without src', () => {
const result = extractMediaFromHtml('<audio></audio>');
expect(result).toEqual([]);
});
it('should extract video tags', () => {
const result = extractMediaFromHtml('<video src="https://example.com/video.mp4"></video>');
expect(result).toEqual([{
type: 'Video',
url: 'https://example.com/video.mp4',
name: null,
}]);
});
it('should ignore video tags without src', () => {
const result = extractMediaFromHtml('<video></video>');
expect(result).toEqual([]);
});
it('should extract alt text from alt property', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/img.png" alt="img tag" title="wrong"/>
<picture><img src="https://example.com/picture.png" alt="picture tag" title="wrong"/></picture>
<object data="https://example.com/object-1.dat" alt="object tag" title="wrong"></object>
<object><img src="https://example.com/object-2.png" alt="object tag" title="wrong"/></object>
<embed src="https://example.com/embed.dat" alt="embed tag" title="wrong"/>
<audio src="https://example.com/audio.mp3" alt="audio tag" title="wrong"/>
<video src="https://example.com/video.mp4" alt="video tag" title="wrong"/>
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/img.png',
name: 'img tag',
},
{
type: 'Image',
url: 'https://example.com/picture.png',
name: 'picture tag',
},
{
type: 'Image',
url: 'https://example.com/object-2.png',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/object-1.dat',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/embed.dat',
name: 'embed tag',
},
{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: 'audio tag',
},
{
type: 'Video',
url: 'https://example.com/video.mp4',
name: 'video tag',
},
]);
});
it('should extract alt text from title property', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/img.png" title="img tag"/>
<picture><img src="https://example.com/picture.png" title="picture tag"/></picture>
<object data="https://example.com/object-1.dat" title="object tag"></object>
<object><img src="https://example.com/object-2.png" title="object tag"/></object>
<embed src="https://example.com/embed.dat" title="embed tag"/>
<audio src="https://example.com/audio.mp3" title="audio tag"/>
<video src="https://example.com/video.mp4" title="video tag"/>
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/img.png',
name: 'img tag',
},
{
type: 'Image',
url: 'https://example.com/picture.png',
name: 'picture tag',
},
{
type: 'Image',
url: 'https://example.com/object-2.png',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/object-1.dat',
name: 'object tag',
},
{
type: 'Document',
url: 'https://example.com/embed.dat',
name: 'embed tag',
},
{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: 'audio tag',
},
{
type: 'Video',
url: 'https://example.com/video.mp4',
name: 'video tag',
},
]);
});
it('should ignore missing alt text', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/img.png"/>
<picture><img src="https://example.com/picture.png"/></picture>
<object data="https://example.com/object-1.dat"></object>
<object><img src="https://example.com/object-2.png"/></object>
<embed src="https://example.com/embed.dat"/>
<audio src="https://example.com/audio.mp3"/>
<video src="https://example.com/video.mp4"/>
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/img.png',
name: null,
},
{
type: 'Image',
url: 'https://example.com/picture.png',
name: null,
},
{
type: 'Image',
url: 'https://example.com/object-2.png',
name: null,
},
{
type: 'Document',
url: 'https://example.com/object-1.dat',
name: null,
},
{
type: 'Document',
url: 'https://example.com/embed.dat',
name: null,
},
{
type: 'Audio',
url: 'https://example.com/audio.mp3',
name: null,
},
{
type: 'Video',
url: 'https://example.com/video.mp4',
name: null,
},
]);
});
it('should de-duplicate attachments', () => {
const result = extractMediaFromHtml(`
<img src="https://example.com/1.png" alt="img 1"/>
<img src="https://example.com/2.png" alt="img 2"/>
<embed src="https://example.com/1.png" alt="embed 1"/>
`);
expect(result).toEqual([
{
type: 'Document',
url: 'https://example.com/1.png',
name: 'embed 1',
},
{
type: 'Image',
url: 'https://example.com/2.png',
name: 'img 2',
},
]);
});
});

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
describe(extractMediaFromMfm, () => {
it('should return empty for empty input', () => {
const result = extractMediaFromMfm('');
expect(result).toEqual([]);
});
it('should return empty for invalid input', () => {
const result = extractMediaFromMfm('*broken markdown\0');
expect(result).toEqual([]);
});
it('should extract all image links', () => {
const result = extractMediaFromMfm(`
![1](https://example.com/images/1.png)
![](https://example.com/images/2.png)
**![3](https://example.com/images/3.png)**
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/images/1.png',
name: '1',
},
{
type: 'Image',
url: 'https://example.com/images/2.png',
name: null,
},
{
type: 'Image',
url: 'https://example.com/images/3.png',
name: '3',
},
]);
});
it('should ignore regular links', () => {
const result = extractMediaFromMfm(`
[1](https://example.com/images/1.png)
[](https://example.com/images/2.png)
**[3](https://example.com/images/3.png)**
`);
expect(result).toEqual([]);
});
it('should ignore silent links', () => {
const result = extractMediaFromMfm(`
?[1](https://example.com/images/1.png)
?[](https://example.com/images/2.png)
**?[3](https://example.com/images/3.png)**
`);
expect(result).toEqual([]);
});
it('should extract complex text', () => {
const result = extractMediaFromMfm('![this is an **image** with *complex* text! :owo: 💙](https://example.com/image.png)');
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/image.png',
name: 'this is an image with complex text! :owo: 💙',
},
]);
});
it('should de-duplicate images', () => {
const result = extractMediaFromMfm(`
![1](https://example.com/images/1.png)
![](https://example.com/images/1.png)
**![3](https://example.com/images/1.png)**
`);
expect(result).toEqual([
{
type: 'Image',
url: 'https://example.com/images/1.png',
name: '3',
},
]);
});
});

View file

@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
describe(getContentByType, () => {
describe('when permissive', () => {
it('should return source.content when it matches', () => {
const obj = {
source: {
content: 'source content',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeYMarkdown, text/markdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe('source content');
});
it('should return _misskey_content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeYMarkdown, text/markdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe('misskey content');
});
it('should return content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/x.misskeYMarkdown, text/markdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe('native content');
});
it('should return null when nothing matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/plain',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown', true);
expect(content).toBe(null);
});
it('should return null for invalid inputs', () => {
const objects = [
{},
{ source: 'nope' },
{ content: null },
{ _misskey_content: 123 },
];
const results = objects.map(c => getContentByType(c, 'text/misskeymarkdown', true));
const expected = objects.map(() => null);
expect(results).toEqual(expected);
});
});
describe('when not permissive', () => {
it('should return source.content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/x.misskeymarkdown',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeymarkdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe('source content');
});
it('should return _misskey_content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: 'misskey content',
content: 'native content',
mediaType: 'text/x.misskeymarkdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe('misskey content');
});
it('should return content when it matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/x.misskeymarkdown',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe('native content');
});
it('should return null when nothing matches', () => {
const obj = {
source: {
content: 'source content',
mediaType: 'text/plain',
},
_misskey_content: null,
content: 'native content',
mediaType: 'text/plain',
};
const content = getContentByType(obj, 'text/x.misskeymarkdown');
expect(content).toBe(null);
});
it('should return null for invalid inputs', () => {
const objects = [
{},
{ source: 'nope' },
{ content: null },
{ _misskey_content: 123 },
];
const results = objects.map(c => getContentByType(c, 'text/misskeymarkdown'));
const expected = objects.map(() => null);
expect(results).toEqual(expected);
});
});
});

View file

@ -652,7 +652,7 @@ export async function sendEnvResetRequest() {
// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。 // 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。
// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する // FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する
export function castAsError(obj: Record<string, unknown>): { error: ApiError } { export function castAsError(obj: object | null | undefined): { error: ApiError } {
return obj as { error: ApiError }; return obj as { error: ApiError };
} }

View file

@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@discordapp/twemoji": "15.1.0", "@discordapp/twemoji": "15.1.0",
"@phosphor-icons/web": "2.1.2", "@phosphor-icons/web": "2.1.2",
"mfm-js": "npm:@transfem-org/sfm-js@0.24.6", "mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
"buraha": "0.0.1", "buraha": "0.0.1",
"frontend-shared": "workspace:*", "frontend-shared": "workspace:*",
"json5": "2.2.3", "json5": "2.2.3",

View file

@ -64,17 +64,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</p> </p>
<div v-show="mergedCW == null || showContent"> <div v-show="mergedCW == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> <div>
<EmMfm <EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
v-if="appearNote.text" <EmMfm
:parsedNodes="parsed" v-if="appearNote.text"
:text="appearNote.text" :parsedNodes="parsed"
:author="appearNote.user" :text="appearNote.text"
:nyaize="'respect'" :author="appearNote.user"
:emojiUrls="appearNote.emojis" :nyaize="'respect'"
:isBlock="true" :emojiUrls="appearNote.emojis"
/> />
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> </div>
<div v-if="appearNote.files && appearNote.files.length > 0"> <div v-if="appearNote.files && appearNote.files.length > 0">
<EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> <EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
</div> </div>

View file

@ -112,7 +112,7 @@
"@vue/compiler-core": "3.5.14", "@vue/compiler-core": "3.5.14",
"@vue/compiler-sfc": "3.5.14", "@vue/compiler-sfc": "3.5.14",
"@vue/runtime-core": "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", "acorn": "8.14.1",
"astring": "1.9.0", "astring": "1.9.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",

View file

@ -72,20 +72,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text"> <div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <div>
<Mfm <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
v-if="appearNote.text" <Mfm
:parsedNodes="parsed" v-if="appearNote.text"
:text="appearNote.text" :parsedNodes="parsed"
:author="appearNote.user" :text="appearNote.text"
:nyaize="'respect'" :author="appearNote.user"
:emojiUrls="appearNote.emojis" :nyaize="'respect'"
:enableEmojiMenu="true" :emojiUrls="appearNote.emojis"
:enableEmojiMenuReaction="true" :enableEmojiMenu="true"
:isAnim="allowAnim" :enableEmojiMenuReaction="true"
:isBlock="true" :isAnim="allowAnim"
class="_selectable" class="_selectable"
/> />
</div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
@ -305,7 +306,7 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW); const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); 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 selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value); const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);

View file

@ -89,21 +89,21 @@ SPDX-License-Identifier: AGPL-3.0-only
</p> </p>
<div v-show="mergedCW == null || showContent"> <div v-show="mergedCW == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <div>
<Mfm <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
v-if="appearNote.text" <Mfm
:parsedNodes="parsed" v-if="appearNote.text"
:text="appearNote.text" :parsedNodes="parsed"
:author="appearNote.user" :text="appearNote.text"
:nyaize="'respect'" :author="appearNote.user"
:emojiUrls="appearNote.emojis" :nyaize="'respect'"
:enableEmojiMenu="true" :emojiUrls="appearNote.emojis"
:enableEmojiMenuReaction="true" :enableEmojiMenu="true"
:isAnim="allowAnim" :enableEmojiMenuReaction="true"
:isBlock="true" :isAnim="allowAnim"
class="_selectable" class="_selectable"
/> />
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> </div>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
@ -414,6 +414,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab); const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null); const reactionTabType = ref<string | null>(null);
// Auto-select the first page of reactions
watch(appearNote, n => {
reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null;
}, { immediate: true });
const renotesPagination = computed<Paging>(() => ({ const renotesPagination = computed<Paging>(() => ({
endpoint: 'notes/renotes', endpoint: 'notes/renotes',
limit: 10, limit: 10,

View file

@ -114,6 +114,7 @@ import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue'; import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance, policies } from '@/instance'; import { instance, policies } from '@/instance';
import { getAppearNote } from '@/utility/get-appear-note';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -128,7 +129,9 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined, 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<HTMLElement>(); const el = shallowRef<HTMLElement>();
const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null); const translation = ref<Misskey.entities.NotesTranslateResponse | false | null>(null);
@ -144,19 +147,11 @@ const likeButton = shallowRef<HTMLElement>();
const renoteTooltip = computeRenoteTooltip(renoted); 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 defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value)); 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<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@ -206,8 +201,8 @@ async function reply(viaKeyboard = false): Promise<void> {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
await os.post({ await os.post({
reply: props.note, reply: appearNote.value,
channel: props.note.channel ?? undefined, channel: appearNote.value.channel ?? undefined,
animation: !viaKeyboard, animation: !viaKeyboard,
}); });
focus(); focus();
@ -217,9 +212,9 @@ function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
if (props.note.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
misskeyApi('notes/like', { misskeyApi('notes/like', {
noteId: props.note.id, noteId: appearNote.value.id,
override: defaultLike.value, override: defaultLike.value,
}); });
const el = reactButton.value as HTMLElement | null | undefined; const el = reactButton.value as HTMLElement | null | undefined;
@ -233,12 +228,12 @@ function react(): void {
} }
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value ?? null, props.note, reaction => { reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => {
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: props.note.id, noteId: appearNote.value.id,
reaction: reaction, 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'); claimAchievement('reactWithoutRead');
} }
}, () => { }, () => {
@ -252,7 +247,7 @@ function like(): void {
showMovedDialog(); showMovedDialog();
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/like', { misskeyApi('notes/like', {
noteId: props.note.id, noteId: appearNote.value.id,
override: defaultLike.value, override: defaultLike.value,
}); });
const el = likeButton.value as HTMLElement | null | undefined; const el = likeButton.value as HTMLElement | null | undefined;
@ -361,7 +356,7 @@ function quote() {
}).then((cancelled) => { }).then((cancelled) => {
if (cancelled) return; if (cancelled) return;
misskeyApi('notes/renotes', { misskeyApi('notes/renotes', {
noteId: props.note.id, noteId: appearNote.value.id,
userId: $i?.id, userId: $i?.id,
limit: 1, limit: 1,
quote: true, quote: true,
@ -383,12 +378,12 @@ function quote() {
} }
function menu(): void { 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); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
} }
async function clip(): Promise<void> { async function clip(): Promise<void> {
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() { async function translate() {
@ -397,7 +392,7 @@ async function translate() {
if (props.detail) { if (props.detail) {
misskeyApi('notes/children', { misskeyApi('notes/children', {
noteId: props.note.id, noteId: appearNote.value.id,
limit: prefer.s.numberOfReplies, limit: prefer.s.numberOfReplies,
showQuotes: false, showQuotes: false,
}).then(res => { }).then(res => {

View file

@ -8,8 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="{ [$style.clickToOpen]: prefer.s.clickToOpen }" @click.stop="prefer.s.clickToOpen ? noteclick(note.id) : undefined"> <div :class="{ [$style.clickToOpen]: prefer.s.clickToOpen }" @click.stop="prefer.s.clickToOpen ? noteclick(note.id) : undefined">
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <div>
<Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/>
</div>
<MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>{{ email }}</div> <div>{{ email }}</div>
</div> </div>
<div> <div>
<div :class="$style.label">Reason</div> <div :class="$style.label">{{ i18n.ts.signupReason }}</div>
<div>{{ reason }}</div> <div>{{ reason }}</div>
</div> </div>
</div> </div>

View file

@ -305,7 +305,7 @@ const galleryEl = useTemplateRef('galleryEl');
const isMyRenote = $i && ($i.id === note.value.userId); const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(prefer.s.uncollapseCW); const showContent = ref(prefer.s.uncollapseCW);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); 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 selfNoteIds = computed(() => getSelfNoteIds(props.note));
const isLong = shouldCollapsed(appearNote.value, urls.value); const isLong = shouldCollapsed(appearNote.value, urls.value);
const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong); const collapsed = ref(prefer.s.expandLongNote && appearNote.value.cw == null && isLong ? false : appearNote.value.cw == null && isLong);

View file

@ -108,7 +108,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:isBlock="true" :isBlock="true"
class="_selectable" class="_selectable"
/> />
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation> <SkNoteTranslation :note="note" :translation="translation" :translating="translating"></SkNoteTranslation>
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
<MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <MkButton v-else-if="!prefer.s.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
@ -420,6 +419,11 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab); const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null); const reactionTabType = ref<string | null>(null);
// Auto-select the first page of reactions
watch(appearNote, n => {
reactionTabType.value ??= Object.keys(n.reactions)[0] ?? null;
}, { immediate: true });
const renotesPagination = computed<Paging>(() => ({ const renotesPagination = computed<Paging>(() => ({
endpoint: 'notes/renotes', endpoint: 'notes/renotes',
limit: 10, limit: 10,

View file

@ -122,6 +122,7 @@ import { prefer } from '@/preferences.js';
import { useNoteCapture } from '@/use/use-note-capture.js'; import { useNoteCapture } from '@/use/use-note-capture.js';
import SkMutedNote from '@/components/SkMutedNote.vue'; import SkMutedNote from '@/components/SkMutedNote.vue';
import { instance, policies } from '@/instance'; import { instance, policies } from '@/instance';
import { getAppearNote } from '@/utility/get-appear-note';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -141,7 +142,9 @@ const props = withDefaults(defineProps<{
onDeleteCallback: undefined, 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 hideLine = computed(() => props.detail);
const el = shallowRef<HTMLElement>(); const el = shallowRef<HTMLElement>();
@ -158,19 +161,11 @@ const likeButton = shallowRef<HTMLElement>();
const renoteTooltip = computeRenoteTooltip(renoted); 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 defaultLike = computed(() => prefer.s.like ? prefer.s.like : null);
const replies = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]);
const mergedCW = computed(() => computeMergedCw(appearNote.value)); 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<OpenOnRemoteOptions>(() => ({ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup', type: 'lookup',
url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`, url: appearNote.value.url ?? appearNote.value.uri ?? `${config.url}/notes/${appearNote.value.id}`,
@ -220,8 +215,8 @@ async function reply(viaKeyboard = false): Promise<void> {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
await os.post({ await os.post({
reply: props.note, reply: appearNote.value,
channel: props.note.channel ?? undefined, channel: appearNote.value.channel ?? undefined,
animation: !viaKeyboard, animation: !viaKeyboard,
}); });
focus(); focus();
@ -231,9 +226,9 @@ function react(): void {
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });
showMovedDialog(); showMovedDialog();
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
if (props.note.reactionAcceptance === 'likeOnly') { if (appearNote.value.reactionAcceptance === 'likeOnly') {
misskeyApi('notes/like', { misskeyApi('notes/like', {
noteId: props.note.id, noteId: appearNote.value.id,
override: defaultLike.value, override: defaultLike.value,
}); });
const el = reactButton.value as HTMLElement | null | undefined; const el = reactButton.value as HTMLElement | null | undefined;
@ -247,12 +242,12 @@ function react(): void {
} }
} else { } else {
blur(); blur();
reactionPicker.show(reactButton.value ?? null, props.note, reaction => { reactionPicker.show(reactButton.value ?? null, appearNote.value, reaction => {
misskeyApi('notes/reactions/create', { misskeyApi('notes/reactions/create', {
noteId: props.note.id, noteId: appearNote.value.id,
reaction: reaction, 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'); claimAchievement('reactWithoutRead');
} }
}, () => { }, () => {
@ -266,7 +261,7 @@ function like(): void {
showMovedDialog(); showMovedDialog();
sound.playMisskeySfx('reaction'); sound.playMisskeySfx('reaction');
misskeyApi('notes/like', { misskeyApi('notes/like', {
noteId: props.note.id, noteId: appearNote.value.id,
override: defaultLike.value, override: defaultLike.value,
}); });
const el = likeButton.value as HTMLElement | null | undefined; const el = likeButton.value as HTMLElement | null | undefined;
@ -375,7 +370,7 @@ function quote() {
}).then((cancelled) => { }).then((cancelled) => {
if (cancelled) return; if (cancelled) return;
misskeyApi('notes/renotes', { misskeyApi('notes/renotes', {
noteId: props.note.id, noteId: appearNote.value.id,
userId: $i?.id, userId: $i?.id,
limit: 1, limit: 1,
quote: true, quote: true,
@ -397,12 +392,12 @@ function quote() {
} }
function menu(): void { 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); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
} }
async function clip(): Promise<void> { async function clip(): Promise<void> {
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() { async function translate() {
@ -411,7 +406,7 @@ async function translate() {
if (props.detail) { if (props.detail) {
misskeyApi('notes/children', { misskeyApi('notes/children', {
noteId: props.note.id, noteId: appearNote.value.id,
limit: prefer.s.numberOfReplies, limit: prefer.s.numberOfReplies,
showQuotes: false, showQuotes: false,
}).then(res => { }).then(res => {

View file

@ -130,6 +130,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</FormSection> </FormSection>
<FormSection v-else-if="info.signupReason">
<template #label>{{ i18n.ts.signupReason }}</template>
{{ info.signupReason }}
</FormSection>
<FormSection v-if="!isSystem && user && iAmModerator"> <FormSection v-if="!isSystem && user && iAmModerator">
<div class="_gaps"> <div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> <MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>

View file

@ -203,7 +203,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, watch, useCssModule } from 'vue'; import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import type { ChartSrc } from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue';
import type { Paging } from '@/components/MkPagination.vue'; import type { Paging } from '@/components/MkPagination.vue';
@ -231,13 +231,10 @@ import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard'; import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { acct } from '@/filters/user';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkNumber from '@/components/MkNumber.vue'; import MkNumber from '@/components/MkNumber.vue';
import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; import SkBadgeStrip from '@/components/SkBadgeStrip.vue';
const $style = useCssModule();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
host: string; host: string;
metaHint?: Misskey.entities.AdminMetaResponse; metaHint?: Misskey.entities.AdminMetaResponse;

View file

@ -10,14 +10,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><Mfm :text="note.cw" :author="note.user"/></div> <div><Mfm :text="note.cw" :author="note.user"/></div>
<MkCwButton v-model="showContent" :text="note.text" :renote="note.renote" :files="note.files" :poll="note.poll" style="margin: 4px 0;"/> <MkCwButton v-model="showContent" :text="note.text" :renote="note.renote" :files="note.files" :poll="note.poll" style="margin: 4px 0;"/>
<div v-if="showContent"> <div v-if="showContent">
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> <div>
<Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user"/>
</div>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div> </div>
</div> </div>
<div v-else ref="noteTextEl" :class="[$style.text, { [$style.collapsed]: shouldCollapse }]"> <div v-else ref="noteTextEl" :class="[$style.text, { [$style.collapsed]: shouldCollapse }]">
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> <div>
<Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user"/>
</div>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div> </div>
<div v-if="note.files && note.files.length > 0" :class="$style.richcontent"> <div v-if="note.files && note.files.length > 0" :class="$style.richcontent">

View file

@ -4251,6 +4251,10 @@ export type components = {
/** Format: url */ /** Format: url */
avatarUrl: string | null; avatarUrl: string | null;
avatarBlurhash: string | null; avatarBlurhash: string | null;
/** @example Hi masters, I am Ai! */
description: string | null;
/** Format: date-time */
createdAt: string;
avatarDecorations: { avatarDecorations: {
/** Format: id */ /** Format: id */
id: string; id: string;
@ -11244,6 +11248,7 @@ export type operations = {
remoteFollowing: number; remoteFollowing: number;
remoteFollowers: number; remoteFollowers: number;
}; };
signupReason: string | null;
}; };
}; };
}; };

136
pnpm-lock.yaml generated
View file

@ -282,8 +282,8 @@ importers:
specifier: 0.50.0 specifier: 0.50.0
version: 0.50.0 version: 0.50.0
mfm-js: mfm-js:
specifier: npm:@transfem-org/sfm-js@0.24.6 specifier: npm:@transfem-org/sfm-js@0.24.8
version: '@transfem-org/sfm-js@0.24.6' version: '@transfem-org/sfm-js@0.24.8'
mime-types: mime-types:
specifier: 2.1.35 specifier: 2.1.35
version: 2.1.35 version: 2.1.35
@ -979,8 +979,8 @@ importers:
specifier: 0.30.17 specifier: 0.30.17
version: 0.30.17 version: 0.30.17
mfm-js: mfm-js:
specifier: npm:@transfem-org/sfm-js@0.24.6 specifier: npm:@transfem-org/sfm-js@0.24.8
version: '@transfem-org/sfm-js@0.24.6' version: '@transfem-org/sfm-js@0.24.8'
micromatch: micromatch:
specifier: 4.0.8 specifier: 4.0.8
version: 4.0.8 version: 4.0.8
@ -1072,8 +1072,8 @@ importers:
specifier: 2.2.3 specifier: 2.2.3
version: 2.2.3 version: 2.2.3
mfm-js: mfm-js:
specifier: npm:@transfem-org/sfm-js@0.24.6 specifier: npm:@transfem-org/sfm-js@0.24.8
version: '@transfem-org/sfm-js@0.24.6' version: '@transfem-org/sfm-js@0.24.8'
misskey-js: misskey-js:
specifier: workspace:* specifier: workspace:*
version: link:../misskey-js version: link:../misskey-js
@ -1286,7 +1286,7 @@ importers:
version: 29.7.0 version: 29.7.0
ts-jest: ts-jest:
specifier: 29.3.4 specifier: 29.3.4
version: 29.3.4(@babel/core@7.24.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.7))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3) version: 29.3.4(@babel/core@7.23.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.5))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3)
packages/misskey-bubble-game: packages/misskey-bubble-game:
dependencies: dependencies:
@ -4015,8 +4015,8 @@ packages:
resolution: {integrity: sha1-LSVFMGgZU9oQlHSVb5XEzOG+yeQ=, tarball: https://activitypub.software/api/v4/projects/229/packages/npm/@transfem-org/cli-highlight/-/@transfem-org/cli-highlight-2.1.12.tgz} resolution: {integrity: sha1-LSVFMGgZU9oQlHSVb5XEzOG+yeQ=, tarball: https://activitypub.software/api/v4/projects/229/packages/npm/@transfem-org/cli-highlight/-/@transfem-org/cli-highlight-2.1.12.tgz}
engines: {node: ^22.0.0} engines: {node: ^22.0.0}
'@transfem-org/sfm-js@0.24.6': '@transfem-org/sfm-js@0.24.8':
resolution: {integrity: sha1-7t+TkCd3PZk+RbbrGbZ/iMs2y7o=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.6.tgz} resolution: {integrity: sha1-G97++XwNPZZaxIExiJbm2kJZSg0=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.8.tgz}
'@transfem-org/summaly@5.2.2': '@transfem-org/summaly@5.2.2':
resolution: {integrity: sha1-MO7cCppxE0luitQqz9A6RiWHpco=, tarball: https://activitypub.software/api/v4/projects/217/packages/npm/@transfem-org/summaly/-/@transfem-org/summaly-5.2.2.tgz} resolution: {integrity: sha1-MO7cCppxE0luitQqz9A6RiWHpco=, tarball: https://activitypub.software/api/v4/projects/217/packages/npm/@transfem-org/summaly/-/@transfem-org/summaly-5.2.2.tgz}
@ -11420,56 +11420,26 @@ snapshots:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.5)': '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.5)': '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.5)': '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.5)': '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.5)': '@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
@ -11480,78 +11450,36 @@ snapshots:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.5)': '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.5)': '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.5)': '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.5)': '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.5)': '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.5)': '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.7)':
dependencies:
'@babel/core': 7.24.7
'@babel/helper-plugin-utils': 7.22.5
optional: true
'@babel/plugin-syntax-typescript@7.23.3(@babel/core@7.23.5)': '@babel/plugin-syntax-typescript@7.23.3(@babel/core@7.23.5)':
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
@ -14152,7 +14080,7 @@ snapshots:
highlight.js: 11.11.1 highlight.js: 11.11.1
htmlparser2: 9.1.0 htmlparser2: 9.1.0
'@transfem-org/sfm-js@0.24.6': '@transfem-org/sfm-js@0.24.8':
dependencies: dependencies:
'@twemoji/parser': 15.0.0 '@twemoji/parser': 15.0.0
@ -15258,20 +15186,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
babel-jest@29.7.0(@babel/core@7.24.7):
dependencies:
'@babel/core': 7.24.7
'@jest/transform': 29.7.0
'@types/babel__core': 7.20.0
babel-plugin-istanbul: 6.1.1
babel-preset-jest: 29.6.3(@babel/core@7.24.7)
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
transitivePeerDependencies:
- supports-color
optional: true
babel-plugin-istanbul@6.1.1: babel-plugin-istanbul@6.1.1:
dependencies: dependencies:
'@babel/helper-plugin-utils': 7.22.5 '@babel/helper-plugin-utils': 7.22.5
@ -15305,36 +15219,12 @@ snapshots:
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.5) '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.5)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.5)
babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.7):
dependencies:
'@babel/core': 7.24.7
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.7)
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.7)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.7)
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.7)
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.7)
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7)
optional: true
babel-preset-jest@29.6.3(@babel/core@7.23.5): babel-preset-jest@29.6.3(@babel/core@7.23.5):
dependencies: dependencies:
'@babel/core': 7.23.5 '@babel/core': 7.23.5
babel-plugin-jest-hoist: 29.6.3 babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5) babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5)
babel-preset-jest@29.6.3(@babel/core@7.24.7):
dependencies:
'@babel/core': 7.24.7
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7)
optional: true
babel-walk@3.0.0-canary-5: babel-walk@3.0.0-canary-5:
dependencies: dependencies:
'@babel/types': 7.27.1 '@babel/types': 7.27.1
@ -21303,7 +21193,7 @@ snapshots:
ts-dedent@2.2.0: {} ts-dedent@2.2.0: {}
ts-jest@29.3.4(@babel/core@7.24.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.7))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3): ts-jest@29.3.4(@babel/core@7.23.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.5))(esbuild@0.25.3)(jest@29.7.0(@types/node@22.15.2))(typescript@5.8.3):
dependencies: dependencies:
bs-logger: 0.2.6 bs-logger: 0.2.6
ejs: 3.1.10 ejs: 3.1.10
@ -21318,10 +21208,10 @@ snapshots:
typescript: 5.8.3 typescript: 5.8.3
yargs-parser: 21.1.1 yargs-parser: 21.1.1
optionalDependencies: optionalDependencies:
'@babel/core': 7.24.7 '@babel/core': 7.23.5
'@jest/transform': 29.7.0 '@jest/transform': 29.7.0
'@jest/types': 29.6.3 '@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.24.7) babel-jest: 29.7.0(@babel/core@7.23.5)
esbuild: 0.25.3 esbuild: 0.25.3
ts-map@1.0.3: {} ts-map@1.0.3: {}

View file

@ -530,6 +530,7 @@ translationFailed: "Failed to translate note. Please try again later or contact
_processErrors: _processErrors:
quoteUnavailable: "Unable to process quote. This post may be missing context." quoteUnavailable: "Unable to process quote. This post may be missing context."
attachmentFailed: "One or more media attachments are unavailable and cannot be shown."
authorizedFetchSection: "Authorized Fetch" authorizedFetchSection: "Authorized Fetch"
authorizedFetchLabel: "Allow unsigned ActivityPub requests:" authorizedFetchLabel: "Allow unsigned ActivityPub requests:"
@ -628,3 +629,5 @@ noteFooterLabel: "Note controls"
rawUserDescription: "Packed user data in its raw form. Most of these fields are public and visible to all users." rawUserDescription: "Packed user data in its raw form. Most of these fields are public and visible to all users."
rawInfoDescription: "Extended user data in its raw form. These fields are private and can only be accessed by moderators." rawInfoDescription: "Extended user data in its raw form. These fields are private and can only be accessed by moderators."
rawApDescription: "ActivityPub user data in its raw form. These fields are public and accessible to other instances." rawApDescription: "ActivityPub user data in its raw form. These fields are public and accessible to other instances."
signupReason: "Signup Reason"