mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - expand phone app experience
This commit is contained in:
@@ -226,6 +226,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',
|
||||
)
|
||||
@@ -818,6 +821,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'
|
||||
@@ -1044,8 +1053,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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1007,6 +1007,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',
|
||||
@@ -1164,13 +1165,59 @@ 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',
|
||||
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',
|
||||
@@ -1198,9 +1245,14 @@ const defaultLocales: LocaleTree = {
|
||||
errors: {
|
||||
invalid_contact: 'Enter a name and valid phone number.',
|
||||
invalid_number: 'Enter a valid phone number.',
|
||||
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.',
|
||||
rate_limited: 'Too many calls. Try again in a minute.',
|
||||
voice_unavailable: 'The configured phone voice service is unavailable.',
|
||||
|
||||
@@ -9,9 +9,14 @@ export type PhoneSim = {
|
||||
}
|
||||
|
||||
export type PhoneContact = {
|
||||
avatar_media_id?: number | null
|
||||
avatar_url?: string | null
|
||||
created_at?: string
|
||||
favorite?: boolean | number
|
||||
id: string
|
||||
name: string
|
||||
notes?: string | null
|
||||
organization?: string | null
|
||||
phone_number: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
@@ -286,6 +286,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',
|
||||
@@ -439,17 +440,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>
|
||||
@@ -741,7 +751,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)
|
||||
|
||||
@@ -117,14 +117,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> {
|
||||
@@ -137,20 +147,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', {
|
||||
@@ -195,20 +192,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(
|
||||
|
||||
@@ -390,6 +390,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) {
|
||||
|
||||
+3578
-212
File diff suppressed because it is too large
Load Diff
+533
-76
@@ -1045,8 +1045,60 @@ const mockHousingOverview = {
|
||||
],
|
||||
}
|
||||
|
||||
let contactSequence = 2
|
||||
let contactSequence = 40
|
||||
const contacts = [
|
||||
{
|
||||
avatar_media_id: 1,
|
||||
avatar_url: 'https://picsum.photos/seed/sky-phone-1/600/800',
|
||||
created_at: isoTime(-42 * 86_400_000),
|
||||
favorite: true,
|
||||
id: 'contact-alex',
|
||||
name: 'Alex Rivera',
|
||||
notes: 'Meeting on Friday at 18:00 near the bank.',
|
||||
organization: 'Maze Bank',
|
||||
phone_number: '5551110001',
|
||||
updated_at: isoTime(-6 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-38 * 86_400_000),
|
||||
id: 'contact-alexander',
|
||||
name: 'Alexander Stone',
|
||||
phone_number: '5551110002',
|
||||
updated_at: isoTime(-8 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-21 * 86_400_000),
|
||||
id: 'contact-andre',
|
||||
name: 'Andre Heinicke',
|
||||
phone_number: '5551110003',
|
||||
updated_at: isoTime(-4 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-34 * 86_400_000),
|
||||
favorite: true,
|
||||
id: 'contact-benni',
|
||||
name: 'Benni Parker',
|
||||
notes: 'Call about the Sultan RS repair estimate.',
|
||||
organization: "Benny's Motor Works",
|
||||
phone_number: '5551110004',
|
||||
updated_at: isoTime(-12 * 86_400_000),
|
||||
},
|
||||
{
|
||||
avatar_media_id: 3,
|
||||
avatar_url: 'https://picsum.photos/seed/sky-phone-3/800/600',
|
||||
created_at: isoTime(-18 * 86_400_000),
|
||||
id: 'contact-bryce',
|
||||
name: 'Bryce Walker',
|
||||
phone_number: '5551110005',
|
||||
updated_at: isoTime(-7 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-27 * 86_400_000),
|
||||
id: 'contact-charlie',
|
||||
name: 'Charlie Davis',
|
||||
phone_number: '5551110006',
|
||||
updated_at: isoTime(-11 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: '2026-08-04 12:00:00',
|
||||
id: 'contact-1',
|
||||
@@ -1054,6 +1106,185 @@ const contacts = [
|
||||
phone_number: '5558675309',
|
||||
updated_at: '2026-08-04 12:00:00',
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-14 * 86_400_000),
|
||||
id: 'contact-morgan',
|
||||
name: 'Morgan Reed',
|
||||
phone_number: '5550192847',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-30 * 86_400_000),
|
||||
id: 'contact-jamie',
|
||||
name: 'Jamie Chen',
|
||||
phone_number: '5559876543',
|
||||
updated_at: isoTime(-30 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
id: 'contact-mechanic',
|
||||
name: 'Downtown Customs',
|
||||
phone_number: '5550100101',
|
||||
updated_at: isoTime(-5 * 86_400_000),
|
||||
},
|
||||
{
|
||||
avatar_media_id: 4,
|
||||
avatar_url: 'https://picsum.photos/seed/sky-phone-4/800/600',
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
favorite: true,
|
||||
id: 'contact-taxi',
|
||||
name: 'Los Santos Taxi',
|
||||
organization: 'Los Santos Taxi Co.',
|
||||
phone_number: '5552222222',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-22 * 86_400_000),
|
||||
id: 'contact-daniel',
|
||||
name: 'Daniel Price',
|
||||
phone_number: '5551110007',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-20 * 86_400_000),
|
||||
id: 'contact-emily',
|
||||
name: 'Emily Hart',
|
||||
phone_number: '5551110008',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-31 * 86_400_000),
|
||||
id: 'contact-franklin',
|
||||
name: 'Franklin Miles',
|
||||
phone_number: '5551110009',
|
||||
updated_at: isoTime(-9 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-25 * 86_400_000),
|
||||
id: 'contact-grace',
|
||||
name: 'Grace Carter',
|
||||
phone_number: '5551110010',
|
||||
updated_at: isoTime(-4 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-19 * 86_400_000),
|
||||
id: 'contact-hannah',
|
||||
name: 'Hannah Brooks',
|
||||
phone_number: '5551110011',
|
||||
updated_at: isoTime(-5 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-17 * 86_400_000),
|
||||
id: 'contact-ivan',
|
||||
name: 'Ivan Petrov',
|
||||
phone_number: '5551110012',
|
||||
updated_at: isoTime(-6 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-16 * 86_400_000),
|
||||
id: 'contact-kevin',
|
||||
name: 'Kevin Adams',
|
||||
phone_number: '5551110013',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-15 * 86_400_000),
|
||||
id: 'contact-naomi',
|
||||
name: 'Naomi King',
|
||||
phone_number: '5551110014',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-13 * 86_400_000),
|
||||
id: 'contact-olivia',
|
||||
name: 'Olivia Moore',
|
||||
phone_number: '5551110015',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-12 * 86_400_000),
|
||||
id: 'contact-paul',
|
||||
name: 'Paul Walker',
|
||||
phone_number: '5551110016',
|
||||
updated_at: isoTime(-4 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-11 * 86_400_000),
|
||||
id: 'contact-quinn',
|
||||
name: 'Quinn Bailey',
|
||||
phone_number: '5551110017',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-10 * 86_400_000),
|
||||
id: 'contact-riley',
|
||||
name: 'Riley Cooper',
|
||||
phone_number: '5551110018',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-9 * 86_400_000),
|
||||
id: 'contact-sofia',
|
||||
name: 'Sofia Bennett',
|
||||
phone_number: '5551110019',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-8 * 86_400_000),
|
||||
id: 'contact-thomas',
|
||||
name: 'Thomas Reed',
|
||||
phone_number: '5551110020',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-7 * 86_400_000),
|
||||
id: 'contact-ursula',
|
||||
name: 'Ursula Grant',
|
||||
phone_number: '5551110021',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-6 * 86_400_000),
|
||||
id: 'contact-victor',
|
||||
name: 'Victor Young',
|
||||
phone_number: '5551110022',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
id: 'contact-wendy',
|
||||
name: 'Wendy Clark',
|
||||
phone_number: '5551110023',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-4 * 86_400_000),
|
||||
id: 'contact-xavier',
|
||||
name: 'Xavier Cole',
|
||||
phone_number: '5551110024',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
id: 'contact-yakup',
|
||||
name: 'Yakup Broooooo',
|
||||
phone_number: '5551110025',
|
||||
updated_at: isoTime(-86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-2 * 86_400_000),
|
||||
id: 'contact-zoe',
|
||||
name: 'Zoe Martinez',
|
||||
phone_number: '5551110026',
|
||||
updated_at: isoTime(-86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-86_400_000),
|
||||
id: 'contact-market',
|
||||
name: '24/7 Supermarket',
|
||||
phone_number: '5552470000',
|
||||
updated_at: isoTime(-3_600_000),
|
||||
},
|
||||
]
|
||||
const attachmentAssets = {
|
||||
gif: new Set(['celebrate', 'hearts', 'party', 'thumbs_up', 'wow']),
|
||||
@@ -1183,6 +1414,126 @@ const smsMessages = [
|
||||
recipient_number: '5558675309',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Das Fahrzeug ist fertig. Du kannst es jederzeit abholen.',
|
||||
created_at: isoTime(-26 * 60 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-customs-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-25 * 60 * 60_000),
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5550100101',
|
||||
},
|
||||
{
|
||||
body: 'Perfekt, ich komme heute Abend vorbei.',
|
||||
created_at: isoTime(-25 * 60 * 60_000),
|
||||
direction: 'sent',
|
||||
id: 'sms-customs-2',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-25 * 60 * 60_000),
|
||||
recipient_number: '5550100101',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Treffen wir uns um 20 Uhr am Casino?',
|
||||
created_at: isoTime(-7 * 60 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-morgan-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-6 * 60 * 60_000),
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5550192847',
|
||||
},
|
||||
{
|
||||
body: 'Ja, passt. Ich bin puenktlich da.',
|
||||
created_at: isoTime(-6 * 60 * 60_000),
|
||||
direction: 'sent',
|
||||
id: 'sms-morgan-2',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-6 * 60 * 60_000),
|
||||
recipient_number: '5550192847',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Bin in zehn Minuten bei dir.',
|
||||
created_at: isoTime(-95 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-jamie-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5559876543',
|
||||
},
|
||||
{
|
||||
body: 'Dein Taxi wartet vor dem Haupteingang.',
|
||||
created_at: isoTime(-38 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-taxi-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5552222222',
|
||||
},
|
||||
{
|
||||
body: 'Danke, ich komme sofort raus.',
|
||||
created_at: isoTime(-36 * 60_000),
|
||||
direction: 'sent',
|
||||
id: 'sms-taxi-2',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: isoTime(-35 * 60_000),
|
||||
recipient_number: '5552222222',
|
||||
sender_number: '5551234567',
|
||||
},
|
||||
{
|
||||
body: 'Denk bitte an die Unterlagen fuer morgen.',
|
||||
created_at: isoTime(-12 * 60_000),
|
||||
direction: 'received',
|
||||
id: 'sms-alex-1',
|
||||
media_duration_ms: null,
|
||||
media_mime: null,
|
||||
media_payload: null,
|
||||
media_waveform: null,
|
||||
message_type: 'text',
|
||||
media_asset_id: null,
|
||||
read_at: null,
|
||||
recipient_number: '5551234567',
|
||||
sender_number: '5551110001',
|
||||
},
|
||||
]
|
||||
const darkChatProfile = {
|
||||
id: 1,
|
||||
@@ -1627,73 +1978,143 @@ const deviceData = {
|
||||
}
|
||||
let mockPasscode = ''
|
||||
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
let mockContacts = [
|
||||
{
|
||||
created_at: isoTime(-14 * 86_400_000),
|
||||
id: 'contact-morgan',
|
||||
name: 'Morgan Reed',
|
||||
phone_number: '5550192847',
|
||||
updated_at: isoTime(-2 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-30 * 86_400_000),
|
||||
id: 'contact-jamie',
|
||||
name: 'Jamie Chen',
|
||||
phone_number: '5559876543',
|
||||
updated_at: isoTime(-30 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
id: 'contact-mechanic',
|
||||
name: 'Downtown Customs',
|
||||
phone_number: '5550100101',
|
||||
updated_at: isoTime(-5 * 86_400_000),
|
||||
},
|
||||
{
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
id: 'contact-taxi',
|
||||
name: 'Los Santos Taxi',
|
||||
phone_number: '5552222222',
|
||||
updated_at: isoTime(-3 * 86_400_000),
|
||||
},
|
||||
]
|
||||
const blockedCallNumbers = new Set()
|
||||
let recentCalls = [
|
||||
{
|
||||
call_id: 'call-alex-incoming',
|
||||
created_at: isoTime(-8 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 184,
|
||||
id: 1,
|
||||
other_number: '5551110001',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-morgan-incoming',
|
||||
created_at: isoTime(-18 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 246,
|
||||
id: 1,
|
||||
id: 2,
|
||||
other_number: '5550192847',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-taxi-outgoing',
|
||||
created_at: isoTime(-42 * 60_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 39,
|
||||
id: 3,
|
||||
other_number: '5552222222',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-jamie-missed',
|
||||
created_at: isoTime(-95 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 2,
|
||||
id: 4,
|
||||
other_number: '5559876543',
|
||||
status: 'missed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-emily-no-answer',
|
||||
created_at: isoTime(-4 * 60 * 60_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: 5,
|
||||
other_number: '5551110008',
|
||||
status: 'no_answer',
|
||||
},
|
||||
{
|
||||
call_id: 'call-yakup-incoming',
|
||||
created_at: isoTime(-8 * 60 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 521,
|
||||
id: 6,
|
||||
other_number: '5551110025',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-customs-outgoing',
|
||||
created_at: isoTime(-25 * 60 * 60_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 83,
|
||||
id: 3,
|
||||
id: 7,
|
||||
other_number: '5550100101',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-unknown-declined',
|
||||
created_at: isoTime(-2 * 86_400_000),
|
||||
created_at: isoTime(-27 * 60 * 60_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 4,
|
||||
id: 8,
|
||||
other_number: '5554040404',
|
||||
status: 'declined',
|
||||
},
|
||||
{
|
||||
call_id: 'call-morgan-outgoing',
|
||||
created_at: isoTime(-2 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 72,
|
||||
id: 9,
|
||||
other_number: '5550192847',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-alex-missed',
|
||||
created_at: isoTime(-3 * 86_400_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 10,
|
||||
other_number: '5551110001',
|
||||
status: 'missed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-benni-busy',
|
||||
created_at: isoTime(-4 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: 11,
|
||||
other_number: '5551110004',
|
||||
status: 'busy',
|
||||
},
|
||||
{
|
||||
call_id: 'call-sofia-incoming',
|
||||
created_at: isoTime(-5 * 86_400_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 116,
|
||||
id: 12,
|
||||
other_number: '5551110019',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-xavier-unavailable',
|
||||
created_at: isoTime(-6 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: 13,
|
||||
other_number: '5551110024',
|
||||
status: 'unavailable',
|
||||
},
|
||||
{
|
||||
call_id: 'call-market-outgoing',
|
||||
created_at: isoTime(-7 * 86_400_000),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 51,
|
||||
id: 14,
|
||||
other_number: '5552470000',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
call_id: 'call-unknown-missed',
|
||||
created_at: isoTime(-8 * 86_400_000),
|
||||
direction: 'incoming',
|
||||
duration_seconds: 0,
|
||||
id: 15,
|
||||
other_number: '5559090909',
|
||||
status: 'missed',
|
||||
},
|
||||
]
|
||||
let mockMedia = [
|
||||
{
|
||||
@@ -5238,15 +5659,27 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
if (endpoint === 'contacts:save') {
|
||||
const name = String(request.body.name ?? '').trim()
|
||||
const notes = String(request.body.notes ?? '').trim().slice(0, 500)
|
||||
const organization = String(request.body.organization ?? '').trim().slice(0, 80)
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '').trim()
|
||||
if (!name || !phoneNumber) {
|
||||
const avatarMediaId = Number(request.body.avatarMediaId) || 0
|
||||
const avatarMedia = avatarMediaId
|
||||
? mockMedia.find(
|
||||
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
|
||||
)
|
||||
: null
|
||||
if (!name || !phoneNumber || (avatarMediaId && !avatarMedia)) {
|
||||
response.json({ success: false, error: 'invalid_contact' })
|
||||
return
|
||||
}
|
||||
let contact = contacts.find((item) => item.id === request.body.id)
|
||||
if (contact) {
|
||||
contact.name = name
|
||||
contact.notes = notes || null
|
||||
contact.organization = organization || null
|
||||
contact.phone_number = phoneNumber
|
||||
contact.avatar_media_id = avatarMedia?.id ?? null
|
||||
contact.avatar_url = avatarMedia?.url ?? null
|
||||
contact.updated_at = new Date()
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
@@ -5255,9 +5688,14 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
contact = {
|
||||
created_at: now,
|
||||
favorite: false,
|
||||
id: `contact-${contactSequence++}`,
|
||||
name,
|
||||
notes: notes || null,
|
||||
organization: organization || null,
|
||||
phone_number: phoneNumber,
|
||||
avatar_media_id: avatarMedia?.id ?? null,
|
||||
avatar_url: avatarMedia?.url ?? null,
|
||||
updated_at: now,
|
||||
}
|
||||
contacts.push(contact)
|
||||
@@ -5265,6 +5703,20 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: contact })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:favorite') {
|
||||
const contact = contacts.find((item) => item.id === request.body.id)
|
||||
if (!contact || typeof request.body.favorite !== 'boolean') {
|
||||
response.json({ success: false, error: 'contact_not_found' })
|
||||
return
|
||||
}
|
||||
contact.favorite = request.body.favorite
|
||||
contact.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
response.json({
|
||||
success: true,
|
||||
data: { favorite: contact.favorite, id: contact.id },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:delete') {
|
||||
const index = contacts.findIndex((item) => item.id === request.body.id)
|
||||
if (index >= 0) contacts.splice(index, 1)
|
||||
@@ -5275,6 +5727,27 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: { videoBitrateKbps: 1500 } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'media:devCapture') {
|
||||
const mediaType = request.body.mediaType === 'video' ? 'video' : 'photo'
|
||||
const id = Math.max(0, ...mockMedia.map((item) => Number(item.id) || 0)) + 1
|
||||
const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video')
|
||||
const media = {
|
||||
createdAt: Date.now(),
|
||||
id,
|
||||
mediaType,
|
||||
url:
|
||||
mediaType === 'photo'
|
||||
? `https://picsum.photos/seed/sky-camera-${id}/900/1600`
|
||||
: fallbackVideo?.url,
|
||||
}
|
||||
if (!media.url) {
|
||||
response.json({ success: false, error: 'unsupported' })
|
||||
return
|
||||
}
|
||||
mockMedia.unshift(media)
|
||||
response.json({ success: true, data: media })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'gallery:list') {
|
||||
if (request.body.mockState === 'error') {
|
||||
response.json({ success: false, error: 'service_unavailable' })
|
||||
@@ -5399,55 +5872,29 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:list') {
|
||||
response.json({ success: true, data: mockContacts })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:save') {
|
||||
const now = new Date().toISOString()
|
||||
const existing = mockContacts.find(
|
||||
(contact) => contact.id === request.body.id,
|
||||
)
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
name: request.body.name,
|
||||
phone_number: request.body.phoneNumber,
|
||||
updated_at: now,
|
||||
})
|
||||
response.json({ success: true, data: existing })
|
||||
return
|
||||
}
|
||||
const contact = {
|
||||
created_at: now,
|
||||
id: `contact-${Date.now()}`,
|
||||
name: request.body.name,
|
||||
phone_number: request.body.phoneNumber,
|
||||
updated_at: now,
|
||||
}
|
||||
mockContacts.push(contact)
|
||||
response.json({ success: true, data: contact })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'contacts:delete') {
|
||||
mockContacts = mockContacts.filter(
|
||||
(contact) => contact.id !== request.body.id,
|
||||
)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calls:recents') {
|
||||
response.json({ success: true, data: recentCalls })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calls:dial') {
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '').replace(/\D/g, '')
|
||||
if (phoneNumber.length !== 10) {
|
||||
response.json({ success: false, error: 'invalid_number' })
|
||||
return
|
||||
}
|
||||
if (!contacts.some((contact) => contact.phone_number === phoneNumber)) {
|
||||
response.json({ success: false, error: 'recipient_not_found' })
|
||||
return
|
||||
}
|
||||
const id = `call-${Date.now()}`
|
||||
const startedAt = Date.now()
|
||||
recentCalls.unshift({
|
||||
call_id: id,
|
||||
created_at: Date.now(),
|
||||
direction: 'outgoing',
|
||||
duration_seconds: 0,
|
||||
id: recentCalls.length + 1,
|
||||
other_number: request.body.phoneNumber,
|
||||
other_number: phoneNumber,
|
||||
status: 'completed',
|
||||
})
|
||||
response.json({
|
||||
@@ -5455,13 +5902,23 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
data: {
|
||||
direction: 'outgoing',
|
||||
id,
|
||||
otherNumber: request.body.phoneNumber,
|
||||
startedAt: Date.now(),
|
||||
otherNumber: phoneNumber,
|
||||
startedAt,
|
||||
state: 'ringing',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'calls:block') {
|
||||
const phoneNumber = String(request.body.phoneNumber ?? '').replace(/\D/g, '')
|
||||
if (!phoneNumber) {
|
||||
response.json({ success: false, error: 'invalid_number' })
|
||||
return
|
||||
}
|
||||
blockedCallNumbers.add(phoneNumber)
|
||||
response.json({ success: true, data: { blocked: true, phoneNumber } })
|
||||
return
|
||||
}
|
||||
if (
|
||||
endpoint === 'calls:answer' ||
|
||||
endpoint === 'calls:decline' ||
|
||||
|
||||
@@ -40,6 +40,7 @@ Config.Calls = {
|
||||
VoiceProvider = "pma",
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
RecentPageSize = 100,
|
||||
}
|
||||
|
||||
|
||||
@@ -377,8 +377,16 @@ Locales["en"] = {
|
||||
phone = {
|
||||
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
|
||||
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
|
||||
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts",
|
||||
addContact = "New Contact", editContact = "Edit Contact", contactName = "Name", phoneNumber = "Phone Number",
|
||||
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites",
|
||||
addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number",
|
||||
choosePhoto = "Choose Contact Photo", chooseGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove Photo",
|
||||
message = "Message", video = "Video", mail = "Mail", defaultLabel = "Default", privateLabel = "Private",
|
||||
contactCard = "Contact Card", removeContact = "Remove Contact", returnToCall = "Return to Call", 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",
|
||||
call = "Call", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
|
||||
missed = "Missed", declined = "Declined", busy = "Busy", unavailable = "Unavailable",
|
||||
noAnswer = "No Answer", cancelled = "Cancelled", disconnected = "Disconnected", sim_removed = "SIM Removed", completed = "Call Ended",
|
||||
@@ -388,8 +396,10 @@ Locales["en"] = {
|
||||
choosePhoneBody = "Select the phone that should receive {number}.", emptyPhone = "No SIM inserted",
|
||||
errors = {
|
||||
invalid_contact = "Enter a name and valid phone number.", invalid_number = "Enter a valid phone number.",
|
||||
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.", busy = "The line is busy.",
|
||||
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
|
||||
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
|
||||
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
|
||||
|
||||
@@ -171,11 +171,13 @@ local server_callbacks = {
|
||||
"contacts:list",
|
||||
"contacts:save",
|
||||
"contacts:delete",
|
||||
"contacts:favorite",
|
||||
"calls:recents",
|
||||
"calls:dial",
|
||||
"calls:answer",
|
||||
"calls:decline",
|
||||
"calls:hangup",
|
||||
"calls:block",
|
||||
"banking:overview",
|
||||
"banking:transfer",
|
||||
"billing:overview",
|
||||
|
||||
@@ -318,8 +318,8 @@ function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
|
||||
{
|
||||
query = [[
|
||||
INSERT INTO `sky_phone_contacts`
|
||||
(`id`, `contact_id`, `device_imei`, `name`, `phone_number`, `created_at`, `updated_at`)
|
||||
SELECT UUID(), `contact_id`, ?, `name`, `phone_number`, `created_at`, `updated_at`
|
||||
(`id`, `contact_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`, `created_at`, `updated_at`)
|
||||
SELECT UUID(), `contact_id`, ?, `name`, `notes`, `organization`, `phone_number`, NULL, `favorite`, `created_at`, `updated_at`
|
||||
FROM `sky_phone_contacts` WHERE `account_id` = ?
|
||||
]],
|
||||
params = { imei, account_id },
|
||||
@@ -343,7 +343,9 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
|
||||
end
|
||||
local condition, params = scope_condition(scope)
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `contact_id` AS `id`, `name`, `phone_number`, `created_at`, `updated_at`
|
||||
SELECT `contact_id` AS `id`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`,
|
||||
(SELECT media.`url` FROM `sky_phone_media` media WHERE media.`id` = `avatar_media_id`) AS `avatar_url`,
|
||||
`created_at`, `updated_at`
|
||||
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
|
||||
]]):format(condition), params)
|
||||
return { success = true, data = rows }
|
||||
@@ -358,10 +360,24 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
|
||||
return error_response
|
||||
end
|
||||
local name = trim(data.name)
|
||||
local notes = trim(data.notes) or ""
|
||||
local organization = trim(data.organization) or ""
|
||||
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
||||
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or not number then
|
||||
local avatar_media_id = tonumber(data.avatarMediaId) or 0
|
||||
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or #notes > Config.Calls.ContactNotesMaxLength or #organization > Config.Calls.ContactNameMaxLength or not number then
|
||||
return { success = false, error = "invalid_contact" }
|
||||
end
|
||||
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
|
||||
return { success = false, error = "invalid_contact" }
|
||||
end
|
||||
local avatar_url
|
||||
if avatar_media_id > 0 then
|
||||
local media_error
|
||||
avatar_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
|
||||
if not avatar_url then
|
||||
return { success = false, error = media_error }
|
||||
end
|
||||
end
|
||||
local condition, condition_params = scope_condition(scope)
|
||||
local id = type(data.id) == "string" and data.id or uuid()
|
||||
if data.id then
|
||||
@@ -373,24 +389,63 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
|
||||
if not owned[1] then
|
||||
return { success = false, error = "contact_not_found" }
|
||||
end
|
||||
local params = { name, number, id }
|
||||
local params = { name, notes, organization, number, avatar_media_id, id }
|
||||
for _, value in ipairs(condition_params) do
|
||||
params[#params + 1] = value
|
||||
end
|
||||
Bridge.Database.Query(([[
|
||||
UPDATE `sky_phone_contacts` SET `name` = ?, `phone_number` = ?
|
||||
UPDATE `sky_phone_contacts` SET `name` = ?, `notes` = NULLIF(?, ''), `organization` = NULLIF(?, ''), `phone_number` = ?, `avatar_media_id` = NULLIF(?, 0)
|
||||
WHERE `contact_id` = ? AND %s
|
||||
]]):format(condition), params)
|
||||
else
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `phone_number`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
]], { uuid(), id, scope.account_id, scope.device_imei, name, number })
|
||||
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`)
|
||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, NULLIF(?, 0))
|
||||
]], { uuid(), id, scope.account_id, scope.device_imei, name, notes, organization, number, avatar_media_id })
|
||||
end
|
||||
if scope.account_id then
|
||||
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
||||
end
|
||||
return { success = true, data = { id = id, name = name, phone_number = number } }
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
id = id,
|
||||
name = name,
|
||||
notes = notes ~= "" and notes or nil,
|
||||
organization = organization ~= "" and organization or nil,
|
||||
phone_number = number,
|
||||
avatar_media_id = avatar_media_id > 0 and avatar_media_id or nil,
|
||||
avatar_url = avatar_url,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:contacts:favorite", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "contact_favorite", 60, 60) or type(data) ~= "table" or type(data.id) ~= "string" or type(data.favorite) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local scope, error_response = current_scope(source)
|
||||
if not scope then
|
||||
return error_response
|
||||
end
|
||||
local condition, condition_params = scope_condition(scope)
|
||||
local owned_params = { data.id }
|
||||
for _, value in ipairs(condition_params) do
|
||||
owned_params[#owned_params + 1] = value
|
||||
end
|
||||
local owned = Bridge.Database.Query(("SELECT `id` FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1"):format(condition), owned_params)
|
||||
if not owned[1] then
|
||||
return { success = false, error = "contact_not_found" }
|
||||
end
|
||||
local params = { data.favorite and 1 or 0, data.id }
|
||||
for _, value in ipairs(condition_params) do
|
||||
params[#params + 1] = value
|
||||
end
|
||||
Bridge.Database.Query(("UPDATE `sky_phone_contacts` SET `favorite` = ? WHERE `contact_id` = ? AND %s"):format(condition), params)
|
||||
if scope.account_id then
|
||||
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
||||
end
|
||||
return { success = true, data = { id = data.id, favorite = data.favorite } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:contacts:delete", function(source, data)
|
||||
@@ -492,12 +547,27 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
|
||||
WHERE s.`phone_number` = ? LIMIT 1
|
||||
]], { number })
|
||||
local target = targets[1]
|
||||
if not target or not target.imei then
|
||||
if not target then
|
||||
dialing_by_sim[scope.device.sim_id] = nil
|
||||
dial_locks[source] = nil
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
if not target.imei then
|
||||
local terminal = create_terminal_call(scope, number, target, "unavailable")
|
||||
dialing_by_sim[scope.device.sim_id] = nil
|
||||
dial_locks[source] = nil
|
||||
return { success = true, data = terminal }
|
||||
end
|
||||
local blocks = Bridge.Database.Query([[
|
||||
SELECT 1 FROM `sky_phone_call_blocks`
|
||||
WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1
|
||||
]], { target.id, scope.device.sim_id })
|
||||
if blocks[1] then
|
||||
local terminal = create_terminal_call(scope, number, target, "declined")
|
||||
dialing_by_sim[scope.device.sim_id] = nil
|
||||
dial_locks[source] = nil
|
||||
return { success = true, data = terminal }
|
||||
end
|
||||
local callee_source = find_device_holder(target.imei)
|
||||
if not callee_source or airplane_mode(target.imei) then
|
||||
local terminal = create_terminal_call(scope, number, target, "unavailable")
|
||||
@@ -751,6 +821,51 @@ Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calls:block", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "call_block", 10, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local scope, error_response = current_scope(source)
|
||||
if not scope then
|
||||
return error_response
|
||||
end
|
||||
if not scope.device.sim_id then
|
||||
return { success = false, error = "no_sim" }
|
||||
end
|
||||
local number = type(data) == "table" and SkyPhoneSimNumber.Normalize(
|
||||
data.phoneNumber,
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
) or nil
|
||||
if not number then
|
||||
return { success = false, error = "invalid_number" }
|
||||
end
|
||||
if number == scope.device.phone_number then
|
||||
return { success = false, error = "self_call" }
|
||||
end
|
||||
local rows = Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
|
||||
{ number }
|
||||
)
|
||||
if not rows[1] then
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_call_blocks` (`blocker_sim_id`, `blocked_sim_id`)
|
||||
VALUES (?, ?)
|
||||
]], { scope.device.sim_id, rows[1].id })
|
||||
|
||||
local call_id = active_by_source[source]
|
||||
local call = call_id and calls[call_id] or nil
|
||||
if call then
|
||||
local other_number = source == call.caller_source and call.callee_number or call.caller_number
|
||||
if other_number == number then
|
||||
finish_call(call, call.answered_at and "completed" or "declined")
|
||||
end
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:payphone:hangup", function(source, data)
|
||||
local call_id = active_by_source[source]
|
||||
local call = call_id and calls[call_id] or nil
|
||||
|
||||
@@ -354,7 +354,11 @@ local schema = {
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "name", type = "VARCHAR(80) NOT NULL" },
|
||||
{ name = "notes", type = "VARCHAR(500) NULL" },
|
||||
{ name = "organization", type = "VARCHAR(80) NULL" },
|
||||
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "favorite", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
@@ -362,10 +366,12 @@ local schema = {
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_contacts_account", columns = "(`account_id`, `name`)" },
|
||||
{ name = "idx_sky_phone_contacts_device", columns = "(`device_imei`, `name`)" },
|
||||
{ name = "idx_sky_phone_contacts_avatar", columns = "(`avatar_media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
|
||||
@@ -153,7 +153,11 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`name` VARCHAR(80) NOT NULL,
|
||||
`notes` VARCHAR(500) NULL,
|
||||
`organization` VARCHAR(80) NULL,
|
||||
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`avatar_media_id` BIGINT UNSIGNED NULL,
|
||||
`favorite` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -161,8 +165,10 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
|
||||
UNIQUE KEY `uniq_sky_phone_contacts_device_contact` (`device_imei`, `contact_id`),
|
||||
KEY `idx_sky_phone_contacts_account` (`account_id`, `name`),
|
||||
KEY `idx_sky_phone_contacts_device` (`device_imei`, `name`),
|
||||
KEY `idx_sky_phone_contacts_avatar` (`avatar_media_id`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
|
||||
@@ -183,6 +189,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
|
||||
FOREIGN KEY (`callee_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_call_blocks` (
|
||||
`blocker_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`blocked_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`blocker_sim_id`, `blocked_sim_id`),
|
||||
KEY `idx_sky_phone_call_blocks_blocked` (`blocked_sim_id`),
|
||||
FOREIGN KEY (`blocker_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`blocked_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`call_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user