Release 202506042038
This commit is contained in:
@@ -6,7 +6,11 @@
|
||||
// @ts-expect-error
|
||||
const typedSelf = self
|
||||
|
||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
|
||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v2'
|
||||
|
||||
/** @typedef {import('../src/lib/webPush').NotificationPayload} NotificationPayload */
|
||||
/** @typedef {{defaultActionUrl: string, payload: NotificationPayload | null}} NotificationData */
|
||||
/** @typedef {NotificationOptions & { actions: { action: string; title: string; icon?: string }[], timestamp: number, data: NotificationData } } CustomNotificationOptions */
|
||||
|
||||
typedSelf.addEventListener('install', (event) => {
|
||||
console.log('Service Worker installing')
|
||||
@@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => {
|
||||
console.log('Push event received:', event)
|
||||
|
||||
if (!event.data) {
|
||||
console.log('Push event but no data')
|
||||
console.error('Push event but no data')
|
||||
return
|
||||
}
|
||||
|
||||
let notificationData
|
||||
try {
|
||||
notificationData = event.data.json()
|
||||
} catch (error) {
|
||||
console.error('Error parsing push data:', error)
|
||||
notificationData = {
|
||||
title: 'New Notification',
|
||||
options: {
|
||||
body: event.data.text() || 'You have a new notification',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { title, options } = notificationData
|
||||
|
||||
const notificationOptions = {
|
||||
body: options.body || '',
|
||||
icon: options.icon || '/favicon.svg',
|
||||
badge: options.badge || '/favicon.svg',
|
||||
data: options.data || {},
|
||||
let title = 'New Notification'
|
||||
/** @type {CustomNotificationOptions} */
|
||||
let options = {
|
||||
body: 'You have a new notification',
|
||||
lang: 'en-US',
|
||||
icon: '/favicon.svg',
|
||||
badge: '/favicon.svg',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
...options,
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
defaultActionUrl: '/notifications',
|
||||
payload: null,
|
||||
},
|
||||
}
|
||||
|
||||
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
|
||||
try {
|
||||
/** @type {NotificationPayload} */
|
||||
const rawData = event.data.json()
|
||||
if (typeof rawData !== 'object' || rawData === null) throw new Error('Invalid push data, not an object')
|
||||
if (!('title' in rawData) || typeof rawData.title !== 'string')
|
||||
throw new Error('Invalid push data, no title')
|
||||
title = rawData.title
|
||||
|
||||
options = {
|
||||
...options,
|
||||
body: rawData.body || undefined,
|
||||
actions: rawData.actions.map((action) => ({
|
||||
action: action.action,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
})),
|
||||
data: {
|
||||
...options.data,
|
||||
payload: rawData,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing push data:', error)
|
||||
}
|
||||
|
||||
event.waitUntil(typedSelf.registration.showNotification(title, options))
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclick', (event) => {
|
||||
@@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => {
|
||||
|
||||
event.notification.close()
|
||||
|
||||
const url = event.notification.data?.url || '/'
|
||||
/** @type {NotificationData} */
|
||||
const data = event.notification.data
|
||||
|
||||
// @ts-expect-error I already use optional chaining
|
||||
const url = data.payload?.[event.action]?.url || data.defaultActionUrl
|
||||
|
||||
event.waitUntil(
|
||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from 'astro/zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { sendNotifications } from '../../lib/sendNotifications'
|
||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||
|
||||
export const adminNotificationActions = {
|
||||
@@ -26,10 +25,8 @@ export const adminNotificationActions = {
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const results = await sendNotifications(notifications.map((notification) => notification.id))
|
||||
|
||||
return {
|
||||
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`,
|
||||
message: `Created ${notifications.length.toString()} notifications.`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
||||
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
||||
<label for={inputId}>{label}</label>
|
||||
<label for={inputId} transition:persist>
|
||||
{label}
|
||||
</label>
|
||||
{required && '*'}
|
||||
</legend>
|
||||
{!!descriptionLabel && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
|
||||
|
||||
import { makeCommentUrl } from './commentsWithReplies'
|
||||
|
||||
import type { NotificationAction } from './webPush'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export function makeNotificationTitle(
|
||||
@@ -244,7 +245,7 @@ export function makeNotificationContent(
|
||||
}
|
||||
}
|
||||
|
||||
export function makeNotificationLink(
|
||||
export function makeNotificationActions(
|
||||
notification: Prisma.NotificationGetPayload<{
|
||||
select: {
|
||||
type: true
|
||||
@@ -286,47 +287,120 @@ export function makeNotificationLink(
|
||||
}
|
||||
}>,
|
||||
origin: string
|
||||
): string | null {
|
||||
): NotificationAction[] {
|
||||
switch (notification.type) {
|
||||
case 'TEST': {
|
||||
return `${origin}/notifications`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/notifications`,
|
||||
},
|
||||
{
|
||||
action: 'profile',
|
||||
title: 'Profile',
|
||||
...iconNameAndUrl('ri:user-line'),
|
||||
url: `${origin}/account`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'COMMENT_STATUS_CHANGE':
|
||||
case 'REPLY_COMMENT_CREATED':
|
||||
case 'COMMUNITY_NOTE_ADDED':
|
||||
case 'ROOT_COMMENT_CREATED': {
|
||||
if (!notification.aboutComment) return null
|
||||
return makeCommentUrl({
|
||||
serviceSlug: notification.aboutComment.service.slug,
|
||||
commentId: notification.aboutComment.id,
|
||||
origin,
|
||||
})
|
||||
if (!notification.aboutComment) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: makeCommentUrl({
|
||||
serviceSlug: notification.aboutComment.service.slug,
|
||||
commentId: notification.aboutComment.id,
|
||||
origin,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_MESSAGE': {
|
||||
if (!notification.aboutServiceSuggestionMessage) return null
|
||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
|
||||
if (!notification.aboutServiceSuggestionMessage) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SUGGESTION_STATUS_CHANGE': {
|
||||
if (!notification.aboutServiceSuggestionId) return null
|
||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
|
||||
if (!notification.aboutServiceSuggestionId) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
// case 'KARMA_UNLOCK': {
|
||||
// return `${origin}/account#karma-unlocks`
|
||||
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
|
||||
// }
|
||||
case 'KARMA_CHANGE': {
|
||||
return `${origin}/account#karma-transactions`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/account#karma-transactions`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'ACCOUNT_STATUS_CHANGE': {
|
||||
return `${origin}/account#account-status`
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/account#account-status`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'EVENT_CREATED': {
|
||||
if (!notification.aboutEvent) return null
|
||||
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
|
||||
if (!notification.aboutEvent) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||
if (!notification.aboutService) return null
|
||||
return `${origin}/service/${notification.aboutService.slug}#verification`
|
||||
if (!notification.aboutService) return []
|
||||
return [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
...iconNameAndUrl('ri:arrow-right-line'),
|
||||
url: `${origin}/service/${notification.aboutService.slug}#verification`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
|
||||
}
|
||||
|
||||
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||
return {
|
||||
iconName,
|
||||
icon: iconUrl(iconName),
|
||||
} as const
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'astro/zod'
|
||||
import { Client } from 'pg'
|
||||
|
||||
import { zodParseJSON } from './json'
|
||||
import { sendNotifications } from './sendNotifications'
|
||||
import { sendNotification } from './sendNotifications'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
import type { AstroIntegration, HookParameters } from 'astro'
|
||||
@@ -21,10 +21,10 @@ async function handleNotificationCreated(
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const results = await sendNotifications([notificationId], logger)
|
||||
const results = await sendNotification(notificationId, logger)
|
||||
|
||||
logger.info(
|
||||
`Sent push notifications for notification ${String(notificationId)} to ${String(results.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed`
|
||||
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { sum } from 'lodash-es'
|
||||
|
||||
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
|
||||
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
|
||||
import { prisma } from './prisma'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
import { type NotificationData, sendPushNotification } from './webPush'
|
||||
import { type NotificationPayload, sendPushNotification } from './webPush'
|
||||
|
||||
import type { AstroIntegrationLogger } from 'astro'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export async function sendNotifications(
|
||||
notificationIds: number[],
|
||||
export async function sendNotification(
|
||||
notificationId: number,
|
||||
logger: AstroIntegrationLogger | Console = console
|
||||
) {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { id: { in: notificationIds } },
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
@@ -110,76 +108,58 @@ export async function sendNotifications(
|
||||
},
|
||||
})
|
||||
|
||||
if (notifications.length < notificationIds.length) {
|
||||
const missingNotificationIds = notificationIds.filter(
|
||||
(id) => !notifications.some((notification) => notification.id === id)
|
||||
)
|
||||
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
|
||||
if (!notification) {
|
||||
logger.error(`Notification with ID ${notificationId.toString()} not found`)
|
||||
return { success: 0, failure: 0, total: 0 }
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
notifications.map(async (notification) => {
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: notification.userId },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: notification.userId },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
logger.info(`No push subscriptions found for user ${notification.user.name}`)
|
||||
return { success: 0, failure: 0, total: 0 }
|
||||
}
|
||||
|
||||
const notificationPayload = {
|
||||
title: makeNotificationTitle(notification, notification.user),
|
||||
body: makeNotificationContent(notification),
|
||||
actions: makeNotificationActions(notification, SITE_URL),
|
||||
} satisfies NotificationPayload
|
||||
|
||||
const subscriptionResults = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
logger.info(`No push subscriptions found for user ${notification.user.name}`)
|
||||
return
|
||||
}
|
||||
const notificationData = {
|
||||
title: makeNotificationTitle(notification, notification.user),
|
||||
body: makeNotificationContent(notification) ?? undefined,
|
||||
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
|
||||
} satisfies NotificationData
|
||||
|
||||
const subscriptionResults = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
notificationData
|
||||
)
|
||||
|
||||
// Remove invalid subscriptions
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
notificationPayload
|
||||
)
|
||||
|
||||
return {
|
||||
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
||||
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
||||
total: subscriptionResults.length,
|
||||
// Remove invalid subscriptions
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
subscriptions: {
|
||||
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)),
|
||||
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)),
|
||||
total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)),
|
||||
},
|
||||
notifications: {
|
||||
success: results.filter((r) => r.status === 'fulfilled').length,
|
||||
failure: results.filter((r) => r.status !== 'fulfilled').length,
|
||||
total: results.length,
|
||||
},
|
||||
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
||||
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
||||
total: subscriptionResults.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,19 @@ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||
|
||||
export { webpush }
|
||||
|
||||
export type NotificationData = {
|
||||
export type NotificationAction = {
|
||||
action: string
|
||||
title: string
|
||||
body?: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
url?: string
|
||||
|
||||
url: string | null
|
||||
iconName?: string
|
||||
}
|
||||
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body: string | null
|
||||
actions: NotificationAction[]
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
@@ -28,26 +35,13 @@ export async function sendPushNotification(
|
||||
auth: string
|
||||
}
|
||||
},
|
||||
data: NotificationData
|
||||
payload: NotificationPayload
|
||||
) {
|
||||
try {
|
||||
const result = await webpush.sendNotification(
|
||||
subscription,
|
||||
JSON.stringify({
|
||||
title: data.title,
|
||||
options: {
|
||||
body: data.body,
|
||||
icon: data.icon ?? '/favicon.svg',
|
||||
badge: data.badge ?? '/favicon.svg',
|
||||
data: {
|
||||
url: data.url,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
TTL: 24 * 60 * 60, // 24 hours
|
||||
}
|
||||
)
|
||||
// NOTE: View sw.js to see how the notification is handled
|
||||
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
||||
TTL: 24 * 60 * 60, // 24 hours
|
||||
})
|
||||
return { success: true, result } as const
|
||||
} catch (error) {
|
||||
console.error('Error sending push notification:', error)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { cn } from '../lib/cn'
|
||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
||||
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from '../lib/notifications'
|
||||
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from '../lib/notifications'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
typeInfo: getNotificationTypeInfo(notification.type),
|
||||
title: makeNotificationTitle(notification, user),
|
||||
content: makeNotificationContent(notification),
|
||||
link: makeNotificationLink(notification, Astro.url.origin),
|
||||
actions: makeNotificationActions(notification, Astro.url.origin),
|
||||
}))
|
||||
---
|
||||
|
||||
@@ -285,18 +285,21 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
type="submit"
|
||||
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
<Icon name={notification.read ? 'ri:eye-close-line' : 'ri:eye-line'} class="size-4" />
|
||||
<Icon name={notification.read ? 'ri:close-line' : 'ri:check-line'} class="size-4" />
|
||||
</Tooltip>
|
||||
</form>
|
||||
{notification.link && (
|
||||
<a
|
||||
href={notification.link}
|
||||
{notification.actions.map((action) => (
|
||||
<Tooltip
|
||||
as="a"
|
||||
href={action.url}
|
||||
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
text={action.title}
|
||||
position="left"
|
||||
>
|
||||
<Icon name="ri:arrow-right-line" class="size-4" />
|
||||
<span class="sr-only">View details</span>
|
||||
</a>
|
||||
)}
|
||||
<Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
|
||||
<span class="sr-only">{action.title}</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user