add chat
This commit is contained in:
parent
22ab359c9e
commit
4d3428e56c
@ -48,6 +48,7 @@
|
|||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"geoip-lite": "^1.4.10",
|
"geoip-lite": "^1.4.10",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
|
"graphql-subscriptions": "^3.0.0",
|
||||||
"graphql-upload": "14",
|
"graphql-upload": "14",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
"i18n-iso-countries": "^7.14.0",
|
"i18n-iso-countries": "^7.14.0",
|
||||||
|
|||||||
@ -22,10 +22,27 @@ model Stream {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
categoryId String? @map("category_id")
|
categoryId String? @map("category_id")
|
||||||
|
chatMessages ChatMessage[]
|
||||||
|
isChatEnable Boolean @default(true) @map("is_chat_enable")
|
||||||
|
isChatFollowersOnly Boolean @default(false) @map("is_chat_followers_onlys")
|
||||||
|
isChatPremiumFollowersOnly Boolean @default(false) @map("is_chat_premium_followers_onlys")
|
||||||
|
|
||||||
@@map("stream")
|
@@map("stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ChatMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
text String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String @map("user_id")
|
||||||
|
stream Stream @relation(fields: [streamId], references: [id], onDelete: Cascade)
|
||||||
|
streamId String @map("stream_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("chat_messages ")
|
||||||
|
}
|
||||||
|
|
||||||
model SocialLink {
|
model SocialLink {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
@ -56,6 +73,7 @@ model User {
|
|||||||
socialLink SocialLink[]
|
socialLink SocialLink[]
|
||||||
totpSecret String? @map("totp_secret")
|
totpSecret String? @map("totp_secret")
|
||||||
stream Stream?
|
stream Stream?
|
||||||
|
chatMessages ChatMessage[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|||||||
@ -16,5 +16,6 @@ export function getGraphQLConfig(configService: ConfigService): ApolloDriverConf
|
|||||||
autoSchemaFile: path.join(process.cwd(), 'src', 'core', 'graphql', 'schema.gql'),
|
autoSchemaFile: path.join(process.cwd(), 'src', 'core', 'graphql', 'schema.gql'),
|
||||||
sortSchema: true,
|
sortSchema: true,
|
||||||
context: ({ req, res }: ContextType) => ({ req, res }),
|
context: ({ req, res }: ContextType) => ({ req, res }),
|
||||||
|
installSubscriptionHandlers: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { SessionModule } from '@/src/module/auth/session/session.module'
|
|||||||
import { TotpModule } from '@/src/module/auth/totp/totp.module'
|
import { TotpModule } from '@/src/module/auth/totp/totp.module'
|
||||||
import { VerificationModule } from '@/src/module/auth/verification/verification.module'
|
import { VerificationModule } from '@/src/module/auth/verification/verification.module'
|
||||||
import { CategoryModule } from '@/src/module/category/category.module'
|
import { CategoryModule } from '@/src/module/category/category.module'
|
||||||
|
import { ChatModule } from '@/src/module/chat/chat.module'
|
||||||
import { CronModule } from '@/src/module/cron/cron.module'
|
import { CronModule } from '@/src/module/cron/cron.module'
|
||||||
import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module'
|
import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module'
|
||||||
import { MailModule } from '@/src/module/libs/mail/mail.module'
|
import { MailModule } from '@/src/module/libs/mail/mail.module'
|
||||||
@ -56,6 +57,7 @@ import { RedisModule } from './redis/redis.module'
|
|||||||
IngressModule,
|
IngressModule,
|
||||||
WebhookModule,
|
WebhookModule,
|
||||||
CategoryModule,
|
CategoryModule,
|
||||||
|
ChatModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule {}
|
export class CoreModule {}
|
||||||
|
|||||||
@ -18,6 +18,12 @@ type CategoryModel {
|
|||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input ChangeChatSettingsInput {
|
||||||
|
isChatEnable: Boolean!
|
||||||
|
isChatFollowersOnly: Boolean!
|
||||||
|
isChatPremiumFollowersOnly: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
input ChangeEmailInput {
|
input ChangeEmailInput {
|
||||||
email: String!
|
email: String!
|
||||||
}
|
}
|
||||||
@ -38,6 +44,15 @@ input ChangeStreamInfoInput {
|
|||||||
title: String!
|
title: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatMessageModel {
|
||||||
|
createdAt: DateTime!
|
||||||
|
id: ID!
|
||||||
|
streamId: ID!
|
||||||
|
text: String!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
userId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
input CreateUserInput {
|
input CreateUserInput {
|
||||||
email: String!
|
email: String!
|
||||||
name: String!
|
name: String!
|
||||||
@ -95,6 +110,7 @@ input LoginInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
changeChatSettings(data: ChangeChatSettingsInput!): StreamModel!
|
||||||
changeEmail(data: ChangeEmailInput!): UserModel!
|
changeEmail(data: ChangeEmailInput!): UserModel!
|
||||||
changePassword(data: ChangePasswordInput!): UserModel!
|
changePassword(data: ChangePasswordInput!): UserModel!
|
||||||
changeProfileAvatar(avatar: Upload!): Boolean!
|
changeProfileAvatar(avatar: Upload!): Boolean!
|
||||||
@ -117,6 +133,7 @@ type Mutation {
|
|||||||
removeStreamThumbnail: Boolean!
|
removeStreamThumbnail: Boolean!
|
||||||
reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean!
|
reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean!
|
||||||
resetPassword(data: ResetPasswordInput!): Boolean!
|
resetPassword(data: ResetPasswordInput!): Boolean!
|
||||||
|
sendChatMessage(data: SendMessageInput!): ChatMessageModel!
|
||||||
setNewPassword(data: NewPasswordInput!): Boolean!
|
setNewPassword(data: NewPasswordInput!): Boolean!
|
||||||
updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel!
|
updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel!
|
||||||
verifyAccount(data: VerificationInput!): AuthModel!
|
verifyAccount(data: VerificationInput!): AuthModel!
|
||||||
@ -133,6 +150,7 @@ type Query {
|
|||||||
findAllStreams(filters: FilterInput!): [StreamModel!]!
|
findAllStreams(filters: FilterInput!): [StreamModel!]!
|
||||||
findCategoryBySlug(slug: String!): CategoryModel!
|
findCategoryBySlug(slug: String!): CategoryModel!
|
||||||
findCurrentSession: SessionModel!
|
findCurrentSession: SessionModel!
|
||||||
|
findMessagesByStream(streamId: String!): [ChatMessageModel!]!
|
||||||
findProfile: UserModel!
|
findProfile: UserModel!
|
||||||
findRandomCategories: [CategoryModel!]!
|
findRandomCategories: [CategoryModel!]!
|
||||||
findRandomStreams: [StreamModel!]!
|
findRandomStreams: [StreamModel!]!
|
||||||
@ -145,6 +163,11 @@ input ResetPasswordInput {
|
|||||||
email: String!
|
email: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input SendMessageInput {
|
||||||
|
streamId: String!
|
||||||
|
text: String!
|
||||||
|
}
|
||||||
|
|
||||||
type SessionMetadataModel {
|
type SessionMetadataModel {
|
||||||
device: DeviceModel!
|
device: DeviceModel!
|
||||||
ip: String!
|
ip: String!
|
||||||
@ -181,9 +204,13 @@ input SocialLinkOrderInput {
|
|||||||
type StreamModel {
|
type StreamModel {
|
||||||
category: CategoryModel!
|
category: CategoryModel!
|
||||||
categoryId: ID!
|
categoryId: ID!
|
||||||
|
chatMessages: [ChatMessageModel!]!
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
id: ID!
|
id: ID!
|
||||||
ingressId: String
|
ingressId: String
|
||||||
|
isChatEnable: Boolean!
|
||||||
|
isChatFollowersOnly: Boolean!
|
||||||
|
isChatPremiumFollowersOnly: Boolean!
|
||||||
isLive: Boolean!
|
isLive: Boolean!
|
||||||
key: String
|
key: String
|
||||||
serverUrl: String
|
serverUrl: String
|
||||||
@ -194,6 +221,10 @@ type StreamModel {
|
|||||||
userId: ID!
|
userId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Subscription {
|
||||||
|
chatMessageAdded(streamId: String!): ChatMessageModel!
|
||||||
|
}
|
||||||
|
|
||||||
type TotpModel {
|
type TotpModel {
|
||||||
qrcodeUrl: String!
|
qrcodeUrl: String!
|
||||||
secret: String!
|
secret: String!
|
||||||
|
|||||||
8
backend/src/module/chat/chat.module.ts
Normal file
8
backend/src/module/chat/chat.module.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { ChatService } from './chat.service'
|
||||||
|
import { ChatResolver } from './chat.resolver'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ChatResolver, ChatService],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
49
backend/src/module/chat/chat.resolver.ts
Normal file
49
backend/src/module/chat/chat.resolver.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input'
|
||||||
|
import { SendMessageInput } from '@/src/module/chat/input/send-message.input'
|
||||||
|
import { StreamModel } from '@/src/module/stream/models/stream.model'
|
||||||
|
import { Authorization } from '@/src/shared/decorators/auth.decorator'
|
||||||
|
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
|
||||||
|
import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql'
|
||||||
|
import { ChatMessage, User } from '@prisma/generated'
|
||||||
|
import { PubSub } from 'graphql-subscriptions'
|
||||||
|
import { ChatService } from './chat.service'
|
||||||
|
import { ChatMessageModel } from './models/chat-message.model'
|
||||||
|
|
||||||
|
@Resolver('Chat')
|
||||||
|
export class ChatResolver {
|
||||||
|
public pubSub: PubSub
|
||||||
|
constructor(private readonly chatService: ChatService) {
|
||||||
|
this.pubSub = new PubSub()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => [ChatMessageModel], { name: 'findMessagesByStream' })
|
||||||
|
public async findMessagesByStream(@Args('streamId') streamId: string) {
|
||||||
|
return this.chatService.findMessagesByStream(streamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Authorization()
|
||||||
|
@Mutation(() => ChatMessageModel, { name: 'sendChatMessage' })
|
||||||
|
public async sendMessage(
|
||||||
|
@Authorized('id') userId: User['id'],
|
||||||
|
@Args('data') input: SendMessageInput,
|
||||||
|
) {
|
||||||
|
const message = this.chatService.sendMessage(userId, input)
|
||||||
|
void this.pubSub.publish('CHAT_MESSAGE_ADDED', { message })
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscription(() => ChatMessageModel, { name: 'chatMessageAdded', filter: (payload: { message: ChatMessage }, variables: ChatMessageModel) => payload.message.streamId === variables.streamId })
|
||||||
|
public async chatMessageAdded(@Args('streamId') streamId: string) {
|
||||||
|
return this.pubSub.asyncIterableIterator('CHAT_MESSAGE_ADDED')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Authorization()
|
||||||
|
@Mutation(() => StreamModel, { name: 'changeChatSettings' })
|
||||||
|
public async changeSettings(
|
||||||
|
@Args('data') input: ChangeChatSettingsInput,
|
||||||
|
@Authorized() user: User,
|
||||||
|
) {
|
||||||
|
return this.chatService.changeSettings(user, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/module/chat/chat.service.ts
Normal file
70
backend/src/module/chat/chat.service.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { PrismaService } from '@/src/core/prisma/prisma.service'
|
||||||
|
import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input'
|
||||||
|
import { SendMessageInput } from '@/src/module/chat/input/send-message.input'
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||||
|
import { Stream, User } from '@prisma/generated'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatService {
|
||||||
|
constructor(
|
||||||
|
public readonly prismaService: PrismaService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findMessagesByStream(streamId: Stream['id']) {
|
||||||
|
return this.prismaService.chatMessage.findMany({
|
||||||
|
where: { streamId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendMessage(userId: User['id'], input: SendMessageInput) {
|
||||||
|
const { text, streamId } = input
|
||||||
|
|
||||||
|
const stream = await this.prismaService.stream.findUnique({
|
||||||
|
where: { id: streamId },
|
||||||
|
})
|
||||||
|
if (!stream) {
|
||||||
|
throw new NotFoundException('Стрим не найден')
|
||||||
|
}
|
||||||
|
if (!stream.isLive) {
|
||||||
|
throw new BadRequestException('Стрим не запущен')
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prismaService.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
})
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Пользователь не найден')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prismaService.chatMessage.create({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stream: {
|
||||||
|
connect: {
|
||||||
|
id: stream.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async changeSettings(user: User, input: ChangeChatSettingsInput) {
|
||||||
|
const { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly } = input
|
||||||
|
|
||||||
|
return this.prismaService.stream.update({
|
||||||
|
where: { userId: user.id },
|
||||||
|
data: { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/src/module/chat/input/change-chat-settings.input.ts
Normal file
17
backend/src/module/chat/input/change-chat-settings.input.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Field, InputType } from '@nestjs/graphql'
|
||||||
|
import { IsBoolean } from 'class-validator'
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class ChangeChatSettingsInput {
|
||||||
|
@Field(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
public isChatEnable: boolean
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
public isChatFollowersOnly: boolean
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
public isChatPremiumFollowersOnly: boolean
|
||||||
|
}
|
||||||
16
backend/src/module/chat/input/send-message.input.ts
Normal file
16
backend/src/module/chat/input/send-message.input.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Field, InputType } from '@nestjs/graphql'
|
||||||
|
import { Stream } from '@prisma/generated'
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator'
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class SendMessageInput {
|
||||||
|
@Field(() => String)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
public text: string
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
streamId: Stream['id']
|
||||||
|
}
|
||||||
25
backend/src/module/chat/models/chat-message.model.ts
Normal file
25
backend/src/module/chat/models/chat-message.model.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { UserModel } from '@/src/module/auth/account/models/user.model'
|
||||||
|
import { StreamModel } from '@/src/module/stream/models/stream.model'
|
||||||
|
import { Field, ID, ObjectType } from '@nestjs/graphql'
|
||||||
|
import { ChatMessage } from '@prisma/generated'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ChatMessageModel implements ChatMessage {
|
||||||
|
@Field(() => ID)
|
||||||
|
public id: string
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
text: string
|
||||||
|
|
||||||
|
@Field(() => ID)
|
||||||
|
streamId: StreamModel['id']
|
||||||
|
|
||||||
|
@Field(() => ID)
|
||||||
|
userId: UserModel['id']
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
public createdAt: Date
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
public updatedAt: Date
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { UserModel } from '@/src/module/auth/account/models/user.model'
|
import { UserModel } from '@/src/module/auth/account/models/user.model'
|
||||||
import { CategoryModel } from '@/src/module/category/models/category.model';
|
import { CategoryModel } from '@/src/module/category/models/category.model'
|
||||||
|
import { ChatMessageModel } from '@/src/module/chat/models/chat-message.model';
|
||||||
import { Field, ID, ObjectType } from '@nestjs/graphql'
|
import { Field, ID, ObjectType } from '@nestjs/graphql'
|
||||||
import { Stream } from '@prisma/generated'
|
import { Stream } from '@prisma/generated'
|
||||||
|
|
||||||
@ -38,6 +39,18 @@ export class StreamModel implements Stream {
|
|||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
ingressId: string
|
ingressId: string
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isChatEnable: boolean
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isChatPremiumFollowersOnly: boolean
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isChatFollowersOnly: boolean
|
||||||
|
|
||||||
|
@Field(() => [ChatMessageModel])
|
||||||
|
chatMessages: ChatMessageModel[]
|
||||||
|
|
||||||
@Field(() => Date)
|
@Field(() => Date)
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
|
|
||||||
|
|||||||
@ -6013,6 +6013,11 @@ graphemer@^1.4.0:
|
|||||||
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
|
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
|
||||||
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
|
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
|
||||||
|
|
||||||
|
graphql-subscriptions@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/graphql-subscriptions/-/graphql-subscriptions-3.0.0.tgz#820c846ef271414c08f64827b5c9a192801e1b6f"
|
||||||
|
integrity sha512-kZCdevgmzDjGAOqH7GlDmQXYAkuHoKpMlJrqF40HMPhUhM5ZWSFSxCwD/nSi6AkaijmMfsFhoJRGJ27UseCvRA==
|
||||||
|
|
||||||
graphql-tag@2.12.6:
|
graphql-tag@2.12.6:
|
||||||
version "2.12.6"
|
version "2.12.6"
|
||||||
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"
|
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user