summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-10-10 20:40:13 +0900
committerGitHub <noreply@github.com>2023-10-10 20:40:13 +0900
commitf964ef163b4c72d7eace319912ef139a41462344 (patch)
tree7315c9e4bf18d24124a393999ec3af194ec2bedb /packages/backend/src/server/api/endpoints
parentMerge pull request #11926 from misskey-dev/develop (diff)
parentfix(backend): センシティブ設定されたチャンネルの投稿をuse... (diff)
downloadmisskey-f964ef163b4c72d7eace319912ef139a41462344.tar.gz
misskey-f964ef163b4c72d7eace319912ef139a41462344.tar.bz2
misskey-f964ef163b4c72d7eace319912ef139a41462344.zip
Merge pull request #11963 from misskey-dev/develop
Release: 2023.10.0
Diffstat (limited to 'packages/backend/src/server/api/endpoints')
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/update.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts110
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/users.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/admin/show-user.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts30
-rw-r--r--packages/backend/src/server/api/endpoints/channels/search.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts109
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/following/update.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/hashtags/trend.ts116
-rw-r--r--packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts51
-rw-r--r--packages/backend/src/server/api/endpoints/meta.ts12
-rw-r--r--packages/backend/src/server/api/endpoints/notes/children.ts21
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts14
-rw-r--r--packages/backend/src/server/api/endpoints/notes/featured.ts56
-rw-r--r--packages/backend/src/server/api/endpoints/notes/global-timeline.ts13
-rw-r--r--packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts124
-rw-r--r--packages/backend/src/server/api/endpoints/notes/local-timeline.ts97
-rw-r--r--packages/backend/src/server/api/endpoints/notes/mentions.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts129
-rw-r--r--packages/backend/src/server/api/endpoints/notes/update.ts89
-rw-r--r--packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts114
-rw-r--r--packages/backend/src/server/api/endpoints/roles/notes.ts22
-rw-r--r--packages/backend/src/server/api/endpoints/roles/users.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/users/featured-notes.ts80
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts79
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/push.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/update-membership.ts79
-rw-r--r--packages/backend/src/server/api/endpoints/users/notes.ts140
-rw-r--r--packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/users/search.ts21
35 files changed, 868 insertions, 768 deletions
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 24d3a8a943..faab8ee608 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -23,6 +23,11 @@ export const meta = {
code: 'NO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
},
+ duplicateName: {
+ message: 'Duplicate name.',
+ code: 'DUPLICATE_NAME',
+ id: 'f7a3462c-4e6e-4069-8421-b9bd4f4c3975',
+ },
},
} as const;
@@ -64,6 +69,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
+ const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
+ if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
const emoji = await this.customEmojiService.add({
driveFile,
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
index 2d69857408..04226d8953 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
@@ -74,6 +74,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
}
+ const emoji = await this.customEmojiService.getEmojiById(ps.id);
+ if (emoji != null) {
+ if (ps.name !== emoji.name) {
+ const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
+ if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
+ }
+ } else {
+ throw new ApiError(meta.errors.noSuchEmoji);
+ }
await this.customEmojiService.update(ps.id, {
driveFile,
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index c3ba07cdd0..5a74456ab0 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -105,40 +105,32 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
- userStarForReactionFallback: {
- type: 'boolean',
- optional: true, nullable: false,
- },
pinnedUsers: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
- optional: false, nullable: false,
},
},
hiddenTags: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
- optional: false, nullable: false,
},
},
blockedHosts: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
- optional: false, nullable: false,
},
},
sensitiveWords: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
- optional: false, nullable: false,
},
},
preservedUsernames: {
@@ -146,129 +138,124 @@ export const meta = {
optional: false, nullable: false,
items: {
type: 'string',
- optional: false, nullable: false,
},
},
hcaptchaSecretKey: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
recaptchaSecretKey: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
turnstileSecretKey: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
sensitiveMediaDetection: {
type: 'string',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
sensitiveMediaDetectionSensitivity: {
type: 'string',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
setSensitiveFlagAutomatically: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
enableSensitiveMediaDetectionForVideos: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
proxyAccountId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
},
- summaryProxy: {
- type: 'string',
- optional: true, nullable: true,
- },
email: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
smtpSecure: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
smtpHost: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
smtpPort: {
type: 'number',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
smtpUser: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
smtpPass: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
swPrivateKey: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
useObjectStorage: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
objectStorageBaseUrl: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStorageBucket: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStoragePrefix: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStorageEndpoint: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStorageRegion: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStoragePort: {
type: 'number',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStorageAccessKey: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStorageSecretKey: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
objectStorageUseSSL: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
objectStorageUseProxy: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
objectStorageSetPublicRead: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
enableIpLogging: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
enableActiveEmailValidation: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
enableChartsForRemoteUser: {
type: 'boolean',
@@ -288,12 +275,32 @@ export const meta = {
},
manifestJsonOverride: {
type: 'string',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
policies: {
type: 'object',
optional: false, nullable: false,
},
+ perLocalUserUserTimelineCacheMax: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ perRemoteUserUserTimelineCacheMax: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ perUserHomeTimelineCacheMax: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ perUserListTimelineCacheMax: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ notesPerOneAd: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
},
},
} as const;
@@ -313,7 +320,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private metaService: MetaService,
) {
- super(meta, paramDef, async (ps, me) => {
+ super(meta, paramDef, async () => {
const instance = await this.metaService.fetch(true);
return {
@@ -328,6 +335,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
tosUrl: instance.termsOfServiceUrl,
repositoryUrl: instance.repositoryUrl,
feedbackUrl: instance.feedbackUrl,
+ impressumUrl: instance.impressumUrl,
+ privacyPolicyUrl: instance.privacyPolicyUrl,
disableRegistration: instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup,
enableHcaptcha: instance.enableHcaptcha,
@@ -399,6 +408,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableIdenticonGeneration: instance.enableIdenticonGeneration,
policies: { ...DEFAULT_POLICIES, ...instance.policies },
manifestJsonOverride: instance.manifestJsonOverride,
+ perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax,
+ perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
+ perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
+ perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
+ notesPerOneAd: instance.notesPerOneAd,
};
});
}
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
index b1772be777..ef5627bc9a 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
@@ -61,9 +61,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
- .andWhere(new Brackets(qb => { qb
- .where('assign.expiresAt IS NULL')
- .orWhere('assign.expiresAt > :now', { now: new Date() });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
}))
.innerJoinAndSelect('assign.user', 'user');
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 3454597532..0731413d05 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -85,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isModerator: isModerator,
isSilenced: isSilenced,
isSuspended: user.isSuspended,
+ isHibernated: user.isHibernated,
lastActiveDate: user.lastActiveDate,
moderationNote: profile.moderationNote ?? '',
signins,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index ea6ebdd1fe..7db25e659f 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -86,6 +86,8 @@ export const paramDef = {
tosUrl: { type: 'string', nullable: true },
repositoryUrl: { type: 'string' },
feedbackUrl: { type: 'string' },
+ impressumUrl: { type: 'string' },
+ privacyPolicyUrl: { type: 'string' },
useObjectStorage: { type: 'boolean' },
objectStorageBaseUrl: { type: 'string', nullable: true },
objectStorageBucket: { type: 'string', nullable: true },
@@ -108,6 +110,11 @@ export const paramDef = {
serverRules: { type: 'array', items: { type: 'string' } },
preservedUsernames: { type: 'array', items: { type: 'string' } },
manifestJsonOverride: { type: 'string' },
+ perLocalUserUserTimelineCacheMax: { type: 'integer' },
+ perRemoteUserUserTimelineCacheMax: { type: 'integer' },
+ perUserHomeTimelineCacheMax: { type: 'integer' },
+ perUserListTimelineCacheMax: { type: 'integer' },
+ notesPerOneAd: { type: 'integer' },
},
required: [],
} as const;
@@ -341,6 +348,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.feedbackUrl = ps.feedbackUrl;
}
+ if (ps.impressumUrl !== undefined) {
+ set.impressumUrl = ps.impressumUrl;
+ }
+
+ if (ps.privacyPolicyUrl !== undefined) {
+ set.privacyPolicyUrl = ps.privacyPolicyUrl;
+ }
+
if (ps.useObjectStorage !== undefined) {
set.useObjectStorage = ps.useObjectStorage;
}
@@ -441,6 +456,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.manifestJsonOverride = ps.manifestJsonOverride;
}
+ if (ps.perLocalUserUserTimelineCacheMax !== undefined) {
+ set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax;
+ }
+
+ if (ps.perRemoteUserUserTimelineCacheMax !== undefined) {
+ set.perRemoteUserUserTimelineCacheMax = ps.perRemoteUserUserTimelineCacheMax;
+ }
+
+ if (ps.perUserHomeTimelineCacheMax !== undefined) {
+ set.perUserHomeTimelineCacheMax = ps.perUserHomeTimelineCacheMax;
+ }
+
+ if (ps.perUserListTimelineCacheMax !== undefined) {
+ set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax;
+ }
+
+ if (ps.notesPerOneAd !== undefined) {
+ set.notesPerOneAd = ps.notesPerOneAd;
+ }
+
const before = await this.metaService.fetch(true);
await this.metaService.update(set);
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index eaae7bff62..6d69971e30 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -12,6 +12,7 @@ import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -56,8 +57,8 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@@ -69,8 +70,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private noteReadService: NoteReadService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+
const antenna = await this.antennasRepository.findOneBy({
id: ps.antennaId,
userId: me.id,
@@ -85,19 +90,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
lastUsedAt: new Date(),
});
- const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
- const noteIdsRes = await this.redisClient.xrevrange(
- `antennaTimeline:${antenna.id}`,
- ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
- ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
- 'COUNT', limit);
-
- if (noteIdsRes.length === 0) {
- return [];
- }
-
- const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
-
+ let noteIds = await this.redisTimelineService.get(`antennaTimeline:${antenna.id}`, untilId, sinceId);
+ noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];
}
@@ -115,7 +109,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedUserQuery(query, me);
const notes = await query.getMany();
- notes.sort((a, b) => a.id > b.id ? -1 : 1);
+ if (sinceId != null && untilId == null) {
+ notes.sort((a, b) => a.id < b.id ? -1 : 1);
+ } else {
+ notes.sort((a, b) => a.id > b.id ? -1 : 1);
+ }
if (notes.length > 0) {
this.noteReadService.read(me.id, notes);
diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts
index 65df45706b..9c78a94844 100644
--- a/packages/backend/src/server/api/endpoints/channels/search.ts
+++ b/packages/backend/src/server/api/endpoints/channels/search.ts
@@ -55,9 +55,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.query !== '') {
if (ps.type === 'nameAndDescription') {
- query.andWhere(new Brackets(qb => { qb
- .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
- .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
+ query.andWhere(new Brackets(qb => {
+ qb
+ .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
+ .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
}));
} else {
query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 026b649537..2dfcf659d7 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -12,6 +12,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -54,8 +57,8 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@@ -66,9 +69,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
+ private redisTimelineService: RedisTimelineService,
+ private cacheService: CacheService,
private activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+ const isRangeSpecified = untilId != null && sinceId != null;
+
const channel = await this.channelsRepository.findOneBy({
id: ps.channelId,
});
@@ -77,70 +86,66 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel);
}
- let timeline: MiNote[] = [];
+ if (me) this.activeUsersChart.read(me);
- const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
- let noteIdsRes: [string, string[]][] = [];
+ if (isRangeSpecified || sinceId == null) {
+ const [
+ userIdsWhoMeMuting,
+ ] = me ? await Promise.all([
+ this.cacheService.userMutingsCache.fetch(me.id),
+ ]) : [new Set<string>()];
- if (!ps.sinceId && !ps.sinceDate) {
- noteIdsRes = await this.redisClient.xrevrange(
- `channelTimeline:${channel.id}`,
- ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
- '-',
- 'COUNT', limit);
- }
+ let noteIds = await this.redisTimelineService.get(`channelTimeline:${channel.id}`, untilId, sinceId);
+ noteIds = noteIds.slice(0, ps.limit);
- // redis から取得していないとき・取得数が足りないとき
- if (noteIdsRes.length < limit) {
- //#region Construct query
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
- .andWhere('note.channelId = :channelId', { channelId: channel.id })
- .innerJoinAndSelect('note.user', 'user')
- .leftJoinAndSelect('note.reply', 'reply')
- .leftJoinAndSelect('note.renote', 'renote')
- .leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser')
- .leftJoinAndSelect('note.channel', 'channel');
+ if (noteIds.length > 0) {
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
- if (me) {
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateMutedNoteQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
- }
- //#endregion
+ let timeline = await query.getMany();
- timeline = await query.limit(ps.limit).getMany();
- } else {
- const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
+ timeline = timeline.filter(note => {
+ if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
- if (noteIds.length === 0) {
- return [];
- }
+ return true;
+ });
- //#region Construct query
- const query = this.notesRepository.createQueryBuilder('note')
- .where('note.id IN (:...noteIds)', { noteIds: noteIds })
- .innerJoinAndSelect('note.user', 'user')
- .leftJoinAndSelect('note.reply', 'reply')
- .leftJoinAndSelect('note.renote', 'renote')
- .leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser')
- .leftJoinAndSelect('note.channel', 'channel');
+ // TODO: フィルタで件数が減った場合の埋め合わせ処理
- if (me) {
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateMutedNoteQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
+ timeline.sort((a, b) => a.id > b.id ? -1 : 1);
+
+ if (timeline.length > 0) {
+ return await this.noteEntityService.packMany(timeline, me);
+ }
}
- //#endregion
+ }
- timeline = await query.getMany();
- timeline.sort((a, b) => a.id > b.id ? -1 : 1);
+ //#region fallback to database
+ const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere('note.channelId = :channelId', { channelId: channel.id })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
+
+ if (me) {
+ this.queryService.generateMutedUserQuery(query, me);
+ this.queryService.generateBlockedUserQuery(query, me);
}
+ //#endregion
- if (me) this.activeUsersChart.read(me);
+ const timeline = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me);
+ //#endregion
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts
index 779231a856..14a13b09c9 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts
@@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/_.js';
+import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
@@ -41,6 +42,9 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
@@ -56,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
+ private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
// Fetch file
@@ -68,9 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchFile);
}
- const notes = await this.notesRepository.createQueryBuilder('note')
- .where(':file = ANY(note.fileIds)', { file: file.id })
- .getMany();
+ const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
+ query.andWhere(':file = ANY(note.fileIds)', { file: file.id });
+
+ const notes = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(notes, me, {
detail: true,
diff --git a/packages/backend/src/server/api/endpoints/following/update.ts b/packages/backend/src/server/api/endpoints/following/update.ts
index 25f393e517..db17d151df 100644
--- a/packages/backend/src/server/api/endpoints/following/update.ts
+++ b/packages/backend/src/server/api/endpoints/following/update.ts
@@ -57,8 +57,9 @@ export const paramDef = {
properties: {
userId: { type: 'string', format: 'misskey:id' },
notify: { type: 'string', enum: ['normal', 'none'] },
+ withReplies: { type: 'boolean' },
},
- required: ['userId', 'notify'],
+ required: ['userId'],
} as const;
@Injectable()
@@ -98,7 +99,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
await this.followingsRepository.update({
id: exist.id,
}, {
- notify: ps.notify === 'none' ? null : ps.notify,
+ notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined,
+ withReplies: ps.withReplies != null ? ps.withReplies : undefined,
});
return await this.userEntityService.pack(follower.id, me);
diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts
index 75d4fe3819..8f382eb96b 100644
--- a/packages/backend/src/server/api/endpoints/hashtags/trend.ts
+++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts
@@ -3,29 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { NotesRepository } from '@/models/_.js';
-import type { MiNote } from '@/models/Note.js';
-import { safeForSql } from '@/misc/safe-for-sql.js';
-import { normalizeForSearch } from '@/misc/normalize-for-search.js';
-import { MetaService } from '@/core/MetaService.js';
import { DI } from '@/di-symbols.js';
-
-/*
-トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要
-ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる
-
-..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する
-*/
-
-const rangeA = 1000 * 60 * 60; // 60分
-//const rangeB = 1000 * 60 * 120; // 2時間
-//const coefficient = 1.25; // 「n倍」の部分
-//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか
-
-const max = 5;
+import { FeaturedService } from '@/core/FeaturedService.js';
+import { HashtagService } from '@/core/HashtagService.js';
export const meta = {
tags: ['hashtags'],
@@ -71,98 +53,18 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
-
- private metaService: MetaService,
+ private featuredService: FeaturedService,
+ private hashtagService: HashtagService,
) {
super(meta, paramDef, async () => {
- const instance = await this.metaService.fetch(true);
- const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
-
- const now = new Date(); // 5分単位で丸めた現在日時
- now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0);
-
- const tagNotes = await this.notesRepository.createQueryBuilder('note')
- .where('note.createdAt > :date', { date: new Date(now.getTime() - rangeA) })
- .andWhere(new Brackets(qb => { qb
- .where('note.visibility = \'public\'')
- .orWhere('note.visibility = \'home\'');
- }))
- .andWhere('note.tags != \'{}\'')
- .select(['note.tags', 'note.userId'])
- .cache(60000) // 1 min
- .getMany();
-
- if (tagNotes.length === 0) {
- return [];
- }
-
- const tags: {
- name: string;
- users: MiNote['userId'][];
- }[] = [];
-
- for (const note of tagNotes) {
- for (const tag of note.tags) {
- if (hiddenTags.includes(tag)) continue;
-
- const x = tags.find(x => x.name === tag);
- if (x) {
- if (!x.users.includes(note.userId)) {
- x.users.push(note.userId);
- }
- } else {
- tags.push({
- name: tag,
- users: [note.userId],
- });
- }
- }
- }
-
- // タグを人気順に並べ替え
- const hots = tags
- .sort((a, b) => b.users.length - a.users.length)
- .map(tag => tag.name)
- .slice(0, max);
-
- //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する
- const countPromises: Promise<number[]>[] = [];
-
- const range = 20;
-
- // 10分
- const interval = 1000 * 60 * 10;
-
- for (let i = 0; i < range; i++) {
- countPromises.push(Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note')
- .select('count(distinct note.userId)')
- .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
- .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) })
- .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
- .cache(60000) // 1 min
- .getRawOne()
- .then(x => parseInt(x.count, 10)),
- )));
- }
-
- const countsLog = await Promise.all(countPromises);
- //#endregion
+ const ranking = await this.featuredService.getHashtagsRanking(10);
- const totalCounts = await Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note')
- .select('count(distinct note.userId)')
- .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
- .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) })
- .cache(60000 * 60) // 60 min
- .getRawOne()
- .then(x => parseInt(x.count, 10)),
- ));
+ const charts = ranking.length === 0 ? {} : await this.hashtagService.getCharts(ranking, 20);
- const stats = hots.map((tag, i) => ({
+ const stats = ranking.map((tag, i) => ({
tag,
- chart: countsLog.map(counts => counts[i]),
- usersCount: totalCounts[i],
+ chart: charts[tag],
+ usersCount: Math.max(...charts[tag]),
}));
return stats;
diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts
deleted file mode 100644
index d62bfbb3ed..0000000000
--- a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { MutedNotesRepository } from '@/models/_.js';
-import { DI } from '@/di-symbols.js';
-
-export const meta = {
- tags: ['account'],
-
- requireCredential: true,
-
- kind: 'read:account',
-
- res: {
- type: 'object',
- optional: false, nullable: false,
- properties: {
- count: {
- type: 'number',
- optional: false, nullable: false,
- },
- },
- },
-} as const;
-
-export const paramDef = {
- type: 'object',
- properties: {},
- required: [],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
- constructor(
- @Inject(DI.mutedNotesRepository)
- private mutedNotesRepository: MutedNotesRepository,
- ) {
- super(meta, paramDef, async (ps, me) => {
- return {
- count: await this.mutedNotesRepository.countBy({
- userId: me.id,
- reason: 'word',
- }),
- };
- });
- }
-}
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index fa6486ed18..2727e4f093 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -181,6 +181,11 @@ export const meta = {
},
},
},
+ notesPerOneAd: {
+ type: 'number',
+ optional: false, nullable: false,
+ default: 0,
+ },
requireSetup: {
type: 'boolean',
optional: false, nullable: false,
@@ -214,11 +219,11 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
- localTimeLine: {
+ localTimeline: {
type: 'boolean',
optional: false, nullable: false,
},
- globalTimeLine: {
+ globalTimeline: {
type: 'boolean',
optional: false, nullable: false,
},
@@ -299,6 +304,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
tosUrl: instance.termsOfServiceUrl,
repositoryUrl: instance.repositoryUrl,
feedbackUrl: instance.feedbackUrl,
+ impressumUrl: instance.impressumUrl,
+ privacyPolicyUrl: instance.privacyPolicyUrl,
disableRegistration: instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup,
enableHcaptcha: instance.enableHcaptcha,
@@ -329,6 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
imageUrl: ad.imageUrl,
dayOfWeek: ad.dayOfWeek,
})),
+ notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,
diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts
index 1a82a4b5d7..1e569d9806 100644
--- a/packages/backend/src/server/api/endpoints/notes/children.ts
+++ b/packages/backend/src/server/api/endpoints/notes/children.ts
@@ -49,16 +49,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
- .andWhere(new Brackets(qb => { qb
- .where('note.replyId = :noteId', { noteId: ps.noteId })
- .orWhere(new Brackets(qb => { qb
- .where('note.renoteId = :noteId', { noteId: ps.noteId })
- .andWhere(new Brackets(qb => { qb
- .where('note.text IS NOT NULL')
- .orWhere('note.fileIds != \'{}\'')
- .orWhere('note.hasPoll = TRUE');
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('note.replyId = :noteId', { noteId: ps.noteId })
+ .orWhere(new Brackets(qb => {
+ qb
+ .where('note.renoteId = :noteId', { noteId: ps.noteId })
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('note.text IS NOT NULL')
+ .orWhere('note.fileIds != \'{}\'')
+ .orWhere('note.hasPoll = TRUE');
+ }));
}));
- }));
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 37a0525e25..3ae4ac044a 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -57,6 +57,12 @@ export const meta = {
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
},
+ cannotRenoteDueToVisibility: {
+ message: 'You can not Renote due to target visibility.',
+ code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
+ id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
+ },
+
noSuchReplyTarget: {
message: 'No such reply target.',
code: 'NO_SUCH_REPLY_TARGET',
@@ -231,6 +237,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.youHaveBeenBlocked);
}
}
+
+ if (renote.visibility === 'followers' && renote.userId !== me.id) {
+ // 他人のfollowers noteはreject
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ } else if (renote.visibility === 'specified') {
+ // specified / direct noteはreject
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ }
}
let reply: MiNote | null = null;
diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts
index 5283b0e0bc..c456874309 100644
--- a/packages/backend/src/server/api/endpoints/notes/featured.ts
+++ b/packages/backend/src/server/api/endpoints/notes/featured.ts
@@ -6,9 +6,9 @@
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
+import { FeaturedService } from '@/core/FeaturedService.js';
export const meta = {
tags: ['notes'],
@@ -32,7 +32,7 @@ export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
- offset: { type: 'integer', default: 0 },
+ untilId: { type: 'string', format: 'misskey:id' },
channelId: { type: 'string', nullable: true, format: 'misskey:id' },
},
required: [],
@@ -40,41 +40,53 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ private globalNotesRankingCache: string[] = [];
+ private globalNotesRankingCacheLastFetchedAt = 0;
+
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
- private queryService: QueryService,
+ private featuredService: FeaturedService,
) {
super(meta, paramDef, async (ps, me) => {
- const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで
+ let noteIds: string[];
+ if (ps.channelId) {
+ noteIds = await this.featuredService.getInChannelNotesRanking(ps.channelId, 50);
+ } else {
+ if (this.globalNotesRankingCacheLastFetchedAt !== 0 && (Date.now() - this.globalNotesRankingCacheLastFetchedAt < 1000 * 60 * 30)) {
+ noteIds = this.globalNotesRankingCache;
+ } else {
+ noteIds = await this.featuredService.getGlobalNotesRanking(100);
+ this.globalNotesRankingCache = noteIds;
+ this.globalNotesRankingCacheLastFetchedAt = Date.now();
+ }
+ }
+
+ if (noteIds.length === 0) {
+ return [];
+ }
+
+ noteIds.sort((a, b) => a > b ? -1 : 1);
+ if (ps.untilId) {
+ noteIds = noteIds.filter(id => id < ps.untilId!);
+ }
+ noteIds = noteIds.slice(0, ps.limit);
const query = this.notesRepository.createQueryBuilder('note')
- .addSelect('note.score')
- .where('note.userHost IS NULL')
- .andWhere('note.score > 0')
- .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) })
- .andWhere('note.visibility = \'public\'')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser');
-
- if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
-
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
-
- let notes = await query
- .orderBy('note.score', 'DESC')
- .limit(100)
- .getMany();
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
- notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ const notes = await query.getMany();
+ notes.sort((a, b) => a.id > b.id ? -1 : 1);
- notes = notes.slice(ps.offset, ps.offset + ps.limit);
+ // TODO: ミュート等考慮
return await this.noteEntityService.packMany(notes, me);
});
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
index 8784e86153..be7557c213 100644
--- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -40,7 +40,6 @@ export const paramDef = {
type: 'object',
properties: {
withFiles: { type: 'boolean', default: false },
- withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
@@ -79,10 +78,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
- this.queryService.generateRepliesQuery(query, ps.withReplies, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
@@ -90,16 +87,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
-
- if (ps.withRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteId IS NULL');
- qb.orWhere(new Brackets(qb => {
- qb.orWhere('note.text IS NOT NULL');
- qb.orWhere('note.fileIds != \'{}\'');
- }));
- }));
- }
//#endregion
const timeline = await query.limit(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index 9bde5dee21..1b77285d47 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -5,14 +5,17 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
+import * as Redis from 'ioredis';
+import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import { CacheService } from '@/core/CacheService.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -51,7 +54,6 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
- withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
@@ -60,97 +62,81 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
- @Inject(DI.followingsRepository)
- private followingsRepository: FollowingsRepository,
-
private noteEntityService: NoteEntityService,
- private queryService: QueryService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
+ private cacheService: CacheService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+
const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.ltlAvailable) {
throw new ApiError(meta.errors.stlDisabled);
}
- //#region Construct query
- const followingQuery = this.followingsRepository.createQueryBuilder('following')
- .select('following.followeeId')
- .where('following.followerId = :followerId', { followerId: me.id });
+ const [
+ userIdsWhoMeMuting,
+ userIdsWhoMeMutingRenotes,
+ userIdsWhoBlockingMe,
+ ] = await Promise.all([
+ this.cacheService.userMutingsCache.fetch(me.id),
+ this.cacheService.renoteMutingsCache.fetch(me.id),
+ this.cacheService.userBlockedCache.fetch(me.id),
+ ]);
+
+ const [htlNoteIds, ltlNoteIds] = await this.redisTimelineService.getMulti([
+ ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
+ ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
+ ], untilId, sinceId);
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
- ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
- .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
- .andWhere(new Brackets(qb => {
- qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id })
- .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
- }))
+ let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
+ noteIds.sort((a, b) => a > b ? -1 : 1);
+ noteIds = noteIds.slice(0, ps.limit);
+
+ if (noteIds.length === 0) {
+ return [];
+ }
+
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
- .setParameters(followingQuery.getParameters());
+ .leftJoinAndSelect('note.channel', 'channel');
- this.queryService.generateChannelQuery(query, me);
- this.queryService.generateRepliesQuery(query, ps.withReplies, me);
- this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateMutedNoteQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
- this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+ let timeline = await query.getMany();
- if (ps.includeMyRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.userId != :meId', { meId: me.id });
- 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)');
- }));
- }
+ timeline = timeline.filter(note => {
+ if (note.userId === me.id) {
+ return true;
+ }
+ if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
+ if (isUserRelated(note, userIdsWhoMeMuting)) return false;
+ if (note.renoteId) {
+ if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
+ if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
+ if (ps.withRenotes === false) return false;
+ }
+ }
- if (ps.includeRenotedMyNotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
- 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)');
- }));
- }
-
- if (ps.includeLocalRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteUserHost IS NOT NULL');
- 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)');
- }));
- }
-
- if (ps.withFiles) {
- query.andWhere('note.fileIds != \'{}\'');
- }
+ return true;
+ });
- if (ps.withRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteId IS NULL');
- qb.orWhere(new Brackets(qb => {
- qb.orWhere('note.text IS NOT NULL');
- qb.orWhere('note.fileIds != \'{}\'');
- }));
- }));
- }
- //#endregion
+ // TODO: フィルタした結果件数が足りなかった場合の対応
- const timeline = await query.limit(ps.limit).getMany();
+ timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
index 0fefddc51b..2357f32d5e 100644
--- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -5,14 +5,17 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { NotesRepository } from '@/models/_.js';
+import * as Redis from 'ioredis';
+import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -41,11 +44,7 @@ export const paramDef = {
type: 'object',
properties: {
withFiles: { type: 'boolean', default: false },
- withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
- fileType: { type: 'array', items: {
- type: 'string',
- } },
excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
@@ -59,71 +58,75 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
- private queryService: QueryService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
+ private cacheService: CacheService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
if (!policies.ltlAvailable) {
throw new ApiError(meta.errors.ltlDisabled);
}
- //#region Construct query
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
- ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
- .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
- .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
+ const [
+ userIdsWhoMeMuting,
+ userIdsWhoMeMutingRenotes,
+ userIdsWhoBlockingMe,
+ ] = me ? await Promise.all([
+ this.cacheService.userMutingsCache.fetch(me.id),
+ this.cacheService.renoteMutingsCache.fetch(me.id),
+ this.cacheService.userBlockedCache.fetch(me.id),
+ ]) : [new Set<string>(), new Set<string>(), new Set<string>()];
+
+ let noteIds = await this.redisTimelineService.get(ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline', untilId, sinceId);
+ noteIds = noteIds.slice(0, ps.limit);
+
+ if (noteIds.length === 0) {
+ return [];
+ }
+
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser');
-
- this.queryService.generateChannelQuery(query, me);
- this.queryService.generateRepliesQuery(query, ps.withReplies, me);
- this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateMutedNoteQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
- if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
- if (ps.withFiles) {
- query.andWhere('note.fileIds != \'{}\'');
- }
+ let timeline = await query.getMany();
- if (ps.fileType != null) {
- query.andWhere('note.fileIds != \'{}\'');
- query.andWhere(new Brackets(qb => {
- for (const type of ps.fileType!) {
- const i = ps.fileType!.indexOf(type);
- qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
+ timeline = timeline.filter(note => {
+ if (me && (note.userId === me.id)) {
+ return true;
+ }
+ if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
+ if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
+ if (note.renoteId) {
+ if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
+ if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
+ if (ps.withRenotes === false) return false;
}
- }));
-
- if (ps.excludeNsfw) {
- query.andWhere('note.cw IS NULL');
- query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
}
- }
- if (ps.withRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteId IS NULL');
- qb.orWhere(new Brackets(qb => {
- qb.orWhere('note.text IS NOT NULL');
- qb.orWhere('note.fileIds != \'{}\'');
- }));
- }));
- }
- //#endregion
+ return true;
+ });
+
+ // TODO: フィルタした結果件数が足りなかった場合の対応
- const timeline = await query.limit(ps.limit).getMany();
+ timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
if (me) {
diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts
index 65e7bd8cd5..6fab024d17 100644
--- a/packages/backend/src/server/api/endpoints/notes/mentions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts
@@ -59,9 +59,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('following.followerId = :followerId', { followerId: me.id });
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
- .andWhere(new Brackets(qb => { qb
- .where(`'{"${me.id}"}' <@ note.mentions`)
- .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
+ .andWhere(new Brackets(qb => {
+ qb
+ .where(`'{"${me.id}"}' <@ note.mentions`)
+ .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
}))
// Avoid scanning primary key index
.orderBy('CONCAT(note.id)', 'DESC')
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
index 29190af62a..986201e950 100644
--- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
+++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -57,9 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('poll.userHost IS NULL')
.andWhere('poll.userId != :meId', { meId: me.id })
.andWhere('poll.noteVisibility = \'public\'')
- .andWhere(new Brackets(qb => { qb
- .where('poll.expiresAt IS NULL')
- .orWhere('poll.expiresAt > :now', { now: new Date() });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('poll.expiresAt IS NULL')
+ .orWhere('poll.expiresAt > :now', { now: new Date() });
}));
//#region exclude arleady voted polls
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index 0d47cc1702..760d52c9db 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -5,13 +5,17 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
+import * as Redis from 'ioredis';
+import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
export const meta = {
tags: ['notes'],
@@ -41,7 +45,6 @@ export const paramDef = {
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
- withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
@@ -50,96 +53,74 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
- @Inject(DI.followingsRepository)
- private followingsRepository: FollowingsRepository,
-
private noteEntityService: NoteEntityService,
- private queryService: QueryService,
private activeUsersChart: ActiveUsersChart,
private idService: IdService,
+ private cacheService: CacheService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
- const followees = await this.followingsRepository.createQueryBuilder('following')
- .select('following.followeeId')
- .where('following.followerId = :followerId', { followerId: me.id })
- .getMany();
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
- //#region Construct query
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
- ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
- // パフォーマンス上の利点が無さそう?
- //.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
- .innerJoinAndSelect('note.user', 'user')
- .leftJoinAndSelect('note.reply', 'reply')
- .leftJoinAndSelect('note.renote', 'renote')
- .leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser');
+ const [
+ followings,
+ userIdsWhoMeMuting,
+ userIdsWhoMeMutingRenotes,
+ userIdsWhoBlockingMe,
+ ] = await Promise.all([
+ this.cacheService.userFollowingsCache.fetch(me.id),
+ this.cacheService.userMutingsCache.fetch(me.id),
+ this.cacheService.renoteMutingsCache.fetch(me.id),
+ this.cacheService.userBlockedCache.fetch(me.id),
+ ]);
- if (followees.length > 0) {
- const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
+ let noteIds = await this.redisTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
+ noteIds = noteIds.slice(0, ps.limit);
- query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
- } else {
- query.andWhere('note.userId = :meId', { meId: me.id });
+ if (noteIds.length === 0) {
+ return [];
}
- this.queryService.generateChannelQuery(query, me);
- this.queryService.generateRepliesQuery(query, ps.withReplies, me);
- this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateMutedNoteQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
- this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
-
- if (ps.includeMyRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.userId != :meId', { meId: me.id });
- 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)');
- }));
- }
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
- if (ps.includeRenotedMyNotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
- 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)');
- }));
- }
+ let timeline = await query.getMany();
- if (ps.includeLocalRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteUserHost IS NOT NULL');
- 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)');
- }));
- }
+ timeline = timeline.filter(note => {
+ if (note.userId === me.id) {
+ return true;
+ }
+ if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
+ if (isUserRelated(note, userIdsWhoMeMuting)) return false;
+ if (note.renoteId) {
+ if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
+ if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
+ if (ps.withRenotes === false) return false;
+ }
+ }
+ if (note.reply && note.reply.visibility === 'followers') {
+ if (!Object.hasOwn(followings, note.reply.userId)) return false;
+ }
- if (ps.withFiles) {
- query.andWhere('note.fileIds != \'{}\'');
- }
+ return true;
+ });
- if (ps.withRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteId IS NULL');
- qb.orWhere(new Brackets(qb => {
- qb.orWhere('note.text IS NOT NULL');
- qb.orWhere('note.fileIds != \'{}\'');
- }));
- }));
- }
- //#endregion
+ // TODO: フィルタした結果件数が足りなかった場合の対応
- const timeline = await query.limit(ps.limit).getMany();
+ timeline.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);
diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts
deleted file mode 100644
index cdf7f085e0..0000000000
--- a/packages/backend/src/server/api/endpoints/notes/update.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import ms from 'ms';
-import { Inject, Injectable } from '@nestjs/common';
-import type { UsersRepository, NotesRepository } from '@/models/_.js';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteDeleteService } from '@/core/NoteDeleteService.js';
-import { DI } from '@/di-symbols.js';
-import { GetterService } from '@/server/api/GetterService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
-import { ApiError } from '../../error.js';
-
-export const meta = {
- tags: ['notes'],
-
- requireCredential: true,
- requireRolePolicy: 'canEditNote',
-
- kind: 'write:notes',
-
- limit: {
- duration: ms('1hour'),
- max: 10,
- minInterval: ms('1sec'),
- },
-
- errors: {
- noSuchNote: {
- message: 'No such note.',
- code: 'NO_SUCH_NOTE',
- id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474',
- },
- },
-} as const;
-
-export const paramDef = {
- type: 'object',
- properties: {
- noteId: { type: 'string', format: 'misskey:id' },
- text: {
- type: 'string',
- minLength: 1,
- maxLength: MAX_NOTE_TEXT_LENGTH,
- nullable: false,
- },
- cw: { type: 'string', nullable: true, maxLength: 100 },
- },
- required: ['noteId', 'text', 'cw'],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
- constructor(
- @Inject(DI.usersRepository)
- private usersRepository: UsersRepository,
-
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
-
- private getterService: GetterService,
- private globalEventService: GlobalEventService,
- ) {
- super(meta, paramDef, async (ps, me) => {
- const note = await this.getterService.getNote(ps.noteId).catch(err => {
- if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
- throw err;
- });
-
- if (note.userId !== me.id) {
- throw new ApiError(meta.errors.noSuchNote);
- }
-
- await this.notesRepository.update({ id: note.id }, {
- updatedAt: new Date(),
- cw: ps.cw,
- text: ps.text,
- });
-
- this.globalEventService.publishNoteStream(note.id, 'updated', {
- cw: ps.cw,
- text: ps.text,
- });
- });
- }
-}
diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
index c20274b2ba..f7ee58264e 100644
--- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -5,12 +5,17 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { NotesRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
+import * as Redis from 'ioredis';
+import type { NotesRepository, UserListsRepository, UserListMembershipsRepository, MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
+import { CacheService } from '@/core/CacheService.js';
+import { IdService } from '@/core/IdService.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -49,7 +54,6 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
- withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
withFiles: {
type: 'boolean',
@@ -63,20 +67,25 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
-
private noteEntityService: NoteEntityService,
- private queryService: QueryService,
private activeUsersChart: ActiveUsersChart,
+ private cacheService: CacheService,
+ private idService: IdService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+
const list = await this.userListsRepository.findOneBy({
id: ps.listId,
userId: me.id,
@@ -86,72 +95,53 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchList);
}
- //#region Construct query
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
- .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId')
+ const [
+ userIdsWhoMeMuting,
+ userIdsWhoMeMutingRenotes,
+ userIdsWhoBlockingMe,
+ ] = await Promise.all([
+ this.cacheService.userMutingsCache.fetch(me.id),
+ this.cacheService.renoteMutingsCache.fetch(me.id),
+ this.cacheService.userBlockedCache.fetch(me.id),
+ ]);
+
+ let noteIds = await this.redisTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId);
+ noteIds = noteIds.slice(0, ps.limit);
+
+ if (noteIds.length === 0) {
+ return [];
+ }
+
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
- .andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
+ .leftJoinAndSelect('note.channel', 'channel');
- this.queryService.generateVisibilityQuery(query, me);
- this.queryService.generateMutedUserQuery(query, me);
- this.queryService.generateMutedNoteQuery(query, me);
- this.queryService.generateBlockedUserQuery(query, me);
- this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+ let timeline = await query.getMany();
- if (ps.includeMyRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.userId != :meId', { meId: me.id });
- 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)');
- }));
- }
+ timeline = timeline.filter(note => {
+ if (note.userId === me.id) {
+ return true;
+ }
+ if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
+ if (isUserRelated(note, userIdsWhoMeMuting)) return false;
+ if (note.renoteId) {
+ if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
+ if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
+ if (ps.withRenotes === false) return false;
+ }
+ }
- if (ps.includeRenotedMyNotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
- 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)');
- }));
- }
-
- if (ps.includeLocalRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteUserHost IS NOT NULL');
- 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)');
- }));
- }
-
- if (!ps.withReplies) {
- query.andWhere('note.replyId IS NULL');
- }
-
- if (ps.withRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteId IS NULL');
- qb.orWhere(new Brackets(qb => {
- qb.orWhere('note.text IS NOT NULL');
- qb.orWhere('note.fileIds != \'{}\'');
- }));
- }));
- }
+ return true;
+ });
- if (ps.withFiles) {
- query.andWhere('note.fileIds != \'{}\'');
- }
- //#endregion
+ // TODO: フィルタした結果件数が足りなかった場合の対応
- const timeline = await query.limit(ps.limit).getMany();
+ timeline.sort((a, b) => a.id > b.id ? -1 : 1);
this.activeUsersChart.read(me);
diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts
index 6dc35907e1..0db51abc55 100644
--- a/packages/backend/src/server/api/endpoints/roles/notes.ts
+++ b/packages/backend/src/server/api/endpoints/roles/notes.ts
@@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -53,8 +54,8 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.redis)
- private redisClient: Redis.Redis,
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@@ -65,8 +66,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
isPublic: true,
@@ -78,18 +83,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!role.isExplorable) {
return [];
}
- const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
- const noteIdsRes = await this.redisClient.xrevrange(
- `roleTimeline:${role.id}`,
- ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
- ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
- 'COUNT', limit);
-
- if (noteIdsRes.length === 0) {
- return [];
- }
- const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
+ let noteIds = await this.redisTimelineService.get(`roleTimeline:${role.id}`, untilId, sinceId);
+ noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length === 0) {
return [];
diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts
index 37aac908b5..caaa3735e9 100644
--- a/packages/backend/src/server/api/endpoints/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/roles/users.ts
@@ -62,9 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
- .andWhere(new Brackets(qb => { qb
- .where('assign.expiresAt IS NULL')
- .orWhere('assign.expiresAt > :now', { now: new Date() });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
}))
.innerJoinAndSelect('assign.user', 'user');
diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts
new file mode 100644
index 0000000000..dec0b7a122
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts
@@ -0,0 +1,80 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import type { NotesRepository } from '@/models/_.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { FeaturedService } from '@/core/FeaturedService.js';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false,
+ allowGet: true,
+ cacheSec: 3600,
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'Note',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ untilId: { type: 'string', format: 'misskey:id' },
+ userId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ private noteEntityService: NoteEntityService,
+ private featuredService: FeaturedService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
+
+ noteIds.sort((a, b) => a > b ? -1 : 1);
+ if (ps.untilId) {
+ noteIds = noteIds.filter(id => id < ps.untilId!);
+ }
+ noteIds = noteIds.slice(0, ps.limit);
+
+ if (noteIds.length === 0) {
+ return [];
+ }
+
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
+
+ const notes = await query.getMany();
+ notes.sort((a, b) => a.id > b.id ? -1 : 1);
+
+ // TODO: ミュート等考慮
+
+ return await this.noteEntityService.packMany(notes, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
index eae55905d3..f2f6c4303a 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts
@@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
-import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js';
+import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import type { MiUserList } from '@/models/UserList.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
@@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@@ -110,7 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name,
} as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0]));
- const users = (await this.userListJoiningsRepository.findBy({
+ const users = (await this.userListMembershipsRepository.findBy({
userListId: ps.listId,
})).map(x => x.userId);
@@ -132,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
- const exist = await this.userListJoiningsRepository.exist({
+ const exist = await this.userListMembershipsRepository.exist({
where: {
userListId: userList.id,
userId: currentUser.id,
diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts
new file mode 100644
index 0000000000..ae8b4e9b81
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { UserListEntityService } from '@/core/entities/UserListEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { QueryService } from '@/core/QueryService.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+ tags: ['lists', 'account'],
+
+ requireCredential: false,
+
+ kind: 'read:account',
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ listId: { type: 'string', format: 'misskey:id' },
+ forPublic: { type: 'boolean', default: false },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['listId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
+
+ private userListEntityService: UserListEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ // Fetch the list
+ const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? {
+ id: ps.listId,
+ userId: me.id,
+ } : {
+ id: ps.listId,
+ isPublic: true,
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId)
+ .andWhere('membership.userListId = :userListId', { userListId: userList.id })
+ .innerJoinAndSelect('membership.user', 'user');
+
+ const memberships = await query
+ .limit(ps.limit)
+ .getMany();
+
+ return this.userListEntityService.packMembershipsMany(memberships);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts
index 72a6a7380d..c4ceec575b 100644
--- a/packages/backend/src/server/api/endpoints/users/lists/push.ts
+++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
-import type { UserListsRepository, UserListJoiningsRepository, BlockingsRepository } from '@/models/_.js';
+import type { UserListsRepository, UserListMembershipsRepository, BlockingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { UserListService } from '@/core/UserListService.js';
@@ -76,8 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
- @Inject(DI.userListJoiningsRepository)
- private userListJoiningsRepository: UserListJoiningsRepository,
+ @Inject(DI.userListMembershipsRepository)
+ private userListMembershipsRepository: UserListMembershipsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@@ -115,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
- const exist = await this.userListJoiningsRepository.exist({
+ const exist = await this.userListMembershipsRepository.exist({
where: {
userListId: userList.id,
userId: user.id,
diff --git a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts
new file mode 100644
index 0000000000..b69465b940
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import type { UserListsRepository } from '@/models/_.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { UserListService } from '@/core/UserListService.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+ tags: ['lists', 'users'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:account',
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02',
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '588e7f72-c744-4a61-b180-d354e912bda2',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ listId: { type: 'string', format: 'misskey:id' },
+ userId: { type: 'string', format: 'misskey:id' },
+ withReplies: { type: 'boolean' },
+ },
+ required: ['listId', 'userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.userListsRepository)
+ private userListsRepository: UserListsRepository,
+
+ private userListService: UserListService,
+ private getterService: GetterService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ // Fetch the list
+ const userList = await this.userListsRepository.findOneBy({
+ id: ps.listId,
+ userId: me.id,
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ // Fetch the user
+ const user = await this.getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ await this.userListService.updateMembership(user, userList, {
+ withReplies: ps.withReplies,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts
index e660a0bb25..dfef35986e 100644
--- a/packages/backend/src/server/api/endpoints/users/notes.ts
+++ b/packages/backend/src/server/api/endpoints/users/notes.ts
@@ -5,19 +5,21 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { NotesRepository } from '@/models/_.js';
+import * as Redis from 'ioredis';
+import type { MiNote, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
-import { GetterService } from '@/server/api/GetterService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { IdService } from '@/core/IdService.js';
+import { isUserRelated } from '@/misc/is-user-related.js';
+import { QueryService } from '@/core/QueryService.js';
+import { RedisTimelineService } from '@/core/RedisTimelineService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['users', 'notes'],
- description: 'Show all notes that this user created.',
-
res: {
type: 'array',
optional: false, nullable: false,
@@ -43,6 +45,7 @@ export const paramDef = {
userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
+ withChannelNotes: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
@@ -50,9 +53,6 @@ export const paramDef = {
untilDate: { type: 'integer' },
includeMyRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
- fileType: { type: 'array', items: {
- type: 'string',
- } },
excludeNsfw: { type: 'boolean', default: false },
},
required: ['userId'],
@@ -61,23 +61,88 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
- private getterService: GetterService,
+ private cacheService: CacheService,
+ private idService: IdService,
+ private redisTimelineService: RedisTimelineService,
) {
super(meta, paramDef, async (ps, me) => {
- // Lookup user
- const user = await this.getterService.getUser(ps.userId).catch(err => {
- if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
- throw err;
- });
+ const untilId = ps.untilId ?? (ps.untilDate ? this.idService.genId(new Date(ps.untilDate!)) : null);
+ const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.genId(new Date(ps.sinceDate!)) : null);
+ const isRangeSpecified = untilId != null && sinceId != null;
+ const isSelf = me && (me.id === ps.userId);
+
+ if (isRangeSpecified || sinceId == null) {
+ const [
+ userIdsWhoMeMuting,
+ ] = me ? await Promise.all([
+ this.cacheService.userMutingsCache.fetch(me.id),
+ ]) : [new Set<string>()];
+
+ const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
+ this.redisTimelineService.get(ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`, untilId, sinceId),
+ ps.withReplies ? this.redisTimelineService.get(`userTimelineWithReplies:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
+ ps.withChannelNotes ? this.redisTimelineService.get(`userTimelineWithChannel:${ps.userId}`, untilId, sinceId) : Promise.resolve([]),
+ ]);
+
+ let noteIds = Array.from(new Set([
+ ...noteIdsRes,
+ ...repliesNoteIdsRes,
+ ...channelNoteIdsRes,
+ ]));
+ noteIds.sort((a, b) => a > b ? -1 : 1);
+ noteIds = noteIds.slice(0, ps.limit);
+
+ if (noteIds.length > 0) {
+ const isFollowing = me && Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId);
+
+ const query = this.notesRepository.createQueryBuilder('note')
+ .where('note.id IN (:...noteIds)', { noteIds: noteIds })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .leftJoinAndSelect('note.channel', 'channel');
+
+ let timeline = await query.getMany();
+
+ timeline = timeline.filter(note => {
+ if (me && isUserRelated(note, userIdsWhoMeMuting, true)) return false;
+
+ if (note.renoteId) {
+ if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
+ if (ps.withRenotes === false) return false;
+ }
+ }
+
+ if (note.channel?.isSensitive && !isSelf) return false;
+ if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
+ if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
+
+ return true;
+ });
+
+ // TODO: フィルタで件数が減った場合の埋め合わせ処理
+
+ timeline.sort((a, b) => a.id > b.id ? -1 : 1);
+
+ if (timeline.length > 0) {
+ return await this.noteEntityService.packMany(timeline, me);
+ }
+ }
+ }
- //#region Construct query
+ //#region fallback to database
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
- .andWhere('note.userId = :userId', { userId: user.id })
+ .andWhere('note.userId = :userId', { userId: ps.userId })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
@@ -85,14 +150,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.channelId IS NULL');
- qb.orWhere('channel.isSensitive = false');
- }));
+ if (!ps.withChannelNotes) {
+ query.andWhere('note.channelId IS NULL');
+ }
this.queryService.generateVisibilityQuery(query, me);
if (me) {
- this.queryService.generateMutedUserQuery(query, me, user);
+ this.queryService.generateMutedUserQuery(query, me, { id: ps.userId });
this.queryService.generateBlockedUserQuery(query, me);
}
@@ -100,38 +164,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('note.fileIds != \'{}\'');
}
- if (ps.fileType != null) {
- query.andWhere('note.fileIds != \'{}\'');
- query.andWhere(new Brackets(qb => {
- for (const type of ps.fileType!) {
- const i = ps.fileType!.indexOf(type);
- qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
- }
- }));
-
- if (ps.excludeNsfw) {
- query.andWhere('note.cw IS NULL');
- query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
- }
- }
-
- if (!ps.withReplies) {
- query.andWhere('note.replyId IS NULL');
- }
-
- if (ps.withRenotes === false) {
- query.andWhere(new Brackets(qb => {
- qb.orWhere('note.renoteId IS NULL');
- qb.orWhere(new Brackets(qb => {
- qb.orWhere('note.text IS NOT NULL');
- qb.orWhere('note.fileIds != \'{}\'');
- }));
- }));
- }
-
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
- qb.orWhere('note.userId != :userId', { userId: user.id });
+ 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 != \'{}\'');
@@ -139,11 +174,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
}
- //#endregion
-
const timeline = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(timeline, me);
+ //#endregion
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
index 74408cc64a..4bf25d9fbb 100644
--- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
+++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
@@ -92,9 +92,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
- .andWhere(new Brackets(qb => { qb
- .where('user.updatedAt IS NULL')
- .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}));
query.setParameters(followingQuery.getParameters());
diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts
index aff5b98779..32b5c12372 100644
--- a/packages/backend/src/server/api/endpoints/users/search.ts
+++ b/packages/backend/src/server/api/endpoints/users/search.ts
@@ -64,9 +64,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (isUsername) {
const usernameQuery = this.usersRepository.createQueryBuilder('user')
.where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' })
- .andWhere(new Brackets(qb => { qb
- .where('user.updatedAt IS NULL')
- .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}))
.andWhere('user.isSuspended = FALSE');
@@ -91,9 +92,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
}
}))
- .andWhere(new Brackets(qb => { qb
- .where('user.updatedAt IS NULL')
- .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}))
.andWhere('user.isSuspended = FALSE');
@@ -122,9 +124,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.usersRepository.createQueryBuilder('user')
.where(`user.id IN (${ profQuery.getQuery() })`)
- .andWhere(new Brackets(qb => { qb
- .where('user.updatedAt IS NULL')
- .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ .andWhere(new Brackets(qb => {
+ qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}))
.andWhere('user.isSuspended = FALSE')
.setParameters(profQuery.getParameters());