ENH - expand phone apps and polish iOS UI

This commit is contained in:
Leon.Schmidt
2026-08-09 04:32:01 +02:00
parent fabebd1d76
commit 1481e44819
37 changed files with 2409 additions and 510 deletions
+17
View File
@@ -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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+25 -47
View File
@@ -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)"
+4 -4
View File
@@ -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,
})
+53
View File
@@ -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([])
})
})
+32 -5
View File
@@ -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
+60
View File
@@ -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([])
})
})
+50
View File
@@ -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
},
},
})
+60
View File
@@ -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 324 letters, numbers, dots, or underscores.',
invalid_display_name: 'Enter a display name.',
invalid_password: 'Password must be 872 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',
+12
View File
@@ -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'>
+2
View File
@@ -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)"
+47 -59
View File
@@ -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)"
>
File diff suppressed because it is too large Load Diff
+21 -9
View File
@@ -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;
+590 -3
View File
@@ -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%;
+83 -29
View File
@@ -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"
+90 -6
View File
@@ -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
+9
View File
@@ -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,
+18 -1
View File
@@ -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 324 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 872 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 = {
+1
View File
@@ -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',
}
+19
View File
@@ -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",
+56
View File
@@ -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 = {
+126 -19
View File
@@ -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),
+161
View File
@@ -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)
+4
View File
@@ -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 },
+18
View File
@@ -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 '',