Compare commits
2 commits
6e64e3d38f
...
f011152ede
Author | SHA1 | Date | |
---|---|---|---|
f011152ede | |||
6b04cfdb67 |
65 changed files with 1387 additions and 615 deletions
28
locales/index.d.ts
vendored
28
locales/index.d.ts
vendored
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -13217,6 +13225,26 @@ export interface Locale extends ILocale {
|
||||||
* Hibernated
|
* Hibernated
|
||||||
*/
|
*/
|
||||||
"hibernated": string;
|
"hibernated": string;
|
||||||
|
/**
|
||||||
|
* When replying to a post with a Content Warning, automatically use the same CW for the reply.
|
||||||
|
*/
|
||||||
|
"keepCwDescription": string;
|
||||||
|
/**
|
||||||
|
* Disabled (do not copy CWs)
|
||||||
|
*/
|
||||||
|
"keepCwDisabled": string;
|
||||||
|
/**
|
||||||
|
* Enabled (copy CWs verbatim)
|
||||||
|
*/
|
||||||
|
"keepCwEnabled": string;
|
||||||
|
/**
|
||||||
|
* Enabled (copy CW and prepend "RE:")
|
||||||
|
*/
|
||||||
|
"keepCwPrependRe": string;
|
||||||
|
/**
|
||||||
|
* Note controls
|
||||||
|
*/
|
||||||
|
"noteFooterLabel": string;
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "quollkey",
|
"name": "quollkey",
|
||||||
"version": "2025.6.1-dev",
|
"version": "2025.6.2-dev",
|
||||||
"codename": "quoll",
|
"codename": "quoll",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class FixIDXNoteForTimeline1749097536193 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_note_for_timelines"
|
||||||
|
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||||
|
include ("userId", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost", "threadId")
|
||||||
|
NULLS NOT DISTINCT
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_note_for_timelines"
|
||||||
|
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||||
|
include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost")
|
||||||
|
NULLS NOT DISTINCT
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CreateIDXNoteUrl1749229288946 {
|
||||||
|
name = 'CreateIDXNoteUrl1749229288946'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_url" ON "note" ("url") `);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_note_url"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
|
import { QueryService } from './QueryService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LatestNoteService {
|
export class LatestNoteService {
|
||||||
|
@ -14,11 +15,12 @@ export class LatestNoteService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private readonly notesRepository: NotesRepository,
|
||||||
|
|
||||||
@Inject(DI.latestNotesRepository)
|
@Inject(DI.latestNotesRepository)
|
||||||
private latestNotesRepository: LatestNotesRepository,
|
private readonly latestNotesRepository: LatestNotesRepository,
|
||||||
|
|
||||||
|
private readonly queryService: QueryService,
|
||||||
loggerService: LoggerService,
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = loggerService.getLogger('LatestNoteService');
|
this.logger = loggerService.getLogger('LatestNoteService');
|
||||||
|
@ -91,7 +93,7 @@ export class LatestNoteService {
|
||||||
|
|
||||||
// Find the newest remaining note for the user.
|
// Find the newest remaining note for the user.
|
||||||
// We exclude DMs and pure renotes.
|
// We exclude DMs and pure renotes.
|
||||||
const nextLatest = await this.notesRepository
|
const query = this.notesRepository
|
||||||
.createQueryBuilder('note')
|
.createQueryBuilder('note')
|
||||||
.select()
|
.select()
|
||||||
.where({
|
.where({
|
||||||
|
@ -106,18 +108,11 @@ export class LatestNoteService {
|
||||||
? Not(null)
|
? Not(null)
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.andWhere(`
|
.orderBy({ id: 'DESC' });
|
||||||
(
|
|
||||||
note."renoteId" IS NULL
|
this.queryService.andIsNotRenote(query, 'note');
|
||||||
OR note.text IS NOT NULL
|
|
||||||
OR note.cw IS NOT NULL
|
const nextLatest = await query.getOne();
|
||||||
OR note."replyId" IS NOT NULL
|
|
||||||
OR note."hasPoll"
|
|
||||||
OR note."fileIds" != '{}'
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.orderBy({ id: 'DESC' })
|
|
||||||
.getOne();
|
|
||||||
if (!nextLatest) return;
|
if (!nextLatest) return;
|
||||||
|
|
||||||
// Record it as the latest
|
// Record it as the latest
|
||||||
|
|
|
@ -160,15 +160,15 @@ export class QueryService {
|
||||||
// Reply to me
|
// Reply to me
|
||||||
.orWhere(':meId = note.replyUserId')
|
.orWhere(':meId = note.replyUserId')
|
||||||
// DM to me
|
// DM to me
|
||||||
.orWhere(':meId = ANY (note.visibleUserIds)')
|
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||||
// Mentions me
|
// Mentions me
|
||||||
.orWhere(':meId = ANY (note.mentions)')
|
.orWhere(':meIdAsList <@ note.mentions')
|
||||||
// Followers-only post
|
// Followers-only post
|
||||||
.orWhere(new Brackets(qb => this
|
.orWhere(new Brackets(qb => this
|
||||||
.andFollowingUser(qb, ':meId', 'note.userId')
|
.andFollowingUser(qb, ':meId', 'note.userId')
|
||||||
.andWhere('note.visibility = \'followers\'')));
|
.andWhere('note.visibility = \'followers\'')));
|
||||||
|
|
||||||
q.setParameters({ meId: me.id });
|
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -188,15 +188,8 @@ export class QueryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateExcludedRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>): SelectQueryBuilder<E> {
|
public generateExcludedRenotesQueryForNotes<Q extends WhereExpressionBuilder>(q: Q): Q {
|
||||||
return q
|
return this.andIsNotRenote(q, 'note');
|
||||||
.andWhere(new Brackets(qb => qb
|
|
||||||
.orWhere('note.renoteId IS NULL')
|
|
||||||
.orWhere('note.text IS NOT NULL')
|
|
||||||
.orWhere('note.cw IS NOT NULL')
|
|
||||||
.orWhere('note.replyId IS NOT NULL')
|
|
||||||
.orWhere('note.hasPoll = true')
|
|
||||||
.orWhere('note.fileIds != \'{}\'')));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -256,6 +249,120 @@ export class QueryService {
|
||||||
return q;
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) refers to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsQuote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) refers to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsQuote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||||
|
.andWhere(new Brackets(qbb => qbb
|
||||||
|
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.hasPoll = true`)
|
||||||
|
.orWhere(`${noteProp}.fileIds != '{}'`)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) does not refer to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotQuote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) does not refer to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotQuote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||||
|
.orWhere(new Brackets(qb => qb
|
||||||
|
.andWhere(`${noteProp}.text IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.cw IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.hasPoll = false`)
|
||||||
|
.andWhere(`${noteProp}.fileIds = '{}'`)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) refers to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsRenote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) refers to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsRenote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||||
|
.andWhere(`${noteProp}.text IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.cw IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.hasPoll = false`)
|
||||||
|
.andWhere(`${noteProp}.fileIds = '{}'`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) does not refer to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotRenote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) does not refer to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotRenote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||||
|
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.hasPoll = true`)
|
||||||
|
.orWhere(`${noteProp}.fileIds != '{}'`)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
|
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
|
||||||
* Both props should be expressions, not raw values.
|
* Both props should be expressions, not raw values.
|
||||||
|
@ -283,6 +390,33 @@ export class QueryService {
|
||||||
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||||
|
return this.addFollowingChannel(q, followerProp, followeeProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||||
|
return this.addFollowingChannel(q, followerProp, followeeProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
const followingQuery = this.channelFollowingsRepository.createQueryBuilder('following')
|
||||||
|
.select('1')
|
||||||
|
.andWhere(`following.followerId = ${followerProp}`)
|
||||||
|
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||||
|
|
||||||
|
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||||
* Both props should be expressions, not raw values.
|
* Both props should be expressions, not raw values.
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -445,7 +445,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||||
emojis,
|
emojis,
|
||||||
attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [],
|
attributionDomains: Array.isArray(person.attributionDomains)
|
||||||
|
? person.attributionDomains
|
||||||
|
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||||
|
.slice(0, 32)
|
||||||
|
: [],
|
||||||
})) as MiRemoteUser;
|
})) as MiRemoteUser;
|
||||||
|
|
||||||
let _description: string | null = null;
|
let _description: string | null = null;
|
||||||
|
@ -629,7 +633,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
|
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
|
||||||
hideOnlineStatus: person.hideOnlineStatus !== false,
|
hideOnlineStatus: person.hideOnlineStatus !== false,
|
||||||
isExplorable: person.discoverable !== false,
|
isExplorable: person.discoverable !== false,
|
||||||
attributionDomains: (Array.isArray(person.attributionDomains) && person.attributionDomains.every(x => typeof x === 'string')) ? person.attributionDomains : [],
|
attributionDomains: Array.isArray(person.attributionDomains)
|
||||||
|
? person.attributionDomains
|
||||||
|
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||||
|
.slice(0, 32)
|
||||||
|
: [],
|
||||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))),
|
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))),
|
||||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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({
|
||||||
|
where: {
|
||||||
id: In(users.filter((user): user is string => typeof user === 'string')),
|
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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,6 +133,7 @@ export class MiNote {
|
||||||
})
|
})
|
||||||
public uri: string | null;
|
public uri: string | null;
|
||||||
|
|
||||||
|
@Index('IDX_note_url')
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 512, nullable: true,
|
length: 512, nullable: true,
|
||||||
comment: 'The human readable url of a note. it will be null when the note is local.',
|
comment: 'The human readable url of a note. it will be null when the note is local.',
|
||||||
|
|
|
@ -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 })
|
||||||
|
@ -389,12 +390,15 @@ export class MiUser {
|
||||||
})
|
})
|
||||||
public allowUnsignedFetch: UserUnsignedFetchOption;
|
public allowUnsignedFetch: UserUnsignedFetchOption;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('text', {
|
||||||
name: 'attributionDomains',
|
name: 'attributionDomains',
|
||||||
length: 128, array: true, default: '{}',
|
array: true, default: '{}',
|
||||||
})
|
})
|
||||||
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;
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,8 +114,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
|
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
|
||||||
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
||||||
|
|
|
@ -137,12 +137,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
.leftJoinAndSelect('note.channel', 'channel')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
if (me) {
|
if (me) {
|
||||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
|
@ -159,6 +161,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
return await query.limit(ps.limit).getMany();
|
return await query.getMany();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,10 +81,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new ApiError(meta.errors.noSuchFile);
|
throw new ApiError(meta.errors.noSuchFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
query.andWhere(':file <@ note.fileIds', { file: [file.id] });
|
.andWhere(':file <@ note.fileIds', { file: [file.id] })
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
const notes = await query.limit(ps.limit).getMany();
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
|
|
||||||
|
const notes = await query.getMany();
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(notes, me, {
|
return await this.noteEntityService.packMany(notes, me, {
|
||||||
detail: true,
|
detail: true,
|
||||||
|
|
|
@ -263,9 +263,15 @@ export const paramDef = {
|
||||||
enum: userUnsignedFetchOptions,
|
enum: userUnsignedFetchOptions,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
},
|
},
|
||||||
attributionDomains: { type: 'array', items: {
|
attributionDomains: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
} },
|
minLength: 1,
|
||||||
|
maxLength: 128,
|
||||||
|
},
|
||||||
|
maxLength: 32,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
@ -64,7 +64,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
if (me) {
|
||||||
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.local) {
|
if (ps.local) {
|
||||||
query.andWhere('note.userHost IS NULL');
|
query.andWhere('note.userHost IS NULL');
|
||||||
|
@ -75,7 +84,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.renote !== undefined) {
|
if (ps.renote !== undefined) {
|
||||||
query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL');
|
if (ps.renote) {
|
||||||
|
this.queryService.andIsRenote(query, 'note');
|
||||||
|
|
||||||
|
if (me) {
|
||||||
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.queryService.andIsNotRenote(query, 'note');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.withFiles !== undefined) {
|
if (ps.withFiles !== undefined) {
|
||||||
|
@ -91,7 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
// query.isBot = bot;
|
// query.isBot = bot;
|
||||||
//}
|
//}
|
||||||
|
|
||||||
const notes = await query.limit(ps.limit).getMany();
|
const notes = await query.getMany();
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(notes);
|
return await this.noteEntityService.packMany(notes);
|
||||||
});
|
});
|
||||||
|
|
|
@ -82,8 +82,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
// This subquery mess teaches postgres how to use the right indexes.
|
// This subquery mess teaches postgres how to use the right indexes.
|
||||||
// Using WHERE or ON conditions causes a fallback to full sequence scan, which times out.
|
// Using WHERE or ON conditions causes a fallback to full sequence scan, which times out.
|
||||||
|
@ -114,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
const timeline = await query.limit(ps.limit).getMany();
|
const timeline = await query.getMany();
|
||||||
|
|
||||||
if (me) {
|
if (me) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
|
|
|
@ -57,26 +57,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
.andWhere(new Brackets(qb => {
|
.andWhere(new Brackets(qb => {
|
||||||
qb
|
qb.orWhere('note.replyId = :noteId');
|
||||||
.where('note.replyId = :noteId', { noteId: ps.noteId });
|
|
||||||
if (ps.showQuotes) {
|
if (ps.showQuotes) {
|
||||||
qb.orWhere(new Brackets(qb => {
|
qb.orWhere(new Brackets(qbb => this.queryService
|
||||||
qb
|
.andIsQuote(qbb, 'note')
|
||||||
.where('note.renoteId = :noteId', { noteId: ps.noteId })
|
.andWhere('note.renoteId = :noteId'),
|
||||||
.andWhere(new Brackets(qb => {
|
));
|
||||||
qb
|
|
||||||
.where('note.text IS NOT NULL')
|
|
||||||
.orWhere('note.fileIds != \'{}\'')
|
|
||||||
.orWhere('note.hasPoll = TRUE');
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.setParameters({ noteId: ps.noteId })
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
@ -85,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notes = await query.limit(ps.limit).getMany();
|
const notes = await query.getMany();
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(notes, me);
|
return await this.noteEntityService.packMany(notes, me);
|
||||||
});
|
});
|
||||||
|
|
|
@ -87,7 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
const query = this.notesRepository
|
const query = this.notesRepository
|
||||||
.createQueryBuilder('note')
|
.createQueryBuilder('note')
|
||||||
.setParameter('me', me.id)
|
.setParameters({ meId: me.id })
|
||||||
|
|
||||||
// Limit to latest notes
|
// Limit to latest notes
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
|
@ -130,8 +130,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
|
||||||
// Exclude channel notes
|
// Exclude channel notes
|
||||||
.andWhere({ channelId: IsNull() })
|
.andWhere({ channelId: IsNull() })
|
||||||
|
@ -177,14 +177,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
* Limit to followers (they follow us)
|
* Limit to followers (they follow us)
|
||||||
*/
|
*/
|
||||||
function addFollower<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
|
function addFollower<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
|
||||||
return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :me');
|
return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :meId');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Limit to followees (we follow them)
|
* Limit to followees (we follow them)
|
||||||
*/
|
*/
|
||||||
function addFollowee<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
|
function addFollowee<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
|
||||||
return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :me AND followee."followeeId" = latest.user_id');
|
return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :meId AND followee."followeeId" = latest.user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -82,8 +82,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
|
|
|
@ -197,51 +197,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
withBots: boolean,
|
withBots: boolean,
|
||||||
withRenotes: boolean,
|
withRenotes: boolean,
|
||||||
}, me: MiLocalUser) {
|
}, me: MiLocalUser) {
|
||||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
|
||||||
const followingChannels = await this.channelFollowingsRepository.find({
|
|
||||||
where: {
|
|
||||||
followerId: me.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
.andWhere(new Brackets(qb => {
|
// 1. by a user I follow, 2. a public local post, 3. my own post
|
||||||
if (followees.length > 0) {
|
.andWhere(new Brackets(qb => this.queryService
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
.orFollowingUser(qb, ':meId', 'note.userId')
|
||||||
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
.orWhere(new Brackets(qbb => qbb
|
||||||
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
.andWhere('note.visibility = \'public\'')
|
||||||
} else {
|
.andWhere('note.userHost IS NULL')))
|
||||||
qb.where('note.userId = :meId', { meId: me.id });
|
.orWhere(':meId = note.userId')))
|
||||||
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
// 1. in a channel I follow, 2. not in a channel
|
||||||
}
|
.andWhere(new Brackets(qb => this.queryService
|
||||||
}))
|
.orFollowingChannel(qb, ':meId', 'note.channelId')
|
||||||
|
.orWhere('note.channelId IS NULL')))
|
||||||
|
.setParameters({ meId: me.id })
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
if (followingChannels.length > 0) {
|
|
||||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
|
||||||
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
|
||||||
qb.orWhere('note.channelId IS NULL');
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
query.andWhere('note.channelId IS NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ps.withReplies) {
|
if (!ps.withReplies) {
|
||||||
query.andWhere(new Brackets(qb => {
|
query
|
||||||
qb
|
// 1. Not a reply, 2. a self-reply
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
.andWhere(new Brackets(qb => qb
|
||||||
.orWhere(new Brackets(qb => {
|
.orWhere('note.replyId IS NULL') // 返信ではない
|
||||||
qb // 返信だけど投稿者自身への返信
|
.orWhere('note.replyUserId = note.userId')));
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('note.replyUserId = note.userId');
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
@ -263,6 +244,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
return await query.limit(ps.limit).getMany();
|
return await query.getMany();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -168,8 +168,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
|
if (!ps.withReplies) {
|
||||||
|
query
|
||||||
|
// 1. Not a reply, 2. a self-reply
|
||||||
|
.andWhere(new Brackets(qb => qb
|
||||||
|
.orWhere('note.replyId IS NULL') // 返信ではない
|
||||||
|
.orWhere('note.replyUserId = note.userId')));
|
||||||
|
}
|
||||||
|
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
|
@ -190,18 +199,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ps.withReplies) {
|
return await query.getMany();
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
|
||||||
.orWhere(new Brackets(qb => {
|
|
||||||
qb // 返信だけど投稿者自身への返信
|
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('note.replyUserId = note.userId');
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.limit(ps.limit).getMany();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { Brackets } from 'typeorm';
|
import { Brackets } from 'typeorm';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
|
import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
|
||||||
|
import { MiNote } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
|
@ -61,36 +62,52 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private readonly activeUsersChart: ActiveUsersChart,
|
private readonly activeUsersChart: ActiveUsersChart,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
ps.sinceId, ps.untilId)
|
.innerJoin(qb => {
|
||||||
.andWhere(new Brackets(qb => qb
|
qb
|
||||||
.orWhere(':meId = ANY (note.mentions)')
|
.select('note.id', 'id')
|
||||||
.orWhere(':meId = ANY (note.visibleUserIds)')))
|
.from(qbb => qbb
|
||||||
.setParameters({ meId: me.id })
|
.select('note.id', 'id')
|
||||||
// Avoid scanning primary key index
|
.from(MiNote, 'note')
|
||||||
.orderBy('CONCAT(note.id)', 'DESC')
|
.where(new Brackets(qbbb => qbbb
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
// DM to me
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
// Mentions me
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.orWhere(':meIdAsList <@ note.mentions'),
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
))
|
||||||
|
.setParameters({ meIdAsList: [me.id] })
|
||||||
|
, 'source')
|
||||||
|
.innerJoin(MiNote, 'note', 'note.id = source.id');
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
// Mentioned or visible users can always access
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
//this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
this.queryService.generateBlockedHostQueryForNote(qb);
|
||||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
this.queryService.generateMutedUserQueryForNotes(qb, me);
|
||||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateMutedNoteThreadQuery(qb, me);
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateBlockedUserQueryForNotes(qb, me);
|
||||||
|
// A renote can't mention a user, so it will never appear here anyway.
|
||||||
|
//this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
if (ps.visibility) {
|
if (ps.visibility) {
|
||||||
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
|
qb.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.following) {
|
if (ps.following) {
|
||||||
this.queryService.andFollowingUser(query, ':meId', 'note.userId');
|
this.queryService
|
||||||
|
.andFollowingUser(qb, ':meId', 'note.userId')
|
||||||
|
.setParameters({ meId: me.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentions = await query.limit(ps.limit).getMany();
|
return qb;
|
||||||
|
}, 'source', 'source.id = note.id')
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
|
const mentions = await query.getMany();
|
||||||
|
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
this.activeUsersChart.read(me);
|
this.activeUsersChart.read(me);
|
||||||
|
|
|
@ -47,7 +47,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
noteId: { type: 'string', format: 'misskey:id' },
|
noteId: { type: 'string', format: 'misskey:id' },
|
||||||
userId: { type: "string", format: "misskey:id" },
|
userId: { type: 'string', format: 'misskey:id' },
|
||||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||||
sinceId: { type: 'string', format: 'misskey:id' },
|
sinceId: { type: 'string', format: 'misskey:id' },
|
||||||
untilId: { type: 'string', format: 'misskey:id' },
|
untilId: { type: 'string', format: 'misskey:id' },
|
||||||
|
@ -81,19 +81,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
if (ps.userId) {
|
if (ps.userId) {
|
||||||
query.andWhere("user.id = :userId", { userId: ps.userId });
|
query.andWhere('user.id = :userId', { userId: ps.userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.quote) {
|
if (ps.quote) {
|
||||||
query.andWhere("note.text IS NOT NULL");
|
this.queryService.andIsQuote(query, 'note');
|
||||||
} else {
|
} else {
|
||||||
query.andWhere("note.text IS NULL");
|
this.queryService.andIsRenote(query, 'note');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
if (me) {
|
||||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
|
}
|
||||||
|
|
||||||
const renotes = await query.limit(ps.limit).getMany();
|
const renotes = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
|
|
|
@ -59,14 +59,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
if (me) {
|
||||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
|
}
|
||||||
|
|
||||||
const timeline = await query.limit(ps.limit).getMany();
|
const timeline = await query.getMany();
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,8 +12,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['notes', 'hashtags'],
|
tags: ['notes', 'hashtags'],
|
||||||
|
@ -82,19 +80,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private cacheService: CacheService,
|
|
||||||
private utilityService: UtilityService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
.andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
|
.andWhere(new Brackets(qb => qb
|
||||||
|
.orWhere('note.visibility = \'public\'')
|
||||||
|
.orWhere('note.visibility = \'home\''))) // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
|
|
||||||
|
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
|
@ -102,7 +99,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
|
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ps.tag) {
|
if (ps.tag) {
|
||||||
|
@ -135,9 +132,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
if (ps.renote != null) {
|
if (ps.renote != null) {
|
||||||
if (ps.renote) {
|
if (ps.renote) {
|
||||||
query.andWhere('note.renoteId IS NOT NULL');
|
this.queryService.andIsRenote(query, 'note');
|
||||||
} else {
|
} else {
|
||||||
query.andWhere('note.renoteId IS NULL');
|
this.queryService.andIsNotRenote(query, 'note');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,16 +151,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search notes
|
// Search notes
|
||||||
let notes = await query.limit(ps.limit).getMany();
|
const notes = await query.getMany();
|
||||||
|
|
||||||
notes = notes.filter(note => {
|
|
||||||
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
|
|
||||||
if (note.user?.isSuspended) return false;
|
|
||||||
if (note.userHost) {
|
|
||||||
if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(notes, me);
|
return await this.noteEntityService.packMany(notes, me);
|
||||||
});
|
});
|
||||||
|
|
|
@ -140,67 +140,31 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
|
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
|
||||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
|
||||||
const followingChannels = await this.channelFollowingsRepository.find({
|
|
||||||
where: {
|
|
||||||
followerId: me.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
//#region Construct query
|
//#region Construct query
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
// 1. in a channel I follow, 2. my own post, 3. by a user I follow
|
||||||
|
.andWhere(new Brackets(qb => this.queryService
|
||||||
|
.orFollowingChannel(qb, ':meId', 'note.channelId')
|
||||||
|
.orWhere(':meId = note.userId')
|
||||||
|
.orWhere(new Brackets(qb2 => this.queryService
|
||||||
|
.andFollowingUser(qb2, ':meId', 'note.userId')
|
||||||
|
.andWhere('note.channelId IS NULL'))),
|
||||||
|
))
|
||||||
|
// 1. Not a reply, 2. a self-reply
|
||||||
|
.andWhere(new Brackets(qb => qb
|
||||||
|
.orWhere('note.replyId IS NULL') // 返信ではない
|
||||||
|
.orWhere('note.replyUserId = note.userId')))
|
||||||
|
.setParameters({ meId: me.id })
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
if (followees.length > 0 && followingChannels.length > 0) {
|
|
||||||
// ユーザー・チャンネルともにフォローあり
|
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
|
||||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where(new Brackets(qb2 => {
|
|
||||||
qb2
|
|
||||||
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
|
||||||
.andWhere('note.channelId IS NULL');
|
|
||||||
}))
|
|
||||||
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
|
||||||
}));
|
|
||||||
} else if (followees.length > 0) {
|
|
||||||
// ユーザーフォローのみ(チャンネルフォローなし)
|
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
|
||||||
query
|
|
||||||
.andWhere('note.channelId IS NULL')
|
|
||||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
|
||||||
} else if (followingChannels.length > 0) {
|
|
||||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
|
||||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
|
|
||||||
.orWhere('note.userId = :meId', { meId: me.id });
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// フォローなし
|
|
||||||
query
|
|
||||||
.andWhere('note.channelId IS NULL')
|
|
||||||
.andWhere('note.userId = :meId', { meId: me.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
|
||||||
.orWhere(new Brackets(qb => {
|
|
||||||
qb // 返信だけど投稿者自身への返信
|
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('note.replyUserId = note.userId');
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
|
|
||||||
|
@ -212,11 +176,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
if (!ps.withRenotes) {
|
if (!ps.withRenotes) {
|
||||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||||
} else if (me) {
|
} else {
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
return await query.limit(ps.limit).getMany();
|
return await query.getMany();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,32 +154,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
//#region Construct query
|
//#region Construct query
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
|
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
|
||||||
|
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
|
||||||
|
.andWhere('note.channelId IS NULL') // チャンネルノートではない
|
||||||
|
.andWhere(new Brackets(qb => qb
|
||||||
|
// 返信ではない
|
||||||
|
.orWhere('note.replyId IS NULL')
|
||||||
|
// 返信だけど投稿者自身への返信
|
||||||
|
.orWhere('note.replyUserId = note.userId')
|
||||||
|
// 返信だけど自分宛ての返信
|
||||||
|
.orWhere('note.replyUserId = :meId')
|
||||||
|
// 返信だけどwithRepliesがtrueの場合
|
||||||
|
.orWhere('userListMemberships.withReplies = true'),
|
||||||
|
))
|
||||||
|
.setParameters({ meId: me.id })
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
|
.limit(ps.limit);
|
||||||
.andWhere('note.channelId IS NULL') // チャンネルノートではない
|
|
||||||
.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
|
||||||
.orWhere(new Brackets(qb => {
|
|
||||||
qb // 返信だけど投稿者自身への返信
|
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('note.replyUserId = note.userId');
|
|
||||||
}))
|
|
||||||
.orWhere(new Brackets(qb => {
|
|
||||||
qb // 返信だけど自分宛ての返信
|
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('note.replyUserId = :meId', { meId: me.id });
|
|
||||||
}))
|
|
||||||
.orWhere(new Brackets(qb => {
|
|
||||||
qb // 返信だけどwithRepliesがtrueの場合
|
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('userListMemberships.withReplies = true');
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
@ -192,12 +185,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
if (!ps.withRenotes) {
|
if (!ps.withRenotes) {
|
||||||
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
this.queryService.generateExcludedRenotesQueryForNotes(query);
|
||||||
} else if (me) {
|
} else {
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
}
|
}
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
return await query.limit(ps.limit).getMany();
|
return await query.getMany();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,10 +107,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
this.queryService.generateBlockedHostQueryForNote(query);
|
this.queryService.generateBlockedHostQueryForNote(query);
|
||||||
|
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
|
@ -205,7 +205,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('note.channel', 'channel')
|
.leftJoinAndSelect('note.channel', 'channel')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.limit(ps.limit);
|
||||||
|
|
||||||
if (ps.withChannelNotes) {
|
if (ps.withChannelNotes) {
|
||||||
if (!isSelf) query.andWhere(new Brackets(qb => {
|
if (!isSelf) query.andWhere(new Brackets(qb => {
|
||||||
|
@ -230,26 +231,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (!ps.withRenotes && !ps.withQuotes) {
|
if (!ps.withRenotes && !ps.withQuotes) {
|
||||||
query.andWhere('note.renoteId IS NULL');
|
query.andWhere('note.renoteId IS NULL');
|
||||||
} else if (!ps.withRenotes) {
|
} else if (!ps.withRenotes) {
|
||||||
query.andWhere(new Brackets(qb => {
|
this.queryService.andIsNotRenote(query, 'note');
|
||||||
qb.orWhere('note.userId != :userId', { userId: ps.userId });
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
} else if (!ps.withQuotes) {
|
} else if (!ps.withQuotes) {
|
||||||
query.andWhere(`
|
this.queryService.andIsNotQuote(query, 'note');
|
||||||
(
|
|
||||||
note."renoteId" IS NULL
|
|
||||||
OR (
|
|
||||||
note.text IS NULL
|
|
||||||
AND note.cw IS NULL
|
|
||||||
AND note."replyId" IS NULL
|
|
||||||
AND note."hasPoll" IS FALSE
|
|
||||||
AND note."fileIds" = '{}'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
|
if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
|
||||||
|
@ -268,6 +252,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
query.andWhere('"user"."isBot" = false');
|
query.andWhere('"user"."isBot" = false');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query.limit(ps.limit).getMany();
|
return await query.getMany();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
||||||
</template>
|
</template>
|
||||||
</MkReactionsViewer>
|
</MkReactionsViewer>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<button :class="$style.footerButton" class="_button" @click.stop @click="reply()">
|
<button :class="$style.footerButton" class="_button" @click.stop @click="reply()">
|
||||||
<i class="ti ti-arrow-back-up"></i>
|
<i class="ti ti-arrow-back-up"></i>
|
||||||
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||||
|
@ -913,11 +913,11 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
max-width: 400px;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover > .article > .main > .footer > .footerButton {
|
&:hover > .article > .main > .footer > .footerButton {
|
||||||
|
@ -1203,10 +1203,6 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--MI_THEME-fgHighlighted);
|
color: var(--MI_THEME-fgHighlighted);
|
||||||
}
|
}
|
||||||
|
@ -1290,25 +1286,7 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 400px) {
|
|
||||||
.root:not(.showActionsOnlyHover) {
|
|
||||||
.footerButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 350px) {
|
@container (max-width: 350px) {
|
||||||
.root:not(.showActionsOnlyHover) {
|
|
||||||
.footerButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.colorBar {
|
.colorBar {
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
|
|
|
@ -118,7 +118,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||||
</div>
|
</div>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<div :class="$style.noteFooterInfo">
|
<div :class="$style.noteFooterInfo">
|
||||||
<div v-if="appearNote.updatedAt">
|
<div v-if="appearNote.updatedAt">
|
||||||
{{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/>
|
{{ i18n.ts.edited }}: <MkTime :time="appearNote.updatedAt" mode="detail"/>
|
||||||
|
@ -889,9 +889,7 @@ function animatedMFM() {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
width: max-content;
|
overflow-x: auto;
|
||||||
min-width: min-content;
|
|
||||||
max-width: fit-content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.replyTo {
|
.replyTo {
|
||||||
|
@ -1083,10 +1081,6 @@ function animatedMFM() {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--MI_THEME-fgHighlighted);
|
color: var(--MI_THEME-fgHighlighted);
|
||||||
}
|
}
|
||||||
|
@ -1169,14 +1163,6 @@ function animatedMFM() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 350px) {
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 300px) {
|
@container (max-width: 300px) {
|
||||||
.root {
|
.root {
|
||||||
font-size: 0.825em;
|
font-size: 0.825em;
|
||||||
|
@ -1186,12 +1172,6 @@ function animatedMFM() {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
|
|
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
|
<MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
|
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
|
||||||
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
||||||
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
|
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
|
||||||
|
@ -422,9 +422,7 @@ if (props.detail) {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
width: max-content;
|
overflow-x: auto;
|
||||||
min-width: min-content;
|
|
||||||
max-width: fit-content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
@ -469,23 +467,11 @@ if (props.detail) {
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--MI_THEME-fgHighlighted);
|
color: var(--MI_THEME-fgHighlighted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 400px) {
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.7em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.noteFooterButtonCount {
|
.noteFooterButtonCount {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 0 0 0 8px;
|
margin: 0 0 0 8px;
|
||||||
|
|
|
@ -373,7 +373,9 @@ if (props.specified) {
|
||||||
// keep cw when reply
|
// keep cw when reply
|
||||||
if (prefer.s.keepCw && props.reply && props.reply.cw) {
|
if (prefer.s.keepCw && props.reply && props.reply.cw) {
|
||||||
useCw.value = true;
|
useCw.value = true;
|
||||||
cw.value = props.reply.cw;
|
cw.value = prefer.s.keepCw === 'prepend-re'
|
||||||
|
? `RE: ${props.reply.cw}`
|
||||||
|
: props.reply.cw;
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply default CW
|
// apply default CW
|
||||||
|
|
|
@ -39,32 +39,34 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts">
|
||||||
|
type ItemOption<T extends string | number | null | boolean = string | number | null> = {
|
||||||
|
type?: 'option';
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ItemGroup<T extends string | number | null | boolean = string | number | null> = {
|
||||||
|
type: 'group';
|
||||||
|
label: string;
|
||||||
|
items: ItemOption<T>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MkSelectItem<T extends string | number | null | boolean = string | number | null> = ItemOption<T> | ItemGroup<T>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup generic="T extends string | number | null | boolean = string | number | null">
|
||||||
import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
|
import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
import type { VNode, VNodeChild } from 'vue';
|
import type { VNode, VNodeChild } from 'vue';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
type ItemOption = {
|
|
||||||
type?: 'option';
|
|
||||||
value: string | number | null;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ItemGroup = {
|
|
||||||
type: 'group';
|
|
||||||
label: string;
|
|
||||||
items: ItemOption[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MkSelectItem = ItemOption | ItemGroup;
|
|
||||||
|
|
||||||
// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する)
|
// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する)
|
||||||
// see: https://github.com/misskey-dev/misskey/issues/15558
|
// see: https://github.com/misskey-dev/misskey/issues/15558
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string | number | null;
|
modelValue: T;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
@ -73,11 +75,11 @@ const props = defineProps<{
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
items?: MkSelectItem[];
|
items?: MkSelectItem<T>[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'update:modelValue', value: string | number | null): void;
|
(ev: 'update:modelValue', value: T): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
|
|
|
@ -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,7 +274,9 @@ 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)
|
||||||
|
? Promise.resolve(props.previewHint)
|
||||||
|
: window.fetch(`/url?${params.toString()}`, { headers })
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
|
@ -236,13 +286,9 @@ function refresh(withFetch = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
55
packages/frontend/src/components/SkDateSeparatedList.vue
Normal file
55
packages/frontend/src/components/SkDateSeparatedList.vue
Normal 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>
|
|
@ -114,7 +114,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
<MkA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</MkA>
|
||||||
</template>
|
</template>
|
||||||
</MkReactionsViewer>
|
</MkReactionsViewer>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<button :class="$style.footerButton" class="_button" @click.stop @click="reply()">
|
<button :class="$style.footerButton" class="_button" @click.stop @click="reply()">
|
||||||
<i class="ti ti-arrow-back-up"></i>
|
<i class="ti ti-arrow-back-up"></i>
|
||||||
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
<p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||||
|
@ -921,11 +921,11 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
max-width: 400px;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover > .article > .main > .footer > .footerButton {
|
&:hover > .article > .main > .footer > .footerButton {
|
||||||
|
@ -947,10 +947,6 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
|
|
||||||
.footerButton {
|
.footerButton {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1238,10 +1234,6 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--MI_THEME-fgHighlighted);
|
color: var(--MI_THEME-fgHighlighted);
|
||||||
}
|
}
|
||||||
|
@ -1358,25 +1350,7 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 400px) {
|
|
||||||
.root:not(.showActionsOnlyHover) {
|
|
||||||
.footerButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 350px) {
|
@container (max-width: 350px) {
|
||||||
.root:not(.showActionsOnlyHover) {
|
|
||||||
.footerButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.colorBar {
|
.colorBar {
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
|
@ -1385,16 +1359,6 @@ function emitUpdReaction(emoji: string, delta: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 300px) {
|
|
||||||
.root:not(.showActionsOnlyHover) {
|
|
||||||
.footerButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 250px) {
|
@container (max-width: 250px) {
|
||||||
.quoteNote {
|
.quoteNote {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
|
@ -132,7 +132,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkA>
|
</MkA>
|
||||||
</div>
|
</div>
|
||||||
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/>
|
<MkReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" style="margin-top: 6px;" :note="appearNote"/>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
||||||
<i class="ti ti-arrow-back-up"></i>
|
<i class="ti ti-arrow-back-up"></i>
|
||||||
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
|
||||||
|
@ -920,11 +920,11 @@ onUnmounted(() => {
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
max-width: 400px;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replyTo {
|
.replyTo {
|
||||||
|
@ -1141,10 +1141,6 @@ onUnmounted(() => {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--MI_THEME-fgHighlighted);
|
color: var(--MI_THEME-fgHighlighted);
|
||||||
}
|
}
|
||||||
|
@ -1234,14 +1230,6 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 350px) {
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 300px) {
|
@container (max-width: 300px) {
|
||||||
.root {
|
.root {
|
||||||
font-size: 0.825em;
|
font-size: 0.825em;
|
||||||
|
@ -1251,12 +1239,6 @@ onUnmounted(() => {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
|
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
|
<MkReactionsViewer ref="reactionsViewer" :note="note"/>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
||||||
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
|
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
|
||||||
<p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p>
|
<p v-if="note.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ note.repliesCount }}</p>
|
||||||
|
@ -449,11 +449,11 @@ if (props.detail) {
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
max-width: 400px;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
@ -559,14 +559,6 @@ if (props.detail) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 400px) {
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.7em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.noteFooterButtonCount {
|
.noteFooterButtonCount {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 0 0 0 8px;
|
margin: 0 0 0 8px;
|
||||||
|
|
|
@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
||||||
</div>
|
</div>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer" class="_gaps _h_gaps" tabindex="0" role="group" :aria-label="i18n.ts.noteFooterLabel">
|
||||||
<div :class="$style.noteFooterInfo">
|
<div :class="$style.noteFooterInfo">
|
||||||
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -168,6 +168,7 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
|
||||||
margin-top: 0.4em;
|
margin-top: 0.4em;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
|
@ -280,23 +281,11 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--MI_THEME-fgHighlighted);
|
color: var(--MI_THEME-fgHighlighted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 350px) {
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 500px) {
|
@container (max-width: 500px) {
|
||||||
.root {
|
.root {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
@ -323,11 +312,5 @@ const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceT
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noteFooterButton {
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 0.1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
348
packages/frontend/src/components/SkUrlPreviewGroup.vue
Normal file
348
packages/frontend/src/components/SkUrlPreviewGroup.vue
Normal 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>
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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([
|
||||||
|
(withHint && props.userHint) ? props.userHint : misskeyApi('users/show', {
|
||||||
userId: props.userId,
|
userId: props.userId,
|
||||||
}), misskeyApi('admin/show-user', {
|
}),
|
||||||
|
(withHint && props.infoHint) ? props.infoHint : misskeyApi('admin/show-user', {
|
||||||
userId: props.userId,
|
userId: props.userId,
|
||||||
}), iAmAdmin ? misskeyApi('admin/get-user-ips', {
|
}),
|
||||||
|
iAmAdmin
|
||||||
|
? (withHint && props.ipsHint) ? props.ipsHint : misskeyApi('admin/get-user-ips', {
|
||||||
userId: props.userId,
|
userId: props.userId,
|
||||||
}) : Promise.resolve(null), iAmAdmin ? misskeyApi('ap/get', {
|
})
|
||||||
|
: null,
|
||||||
|
iAmAdmin ? misskeyApi('ap/get', {
|
||||||
uri: `${url}/users/${props.userId}`,
|
uri: `${url}/users/${props.userId}`,
|
||||||
}).catch(() => null) : null]).then(([_user, _info, _ips, _ap]) => {
|
}).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) {
|
||||||
|
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
@ -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,10 +368,14 @@ 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
|
||||||
|
: iAmAdmin ? misskeyApi('admin/meta') : null,
|
||||||
|
(withHint && props.instanceHint)
|
||||||
|
? props.instanceHint
|
||||||
|
: misskeyApi('federation/show-instance', {
|
||||||
host: props.host,
|
host: props.host,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
@ -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}`,
|
||||||
|
|
|
@ -403,9 +403,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<SearchMarker :keywords="['remember', 'keep', 'note', 'cw']">
|
<SearchMarker :keywords="['remember', 'keep', 'note', 'cw']">
|
||||||
<MkPreferenceContainer k="keepCw">
|
<MkPreferenceContainer k="keepCw">
|
||||||
<MkSwitch v-model="keepCw">
|
<MkSelect v-model="keepCw">
|
||||||
<template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template>
|
<template #label><SearchLabel>{{ i18n.ts.keepCw }}</SearchLabel></template>
|
||||||
</MkSwitch>
|
<template #caption><SearchKeyword>{{ i18n.ts.keepCwDescription }}</SearchKeyword></template>
|
||||||
|
<option :value="false">{{ i18n.ts.keepCwDisabled }}</option>>
|
||||||
|
<option :value="true">{{ i18n.ts.keepCwEnabled }}</option>>
|
||||||
|
<option value="prepend-re">{{ i18n.ts.keepCwPrependRe }}</option>
|
||||||
|
</MkSelect>
|
||||||
</MkPreferenceContainer>
|
</MkPreferenceContainer>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
|
|
||||||
|
|
|
@ -120,7 +120,7 @@ export const PREF_DEF = {
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
keepCw: {
|
keepCw: {
|
||||||
default: true,
|
default: true as boolean | 'prepend-re',
|
||||||
},
|
},
|
||||||
rememberNoteVisibility: {
|
rememberNoteVisibility: {
|
||||||
default: false,
|
default: false,
|
||||||
|
|
|
@ -428,6 +428,14 @@ rt {
|
||||||
gap: var(--MI-margin);
|
gap: var(--MI-margin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use with _gaps, _gaps_m, or _gaps_s.
|
||||||
|
* Place the other class *first*!
|
||||||
|
*/
|
||||||
|
._h_gaps {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
._buttons {
|
._buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
@ -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}`);
|
|
||||||
}
|
}
|
||||||
|
|
44
packages/frontend/src/utility/getNoteUrls.ts
Normal file
44
packages/frontend/src/utility/getNoteUrls.ts
Normal 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;
|
||||||
|
}
|
|
@ -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++) {
|
||||||
|
|
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
|
@ -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))
|
||||||
|
|
|
@ -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}"
|
||||||
|
@ -614,3 +617,10 @@ bubble: "Bubble"
|
||||||
verified: "Verified"
|
verified: "Verified"
|
||||||
notVerified: "Not Verified"
|
notVerified: "Not Verified"
|
||||||
hibernated: "Hibernated"
|
hibernated: "Hibernated"
|
||||||
|
|
||||||
|
keepCwDescription: "When replying to a post with a Content Warning, automatically use the same CW for the reply."
|
||||||
|
keepCwDisabled: "Disabled (do not copy CWs)"
|
||||||
|
keepCwEnabled: "Enabled (copy CWs verbatim)"
|
||||||
|
keepCwPrependRe: "Enabled (copy CW and prepend \"RE:\")"
|
||||||
|
|
||||||
|
noteFooterLabel: "Note controls"
|
||||||
|
|
|
@ -52,3 +52,11 @@ _moderationLogTypes:
|
||||||
acceptRemoteInstanceReports: "รายงานได้รับการยอมรับจากเซิร์ฟเวอร์ระยะไกล"
|
acceptRemoteInstanceReports: "รายงานได้รับการยอมรับจากเซิร์ฟเวอร์ระยะไกล"
|
||||||
rejectQuotesUser: "โพสต์คำพูดของผู้ใช้ถูกบล็อค/ลบ"
|
rejectQuotesUser: "โพสต์คำพูดของผู้ใช้ถูกบล็อค/ลบ"
|
||||||
allowQuotesUser: "อนุญาตให้อ้างอิงข้อความจากผู้ใช้"
|
allowQuotesUser: "อนุญาตให้อ้างอิงข้อความจากผู้ใช้"
|
||||||
|
clearUserFiles: "ล้างไฟล์ไดรฟ์ของผู้ใช้"
|
||||||
|
nsfwUser: "ผู้ใช้ถูกทำเครื่องหมายเป็น NSFW"
|
||||||
|
unNsfwUser: "ผู้ใช้ที่ไม่ได้ทำเครื่องหมายเป็น NSFW"
|
||||||
|
silenceUser: "ผู้ใช้ที่ถูกปิดเสียง"
|
||||||
|
unSilenceUser: "ผู้ใช้ที่ไม่ได้รับการปิดเสียง"
|
||||||
|
createAccount: "สร้างบัญชีแล้ว"
|
||||||
|
clearRemoteFiles: "ล้างไฟล์ไดรฟ์ระยะไกล"
|
||||||
|
clearOwnerlessFiles: "ล้างไฟล์ไดรฟ์ที่ไม่มีเจ้าของ"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue