KI-AGENT: Chat Benachrichtigungen und Ungelesen-Zähler umsetzen
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm"
|
import { and, eq, inArray, ne } from "drizzle-orm"
|
||||||
import { authProfiles, authTenantUsers, authUsers, projects } from "../../db/schema"
|
import { authProfiles, authTenantUsers, authUsers, notificationsItems, projects } from "../../db/schema"
|
||||||
import { matrixService } from "../modules/matrix.service"
|
import { matrixService } from "../modules/matrix.service"
|
||||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
|
|
||||||
@@ -15,6 +15,14 @@ const getUserDirectory: UserDirectory = async (server: FastifyInstance, userId)
|
|||||||
return rows[0] || null
|
return rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatRecipient = {
|
||||||
|
userId: string
|
||||||
|
email?: string | null
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
fullName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export default async function communicationRoutes(server: FastifyInstance) {
|
export default async function communicationRoutes(server: FastifyInstance) {
|
||||||
const matrix = matrixService(server)
|
const matrix = matrixService(server)
|
||||||
const notifications = new NotificationService(server, getUserDirectory)
|
const notifications = new NotificationService(server, getUserDirectory)
|
||||||
@@ -64,6 +72,160 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
return name || user.email || "Benutzer"
|
return name || user.email || "Benutzer"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTenantRecipients = async (tenantId: number, senderUserId: string) => {
|
||||||
|
return await server.db
|
||||||
|
.select({
|
||||||
|
userId: authTenantUsers.user_id,
|
||||||
|
email: authUsers.email,
|
||||||
|
firstName: authProfiles.first_name,
|
||||||
|
lastName: authProfiles.last_name,
|
||||||
|
fullName: authProfiles.full_name,
|
||||||
|
})
|
||||||
|
.from(authTenantUsers)
|
||||||
|
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
|
||||||
|
.leftJoin(authProfiles, and(
|
||||||
|
eq(authProfiles.user_id, authTenantUsers.user_id),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
))
|
||||||
|
.where(and(
|
||||||
|
eq(authTenantUsers.tenant_id, tenantId),
|
||||||
|
ne(authTenantUsers.user_id, senderUserId)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSenderName = async (tenantId: number, senderUserId: string) => {
|
||||||
|
const [sender] = await server.db
|
||||||
|
.select({
|
||||||
|
email: authUsers.email,
|
||||||
|
firstName: authProfiles.first_name,
|
||||||
|
lastName: authProfiles.last_name,
|
||||||
|
fullName: authProfiles.full_name,
|
||||||
|
})
|
||||||
|
.from(authUsers)
|
||||||
|
.leftJoin(authProfiles, and(
|
||||||
|
eq(authProfiles.user_id, authUsers.id),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
))
|
||||||
|
.where(eq(authUsers.id, senderUserId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return sender ? displayUserName(sender) : "FEDEO"
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionAliasesForUser = (user: ChatRecipient) => {
|
||||||
|
const name = displayUserName(user)
|
||||||
|
return Array.from(new Set([
|
||||||
|
name,
|
||||||
|
user.fullName,
|
||||||
|
[user.firstName, user.lastName].filter(Boolean).join(" "),
|
||||||
|
user.firstName,
|
||||||
|
user.email,
|
||||||
|
].filter(Boolean).map((value) => String(value).toLowerCase())))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionedRecipientIds = (text: string, recipients: ChatRecipient[]) => {
|
||||||
|
const normalizedText = text.toLowerCase()
|
||||||
|
|
||||||
|
return recipients
|
||||||
|
.filter((recipient) => mentionAliasesForUser(recipient).some((alias) =>
|
||||||
|
normalizedText.includes(`@${alias}`)
|
||||||
|
))
|
||||||
|
.map((recipient) => recipient.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatMessageRecipients = async (
|
||||||
|
tenantId: number,
|
||||||
|
senderUserId: string,
|
||||||
|
room: any,
|
||||||
|
text: string
|
||||||
|
) => {
|
||||||
|
const recipients = await getTenantRecipients(tenantId, senderUserId)
|
||||||
|
const mentioned = new Set(mentionedRecipientIds(text, recipients))
|
||||||
|
const directRecipients = new Set<string>()
|
||||||
|
|
||||||
|
if (room?.type === "direct" && room.entityUuid && room.entityUuid !== senderUserId) {
|
||||||
|
directRecipients.add(room.entityUuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipients
|
||||||
|
.filter((recipient) => directRecipients.has(recipient.userId) || mentioned.has(recipient.userId))
|
||||||
|
.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
mentioned: mentioned.has(recipient.userId),
|
||||||
|
direct: directRecipients.has(recipient.userId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyUsersAboutChatMessage = async (req: any, room: any, message: any, text: string) => {
|
||||||
|
if (!req.user.tenant_id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recipients = await chatMessageRecipients(req.user.tenant_id, req.user.user_id, room, text)
|
||||||
|
if (!recipients.length) return
|
||||||
|
|
||||||
|
const senderName = await getSenderName(req.user.tenant_id, req.user.user_id)
|
||||||
|
const preview = text.length > 160 ? `${text.slice(0, 157)}...` : text
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await notifications.trigger({
|
||||||
|
tenantId: req.user.tenant_id,
|
||||||
|
userId: recipient.userId,
|
||||||
|
eventType: "communication.message.new",
|
||||||
|
title: recipient.mentioned ? `${senderName} hat dich erwähnt` : `Neue Direktnachricht von ${senderName}`,
|
||||||
|
message: preview,
|
||||||
|
payload: {
|
||||||
|
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
|
||||||
|
roomKey: room.key,
|
||||||
|
roomName: room.name,
|
||||||
|
roomType: room.type,
|
||||||
|
messageId: message.id,
|
||||||
|
mentioned: recipient.mentioned,
|
||||||
|
direct: recipient.direct,
|
||||||
|
},
|
||||||
|
channels: ["inapp", "push"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadChatNotifications = async (tenantId: number, userId: string) => {
|
||||||
|
return await server.db
|
||||||
|
.select({
|
||||||
|
id: notificationsItems.id,
|
||||||
|
payload: notificationsItems.payload,
|
||||||
|
})
|
||||||
|
.from(notificationsItems)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationsItems.tenantId, tenantId),
|
||||||
|
eq(notificationsItems.userId, userId),
|
||||||
|
eq(notificationsItems.eventType, "communication.message.new"),
|
||||||
|
eq(notificationsItems.channel, "inapp"),
|
||||||
|
ne(notificationsItems.status, "read")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markRoomNotificationsRead = async (tenantId: number, userId: string, roomKey: string) => {
|
||||||
|
const rows = await unreadChatNotifications(tenantId, userId)
|
||||||
|
const ids = rows
|
||||||
|
.filter((row) => (row.payload as any)?.roomKey === roomKey)
|
||||||
|
.map((row) => row.id)
|
||||||
|
|
||||||
|
if (!ids.length) return { read: 0 }
|
||||||
|
|
||||||
|
await server.db
|
||||||
|
.update(notificationsItems)
|
||||||
|
.set({ readAt: new Date(), status: "read" })
|
||||||
|
.where(and(
|
||||||
|
eq(notificationsItems.tenantId, tenantId),
|
||||||
|
eq(notificationsItems.userId, userId),
|
||||||
|
inArray(notificationsItems.id, ids)
|
||||||
|
))
|
||||||
|
|
||||||
|
return { read: ids.length }
|
||||||
|
}
|
||||||
|
|
||||||
const callModeFromRequest = (req: any): "audio" | "video" => {
|
const callModeFromRequest = (req: any): "audio" | "video" => {
|
||||||
const body = (req.body || {}) as { mode?: string }
|
const body = (req.body || {}) as { mode?: string }
|
||||||
return body.mode === "audio" ? "audio" : "video"
|
return body.mode === "audio" ? "audio" : "video"
|
||||||
@@ -148,6 +310,32 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.get("/communication/matrix/unread", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
|
||||||
|
|
||||||
|
const rows = await unreadChatNotifications(req.user.tenant_id, req.user.user_id)
|
||||||
|
const rooms = rows.reduce((acc: Record<string, { count: number; mentions: number }>, row) => {
|
||||||
|
const payload = row.payload as any
|
||||||
|
const roomKey = payload?.roomKey
|
||||||
|
if (!roomKey) return acc
|
||||||
|
|
||||||
|
acc[roomKey] = acc[roomKey] || { count: 0, mentions: 0 }
|
||||||
|
acc[roomKey].count += 1
|
||||||
|
|
||||||
|
if (payload.mentioned) {
|
||||||
|
acc[roomKey].mentions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return { rooms }
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix unread state failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/communication/matrix/project-rooms", async (req, reply) => {
|
server.get("/communication/matrix/project-rooms", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
|
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
|
||||||
@@ -414,7 +602,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
|
server.post("/communication/matrix/rooms/general/messages", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as { text?: string }
|
const body = req.body as { text?: string }
|
||||||
return await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
|
const message = await matrix.sendGeneralRoomMessage(req.user.user_id, req.user.tenant_id, body.text || "")
|
||||||
|
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, "allgemein", "Allgemeiner Chat")
|
||||||
|
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
|
||||||
|
return message
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
||||||
}
|
}
|
||||||
@@ -441,6 +632,16 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post("/communication/matrix/rooms/:roomKey/read", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user.tenant_id) return reply.code(400).send({ error: "Kein aktiver Mandant" })
|
||||||
|
const params = req.params as { roomKey: string }
|
||||||
|
return await markRoomNotificationsRead(req.user.tenant_id, req.user.user_id, params.roomKey)
|
||||||
|
} catch (err: any) {
|
||||||
|
return handleMatrixError(req, reply, err, "Matrix room read state failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
server.get("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await matrix.getTenantRoomMessages(
|
return await matrix.getTenantRoomMessages(
|
||||||
@@ -507,12 +708,15 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
|||||||
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
server.post("/communication/matrix/rooms/:roomKey/messages", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as { text?: string }
|
const body = req.body as { text?: string }
|
||||||
return await matrix.sendTenantRoomMessage(
|
const message = await matrix.sendTenantRoomMessage(
|
||||||
req.user.user_id,
|
req.user.user_id,
|
||||||
req.user.tenant_id,
|
req.user.tenant_id,
|
||||||
roomOptionsFromRequest(req),
|
roomOptionsFromRequest(req),
|
||||||
body.text || ""
|
body.text || ""
|
||||||
)
|
)
|
||||||
|
const room = await matrix.getTenantRoomStatus(req.user.tenant_id, message.key)
|
||||||
|
await notifyUsersAboutChatMessage(req, room, message, body.text || "")
|
||||||
|
return message
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
return handleMatrixError(req, reply, err, "Matrix message send failed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { ConnectionState, Room, RoomEvent, Track } from "livekit-client"
|
|||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const status = ref(null)
|
const status = ref(null)
|
||||||
const identity = ref(null)
|
const identity = ref(null)
|
||||||
const matrixRooms = ref([])
|
const matrixRooms = ref([])
|
||||||
const matrixProjectRooms = ref([])
|
const matrixProjectRooms = ref([])
|
||||||
const matrixDirectRooms = ref([])
|
const matrixDirectRooms = ref([])
|
||||||
const activeRoomKey = ref("allgemein")
|
const unreadRooms = ref({})
|
||||||
|
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
|
||||||
const matrixMessages = ref([])
|
const matrixMessages = ref([])
|
||||||
const matrixMembers = ref([])
|
const matrixMembers = ref([])
|
||||||
const matrixMessageDraft = ref("")
|
const matrixMessageDraft = ref("")
|
||||||
@@ -124,6 +126,8 @@ const rooms = computed(() => {
|
|||||||
...room,
|
...room,
|
||||||
group: "Räume",
|
group: "Räume",
|
||||||
icon: "i-heroicons-chat-bubble-left-right",
|
icon: "i-heroicons-chat-bubble-left-right",
|
||||||
|
unread: unreadRooms.value[room.key]?.count || 0,
|
||||||
|
mentions: unreadRooms.value[room.key]?.mentions || 0,
|
||||||
description: room.alias || room.roomId || "Mandantenweiter Austausch"
|
description: room.alias || room.roomId || "Mandantenweiter Austausch"
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -131,6 +135,8 @@ const rooms = computed(() => {
|
|||||||
...room,
|
...room,
|
||||||
group: "Projekte",
|
group: "Projekte",
|
||||||
icon: "i-heroicons-briefcase",
|
icon: "i-heroicons-briefcase",
|
||||||
|
unread: unreadRooms.value[room.key]?.count || 0,
|
||||||
|
mentions: unreadRooms.value[room.key]?.mentions || 0,
|
||||||
description: room.projectNumber || room.topic || "Projektkommunikation",
|
description: room.projectNumber || room.topic || "Projektkommunikation",
|
||||||
provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision`
|
provisionEndpoint: `/api/communication/matrix/project-rooms/${encodeURIComponent(room.projectId)}/provision`
|
||||||
}))
|
}))
|
||||||
@@ -139,6 +145,8 @@ const rooms = computed(() => {
|
|||||||
...room,
|
...room,
|
||||||
group: "Direkt",
|
group: "Direkt",
|
||||||
icon: "i-heroicons-user-circle",
|
icon: "i-heroicons-user-circle",
|
||||||
|
unread: unreadRooms.value[room.key]?.count || 0,
|
||||||
|
mentions: unreadRooms.value[room.key]?.mentions || 0,
|
||||||
description: room.email || room.topic || "Direktnachricht",
|
description: room.email || room.topic || "Direktnachricht",
|
||||||
provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`
|
provisionEndpoint: `/api/communication/matrix/direct-rooms/${encodeURIComponent(room.userId)}/provision`
|
||||||
}))
|
}))
|
||||||
@@ -285,6 +293,33 @@ const scrollMessagesToBottom = async () => {
|
|||||||
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
|
matrixMessagesViewport.value.scrollTop = matrixMessagesViewport.value.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadUnreadCounts = async () => {
|
||||||
|
if (!canUseMatrixChat.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $api("/api/communication/matrix/unread")
|
||||||
|
unreadRooms.value = res.rooms || {}
|
||||||
|
} catch (error) {
|
||||||
|
unreadRooms.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markActiveRoomRead = async () => {
|
||||||
|
if (!canUseMatrixChat.value || !activeRoom.value?.exists) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $api(`${activeRoomEndpoint.value}/read`, {
|
||||||
|
method: "POST"
|
||||||
|
})
|
||||||
|
unreadRooms.value = {
|
||||||
|
...unreadRooms.value,
|
||||||
|
[activeRoomKey.value]: { count: 0, mentions: 0 }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Lesestatus ist Komfortfunktion; Chat selbst soll dadurch nicht blockieren.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadChatInfo = async () => {
|
const loadChatInfo = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -302,6 +337,7 @@ const loadChatInfo = async () => {
|
|||||||
matrixProjectRooms.value = projectRoomsRes.rooms || []
|
matrixProjectRooms.value = projectRoomsRes.rooms || []
|
||||||
matrixDirectRooms.value = directRoomsRes.rooms || []
|
matrixDirectRooms.value = directRoomsRes.rooms || []
|
||||||
lastUpdated.value = new Date()
|
lastUpdated.value = new Date()
|
||||||
|
await loadUnreadCounts()
|
||||||
|
|
||||||
if (activeRoom.value?.exists && canUseMatrixChat.value) {
|
if (activeRoom.value?.exists && canUseMatrixChat.value) {
|
||||||
await loadRoomChat({ silent: true, includeMembers: true })
|
await loadRoomChat({ silent: true, includeMembers: true })
|
||||||
@@ -403,6 +439,7 @@ const loadRoomMessages = async ({ silent = false } = {}) => {
|
|||||||
try {
|
try {
|
||||||
const res = await $api(`${activeRoomEndpoint.value}/messages`)
|
const res = await $api(`${activeRoomEndpoint.value}/messages`)
|
||||||
mergeMatrixMessages(res.messages || [])
|
mergeMatrixMessages(res.messages || [])
|
||||||
|
await markActiveRoomRead()
|
||||||
matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? {
|
matrixRooms.value = matrixRooms.value.map((room) => room.key === activeRoomKey.value ? {
|
||||||
...room,
|
...room,
|
||||||
alias: res.alias || room.alias,
|
alias: res.alias || room.alias,
|
||||||
@@ -826,6 +863,7 @@ const startMatrixAutoRefresh = () => {
|
|||||||
matrixRefreshInterval = window.setInterval(() => {
|
matrixRefreshInterval = window.setInterval(() => {
|
||||||
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
|
if (!document.hidden && canUseMatrixChat.value && activeRoom.value?.exists) {
|
||||||
loadRoomChat({ silent: true })
|
loadRoomChat({ silent: true })
|
||||||
|
loadUnreadCounts()
|
||||||
}
|
}
|
||||||
}, 15000)
|
}, 15000)
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1048,14 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
lädt
|
lädt
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="room.unread"
|
||||||
|
:color="room.mentions ? 'error' : 'primary'"
|
||||||
|
variant="solid"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ room.mentions ? `@${room.mentions}` : room.unread }}
|
||||||
|
</UBadge>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-else-if="room.exists"
|
v-else-if="room.exists"
|
||||||
color="success"
|
color="success"
|
||||||
@@ -1105,7 +1151,16 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="room.disabled"
|
:disabled="room.disabled"
|
||||||
@click="setActiveRoom(room)"
|
@click="setActiveRoom(room)"
|
||||||
>
|
>
|
||||||
{{ room.name }}
|
<span>{{ room.name }}</span>
|
||||||
|
<UBadge
|
||||||
|
v-if="room.unread"
|
||||||
|
class="ml-2"
|
||||||
|
:color="room.mentions ? 'error' : 'primary'"
|
||||||
|
variant="solid"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{{ room.mentions ? `@${room.mentions}` : room.unread }}
|
||||||
|
</UBadge>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user