Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance: 人気のPlayを10件以上表示できるように #14443

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました
- Enhance: 依存関係の更新
- Enhance: l10nの更新
- Enhance: Playの「人気」タブで10件以上表示可能に #14399
- Fix: 連合のホワイトリストが正常に登録されない問題を修正

### Client
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
Expand Down Expand Up @@ -217,6 +218,7 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
Expand Down Expand Up @@ -367,6 +369,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookTestService,
UtilityService,
FileInfoService,
FlashService,
SearchService,
ClipService,
FeaturedService,
Expand Down Expand Up @@ -513,6 +516,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookTestService,
$UtilityService,
$FileInfoService,
$FlashService,
$SearchService,
$ClipService,
$FeaturedService,
Expand Down Expand Up @@ -660,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookTestService,
UtilityService,
FileInfoService,
FlashService,
SearchService,
ClipService,
FeaturedService,
Expand Down
40 changes: 40 additions & 0 deletions packages/backend/src/core/FlashService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { type FlashsRepository } from '@/models/_.js';

/**
* MisskeyPlay関係のService
*/
@Injectable()
export class FlashService {
constructor(
@Inject(DI.flashsRepository)
private flashRepository: FlashsRepository,
) {
}

/**
* 人気のあるPlay一覧を取得する.
*/
public async featured(opts?: { offset?: number, limit: number }) {
const builder = this.flashRepository.createQueryBuilder('flash')
.andWhere('flash.likedCount > 0')
.andWhere('flash.visibility = :visibility', { visibility: 'public' })
.addOrderBy('flash.likedCount', 'DESC')
.addOrderBy('flash.updatedAt', 'DESC')
.addOrderBy('flash.id', 'DESC');

if (opts?.offset) {
builder.skip(opts.offset);
}

builder.take(opts?.limit ?? 10);

return await builder.getMany();
}
}
41 changes: 30 additions & 11 deletions packages/backend/src/core/entities/FlashEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@

import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiFlash } from '@/models/Flash.js';
import { bindThis } from '@/decorators.js';
Expand All @@ -20,10 +18,8 @@ export class FlashEntityService {
constructor(
@Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository,

@Inject(DI.flashLikesRepository)
private flashLikesRepository: FlashLikesRepository,

private userEntityService: UserEntityService,
private idService: IdService,
) {
Expand All @@ -34,25 +30,36 @@ export class FlashEntityService {
src: MiFlash['id'] | MiFlash,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
packedUser?: Packed<'UserLite'>,
likedFlashIds?: MiFlash['id'][],
},
): Promise<Packed<'Flash'>> {
const meId = me ? me.id : null;
const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });

return await awaitAll({
// { schema: 'UserDetailed' } すると無限ループするので注意
const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me);

let isLiked = false;
if (meId) {
isLiked = hint?.likedFlashIds
? hint.likedFlashIds.includes(flash.id)
: await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } });
}

return {
id: flash.id,
createdAt: this.idService.parse(flash.id).date.toISOString(),
updatedAt: flash.updatedAt.toISOString(),
userId: flash.userId,
user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意
user: user,
title: flash.title,
summary: flash.summary,
script: flash.script,
visibility: flash.visibility,
likedCount: flash.likedCount,
isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
});
isLiked: isLiked,
};
}

@bindThis
Expand All @@ -63,7 +70,19 @@ export class FlashEntityService {
const _users = flashes.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) })));
const _likedFlashIds = me
? await this.flashLikesRepository.createQueryBuilder('flashLike')
.select('flashLike.flashId')
.where('flashLike.userId = :userId', { userId: me.id })
.getRawMany<{ flashLike_flashId: string }>()
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
: [];
return Promise.all(
flashes.map(flash => this.pack(flash, me, {
packedUser: _userMap.get(flash.userId),
likedFlashIds: _likedFlashIds,
})),
);
}
}

5 changes: 4 additions & 1 deletion packages/backend/src/models/Flash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ
import { id } from './util/id.js';
import { MiUser } from './User.js';

export const flashVisibility = ['public', 'private'] as const;
export type FlashVisibility = typeof flashVisibility[number];

@Entity('flash')
export class MiFlash {
@PrimaryColumn(id())
Expand Down Expand Up @@ -63,5 +66,5 @@ export class MiFlash {
@Column('varchar', {
length: 512, default: 'public',
})
public visibility: 'public' | 'private';
public visibility: FlashVisibility;
}
22 changes: 11 additions & 11 deletions packages/backend/src/server/api/endpoints/flash/featured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { DI } from '@/di-symbols.js';
import { FlashService } from '@/core/FlashService.js';

export const meta = {
tags: ['flash'],
Expand All @@ -27,26 +28,25 @@ export const meta = {

export const paramDef = {
type: 'object',
properties: {},
properties: {
offset: { type: 'integer', minimum: 0, default: 0 },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: [],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository,

private flashService: FlashService,
private flashEntityService: FlashEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.flashsRepository.createQueryBuilder('flash')
.andWhere('flash.likedCount > 0')
.orderBy('flash.likedCount', 'DESC');

const flashs = await query.limit(10).getMany();

return await this.flashEntityService.packMany(flashs, me);
const result = await this.flashService.featured({
offset: ps.offset,
limit: ps.limit,
});
return await this.flashEntityService.packMany(result, me);
});
}
}
152 changes: 152 additions & 0 deletions packages/backend/test/unit/FlashService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Test, TestingModule } from '@nestjs/testing';
import { FlashService } from '@/core/FlashService.js';
import { IdService } from '@/core/IdService.js';
import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { GlobalModule } from '@/GlobalModule.js';

describe('FlashService', () => {
let app: TestingModule;
let service: FlashService;

// --------------------------------------------------------------------------------------

let flashsRepository: FlashsRepository;
let usersRepository: UsersRepository;
let userProfilesRepository: UserProfilesRepository;
let idService: IdService;

// --------------------------------------------------------------------------------------

let root: MiUser;
let alice: MiUser;
let bob: MiUser;

// --------------------------------------------------------------------------------------

async function createFlash(data: Partial<MiFlash>) {
return flashsRepository.insert({
id: idService.gen(),
updatedAt: new Date(),
userId: root.id,
title: 'title',
summary: 'summary',
script: 'script',
permissions: [],
likedCount: 0,
...data,
}).then(x => flashsRepository.findOneByOrFail(x.identifiers[0]));
}

async function createUser(data: Partial<MiUser> = {}) {
const user = await usersRepository
.insert({
id: idService.gen(),
...data,
})
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));

await userProfilesRepository.insert({
userId: user.id,
});

return user;
}

// --------------------------------------------------------------------------------------

beforeEach(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
],
providers: [
FlashService,
IdService,
],
}).compile();

service = app.get(FlashService);

flashsRepository = app.get(DI.flashsRepository);
usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository);
idService = app.get(IdService);

root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false });
});

afterEach(async () => {
await usersRepository.delete({});
await userProfilesRepository.delete({});
await flashsRepository.delete({});
});

afterAll(async () => {
await app.close();
});

// --------------------------------------------------------------------------------------

describe('featured', () => {
test('should return featured flashes', async () => {
const flash1 = await createFlash({ likedCount: 1 });
const flash2 = await createFlash({ likedCount: 2 });
const flash3 = await createFlash({ likedCount: 3 });

const result = await service.featured({
offset: 0,
limit: 10,
});

expect(result).toEqual([flash3, flash2, flash1]);
});

test('should return featured flashes public visibility only', async () => {
const flash1 = await createFlash({ likedCount: 1, visibility: 'public' });
const flash2 = await createFlash({ likedCount: 2, visibility: 'public' });
const flash3 = await createFlash({ likedCount: 3, visibility: 'private' });

const result = await service.featured({
offset: 0,
limit: 10,
});

expect(result).toEqual([flash2, flash1]);
});

test('should return featured flashes with offset', async () => {
const flash1 = await createFlash({ likedCount: 1 });
const flash2 = await createFlash({ likedCount: 2 });
const flash3 = await createFlash({ likedCount: 3 });

const result = await service.featured({
offset: 1,
limit: 10,
});

expect(result).toEqual([flash2, flash1]);
});

test('should return featured flashes with limit', async () => {
const flash1 = await createFlash({ likedCount: 1 });
const flash2 = await createFlash({ likedCount: 2 });
const flash3 = await createFlash({ likedCount: 3 });

const result = await service.featured({
offset: 0,
limit: 2,
});

expect(result).toEqual([flash3, flash2]);
});
});
});
3 changes: 2 additions & 1 deletion packages/frontend/src/pages/flash/flash-index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ const tab = ref('featured');

const featuredFlashsPagination = {
endpoint: 'flash/featured' as const,
noPaging: true,
limit: 5,
offsetMode: true,
};
const myFlashsPagination = {
endpoint: 'flash/my' as const,
Expand Down
Loading
Loading