Merge branch 'develop' into upstream/2025.5.0
This commit is contained in:
commit
886160bdec
52 changed files with 1519 additions and 630 deletions
|
|
@ -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
8
locales/index.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,10 @@ export const meta = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
signupReason: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -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>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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(`
|
||||||
|

|
||||||
|

|
||||||
|
****
|
||||||
|
`);
|
||||||
|
|
||||||
|
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('');
|
||||||
|
|
||||||
|
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(`
|
||||||
|

|
||||||
|

|
||||||
|
****
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
type: 'Image',
|
||||||
|
url: 'https://example.com/images/1.png',
|
||||||
|
name: '3',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
136
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue