mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - merge dev into Companies feature
This commit is contained in:
@@ -261,6 +261,9 @@ const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isAppRoute = computed(() => route.name === 'app')
|
||||
const showActiveCallReturn = computed(
|
||||
() => Boolean(calls.activeCall) && route.params.appId !== 'phone',
|
||||
)
|
||||
const appTransitionName = computed(() =>
|
||||
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
|
||||
)
|
||||
@@ -407,6 +410,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
removable: true,
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
@@ -974,6 +978,12 @@ function lockPhone(): void {
|
||||
isLocked.value = true
|
||||
}
|
||||
|
||||
function returnToActiveCall(): void {
|
||||
if (!calls.activeCall) return
|
||||
controlCenterOpened.value = false
|
||||
void router.push('/apps/phone')
|
||||
}
|
||||
|
||||
function unlockCamera(): void {
|
||||
if (phone.security.enabled) {
|
||||
pendingUnlockRoute.value = '/apps/camera'
|
||||
@@ -1205,8 +1215,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<PhoneStatusBar
|
||||
v-if="!isLocked"
|
||||
:active-call-return="showActiveCallReturn"
|
||||
:control-center-opened="controlCenterOpened"
|
||||
lockable
|
||||
@active-call="returnToActiveCall"
|
||||
@control-center="toggleControlCenter"
|
||||
@lock="lockPhone"
|
||||
/>
|
||||
|
||||
@@ -467,6 +467,47 @@ button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.phone-status-bar__time-button--active-call {
|
||||
isolation: isolate;
|
||||
text-shadow: none;
|
||||
}
|
||||
.phone-status-bar__time-button--active-call::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 19px;
|
||||
left: calc(50% - 3px);
|
||||
width: 90px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: #34c759;
|
||||
box-shadow: 0 2px 8px #0b6f2b66;
|
||||
animation: phone-active-call-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
content: '';
|
||||
transform-origin: center;
|
||||
transform: translateX(-50%);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.phone-status-bar__time-button--active-call:active::before {
|
||||
transform: translateX(-50%) scale(0.94);
|
||||
}
|
||||
.phone-status-bar__active-call-icon {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
left: calc(50% - 39px);
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
@keyframes phone-active-call-pill-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.82);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
.phone-status-bar__indicators {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { PhoneCall } from 'lucide-vue-next'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
withDefaults(
|
||||
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activeCallReturn?: boolean
|
||||
controlCenterOpened?: boolean
|
||||
lockable?: boolean
|
||||
}>(),
|
||||
{
|
||||
activeCallReturn: false,
|
||||
controlCenterOpened: false,
|
||||
lockable: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
|
||||
const emit = defineEmits<{ activeCall: []; controlCenter: []; lock: [] }>()
|
||||
const time = ref('')
|
||||
let intervalId: number | undefined
|
||||
|
||||
@@ -23,6 +29,14 @@ function updateTime(): void {
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
function handleTimeClick(): void {
|
||||
if (props.activeCallReturn) {
|
||||
emit('activeCall')
|
||||
return
|
||||
}
|
||||
emit('lock')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
intervalId = window.setInterval(updateTime, 30_000)
|
||||
@@ -38,10 +52,22 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="lockable"
|
||||
class="phone-status-bar__time phone-status-bar__time-button"
|
||||
:class="{
|
||||
'phone-status-bar__time-button--active-call': activeCallReturn,
|
||||
}"
|
||||
type="button"
|
||||
:aria-label="phone.t('LockScreen.label')"
|
||||
@click.stop="emit('lock')"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
activeCallReturn ? 'Apps.phone.returnToCall' : 'LockScreen.label',
|
||||
)
|
||||
"
|
||||
@click.stop="handleTimeClick"
|
||||
>
|
||||
<PhoneCall
|
||||
v-if="activeCallReturn"
|
||||
class="phone-status-bar__active-call-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<time>{{ time }}</time>
|
||||
</button>
|
||||
<time v-else class="phone-status-bar__time">{{ time }}</time>
|
||||
|
||||
@@ -68,4 +68,54 @@ describe('calls store', () => {
|
||||
expect(calls.activeCall).toBeNull()
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
|
||||
})
|
||||
|
||||
it('blocks the active caller and closes the call screen', async () => {
|
||||
const calls = useCallsStore()
|
||||
calls.applyCallState({
|
||||
direction: 'incoming',
|
||||
id: 'call-3',
|
||||
otherNumber: '5551110025',
|
||||
startedAt: 1,
|
||||
state: 'ringing',
|
||||
})
|
||||
|
||||
const response = await calls.blockNumber('5551110025')
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:block', {
|
||||
phoneNumber: '5551110025',
|
||||
})
|
||||
expect(calls.activeCall).toBeNull()
|
||||
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
|
||||
})
|
||||
|
||||
it('updates a contact favorite and refreshes the contact list', async () => {
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { favorite: true, id: 'contact-alex' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
favorite: true,
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
phone_number: '5551110001',
|
||||
},
|
||||
],
|
||||
})
|
||||
const calls = useCallsStore()
|
||||
|
||||
const response = await calls.setContactFavorite('contact-alex', true)
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(1, 'contacts:favorite', {
|
||||
favorite: true,
|
||||
id: 'contact-alex',
|
||||
})
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(2, 'contacts:list')
|
||||
expect(calls.contacts[0]?.favorite).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,8 +35,11 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
}
|
||||
|
||||
async function saveContact(contact: {
|
||||
avatarMediaId?: number | null
|
||||
id?: string
|
||||
name: string
|
||||
notes?: string
|
||||
organization?: string
|
||||
phoneNumber: string
|
||||
}): Promise<NuiResponse<PhoneContact>> {
|
||||
const response = await nuiCall<PhoneContact>('contacts:save', contact)
|
||||
@@ -58,17 +61,56 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
|
||||
async function answer(): Promise<NuiResponse> {
|
||||
if (!activeCall.value) return { success: false, error: 'call_not_found' }
|
||||
return nuiCall('calls:answer', { id: activeCall.value.id })
|
||||
const response = await nuiCall('calls:answer', { id: activeCall.value.id })
|
||||
if (response.success && activeCall.value) {
|
||||
activeCall.value = {
|
||||
...activeCall.value,
|
||||
answeredAt: activeCall.value.answeredAt ?? Date.now(),
|
||||
state: 'connected',
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function decline(): Promise<boolean> {
|
||||
if (!activeCall.value) return false
|
||||
return (await nuiCall('calls:decline', { id: activeCall.value.id })).success
|
||||
const response = await nuiCall('calls:decline', { id: activeCall.value.id })
|
||||
if (response.success) activeCall.value = null
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function setContactFavorite(
|
||||
id: string,
|
||||
favorite: boolean,
|
||||
): Promise<NuiResponse<{ favorite: boolean; id: string }>> {
|
||||
const response = await nuiCall<{ favorite: boolean; id: string }>(
|
||||
'contacts:favorite',
|
||||
{ favorite, id },
|
||||
)
|
||||
if (response.success) await loadContacts()
|
||||
return response
|
||||
}
|
||||
|
||||
async function hangup(): Promise<boolean> {
|
||||
if (!activeCall.value) return false
|
||||
return (await nuiCall('calls:hangup', { id: activeCall.value.id })).success
|
||||
const response = await nuiCall('calls:hangup', { id: activeCall.value.id })
|
||||
if (response.success) {
|
||||
activeCall.value = null
|
||||
await loadRecents()
|
||||
}
|
||||
return response.success
|
||||
}
|
||||
|
||||
async function blockNumber(phoneNumber: string): Promise<NuiResponse> {
|
||||
const response = await nuiCall('calls:block', { phoneNumber })
|
||||
if (
|
||||
response.success &&
|
||||
activeCall.value?.otherNumber === phoneNumber
|
||||
) {
|
||||
activeCall.value = null
|
||||
await loadRecents()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function applyCallState(call: PhoneCall): void {
|
||||
@@ -95,6 +137,7 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
answer,
|
||||
applyCallState,
|
||||
bootstrap,
|
||||
blockNumber,
|
||||
contacts,
|
||||
decline,
|
||||
deleteContact,
|
||||
@@ -104,5 +147,6 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
loadRecents,
|
||||
recents,
|
||||
saveContact,
|
||||
setContactFavorite,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1312,6 +1312,7 @@ const defaultLocales: LocaleTree = {
|
||||
addContact: 'Add Contact',
|
||||
contactSaved: 'Contact saved',
|
||||
removeContact: 'Remove Contact',
|
||||
returnToCall: 'Return to Call',
|
||||
block: 'Block User',
|
||||
unblock: 'Unblock User',
|
||||
clearChat: 'Clear Chat',
|
||||
@@ -1472,14 +1473,60 @@ const defaultLocales: LocaleTree = {
|
||||
noSim: 'No SIM',
|
||||
noSimBody: 'Insert a SIM card in Settings to make calls.',
|
||||
noRecents: 'No Recent Calls',
|
||||
allCalls: 'All',
|
||||
missedCalls: 'Missed',
|
||||
searchRecents: 'Search Calls',
|
||||
contactDetails: 'Contact Details',
|
||||
unknownCaller: 'Unknown Caller',
|
||||
callHistory: 'Call History',
|
||||
noCallHistory: 'No calls with this number yet.',
|
||||
noContacts: 'No Contacts',
|
||||
favorites: 'Favorites',
|
||||
searchContacts: 'Search Contacts',
|
||||
myCard: 'My Card',
|
||||
myNumber: 'My Number',
|
||||
device: 'Device',
|
||||
contactIndex: 'Contact Index',
|
||||
addContact: 'New Contact',
|
||||
newContact: 'New',
|
||||
editContact: 'Edit Contact',
|
||||
contactName: 'Name',
|
||||
firstName: 'First Name',
|
||||
lastName: 'Last Name',
|
||||
companyOrGroup: 'Company or Group',
|
||||
phoneNumber: 'Phone Number',
|
||||
officialContact: 'Official company contact',
|
||||
choosePhoto: 'Choose Contact Photo',
|
||||
chooseGallery: 'Gallery',
|
||||
takePhoto: 'Camera',
|
||||
removePhoto: 'Remove Photo',
|
||||
call: 'Call',
|
||||
message: 'Message',
|
||||
video: 'Video',
|
||||
mail: 'Mail',
|
||||
defaultLabel: 'Default',
|
||||
privateLabel: 'Private',
|
||||
contactCard: 'Contact Card',
|
||||
removeContact: 'Remove Contact',
|
||||
mobile: 'Mobile',
|
||||
messagesProfile: 'Messages',
|
||||
notes: 'Notes',
|
||||
sendMessage: 'Send Message',
|
||||
shareContact: 'Share Contact',
|
||||
addFavorite: 'Add to Favorites',
|
||||
removeFavorite: 'Remove from Favorites',
|
||||
addEmergency: 'Add to Emergency Contacts',
|
||||
blockContact: 'Block Contact',
|
||||
block: 'Block',
|
||||
blockCaller: 'Block Caller',
|
||||
blockCallerTitle: 'Block this caller?',
|
||||
blockCallerBody: '{number} will no longer be able to call this SIM.',
|
||||
faceTime: 'FaceTime',
|
||||
hideKeypad: 'Hide Keypad',
|
||||
more: 'More',
|
||||
mute: 'Mute',
|
||||
speaker: 'Speaker',
|
||||
viewContact: 'View Contact',
|
||||
calling: 'calling...',
|
||||
incoming: 'Incoming Call',
|
||||
incomingDirection: 'Incoming',
|
||||
@@ -1508,9 +1555,14 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_contact: 'Enter a name and valid phone number.',
|
||||
invalid_number: 'Enter a valid phone number.',
|
||||
invalid_sim: 'The active SIM card is unavailable.',
|
||||
message_unavailable: 'The conversation could not be opened.',
|
||||
contact_remove_failed: 'The contact could not be removed.',
|
||||
contact_favorite_failed: 'The favorite could not be updated.',
|
||||
no_sim: 'This phone has no SIM card.',
|
||||
airplane_mode: 'Turn off Airplane Mode to make calls.',
|
||||
self_call: 'You cannot call your own number.',
|
||||
blocked: 'This number is blocked.',
|
||||
recipient_not_found: 'This number is not known.',
|
||||
busy: 'The line is busy.',
|
||||
company_unavailable: 'This company service line is unavailable.',
|
||||
readonly_contact: 'Official company contacts cannot be changed.',
|
||||
|
||||
@@ -3,18 +3,24 @@ export type SimType = 'registered' | 'anonymous'
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
removable: boolean
|
||||
registered: boolean
|
||||
type: SimType
|
||||
}
|
||||
|
||||
export type PhoneContact = {
|
||||
avatar_media_id?: number | null
|
||||
avatar_url?: string | null
|
||||
canCall?: boolean
|
||||
canMessage?: boolean
|
||||
companyId?: string
|
||||
created_at?: string
|
||||
favorite?: boolean | number
|
||||
id: string
|
||||
icon?: string
|
||||
name: string
|
||||
notes?: string | null
|
||||
organization?: string | null
|
||||
phone_number: string
|
||||
readonly?: boolean
|
||||
source?: 'personal' | 'company'
|
||||
|
||||
@@ -288,6 +288,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<template v-else-if="billing.detail">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-detail__hero"
|
||||
:class="{
|
||||
'billing-detail__hero--paid': billing.detail.status === 'paid',
|
||||
@@ -441,17 +442,26 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else-if="tab === 'overview' && billing.overview">
|
||||
<div class="billing-summary">
|
||||
<kGlass class="billing-summary__item billing-summary__item--open">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-summary__item billing-summary__item--open"
|
||||
>
|
||||
<ReceiptText :size="19" />
|
||||
<span>{{ t('summary.open') }}</span>
|
||||
<strong>{{ billing.overview.openCount }}</strong>
|
||||
</kGlass>
|
||||
<kGlass class="billing-summary__item billing-summary__item--due">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-summary__item billing-summary__item--due"
|
||||
>
|
||||
<CalendarDays :size="19" />
|
||||
<span>{{ t('summary.due') }}</span>
|
||||
<strong>{{ formatMoney(billing.overview.openTotal) }}</strong>
|
||||
</kGlass>
|
||||
<kGlass class="billing-summary__item billing-summary__item--overdue">
|
||||
<kGlass
|
||||
:highlight="false"
|
||||
class="billing-summary__item billing-summary__item--overdue"
|
||||
>
|
||||
<AlertTriangle :size="19" />
|
||||
<span>{{ t('summary.overdue') }}</span>
|
||||
<strong>{{ billing.overview.overdueCount }}</strong>
|
||||
@@ -743,7 +753,7 @@ onBeforeUnmount(() => {
|
||||
/></span>
|
||||
<h2>{{ t('payment.title') }}</h2>
|
||||
<p>{{ t('payment.body', { issuer: billing.detail.issuerLabel }) }}</p>
|
||||
<kGlass class="billing-payment-total">
|
||||
<kGlass :highlight="false" class="billing-payment-total">
|
||||
<span>{{ billing.detail.title }}</span>
|
||||
<strong>{{
|
||||
formatMoney(billing.detail.amount, billing.detail.currency)
|
||||
|
||||
@@ -118,14 +118,24 @@ function queueCapture(id: string, mediaType: MediaType): void {
|
||||
captures.value = captures.value.slice(0, 6)
|
||||
}
|
||||
|
||||
function devMedia(id: string, mediaType: MediaType): PhoneMedia {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#19354f"/><stop offset="1" stop-color="#d78357"/></linearGradient></defs><rect width="900" height="1600" fill="url(#g)"/><circle cx="680" cy="380" r="210" fill="#ffffff22"/><path d="M0 1200 260 840l180 220 170-170 290 310v400H0z" fill="#102331aa"/></svg>`
|
||||
return {
|
||||
createdAt: Date.now(),
|
||||
id: Number(Date.now()),
|
||||
mediaType,
|
||||
url: `data:image/svg+xml,${encodeURIComponent(svg)}#${id}`,
|
||||
}
|
||||
async function completeDevelopmentCapture(
|
||||
correlationId: string,
|
||||
mediaType: MediaType,
|
||||
): Promise<void> {
|
||||
const response = await nuiCall<PhoneMedia>('media:devCapture', { mediaType })
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId,
|
||||
error: response.error,
|
||||
media: response.data,
|
||||
success: response.success,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function requestPhoto(): Promise<void> {
|
||||
@@ -138,20 +148,7 @@ async function requestPhoto(): Promise<void> {
|
||||
shutterActive.value = false
|
||||
}, 280)
|
||||
if (isDevelopment) {
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId: id,
|
||||
media: devMedia(id, 'photo'),
|
||||
success: true,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 700)
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
|
||||
return
|
||||
}
|
||||
await nuiCall('media:requestUpload', {
|
||||
@@ -196,20 +193,7 @@ function stopRecording(): void {
|
||||
savingVideo.value = true
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
recordingTimer = undefined
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
data: {
|
||||
correlationId: id,
|
||||
media: devMedia(id, 'video'),
|
||||
success: true,
|
||||
},
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 900)
|
||||
window.setTimeout(() => void completeDevelopmentCapture(id, 'video'), 900)
|
||||
return
|
||||
}
|
||||
window.postMessage(
|
||||
|
||||
@@ -407,6 +407,8 @@ async function saveContactDetails(): Promise<void> {
|
||||
const response = await calls.saveContact({
|
||||
id: activeContact.value?.id,
|
||||
name: contactNameDraft.value.trim(),
|
||||
notes: activeContact.value?.notes ?? '',
|
||||
organization: activeContact.value?.organization ?? '',
|
||||
phoneNumber: contactNumberDraft.value,
|
||||
})
|
||||
if (!response.success) {
|
||||
|
||||
+3648
-231
File diff suppressed because it is too large
Load Diff
@@ -1337,7 +1337,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
/>
|
||||
<k-list-item
|
||||
v-if="phone.device?.sim"
|
||||
v-if="phone.device?.sim?.removable"
|
||||
:title="phone.t('Apps.settings.simType')"
|
||||
:after="
|
||||
phone.t(
|
||||
@@ -1348,7 +1348,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
/>
|
||||
<k-list-button
|
||||
v-if="phone.device?.sim"
|
||||
v-if="phone.device?.sim?.removable"
|
||||
@click="simEjectOpened = true"
|
||||
>
|
||||
{{ phone.t('Apps.settings.ejectSim') }}
|
||||
@@ -1610,7 +1610,11 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</k-dialog>
|
||||
|
||||
<k-dialog :opened="simEjectOpened" @backdropclick="simEjectOpened = false">
|
||||
<k-dialog
|
||||
v-if="phone.device?.sim?.removable"
|
||||
:opened="simEjectOpened"
|
||||
@backdropclick="simEjectOpened = false"
|
||||
>
|
||||
<template #title>{{ phone.t('Apps.settings.ejectSim') }}</template>
|
||||
<p>{{ phone.t('Apps.settings.ejectSimBody') }}</p>
|
||||
<template #buttons>
|
||||
|
||||
Reference in New Issue
Block a user