Per-stream visibility: isPublic on StreamPlan, PATCH endpoint, feed + profile updates
This commit is contained in:
46
package-lock.json
generated
46
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.2.1",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "^6.4.1",
|
||||
@@ -490,6 +491,12 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
||||
@@ -530,6 +537,22 @@
|
||||
"mnemonist": "0.40.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/deepmerge": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
|
||||
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
@@ -600,6 +623,29 @@
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/multipart": {
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz",
|
||||
"integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^3.0.0",
|
||||
"@fastify/deepmerge": "^3.0.0",
|
||||
"@fastify/error": "^4.0.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"secure-json-parse": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/proxy-addr": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.2.1",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "^6.4.1",
|
||||
|
||||
@@ -78,6 +78,7 @@ model StreamPlan {
|
||||
status String @default("DRAFT")
|
||||
executionMode String @default("IN_GAME")
|
||||
gameId String @default("")
|
||||
isPublic Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
destinations StreamDestination[]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import multipart from '@fastify/multipart';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import websocket from '@fastify/websocket';
|
||||
import prismaPlugin from './plugins/prisma.js';
|
||||
@@ -15,6 +16,7 @@ import youtubeRoutes from './routes/providers/youtube.js';
|
||||
import twitchRoutes from './routes/providers/twitch.js';
|
||||
import planRoutes from './routes/streams/plans.js';
|
||||
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
||||
import previewRoutes from './routes/streams/preview.js';
|
||||
import followingRoutes from './routes/social/following.js';
|
||||
import feedRoutes from './routes/social/feed.js';
|
||||
import likesRoutes from './routes/social/likes.js';
|
||||
@@ -43,6 +45,7 @@ export async function buildApp() {
|
||||
await app.register(prismaPlugin);
|
||||
await app.register(authPlugin);
|
||||
await app.register(websocket);
|
||||
await app.register(multipart);
|
||||
|
||||
// Chat manager (instantiated after prisma is available)
|
||||
const chatManager = new ChatManager(app.prisma, app.log);
|
||||
@@ -58,6 +61,7 @@ export async function buildApp() {
|
||||
await app.register(twitchRoutes);
|
||||
await app.register(planRoutes);
|
||||
await app.register(lifecycleRoutes);
|
||||
await app.register(previewRoutes);
|
||||
await app.register(followingRoutes);
|
||||
await app.register(feedRoutes);
|
||||
await app.register(likesRoutes);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { optionalAuth } from '../../middleware/require-auth.js';
|
||||
import { PREVIEWS_DIR } from '../streams/preview.js';
|
||||
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
||||
|
||||
const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
@@ -11,10 +14,13 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursorId = request.query.cursor;
|
||||
|
||||
// Base condition: LIVE + ENDED plans from public users
|
||||
// Base condition: LIVE + ENDED plans that are public OR owned by the current user
|
||||
const baseWhere: any = {
|
||||
status: { in: ['LIVE', 'ENDED'] },
|
||||
user: { isPublic: true },
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
...(request.userId ? [{ userId: request.userId }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
// If following filter, restrict to followed users (requires auth)
|
||||
@@ -80,13 +86,16 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
: [];
|
||||
const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName]));
|
||||
|
||||
const feedItems: FeedItemResponse[] = items.map(plan => ({
|
||||
const feedItems: FeedItemResponse[] = items.map(plan => {
|
||||
const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`));
|
||||
return {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
status: plan.status,
|
||||
executionMode: plan.executionMode,
|
||||
gameId: plan.gameId,
|
||||
isPublic: plan.isPublic,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
destinations: plan.destinations.map(d => ({
|
||||
@@ -108,10 +117,19 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
})),
|
||||
user: plan.user,
|
||||
},
|
||||
previewUrl: hasPreview ? `/streams/plans/${plan.id}/preview` : null,
|
||||
likeCount: plan._count.likes,
|
||||
commentCount: 0, // portal comments are ephemeral via WebSocket
|
||||
isLiked: likedSet.has(plan.id),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
// LIVE streams first, then ENDED
|
||||
feedItems.sort((a, b) => {
|
||||
const aLive = a.plan.status === 'LIVE' ? 0 : 1;
|
||||
const bLive = b.plan.status === 'LIVE' ? 0 : 1;
|
||||
return aLive - bLive;
|
||||
});
|
||||
|
||||
const response: FeedResponse = {
|
||||
items: feedItems,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { requireAuth, optionalAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import { formatPlan } from '../streams/plans.js';
|
||||
import type { PublicUserResponse, FollowListResponse } from '../../types/api.js';
|
||||
|
||||
const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
@@ -142,7 +143,7 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// GET /social/users/:userId — public profile
|
||||
fastify.get<{ Params: { userId: string } }>('/social/users/:userId', {
|
||||
preHandler: [requireAuth],
|
||||
preHandler: [optionalAuth],
|
||||
}, async (request) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
@@ -155,7 +156,9 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
const isFollowing = await fastify.prisma.follow.findUnique({
|
||||
let isFollowing = false;
|
||||
if (request.userId) {
|
||||
const follow = await fastify.prisma.follow.findUnique({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: request.userId,
|
||||
@@ -163,6 +166,20 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
isFollowing = !!follow;
|
||||
}
|
||||
|
||||
// Fetch this user's public LIVE + ENDED streams
|
||||
const publicPlans = await fastify.prisma.streamPlan.findMany({
|
||||
where: {
|
||||
userId: targetId,
|
||||
isPublic: true,
|
||||
status: { in: ['LIVE', 'ENDED'] },
|
||||
},
|
||||
include: { destinations: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const response: PublicUserResponse = {
|
||||
id: user.id,
|
||||
@@ -171,7 +188,8 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
bio: user.bio,
|
||||
followerCount: user._count.followers,
|
||||
followingCount: user._count.following,
|
||||
isFollowing: !!isFollowing,
|
||||
isFollowing,
|
||||
streams: publicPlans.map(formatPlan),
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js';
|
||||
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
||||
import { PREVIEWS_DIR } from './preview.js';
|
||||
import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
||||
|
||||
function formatPlan(plan: any): StreamPlanResponse {
|
||||
export function formatPlan(plan: any): StreamPlanResponse {
|
||||
return {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
status: plan.status,
|
||||
executionMode: plan.executionMode ?? 'IN_GAME',
|
||||
gameId: plan.gameId ?? '',
|
||||
isPublic: plan.isPublic ?? false,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({
|
||||
@@ -121,6 +125,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||
gameId: { type: 'string', maxLength: 200 },
|
||||
isPublic: { type: 'boolean' },
|
||||
destinations: {
|
||||
type: 'array',
|
||||
maxItems: 10,
|
||||
@@ -145,7 +150,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { name, executionMode, gameId, destinations } = request.body;
|
||||
const { name, executionMode, gameId, isPublic, destinations } = request.body;
|
||||
|
||||
// Verify user has the required linked accounts
|
||||
const linkedAccounts = await fastify.prisma.linkedAccount.findMany({
|
||||
@@ -185,6 +190,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
name,
|
||||
executionMode: executionMode ?? 'IN_GAME',
|
||||
gameId: gameId ?? '',
|
||||
isPublic: isPublic ?? true,
|
||||
destinations: {
|
||||
create: resolvedDestinations.map((d) => {
|
||||
const isCustom = d.rtmpUrl && d.streamKey;
|
||||
@@ -277,6 +283,13 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan');
|
||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
||||
|
||||
// Clean up preview file if exists
|
||||
const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
|
||||
if (existsSync(previewPath)) {
|
||||
try { unlinkSync(previewPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
@@ -295,6 +308,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||
gameId: { type: 'string', maxLength: 200 },
|
||||
isPublic: { type: 'boolean' },
|
||||
destinations: {
|
||||
type: 'array',
|
||||
maxItems: 10,
|
||||
@@ -326,7 +340,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited');
|
||||
|
||||
const { name, executionMode, gameId, destinations } = request.body;
|
||||
const { name, executionMode, gameId, isPublic, destinations } = request.body;
|
||||
|
||||
// If destinations provided, verify accounts and replace them
|
||||
if (destinations) {
|
||||
@@ -406,12 +420,44 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
...(name !== undefined && { name }),
|
||||
...(executionMode !== undefined && { executionMode }),
|
||||
...(gameId !== undefined && { gameId }),
|
||||
...(isPublic !== undefined && { isPublic }),
|
||||
},
|
||||
include: { destinations: true },
|
||||
});
|
||||
|
||||
reply.status(200).send(formatPlan(updated));
|
||||
});
|
||||
|
||||
// PATCH /streams/plans/:id/visibility — toggle visibility on any status
|
||||
fastify.patch<{ Params: { id: string }; Body: { isPublic: boolean } }>('/streams/plans/:id/visibility', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['isPublic'],
|
||||
properties: { isPublic: { type: 'boolean' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const plan = await fastify.prisma.streamPlan.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
const updated = await fastify.prisma.streamPlan.update({
|
||||
where: { id: plan.id },
|
||||
data: { isPublic: request.body.isPublic },
|
||||
include: { destinations: true },
|
||||
});
|
||||
|
||||
reply.status(200).send(formatPlan(updated));
|
||||
});
|
||||
};
|
||||
|
||||
export default planRoutes;
|
||||
|
||||
96
src/routes/streams/preview.ts
Normal file
96
src/routes/streams/preview.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews');
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Ensure previews directory exists
|
||||
if (!existsSync(PREVIEWS_DIR)) {
|
||||
mkdirSync(PREVIEWS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const previewRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// POST /streams/plans/:id/preview — upload preview clip (multipart)
|
||||
fastify.post<{ Params: { id: string } }>('/streams/plans/:id/preview', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const plan = await fastify.prisma.streamPlan.findFirst({
|
||||
where: { id: request.params.id, userId: request.userId },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
const data = await request.file({ limits: { fileSize: MAX_FILE_SIZE } });
|
||||
if (!data) throw new AppError(400, 'No file uploaded');
|
||||
|
||||
const outputPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
|
||||
await pipeline(data.file, createWriteStream(outputPath));
|
||||
|
||||
// Check if file was truncated (exceeded size limit)
|
||||
if (data.file.truncated) {
|
||||
unlinkSync(outputPath);
|
||||
throw new AppError(413, 'File too large (max 10MB)');
|
||||
}
|
||||
|
||||
request.log.info({ planId: plan.id }, 'Preview clip uploaded');
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// GET /streams/plans/:id/preview — serve preview clip with range support
|
||||
fastify.get<{ Params: { id: string } }>('/streams/plans/:id/preview', {
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const filePath = join(PREVIEWS_DIR, `${request.params.id}.mp4`);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new AppError(404, 'Preview not found');
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = request.headers.range;
|
||||
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Cache-Control', 'public, max-age=30');
|
||||
reply.header('Content-Type', 'video/mp4');
|
||||
|
||||
if (range) {
|
||||
// Parse byte range
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
|
||||
if (start >= fileSize || end >= fileSize || start > end) {
|
||||
reply.status(416).header('Content-Range', `bytes */${fileSize}`).send();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(206);
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
reply.header('Content-Length', end - start + 1);
|
||||
return reply.send(createReadStream(filePath, { start, end }));
|
||||
}
|
||||
|
||||
reply.header('Content-Length', fileSize);
|
||||
return reply.send(createReadStream(filePath));
|
||||
});
|
||||
};
|
||||
|
||||
export default previewRoutes;
|
||||
export { PREVIEWS_DIR };
|
||||
@@ -71,6 +71,7 @@ export interface CreateStreamPlanBody {
|
||||
name: string;
|
||||
executionMode?: string;
|
||||
gameId?: string;
|
||||
isPublic?: boolean;
|
||||
destinations: CreateDestinationBody[];
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export interface UpdateStreamPlanBody {
|
||||
name?: string;
|
||||
executionMode?: string;
|
||||
gameId?: string;
|
||||
isPublic?: boolean;
|
||||
destinations?: CreateDestinationBody[];
|
||||
}
|
||||
|
||||
@@ -98,6 +100,7 @@ export interface StreamPlanResponse {
|
||||
status: string;
|
||||
executionMode: string;
|
||||
gameId: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
destinations: StreamDestinationResponse[];
|
||||
@@ -140,10 +143,12 @@ export interface PublicUserResponse {
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
streams?: StreamPlanResponse[];
|
||||
}
|
||||
|
||||
export interface FeedItemResponse {
|
||||
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
||||
previewUrl: string | null;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
isLiked: boolean;
|
||||
|
||||
Reference in New Issue
Block a user