ENH - expand phone apps and polish iOS UI
@@ -20,6 +20,23 @@ MusicTracks = {
|
||||
}
|
||||
```
|
||||
|
||||
## FlipTok accounts
|
||||
|
||||
FlipTok profiles use their own username and password login. Registration requires a linked iFruit
|
||||
account once so an existing creator profile, videos, followers, and verification can be claimed
|
||||
without data loss. Login sessions are stored per phone IMEI and survive resource or server restarts;
|
||||
signing out removes only that device session.
|
||||
|
||||
Set a private, stable password pepper in `server.cfg` before players register. Changing it later
|
||||
invalidates every existing FlipTok password:
|
||||
|
||||
```cfg
|
||||
set sky_phone_fliptok_password_pepper "replace-with-a-long-random-secret"
|
||||
```
|
||||
|
||||
Passwords are stored as salted hashes. The pepper is read server-side from the convar and is never
|
||||
included in the NUI bundle.
|
||||
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
|
||||
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3b82f6"/>
|
||||
<stop offset="0.52" stop-color="#1857d9"/>
|
||||
<stop offset="1" stop-color="#081b55"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine" x1="26" y1="18" x2="99" y2="106" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff" stop-opacity=".95"/>
|
||||
<stop offset="1" stop-color="#cfe1ff" stop-opacity=".82"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
|
||||
<circle cx="103" cy="22" r="35" fill="#6aa4ff" opacity=".2"/>
|
||||
<path d="M26 52 64 29l38 23v8H26v-8Zm7 15h10v25H33V67Zm21 0h10v25H54V67Zm21 0h10v25H75V67Zm21 0h-1v25h-9V67h10ZM25 98h78v10H25V98Z" fill="url(#shine)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 846 B |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="18" y1="12" x2="108" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9f7aea"/>
|
||||
<stop offset="1" stop-color="#5b21b6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="#050507"/>
|
||||
<path d="M24 30c0-8 6-14 14-14h52c8 0 14 6 14 14v42c0 8-6 14-14 14H65L43 105c-3 3-8 1-8-4V86c-6-2-11-7-11-14V30Z" fill="url(#g)"/>
|
||||
<path d="M49 51a15 15 0 0 1 30 0v5h3c3 0 5 2 5 5v14c0 3-2 5-5 5H46c-3 0-5-2-5-5V61c0-3 2-5 5-5h3v-5Zm8 5h14v-5a7 7 0 0 0-14 0v5Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 622 B |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="FlipTok">
|
||||
<defs>
|
||||
<linearGradient id="flip-bg" x1="18" y1="10" x2="112" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7471f2"/>
|
||||
<stop offset="1" stop-color="#3532a8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flip-card" x1="48" y1="30" x2="98" y2="106" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#e8e7ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="url(#flip-bg)"/>
|
||||
<rect x="23" y="25" width="55" height="76" rx="14" transform="rotate(-9 23 25)" fill="#ffffff" fill-opacity=".23" stroke="#ffffff" stroke-opacity=".46" stroke-width="3"/>
|
||||
<rect x="46" y="23" width="61" height="82" rx="16" fill="url(#flip-card)"/>
|
||||
<path d="M69 50.5c0-3 3.3-4.8 5.8-3.1l21 14c2.2 1.5 2.2 4.7 0 6.2l-21 14c-2.5 1.7-5.8-.1-5.8-3.1v-28Z" fill="#403db7"/>
|
||||
<path d="M24 89c7 18 24 27 44 27 13 0 25-4 34-12" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/>
|
||||
<path d="m94 101 11 1-2 11" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Garage">
|
||||
<defs>
|
||||
<linearGradient id="garage-bg" x1="18" y1="8" x2="112" y2="122" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#62ddff"/>
|
||||
<stop offset="0.5" stop-color="#287cff"/>
|
||||
<stop offset="1" stop-color="#1643aa"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="garage-car" x1="39" y1="53" x2="92" y2="94" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#fff"/>
|
||||
<stop offset="1" stop-color="#dbeaff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="url(#garage-bg)"/>
|
||||
<path d="M25 56 64 29l39 27v44a5 5 0 0 1-5 5H30a5 5 0 0 1-5-5V56Z" fill="#fff" fill-opacity=".22" stroke="#fff" stroke-width="5" stroke-linejoin="round"/>
|
||||
<path d="M37 68h54v37H37V68Z" fill="#1759c8" fill-opacity=".72"/>
|
||||
<path d="m45 82 5-13a6 6 0 0 1 6-4h17a6 6 0 0 1 6 4l5 13 5 5v9a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4H51a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4v-9l5-5Z" fill="url(#garage-car)"/>
|
||||
<path d="M49 81h30l-4-10a3 3 0 0 0-3-2H56a3 3 0 0 0-3 2l-4 10Z" fill="#4f9cff"/>
|
||||
<circle cx="50" cy="90" r="4" fill="#287cff"/>
|
||||
<circle cx="78" cy="90" r="4" fill="#287cff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -4153,29 +4153,23 @@ button {
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.messages-inbox-toolbar label {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid #ededf0;
|
||||
border-radius: 22px;
|
||||
background: rgb(255 255 255 / 94%);
|
||||
box-shadow: 0 7px 25px rgb(0 0 0 / 8%);
|
||||
color: #9b9ba0;
|
||||
backdrop-filter: blur(22px);
|
||||
-webkit-backdrop-filter: blur(22px);
|
||||
}
|
||||
.messages-inbox-toolbar input {
|
||||
.messages-inbox-search {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #111;
|
||||
font-size: 13px;
|
||||
}
|
||||
.messages-inbox-search__voice {
|
||||
position: absolute;
|
||||
z-index: 45;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: currentColor;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.messages-inbox-toolbar > button {
|
||||
width: 40px;
|
||||
@@ -4380,11 +4374,9 @@ button {
|
||||
}
|
||||
.messages-chat-header__name strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.messages-chat-header__name svg {
|
||||
@@ -5166,7 +5158,6 @@ button {
|
||||
backdrop-filter: blur(22px) saturate(175%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(175%);
|
||||
}
|
||||
.messages-inbox-toolbar label,
|
||||
.messages-edit-toolbar,
|
||||
.messages-messagebar,
|
||||
.messages-chat-header__name {
|
||||
@@ -5533,7 +5524,6 @@ button {
|
||||
.phone-app.dark .messages-contact-details__fields span {
|
||||
color: #98989f;
|
||||
}
|
||||
.phone-app.dark .messages-inbox-toolbar label,
|
||||
.phone-app.dark .messages-edit-toolbar,
|
||||
.phone-app.dark .messages-messagebar,
|
||||
.phone-app.dark .messages-chat-header__name {
|
||||
@@ -5543,7 +5533,6 @@ button {
|
||||
inset 0 1px 0 rgb(255 255 255 / 10%),
|
||||
0 8px 26px rgb(0 0 0 / 32%);
|
||||
}
|
||||
.phone-app.dark .messages-inbox-toolbar input,
|
||||
.phone-app.dark .messages-messagebar textarea,
|
||||
.phone-app.dark .messages-gif-search input,
|
||||
.phone-app.dark .messages-full-emoji-picker__search input {
|
||||
@@ -5636,6 +5625,11 @@ button {
|
||||
}
|
||||
.messages-thread-page .messages-chat-header__contact {
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
margin-left: -140px;
|
||||
transform: none;
|
||||
}
|
||||
.messages-thread-page .messages-avatar--header {
|
||||
width: 64px;
|
||||
@@ -5644,7 +5638,8 @@ button {
|
||||
}
|
||||
.messages-thread-page .messages-chat-header__name {
|
||||
min-width: 0;
|
||||
max-width: 230px;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 8px);
|
||||
height: 32px;
|
||||
padding: 0 11px;
|
||||
justify-content: center;
|
||||
@@ -6047,29 +6042,13 @@ button {
|
||||
height: 50px;
|
||||
gap: 10px;
|
||||
}
|
||||
.messages-inbox-page .messages-inbox-toolbar label {
|
||||
.messages-inbox-page .messages-inbox-search {
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
border-color: var(--messages-inbox-control-border);
|
||||
border-radius: 25px;
|
||||
background: var(--messages-inbox-control);
|
||||
box-shadow: var(--messages-inbox-control-shadow);
|
||||
color: var(--messages-inbox-secondary);
|
||||
}
|
||||
.messages-inbox-page .messages-inbox-toolbar label > svg:first-child {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
.messages-inbox-page .messages-inbox-search__voice {
|
||||
color: var(--messages-inbox-primary);
|
||||
}
|
||||
.messages-inbox-page .messages-inbox-toolbar label > svg:last-child {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
color: var(--messages-inbox-primary);
|
||||
}
|
||||
.messages-inbox-page .messages-inbox-toolbar input {
|
||||
color: var(--messages-inbox-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
.messages-inbox-page .messages-inbox-toolbar > button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -6100,8 +6079,7 @@ button {
|
||||
--messages-inbox-secondary: #8e8e93;
|
||||
--messages-inbox-separator: #2c2c2e;
|
||||
}
|
||||
.phone-app.dark .messages-inbox-page .messages-inbox-header,
|
||||
.phone-app.dark .messages-inbox-page .messages-inbox-toolbar label {
|
||||
.phone-app.dark .messages-inbox-page .messages-inbox-header {
|
||||
background: var(--messages-inbox-control);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,8 @@ watch(
|
||||
:key="contact.id"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
:title="contact.name"
|
||||
:subtitle="contact.phone_number"
|
||||
@click="toggleContact(contact.id)"
|
||||
|
||||
@@ -183,6 +183,8 @@ watch(
|
||||
:key="definition.kind"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
:subtitle="phone.t(definition.descriptionKey)"
|
||||
@click="selectWidget(definition)"
|
||||
|
||||
@@ -35,7 +35,7 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg'
|
||||
import mailIcon from '@/assets/img/app-icons/mail.webp'
|
||||
import mapIcon from '@/assets/img/app-icons/map.webp'
|
||||
import messagesIcon from '@/assets/img/app-icons/sms.webp'
|
||||
import darkChatIcon from '@/assets/img/app-icons/darkchat.svg'
|
||||
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
|
||||
import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
import photosIcon from '@/assets/img/app-icons/gallery.webp'
|
||||
import phoneIcon from '@/assets/img/app-icons/phone.webp'
|
||||
@@ -48,11 +48,11 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
|
||||
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
|
||||
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
|
||||
import weatherIcon from '@/assets/img/app-icons/weather.webp'
|
||||
import bankingIcon from '@/assets/img/app-icons/banking.svg'
|
||||
import garageIcon from '@/assets/img/app-icons/garage.svg'
|
||||
import bankingIcon from '@/assets/img/app-icons/banking.webp'
|
||||
import garageIcon from '@/assets/img/app-icons/garage.webp'
|
||||
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
|
||||
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
|
||||
import flipTokIcon from '@/assets/img/app-icons/fliptok.svg'
|
||||
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
defaultMapPercentToWorld,
|
||||
defaultMapWorldToPercent,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
|
||||
describe('default map geometry', () => {
|
||||
it('round-trips world coordinates through map percentages', () => {
|
||||
const world = { x: -75.2, y: -818.9 }
|
||||
const restored = defaultMapPercentToWorld(defaultMapWorldToPercent(world))
|
||||
|
||||
expect(restored.x).toBeCloseTo(world.x, 6)
|
||||
expect(restored.y).toBeCloseTo(world.y, 6)
|
||||
})
|
||||
})
|
||||
@@ -77,3 +77,11 @@ export const defaultMapWorldToPercent = (point: MapPoint): MapPoint => ({
|
||||
(defaultMapCoordinates.yFlipOffset - point.y - defaultMapCoordinates.minY) /
|
||||
defaultMapCoordinates.height,
|
||||
})
|
||||
|
||||
export const defaultMapPercentToWorld = (point: MapPoint): MapPoint => ({
|
||||
x: defaultMapCoordinates.minX + point.x * defaultMapCoordinates.width,
|
||||
y:
|
||||
defaultMapCoordinates.yFlipOffset -
|
||||
defaultMapCoordinates.minY -
|
||||
point.y * defaultMapCoordinates.height,
|
||||
})
|
||||
|
||||
@@ -130,4 +130,57 @@ describe('FlipTok verification updates', () => {
|
||||
expect(store.activities).toEqual([])
|
||||
expect(store.viewedProfile).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the app signed out when bootstrap has no FlipTok session', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValue({
|
||||
success: true,
|
||||
data: { authenticated: false, musicTracks: [] },
|
||||
})
|
||||
const store = useFlipTokStore()
|
||||
|
||||
expect(await store.bootstrap()).toBe(true)
|
||||
expect(store.authenticated).toBe(false)
|
||||
expect(store.profile).toBeNull()
|
||||
expect(store.feed).toEqual([])
|
||||
})
|
||||
|
||||
it('loads the profile after a successful login', async () => {
|
||||
vi.mocked(nuiCall)
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
authenticated: true,
|
||||
feed: { hasMore: false, items: [{ ...video }], offset: 0 },
|
||||
isAdmin: false,
|
||||
musicTracks: [],
|
||||
profile: { ...profile },
|
||||
},
|
||||
})
|
||||
const store = useFlipTokStore()
|
||||
|
||||
expect((await store.login('nova', 'password123')).success).toBe(true)
|
||||
expect(store.authenticated).toBe(true)
|
||||
expect(store.profile?.handle).toBe('nova')
|
||||
expect(nuiCall).toHaveBeenNthCalledWith(1, 'fliptok:login', {
|
||||
handle: 'nova',
|
||||
password: 'password123',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the complete local session after logout', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValue({ success: true })
|
||||
const store = useFlipTokStore()
|
||||
store.authenticated = true
|
||||
store.profile = { ...profile }
|
||||
store.feed = [{ ...video }]
|
||||
store.activities = [{ ...activity }]
|
||||
|
||||
expect((await store.logout()).success).toBe(true)
|
||||
expect(nuiCall).toHaveBeenCalledWith('fliptok:logout')
|
||||
expect(store.authenticated).toBe(false)
|
||||
expect(store.profile).toBeNull()
|
||||
expect(store.feed).toEqual([])
|
||||
expect(store.activities).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
export const useFlipTokStore = defineStore('fliptok', {
|
||||
state: () => ({
|
||||
activities: [] as FlipTokActivity[],
|
||||
authenticated: false,
|
||||
comments: [] as FlipTokComment[],
|
||||
feed: [] as FlipTokVideo[],
|
||||
isAdmin: false,
|
||||
@@ -61,19 +62,45 @@ export const useFlipTokStore = defineStore('fliptok', {
|
||||
async bootstrap(): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<{
|
||||
feed: FlipTokPage
|
||||
isAdmin: boolean
|
||||
authenticated: boolean
|
||||
feed?: FlipTokPage
|
||||
isAdmin?: boolean
|
||||
musicTracks: FlipTokMusicTrack[]
|
||||
profile: FlipTokProfile
|
||||
profile?: FlipTokProfile
|
||||
}>('fliptok:bootstrap')
|
||||
this.loading = false
|
||||
if (!response.success || !response.data) return false
|
||||
this.profile = response.data.profile
|
||||
this.feed = response.data.feed.items
|
||||
this.authenticated = response.data.authenticated === true
|
||||
this.profile = response.data.profile ?? null
|
||||
this.feed = response.data.feed?.items ?? []
|
||||
this.isAdmin = response.data.isAdmin === true
|
||||
this.musicTracks = response.data.musicTracks ?? []
|
||||
return true
|
||||
},
|
||||
async login(handle: string, password: string): Promise<NuiResponse> {
|
||||
const response = await nuiCall('fliptok:login', { handle, password })
|
||||
if (response.success) await this.bootstrap()
|
||||
return response
|
||||
},
|
||||
async register(
|
||||
displayName: string,
|
||||
handle: string,
|
||||
password: string,
|
||||
): Promise<NuiResponse> {
|
||||
const response = await nuiCall('fliptok:register', {
|
||||
displayName,
|
||||
handle,
|
||||
password,
|
||||
})
|
||||
if (response.success) await this.bootstrap()
|
||||
return response
|
||||
},
|
||||
async logout(): Promise<NuiResponse> {
|
||||
const response = await nuiCall('fliptok:logout')
|
||||
if (!response.success) return response
|
||||
this.$reset()
|
||||
return response
|
||||
},
|
||||
async loadFeed(mode?: 'for-you' | 'following'): Promise<boolean> {
|
||||
mode ??= this.mode
|
||||
this.mode = mode
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMapStore } from '@/stores/map'
|
||||
import type { MapMarker } from '@/types/map'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
const marker: MapMarker = {
|
||||
color: 'blue',
|
||||
coords: { x: -75.2, y: -818.9, z: 0 },
|
||||
id: 'marker-1',
|
||||
label: 'Meeting point',
|
||||
}
|
||||
|
||||
describe('map store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads persistent markers', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: [marker], success: true })
|
||||
const map = useMapStore()
|
||||
|
||||
expect(await map.load()).toBe(true)
|
||||
expect(map.markers).toEqual([marker])
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('map:markers')
|
||||
})
|
||||
|
||||
it('adds a marker returned by the server', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: marker, success: true })
|
||||
const map = useMapStore()
|
||||
const input = {
|
||||
color: marker.color,
|
||||
coords: marker.coords,
|
||||
label: marker.label,
|
||||
}
|
||||
|
||||
expect((await map.create(input)).success).toBe(true)
|
||||
expect(map.markers).toEqual([marker])
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('map:create-marker', input)
|
||||
})
|
||||
|
||||
it('only removes a marker after server confirmation', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ error: 'request_failed', success: false })
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
const map = useMapStore()
|
||||
map.markers = [marker]
|
||||
|
||||
await map.remove(marker.id)
|
||||
expect(map.markers).toEqual([marker])
|
||||
|
||||
await map.remove(marker.id)
|
||||
expect(map.markers).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { CreateMapMarker, MapMarker } from '@/types/map'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useMapStore = defineStore('map', {
|
||||
state: () => ({
|
||||
error: '',
|
||||
isLoading: false,
|
||||
markers: [] as MapMarker[],
|
||||
}),
|
||||
actions: {
|
||||
async load(): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<MapMarker[]>('map:markers')
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) {
|
||||
this.markers = response.data
|
||||
this.error = ''
|
||||
return true
|
||||
}
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
},
|
||||
async create(marker: CreateMapMarker): Promise<NuiResponse<MapMarker>> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall<MapMarker>('map:create-marker', marker)
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) {
|
||||
this.markers.push(response.data)
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
async remove(id: string): Promise<NuiResponse> {
|
||||
this.isLoading = true
|
||||
const response = await nuiCall('map:delete-marker', { id })
|
||||
this.isLoading = false
|
||||
if (response.success) {
|
||||
this.markers = this.markers.filter((marker) => marker.id !== id)
|
||||
this.error = ''
|
||||
} else {
|
||||
this.error = response.error ?? 'request_failed'
|
||||
}
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -119,6 +119,30 @@ const defaultLocales: LocaleTree = {
|
||||
username: 'Username',
|
||||
bio: 'Bio',
|
||||
accountType: 'Account type',
|
||||
authTitle: 'Your FlipTok account',
|
||||
login: 'Sign In',
|
||||
register: 'Register',
|
||||
createAccount: 'Create Account',
|
||||
logout: 'Sign Out',
|
||||
loginBody:
|
||||
'Sign in to continue with your videos, follows, and saved posts.',
|
||||
registerBody: 'Create a private FlipTok login for this profile.',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordsMismatch: 'The passwords do not match.',
|
||||
displayNamePlaceholder: 'Your name',
|
||||
usernamePlaceholder: 'username',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
confirmPasswordPlaceholder: 'Enter password again',
|
||||
registrationHint:
|
||||
'An iFruit account is required once to create and own a FlipTok profile.',
|
||||
accountDetails: 'Account details',
|
||||
profileDetails: 'Profile details',
|
||||
account: 'Account',
|
||||
signOutTitle: 'Sign out of FlipTok?',
|
||||
signOutBody:
|
||||
'Your profile and videos stay online. This phone will return to the FlipTok sign-in screen.',
|
||||
signingOut: 'Signing Out...',
|
||||
accountTypes: {
|
||||
person: 'Person',
|
||||
business: 'Business',
|
||||
@@ -145,6 +169,12 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_comment: 'Enter a valid comment.',
|
||||
comments_disabled: 'Comments are disabled.',
|
||||
invalid_profile: 'Check your profile details.',
|
||||
invalid_handle: 'Use 3–24 letters, numbers, dots, or underscores.',
|
||||
invalid_display_name: 'Enter a display name.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
invalid_credentials: 'Username or password is incorrect.',
|
||||
already_registered:
|
||||
'This iFruit account already owns a registered FlipTok profile.',
|
||||
handle_taken: 'This username is already taken.',
|
||||
video_not_found: 'This video is unavailable.',
|
||||
rate_limited: 'Too many actions. Try again shortly.',
|
||||
@@ -1074,6 +1104,36 @@ const defaultLocales: LocaleTree = {
|
||||
currentLocation: 'Current Location',
|
||||
imageError: 'The map image could not be loaded.',
|
||||
switchStyle: 'Switch Map Type',
|
||||
addMarker: 'Add Marker',
|
||||
placeMarker: 'Place Marker',
|
||||
placeMarkerHint:
|
||||
'Move the map until the crosshair is over the destination.',
|
||||
addHere: 'Add Here',
|
||||
newMarker: 'New Marker',
|
||||
newMarkerDescription: 'Give this saved place a name and color.',
|
||||
markerName: 'Name',
|
||||
markerNamePlaceholder: 'e.g. Meeting point',
|
||||
markerColor: 'Marker Color',
|
||||
saveMarker: 'Save Marker',
|
||||
deleteMarker: 'Delete Marker',
|
||||
setWaypoint: 'Set Waypoint',
|
||||
waypointSet: 'Waypoint set.',
|
||||
markerSaved: 'Marker saved.',
|
||||
markerDeleted: 'Marker deleted.',
|
||||
colors: {
|
||||
blue: 'Blue',
|
||||
green: 'Green',
|
||||
orange: 'Orange',
|
||||
purple: 'Purple',
|
||||
red: 'Red',
|
||||
},
|
||||
errors: {
|
||||
invalid_marker: 'Enter a valid marker name and position.',
|
||||
marker_limit: 'This phone has reached its marker limit.',
|
||||
marker_not_found: 'This marker no longer exists.',
|
||||
rate_limited: 'Too many changes. Try again shortly.',
|
||||
request_failed: 'The marker could not be saved.',
|
||||
},
|
||||
styles: {
|
||||
default: 'Default Map',
|
||||
satellite: 'Satellite Map',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MapPoint } from '@/features/map/defaultMapGeometry'
|
||||
|
||||
export type MapMarkerColor = 'blue' | 'green' | 'orange' | 'purple' | 'red'
|
||||
|
||||
export type MapMarker = {
|
||||
color: MapMarkerColor
|
||||
coords: MapPoint & { z: number }
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type CreateMapMarker = Omit<MapMarker, 'id'>
|
||||
@@ -1004,6 +1004,7 @@ watch(isEditablePage, (visible) => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="phone.t('Home.widgetSystem.editWidget')"
|
||||
@click="openWidgetConfig"
|
||||
>
|
||||
@@ -1012,6 +1013,7 @@ watch(isEditablePage, (visible) => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
class="widget-action-remove"
|
||||
:title="phone.t('Home.widgetSystem.removeWidget')"
|
||||
@click="activeWidget && removeWidget(activeWidget.id)"
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
Plus,
|
||||
QrCode,
|
||||
Reply,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
@@ -89,6 +88,10 @@ const darkMessagebarColors = {
|
||||
placeholderIos: 'placeholder-[#8e8e93]',
|
||||
toolbarIconIos: 'fill-[#0a84ff]',
|
||||
}
|
||||
const darkSearchbarColors = {
|
||||
inputBgIos: 'bg-[#1c1c1e]',
|
||||
placeholderIos: 'placeholder-[#8e8e93]',
|
||||
}
|
||||
const darkSheetColors = {
|
||||
bgIos: 'bg-[#111113]',
|
||||
}
|
||||
@@ -182,10 +185,6 @@ function inputValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
function setSearch(event: Event): void {
|
||||
search.value = inputValue(event)
|
||||
}
|
||||
|
||||
function setIdentifier(event: Event): void {
|
||||
identifier.value = inputValue(event)
|
||||
}
|
||||
@@ -1706,20 +1705,6 @@ onBeforeUnmount(() => {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.dc-report-dialog textarea {
|
||||
width: 100%;
|
||||
min-height: 82px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
resize: none;
|
||||
border: 0.5px solid var(--dc-border);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
color: var(--dc-label);
|
||||
font-size: 14px;
|
||||
background: var(--dc-surface-raised);
|
||||
}
|
||||
|
||||
.dc-action-sheet {
|
||||
max-height: 72%;
|
||||
padding: 7px 0 32px;
|
||||
@@ -2023,33 +2008,10 @@ onBeforeUnmount(() => {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.dc-sms-inbox-toolbar label {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
.dc-sms-search {
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0.5px solid var(--dc-border);
|
||||
border-radius: 22px;
|
||||
color: var(--dc-tertiary);
|
||||
background: rgb(28 28 30 / 94%);
|
||||
}
|
||||
|
||||
.dc-sms-inbox-toolbar input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--dc-label);
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dc-sms-inbox-toolbar input::placeholder {
|
||||
color: var(--dc-tertiary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dc-sms-inbox-toolbar > button {
|
||||
@@ -2244,13 +2206,14 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<footer class="dc-sms-inbox-toolbar">
|
||||
<label
|
||||
><Search :size="17" /><input
|
||||
:value="search"
|
||||
type="search"
|
||||
:placeholder="phone.t('Common.search')"
|
||||
@input="setSearch" /></label
|
||||
><k-glass
|
||||
<k-searchbar
|
||||
v-model="search"
|
||||
class="dc-sms-search"
|
||||
:placeholder="phone.t('Common.search')"
|
||||
:colors="darkSearchbarColors"
|
||||
:input-style="{ color: 'var(--dc-label)' }"
|
||||
/>
|
||||
<k-glass
|
||||
component="button"
|
||||
type="button"
|
||||
:aria-label="t('newChat')"
|
||||
@@ -2309,6 +2272,7 @@ onBeforeUnmount(() => {
|
||||
:key="contact.id"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="contact.alias"
|
||||
:subtitle="contact.darkId"
|
||||
@click="requestStart(contact.darkId)"
|
||||
@@ -2611,6 +2575,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('disappearing')"
|
||||
@click="selectionSheet = 'disappearing'"
|
||||
>
|
||||
@@ -2640,6 +2605,7 @@ onBeforeUnmount(() => {
|
||||
v-if="active.peer.isContact"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('removeContact')"
|
||||
@click="removeContact"
|
||||
><template #media><UserMinus :size="20" /></template
|
||||
@@ -2647,6 +2613,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="active.peer.blocked ? t('unblock') : t('block')"
|
||||
@click="toggleBlock"
|
||||
><template #media><ShieldOff :size="20" /></template
|
||||
@@ -2654,6 +2621,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('report')"
|
||||
@click="beginReport()"
|
||||
><template #media><BellOff :size="20" /></template
|
||||
@@ -2661,6 +2629,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('clearChat')"
|
||||
@click="clearChat"
|
||||
><template #media><Trash2 :size="20" /></template
|
||||
@@ -2716,12 +2685,18 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
:title="t('notificationPrivacy')"
|
||||
content-class="w-full"
|
||||
title-wrap-class="gap-2"
|
||||
@click="selectionSheet = 'notification'"
|
||||
>
|
||||
<template #media><Bell :size="20" /></template>
|
||||
<template #title>
|
||||
<span class="whitespace-nowrap text-[15px]">
|
||||
{{ t('notificationPrivacy') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #after
|
||||
><span class="dc-setting-value">{{
|
||||
><span class="dc-setting-value max-w-[106px] text-[12px]">{{
|
||||
notificationOptions.find(
|
||||
(option) => option.value === notificationMode,
|
||||
)?.label
|
||||
@@ -2797,6 +2772,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('reply')"
|
||||
@click="beginReply(selectedMessage)"
|
||||
><template #media><Reply :size="20" /></template
|
||||
@@ -2808,6 +2784,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('copy')"
|
||||
@click="copyMessage(selectedMessage)"
|
||||
><template #media><Copy :size="20" /></template
|
||||
@@ -2815,6 +2792,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('deleteForMe')"
|
||||
@click="messageAction(selectedMessage, 'delete_me')"
|
||||
><template #media><Trash2 :size="20" /></template
|
||||
@@ -2823,6 +2801,7 @@ onBeforeUnmount(() => {
|
||||
v-if="selectedMessage.direction === 'sent'"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
class="dc-danger-row"
|
||||
:title="t('deleteForBoth')"
|
||||
@click="messageAction(selectedMessage, 'delete_all')"
|
||||
@@ -2832,6 +2811,7 @@ onBeforeUnmount(() => {
|
||||
v-if="selectedMessage.direction === 'received'"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
class="dc-danger-row"
|
||||
:title="t('report')"
|
||||
@click="beginReport(selectedMessage)"
|
||||
@@ -2859,6 +2839,7 @@ onBeforeUnmount(() => {
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="t('reportUser')"
|
||||
@click="selectionSheet = 'report'"
|
||||
>
|
||||
@@ -2870,12 +2851,17 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<textarea
|
||||
:value="reportDetails"
|
||||
maxlength="500"
|
||||
:placeholder="t('reportDetails')"
|
||||
@input="setReportDetails"
|
||||
/>
|
||||
<k-list inset strong class="dc-form-list">
|
||||
<k-list-input
|
||||
type="textarea"
|
||||
:value="reportDetails"
|
||||
maxlength="500"
|
||||
:placeholder="t('reportDetails')"
|
||||
:colors="darkInputColors"
|
||||
input-class="text-[#f5f5f7] placeholder:text-[#8e8e93]"
|
||||
@input="setReportDetails"
|
||||
/>
|
||||
</k-list>
|
||||
<template #buttons
|
||||
><k-dialog-button @click="closeReport">{{
|
||||
phone.t('Common.cancel')
|
||||
@@ -2900,6 +2886,8 @@ onBeforeUnmount(() => {
|
||||
:key="option.value"
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:chevron="false"
|
||||
:title="option.label"
|
||||
@click="chooseSelection(option.value)"
|
||||
>
|
||||
|
||||
@@ -741,18 +741,29 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.garage-vehicle__visual > i {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 7px;
|
||||
padding: 3px 6px;
|
||||
top: 7px;
|
||||
left: 50%;
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
min-width: 48px;
|
||||
max-width: 70px;
|
||||
height: 14px;
|
||||
padding: 0 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: rgb(20 92 48 / 78%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
font-weight: 720;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.02em;
|
||||
backdrop-filter: blur(12px);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.garage-vehicle__visual > i.is-out {
|
||||
background: rgb(176 91 0 / 82%);
|
||||
@@ -794,14 +805,15 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.garage-vehicle__title > b {
|
||||
flex: none;
|
||||
padding: 4px 7px;
|
||||
padding: 2px 5px;
|
||||
border: 0.5px solid var(--garage-separator);
|
||||
border-radius: 7px;
|
||||
border-radius: 5px;
|
||||
background: var(--garage-surface-muted);
|
||||
color: var(--garage-text);
|
||||
font-size: 11px;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.garage-vehicle__meta {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { kFab, kPage } from 'konsta/vue'
|
||||
import { LocateFixed, Map, MapPinned, Route, Satellite } from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kFab,
|
||||
kList,
|
||||
kListInput,
|
||||
kPage,
|
||||
kPreloader,
|
||||
kSheet,
|
||||
kToast,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
LocateFixed,
|
||||
Map,
|
||||
MapPin,
|
||||
MapPinned,
|
||||
MapPinPlus,
|
||||
Route,
|
||||
Satellite,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -8,15 +27,19 @@ import {
|
||||
defaultCayoStyle,
|
||||
defaultMainlandStyle,
|
||||
defaultMapCoordinates,
|
||||
defaultMapPercentToWorld,
|
||||
defaultMapWorldToPercent,
|
||||
type MapPoint,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
import { useMapStore } from '@/stores/map'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { MapMarker, MapMarkerColor } from '@/types/map'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mapStore = useMapStore()
|
||||
const mapStyle = ref<MapStyle>('default')
|
||||
const zoom = ref(1.1)
|
||||
const pan = ref<MapPoint>({ x: 0, y: 0 })
|
||||
@@ -31,8 +54,16 @@ const canvasRef = ref<HTMLElement | null>(null)
|
||||
const locationRef = ref<HTMLElement | null>(null)
|
||||
const isPointerDown = ref(false)
|
||||
const isPanning = ref(false)
|
||||
const placingMarker = ref(false)
|
||||
const draftCoords = ref<(MapPoint & { z: number }) | null>(null)
|
||||
const markerLabel = ref('')
|
||||
const markerColor = ref<MapMarkerColor>('blue')
|
||||
const selectedMarker = ref<MapMarker | null>(null)
|
||||
const markerError = ref('')
|
||||
const toastText = ref('')
|
||||
let pointerMoveFrame: number | undefined
|
||||
let wheelZoomFrame: number | undefined
|
||||
let toastTimer: number | undefined
|
||||
let pendingWheelDirection: -1 | 0 | 1 = 0
|
||||
let pendingWheelPoint: MapPoint | undefined
|
||||
|
||||
@@ -61,6 +92,13 @@ const mapStyles = [
|
||||
{ id: 'atlas' as const, icon: Map },
|
||||
{ id: 'roads' as const, icon: Route },
|
||||
]
|
||||
const markerColors: Array<{ id: MapMarkerColor; value: string }> = [
|
||||
{ id: 'blue', value: '#0a84ff' },
|
||||
{ id: 'green', value: '#30d158' },
|
||||
{ id: 'orange', value: '#ff9f0a' },
|
||||
{ id: 'red', value: '#ff453a' },
|
||||
{ id: 'purple', value: '#bf5af2' },
|
||||
]
|
||||
const mapControlColors = {
|
||||
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
|
||||
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
||||
@@ -122,6 +160,21 @@ const worldToPercent = (coords: MapPoint): MapPoint => {
|
||||
}
|
||||
}
|
||||
|
||||
const percentToWorld = (point: MapPoint): MapPoint => {
|
||||
if (mapStyle.value === 'default') {
|
||||
return defaultMapPercentToWorld(point)
|
||||
}
|
||||
|
||||
const projected = {
|
||||
x: mapBounds.minX + point.x * (mapBounds.maxX - mapBounds.minX),
|
||||
y: mapBounds.maxY - point.y * (mapBounds.maxY - mapBounds.minY),
|
||||
}
|
||||
return {
|
||||
x: (projected.x - mapOrigin.x) * mapScale.x,
|
||||
y: (projected.y - mapOrigin.y) * mapScale.y,
|
||||
}
|
||||
}
|
||||
|
||||
const locationStyle = computed(() => {
|
||||
if (!currentLocation.value) return undefined
|
||||
const percent = worldToPercent(currentLocation.value)
|
||||
@@ -132,6 +185,39 @@ const locationStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function markerStyle(marker: MapMarker): Record<string, string> {
|
||||
const percent = worldToPercent(marker.coords)
|
||||
return {
|
||||
left: `${percent.x * 100}%`,
|
||||
top: `${percent.y * 100}%`,
|
||||
transform: `translate(-50%, -100%) scale(${1 / zoom.value})`,
|
||||
}
|
||||
}
|
||||
|
||||
function markerColorValue(color: MapMarkerColor): string {
|
||||
return (
|
||||
markerColors.find((candidate) => candidate.id === color)?.value ??
|
||||
markerColors[0].value
|
||||
)
|
||||
}
|
||||
|
||||
function showToast(message: string): void {
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
toastText.value = message
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toastText.value = ''
|
||||
toastTimer = undefined
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function markerErrorText(error?: string): string {
|
||||
const key = error ?? 'request_failed'
|
||||
const translated = phone.t(`Apps.map.errors.${key}`)
|
||||
return translated === `Apps.map.errors.${key}`
|
||||
? phone.t('Apps.map.errors.request_failed')
|
||||
: translated
|
||||
}
|
||||
|
||||
function setMapStyle(style: MapStyle): void {
|
||||
mapStyle.value = style
|
||||
imageError.value = false
|
||||
@@ -144,6 +230,116 @@ function cycleMapStyle(): void {
|
||||
setMapStyle(mapStyles[(currentIndex + 1) % mapStyles.length].id)
|
||||
}
|
||||
|
||||
function startMarkerPlacement(): void {
|
||||
selectedMarker.value = null
|
||||
draftCoords.value = null
|
||||
markerError.value = ''
|
||||
placingMarker.value = true
|
||||
}
|
||||
|
||||
function cancelMarkerPlacement(): void {
|
||||
placingMarker.value = false
|
||||
}
|
||||
|
||||
function openMarkerEditor(): void {
|
||||
const viewport = viewportRef.value?.getBoundingClientRect()
|
||||
const canvas = canvasRef.value?.getBoundingClientRect()
|
||||
if (!viewport || !canvas || canvas.width <= 0 || canvas.height <= 0) {
|
||||
showToast(phone.t('Apps.map.errors.request_failed'))
|
||||
return
|
||||
}
|
||||
|
||||
const percent = {
|
||||
x: Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(viewport.left + viewport.width / 2 - canvas.left) / canvas.width,
|
||||
),
|
||||
),
|
||||
y: Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(viewport.top + viewport.height / 2 - canvas.top) / canvas.height,
|
||||
),
|
||||
),
|
||||
}
|
||||
const coords = percentToWorld(percent)
|
||||
draftCoords.value = {
|
||||
x: Math.round(coords.x * 100) / 100,
|
||||
y: Math.round(coords.y * 100) / 100,
|
||||
z: 0,
|
||||
}
|
||||
markerLabel.value = ''
|
||||
markerColor.value = 'blue'
|
||||
markerError.value = ''
|
||||
placingMarker.value = false
|
||||
}
|
||||
|
||||
function updateMarkerLabel(event: Event): void {
|
||||
markerLabel.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function closeMarkerSheet(): void {
|
||||
if (mapStore.isLoading) return
|
||||
draftCoords.value = null
|
||||
selectedMarker.value = null
|
||||
markerError.value = ''
|
||||
}
|
||||
|
||||
function selectMarker(marker: MapMarker): void {
|
||||
if (placingMarker.value) return
|
||||
selectedMarker.value = marker
|
||||
markerError.value = ''
|
||||
}
|
||||
|
||||
function handleMarkerResponse(
|
||||
response: NuiResponse,
|
||||
successMessage: string,
|
||||
): boolean {
|
||||
if (!response.success) {
|
||||
markerError.value = markerErrorText(response.error)
|
||||
return false
|
||||
}
|
||||
showToast(successMessage)
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveMarker(): Promise<void> {
|
||||
const coords = draftCoords.value
|
||||
const label = markerLabel.value.trim()
|
||||
if (!coords || !label || label.length > 40) {
|
||||
markerError.value = phone.t('Apps.map.errors.invalid_marker')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await mapStore.create({
|
||||
color: markerColor.value,
|
||||
coords,
|
||||
label,
|
||||
})
|
||||
if (!handleMarkerResponse(response, phone.t('Apps.map.markerSaved'))) return
|
||||
draftCoords.value = null
|
||||
}
|
||||
|
||||
async function deleteSelectedMarker(): Promise<void> {
|
||||
const marker = selectedMarker.value
|
||||
if (!marker) return
|
||||
const response = await mapStore.remove(marker.id)
|
||||
if (!handleMarkerResponse(response, phone.t('Apps.map.markerDeleted'))) return
|
||||
selectedMarker.value = null
|
||||
}
|
||||
|
||||
async function setSelectedMarkerWaypoint(): Promise<void> {
|
||||
const marker = selectedMarker.value
|
||||
if (!marker) return
|
||||
|
||||
const response = await nuiCall('map:setWaypoint', { coords: marker.coords })
|
||||
if (!handleMarkerResponse(response, phone.t('Apps.map.waypointSet'))) return
|
||||
selectedMarker.value = null
|
||||
}
|
||||
|
||||
function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void {
|
||||
const targetZoom =
|
||||
direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor
|
||||
@@ -268,11 +464,13 @@ async function loadCurrentLocation(center: boolean): Promise<void> {
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentLocation(false)
|
||||
void mapStore.load()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
|
||||
if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame)
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -320,6 +518,32 @@ onBeforeUnmount(() => {
|
||||
<span class="current-location__pulse"></span>
|
||||
<span class="current-location__dot"></span>
|
||||
</div>
|
||||
<button
|
||||
v-for="marker in mapStore.markers"
|
||||
:key="marker.id"
|
||||
type="button"
|
||||
class="custom-map-marker"
|
||||
:style="markerStyle(marker)"
|
||||
:aria-label="marker.label"
|
||||
@pointerdown.stop
|
||||
@click.stop="selectMarker(marker)"
|
||||
>
|
||||
<MapPin
|
||||
:size="30"
|
||||
:style="{ color: markerColorValue(marker.color) }"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ marker.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="placingMarker"
|
||||
class="map-placement-crosshair"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<p v-if="imageError" class="map-error">
|
||||
@@ -340,6 +564,19 @@ onBeforeUnmount(() => {
|
||||
<component :is="activeMapStyle.icon" aria-hidden="true" />
|
||||
</template>
|
||||
</k-fab>
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--marker"
|
||||
:colors="locationControlColors"
|
||||
:disabled="placingMarker"
|
||||
:aria-label="phone.t('Apps.map.addMarker')"
|
||||
@click="startMarkerPlacement"
|
||||
>
|
||||
<template #icon>
|
||||
<MapPinPlus aria-hidden="true" />
|
||||
</template>
|
||||
</k-fab>
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -354,6 +591,134 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</k-fab>
|
||||
</nav>
|
||||
|
||||
<section v-if="placingMarker" class="map-placement-panel">
|
||||
<strong>{{ phone.t('Apps.map.placeMarker') }}</strong>
|
||||
<span>{{ phone.t('Apps.map.placeMarkerHint') }}</span>
|
||||
<div>
|
||||
<k-button small rounded outline @click="cancelMarkerPlacement">
|
||||
<X :size="16" />
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-button>
|
||||
<k-button small rounded @click="openMarkerEditor">
|
||||
<MapPin :size="16" />
|
||||
{{ phone.t('Apps.map.addHere') }}
|
||||
</k-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<k-sheet
|
||||
:opened="Boolean(draftCoords || selectedMarker)"
|
||||
class="map-marker-sheet"
|
||||
@backdropclick="closeMarkerSheet"
|
||||
>
|
||||
<section
|
||||
v-if="draftCoords"
|
||||
class="map-marker-sheet__content"
|
||||
:class="{ 'map-marker-sheet__content--dark': phone.isDarkMode }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="phone.t('Apps.map.newMarker')"
|
||||
>
|
||||
<h2>{{ phone.t('Apps.map.newMarker') }}</h2>
|
||||
<p>{{ phone.t('Apps.map.newMarkerDescription') }}</p>
|
||||
<k-list inset strong>
|
||||
<k-list-input
|
||||
input-id="map-marker-label"
|
||||
:label="phone.t('Apps.map.markerName')"
|
||||
:placeholder="phone.t('Apps.map.markerNamePlaceholder')"
|
||||
:value="markerLabel"
|
||||
maxlength="40"
|
||||
outline
|
||||
@input="updateMarkerLabel"
|
||||
@keydown.enter="saveMarker"
|
||||
/>
|
||||
</k-list>
|
||||
<span class="map-marker-sheet__label">{{
|
||||
phone.t('Apps.map.markerColor')
|
||||
}}</span>
|
||||
<div
|
||||
class="map-marker-colors"
|
||||
role="radiogroup"
|
||||
:aria-label="phone.t('Apps.map.markerColor')"
|
||||
>
|
||||
<button
|
||||
v-for="color in markerColors"
|
||||
:key="color.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="markerColor === color.id"
|
||||
:aria-label="phone.t(`Apps.map.colors.${color.id}`)"
|
||||
:class="{ 'map-marker-color--active': markerColor === color.id }"
|
||||
:style="{ backgroundColor: color.value }"
|
||||
@click="markerColor = color.id"
|
||||
></button>
|
||||
</div>
|
||||
<p v-if="markerError" class="map-marker-error" role="alert">
|
||||
{{ markerError }}
|
||||
</p>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
:disabled="mapStore.isLoading || !markerLabel.trim()"
|
||||
@click="saveMarker"
|
||||
>
|
||||
<k-preloader v-if="mapStore.isLoading" />
|
||||
<template v-else>{{ phone.t('Apps.map.saveMarker') }}</template>
|
||||
</k-button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="selectedMarker"
|
||||
class="map-marker-sheet__content map-marker-sheet__content--details"
|
||||
:class="{ 'map-marker-sheet__content--dark': phone.isDarkMode }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="selectedMarker.label"
|
||||
>
|
||||
<span
|
||||
class="map-marker-sheet__pin"
|
||||
:style="{ color: markerColorValue(selectedMarker.color) }"
|
||||
>
|
||||
<MapPin :size="32" fill="currentColor" />
|
||||
</span>
|
||||
<h2>{{ selectedMarker.label }}</h2>
|
||||
<p>
|
||||
{{ selectedMarker.coords.x.toFixed(1) }},
|
||||
{{ selectedMarker.coords.y.toFixed(1) }}
|
||||
</p>
|
||||
<p v-if="markerError" class="map-marker-error" role="alert">
|
||||
{{ markerError }}
|
||||
</p>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
class="map-marker-waypoint"
|
||||
:disabled="mapStore.isLoading"
|
||||
@click="setSelectedMarkerWaypoint"
|
||||
>
|
||||
<Route :size="17" />
|
||||
{{ phone.t('Apps.map.setWaypoint') }}
|
||||
</k-button>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
class="map-marker-delete"
|
||||
:disabled="mapStore.isLoading"
|
||||
@click="deleteSelectedMarker"
|
||||
>
|
||||
<k-preloader v-if="mapStore.isLoading" />
|
||||
<template v-else>
|
||||
<Trash2 :size="17" />
|
||||
{{ phone.t('Apps.map.deleteMarker') }}
|
||||
</template>
|
||||
</k-button>
|
||||
</section>
|
||||
</k-sheet>
|
||||
|
||||
<k-toast :opened="Boolean(toastText)" position="center">
|
||||
{{ toastText }}
|
||||
</k-toast>
|
||||
</k-page>
|
||||
</template>
|
||||
|
||||
@@ -428,6 +793,84 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.custom-map-marker {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 45%));
|
||||
transform-origin: 50% 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-map-marker svg {
|
||||
stroke: #fff;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.custom-map-marker span {
|
||||
max-width: 112px;
|
||||
padding: 3px 7px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 8px;
|
||||
background: rgb(20 20 22 / 84%);
|
||||
box-shadow: 0 2px 7px rgb(0 0 0 / 30%);
|
||||
backdrop-filter: blur(12px);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-placement-crosshair {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid rgb(255 255 255 / 80%);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 2px 12px rgb(0 0 0 / 40%),
|
||||
inset 0 0 0 1px rgb(0 0 0 / 16%);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-placement-crosshair::before,
|
||||
.map-placement-crosshair::after,
|
||||
.map-placement-crosshair span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
background: #fff;
|
||||
content: '';
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.map-placement-crosshair::before {
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.map-placement-crosshair::after {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.map-placement-crosshair span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border: 1px solid rgb(0 0 0 / 35%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
@@ -448,6 +891,150 @@ onBeforeUnmount(() => {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.map-control--marker {
|
||||
--color-primary: #0a84ff;
|
||||
}
|
||||
|
||||
.map-placement-panel {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 66px;
|
||||
bottom: 28px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
min-height: 84px;
|
||||
padding: 12px;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid rgb(255 255 255 / 22%);
|
||||
border-radius: 18px;
|
||||
color: #fff;
|
||||
background: rgb(24 24 27 / 86%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 32%);
|
||||
backdrop-filter: blur(22px) saturate(145%);
|
||||
}
|
||||
|
||||
.map-placement-panel strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.map-placement-panel > span {
|
||||
margin-top: 2px;
|
||||
color: rgb(255 255 255 / 64%);
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.map-placement-panel > div {
|
||||
display: grid;
|
||||
margin-top: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.map-placement-panel :deep(button) {
|
||||
min-height: 32px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content {
|
||||
display: flex;
|
||||
min-height: 350px;
|
||||
padding: 22px 16px calc(18px + env(safe-area-inset-bottom));
|
||||
flex-direction: column;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content--dark {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content > p {
|
||||
margin: 5px 0 14px;
|
||||
color: rgb(60 60 67 / 60%);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content--dark > p {
|
||||
color: rgb(235 235 245 / 60%);
|
||||
}
|
||||
|
||||
.map-marker-sheet__label {
|
||||
margin: 13px 5px 8px;
|
||||
color: rgb(60 60 67 / 60%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content--dark .map-marker-sheet__label {
|
||||
color: rgb(235 235 245 / 60%);
|
||||
}
|
||||
|
||||
.map-marker-colors {
|
||||
display: flex;
|
||||
margin-bottom: 18px;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.map-marker-colors button {
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
padding: 0;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.map-marker-colors .map-marker-color--active {
|
||||
border-color: #fff;
|
||||
outline: 2px solid #0a84ff;
|
||||
}
|
||||
|
||||
.map-marker-error {
|
||||
color: #ff3b30 !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content--details {
|
||||
min-height: 250px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.map-marker-sheet__pin {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin-bottom: 10px;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background: rgb(120 120 128 / 12%);
|
||||
}
|
||||
|
||||
.map-marker-sheet__pin svg {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content .map-marker-delete {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
color: #fff;
|
||||
background: #ff3b30;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content .map-marker-waypoint {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.map-error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
kNavbarBackLink,
|
||||
kPage,
|
||||
kPreloader,
|
||||
kSearchbar,
|
||||
kToast,
|
||||
kToolbarPane,
|
||||
} from 'konsta/vue'
|
||||
@@ -394,7 +395,8 @@ async function saveContactDetails(): Promise<void> {
|
||||
return
|
||||
}
|
||||
contactEditing.value = false
|
||||
contactNumberDraft.value = response.data?.phone_number ?? contactNumberDraft.value
|
||||
contactNumberDraft.value =
|
||||
response.data?.phone_number ?? contactNumberDraft.value
|
||||
}
|
||||
|
||||
async function deleteActiveContact(): Promise<void> {
|
||||
@@ -432,7 +434,10 @@ function openEmojiPicker(): void {
|
||||
emojiOpen.value = true
|
||||
}
|
||||
|
||||
function openMediaApp(app: 'camera' | 'photos', mediaType: 'photo' | 'video'): void {
|
||||
function openMediaApp(
|
||||
app: 'camera' | 'photos',
|
||||
mediaType: 'photo' | 'video',
|
||||
): void {
|
||||
if (!messages.activeNumber) return
|
||||
attachmentMenuOpen.value = false
|
||||
messageMedia.begin(messages.activeNumber, mediaType)
|
||||
@@ -536,7 +541,10 @@ function sampleMicrophone(): void {
|
||||
|
||||
async function startVoiceRecording(): Promise<void> {
|
||||
emojiOpen.value = false
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
if (
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
showToast(phone.t('Apps.messages.microphoneUnavailable'))
|
||||
return
|
||||
}
|
||||
@@ -618,7 +626,13 @@ function compressedWaveform(): number[] {
|
||||
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
|
||||
const bucket = recordingSamples.slice(start, end)
|
||||
result.push(
|
||||
Math.max(0.08, Math.min(1, bucket.reduce((sum, value) => sum + value, 0) / bucket.length)),
|
||||
Math.max(
|
||||
0.08,
|
||||
Math.min(
|
||||
1,
|
||||
bucket.reduce((sum, value) => sum + value, 0) / bucket.length,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return result
|
||||
@@ -694,7 +708,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<k-navbar large transparent :title="phone.t('Apps.messages.name')" />
|
||||
<div class="messages-empty-state">
|
||||
<span class="messages-empty-state__icon"><MessageCircle :size="35" /></span>
|
||||
<span class="messages-empty-state__icon"
|
||||
><MessageCircle :size="35"
|
||||
/></span>
|
||||
<h2>{{ phone.t('Apps.messages.noSim') }}</h2>
|
||||
<p>{{ phone.t('Apps.messages.noSimBody') }}</p>
|
||||
</div>
|
||||
@@ -746,10 +762,7 @@ onBeforeUnmount(() => {
|
||||
</k-glass>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="filteredConversations.length"
|
||||
class="messages-conversation-list"
|
||||
>
|
||||
<div v-if="filteredConversations.length" class="messages-conversation-list">
|
||||
<button
|
||||
v-for="conversation in filteredConversations"
|
||||
:key="conversation.phoneNumber"
|
||||
@@ -797,7 +810,9 @@ onBeforeUnmount(() => {
|
||||
<span class="messages-conversation__body">
|
||||
<span class="messages-conversation__headline">
|
||||
<strong>{{ contactName(conversation.phoneNumber) }}</strong>
|
||||
<time>{{ formatConversationDate(conversation.lastMessageAt) }}</time>
|
||||
<time>{{
|
||||
formatConversationDate(conversation.lastMessageAt)
|
||||
}}</time>
|
||||
<ChevronRight :size="13" />
|
||||
</span>
|
||||
<span class="messages-conversation__preview">
|
||||
@@ -808,7 +823,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="messages-empty-state messages-empty-state--list">
|
||||
<span class="messages-empty-state__icon"><MessageCircle :size="35" /></span>
|
||||
<span class="messages-empty-state__icon"
|
||||
><MessageCircle :size="35"
|
||||
/></span>
|
||||
<h2>
|
||||
{{
|
||||
phone.t(
|
||||
@@ -823,16 +840,31 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<footer v-if="!editingList" class="messages-inbox-toolbar">
|
||||
<label>
|
||||
<Search :size="16" />
|
||||
<input
|
||||
<div class="messages-inbox-search">
|
||||
<k-searchbar
|
||||
:value="search"
|
||||
type="search"
|
||||
:placeholder="phone.t('Apps.messages.search')"
|
||||
:colors="{
|
||||
inputBgIos: 'bg-transparent',
|
||||
placeholderIos: 'placeholder-[#8e8e93]',
|
||||
}"
|
||||
:clear-button="false"
|
||||
:input-style="{
|
||||
color: phone.isDarkMode ? '#f5f5f7' : '#111',
|
||||
paddingRight: '48px',
|
||||
}"
|
||||
@input="search = eventValue($event)"
|
||||
@clear="search = ''"
|
||||
/>
|
||||
<Mic :size="16" />
|
||||
</label>
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
class="messages-inbox-search__voice"
|
||||
:aria-label="phone.t('Apps.messages.search')"
|
||||
>
|
||||
<Mic :size="20" />
|
||||
</k-link>
|
||||
</div>
|
||||
<k-glass
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -843,7 +875,11 @@ onBeforeUnmount(() => {
|
||||
</k-glass>
|
||||
</footer>
|
||||
<footer v-else class="messages-edit-toolbar">
|
||||
<span>{{ phone.t('Apps.messages.selectedCount', { count: String(selectedNumbers.length) }) }}</span>
|
||||
<span>{{
|
||||
phone.t('Apps.messages.selectedCount', {
|
||||
count: String(selectedNumbers.length),
|
||||
})
|
||||
}}</span>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!selectedNumbers.length"
|
||||
@@ -881,7 +917,11 @@ onBeforeUnmount(() => {
|
||||
@keyup.enter="chooseRecipient(composerNumber)"
|
||||
/>
|
||||
</k-list>
|
||||
<k-list v-if="contactSuggestions.length" class="messages-contact-list" strong>
|
||||
<k-list
|
||||
v-if="contactSuggestions.length"
|
||||
class="messages-contact-list"
|
||||
strong
|
||||
>
|
||||
<k-list-item
|
||||
v-for="contact in contactSuggestions"
|
||||
:key="contact.id"
|
||||
@@ -895,7 +935,9 @@ onBeforeUnmount(() => {
|
||||
class="messages-avatar messages-avatar--small"
|
||||
:style="avatarStyle(contact.phone_number)"
|
||||
>
|
||||
<span class="messages-avatar__glyph">{{ avatarGlyph(contact.phone_number) }}</span>
|
||||
<span class="messages-avatar__glyph">{{
|
||||
avatarGlyph(contact.phone_number)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</k-list-item>
|
||||
@@ -919,27 +961,32 @@ onBeforeUnmount(() => {
|
||||
:aria-label="activeTitle"
|
||||
>
|
||||
<header class="messages-chat-header">
|
||||
<button
|
||||
<k-link
|
||||
component="button"
|
||||
icon-only
|
||||
type="button"
|
||||
class="messages-chat-header__back"
|
||||
:aria-label="phone.t('Apps.messages.name')"
|
||||
@click="goBack"
|
||||
>
|
||||
<ChevronLeft :size="28" :stroke-width="2.35" />
|
||||
</button>
|
||||
</k-link>
|
||||
<div class="messages-chat-header__contact">
|
||||
<span
|
||||
class="messages-avatar messages-avatar--header"
|
||||
:class="{ 'messages-avatar--unknown': !activeContact }"
|
||||
:style="avatarStyle(messages.activeNumber ?? '')"
|
||||
>
|
||||
<span v-if="activeContact" class="messages-avatar__glyph">{{ avatarGlyph(messages.activeNumber ?? '') }}</span>
|
||||
<span v-if="activeContact" class="messages-avatar__glyph">{{
|
||||
avatarGlyph(messages.activeNumber ?? '')
|
||||
}}</span>
|
||||
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
|
||||
<i />
|
||||
<b />
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
<k-link
|
||||
component="button"
|
||||
type="button"
|
||||
class="messages-chat-header__name"
|
||||
:aria-label="phone.t('Apps.messages.contactDetails')"
|
||||
@@ -947,7 +994,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<strong>{{ activeTitle }}</strong>
|
||||
<ChevronRight :size="13" />
|
||||
</button>
|
||||
</k-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -977,7 +1024,9 @@ onBeforeUnmount(() => {
|
||||
:class="{ 'messages-avatar--unknown': !activeContact }"
|
||||
:style="avatarStyle(messages.activeNumber ?? '')"
|
||||
>
|
||||
<span v-if="activeContact" class="messages-avatar__glyph">{{ avatarGlyph(messages.activeNumber ?? '') }}</span>
|
||||
<span v-if="activeContact" class="messages-avatar__glyph">{{
|
||||
avatarGlyph(messages.activeNumber ?? '')
|
||||
}}</span>
|
||||
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
|
||||
<i />
|
||||
<b />
|
||||
@@ -1035,11 +1084,17 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<k-messages class="messages-bubbles">
|
||||
<template v-for="(message, index) in messages.messages" :key="message.client_id ?? message.id">
|
||||
<template
|
||||
v-for="(message, index) in messages.messages"
|
||||
:key="message.client_id ?? message.id"
|
||||
>
|
||||
<k-messages-title v-if="startsDay(message, index)">
|
||||
<span class="messages-thread-timestamp">
|
||||
<span>{{ phone.t('Apps.messages.smsLabel') }}</span>
|
||||
<b>{{ dayLabel(message.created_at) }}, {{ timeLabel(message.created_at) }}</b>
|
||||
<b
|
||||
>{{ dayLabel(message.created_at) }},
|
||||
{{ timeLabel(message.created_at) }}</b
|
||||
>
|
||||
</span>
|
||||
</k-messages-title>
|
||||
<k-message
|
||||
@@ -1166,7 +1221,6 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
|
||||
<k-messagebar
|
||||
v-else
|
||||
class="messages-messagebar"
|
||||
|
||||
@@ -23,6 +23,14 @@ let draft = null
|
||||
let mockBankBalance = 24787
|
||||
let mockCashBalance = 2350
|
||||
let nextBankTransactionId = 7
|
||||
let mockMapMarkers = [
|
||||
{
|
||||
color: 'blue',
|
||||
coords: { x: -75.2, y: -818.9, z: 0 },
|
||||
id: 'mock-map-marker-1',
|
||||
label: 'Meeting point',
|
||||
},
|
||||
]
|
||||
const flipTokProfile = {
|
||||
id: 1,
|
||||
handle: 'skyline',
|
||||
@@ -36,6 +44,7 @@ const flipTokProfile = {
|
||||
following: 128,
|
||||
video_count: 2,
|
||||
}
|
||||
let flipTokAuthenticated = true
|
||||
const flipTokMusicTracks = [
|
||||
{
|
||||
id: 'night-drive',
|
||||
@@ -1173,9 +1182,20 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
transactions: mockBankTransactions,
|
||||
})
|
||||
if (endpoint === 'fliptok:bootstrap') {
|
||||
if (!flipTokAuthenticated) {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
authenticated: false,
|
||||
musicTracks: flipTokMusicTracks,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
authenticated: true,
|
||||
profile: flipTokProfile,
|
||||
feed: { items: flipTokVideos, offset: 0, hasMore: false },
|
||||
isAdmin: true,
|
||||
@@ -1184,6 +1204,16 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:login' || endpoint === 'fliptok:register') {
|
||||
flipTokAuthenticated = true
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:logout') {
|
||||
flipTokAuthenticated = false
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:feed') {
|
||||
const items =
|
||||
request.body.mode === 'following'
|
||||
@@ -1252,7 +1282,8 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
if (endpoint === 'fliptok:profile') {
|
||||
const profileId = Number(request.body.profileId || 0)
|
||||
const handle = String(request.body.handle || '').toLowerCase()
|
||||
const own = profileId === flipTokProfile.id || handle === flipTokProfile.handle
|
||||
const own =
|
||||
profileId === flipTokProfile.id || handle === flipTokProfile.handle
|
||||
const videos = own
|
||||
? flipTokVideos.filter((video) => video.profile_id === flipTokProfile.id)
|
||||
: flipTokVideos.filter((video) =>
|
||||
@@ -1288,9 +1319,15 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
if (endpoint === 'fliptok:block') {
|
||||
const profileId = Number(request.body.profileId)
|
||||
flipTokVideos = flipTokVideos.filter((video) => video.profile_id !== profileId)
|
||||
flipTokComments = flipTokComments.filter((comment) => comment.profile_id !== profileId)
|
||||
flipTokActivities = flipTokActivities.filter((activity) => activity.profile_id !== profileId)
|
||||
flipTokVideos = flipTokVideos.filter(
|
||||
(video) => video.profile_id !== profileId,
|
||||
)
|
||||
flipTokComments = flipTokComments.filter(
|
||||
(comment) => comment.profile_id !== profileId,
|
||||
)
|
||||
flipTokActivities = flipTokActivities.filter(
|
||||
(activity) => activity.profile_id !== profileId,
|
||||
)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
@@ -1299,9 +1336,13 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:admin-resolve-report') {
|
||||
const selected = flipTokReports.find((report) => report.id === request.body.id)
|
||||
const selected = flipTokReports.find(
|
||||
(report) => report.id === request.body.id,
|
||||
)
|
||||
if (selected && request.body.action === 'remove')
|
||||
flipTokVideos = flipTokVideos.filter((video) => video.id !== selected.video_id)
|
||||
flipTokVideos = flipTokVideos.filter(
|
||||
(video) => video.id !== selected.video_id,
|
||||
)
|
||||
flipTokReports = flipTokReports.filter((report) =>
|
||||
request.body.action === 'remove'
|
||||
? report.video_id !== selected?.video_id
|
||||
@@ -1474,6 +1515,49 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (endpoint === 'map:setWaypoint') {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'map:markers') {
|
||||
response.json({ success: true, data: mockMapMarkers })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'map:create-marker') {
|
||||
const label = String(request.body.label ?? '').trim()
|
||||
const color = String(request.body.color ?? '')
|
||||
const coords = request.body.coords
|
||||
if (!label || label.length > 40 || !coords) {
|
||||
response.json({ success: false, error: 'invalid_marker' })
|
||||
return
|
||||
}
|
||||
const marker = {
|
||||
color,
|
||||
coords: {
|
||||
x: Number(coords.x),
|
||||
y: Number(coords.y),
|
||||
z: Number(coords.z) || 0,
|
||||
},
|
||||
id: `mock-map-marker-${Date.now()}`,
|
||||
label,
|
||||
}
|
||||
mockMapMarkers.push(marker)
|
||||
response.json({ success: true, data: marker })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'map:delete-marker') {
|
||||
const previousLength = mockMapMarkers.length
|
||||
mockMapMarkers = mockMapMarkers.filter(
|
||||
(marker) => marker.id !== request.body.id,
|
||||
)
|
||||
response.json(
|
||||
mockMapMarkers.length === previousLength
|
||||
? { success: false, error: 'marker_not_found' }
|
||||
: { success: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'darkchat:bootstrap') {
|
||||
response.json({ success: true, data: darkChatBootstrap() })
|
||||
return
|
||||
|
||||
@@ -223,6 +223,9 @@ Config.FlipTok = {
|
||||
CaptionMaxLength = 500,
|
||||
CommentMaxLength = 300,
|
||||
BioMaxLength = 160,
|
||||
PasswordMinLength = 8,
|
||||
PasswordMaxLength = 72,
|
||||
PasswordPepperConvar = "sky_phone_fliptok_password_pepper",
|
||||
MaxVideoDurationMs = 300000,
|
||||
MusicTracks = {},
|
||||
VerifyCommand = "fliptokverify",
|
||||
@@ -230,6 +233,12 @@ Config.FlipTok = {
|
||||
ReportAdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.MapMarkers = {
|
||||
MaximumMarkers = 50,
|
||||
LabelMaxLength = 40,
|
||||
ActionsPerMinute = 60,
|
||||
}
|
||||
|
||||
Config.Calendar = {
|
||||
TitleMaxLength = 120,
|
||||
NoteMaxLength = 2000,
|
||||
|
||||
@@ -89,10 +89,17 @@ Locales["en"] = {
|
||||
trimAndCover = "Trim & cover", trimStart = "Start", trimEnd = "End", coverFrame = "Cover", originalVolume = "Original sound", musicVolume = "Music",
|
||||
moderation = "Moderation", reports = "Open reports", noReports = "No open reports", removeVideo = "Remove video", dismissReport = "Dismiss",
|
||||
done = "Done", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type",
|
||||
authTitle = "Your FlipTok account", login = "Sign In", register = "Register", createAccount = "Create Account", logout = "Sign Out",
|
||||
loginBody = "Sign in to continue with your videos, follows, and saved posts.", registerBody = "Create a private FlipTok login for this profile.",
|
||||
password = "Password", confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.",
|
||||
displayNamePlaceholder = "Your name", usernamePlaceholder = "username", passwordPlaceholder = "At least 8 characters", confirmPasswordPlaceholder = "Enter password again",
|
||||
registrationHint = "An iFruit account is required once to create and own a FlipTok profile.",
|
||||
accountDetails = "Account details", profileDetails = "Profile details", account = "Account",
|
||||
signOutTitle = "Sign out of FlipTok?", signOutBody = "Your profile and videos stay online. This phone will return to the FlipTok sign-in screen.", signingOut = "Signing Out...",
|
||||
accountTypes = { person = "Person", business = "Business", organization = "Organization", media = "Media", event = "Event" },
|
||||
activityKinds = { like = "liked your video", comment = "commented on your video", follow = "started following you", verified = "verification changed" },
|
||||
notifications = { like = "{actor} liked your video.", comment = "{actor} commented on your video.", follow = "{actor} started following you.", verified = "Your FlipTok account is now verified.", default = "You have new FlipTok activity." },
|
||||
errors = { invalid_video = "Check the video details.", invalid_media = "Choose a video from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to iFruit first.", default = "FlipTok could not complete the request." },
|
||||
errors = { invalid_video = "Check the video details.", invalid_media = "Choose a video from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This iFruit account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to iFruit first.", default = "FlipTok could not complete the request." },
|
||||
},
|
||||
darkchat = {
|
||||
name = "DarkChat", newMessage = "New DarkChat message from {sender}", privateNotification = "New DarkChat message",
|
||||
@@ -520,6 +527,16 @@ Locales["en"] = {
|
||||
map = {
|
||||
name = "Map", controls = "Map controls", currentLocation = "Current Location",
|
||||
imageError = "The map image could not be loaded.", switchStyle = "Switch Map Type",
|
||||
addMarker = "Add Marker", placeMarker = "Place Marker", placeMarkerHint = "Move the map until the crosshair is over the destination.",
|
||||
addHere = "Add Here", newMarker = "New Marker", newMarkerDescription = "Give this saved place a name and color.",
|
||||
markerName = "Name", markerNamePlaceholder = "e.g. Meeting point", markerColor = "Marker Color",
|
||||
saveMarker = "Save Marker", deleteMarker = "Delete Marker", setWaypoint = "Set Waypoint", markerSaved = "Marker saved.", markerDeleted = "Marker deleted.", waypointSet = "Waypoint set.",
|
||||
colors = { blue = "Blue", green = "Green", orange = "Orange", purple = "Purple", red = "Red" },
|
||||
errors = {
|
||||
invalid_marker = "Enter a valid marker name and position.", marker_limit = "This phone has reached its marker limit.",
|
||||
marker_not_found = "This marker no longer exists.", rate_limited = "Too many changes. Try again shortly.",
|
||||
request_failed = "The marker could not be saved.",
|
||||
},
|
||||
styles = { default = "Default Map", satellite = "Satellite Map", atlas = "Atlas Map", roads = "Road Map" },
|
||||
},
|
||||
weather = {
|
||||
|
||||
@@ -56,6 +56,7 @@ server_scripts {
|
||||
'source/server/marketplace.lua',
|
||||
'source/server/pages.lua',
|
||||
'source/server/fliptok.lua',
|
||||
'source/server/map.lua',
|
||||
'source/server/calendar.lua',
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ local server_callbacks = {
|
||||
"pages:share-citymarkt",
|
||||
"pages:react",
|
||||
"pages:delete",
|
||||
"fliptok:register",
|
||||
"fliptok:login",
|
||||
"fliptok:logout",
|
||||
"fliptok:bootstrap",
|
||||
"fliptok:feed",
|
||||
"fliptok:discover",
|
||||
@@ -83,6 +86,9 @@ local server_callbacks = {
|
||||
"calendar:create",
|
||||
"calendar:update",
|
||||
"calendar:delete",
|
||||
"map:markers",
|
||||
"map:create-marker",
|
||||
"map:delete-marker",
|
||||
"sim:insert",
|
||||
"sim:eject",
|
||||
"contacts:list",
|
||||
@@ -251,6 +257,19 @@ RegisterNUICallback("map:getPlayerCoords", function(_, cb)
|
||||
})
|
||||
end)
|
||||
|
||||
RegisterNUICallback("map:setWaypoint", function(data, cb)
|
||||
local coords = type(data) == "table" and data.coords or nil
|
||||
local x = type(coords) == "table" and tonumber(coords.x) or nil
|
||||
local y = type(coords) == "table" and tonumber(coords.y) or nil
|
||||
if not x or not y or x ~= x or y ~= y or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 then
|
||||
cb({ success = false, error = "invalid_marker" })
|
||||
return
|
||||
end
|
||||
|
||||
SetNewWaypoint(x, y)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
local weather_types = {
|
||||
[joaat("EXTRASUNNY")] = "sunny",
|
||||
[joaat("CLEAR")] = "clear",
|
||||
|
||||
@@ -683,6 +683,33 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_map_markers",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "label", type = "VARCHAR(40) NOT NULL" },
|
||||
{ name = "color", type = "VARCHAR(16) NOT NULL" },
|
||||
{ name = "position_x", type = "DOUBLE NOT NULL" },
|
||||
{ name = "position_y", type = "DOUBLE NOT NULL" },
|
||||
{ name = "position_z", type = "DOUBLE 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" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_map_markers_device", columns = "(`device_imei`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_calendar_events",
|
||||
columns = {
|
||||
@@ -884,6 +911,35 @@ local schema = {
|
||||
foreignKeys = {{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_credentials",
|
||||
columns = {
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "password_hash", type = "BINARY(32) NOT NULL" },
|
||||
{ name = "password_salt", type = "CHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "profile_id",
|
||||
foreignKeys = {{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" }},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_sessions",
|
||||
columns = {
|
||||
{ name = "device_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "device_imei",
|
||||
indexes = {{ name = "idx_sky_phone_fliptok_sessions_profile", columns = "(`profile_id`, `updated_at`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_videos",
|
||||
columns = {
|
||||
|
||||
@@ -5,6 +5,16 @@ local report_reasons = { spam = true, harassment = true, dangerous = true, illeg
|
||||
local report_actions = { dismiss = true, remove = true }
|
||||
local music_tracks = {}
|
||||
local music_track_list = {}
|
||||
local password_pepper = GetConvar(Config.FlipTok.PasswordPepperConvar, "")
|
||||
|
||||
if password_pepper == "" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] FlipTok password pepper convar '%s' is empty; configure it before production use.",
|
||||
Config.FlipTok.PasswordPepperConvar,
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
|
||||
for _, track in ipairs(Config.FlipTok.MusicTracks) do
|
||||
local id = tostring(track.Id or track.id or "")
|
||||
@@ -50,28 +60,33 @@ local function new_id()
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function profile_for_account(account)
|
||||
local rows = Bridge.Database.Query("SELECT * FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
if rows[1] then return rows[1] end
|
||||
|
||||
local base = account.email:match("^([^@]+)") or "user"
|
||||
base = base:lower():gsub("[^a-z0-9._]", ""):sub(1, 16)
|
||||
if #base < 3 then base = "user" end
|
||||
local handle = base
|
||||
local suffix = 0
|
||||
while Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1", { handle })[1] do
|
||||
suffix = suffix + 1
|
||||
handle = (base:sub(1, 18) .. tostring(account.id) .. tostring(suffix)):sub(1, 24)
|
||||
local function normalize_handle(value)
|
||||
local handle = trim(value)
|
||||
if not handle then return nil end
|
||||
handle = handle:lower():gsub("^@", "")
|
||||
if #handle < 3 or #handle > 24 or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$") or handle:find("..", 1, true) then
|
||||
return nil
|
||||
end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_profiles`
|
||||
(`account_id`, `handle`, `display_name`) VALUES (?, ?, ?)]], { account.id, handle, base })
|
||||
return Bridge.Database.Query("SELECT * FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })[1]
|
||||
return handle
|
||||
end
|
||||
|
||||
local function valid_password(value)
|
||||
local length = type(value) == "string" and utf8.len(value) or nil
|
||||
return length and length >= Config.FlipTok.PasswordMinLength and length <= Config.FlipTok.PasswordMaxLength
|
||||
end
|
||||
|
||||
local function profile_for_session(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return nil, error_response end
|
||||
local rows = Bridge.Database.Query([[SELECT p.* FROM `sky_phone_fliptok_sessions` s
|
||||
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = s.`profile_id`
|
||||
WHERE s.`device_imei` = ? LIMIT 1]], { session.imei })
|
||||
if not rows[1] then return nil, { success = false, error = "fliptok_not_authenticated" } end
|
||||
return rows[1], nil
|
||||
end
|
||||
|
||||
local function require_profile(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return nil, error_response end
|
||||
return profile_for_account(account), nil
|
||||
return profile_for_session(source)
|
||||
end
|
||||
|
||||
local function hydrate_profile(profile, viewer_id)
|
||||
@@ -181,12 +196,104 @@ local function feed(source, data)
|
||||
return { success = true, data = { items = rows, offset = offset, hasMore = has_more } }
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:register", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:register", 5, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
|
||||
|
||||
local handle = normalize_handle(data.handle)
|
||||
local display_name = trim(data.displayName)
|
||||
if not handle then return { success = false, error = "invalid_handle" } end
|
||||
if not valid_text(display_name, 1, 40) then return { success = false, error = "invalid_display_name" } end
|
||||
if not valid_password(data.password) then return { success = false, error = "invalid_password" } end
|
||||
|
||||
local profiles = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
local profile_id = profiles[1] and tonumber(profiles[1].id) or nil
|
||||
local duplicates = profile_id and Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1",
|
||||
{ handle, profile_id }
|
||||
) or Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1",
|
||||
{ handle }
|
||||
)
|
||||
if duplicates[1] then return { success = false, error = "handle_taken" } end
|
||||
|
||||
if profile_id then
|
||||
local credentials = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_credentials` WHERE `profile_id` = ? LIMIT 1", { profile_id })
|
||||
if credentials[1] then return { success = false, error = "already_registered" } end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ? WHERE `id` = ?", {
|
||||
handle, display_name, profile_id,
|
||||
})
|
||||
else
|
||||
local result = Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_profiles`
|
||||
(`account_id`, `handle`, `display_name`) VALUES (?, ?, ?)]], { account.id, handle, display_name })
|
||||
if affected_rows(result) ~= 1 then return { success = false, error = "handle_taken" } end
|
||||
local created = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
if not created[1] then error("[sky_phone] FlipTok profile insert did not return the created profile.") end
|
||||
profile_id = tonumber(created[1].id)
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then error("[sky_phone] Database did not generate a FlipTok password salt.") end
|
||||
local credential_result = Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_credentials`
|
||||
(`profile_id`, `password_hash`, `password_salt`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]], {
|
||||
profile_id, password_pepper, salt, data.password, salt,
|
||||
})
|
||||
if affected_rows(credential_result) ~= 1 then return { success = false, error = "already_registered" } end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], {
|
||||
account.imei, profile_id,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:login", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:login", 10, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return error_response end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_credentials" } end
|
||||
local handle = normalize_handle(data.handle)
|
||||
if not handle or not valid_password(data.password) then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
local profiles = Bridge.Database.Query([[SELECT p.`id` FROM `sky_phone_fliptok_profiles` p
|
||||
JOIN `sky_phone_fliptok_credentials` c ON c.`profile_id` = p.`id`
|
||||
WHERE p.`handle` = ?
|
||||
AND c.`password_hash` = UNHEX(SHA2(CONCAT(?, c.`password_salt`, ?), 256))
|
||||
LIMIT 1]], { handle, password_pepper, data.password })
|
||||
if not profiles[1] then return { success = false, error = "invalid_credentials" } end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], {
|
||||
session.imei, profiles[1].id,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:logout", function(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return error_response end
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?", { session.imei })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:bootstrap", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not profile then
|
||||
if error_response.error == "fliptok_not_authenticated" then
|
||||
return { success = true, data = { authenticated = false, musicTracks = music_track_list } }
|
||||
end
|
||||
return error_response
|
||||
end
|
||||
local result = feed(source, { mode = "for-you", offset = 0 })
|
||||
if not result.success then return result end
|
||||
return { success = true, data = {
|
||||
authenticated = true,
|
||||
profile = load_profile(profile.id, profile.id),
|
||||
feed = result.data,
|
||||
isAdmin = Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local marker_colors = {
|
||||
blue = true,
|
||||
green = true,
|
||||
orange = true,
|
||||
purple = true,
|
||||
red = true,
|
||||
}
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
|
||||
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
end
|
||||
|
||||
local function text_length(value)
|
||||
return type(value) == "string" and utf8.len(value) or nil
|
||||
end
|
||||
|
||||
local function marker_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
label = row.label,
|
||||
color = row.color,
|
||||
coords = {
|
||||
x = tonumber(row.position_x) or 0.0,
|
||||
y = tonumber(row.position_y) or 0.0,
|
||||
z = tonumber(row.position_z) or 0.0,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function validate_marker(data)
|
||||
if type(data) ~= "table" or type(data.coords) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local label = type(data.label) == "string" and data.label:match("^%s*(.-)%s*$") or nil
|
||||
local label_length = text_length(label)
|
||||
local color = data.color
|
||||
local x = tonumber(data.coords.x)
|
||||
local y = tonumber(data.coords.y)
|
||||
local z = tonumber(data.coords.z)
|
||||
if not label_length
|
||||
or label_length < 1
|
||||
or label_length > Config.MapMarkers.LabelMaxLength
|
||||
or not marker_colors[color]
|
||||
or not x
|
||||
or not y
|
||||
or (data.coords.z ~= nil and not z)
|
||||
or x ~= x
|
||||
or y ~= y
|
||||
or (z and z ~= z)
|
||||
or math.abs(x) > 10000.0
|
||||
or math.abs(y) > 10000.0
|
||||
or (z and z < -1000.0)
|
||||
or (z and z > 3000.0)
|
||||
then
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
label = label,
|
||||
color = color,
|
||||
x = x,
|
||||
y = y,
|
||||
z = z or 0.0,
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:map:markers", function(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `label`, `color`, `position_x`, `position_y`, `position_z`
|
||||
FROM `sky_phone_map_markers`
|
||||
WHERE `device_imei` = ?
|
||||
ORDER BY `created_at`, `id`
|
||||
LIMIT ?
|
||||
]], { session.imei, Config.MapMarkers.MaximumMarkers })
|
||||
local markers = {}
|
||||
for index = 1, #rows do
|
||||
markers[index] = marker_dto(rows[index])
|
||||
end
|
||||
return { success = true, data = markers }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:map:create-marker", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "map_marker_write", Config.MapMarkers.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
|
||||
local marker = validate_marker(data)
|
||||
if not marker then
|
||||
return { success = false, error = "invalid_marker" }
|
||||
end
|
||||
|
||||
local count_rows = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count`
|
||||
FROM `sky_phone_map_markers`
|
||||
WHERE `device_imei` = ?
|
||||
]], { session.imei })
|
||||
if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.MapMarkers.MaximumMarkers then
|
||||
return { success = false, error = "marker_limit" }
|
||||
end
|
||||
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = ids[1] and ids[1].id
|
||||
if type(id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a map marker id.")
|
||||
end
|
||||
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_map_markers`
|
||||
(`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
]], { id, session.imei, marker.label, marker.color, marker.x, marker.y, marker.z })
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
id = id,
|
||||
label = marker.label,
|
||||
color = marker.color,
|
||||
coords = { x = marker.x, y = marker.y, z = marker.z },
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:map:delete-marker", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "map_marker_write", Config.MapMarkers.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or #data.id ~= 36 then
|
||||
return { success = false, error = "invalid_marker" }
|
||||
end
|
||||
|
||||
local result = Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_map_markers` WHERE `id` = ? AND `device_imei` = ?",
|
||||
{ data.id, session.imei }
|
||||
)
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "marker_not_found" }
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
end)
|
||||
@@ -1005,6 +1005,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
|
||||
params = { Config.Phone.DeviceName, session.imei },
|
||||
|
||||
@@ -251,6 +251,24 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_profiles` (
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_credentials` (
|
||||
`profile_id` BIGINT UNSIGNED NOT NULL, `password_hash` BINARY(32) NOT NULL,
|
||||
`password_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`profile_id`),
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_sessions` (
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `profile_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`device_imei`), KEY `idx_sky_phone_fliptok_sessions_profile` (`profile_id`,`updated_at`),
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_videos` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `profile_id` BIGINT UNSIGNED NOT NULL, `media_id` BIGINT UNSIGNED NOT NULL,
|
||||
`caption` VARCHAR(500) NOT NULL DEFAULT '', `location` VARCHAR(80) NOT NULL DEFAULT '',
|
||||
|
||||