ENH - expand phone app experience

This commit is contained in:
smx.pusha
2026-08-11 12:51:41 +02:00
parent ab7c38b682
commit 67edb404c6
18 changed files with 4548 additions and 350 deletions
+11
View File
@@ -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"
/>
+41
View File
@@ -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;
+31 -5
View File
@@ -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>
+50
View File
@@ -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)
})
})
+47 -3
View File
@@ -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,
}
})
+52
View File
@@ -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.',
+5
View File
@@ -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
}
+14 -4
View File
@@ -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)
+20 -36
View File
@@ -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(
+2
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
+533 -76
View File
@@ -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' ||
+1
View File
@@ -40,6 +40,7 @@ Config.Calls = {
VoiceProvider = "pma",
RingSeconds = 30,
ContactNameMaxLength = 80,
ContactNotesMaxLength = 500,
RecentPageSize = 100,
}
+12 -2
View File
@@ -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.",
+2
View File
@@ -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",
+126 -11
View File
@@ -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
+6
View File
@@ -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",
},
+17 -1
View File
@@ -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,