mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - install games through App Store
This commit is contained in:
@@ -1808,11 +1808,9 @@ button {
|
||||
.store-icon {
|
||||
width: 49px;
|
||||
height: 49px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 49px;
|
||||
border-radius: 11px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.store-list article > div:nth-child(2) {
|
||||
display: flex;
|
||||
@@ -1823,13 +1821,29 @@ button {
|
||||
color: #888;
|
||||
}
|
||||
.store-list button {
|
||||
width: 67px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
padding: 5px 15px;
|
||||
padding: 0 12px;
|
||||
background: #222;
|
||||
color: #0a84ff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.store-list button:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
.store-installing {
|
||||
color: #0a84ff;
|
||||
}
|
||||
.store-empty {
|
||||
margin: 32px 0;
|
||||
color: #777;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Reference-accurate app surfaces. These remain scoped to the phone resource. */
|
||||
.calculator-app {
|
||||
padding: 54px 16px 27px;
|
||||
@@ -1908,32 +1922,12 @@ button {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.store-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.store-title > div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 11px;
|
||||
}
|
||||
.store-title h1 {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
}
|
||||
.store-title strong {
|
||||
color: #777;
|
||||
font-size: 18px;
|
||||
}
|
||||
.store-title > span {
|
||||
width: 37px;
|
||||
height: 37px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #3a3c46, #1b1c22);
|
||||
}
|
||||
.editorial-card {
|
||||
position: relative;
|
||||
height: 390px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
|
||||
@@ -16,6 +16,10 @@ describe('app store', () => {
|
||||
mocks.saveDeviceNamespace.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('hydrates valid launch counts and persists launches with claimed apps', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -31,4 +35,38 @@ describe('app store', () => {
|
||||
launchCounts: { mail: 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('installs an app after showing a three second loading state', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.installApp('snake')
|
||||
|
||||
expect(apps.installingApps.snake).toBe(true)
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(2999)
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(apps.installingApps.snake).toBeUndefined()
|
||||
expect(apps.claimedApps).toEqual(['snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
launchCounts: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores duplicate installation requests and invalid persisted ids', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['memory', 'not-an-app'] })
|
||||
apps.installApp('snake')
|
||||
apps.installApp('snake')
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.claimedApps).toEqual(['memory', 'snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,26 +4,42 @@ import { isPhoneAppId } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as string[],
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
actions: {
|
||||
claimApp(id: string): void {
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (this.claimedApps.includes(id) || this.installingApps[id]) return
|
||||
|
||||
this.installingApps[id] = true
|
||||
globalThis.setTimeout(() => {
|
||||
this.claimApp(id)
|
||||
delete this.installingApps[id]
|
||||
}, INSTALL_DURATION_MS)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
launchCounts?: unknown
|
||||
} | null
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter((id): id is string => typeof id === 'string')
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
this.installingApps = {}
|
||||
this.launchCounts = {}
|
||||
if (data?.launchCounts && typeof data.launchCounts === 'object') {
|
||||
for (const [appId, count] of Object.entries(data.launchCounts)) {
|
||||
|
||||
@@ -31,42 +31,18 @@ const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
appStore: {
|
||||
name: 'App Store',
|
||||
eyebrow: 'Discover',
|
||||
featured: 'Featured',
|
||||
heroTitle: 'Apps for every day',
|
||||
heroBody: 'Fresh ideas, built for your life in the city.',
|
||||
get: 'GET',
|
||||
open: 'OPEN',
|
||||
searchPlaceholder: 'Games, Apps, Stories and More',
|
||||
communityTitle: 'Black Voices and Creators',
|
||||
communityBody: 'Apps and games from the community',
|
||||
playing: "What We're Playing",
|
||||
recommended: 'Recommended for You',
|
||||
selected: 'Great apps selected by our editors',
|
||||
installing: 'Installing',
|
||||
searchPlaceholder: 'Search apps and games',
|
||||
appsTitle: 'Built-in Apps',
|
||||
gamesTitle: 'Games',
|
||||
selected: 'Apps available for your Sky Phone',
|
||||
tabs: {
|
||||
today: 'Today',
|
||||
apps: 'Apps',
|
||||
games: 'Games',
|
||||
arcade: 'Arcade',
|
||||
search: 'Search',
|
||||
},
|
||||
catalog: {
|
||||
orbit: 'Plan your day',
|
||||
studio: 'Create something new',
|
||||
trail: 'Explore nearby',
|
||||
prism: 'A colorful puzzle',
|
||||
},
|
||||
card: {
|
||||
oneEyebrow: 'App of the Day',
|
||||
oneTitle: 'A universe in your pocket',
|
||||
oneBody: 'Explore something extraordinary today.',
|
||||
twoEyebrow: 'Now Trending',
|
||||
twoTitle: 'Turn up your afternoon',
|
||||
twoBody: 'Fresh sounds and stories picked for you.',
|
||||
threeEyebrow: "Editors' Choice",
|
||||
threeTitle: 'Play without limits',
|
||||
threeBody: 'A new world is waiting.',
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
name: 'Phone',
|
||||
|
||||
@@ -29,7 +29,9 @@ let pointerStart = 0
|
||||
let pointerStartedAt = 0
|
||||
|
||||
const gridApps = computed(() =>
|
||||
[...PHONE_APPS].sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
PHONE_APPS.filter(
|
||||
(app) => app.category !== 'games' || appStore.claimedApps.includes(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const appPages = computed(() =>
|
||||
paginateItems(gridApps.value, APPS_PER_HOME_PAGE),
|
||||
@@ -70,11 +72,13 @@ const appGroups = computed(() => {
|
||||
key: category,
|
||||
})),
|
||||
]
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
apps: group.apps.slice(0, 3),
|
||||
moreApps: group.apps.slice(3),
|
||||
}))
|
||||
return groups
|
||||
.filter((group) => group.apps.length > 0)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
apps: group.apps.slice(0, 3),
|
||||
moreApps: group.apps.slice(3),
|
||||
}))
|
||||
})
|
||||
const alphabeticalGroups = computed(() => {
|
||||
const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = []
|
||||
|
||||
@@ -1,144 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Gamepad2,
|
||||
Grid2X2,
|
||||
Rocket,
|
||||
Search,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import { kPreloader } from 'konsta/vue'
|
||||
import { Gamepad2, Grid2X2, Search } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { PHONE_APPS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const tab = ref<'today' | 'apps' | 'games' | 'arcade' | 'search'>('today')
|
||||
const router = useRouter()
|
||||
const tab = ref<'apps' | 'games' | 'search'>('apps')
|
||||
const query = ref('')
|
||||
const tabs = [
|
||||
{ id: 'today', icon: Sparkles },
|
||||
{ id: 'apps', icon: Grid2X2 },
|
||||
{ id: 'games', icon: Gamepad2 },
|
||||
{ id: 'arcade', icon: Rocket },
|
||||
{ id: 'search', icon: Search },
|
||||
] as const
|
||||
const catalog = [
|
||||
{
|
||||
id: 'orbit',
|
||||
title: 'Orbit',
|
||||
subtitle: 'Apps.appStore.catalog.orbit',
|
||||
gradient: 'linear-gradient(145deg,#453bd1,#75e8ff)',
|
||||
},
|
||||
{
|
||||
id: 'studio',
|
||||
title: 'Studio',
|
||||
subtitle: 'Apps.appStore.catalog.studio',
|
||||
gradient: 'linear-gradient(145deg,#ff8b4a,#ff2d75)',
|
||||
},
|
||||
{
|
||||
id: 'trail',
|
||||
title: 'Trail',
|
||||
subtitle: 'Apps.appStore.catalog.trail',
|
||||
gradient: 'linear-gradient(145deg,#4bd37b,#147ec1)',
|
||||
},
|
||||
{
|
||||
id: 'prism',
|
||||
title: 'Prism',
|
||||
subtitle: 'Apps.appStore.catalog.prism',
|
||||
gradient: 'linear-gradient(145deg,#ffd84d,#7b36dc)',
|
||||
},
|
||||
]
|
||||
const shown = computed(() =>
|
||||
catalog.filter((item) =>
|
||||
item.title.toLowerCase().includes(query.value.toLowerCase()),
|
||||
),
|
||||
const catalog = PHONE_APPS.filter((app) => app.id !== 'app-store').sort(
|
||||
(a, b) => a.gridOrder - b.gridOrder,
|
||||
)
|
||||
const date = new Intl.DateTimeFormat(phone.lang, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date())
|
||||
const shownApps = computed(() => {
|
||||
if (tab.value === 'games') {
|
||||
return catalog.filter((app) => app.category === 'games')
|
||||
}
|
||||
if (tab.value === 'apps') {
|
||||
return catalog.filter((app) => app.category !== 'games')
|
||||
}
|
||||
|
||||
const search = query.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!search) return catalog
|
||||
return catalog.filter((app) =>
|
||||
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
function isInstalled(app: PhoneAppDefinition): boolean {
|
||||
return app.category !== 'games' || appStore.claimedApps.includes(app.id)
|
||||
}
|
||||
|
||||
function handleApp(app: PhoneAppDefinition): void {
|
||||
if (isInstalled(app)) {
|
||||
if (app.route) void router.push(app.route)
|
||||
return
|
||||
}
|
||||
|
||||
appStore.installApp(app.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="native-app reference-store">
|
||||
<section class="store-scroll">
|
||||
<header class="store-title">
|
||||
<div>
|
||||
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
|
||||
<strong v-if="tab === 'today'">{{ date }}</strong>
|
||||
</div>
|
||||
<span><UserRound :size="22" /></span>
|
||||
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="tab === 'search'" class="app-search">
|
||||
<Search :size="17" /><input
|
||||
<Search :size="17" />
|
||||
<input
|
||||
v-model="query"
|
||||
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="tab === 'today'">
|
||||
<article class="editorial-card editorial-card--sky">
|
||||
<small>{{ phone.t('Apps.appStore.card.oneEyebrow') }}</small>
|
||||
<h2>{{ phone.t('Apps.appStore.card.oneTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.card.oneBody') }}</p>
|
||||
<div class="editorial-orbit">✦</div>
|
||||
</article>
|
||||
<article class="editorial-card editorial-card--music">
|
||||
<small>{{ phone.t('Apps.appStore.card.twoEyebrow') }}</small>
|
||||
<h2>{{ phone.t('Apps.appStore.card.twoTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.card.twoBody') }}</p>
|
||||
<div class="editorial-wave" />
|
||||
</article>
|
||||
<div class="store-section-title">
|
||||
<h2>{{ phone.t('Apps.appStore.communityTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.communityBody') }}</p>
|
||||
</div>
|
||||
<article class="editorial-card editorial-card--play">
|
||||
<small>{{ phone.t('Apps.appStore.card.threeEyebrow') }}</small>
|
||||
<h2>{{ phone.t('Apps.appStore.card.threeTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.card.threeBody') }}</p>
|
||||
</article>
|
||||
</template>
|
||||
<template v-else>
|
||||
<article v-if="tab !== 'search'" class="store-feature">
|
||||
<p>{{ phone.t('Apps.appStore.featured') }}</p>
|
||||
<h2>{{ phone.t('Apps.appStore.heroTitle') }}</h2>
|
||||
<span>{{ phone.t('Apps.appStore.heroBody') }}</span>
|
||||
</article>
|
||||
<div class="store-section-title">
|
||||
<h2>
|
||||
{{
|
||||
phone.t(
|
||||
tab === 'games'
|
||||
? 'Apps.appStore.playing'
|
||||
: 'Apps.appStore.recommended',
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<p>{{ phone.t('Apps.appStore.selected') }}</p>
|
||||
</div>
|
||||
<section class="store-list">
|
||||
<article v-for="item in shown" :key="item.id">
|
||||
<div class="store-icon" :style="{ background: item.gradient }">
|
||||
{{ item.title[0] }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong
|
||||
><small>{{ phone.t(item.subtitle) }}</small>
|
||||
</div>
|
||||
<button type="button" @click="appStore.claimApp(item.id)">
|
||||
|
||||
<div class="store-section-title">
|
||||
<h2>
|
||||
{{
|
||||
phone.t(
|
||||
tab === 'games'
|
||||
? 'Apps.appStore.gamesTitle'
|
||||
: 'Apps.appStore.appsTitle',
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<p>{{ phone.t('Apps.appStore.selected') }}</p>
|
||||
</div>
|
||||
|
||||
<section class="store-list">
|
||||
<article v-for="app in shownApps" :key="app.id">
|
||||
<img
|
||||
class="store-icon"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ phone.t(app.labelKey) }}</strong>
|
||||
<small>{{ phone.t(`Home.groups.${app.category}`) }}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="appStore.installingApps[app.id]"
|
||||
:aria-label="`${phone.t(app.labelKey)} ${phone.t(
|
||||
appStore.installingApps[app.id]
|
||||
? 'Apps.appStore.installing'
|
||||
: isInstalled(app)
|
||||
? 'Apps.appStore.open'
|
||||
: 'Apps.appStore.get',
|
||||
)}`"
|
||||
@click="handleApp(app)"
|
||||
>
|
||||
<k-preloader
|
||||
v-if="appStore.installingApps[app.id]"
|
||||
class="store-installing"
|
||||
:size="16"
|
||||
/>
|
||||
<template v-else>
|
||||
{{
|
||||
phone.t(
|
||||
appStore.claimedApps.includes(item.id)
|
||||
? 'Apps.appStore.open'
|
||||
: 'Apps.appStore.get',
|
||||
isInstalled(app) ? 'Apps.appStore.open' : 'Apps.appStore.get',
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</button>
|
||||
</article>
|
||||
<p v-if="shownApps.length === 0" class="store-empty">
|
||||
{{ phone.t('Home.noApps') }}
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<nav class="reference-tabbar">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
@@ -147,9 +131,8 @@ const date = new Intl.DateTimeFormat(phone.lang, {
|
||||
type="button"
|
||||
@click="tab = item.id"
|
||||
>
|
||||
<component :is="item.icon" :size="21" /><span>{{
|
||||
phone.t(`Apps.appStore.tabs.${item.id}`)
|
||||
}}</span>
|
||||
<component :is="item.icon" :size="21" />
|
||||
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
@@ -344,17 +344,7 @@ const deviceData = {
|
||||
},
|
||||
apps: {
|
||||
payload: {
|
||||
claimedApps: [
|
||||
'snake',
|
||||
'memory',
|
||||
'number-merge',
|
||||
'minesweeper',
|
||||
'tower-stack',
|
||||
'sky-flappy',
|
||||
'neon-drop',
|
||||
'citymarkt',
|
||||
'local-pages',
|
||||
],
|
||||
claimedApps: [],
|
||||
},
|
||||
revision: 2,
|
||||
},
|
||||
|
||||
@@ -378,18 +378,10 @@ Locales["en"] = {
|
||||
},
|
||||
},
|
||||
appStore = {
|
||||
name = "App Store", eyebrow = "Discover", featured = "Featured", heroTitle = "Apps for every day",
|
||||
heroBody = "Fresh ideas, built for your life in the city.", get = "GET", open = "OPEN",
|
||||
searchPlaceholder = "Games, Apps, Stories and More",
|
||||
communityTitle = "Black Voices and Creators", communityBody = "Apps and games from the community",
|
||||
playing = "What We're Playing", recommended = "Recommended for You", selected = "Great apps selected by our editors",
|
||||
tabs = { today = "Today", apps = "Apps", games = "Games", arcade = "Arcade", search = "Search" },
|
||||
catalog = { orbit = "Plan your day", studio = "Create something new", trail = "Explore nearby", prism = "A colorful puzzle" },
|
||||
card = {
|
||||
oneEyebrow = "App of the Day", oneTitle = "A universe in your pocket", oneBody = "Explore something extraordinary today.",
|
||||
twoEyebrow = "Now Trending", twoTitle = "Turn up your afternoon", twoBody = "Fresh sounds and stories picked for you.",
|
||||
threeEyebrow = "Editors' Choice", threeTitle = "Play without limits", threeBody = "A new world is waiting.",
|
||||
},
|
||||
name = "App Store", get = "GET", open = "OPEN", installing = "Installing",
|
||||
searchPlaceholder = "Search apps and games", appsTitle = "Built-in Apps", gamesTitle = "Games",
|
||||
selected = "Apps available for your Sky Phone",
|
||||
tabs = { apps = "Apps", games = "Games", search = "Search" },
|
||||
},
|
||||
settings = {
|
||||
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-CHHmHX1D.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-j2j_4-kJ.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-BrXTyVwO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-BmXo2WLq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user