Add reels feed support
هذا الالتزام موجود في:
@@ -4,10 +4,13 @@ import { Transform, Type } from 'class-transformer';
|
|||||||
import { PostType } from '../../../common/enums/post-type.enum';
|
import { PostType } from '../../../common/enums/post-type.enum';
|
||||||
import { toBoolean } from '../../../common/utils/query-transform.util';
|
import { toBoolean } from '../../../common/utils/query-transform.util';
|
||||||
|
|
||||||
|
export const FEED_POST_TYPE_FILTERS = [...Object.values(PostType), 'reel'] as const;
|
||||||
|
export type FeedPostTypeFilter = (typeof FEED_POST_TYPE_FILTERS)[number];
|
||||||
|
|
||||||
export class FeedQueryDto extends PaginationQueryDto {
|
export class FeedQueryDto extends PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(PostType)
|
@IsEnum(FEED_POST_TYPE_FILTERS)
|
||||||
preferredPostType?: PostType;
|
preferredPostType?: FeedPostTypeFilter;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export class FeedController {
|
|||||||
return this.feedService.getMyFeed(user.sub, query);
|
return this.feedService.getMyFeed(user.sub, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('reels')
|
||||||
|
async reels(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) {
|
||||||
|
return this.feedService.getReels(user.sub, query);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('trending')
|
@Get('trending')
|
||||||
|
|||||||
231
src/modules/feed/feed.service.spec.ts
Normal file
231
src/modules/feed/feed.service.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Types } from 'mongoose';
|
||||||
|
import { PostType } from '../../common/enums/post-type.enum';
|
||||||
|
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||||
|
import { decodeOffsetCursor } from '../../common/utils/cursor.util';
|
||||||
|
import { buildPostMediaResponse } from '../../common/utils/post-media-response.util';
|
||||||
|
import { FeedService } from './feed.service';
|
||||||
|
|
||||||
|
const makePost = (input: Partial<Record<string, any>> = {}) => {
|
||||||
|
const id = input.id ?? new Types.ObjectId().toString();
|
||||||
|
const authorId = input.authorId ?? new Types.ObjectId().toString();
|
||||||
|
const postType = input.postType ?? PostType.TEXT;
|
||||||
|
const raw = {
|
||||||
|
_id: new Types.ObjectId(id),
|
||||||
|
id,
|
||||||
|
authorId: {
|
||||||
|
_id: new Types.ObjectId(authorId),
|
||||||
|
id: authorId,
|
||||||
|
username: `author_${authorId.slice(-4)}`,
|
||||||
|
isVerified: false,
|
||||||
|
},
|
||||||
|
content: input.content ?? `${postType} content`,
|
||||||
|
postType,
|
||||||
|
visibility: input.visibility ?? PostVisibility.PUBLIC,
|
||||||
|
videoUrl: input.videoUrl ?? '',
|
||||||
|
hlsUrl: input.hlsUrl ?? '',
|
||||||
|
thumbnailUrl: input.thumbnailUrl ?? '',
|
||||||
|
likesCount: input.likesCount ?? 0,
|
||||||
|
commentsCount: input.commentsCount ?? 0,
|
||||||
|
savesCount: input.savesCount ?? 0,
|
||||||
|
shareCount: input.shareCount ?? 0,
|
||||||
|
viewCount: input.viewCount ?? 0,
|
||||||
|
playCount: input.playCount ?? 0,
|
||||||
|
commentsDisabled: input.commentsDisabled ?? false,
|
||||||
|
createdAt: input.createdAt ?? new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
toObject: () => ({
|
||||||
|
...raw,
|
||||||
|
media: buildPostMediaResponse(raw),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createService = () => {
|
||||||
|
const feedRepository = {
|
||||||
|
findFollowingIds: jest.fn().mockResolvedValue([]),
|
||||||
|
findCandidatePosts: jest.fn().mockResolvedValue([]),
|
||||||
|
findTrendingPublicPosts: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
const usersRepository = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
id: new Types.ObjectId().toString(),
|
||||||
|
musicGenres: [],
|
||||||
|
favoriteInstruments: [],
|
||||||
|
favoriteMaqamat: [],
|
||||||
|
musicRoles: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const cacheService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
};
|
||||||
|
const configService = {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
if (key === 'feedCache.enabled') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const likesRepository = {
|
||||||
|
findLikedPostIds: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const savesRepository = {
|
||||||
|
findSavedPostIds: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const connection = {
|
||||||
|
collection: jest.fn(() => ({
|
||||||
|
find: jest.fn(() => ({
|
||||||
|
project: jest.fn().mockReturnThis(),
|
||||||
|
toArray: jest.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new FeedService(
|
||||||
|
feedRepository as any,
|
||||||
|
usersRepository as any,
|
||||||
|
cacheService as any,
|
||||||
|
{ getGlobalVersion: jest.fn(), bumpGlobalVersion: jest.fn() } as any,
|
||||||
|
configService as any,
|
||||||
|
likesRepository as any,
|
||||||
|
savesRepository as any,
|
||||||
|
{ getSuggestions: jest.fn() } as any,
|
||||||
|
{
|
||||||
|
getPublicListings: jest.fn(),
|
||||||
|
getPublicInstruments: jest.fn(),
|
||||||
|
getPublicRepairShops: jest.fn(),
|
||||||
|
} as any,
|
||||||
|
{ getInvisibleUserIds: jest.fn().mockResolvedValue([]) } as any,
|
||||||
|
connection as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, feedRepository, likesRepository, savesRepository };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('FeedService reels feed', () => {
|
||||||
|
it('keeps /feed/me without preferredPostType unfiltered', async () => {
|
||||||
|
const { service, feedRepository } = createService();
|
||||||
|
const posts = [
|
||||||
|
makePost({ postType: PostType.IMAGE }),
|
||||||
|
makePost({ postType: PostType.VIDEO, videoUrl: 'https://cdn.example.com/video.mp4' }),
|
||||||
|
];
|
||||||
|
feedRepository.findCandidatePosts.mockResolvedValue(posts);
|
||||||
|
|
||||||
|
const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
|
||||||
|
includeSuggestions: false,
|
||||||
|
limit: 10,
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items.map((item: any) => item.postType).sort()).toEqual(['image', 'video']);
|
||||||
|
expect(result.pagination.mode).toBe('cursor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps preferredPostType=reel to video posts and normalizes response postType', async () => {
|
||||||
|
const { service, feedRepository, likesRepository, savesRepository } = createService();
|
||||||
|
const reel = makePost({
|
||||||
|
postType: PostType.VIDEO,
|
||||||
|
videoUrl: 'https://cdn.example.com/reel.mp4',
|
||||||
|
hlsUrl: 'https://cdn.example.com/reel.m3u8',
|
||||||
|
thumbnailUrl: 'https://cdn.example.com/reel.jpg',
|
||||||
|
likesCount: 2,
|
||||||
|
commentsCount: 3,
|
||||||
|
savesCount: 4,
|
||||||
|
shareCount: 5,
|
||||||
|
viewCount: 6,
|
||||||
|
playCount: 7,
|
||||||
|
commentsDisabled: true,
|
||||||
|
});
|
||||||
|
const image = makePost({ postType: PostType.IMAGE });
|
||||||
|
feedRepository.findCandidatePosts.mockResolvedValue([reel, image]);
|
||||||
|
likesRepository.findLikedPostIds.mockResolvedValue([reel.id]);
|
||||||
|
savesRepository.findSavedPostIds.mockResolvedValue([reel.id]);
|
||||||
|
|
||||||
|
const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
|
||||||
|
preferredPostType: 'reel',
|
||||||
|
includeSuggestions: false,
|
||||||
|
limit: 10,
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
expect(feedRepository.findCandidatePosts).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
$and: expect.arrayContaining([expect.objectContaining({ postType: PostType.VIDEO })]),
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: reel.id,
|
||||||
|
postType: 'reel',
|
||||||
|
feedItemType: 'post',
|
||||||
|
likedByMe: true,
|
||||||
|
savedByMe: true,
|
||||||
|
isSharedByMe: false,
|
||||||
|
commentsDisabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((result.items[0] as any).media).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
preferredPlaybackUrl: 'https://cdn.example.com/reel.m3u8',
|
||||||
|
hlsUrl: 'https://cdn.example.com/reel.m3u8',
|
||||||
|
videoUrl: 'https://cdn.example.com/reel.mp4',
|
||||||
|
thumbnailUrl: 'https://cdn.example.com/reel.jpg',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((result.items[0] as any).engagement).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
likesCount: 2,
|
||||||
|
commentsCount: 3,
|
||||||
|
savesCount: 4,
|
||||||
|
shareCount: 5,
|
||||||
|
viewCount: 6,
|
||||||
|
playCount: 7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cursor pagination for reels', async () => {
|
||||||
|
const { service, feedRepository } = createService();
|
||||||
|
feedRepository.findCandidatePosts.mockResolvedValue([
|
||||||
|
makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:02:00.000Z') }),
|
||||||
|
makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:01:00.000Z') }),
|
||||||
|
makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:00:00.000Z') }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const firstPage = (await service.getMyFeed(new Types.ObjectId().toString(), {
|
||||||
|
preferredPostType: 'reel',
|
||||||
|
includeSuggestions: false,
|
||||||
|
limit: 2,
|
||||||
|
})) as any;
|
||||||
|
const secondPage = (await service.getMyFeed(new Types.ObjectId().toString(), {
|
||||||
|
preferredPostType: 'reel',
|
||||||
|
includeSuggestions: false,
|
||||||
|
limit: 2,
|
||||||
|
cursor: firstPage.nextCursor ?? undefined,
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
expect(firstPage.items).toHaveLength(2);
|
||||||
|
expect(firstPage.nextCursor).toBeTruthy();
|
||||||
|
expect(decodeOffsetCursor(firstPage.nextCursor ?? undefined)).toBe(2);
|
||||||
|
expect(secondPage.items).toHaveLength(1);
|
||||||
|
expect(secondPage.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes /feed/reels through the same reel contract', async () => {
|
||||||
|
const { service, feedRepository } = createService();
|
||||||
|
feedRepository.findCandidatePosts.mockResolvedValue([
|
||||||
|
makePost({ postType: PostType.VIDEO, videoUrl: 'https://cdn.example.com/reel.mp4' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = (await service.getReels(new Types.ObjectId().toString(), { limit: 10 })) as any;
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toEqual(expect.objectContaining({ postType: 'reel' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import { PostShareTarget } from '../posts/dto/share-post.dto';
|
|||||||
import { SavesRepository } from '../saves/saves.repository';
|
import { SavesRepository } from '../saves/saves.repository';
|
||||||
import { UserDocument } from '../users/schemas/user.schema';
|
import { UserDocument } from '../users/schemas/user.schema';
|
||||||
import { UsersRepository } from '../users/users.repository';
|
import { UsersRepository } from '../users/users.repository';
|
||||||
import { FeedQueryDto } from './dto/feed-query.dto';
|
import { FeedPostTypeFilter, FeedQueryDto } from './dto/feed-query.dto';
|
||||||
import { FeedRepository } from './feed.repository';
|
import { FeedRepository } from './feed.repository';
|
||||||
|
|
||||||
type FeedPostItem = Record<string, unknown> & {
|
type FeedPostItem = Record<string, unknown> & {
|
||||||
@@ -119,6 +119,7 @@ export class FeedService {
|
|||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const radiusKm = query.radiusKm ?? 30;
|
const radiusKm = query.radiusKm ?? 30;
|
||||||
const skip = cursorOffset ?? (page - 1) * limit;
|
const skip = cursorOffset ?? (page - 1) * limit;
|
||||||
|
const requestedPostType = this.resolveRequestedPostType(query.preferredPostType);
|
||||||
|
|
||||||
const [followingIds, invisibleUserIds] = await Promise.all([
|
const [followingIds, invisibleUserIds] = await Promise.all([
|
||||||
this.feedRepository.findFollowingIds(currentUserId),
|
this.feedRepository.findFollowingIds(currentUserId),
|
||||||
@@ -126,6 +127,9 @@ export class FeedService {
|
|||||||
]);
|
]);
|
||||||
const relationLookupMs = this.markTiming(timing);
|
const relationLookupMs = this.markTiming(timing);
|
||||||
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds);
|
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds);
|
||||||
|
if (requestedPostType) {
|
||||||
|
filter = { $and: [filter, { postType: requestedPostType }] };
|
||||||
|
}
|
||||||
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||||
const firstCandidateLookupMs = this.markTiming(timing);
|
const firstCandidateLookupMs = this.markTiming(timing);
|
||||||
|
|
||||||
@@ -137,17 +141,18 @@ export class FeedService {
|
|||||||
followingOnly
|
followingOnly
|
||||||
) {
|
) {
|
||||||
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds);
|
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds);
|
||||||
|
if (requestedPostType) {
|
||||||
|
filter = { $and: [filter, { postType: requestedPostType }] };
|
||||||
|
}
|
||||||
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||||
}
|
}
|
||||||
const fallbackCandidateLookupMs = this.markTiming(timing);
|
const fallbackCandidateLookupMs = this.markTiming(timing);
|
||||||
|
|
||||||
const scored = candidates
|
const filteredCandidates = requestedPostType
|
||||||
.filter((post) => {
|
? candidates.filter((post) => post.postType === requestedPostType)
|
||||||
if (!query.preferredPostType) {
|
: candidates;
|
||||||
return true;
|
|
||||||
}
|
const scored = filteredCandidates
|
||||||
return post.postType === query.preferredPostType;
|
|
||||||
})
|
|
||||||
.map((post) => ({
|
.map((post) => ({
|
||||||
post,
|
post,
|
||||||
score: this.scorePost({
|
score: this.scorePost({
|
||||||
@@ -173,10 +178,14 @@ export class FeedService {
|
|||||||
feedScore: Number(entry.score.toFixed(3)),
|
feedScore: Number(entry.score.toFixed(3)),
|
||||||
}));
|
}));
|
||||||
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
|
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
|
||||||
|
const normalizedPosts = this.normalizePreferredPostTypeForResponse(
|
||||||
|
decoratedPosts,
|
||||||
|
query.preferredPostType,
|
||||||
|
);
|
||||||
const decorationMs = this.markTiming(timing);
|
const decorationMs = this.markTiming(timing);
|
||||||
const items = includeSuggestions
|
const items = includeSuggestions
|
||||||
? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4)
|
? await this.mixHomeFeedItems(currentUserId, normalizedPosts, query.suggestionInterval ?? 4)
|
||||||
: decoratedPosts;
|
: normalizedPosts;
|
||||||
const cardsMs = this.markTiming(timing);
|
const cardsMs = this.markTiming(timing);
|
||||||
const nextOffset = skip + pagedPosts.length;
|
const nextOffset = skip + pagedPosts.length;
|
||||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
@@ -209,9 +218,10 @@ export class FeedService {
|
|||||||
followingCount: followingIds.length,
|
followingCount: followingIds.length,
|
||||||
invisibleUserCount: invisibleUserIds.length,
|
invisibleUserCount: invisibleUserIds.length,
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
|
filteredCandidateCount: filteredCandidates.length,
|
||||||
scoredCount: scored.length,
|
scoredCount: scored.length,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
postItemCount: decoratedPosts.length,
|
postItemCount: normalizedPosts.length,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
relationLookupMs,
|
relationLookupMs,
|
||||||
firstCandidateLookupMs,
|
firstCandidateLookupMs,
|
||||||
@@ -233,6 +243,14 @@ export class FeedService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getReels(currentUserId: string, query: FeedQueryDto) {
|
||||||
|
return this.getMyFeed(currentUserId, {
|
||||||
|
...query,
|
||||||
|
preferredPostType: 'reel',
|
||||||
|
includeSuggestions: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getTrending(currentUserId: string, query: FeedQueryDto) {
|
async getTrending(currentUserId: string, query: FeedQueryDto) {
|
||||||
const timing = this.startTiming('feed.trending');
|
const timing = this.startTiming('feed.trending');
|
||||||
const cacheEnabled =
|
const cacheEnabled =
|
||||||
@@ -272,8 +290,9 @@ export class FeedService {
|
|||||||
if (invisibleUserIds.length) {
|
if (invisibleUserIds.length) {
|
||||||
trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) };
|
trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) };
|
||||||
}
|
}
|
||||||
if (query.preferredPostType) {
|
const requestedPostType = this.resolveRequestedPostType(query.preferredPostType);
|
||||||
trendingFilter.postType = query.preferredPostType;
|
if (requestedPostType) {
|
||||||
|
trendingFilter.postType = requestedPostType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, total] = await Promise.all([
|
const [rows, total] = await Promise.all([
|
||||||
@@ -286,11 +305,15 @@ export class FeedService {
|
|||||||
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
|
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
|
||||||
followingIds,
|
followingIds,
|
||||||
);
|
);
|
||||||
|
const normalizedPosts = this.normalizePreferredPostTypeForResponse(
|
||||||
|
decoratedPosts,
|
||||||
|
query.preferredPostType,
|
||||||
|
);
|
||||||
const decorationMs = this.markTiming(timing);
|
const decorationMs = this.markTiming(timing);
|
||||||
const nextOffset = skip + rows.length;
|
const nextOffset = skip + rows.length;
|
||||||
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
|
||||||
|
|
||||||
const result = buildPaginatedResponse(decoratedPosts, {
|
const result = buildPaginatedResponse(normalizedPosts, {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
@@ -316,7 +339,7 @@ export class FeedService {
|
|||||||
preferredPostType: query.preferredPostType ?? '',
|
preferredPostType: query.preferredPostType ?? '',
|
||||||
followingCount: followingIds.length,
|
followingCount: followingIds.length,
|
||||||
invisibleUserCount: invisibleUserIds.length,
|
invisibleUserCount: invisibleUserIds.length,
|
||||||
itemCount: decoratedPosts.length,
|
itemCount: normalizedPosts.length,
|
||||||
total,
|
total,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
relationLookupMs,
|
relationLookupMs,
|
||||||
@@ -537,7 +560,7 @@ export class FeedService {
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
followingIds: string[];
|
followingIds: string[];
|
||||||
post: Record<string, any>;
|
post: Record<string, any>;
|
||||||
preferredPostType?: PostType;
|
preferredPostType?: FeedPostTypeFilter;
|
||||||
radiusKm: number;
|
radiusKm: number;
|
||||||
}): number {
|
}): number {
|
||||||
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
|
||||||
@@ -680,6 +703,28 @@ export class FeedService {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveRequestedPostType(preferredPostType?: FeedPostTypeFilter): PostType | null {
|
||||||
|
if (!preferredPostType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredPostType === 'reel' ? PostType.VIDEO : preferredPostType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePreferredPostTypeForResponse<T extends FeedPostItem>(
|
||||||
|
items: T[],
|
||||||
|
preferredPostType?: FeedPostTypeFilter,
|
||||||
|
): T[] {
|
||||||
|
if (preferredPostType !== 'reel') {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
postType: 'reel',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private startTiming(scope: string): { scope: string; startedAt: number; lastAt: number } {
|
private startTiming(scope: string): { scope: string; startedAt: number; lastAt: number } {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
return { scope, startedAt: now, lastAt: now };
|
return { scope, startedAt: now, lastAt: now };
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم