Release: dev → main (first public release) #258
@@ -30,6 +30,12 @@
|
||||
<span v-if="totalImages" class="fc-post-card__meta">
|
||||
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<!-- #4402. Referenced images are counted apart from the post's own: a
|
||||
Patreon card must not read as though the creator posted Discord's
|
||||
files on Patreon. -->
|
||||
<span v-if="refImages.length" class="fc-post-card__meta fc-post-card__ref-meta">
|
||||
· +{{ refImages.length }} from Discord
|
||||
</span>
|
||||
<!-- Only on a grouping that has actually grown. An ordinary post can
|
||||
never show this, and a grouping that has not grown says nothing —
|
||||
an "updated" label that is always present teaches you to ignore it. -->
|
||||
@@ -66,9 +72,16 @@
|
||||
<button
|
||||
v-for="t in rail" :key="t.image_id" type="button"
|
||||
class="fc-post-card__rail-cell"
|
||||
aria-label="Open image" @click="openModal(t.image_id)"
|
||||
:class="{ 'fc-post-card__rail-cell--ref': t.role }"
|
||||
:title="t.role ? refTitle(t) : undefined"
|
||||
:aria-label="t.role ? `Open ${refTitle(t)}` : 'Open image'"
|
||||
@click="openModal(t.image_id)"
|
||||
>
|
||||
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
|
||||
<v-icon
|
||||
v-if="t.role" icon="mdi-link-variant" size="x-small"
|
||||
class="fc-post-card__ref-badge"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="moreCount > 0" type="button"
|
||||
@@ -138,9 +151,40 @@
|
||||
query params" — so this works from Latest or Browse alike without
|
||||
the card reaching for useRoute(), which it has no other reason to
|
||||
know about. -->
|
||||
<div v-if="associations.length" class="fc-post-card__assoc">
|
||||
<!-- #4402: the unified card. A teaser is a pointer, so the card shows
|
||||
what it points at — the drop's images join the rail above and
|
||||
its text sits here — by REFERENCE: the Discord posts keep their
|
||||
own place in the feed, and nothing here moves them. A link FC made
|
||||
by itself says so and offers the undo the operator asked for. -->
|
||||
<div v-if="unified" class="fc-post-card__unified">
|
||||
<div
|
||||
v-for="l in unified.links" :key="l.association_id"
|
||||
class="fc-post-card__unified-head"
|
||||
>
|
||||
<v-icon icon="mdi-link-variant" size="x-small" />
|
||||
<RouterLink
|
||||
:to="{ query: { post_id: l.post_id } }"
|
||||
class="fc-post-card__assoc-link"
|
||||
>{{ linkLabel(l) }}</RouterLink>
|
||||
<button
|
||||
type="button" class="fc-post-card__undo"
|
||||
:disabled="unlinking === l.association_id"
|
||||
@click="undoLink(l)"
|
||||
>{{ l.linked_by === 'fc' ? 'Undo' : 'Unlink' }}</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="t in unified.texts" :key="t.post_id"
|
||||
class="fc-post-card__unified-text"
|
||||
>
|
||||
<div class="fc-post-card__unified-meta">
|
||||
Discord · {{ shortDate(t.date) }}{{ t.role === 'variant' ? ' · variant' : '' }}
|
||||
</div>
|
||||
<p class="fc-post-card__unified-body">{{ t.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="plainLinks.length" class="fc-post-card__assoc">
|
||||
<RouterLink
|
||||
v-for="a in associations" :key="a.id"
|
||||
v-for="a in plainLinks" :key="a.id"
|
||||
:to="{ query: { post_id: a.post_id } }"
|
||||
class="fc-post-card__assoc-link"
|
||||
>
|
||||
@@ -191,6 +235,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { toPlainText } from '../../utils/htmlSanitize.js'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import PostSeriesMenu from './PostSeriesMenu.vue'
|
||||
import PostTranslationControl from './PostTranslationControl.vue'
|
||||
|
||||
@@ -207,8 +252,16 @@ const modal = useModalStore()
|
||||
const detail = ref(null)
|
||||
|
||||
const attachments = computed(() => props.post.attachments || [])
|
||||
const images = computed(() => props.post.thumbnails || [])
|
||||
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
|
||||
// #4402. A teaser's card also shows what it points at: the linked drop's
|
||||
// images and the piece's other variants, each tagged with the post it really
|
||||
// belongs to. They follow the post's own images so the teaser keeps its hero,
|
||||
// and the post's own capped list stays a PREFIX of everything shown — which is
|
||||
// what lets the "+N" tile and the modal playlist keep indexing correctly.
|
||||
const unified = computed(() => props.post.unified || null)
|
||||
const ownImages = computed(() => props.post.thumbnails || [])
|
||||
const refImages = computed(() => unified.value?.thumbnails || [])
|
||||
const images = computed(() => [...ownImages.value, ...refImages.value])
|
||||
const totalImages = computed(() => ownImages.value.length + (props.post.thumbnails_more || 0))
|
||||
const plainTitle = computed(() => toPlainText(props.post.post_title))
|
||||
|
||||
// #388 E2. Non-null `synthesized_by` means FC authored this row by grouping a
|
||||
@@ -273,6 +326,44 @@ const grewAt = computed(() => (synthesized.value ? props.post.last_grew_at : nul
|
||||
// never beside the artwork. Defaults to [] so a post dict from before the
|
||||
// feature (or composed by hand) renders without a link rather than throwing.
|
||||
const associations = computed(() => props.post.associations || [])
|
||||
// The teaser side of a link is drawn by the unified block when there is one;
|
||||
// only a link it does not cover (the drop's "announced by", or a feed payload
|
||||
// from before #4402) keeps the plain text link.
|
||||
const plainLinks = computed(() =>
|
||||
unified.value
|
||||
? associations.value.filter((a) => a.role !== 'announces')
|
||||
: associations.value,
|
||||
)
|
||||
|
||||
function linkLabel (l) {
|
||||
if (l.linked_by === 'fc') {
|
||||
return l.token
|
||||
? `Linked by FabledCurator — matched on “${l.token}”`
|
||||
: 'Linked by FabledCurator'
|
||||
}
|
||||
return 'Linked to its Discord drop'
|
||||
}
|
||||
|
||||
function refTitle (t) {
|
||||
return t.role === 'variant' ? 'a variant from Discord' : 'from the Discord drop'
|
||||
}
|
||||
|
||||
function shortDate (iso) {
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
const unlinking = ref(null)
|
||||
async function undoLink (l) {
|
||||
unlinking.value = l.association_id
|
||||
try {
|
||||
await postsStore.unlink(props.post.id, l.association_id)
|
||||
toast({ text: 'Unlinked — the Discord drop returns to the feed on the next load', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Unlink failed: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
unlinking.value = null
|
||||
}
|
||||
}
|
||||
const grewRelative = computed(() => (grewAt.value ? relativeFrom(grewAt.value) : ''))
|
||||
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
||||
function relativeFrom (iso) {
|
||||
@@ -298,7 +389,8 @@ async function fullImageIds () {
|
||||
detail.value = await postsStore.getPostFull(props.post.id)
|
||||
} catch { /* fall back to the capped feed list */ }
|
||||
}
|
||||
return (detail.value?.thumbnails || images.value).map((t) => t.image_id)
|
||||
const own = detail.value?.thumbnails || ownImages.value
|
||||
return [...own, ...refImages.value].map((t) => t.image_id)
|
||||
}
|
||||
|
||||
async function openModal (imageId) {
|
||||
@@ -447,6 +539,54 @@ function formatBytes (n) {
|
||||
.fc-post-card__grew { color: rgb(var(--v-theme-accent)); }
|
||||
|
||||
.fc-post-card__assoc { margin-top: 8px; }
|
||||
|
||||
/* #4402 — the unified block. Quiet by design: it explains the references in
|
||||
the rail, it does not compete with them. */
|
||||
.fc-post-card__unified {
|
||||
margin-top: 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid rgba(var(--v-theme-accent), 0.5);
|
||||
}
|
||||
.fc-post-card__unified-head {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-card__undo {
|
||||
padding: 0; border: 0; background: none; cursor: pointer;
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__undo:hover { color: rgb(var(--v-theme-accent)); text-decoration: underline; }
|
||||
.fc-post-card__undo:disabled { cursor: default; text-decoration: none; }
|
||||
.fc-post-card__unified-text { margin-top: 6px; }
|
||||
.fc-post-card__unified-meta {
|
||||
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__unified-body {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.85rem; line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-post-card__ref-meta { color: rgb(var(--v-theme-accent)); }
|
||||
|
||||
/* A referenced tile carries a corner badge: the images are Discord's, shown
|
||||
here, and the card must not pass them off as the teaser's own. */
|
||||
.fc-post-card__rail-cell { position: relative; }
|
||||
.fc-post-card__rail-cell--ref { outline: 1px solid rgba(var(--v-theme-accent), 0.55); outline-offset: -1px; }
|
||||
.fc-post-card__ref-badge {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
padding: 2px; border-radius: 4px;
|
||||
background: rgba(var(--v-theme-surface), 0.85);
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-card__assoc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -61,6 +61,47 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- #4402. What a link DOES once made: the teaser's card shows the drop
|
||||
and the piece's other variants by reference. Nothing is moved — these
|
||||
two windows only decide what the card shows and which duplicate the
|
||||
feed leaves out. -->
|
||||
<div class="text-body-2 mt-4 mb-1"><strong>On the teaser's card</strong></div>
|
||||
<div class="text-caption fc-muted mb-2">
|
||||
A linked teaser shows the Discord drop it announced — its images and its
|
||||
message — and the other versions of the same piece: the wips, alts and
|
||||
censor passes posted under the same working name. They stay where they
|
||||
landed in the feed; the card only shows them together.
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<SettingNumberField
|
||||
v-model="foldHours" label="Hide the drop's own card within (hours)"
|
||||
:min="0" :step="1"
|
||||
density="comfortable" max-width="none"
|
||||
:disabled="!enabled" @change="store.setFoldHours(Number(foldHours))"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
A drop this close to its teaser is the same release shown twice, so
|
||||
only the teaser's card stays in the feed. A drop further away keeps
|
||||
its own card. 0 hides nothing.
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<SettingNumberField
|
||||
v-model="familyDays" label="Reach back for variants (days)"
|
||||
:min="0" :step="1"
|
||||
density="comfortable" max-width="none"
|
||||
:disabled="!enabled" @change="store.setFamilyDays(Number(familyDays))"
|
||||
/>
|
||||
<div class="text-caption fc-muted mt-1">
|
||||
How far either side of the teaser to look for the rest of the piece.
|
||||
Measured on real drops: a piece's versions span up to about six
|
||||
weeks, while unrelated pieces that happen to share a name are years
|
||||
apart. 0 shows the drop alone.
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="d-flex align-center mt-4 mb-2" style="gap:12px">
|
||||
<strong class="text-body-2">
|
||||
{{ store.proposals.length }} waiting for review
|
||||
@@ -130,11 +171,15 @@ const enabled = ref(true)
|
||||
const auto = ref(true)
|
||||
const threshold = ref(0.6)
|
||||
const windowHours = ref(24)
|
||||
const foldHours = ref(24)
|
||||
const familyDays = ref(60)
|
||||
|
||||
watch(() => store.enabled, (v) => { enabled.value = v }, { immediate: true })
|
||||
watch(() => store.auto, (v) => { auto.value = v }, { immediate: true })
|
||||
watch(() => store.threshold, (v) => { threshold.value = v }, { immediate: true })
|
||||
watch(() => store.windowHours, (v) => { windowHours.value = v }, { immediate: true })
|
||||
watch(() => store.foldHours, (v) => { foldHours.value = v }, { immediate: true })
|
||||
watch(() => store.familyDays, (v) => { familyDays.value = v }, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
// Both swallow their own failures: a settings read that fails should not
|
||||
|
||||
@@ -20,6 +20,9 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => {
|
||||
const auto = ref(true)
|
||||
const threshold = ref(0.6)
|
||||
const windowHours = ref(24)
|
||||
// #4402 — what a link shows once made. See PostAssociationsCard.
|
||||
const foldHours = ref(24)
|
||||
const familyDays = ref(60)
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
|
||||
async function load () {
|
||||
@@ -35,6 +38,8 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => {
|
||||
auto.value = s.discord_link_auto
|
||||
threshold.value = s.discord_link_threshold
|
||||
windowHours.value = s.discord_link_window_hours
|
||||
foldHours.value = s.discord_link_fold_hours
|
||||
familyDays.value = s.discord_family_window_days
|
||||
}
|
||||
|
||||
async function saveSettings (patch) {
|
||||
@@ -61,6 +66,16 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => {
|
||||
await saveSettings({ discord_link_window_hours: v })
|
||||
}
|
||||
|
||||
async function setFoldHours (v) {
|
||||
foldHours.value = v
|
||||
await saveSettings({ discord_link_fold_hours: v })
|
||||
}
|
||||
|
||||
async function setFamilyDays (v) {
|
||||
familyDays.value = v
|
||||
await saveSettings({ discord_family_window_days: v })
|
||||
}
|
||||
|
||||
async function accept (id) {
|
||||
try {
|
||||
await api.post(`/api/posts/associations/${id}/accept`, {})
|
||||
@@ -86,8 +101,9 @@ export const usePostAssociationsStore = defineStore('postAssociations', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
proposals, enabled, auto, threshold, windowHours, loading, error,
|
||||
proposals, enabled, auto, threshold, windowHours, foldHours, familyDays,
|
||||
loading, error,
|
||||
load, loadSettings, setEnabled, setAuto, setThreshold, setWindowHours,
|
||||
accept, dismiss, rescan
|
||||
setFoldHours, setFamilyDays, accept, dismiss, rescan
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,6 +89,35 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return res
|
||||
}
|
||||
|
||||
// Undo a link on a unified card (#4402). The operator chose "nest
|
||||
// automatically, with visible undo" — so the undo is the review queue's own
|
||||
// dismiss, whose kept row is what stops the next sweep linking the pair
|
||||
// straight back. Patches the loaded teaser in place so the card drops its
|
||||
// references at once; the drop's own card is not in the loaded page (it was
|
||||
// folded out), so it returns on the next load rather than being guessed into
|
||||
// position here.
|
||||
async function unlink(postId, associationId) {
|
||||
await api.post(`/api/posts/associations/${associationId}/dismiss`, {})
|
||||
const item = items.value.find((p) => p.id === postId)
|
||||
if (!item) return
|
||||
item.associations = (item.associations || []).filter((a) => a.id !== associationId)
|
||||
const unified = item.unified
|
||||
if (!unified) return
|
||||
const gone = unified.links.find((l) => l.association_id === associationId)
|
||||
const links = unified.links.filter((l) => l.association_id !== associationId)
|
||||
// Variants are keyed on the whole seed, so with one link left they may no
|
||||
// longer all belong; with none left there is nothing to show at all. Drop
|
||||
// what is certainly gone and let the next load recompute the rest.
|
||||
item.unified = links.length
|
||||
? {
|
||||
...unified,
|
||||
links,
|
||||
thumbnails: unified.thumbnails.filter((t) => t.post_id !== gone?.post_id),
|
||||
texts: unified.texts.filter((t) => t.post_id !== gone?.post_id),
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
// Filter overlay for the around/older/newer (in-context anchored)
|
||||
// path. Keep this distinct from `filters.value` (the down-only feed)
|
||||
// so a normal-feed filter change doesn't leak into an active anchored
|
||||
@@ -186,7 +215,7 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return {
|
||||
items, cursor, loading, done, error, filters,
|
||||
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
||||
loadInitial, loadMore, getPostFull, applyTranslationOverride,
|
||||
loadInitial, loadMore, getPostFull, applyTranslationOverride, unlink,
|
||||
loadAround, loadOlder, loadNewer,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
import PostCard from '../../src/components/posts/PostCard.vue'
|
||||
import { useModalStore } from '../../src/stores/modal.js'
|
||||
import { usePostsStore } from '../../src/stores/posts.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const now = new Date().toISOString()
|
||||
@@ -201,3 +202,91 @@ describe('PostCard', () => {
|
||||
expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 11] })
|
||||
})
|
||||
})
|
||||
|
||||
// #4402. A teaser is a pointer, so its card shows what it points at — by
|
||||
// REFERENCE. These pin the honesty half as hard as the feature half: the
|
||||
// referenced images are marked as Discord's, and a link FC made by itself says
|
||||
// so and offers the undo the operator chose over a silent merge.
|
||||
describe('the unified card', () => {
|
||||
const UNIFIED = {
|
||||
links: [{ association_id: 7, post_id: 42, linked_by: 'fc', token: '0-k' }],
|
||||
thumbnails: [
|
||||
{ image_id: 200, thumbnail_url: '/d0', post_id: 42, role: 'drop' },
|
||||
{ image_id: 201, thumbnail_url: '/v0', post_id: 40, role: 'variant' },
|
||||
],
|
||||
variant_count: 1,
|
||||
texts: [
|
||||
{ post_id: 42, role: 'drop', date: now, text: '@everyone the full set' },
|
||||
{ post_id: 40, role: 'variant', date: now, text: 'wip, feedback welcome' },
|
||||
],
|
||||
}
|
||||
const TEASER = {
|
||||
...BASE,
|
||||
description_plain: 'Full set in the server',
|
||||
thumbnails: [{ image_id: 10, thumbnail_url: '/a' }],
|
||||
associations: [{ id: 7, role: 'announces', post_id: 42 }],
|
||||
unified: UNIFIED,
|
||||
}
|
||||
|
||||
it('shows the drop and its variants beside the teaser, marked as references', () => {
|
||||
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() })
|
||||
const refs = w.findAll('.fc-post-card__rail-cell--ref')
|
||||
expect(refs).toHaveLength(2)
|
||||
// Counted apart from the teaser's own, so the card never reads as though
|
||||
// the creator put Discord's files on Patreon.
|
||||
expect(w.text()).toContain('1 image')
|
||||
expect(w.text()).toContain('+2 from Discord')
|
||||
})
|
||||
|
||||
it('carries the text of everything it unifies', () => {
|
||||
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() })
|
||||
expect(w.text()).toContain('@everyone the full set')
|
||||
expect(w.text()).toContain('wip, feedback welcome')
|
||||
})
|
||||
|
||||
it('says FC made the link, on what, and offers an undo', () => {
|
||||
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia: freshPinia() })
|
||||
expect(w.text()).toContain('Linked by FabledCurator')
|
||||
expect(w.text()).toContain('0-k')
|
||||
expect(w.find('.fc-post-card__undo').text()).toBe('Undo')
|
||||
// The old text link would say the same thing twice.
|
||||
expect(w.text()).not.toContain('The full set is in Discord')
|
||||
})
|
||||
|
||||
it('never claims FC made a link a person accepted', () => {
|
||||
const post = {
|
||||
...TEASER,
|
||||
unified: { ...UNIFIED, links: [{ ...UNIFIED.links[0], linked_by: 'operator' }] },
|
||||
}
|
||||
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
|
||||
expect(w.text()).not.toContain('Linked by FabledCurator')
|
||||
expect(w.find('.fc-post-card__undo').text()).toBe('Unlink')
|
||||
})
|
||||
|
||||
it('undo dismisses that one link', async () => {
|
||||
const pinia = freshPinia()
|
||||
const store = usePostsStore()
|
||||
const spy = vi.spyOn(store, 'unlink').mockResolvedValue()
|
||||
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia })
|
||||
await w.find('.fc-post-card__undo').trigger('click')
|
||||
await flushPromises()
|
||||
expect(spy).toHaveBeenCalledWith(1, 7)
|
||||
})
|
||||
|
||||
it('arrows through the teaser first, then what it references', async () => {
|
||||
const pinia = freshPinia()
|
||||
const openSpy = vi.spyOn(useModalStore(), 'open').mockResolvedValue()
|
||||
const w = mountComponent(PostCard, { props: { post: TEASER }, pinia })
|
||||
await w.find('.fc-post-card__hero').trigger('click')
|
||||
await flushPromises()
|
||||
expect(openSpy).toHaveBeenCalledWith(10, { playlistIds: [10, 200, 201] })
|
||||
})
|
||||
|
||||
it('an ordinary post is untouched', () => {
|
||||
const w = mountComponent(PostCard, {
|
||||
props: { post: { ...BASE, unified: null } }, pinia: freshPinia(),
|
||||
})
|
||||
expect(w.find('.fc-post-card__unified').exists()).toBe(false)
|
||||
expect(w.text()).not.toContain('from Discord')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user