summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-06-05 08:00:32 +0000
committerHazelnoot <acomputerdog@gmail.com>2025-06-05 08:00:32 +0000
commitf88253b95f5ed16f23a23796f76ba4e8ea5f99b3 (patch)
treebec1ce21bd1b2de423b110a74f4c0dd4199583d4 /packages
parentmerge: Add option to keep CWs with "RE:" prefix (!1093) (diff)
parentsupport link attributions in SkUrlPreviewGroup (diff)
downloadsharkey-f88253b95f5ed16f23a23796f76ba4e8ea5f99b3.tar.gz
sharkey-f88253b95f5ed16f23a23796f76ba4e8ea5f99b3.tar.bz2
sharkey-f88253b95f5ed16f23a23796f76ba4e8ea5f99b3.zip
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>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/src/core/ReversiService.ts2
-rw-r--r--packages/backend/src/core/WebhookTestService.ts3
-rw-r--r--packages/backend/src/core/entities/AbuseUserReportEntityService.ts36
-rw-r--r--packages/backend/src/core/entities/InstanceEntityService.ts29
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts34
-rw-r--r--packages/backend/src/models/AbuseUserReport.ts21
-rw-r--r--packages/backend/src/models/User.ts4
-rw-r--r--packages/backend/src/models/UserProfile.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts17
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts55
-rw-r--r--packages/backend/test/unit/AbuseReportNotificationService.ts2
-rw-r--r--packages/frontend/package.json1
-rw-r--r--packages/frontend/src/components/MkAbuseReport.vue65
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue111
-rw-r--r--packages/frontend/src/components/SkDateSeparatedList.vue55
-rw-r--r--packages/frontend/src/components/SkUrlPreviewGroup.vue348
-rw-r--r--packages/frontend/src/components/global/PageWithHeader.vue9
-rw-r--r--packages/frontend/src/pages/admin-user.vue39
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue7
-rw-r--r--packages/frontend/src/pages/instance-info.vue29
-rw-r--r--packages/frontend/src/utility/extract-preview-urls.ts27
-rw-r--r--packages/frontend/src/utility/getNoteUrls.ts44
-rw-r--r--packages/frontend/src/utility/timeline-date-separate.ts4
-rw-r--r--packages/misskey-js/src/autogen/types.ts1
24 files changed, 821 insertions, 124 deletions
diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts
index e31d9e5b1a..b57ab6d9cb 100644
--- a/packages/backend/src/core/ReversiService.ts
+++ b/packages/backend/src/core/ReversiService.ts
@@ -588,6 +588,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
instance: null,
+ userProfile: null,
} : null,
user2: parsed.user2 != null ? {
...parsed.user2,
@@ -599,6 +600,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
instance: null,
+ userProfile: null,
} : null,
};
} else {
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 8c1508df24..c4b01d535b 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -77,6 +77,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
mandatoryCW: null,
rejectQuotes: false,
allowUnsignedFetch: 'staff',
+ userProfile: null,
attributionDomains: [],
...override,
};
@@ -363,8 +364,10 @@ export class WebhookTestService {
id: 'dummy-abuse-report1',
targetUserId: 'dummy-target-user',
targetUser: null,
+ targetUserInstance: null,
reporterId: 'dummy-reporter-user',
reporter: null,
+ reporterInstance: null,
assigneeId: null,
assignee: null,
resolved: false,
diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts
index 70ead890ab..c1d877aa12 100644
--- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts
+++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts
@@ -5,13 +5,14 @@
import { Inject, Injectable } from '@nestjs/common';
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 type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { Packed } from '@/misc/json-schema.js';
import { UserEntityService } from './UserEntityService.js';
+import { InstanceEntityService } from './InstanceEntityService.js';
@Injectable()
export class AbuseUserReportEntityService {
@@ -19,6 +20,10 @@ export class AbuseUserReportEntityService {
@Inject(DI.abuseUserReportsRepository)
private abuseUserReportsRepository: AbuseUserReportsRepository,
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
+ private readonly instanceEntityService: InstanceEntityService,
private userEntityService: UserEntityService,
private idService: IdService,
) {
@@ -30,11 +35,14 @@ export class AbuseUserReportEntityService {
hint?: {
packedReporter?: Packed<'UserDetailedNotMe'>,
packedTargetUser?: Packed<'UserDetailedNotMe'>,
+ packedTargetInstance?: Packed<'FederationInstance'>,
packedAssignee?: Packed<'UserDetailedNotMe'>,
},
+ me?: MiUser | null,
) {
const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src });
+ // noinspection ES6MissingAwait
return await awaitAll({
id: report.id,
createdAt: this.idService.parse(report.id).date.toISOString(),
@@ -43,13 +51,22 @@ export class AbuseUserReportEntityService {
reporterId: report.reporterId,
targetUserId: report.targetUserId,
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',
}),
- targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, {
+ targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, me, {
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',
}) : null,
forwarded: report.forwarded,
@@ -61,21 +78,28 @@ export class AbuseUserReportEntityService {
@bindThis
public async packMany(
reports: MiAbuseUserReport[],
+ me?: MiUser | null,
) {
const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId);
const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId);
const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null);
const _userMap = await this.userEntityService.packMany(
[..._reporters, ..._targetUsers, ..._assignees],
- null,
+ me,
{ schema: 'UserDetailedNotMe' },
).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(
reports.map(report => {
const packedReporter = _userMap.get(report.reporterId);
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;
- return this.pack(report, { packedReporter, packedTargetUser, packedAssignee });
+ return this.pack(report, { packedReporter, packedTargetUser, packedAssignee, packedTargetInstance }, me);
}),
);
}
diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts
index a2ee4b0505..4ca4ff650b 100644
--- a/packages/backend/src/core/entities/InstanceEntityService.ts
+++ b/packages/backend/src/core/entities/InstanceEntityService.ts
@@ -4,6 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
+import { In } from 'typeorm';
import type { Packed } from '@/misc/json-schema.js';
import type { MiInstance } from '@/models/Instance.js';
import { bindThis } from '@/decorators.js';
@@ -11,7 +12,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { RoleService } from '@/core/RoleService.js';
import { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
-import { MiMeta } from '@/models/_.js';
+import type { InstancesRepository, MiMeta } from '@/models/_.js';
@Injectable()
export class InstanceEntityService {
@@ -19,6 +20,9 @@ export class InstanceEntityService {
@Inject(DI.meta)
private meta: MiMeta,
+ @Inject(DI.instancesRepository)
+ private readonly instancesRepository: InstancesRepository,
+
private roleService: RoleService,
private utilityService: UtilityService,
@@ -73,5 +77,28 @@ export class InstanceEntityService {
) {
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;
+ }
}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 3524119ba1..326baaefd4 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -487,7 +487,10 @@ export class UserEntityService implements OnModuleInit {
includeSecrets: false,
}, 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
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 profile = isDetailed
- ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
+ ? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
: 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 :
(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');
if (_users.length !== users.length) {
_users.push(
- ...await this.usersRepository.findBy({
- id: In(users.filter((user): user is string => typeof user === 'string')),
+ ...await this.usersRepository.find({
+ 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();
if (options?.schema !== 'UserLite') {
- profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) })
- .then(profiles => new Map(profiles.map(p => [p.userId, p])));
+ const _profiles: MiUserProfile[] = [];
+ 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;
if (meId) {
diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts
index d43ebf9342..8f8d759004 100644
--- a/packages/backend/src/models/AbuseUserReport.ts
+++ b/packages/backend/src/models/AbuseUserReport.ts
@@ -4,6 +4,7 @@
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@@ -88,11 +89,31 @@ export class MiAbuseUserReport {
})
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()
@Column('varchar', {
length: 128, nullable: true,
comment: '[Denormalized]',
})
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
}
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 3ef5817672..2f13400944 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -8,6 +8,7 @@ import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.
import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js';
import { MiDriveFile } from './DriveFile.js';
+import type { MiUserProfile } from './UserProfile.js';
@Entity('user')
@Index(['usernameLower', 'host'], { unique: true })
@@ -395,6 +396,9 @@ export class MiUser {
})
public attributionDomains: string[];
+ @OneToOne('user_profile', (profile: MiUserProfile) => profile.user)
+ public userProfile: MiUserProfile | null;
+
constructor(data: Partial<MiUser>) {
if (data == null) return;
diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts
index 29c453dd71..6ee72e6ddd 100644
--- a/packages/backend/src/models/UserProfile.ts
+++ b/packages/backend/src/models/UserProfile.ts
@@ -17,7 +17,7 @@ export class MiUserProfile {
@PrimaryColumn(id())
public userId: MiUser['id'];
- @OneToOne(type => MiUser, {
+ @OneToOne(() => MiUser, user => user.userProfile, {
onDelete: 'CASCADE',
})
@JoinColumn()
diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts
index 0dbfaae054..b8200c09aa 100644
--- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts
+++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts
@@ -69,6 +69,11 @@ export const meta = {
nullable: false, optional: false,
ref: 'UserDetailedNotMe',
},
+ targetInstance: {
+ type: 'object',
+ nullable: true, optional: false,
+ ref: 'FederationInstance',
+ },
assignee: {
type: 'object',
nullable: true, optional: false,
@@ -115,7 +120,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
) {
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) {
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();
- return await this.abuseUserReportEntityService.packMany(reports);
+ return await this.abuseUserReportEntityService.packMany(reports, me);
});
}
}
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index 2a300782c6..78b2204fbb 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -123,26 +123,45 @@ export class UrlPreviewService {
request: FastifyRequest<PreviewRoute>,
reply: FastifyReply,
): Promise<void> {
+ if (!this.meta.urlPreviewEnabled) {
+ return reply.code(403).send({
+ error: {
+ message: 'URL preview is disabled',
+ code: 'URL_PREVIEW_DISABLED',
+ id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
+ },
+ });
+ }
+
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;
}
- if (!this.meta.urlPreviewEnabled) {
- return reply.code(403).send({
- error: {
- message: 'URL preview is disabled',
- code: 'URL_PREVIEW_DISABLED',
- id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
- },
- });
+ // 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
@@ -151,7 +170,7 @@ export class UrlPreviewService {
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({
error: {
message: 'URL is blocked',
@@ -166,7 +185,7 @@ export class UrlPreviewService {
return;
}
- const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
+ const cacheKey = getCacheKey(url, lang);
if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
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.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
if (!summary.activityPub || summary.haveNoteLocally) {
reply.header('Cache-Control', 'public, max-age=86400');
@@ -533,3 +564,7 @@ export class UrlPreviewService {
return true;
}
}
+
+function getCacheKey(url: string, lang = 'none') {
+ return `${url}@${lang}@${cacheFormatVersion}`;
+}
diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts
index 6d555326fb..ee68b10f1b 100644
--- a/packages/backend/test/unit/AbuseReportNotificationService.ts
+++ b/packages/backend/test/unit/AbuseReportNotificationService.ts
@@ -367,8 +367,10 @@ describe('AbuseReportNotificationService', () => {
id: idService.gen(),
targetUserId: alice.id,
targetUser: alice,
+ targetUserInstance: null,
reporterId: bob.id,
reporter: bob,
+ reporterInstance: null,
assigneeId: null,
assignee: null,
resolved: false,
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index f5c7bcf1b4..640ebe70d6 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -60,6 +60,7 @@
"misskey-reversi": "workspace:*",
"moment": "^2.30.1",
"photoswipe": "5.4.4",
+ "promise-limit": "2.7.0",
"punycode.js": "2.3.1",
"rollup": "4.40.0",
"sanitize-html": "2.16.0",
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index c52fdb898e..5bf5380a1e 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -33,10 +33,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></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;">
- <RouterView :router="targetRouter"/>
+ <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
+ <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>
</MkFolder>
@@ -44,23 +62,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<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>
</MkFolder>
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></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;">
- <RouterView :router="reporterRouter"/>
+ <div style="--MI-stickyTop: 0; --MI-stickyBottom: 0;">
+ <admin-user :userId="report.reporterId" :userHint="report.reporter"></admin-user>
</div>
</MkFolder>
<MkFolder :defaultOpen="false">
<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>
<div class="_gaps_s">
<MkTextarea v-model="moderationNote" manualSave>
@@ -78,8 +97,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<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 mfm from '@transfem-org/sfm-js';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.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 { copyToClipboard } from '@/utility/copy-to-clipboard.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<{
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
@@ -100,10 +126,27 @@ const emit = defineEmits<{
(ev: 'resolved', reportId: string): void;
}>();
+/*
const targetRouter = createRouter(`/admin/user/${props.report.targetUserId}`);
targetRouter.init();
const reporterRouter = createRouter(`/admin/user/${props.report.reporterId}`);
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 ?? '');
@@ -150,4 +193,8 @@ function showMenu(ev: MouseEvent) {
</script>
<style lang="scss" module>
+.instanceIcon {
+ width: 18px;
+ height: 18px;
+}
</style>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 69a1540600..5d0e6e3df7 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -99,13 +99,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</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>
import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
import { url as local } from '@@/js/config.js';
import { versatileLang } from '@@/js/intl-const.js';
import * as Misskey from 'misskey-js';
import { maybeMakeRelative } from '@@/js/url.js';
-import type { summaly } from '@misskey-dev/summaly';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deviceKind } from '@/utility/device-kind.js';
@@ -119,8 +130,6 @@ import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
import { $i } from '@/i';
import { userPage } from '@/filters/user.js';
-type SummalyResult = Awaited<ReturnType<typeof summaly>>;
-
const props = withDefaults(defineProps<{
url: string;
detail?: boolean;
@@ -128,12 +137,18 @@ const props = withDefaults(defineProps<{
showAsQuote?: boolean;
showActions?: boolean;
skipNoteIds?: (string | undefined)[];
+ previewHint?: SummalyResult;
+ noteHint?: Misskey.entities.Note | null;
+ attributionHint?: Misskey.entities.User | null;
}>(), {
detail: false,
compact: false,
showAsQuote: false,
showActions: true,
skipNoteIds: undefined,
+ previewHint: undefined,
+ noteHint: undefined,
+ attributionHint: undefined,
});
const MOBILE_THRESHOLD = 500;
@@ -170,12 +185,35 @@ const tweetHeight = ref(150);
const unknownUrl = ref(false);
const theNote = ref<Misskey.entities.Note | null>(null);
const fetchingTheNote = ref(false);
+const fetchingAttribution = ref<Promise<void> | null>(null);
onDeactivated(() => {
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 (!activityPub.value) return;
if (theNote.value) return;
@@ -183,8 +221,15 @@ async function fetchNote() {
fetchingTheNote.value = true;
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.object) {
+ activityPub.value = null;
+ theNote.value = null;
+ return;
+ }
const theNoteId = response['object'].id;
if (theNoteId && props.skipNoteIds && props.skipNoteIds.includes(theNoteId)) {
hidePreview.value = true;
@@ -210,13 +255,16 @@ if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twi
if (m) tweetId.value = m[1];
}
+// This is now handled on the backend
+/*
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
requestUrl.hostname = 'www.youtube.com';
}
requestUrl.hash = '';
+*/
-function refresh(withFetch = false) {
+function refresh(withFetch = false, initial = false) {
const params = new URLSearchParams({
url: requestUrl.href,
lang: versatileLang,
@@ -226,23 +274,21 @@ function refresh(withFetch = false) {
}
const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined;
- return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers })
- .then(res => {
- if (!res.ok) {
- if (_DEV_) {
- console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ const fetchPromise: Promise<SummalyResult | null> = (initial && props.previewHint)
+ ? Promise.resolve(props.previewHint)
+ : window.fetch(`/url?${params.toString()}`, { headers })
+ .then(res => {
+ if (!res.ok) {
+ if (_DEV_) {
+ console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ }
+ return null;
}
- return null;
- }
- return res.json();
- })
- .then(async (info: SummalyResult & {
- haveNoteLocally?: boolean,
- linkAttribution?: {
- userId: string,
- }
- } | null) => {
+ return res.json();
+ });
+ return fetching.value ??= fetchPromise
+ .then(async (info: SummalyResult | null) => {
unknownUrl.value = info == null;
title.value = info?.title ?? null;
description.value = info?.description ?? null;
@@ -258,20 +304,15 @@ function refresh(withFetch = false) {
sensitive.value = info?.sensitive ?? false;
activityPub.value = info?.activityPub ?? 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;
- if (info?.haveNoteLocally) {
- await fetchNote();
- }
+
+ await Promise.all([
+ fetchAttribution(initial),
+ fetchNote(initial),
+ ]);
})
.finally(() => {
fetching.value = null;
@@ -304,7 +345,7 @@ onUnmounted(() => {
});
// Load initial data
-refresh();
+refresh(false, true);
</script>
<style lang="scss" module>
@@ -388,7 +429,7 @@ refresh();
.body {
position: relative;
box-sizing: border-box;
- padding: 16px;
+ padding: 16px !important; // Unfortunately needed to win a specificity race with MkNoteSimple / SkNoteSimple
}
.header {
diff --git a/packages/frontend/src/components/SkDateSeparatedList.vue b/packages/frontend/src/components/SkDateSeparatedList.vue
new file mode 100644
index 0000000000..239d0c1939
--- /dev/null
+++ b/packages/frontend/src/components/SkDateSeparatedList.vue
@@ -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>
diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue
new file mode 100644
index 0000000000..32b11d9db4
--- /dev/null
+++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue
@@ -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>
diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue
index d2e59bf4ad..485ea687de 100644
--- a/packages/frontend/src/components/global/PageWithHeader.vue
+++ b/packages/frontend/src/components/global/PageWithHeader.vue
@@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
<MkStickyContainer>
- <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template>
- <div :class="$style.body">
+ <template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps" :class="{ _spacer: spacer }"/></template>
+ <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">
<slot></slot>
</MkSwiper>
@@ -30,13 +30,16 @@ const props = withDefaults(defineProps<PageHeaderProps & {
reversed?: boolean;
swipable?: boolean;
page?: string;
+ spacer?: boolean;
}>(), {
reversed: false,
swipable: true,
+ page: undefined,
+ spacer: false,
});
const pageHeaderProps = computed(() => {
- const { reversed, ...rest } = props;
+ const { reversed, spacer, ...rest } = props;
return rest;
});
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 0596b165fd..3d17aca7d9 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
- <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
+<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>
<FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_gaps">
<div v-if="user" class="aeakzknw">
@@ -273,8 +273,14 @@ import SkBadgeStrip from '@/components/SkBadgeStrip.vue';
const props = withDefaults(defineProps<{
userId: string;
initialTab?: string;
+ userHint?: Misskey.entities.UserDetailed;
+ infoHint?: Misskey.entities.AdminShowUserResponse;
+ ipsHint?: Misskey.entities.AdminGetUserIpsResponse;
}>(), {
initialTab: 'overview',
+ userHint: undefined,
+ infoHint: undefined,
+ ipsHint: undefined,
});
const tab = ref(props.initialTab);
@@ -405,16 +411,23 @@ const announcementsPagination = {
};
const expandedRoles = ref([]);
-function createFetcher() {
- return () => Promise.all([misskeyApi('users/show', {
- userId: props.userId,
- }), misskeyApi('admin/show-user', {
- userId: props.userId,
- }), iAmAdmin ? misskeyApi('admin/get-user-ips', {
- userId: props.userId,
- }) : Promise.resolve(null), iAmAdmin ? misskeyApi('ap/get', {
- uri: `${url}/users/${props.userId}`,
- }).catch(() => null) : null]).then(([_user, _info, _ips, _ap]) => {
+function createFetcher(withHint = true) {
+ return () => Promise.all([
+ (withHint && props.userHint) ? props.userHint : misskeyApi('users/show', {
+ userId: props.userId,
+ }),
+ (withHint && props.infoHint) ? props.infoHint : misskeyApi('admin/show-user', {
+ userId: props.userId,
+ }),
+ iAmAdmin
+ ? (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;
info.value = _info;
ips.value = _ips;
@@ -432,7 +445,7 @@ function createFetcher() {
async function refreshUser() {
// Not a typo - createFetcher() returns a function()
- await createFetcher()();
+ await createFetcher(false)();
}
async function onMandatoryCWChanged(value: string) {
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index 4ec4372492..6531a3e49d 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -48,9 +48,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50">
- <div class="_gaps">
- <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
- </div>
+ <SkDateSeparatedList v-slot="{ item: report }" :items="items">
+ <XAbuseReport :report="report" @resolved="resolved"/>
+ </SkDateSeparatedList>
</MkPagination>
</div>
</div>
@@ -67,6 +67,7 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import { store } from '@/store.js';
+import SkDateSeparatedList from '@/components/SkDateSeparatedList.vue';
const reports = useTemplateRef('reports');
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index b60bdf3a72..3f14cdabbe 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
- <div v-if="instance" class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
+<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">
<!-- This empty div is preserved to avoid merge conflicts -->
<div>
<div v-if="tab === 'overview'" class="_gaps">
@@ -238,9 +238,14 @@ import SkBadgeStrip from '@/components/SkBadgeStrip.vue';
const $style = useCssModule();
-const props = defineProps<{
+const props = withDefaults(defineProps<{
host: string;
-}>();
+ metaHint?: Misskey.entities.AdminMetaResponse;
+ instanceHint?: Misskey.entities.FederationInstance;
+}>(), {
+ metaHint: undefined,
+ instanceHint: undefined,
+});
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([
- iAmAdmin ? misskeyApi('admin/meta') : null,
- misskeyApi('federation/show-instance', {
- host: props.host,
- }),
+ (withHint && props.metaHint)
+ ? props.metaHint
+ : iAmAdmin ? misskeyApi('admin/meta') : null,
+ (withHint && props.instanceHint)
+ ? props.instanceHint
+ : misskeyApi('federation/show-instance', {
+ host: props.host,
+ }),
]);
meta.value = m;
instance.value = i;
@@ -531,7 +540,7 @@ async function severAllFollowRelations(): Promise<void> {
});
}
-fetch();
+fetch(true);
const headerActions = computed(() => [{
text: `https://${props.host}`,
diff --git a/packages/frontend/src/utility/extract-preview-urls.ts b/packages/frontend/src/utility/extract-preview-urls.ts
index 5fc9c87a32..e3bd62c993 100644
--- a/packages/frontend/src/utility/extract-preview-urls.ts
+++ b/packages/frontend/src/utility/extract-preview-urls.ts
@@ -3,35 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import * as config from '@@/js/config.js';
import type * as Misskey from 'misskey-js';
import type * as mfm from '@transfem-org/sfm-js';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
+import { getNoteUrls } from '@/utility/getNoteUrls';
/**
* Extracts all previewable URLs from a note.
*/
export function extractPreviewUrls(note: Misskey.entities.Note, contents: mfm.MfmNode[]): string[] {
const links = extractUrlFromMfm(contents);
- return links.filter(url =>
- // Remote note
- url !== note.url &&
- url !== note.uri &&
- // 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}`);
+ if (links.length < 0) return [];
+
+ const self = getNoteUrls(note);
+ return links.filter(url => !self.includes(url));
}
diff --git a/packages/frontend/src/utility/getNoteUrls.ts b/packages/frontend/src/utility/getNoteUrls.ts
new file mode 100644
index 0000000000..efd014cbf0
--- /dev/null
+++ b/packages/frontend/src/utility/getNoteUrls.ts
@@ -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;
+}
diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts
index e1bc9790b9..ef946b11d6 100644
--- a/packages/frontend/src/utility/timeline-date-separate.ts
+++ b/packages/frontend/src/utility/timeline-date-separate.ts
@@ -4,7 +4,7 @@
*/
import { computed } from 'vue';
-import type { Ref } from 'vue';
+import type { Ref, ComputedRef } from 'vue';
export function getDateText(dateInstance: Date) {
const date = dateInstance.getDate();
@@ -25,7 +25,7 @@ export type DateSeparetedTimelineItem<T> = {
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>[]>(() => {
const tl: DateSeparetedTimelineItem<T>[] = [];
for (let i = 0; i < items.value.length; i++) {
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 5e91fb14ac..b1d5f63a8b 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -6198,6 +6198,7 @@ export type operations = {
assigneeId: string | null;
reporter: components['schemas']['UserDetailedNotMe'];
targetUser: components['schemas']['UserDetailedNotMe'];
+ targetInstance: components['schemas']['FederationInstance'] | null;
assignee: components['schemas']['UserDetailedNotMe'] | null;
forwarded: boolean;
/** @enum {string|null} */