merge: Report admin UX improvements (!1060)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1060

Approved-by: Marie <github@yuugi.dev>
Approved-by: dakkar <dakkar@thenautilus.net>
This commit is contained in:
Hazelnoot 2025-06-05 08:00:32 +00:00
commit f88253b95f
27 changed files with 846 additions and 135 deletions

8
locales/index.d.ts vendored
View file

@ -13157,6 +13157,14 @@ export interface Locale extends ILocale {
* Timeout in milliseconds for translation API requests. * Timeout in milliseconds for translation API requests.
*/ */
"translationTimeoutCaption": string; "translationTimeoutCaption": string;
/**
* Staff notes
*/
"staffNotes": string;
/**
* Icon of {name}
*/
"instanceIconAlt": ParameterizedString<"name">;
/** /**
* Attribution Domains * Attribution Domains
*/ */

View file

@ -588,6 +588,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null, lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null, movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
instance: null, instance: null,
userProfile: null,
} : null, } : null,
user2: parsed.user2 != null ? { user2: parsed.user2 != null ? {
...parsed.user2, ...parsed.user2,
@ -599,6 +600,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null, lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null, movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
instance: null, instance: null,
userProfile: null,
} : null, } : null,
}; };
} else { } else {

View file

@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
mandatoryCW: null, mandatoryCW: null,
rejectQuotes: false, rejectQuotes: false,
allowUnsignedFetch: 'staff', allowUnsignedFetch: 'staff',
userProfile: null,
attributionDomains: [], attributionDomains: [],
...override, ...override,
}; };
@ -363,8 +364,10 @@ export class WebhookTestService {
id: 'dummy-abuse-report1', id: 'dummy-abuse-report1',
targetUserId: 'dummy-target-user', targetUserId: 'dummy-target-user',
targetUser: null, targetUser: null,
targetUserInstance: null,
reporterId: 'dummy-reporter-user', reporterId: 'dummy-reporter-user',
reporter: null, reporter: null,
reporterInstance: null,
assigneeId: null, assigneeId: null,
assignee: null, assignee: null,
resolved: false, resolved: false,

View file

@ -5,13 +5,14 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AbuseUserReportsRepository } from '@/models/_.js'; import type { AbuseUserReportsRepository, InstancesRepository, MiInstance, MiUser } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { UserEntityService } from './UserEntityService.js'; import { UserEntityService } from './UserEntityService.js';
import { InstanceEntityService } from './InstanceEntityService.js';
@Injectable() @Injectable()
export class AbuseUserReportEntityService { export class AbuseUserReportEntityService {
@ -19,6 +20,10 @@ export class AbuseUserReportEntityService {
@Inject(DI.abuseUserReportsRepository) @Inject(DI.abuseUserReportsRepository)
private abuseUserReportsRepository: AbuseUserReportsRepository, private abuseUserReportsRepository: AbuseUserReportsRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private readonly instanceEntityService: InstanceEntityService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
) { ) {
@ -30,11 +35,14 @@ export class AbuseUserReportEntityService {
hint?: { hint?: {
packedReporter?: Packed<'UserDetailedNotMe'>, packedReporter?: Packed<'UserDetailedNotMe'>,
packedTargetUser?: Packed<'UserDetailedNotMe'>, packedTargetUser?: Packed<'UserDetailedNotMe'>,
packedTargetInstance?: Packed<'FederationInstance'>,
packedAssignee?: Packed<'UserDetailedNotMe'>, packedAssignee?: Packed<'UserDetailedNotMe'>,
}, },
me?: MiUser | null,
) { ) {
const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src });
// noinspection ES6MissingAwait
return await awaitAll({ return await awaitAll({
id: report.id, id: report.id,
createdAt: this.idService.parse(report.id).date.toISOString(), createdAt: this.idService.parse(report.id).date.toISOString(),
@ -43,13 +51,22 @@ export class AbuseUserReportEntityService {
reporterId: report.reporterId, reporterId: report.reporterId,
targetUserId: report.targetUserId, targetUserId: report.targetUserId,
assigneeId: report.assigneeId, assigneeId: report.assigneeId,
reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, { reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, me, {
schema: 'UserDetailedNotMe', schema: 'UserDetailedNotMe',
}), }),
targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, me, {
schema: 'UserDetailedNotMe', schema: 'UserDetailedNotMe',
}), }),
assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { // return hint, or pack by relation, or fetch and pack by id, or null
targetInstance: hint?.packedTargetInstance ?? (
report.targetUserInstance
? this.instanceEntityService.pack(report.targetUserInstance, me)
: report.targetUserHost
? this.instancesRepository.findOneBy({ host: report.targetUserHost }).then(instance => instance
? this.instanceEntityService.pack(instance, me)
: null)
: null),
assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, me, {
schema: 'UserDetailedNotMe', schema: 'UserDetailedNotMe',
}) : null, }) : null,
forwarded: report.forwarded, forwarded: report.forwarded,
@ -61,21 +78,28 @@ export class AbuseUserReportEntityService {
@bindThis @bindThis
public async packMany( public async packMany(
reports: MiAbuseUserReport[], reports: MiAbuseUserReport[],
me?: MiUser | null,
) { ) {
const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId);
const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId);
const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null); const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null);
const _userMap = await this.userEntityService.packMany( const _userMap = await this.userEntityService.packMany(
[..._reporters, ..._targetUsers, ..._assignees], [..._reporters, ..._targetUsers, ..._assignees],
null, me,
{ schema: 'UserDetailedNotMe' }, { schema: 'UserDetailedNotMe' },
).then(users => new Map(users.map(u => [u.id, u]))); ).then(users => new Map(users.map(u => [u.id, u])));
const _targetInstances = reports
.map(({ targetUserInstance, targetUserHost }) => targetUserInstance ?? targetUserHost)
.filter((i): i is MiInstance | string => i != null);
const _instanceMap = await this.instanceEntityService.packMany(await this.instanceEntityService.fetchInstancesByHost(_targetInstances), me)
.then(instances => new Map(instances.map(i => [i.host, i])));
return Promise.all( return Promise.all(
reports.map(report => { reports.map(report => {
const packedReporter = _userMap.get(report.reporterId); const packedReporter = _userMap.get(report.reporterId);
const packedTargetUser = _userMap.get(report.targetUserId); const packedTargetUser = _userMap.get(report.targetUserId);
const packedTargetInstance = report.targetUserHost ? _instanceMap.get(report.targetUserHost) : undefined;
const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined; const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined;
return this.pack(report, { packedReporter, packedTargetUser, packedAssignee }); return this.pack(report, { packedReporter, packedTargetUser, packedAssignee, packedTargetInstance }, me);
}), }),
); );
} }

View file

@ -4,6 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { MiInstance } from '@/models/Instance.js'; import type { MiInstance } from '@/models/Instance.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -11,7 +12,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/_.js'; import type { InstancesRepository, MiMeta } from '@/models/_.js';
@Injectable() @Injectable()
export class InstanceEntityService { export class InstanceEntityService {
@ -19,6 +20,9 @@ export class InstanceEntityService {
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@Inject(DI.instancesRepository)
private readonly instancesRepository: InstancesRepository,
private roleService: RoleService, private roleService: RoleService,
private utilityService: UtilityService, private utilityService: UtilityService,
@ -73,5 +77,28 @@ export class InstanceEntityService {
) { ) {
return Promise.all(instances.map(x => this.pack(x, me))); return Promise.all(instances.map(x => this.pack(x, me)));
} }
@bindThis
public async fetchInstancesByHost(instances: (MiInstance | MiInstance['host'])[]): Promise<MiInstance[]> {
const result: MiInstance[] = [];
const toFetch: string[] = [];
for (const instance of instances) {
if (typeof(instance) === 'string') {
toFetch.push(instance);
} else {
result.push(instance);
}
}
if (toFetch.length > 0) {
const fetched = await this.instancesRepository.findBy({
host: In(toFetch),
});
result.push(...fetched);
}
return result;
}
} }

View file

@ -487,7 +487,10 @@ export class UserEntityService implements OnModuleInit {
includeSecrets: false, includeSecrets: false,
}, options); }, options);
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); const user = typeof src === 'object' ? src : await this.usersRepository.findOneOrFail({
where: { id: src },
relations: { userProfile: true },
});
// migration // migration
if (user.avatarId != null && user.avatarUrl === null) { if (user.avatarId != null && user.avatarUrl === null) {
@ -521,7 +524,7 @@ export class UserEntityService implements OnModuleInit {
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
const profile = isDetailed const profile = isDetailed
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
: null; : null;
let relation: UserRelation | null = null; let relation: UserRelation | null = null;
@ -556,7 +559,7 @@ export class UserEntityService implements OnModuleInit {
} }
} }
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; const mastoapi = !isDetailed ? opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
const followingCount = profile == null ? null : const followingCount = profile == null ? null :
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount : (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
@ -785,8 +788,13 @@ export class UserEntityService implements OnModuleInit {
const _users = users.filter((user): user is MiUser => typeof user !== 'string'); const _users = users.filter((user): user is MiUser => typeof user !== 'string');
if (_users.length !== users.length) { if (_users.length !== users.length) {
_users.push( _users.push(
...await this.usersRepository.findBy({ ...await this.usersRepository.find({
id: In(users.filter((user): user is string => typeof user === 'string')), where: {
id: In(users.filter((user): user is string => typeof user === 'string')),
},
relations: {
userProfile: true,
},
}), }),
); );
} }
@ -800,8 +808,20 @@ export class UserEntityService implements OnModuleInit {
let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map(); let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map();
if (options?.schema !== 'UserLite') { if (options?.schema !== 'UserLite') {
profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) const _profiles: MiUserProfile[] = [];
.then(profiles => new Map(profiles.map(p => [p.userId, p]))); const _profilesToFetch: string[] = [];
for (const user of _users) {
if (user.userProfile) {
_profiles.push(user.userProfile);
} else {
_profilesToFetch.push(user.id);
}
}
if (_profilesToFetch.length > 0) {
const fetched = await this.userProfilesRepository.findBy({ userId: In(_profilesToFetch) });
_profiles.push(...fetched);
}
profilesMap = new Map(_profiles.map(p => [p.userId, p]));
const meId = me ? me.id : null; const meId = me ? me.id : null;
if (meId) { if (meId) {

View file

@ -4,6 +4,7 @@
*/ */
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
@ -88,11 +89,31 @@ export class MiAbuseUserReport {
}) })
public targetUserHost: string | null; public targetUserHost: string | null;
@ManyToOne(() => MiInstance, {
// TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged
createForeignKeyConstraints: false,
})
@JoinColumn({
name: 'targetUserHost',
referencedColumnName: 'host',
})
public targetUserInstance: MiInstance | null;
@Index() @Index()
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
comment: '[Denormalized]', comment: '[Denormalized]',
}) })
public reporterHost: string | null; public reporterHost: string | null;
@ManyToOne(() => MiInstance, {
// TODO create a foreign key constraint after hazelnoot/labs/persisted-instance-blocks is merged
createForeignKeyConstraints: false,
})
@JoinColumn({
name: 'reporterHost',
referencedColumnName: 'host',
})
public reporterInstance: MiInstance | null;
//#endregion //#endregion
} }

View file

@ -8,6 +8,7 @@ import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.
import { MiInstance } from '@/models/Instance.js'; import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiDriveFile } from './DriveFile.js'; import { MiDriveFile } from './DriveFile.js';
import type { MiUserProfile } from './UserProfile.js';
@Entity('user') @Entity('user')
@Index(['usernameLower', 'host'], { unique: true }) @Index(['usernameLower', 'host'], { unique: true })
@ -395,6 +396,9 @@ export class MiUser {
}) })
public attributionDomains: string[]; public attributionDomains: string[];
@OneToOne('user_profile', (profile: MiUserProfile) => profile.user)
public userProfile: MiUserProfile | null;
constructor(data: Partial<MiUser>) { constructor(data: Partial<MiUser>) {
if (data == null) return; if (data == null) return;

View file

@ -17,7 +17,7 @@ export class MiUserProfile {
@PrimaryColumn(id()) @PrimaryColumn(id())
public userId: MiUser['id']; public userId: MiUser['id'];
@OneToOne(type => MiUser, { @OneToOne(() => MiUser, user => user.userProfile, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()

View file

@ -69,6 +69,11 @@ export const meta = {
nullable: false, optional: false, nullable: false, optional: false,
ref: 'UserDetailedNotMe', ref: 'UserDetailedNotMe',
}, },
targetInstance: {
type: 'object',
nullable: true, optional: false,
ref: 'FederationInstance',
},
assignee: { assignee: {
type: 'object', type: 'object',
nullable: true, optional: false, nullable: true, optional: false,
@ -115,7 +120,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId)
.leftJoinAndSelect('report.targetUser', 'targetUser')
.leftJoinAndSelect('targetUser.userProfile', 'targetUserProfile')
.leftJoinAndSelect('report.targetUserInstance', 'targetUserInstance')
.leftJoinAndSelect('report.reporter', 'reporter')
.leftJoinAndSelect('reporter.userProfile', 'reporterProfile')
.leftJoinAndSelect('report.assignee', 'assignee')
.leftJoinAndSelect('assignee.userProfile', 'assigneeProfile')
;
switch (ps.state) { switch (ps.state) {
case 'resolved': query.andWhere('report.resolved = TRUE'); break; case 'resolved': query.andWhere('report.resolved = TRUE'); break;
@ -134,7 +147,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const reports = await query.limit(ps.limit).getMany(); const reports = await query.limit(ps.limit).getMany();
return await this.abuseUserReportEntityService.packMany(reports); return await this.abuseUserReportEntityService.packMany(reports, me);
}); });
} }
} }

View file

@ -123,18 +123,6 @@ export class UrlPreviewService {
request: FastifyRequest<PreviewRoute>, request: FastifyRequest<PreviewRoute>,
reply: FastifyReply, reply: FastifyReply,
): Promise<void> { ): Promise<void> {
const url = request.query.url;
if (typeof url !== 'string' || !URL.canParse(url)) {
reply.code(400);
return;
}
const lang = request.query.lang;
if (Array.isArray(lang)) {
reply.code(400);
return;
}
if (!this.meta.urlPreviewEnabled) { if (!this.meta.urlPreviewEnabled) {
return reply.code(403).send({ return reply.code(403).send({
error: { error: {
@ -145,13 +133,44 @@ export class UrlPreviewService {
}); });
} }
const url = request.query.url;
if (typeof url !== 'string' || !URL.canParse(url)) {
reply.code(400);
return;
}
// Enforce HTTP(S) for input URLs
const urlScheme = this.utilityService.getUrlScheme(url);
if (urlScheme !== 'http:' && urlScheme !== 'https:') {
reply.code(400);
return;
}
const lang = request.query.lang;
if (Array.isArray(lang)) {
reply.code(400);
return;
}
// Strip out hash (anchor)
const urlObj = new URL(url);
if (urlObj.hash) {
urlObj.hash = '';
const params = new URLSearchParams({ url: urlObj.href });
if (lang) params.set('lang', lang);
const newUrl = `/url?${params.toString()}`;
reply.redirect(newUrl, 301);
return;
}
// Check rate limit // Check rate limit
const auth = await this.authenticate(request); const auth = await this.authenticate(request);
if (!await this.checkRateLimit(auth, reply)) { if (!await this.checkRateLimit(auth, reply)) {
return; return;
} }
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) { if (this.utilityService.isBlockedHost(this.meta.blockedHosts, urlObj.host)) {
return reply.code(403).send({ return reply.code(403).send({
error: { error: {
message: 'URL is blocked', message: 'URL is blocked',
@ -166,7 +185,7 @@ export class UrlPreviewService {
return; return;
} }
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`; const cacheKey = getCacheKey(url, lang);
if (await this.sendCachedPreview(cacheKey, reply, fetch)) { if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
return; return;
} }
@ -217,6 +236,18 @@ export class UrlPreviewService {
// Await this to avoid hammering redis when a bunch of URLs are fetched at once // Await this to avoid hammering redis when a bunch of URLs are fetched at once
await this.previewCache.set(cacheKey, summary); await this.previewCache.set(cacheKey, summary);
// Also cache the response URL in case of redirects
if (summary.url !== url) {
const responseCacheKey = getCacheKey(summary.url, lang);
await this.previewCache.set(responseCacheKey, summary);
}
// Also cache the ActivityPub URL, if different from the others
if (summary.activityPub && summary.activityPub !== summary.url) {
const apCacheKey = getCacheKey(summary.activityPub, lang);
await this.previewCache.set(apCacheKey, summary);
}
// Cache 1 day (matching redis), but only once we finalize the result // Cache 1 day (matching redis), but only once we finalize the result
if (!summary.activityPub || summary.haveNoteLocally) { if (!summary.activityPub || summary.haveNoteLocally) {
reply.header('Cache-Control', 'public, max-age=86400'); reply.header('Cache-Control', 'public, max-age=86400');
@ -533,3 +564,7 @@ export class UrlPreviewService {
return true; return true;
} }
} }
function getCacheKey(url: string, lang = 'none') {
return `${url}@${lang}@${cacheFormatVersion}`;
}

View file

@ -367,8 +367,10 @@ describe('AbuseReportNotificationService', () => {
id: idService.gen(), id: idService.gen(),
targetUserId: alice.id, targetUserId: alice.id,
targetUser: alice, targetUser: alice,
targetUserInstance: null,
reporterId: bob.id, reporterId: bob.id,
reporter: bob, reporter: bob,
reporterInstance: null,
assigneeId: null, assigneeId: null,
assignee: null, assignee: null,
resolved: false, resolved: false,

View file

@ -60,6 +60,7 @@
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"moment": "^2.30.1", "moment": "^2.30.1",
"photoswipe": "5.4.4", "photoswipe": "5.4.4",
"promise-limit": "2.7.0",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.40.0", "rollup": "4.40.0",
"sanitize-html": "2.16.0", "sanitize-html": "2.16.0",

View file

@ -33,10 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder :withSpacer="false"> <MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template> <template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template>
<template #suffix>#{{ report.targetUserId.toUpperCase() }}</template> <template #suffix>{{ i18n.ts.id }}# {{ report.targetUserId }}</template>
<div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
<RouterView :router="targetRouter"/> <admin-user :userId="report.targetUserId" :userHint="report.targetUser"></admin-user>
</div>
</MkFolder>
<MkFolder v-if="report.targetInstance" :withSpacer="false">
<template #icon>
<img
v-if="targetInstanceIcon"
:src="targetInstanceIcon"
:alt="i18n.tsx.instanceIconAlt({ name: report.targetInstance.name || report.targetInstance.host })"
:class="$style.instanceIcon"
class="icon"
/>
</template>
<template #label>{{ i18n.ts.instance }}: {{ report.targetInstance.name || report.targetInstance.host }}</template>
<template #suffix>{{ i18n.ts.id }}# {{ report.targetInstance.id }}</template>
<div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
<instance-info :host="report.targetInstance.host" :instanceHint="report.targetInstance" :metaHint="metaHint"></instance-info>
</div> </div>
</MkFolder> </MkFolder>
@ -44,23 +62,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-message-2"></i></template> <template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template> <template #label>{{ i18n.ts.details }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
<Mfm :text="report.comment" :isBlock="true" :linkNavigationBehavior="'window'"/> <Mfm :text="report.comment" :parsedNodes="parsedComment" :isBlock="true" :linkNavigationBehavior="'window'" :author="report.reporter" :nyaize="false" :isAnim="false"/>
<SkUrlPreviewGroup :sourceNodes="parsedComment" :compact="false" :detail="false" :showAsQuote="true"/>
</div> </div>
</MkFolder> </MkFolder>
<MkFolder :withSpacer="false"> <MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template> <template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template> <template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
<template #suffix>#{{ report.reporterId.toUpperCase() }}</template> <template #suffix>{{ i18n.ts.id }}# {{ report.reporterId }}</template>
<div style="height: 300px; --MI-stickyTop: 0; --MI-stickyBottom: 0;"> <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
<RouterView :router="reporterRouter"/> <admin-user :userId="report.reporterId" :userHint="report.reporter"></admin-user>
</div> </div>
</MkFolder> </MkFolder>
<MkFolder :defaultOpen="false"> <MkFolder :defaultOpen="false">
<template #icon><i class="ti ti-message-2"></i></template> <template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.moderationNote }}</template> <template #label>{{ i18n.ts.staffNotes }}</template>
<template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template> <template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
<MkTextarea v-model="moderationNote" manualSave> <MkTextarea v-model="moderationNote" manualSave>
@ -78,8 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { provide, ref, watch } from 'vue'; import { computed, provide, ref, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import * as mfm from '@transfem-org/sfm-js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkKeyValue from '@/components/MkKeyValue.vue'; import MkKeyValue from '@/components/MkKeyValue.vue';
@ -91,6 +111,12 @@ import RouterView from '@/components/global/RouterView.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { createRouter } from '@/router.js'; import { createRouter } from '@/router.js';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy';
import InstanceInfo from '@/pages/instance-info.vue';
import { iAmAdmin } from '@/i';
import { misskeyApi } from '@/utility/misskey-api';
import AdminUser from '@/pages/admin-user.vue';
import SkUrlPreviewGroup from '@/components/SkUrlPreviewGroup.vue';
const props = defineProps<{ const props = defineProps<{
report: Misskey.entities.AdminAbuseUserReportsResponse[number]; report: Misskey.entities.AdminAbuseUserReportsResponse[number];
@ -100,10 +126,27 @@ const emit = defineEmits<{
(ev: 'resolved', reportId: string): void; (ev: 'resolved', reportId: string): void;
}>(); }>();
/*
const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`); const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`);
targetRouter.init(); targetRouter.init();
const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`); const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
reporterRouter.init(); reporterRouter.init();
*/
const parsedComment = computed(() => mfm.parse(props.report.comment));
const metaHint = ref<Misskey.entities.AdminMetaResponse | undefined>(undefined);
const targetInstanceIcon = computed(() => props.report.targetInstance?.faviconUrl
? getProxiedImageUrlNullable(props.report.targetInstance.faviconUrl, 'preview')
: props.report.targetInstance?.iconUrl
? getProxiedImageUrlNullable(props.report.targetInstance.iconUrl, 'preview')
: null);
if (iAmAdmin) {
misskeyApi('admin/meta')
.then(meta => metaHint.value = meta)
.catch(err => console.error('[MkAbuseReport] Error fetching meta:', err));
}
const moderationNote = ref(props.report.moderationNote ?? ''); const moderationNote = ref(props.report.moderationNote ?? '');
@ -150,4 +193,8 @@ function showMenu(ev: MouseEvent) {
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.instanceIcon {
width: 18px;
height: 18px;
}
</style> </style>

View file

@ -99,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
<script lang="ts">
// eslint-disable-next-line import/order
import type { summaly } from '@misskey-dev/summaly';
export type SummalyResult = Awaited<ReturnType<typeof summaly>> & {
haveNoteLocally?: boolean,
linkAttribution?: {
userId: string,
}
};
</script>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
import { url as local } from '@@/js/config.js'; import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js'; import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { maybeMakeRelative } from '@@/js/url.js'; import { maybeMakeRelative } from '@@/js/url.js';
import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { deviceKind } from '@/utility/device-kind.js'; import { deviceKind } from '@/utility/device-kind.js';
@ -119,8 +130,6 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { $i } from '@/i'; import { $i } from '@/i';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
url: string; url: string;
detail?: boolean; detail?: boolean;
@ -128,12 +137,18 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean; showAsQuote?: boolean;
showActions?: boolean; showActions?: boolean;
skipNoteIds?: (string | undefined)[]; skipNoteIds?: (string | undefined)[];
previewHint?: SummalyResult;
noteHint?: Misskey.entities.Note | null;
attributionHint?: Misskey.entities.User | null;
}>(), { }>(), {
detail: false, detail: false,
compact: false, compact: false,
showAsQuote: false, showAsQuote: false,
showActions: true, showActions: true,
skipNoteIds: undefined, skipNoteIds: undefined,
previewHint: undefined,
noteHint: undefined,
attributionHint: undefined,
}); });
const MOBILE_THRESHOLD = 500; const MOBILE_THRESHOLD = 500;
@ -170,12 +185,35 @@ const tweetHeight = ref(150);
const unknownUrl = ref(false); const unknownUrl = ref(false);
const theNote = ref<Misskey.entities.Note | null>(null); const theNote = ref<Misskey.entities.Note | null>(null);
const fetchingTheNote = ref(false); const fetchingTheNote = ref(false);
const fetchingAttribution = ref<Promise<void> | null>(null);
onDeactivated(() => { onDeactivated(() => {
playerEnabled.value = false; playerEnabled.value = false;
}); });
async function fetchNote() { async function fetchAttribution(initial: boolean): Promise<void> {
if (!linkAttribution.value) return;
if (attributionUser.value) return;
if (fetchingAttribution.value) return fetchingAttribution.value;
return fetchingAttribution.value ??= (async (userId: string): Promise<void> => {
try {
if (initial && props.attributionHint !== undefined) {
attributionUser.value = props.attributionHint;
} else {
attributionUser.value = await misskeyApi('users/show', { userId });
}
} catch {
// makes the loading ellipsis vanish.
linkAttribution.value = null;
} finally {
// Reset promise to mark as done
fetchingAttribution.value = null;
}
})(linkAttribution.value.userId);
}
async function fetchNote(initial: boolean) {
if (!props.showAsQuote) return; if (!props.showAsQuote) return;
if (!activityPub.value) return; if (!activityPub.value) return;
if (theNote.value) return; if (theNote.value) return;
@ -183,8 +221,15 @@ async function fetchNote() {
fetchingTheNote.value = true; fetchingTheNote.value = true;
try { try {
const response = await misskeyApi('ap/show', { uri: activityPub.value }); const response = (initial && props.noteHint !== undefined)
? { type: 'Note', object: props.noteHint }
: await misskeyApi('ap/show', { uri: activityPub.value });
if (response.type !== 'Note') return; if (response.type !== 'Note') return;
if (!response.object) {
activityPub.value = null;
theNote.value = null;
return;
}
const theNoteId = response['object'].id; const theNoteId = response['object'].id;
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) { if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
hidePreview.value = true; hidePreview.value = true;
@ -210,13 +255,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi
if (m) tweetId.value = m[1]; if (m) tweetId.value = m[1];
} }
// This is now handled on the backend
/*
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com'; requestUrl.hostname = 'www.youtube.com';
} }
requestUrl.hash = ''; requestUrl.hash = '';
*/
function refresh(withFetch = false) { function refresh(withFetch = false, initial = false) {
const params = new URLSearchParams({ const params = new URLSearchParams({
url: requestUrl.href, url: requestUrl.href,
lang: versatileLang, lang: versatileLang,
@ -226,23 +274,21 @@ function refresh(withFetch = false) {
} }
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers }) const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint)
.then(res => { ? Promise.resolve(props.previewHint)
if (!res.ok) { : window.fetch(`/url?${params.toString()}`, { headers })
if (_DEV_) { .then(res => {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`); if (!res.ok) {
if (_DEV_) {
console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
}
return null;
} }
return null;
}
return res.json(); return res.json();
}) });
.then(async (info: SummalyResult & { return fetching.value ??= fetchPromise
haveNoteLocally?: boolean, .then(async (info: SummalyResult | null) => {
linkAttribution?: {
userId: string,
}
} | null) => {
unknownUrl.value = info == null; unknownUrl.value = info == null;
title.value = info?.title ?? null; title.value = info?.title ?? null;
description.value = info?.description ?? null; description.value = info?.description ?? null;
@ -258,20 +304,15 @@ function refresh(withFetch = false) {
sensitive.value = info?.sensitive ?? false; sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? null; activityPub.value = info?.activityPub ?? null;
linkAttribution.value = info?.linkAttribution ?? null; linkAttribution.value = info?.linkAttribution ?? null;
if (linkAttribution.value) {
try {
const response = await misskeyApi('users/show', { userId: linkAttribution.value.userId });
attributionUser.value = response;
} catch {
// makes the loading ellipsis vanish.
linkAttribution.value = null;
}
}
// These will be populated by the fetch* functions
attributionUser.value = null;
theNote.value = null; theNote.value = null;
if (info?.haveNoteLocally) {
await fetchNote(); await Promise.all([
} fetchAttribution(initial),
fetchNote(initial),
]);
}) })
.finally(() => { .finally(() => {
fetching.value = null; fetching.value = null;
@ -304,7 +345,7 @@ onUnmounted(() => {
}); });
// Load initial data // Load initial data
refresh(); refresh(false, true);
</script> </script>
<style lang="scss" module> <style lang="scss" module>
@ -388,7 +429,7 @@ refresh();
.body { .body {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
padding: 16px; padding: 16px !important; // Unfortunately needed to win a specificity race with MkNoteSimple / SkNoteSimple
} }
.header { .header {

View file

@ -0,0 +1,55 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<template v-for="(item, index) in timeline" :key="item.id">
<slot v-if="item.type === 'item'" :id="item.id" :index="index" :item="item.data"></slot>
<slot v-else-if="item.type === 'date'" :id="item.id" :index="index" :prev="item.prev" :prevText="item.prevText" :next="item.next" :nextText="item.nextText" name="date">
<div :class="$style.dateDivider">
<span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
<span :class="$style.dateSeparator"></span>
<span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
</div>
</slot>
</template>
</div>
</template>
<script setup lang="ts" generic="T extends { id: string; createdAt: string; }">
import { computed } from 'vue';
import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate';
const props = defineProps<{
items: T[],
}>();
const itemsRef = computed(() => props.items);
const timeline = makeDateSeparatedTimelineComputedRef(itemsRef);
</script>
<style module lang="scss">
// From room.vue
.dateDivider {
display: flex;
font-size: 85%;
align-items: center;
justify-content: center;
gap: 0.5em;
opacity: 0.75;
border: solid 0.5px var(--MI_THEME-divider);
border-radius: 999px;
width: fit-content;
padding: 0.5em 1em;
margin: 0 auto;
}
// From room.vue
.dateSeparator {
height: 1em;
width: 1px;
background: var(--MI_THEME-divider);
}
</style>

View file

@ -0,0 +1,348 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="isRefreshing">
<MkLoading :class="$style.loading"></MkLoading>
</div>
<template v-else>
<MkUrlPreview
v-for="preview of urlPreviews"
:key="preview.url"
:url="preview.url"
:previewHint="preview"
:noteHint="preview.note"
:attributionHint="preview.attributionUser"
:detail="detail"
:compact="compact"
:showAsQuote="showAsQuote"
:showActions="showActions"
:skipNoteIds="skipNoteIds"
></MkUrlPreview>
</template>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import * as mfm from '@transfem-org/sfm-js';
import { computed, ref, watch } from 'vue';
import { versatileLang } from '@@/js/intl-const';
import promiseLimit from 'promise-limit';
import type { SummalyResult } from '@/components/MkUrlPreview.vue';
import { extractPreviewUrls } from '@/utility/extract-preview-urls';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
import { $i } from '@/i';
import { misskeyApi } from '@/utility/misskey-api';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { getNoteUrls } from '@/utility/getNoteUrls';
type Summary = SummalyResult & {
note?: Misskey.entities.Note | null;
attributionUser?: Misskey.entities.User | null;
};
type Limiter<T> = ReturnType<typeof promiseLimit<T>>;
const props = withDefaults(defineProps<{
sourceUrls?: string[];
sourceNodes?: mfm.MfmNode[];
sourceText?: string;
sourceNote?: Misskey.entities.Note;
detail?: boolean;
compact?: boolean;
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: string[];
}>(), {
sourceUrls: undefined,
sourceText: undefined,
sourceNodes: undefined,
sourceNote: undefined,
detail: undefined,
compact: undefined,
showAsQuote: undefined,
showActions: undefined,
skipNoteIds: () => [],
});
const urlPreviews = ref<Summary[]>([]);
const urls = computed<string[]>(() => {
if (props.sourceUrls) {
return props.sourceUrls;
}
// sourceNodes > sourceText > sourceNote
const source =
props.sourceNodes ??
(props.sourceText ? mfm.parse(props.sourceText) : null) ??
(props.sourceNote?.text ? mfm.parse(props.sourceNote.text) : null);
if (source) {
if (props.sourceNote) {
return extractPreviewUrls(props.sourceNote, source);
} else {
return extractUrlFromMfm(source);
}
}
return [];
});
// todo un-ref these
const isRefreshing = ref<Promise<void> | false>(false);
const cachedNotes = ref(new Map<string, Misskey.entities.Note | null>());
const cachedPreviews = ref(new Map<string, Summary | null>());
const cachedUsers = new Map<string, Misskey.entities.User | null>();
/**
* Refreshes the group.
* Calls are automatically de-duplicated.
*/
function refresh(): Promise<void> {
if (isRefreshing.value) return isRefreshing.value;
const promise = doRefresh();
promise.finally(() => isRefreshing.value = false);
isRefreshing.value = promise;
return promise;
}
/**
* Refreshes the group.
* Don't call this directly - use refresh() instead!
*/
async function doRefresh(): Promise<void> {
let previews = await fetchPreviews();
// Remove duplicates
previews = deduplicatePreviews(previews);
// Remove any with hidden notes
previews = previews.filter(preview => !preview.note || !props.skipNoteIds.includes(preview.note.id));
urlPreviews.value = previews;
}
async function fetchPreviews(): Promise<Summary[]> {
const userLimiter = promiseLimit<Misskey.entities.User | null>(4);
const noteLimiter = promiseLimit<Misskey.entities.Note | null>(2);
const summaryLimiter = promiseLimit<Summary | null>(5);
const summaries = await Promise.all(urls.value.map(url =>
summaryLimiter(async () => {
return await fetchPreview(url);
}).then(async (summary) => {
if (summary) {
await Promise.all([
attachNote(summary, noteLimiter),
attachAttribution(summary, userLimiter),
]);
}
return summary;
})));
return summaries.filter((preview): preview is Summary => preview != null);
}
async function fetchPreview(url: string): Promise<Summary | null> {
const cached = cachedPreviews.value.get(url);
if (cached) {
return cached;
}
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
const params = new URLSearchParams({ url, lang: versatileLang });
const res = await window.fetch(`/url?${params.toString()}`, { headers }).catch(() => null);
if (res?.ok) {
// Success - got the summary
const summary: Summary = await res.json();
cachedPreviews.value.set(url, summary);
if (summary.url !== url) {
cachedPreviews.value.set(summary.url, summary);
}
return summary;
}
// Failed, blocked, or not found
cachedPreviews.value.set(url, null);
return null;
}
async function attachNote(summary: Summary, noteLimiter: Limiter<Misskey.entities.Note | null>): Promise<void> {
if (props.showAsQuote && summary.activityPub && summary.haveNoteLocally) {
// Have to pull this out to make TS happy
const noteUri = summary.activityPub;
summary.note = await noteLimiter(async () => {
return await fetchNote(noteUri);
});
}
}
async function fetchNote(noteUri: string): Promise<Misskey.entities.Note | null> {
const cached = cachedNotes.value.get(noteUri);
if (cached) {
return cached;
}
const response = await misskeyApi('ap/show', { uri: noteUri }).catch(() => null);
if (response && response.type === 'Note') {
const note = response['object'];
// Success - got the note
cachedNotes.value.set(noteUri, note);
if (note.uri && note.uri !== noteUri) {
cachedNotes.value.set(note.uri, note);
}
return note;
}
// Failed, blocked, or not found
cachedNotes.value.set(noteUri, null);
return null;
}
async function attachAttribution(summary: Summary, userLimiter: Limiter<Misskey.entities.User | null>): Promise<void> {
if (summary.linkAttribution) {
// Have to pull this out to make TS happy
const userId = summary.linkAttribution.userId;
summary.attributionUser = await userLimiter(async () => {
return await fetchUser(userId);
});
}
}
async function fetchUser(userId: string): Promise<Misskey.entities.User | null> {
const cached = cachedUsers.get(userId);
if (cached) {
return cached;
}
const user = await misskeyApi('users/show', { userId }).catch(() => null);
cachedUsers.set(userId, user);
return user;
}
function deduplicatePreviews(previews: Summary[]): Summary[] {
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews with duplicate URL
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip differing URLs (not duplicate).
if (p.url !== preview.url) return false;
// Skip if we have AP and the other doesn't
if (preview.activityPub && !p.activityPub) return false;
// Skip if we have a note and the other doesn't
if (preview.note && !p.note) return false;
// Skip later previews (keep the earliest instance)...
// ...but only if we have AP or the later one doesn't...
// ...and only if we have note or the later one doesn't.
if (i > index && (preview.activityPub || !p.activityPub) && (preview.note || !p.note)) return false;
// If we get here, then "preview" is a duplicate of "p" and should be skipped.
return true;
}));
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews with duplicate AP
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip if we don't have AP
if (!preview.activityPub) return false;
// Skip if other does not have AP
if (!p.activityPub) return false;
// Skip differing URLs (not duplicate).
if (p.activityPub !== preview.activityPub) return false;
// Skip later previews (keep the earliest instance)
if (i > index) return false;
// If we get here, then "preview" is a duplicate of "p" and should be skipped.
return true;
}));
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews with duplicate note
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip if we don't have a note
if (!preview.note) return false;
// Skip if other does not have a note
if (!p.note) return false;
// Skip differing notes (not duplicate).
if (p.note.id !== preview.note.id) return false;
// Skip later previews (keep the earliest instance)
if (i > index) return false;
// If we get here, then "preview" is a duplicate of "p" and should be skipped.
return true;
}));
// eslint-disable-next-line no-param-reassign
previews = previews
// Remove any previews where the note duplicates url
.filter((preview, index) => !previews.some((p, i) => {
// Skip the current preview (don't count self as duplicate).
if (p === preview) return false;
// Skip if we have a note
if (preview.note) return false;
// Skip if other does not have a note
if (!p.note) return false;
// Skip later previews (keep the earliest instance)
if (i > index) return false;
const noteUrls = getNoteUrls(p.note);
// Remove if other duplicates our AP URL
if (preview.activityPub && noteUrls.includes(preview.activityPub)) return true;
// Remove if other duplicates our main URL
return noteUrls.includes(preview.url);
}));
return previews;
}
// Kick everything off, and watch for changes.
watch(
[urls, () => props.showAsQuote, () => props.skipNoteIds],
() => refresh(),
{ immediate: true },
);
</script>
<style module lang="scss">
.loading {
box-shadow: 0 0 0 1px var(--MI_THEME-divider);
border-radius: var(--MI-radius-sm);
}
</style>

View file

@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> <div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template> <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps" :class="{ _spacer: spacer }"/></template>
<div :class="$style.body"> <div :class="[ $style.body, { _spacer: spacer } ]">
<MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page"> <MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page">
<slot></slot> <slot></slot>
</MkSwiper> </MkSwiper>
@ -30,13 +30,16 @@ const props = withDefaults(defineProps<PageHeaderProps & {
reversed?: boolean; reversed?: boolean;
swipable?: boolean; swipable?: boolean;
page?: string; page?: string;
spacer?: boolean;
}>(), { }>(), {
reversed: false, reversed: false,
swipable: true, swipable: true,
page: undefined,
spacer: false,
}); });
const pageHeaderProps = computed(() => { const pageHeaderProps = computed(() => {
const { reversed, ...rest } = props; const { reversed, spacer, ...rest } = props;
return rest; return rest;
}); });

View file

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> <div>
<FormSuspense :p="init"> <FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_gaps"> <div v-if="tab === 'overview'" class="_gaps">
<div v-if="user" class="aeakzknw"> <div v-if="user" class="aeakzknw">
@ -273,8 +273,14 @@ import SkBadgeStrip from '@/components/SkBadgeStrip.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
userId: string; userId: string;
initialTab?: string; initialTab?: string;
userHint?: Misskey.entities.UserDetailed;
infoHint?: Misskey.entities.AdminShowUserResponse;
ipsHint?: Misskey.entities.AdminGetUserIpsResponse;
}>(), { }>(), {
initialTab: 'overview', initialTab: 'overview',
userHint: undefined,
infoHint: undefined,
ipsHint: undefined,
}); });
const tab = ref(props.initialTab); const tab = ref(props.initialTab);
@ -405,16 +411,23 @@ const announcementsPagination = {
}; };
const expandedRoles = ref([]); const expandedRoles = ref([]);
function createFetcher() { function createFetcher(withHint = true) {
return () => Promise.all([misskeyApi('users/show', { return () => Promise.all([
userId: props.userId, (withHint && props.userHint) ? props.userHint : misskeyApi('users/show', {
}), misskeyApi('admin/show-user', { userId: props.userId,
userId: props.userId, }),
}), iAmAdmin ? misskeyApi('admin/get-user-ips', { (withHint && props.infoHint) ? props.infoHint : misskeyApi('admin/show-user', {
userId: props.userId, userId: props.userId,
}) : Promise.resolve(null), iAmAdmin ? misskeyApi('ap/get', { }),
uri: `${url}/users/${props.userId}`, iAmAdmin
}).catch(() => null) : null]).then(([_user, _info, _ips, _ap]) => { ? (withHint && props.ipsHint) ? props.ipsHint : misskeyApi('admin/get-user-ips', {
userId: props.userId,
})
: null,
iAmAdmin ? misskeyApi('ap/get', {
uri: `${url}/users/${props.userId}`,
}).catch(() => null) : null],
).then(([_user, _info, _ips, _ap]) => {
user.value = _user; user.value = _user;
info.value = _info; info.value = _info;
ips.value = _ips; ips.value = _ips;
@ -432,7 +445,7 @@ function createFetcher() {
async function refreshUser() { async function refreshUser() {
// Not a typo - createFetcher() returns a function() // Not a typo - createFetcher() returns a function()
await createFetcher()(); await createFetcher(false)();
} }
async function onMandatoryCWChanged(value: string) { async function onMandatoryCWChanged(value: string) {

View file

@ -48,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50"> <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50">
<div class="_gaps"> <SkDateSeparatedList v-slot="{ item: report }" :items="items">
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> <XAbuseReport :report="report" @resolved="resolved"/>
</div> </SkDateSeparatedList>
</MkPagination> </MkPagination>
</div> </div>
</div> </div>
@ -67,6 +67,7 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { store } from '@/store.js'; import { store } from '@/store.js';
import SkDateSeparatedList from '@/components/SkDateSeparatedList.vue';
const reports = useTemplateRef('reports'); const reports = useTemplateRef('reports');

View file

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> <div v-if="instance">
<!-- This empty div is preserved to avoid merge conflicts --> <!-- This empty div is preserved to avoid merge conflicts -->
<div> <div>
<div v-if="tab === 'overview'" class="_gaps"> <div v-if="tab === 'overview'" class="_gaps">
@ -238,9 +238,14 @@ import SkBadgeStrip from '@/components/SkBadgeStrip.vue';
const $style = useCssModule(); const $style = useCssModule();
const props = defineProps<{ const props = withDefaults(defineProps<{
host: string; host: string;
}>(); metaHint?: Misskey.entities.AdminMetaResponse;
instanceHint?: Misskey.entities.FederationInstance;
}>(), {
metaHint: undefined,
instanceHint: undefined,
});
const tab = ref('overview'); const tab = ref('overview');
@ -363,12 +368,16 @@ async function saveModerationNote() {
} }
} }
async function fetch(): Promise<void> { async function fetch(withHint = false): Promise<void> {
const [m, i] = await Promise.all([ const [m, i] = await Promise.all([
iAmAdmin ? misskeyApi('admin/meta') : null, (withHint && props.metaHint)
misskeyApi('federation/show-instance', { ? props.metaHint
host: props.host, : iAmAdmin ? misskeyApi('admin/meta') : null,
}), (withHint && props.instanceHint)
? props.instanceHint
: misskeyApi('federation/show-instance', {
host: props.host,
}),
]); ]);
meta.value = m; meta.value = m;
instance.value = i; instance.value = i;
@ -531,7 +540,7 @@ async function severAllFollowRelations(): Promise<void> {
}); });
} }
fetch(); fetch(true);
const headerActions = computed(() => [{ const headerActions = computed(() => [{
text: `https://${props.host}`, text: `https://${props.host}`,

View file

@ -3,35 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as config from '@@/js/config.js';
import type * as Misskey from 'misskey-js'; import type * as Misskey from 'misskey-js';
import type * as mfm from '@transfem-org/sfm-js'; import type * as mfm from '@transfem-org/sfm-js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import { getNoteUrls } from '@/utility/getNoteUrls';
/** /**
* Extracts all previewable URLs from a note. * Extracts all previewable URLs from a note.
*/ */
export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] { export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] {
const links = extractUrlFromMfm(contents); const links = extractUrlFromMfm(contents);
return links.filter(url => if (links.length < 0) return [];
// Remote note
url !== note.url && const self = getNoteUrls(note);
url !== note.uri && return links.filter(url => !self.includes(url));
// Local note
url !== `${config.url}/notes/${note.id}` &&
// Remote reply
url !== note.reply?.url &&
url !== note.reply?.uri &&
// Local reply
url !== `${config.url}/notes/${note.reply?.id}` &&
// Remote renote or quote
url !== note.renote?.url &&
url !== note.renote?.uri &&
// Local renote or quote
url !== `${config.url}/notes/${note.renote?.id}` &&
// Remote renote *of* a quote
url !== note.renote?.renote?.url &&
url !== note.renote?.renote?.uri &&
// Local renote *of* a quote
url !== `${config.url}/notes/${note.renote?.renote?.id}`);
} }

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as config from '@@/js/config.js';
import type * as Misskey from 'misskey-js';
export function getNoteUrls(note: Misskey.entities.Note): string[] {
const urls: string[] = [
// Any note
`${config.url}/notes/${note.id}`,
];
// Remote note
if (note.url) urls.push(note.url);
if (note.uri) urls.push(note.uri);
if (note.reply) {
// Any Reply
urls.push(`${config.url}/notes/${note.reply.id}`);
// Remote Reply
if (note.reply.url) urls.push(note.reply.url);
if (note.reply.uri) urls.push(note.reply.uri);
}
if (note.renote) {
// Any Renote
urls.push(`${config.url}/notes/${note.renote.id}`);
// Remote Renote
if (note.renote.url) urls.push(note.renote.url);
if (note.renote.uri) urls.push(note.renote.uri);
}
if (note.renote?.renote) {
// Any Quote
urls.push(`${config.url}/notes/${note.renote.renote.id}`);
// Remote Quote
if (note.renote.renote.url) urls.push(note.renote.renote.url);
if (note.renote.renote.uri) urls.push(note.renote.renote.uri);
}
return urls;
}

View file

@ -4,7 +4,7 @@
*/ */
import { computed } from 'vue'; import { computed } from 'vue';
import type { Ref } from 'vue'; import type { Ref, ComputedRef } from 'vue';
export function getDateText(dateInstance: Date) { export function getDateText(dateInstance: Date) {
const date = dateInstance.getDate(); const date = dateInstance.getDate();
@ -25,7 +25,7 @@ export type DateSeparetedTimelineItem<T> = {
nextText: string; nextText: string;
}; };
export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ComputedRef<T[]>) {
return computed<DateSeparetedTimelineItem<T>[]>(() => { return computed<DateSeparetedTimelineItem<T>[]>(() => {
const tl: DateSeparetedTimelineItem<T>[] = []; const tl: DateSeparetedTimelineItem<T>[] = [];
for (let i = 0; i < items.value.length; i++) { for (let i = 0; i < items.value.length; i++) {

View file

@ -6198,6 +6198,7 @@ export type operations = {
assigneeId: string | null; assigneeId: string | null;
reporter: components['schemas']['UserDetailedNotMe']; reporter: components['schemas']['UserDetailedNotMe'];
targetUser: components['schemas']['UserDetailedNotMe']; targetUser: components['schemas']['UserDetailedNotMe'];
targetInstance: components['schemas']['FederationInstance'] | null;
assignee: components['schemas']['UserDetailedNotMe'] | null; assignee: components['schemas']['UserDetailedNotMe'] | null;
forwarded: boolean; forwarded: boolean;
/** @enum {string|null} */ /** @enum {string|null} */

17
pnpm-lock.yaml generated
View file

@ -847,6 +847,9 @@ importers:
photoswipe: photoswipe:
specifier: 5.4.4 specifier: 5.4.4
version: 5.4.4 version: 5.4.4
promise-limit:
specifier: 2.7.0
version: 2.7.0
punycode.js: punycode.js:
specifier: 2.3.1 specifier: 2.3.1
version: 2.3.1 version: 2.3.1
@ -1013,7 +1016,7 @@ importers:
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3) version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 3.1.2 specifier: 3.1.2
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
'@vue/compiler-core': '@vue/compiler-core':
specifier: 3.5.14 specifier: 3.5.14
version: 3.5.14 version: 3.5.14
@ -1082,7 +1085,7 @@ importers:
version: 1.0.3 version: 1.0.3
vitest: vitest:
specifier: 3.1.2 specifier: 3.1.2
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest-fetch-mock: vitest-fetch-mock:
specifier: 0.4.5 specifier: 0.4.5
version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
@ -1209,7 +1212,7 @@ importers:
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3) version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 3.1.2 specifier: 3.1.2
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
'@vue/runtime-core': '@vue/runtime-core':
specifier: 3.5.14 specifier: 3.5.14
version: 3.5.14 version: 3.5.14
@ -14749,7 +14752,7 @@ snapshots:
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vue: 3.5.14(typescript@5.8.3) vue: 3.5.14(typescript@5.8.3)
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))': '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
@ -14763,7 +14766,7 @@ snapshots:
std-env: 3.9.0 std-env: 3.9.0
test-exclude: 7.0.1 test-exclude: 7.0.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -21931,9 +21934,9 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)): vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)):
dependencies: dependencies:
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3) vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3): vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
dependencies: dependencies:
'@vitest/expect': 3.1.2 '@vitest/expect': 3.1.2
'@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)) '@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))

View file

@ -598,6 +598,9 @@ roleAutomatic: "automatic"
translationTimeoutLabel: "Translation timeout" translationTimeoutLabel: "Translation timeout"
translationTimeoutCaption: "Timeout in milliseconds for translation API requests." translationTimeoutCaption: "Timeout in milliseconds for translation API requests."
staffNotes: "Staff notes"
instanceIconAlt: "Icon of {name}"
attributionDomains: "Attribution Domains" attributionDomains: "Attribution Domains"
attributionDomainsDescription: "A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:" attributionDomainsDescription: "A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:"
writtenBy: "Written by {user}" writtenBy: "Written by {user}"