feat(skypic): add account controls and media batches

This commit is contained in:
Dominik
2026-08-20 01:34:05 +02:00
parent 12031a9c2e
commit 47c4d8c1d7
20 changed files with 2126 additions and 133 deletions
+55 -5
View File
@@ -97,6 +97,7 @@ type AppMessage = {
| PicstagramVerificationData
| PicstagramNotificationData
| SkyPicNotificationData
| SkyPicChangedData
| FeatherNotificationData
| BankingChangedData
| CryptoMarketChangedData
@@ -247,6 +248,12 @@ type SkyPicNotificationData = {
title?: string
}
type SkyPicChangedData = {
device?: PhoneNotificationDevicePayload
profileId?: string
reason?: 'account_deleted'
}
type FeatherNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
@@ -513,6 +520,35 @@ function cancelUnlockedPhoneDataLoad(): void {
unlockedServicesIdle = undefined
}
async function refreshSkyPicState(refreshThread = false): Promise<void> {
if (!account.email || !appAuth.isSignedIn('skypic')) {
if (!account.email) {
skypic.resetSession()
} else {
if (!skypic.bootstrapPending) skypic.resetSession()
}
return
}
if (skypic.accountDeletePending) return
const accountEmail = account.email
const imei = phone.device?.imei ?? ''
const loaded = await skypic.bootstrap()
if (
!loaded ||
account.email !== accountEmail ||
(phone.device?.imei ?? '') !== imei ||
!appAuth.isSignedIn('skypic')
) {
return
}
if (!skypic.profile) {
appAuth.signOut('skypic')
skypic.resetSession()
return
}
if (refreshThread) await skypic.refreshActiveThread()
}
function queueCompaniesChange(change: CompanyChangedPayload): void {
if (!pendingCompaniesChange) {
pendingCompaniesChange = { ...change }
@@ -540,7 +576,7 @@ function queueCompaniesChange(change: CompanyChangedPayload): void {
async function bootstrapUnlockedPhoneData(): Promise<void> {
const tasks: Array<() => Promise<unknown> | void> = [
() => (account.email ? skypic.bootstrap() : skypic.resetSession()),
() => refreshSkyPicState(),
() => calls.bootstrap(),
() => messages.loadConversations(),
() => billing.loadOverview(),
@@ -927,6 +963,7 @@ function onMessage(event: MessageEvent<AppMessage>): void {
const data = event.data.data as SkyPicNotificationData
const targetsActiveDevice =
!data.device || data.device.imei === phone.device?.imei
const signedInOnActiveDevice = appAuth.isSignedIn('skypic')
const notification: PhoneNotificationInput = {
appId: 'skypic',
route: skyPicNotificationRoute(data),
@@ -944,11 +981,24 @@ function onMessage(event: MessageEvent<AppMessage>): void {
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
if (!targetsActiveDevice || signedInOnActiveDevice) {
notifications.show(notification)
}
if (phone.isOpen && targetsActiveDevice && signedInOnActiveDevice) {
void refreshSkyPicState(true)
} else if (
targetsActiveDevice &&
!signedInOnActiveDevice &&
!skypic.bootstrapPending
) {
skypic.resetSession()
}
} else if (event.data?.type === 'skypic:changed' && event.data.data) {
const data = event.data.data as SkyPicChangedData
const targetsActiveDevice =
!data.device || data.device.imei === phone.device?.imei
if (phone.isOpen && targetsActiveDevice) {
void skypic.bootstrap().then((loaded) => {
if (loaded) void skypic.refreshActiveThread()
})
void refreshSkyPicState(true)
}
} else if (
event.data?.type === 'marketplace:new-message' &&
+78 -1
View File
@@ -39,6 +39,7 @@ function installTable(name: string): string {
const callbacks = [
'bootstrap',
'create-profile',
'delete-account',
'update-profile',
'search',
'add-friend',
@@ -197,6 +198,51 @@ describe('SkyPic backend contracts', () => {
)
})
it('validates dense photo batches before creating their cartesian snap set', () => {
const editors = block(
server,
'local function snap_editor_payloads',
'Bridge.Callbacks.Register("sky_phone:skypic:thread"',
)
expect(config).toContain('MaximumMediaPerSend = 10')
expect(config).toContain('MaximumSnapMessagesPerSend = 40')
expect(editors).toContain('count > limit("MaximumMediaPerSend", 10)')
expect(editors).toContain('if key_count ~= count then')
expect(editors).toContain('seen[media_id]')
expect(editors).toContain('mediaType = "photo"')
expect(server).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)',
)
const sendSnap = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
)
expect(sendSnap).toContain(
'raw_media_count > limit("MaximumMediaPerSend", 10)',
)
expect(sendSnap).toContain(
'message_count > limit("MaximumSnapMessagesPerSend", 40)',
)
expect(sendSnap).toContain('editor = editor')
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
expect(sendSnap).toContain('SELECT COUNT(*) FROM')
expect(sendSnap).toContain(') <> ?')
expect(sendSnap).toContain(
'assertion_params[#assertion_params + 1] = #entries',
)
expect(sendSnap).toContain('message_ids[#message_ids + 1] = entry.id')
expect(sendSnap).toContain(
'local sent = load_snap_metadata(message_ids, profile.profile_id)',
)
expect(mockServer).toContain('function skyPicMediaItems(body)')
expect(mockServer).toContain('submitted.length > 10')
expect(mockServer).toContain('seen.has(mediaId)')
expect(mockServer).toContain('mediaItems.length * recipients.length > 40')
})
it('keeps browser payload limits and profile creation aligned with production', () => {
expect(config).toContain('CaptionMaxLength = 160')
expect(server).toContain('valid_integer(data.avatarSeed, 1, 2147483647)')
@@ -216,7 +262,38 @@ describe('SkyPic backend contracts', () => {
expect(mockServer).toContain(
'onboardingScenario && skyPicOnboardingProfile',
)
expect(mockServer).toContain('profile: skyPicOnboardingProfile')
expect(mockServer).toContain('const profile = skyPicOnboardingProfile')
expect(mockServer).toContain('suggestions: profile')
})
it('deletes only the confirmed SkyPic account and silently refreshes peers', () => {
const deletion = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:delete-account"',
'Bridge.Callbacks.Register("sky_phone:skypic:update-profile"',
)
expect(deletion).toContain('data.confirmed ~= true')
expect(deletion).toContain('error = "confirmation_required"')
expect(deletion).toContain('Bridge.Database.Transaction({')
expect(deletion).toContain('SET peer.')
expect(deletion).toContain('friend_count')
expect(deletion).toContain(' > 0, peer.')
expect(deletion).toContain('- 1, 0')
expect(deletion).toContain('DELETE FROM')
expect(deletion).toContain('sky_phone_skypic_profiles')
expect(deletion).toContain('sky_phone_skypic_blocks')
expect(deletion).toContain('UNION ALL')
expect(deletion).toContain(
"AND status = 'active'".replace(
'status',
String.fromCharCode(96) + 'status' + String.fromCharCode(96),
),
)
expect(deletion).not.toContain('sky_phone_media')
expect(deletion).toContain('"sky_phone:skypic:changed"')
expect(deletion).not.toContain('"sky_phone:skypic:new"')
expect(mockServer).toContain("if (endpoint === 'skypic:delete-account')")
expect(mockServer).toContain("error: 'confirmation_required'")
})
it('keeps direct snap secrets out of bootstrap and thread serializers', () => {
@@ -6,11 +6,13 @@ const read = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
const app = read('./App.vue')
const appAuth = read('./stores/app-auth.ts')
const appIcon = read('./components/AppIcon.vue')
const client = read('../../sky_phone/source/client/main.lua')
const easyShare = read('../../sky_phone/source/server/easyshare.lua')
const manifest = read('../../sky_phone/fxmanifest.lua')
const mockServer = read('../testserver/index.cjs')
const phoneServer = read('../../sky_phone/source/server/phone.lua')
const reservedApps = read('../../sky_phone/source/shared/custom_apps.lua')
const server = read('../../sky_phone/source/server/skypic.lua')
const types = read('./types/skypic.ts')
@@ -18,6 +20,7 @@ const types = read('./types/skypic.ts')
const callbacks = [
'skypic:bootstrap',
'skypic:create-profile',
'skypic:delete-account',
'skypic:update-profile',
'skypic:search',
'skypic:add-friend',
@@ -82,12 +85,25 @@ describe('SkyPic cross-runtime integration contract', () => {
expect(client).toContain(
'SendNUIMessage({ type = "skypic:new", data = data })',
)
expect(client).toContain(
'RegisterNetEvent("sky_phone:skypic:changed", function(data)',
)
expect(client).toContain(
'SendNUIMessage({ type = "skypic:changed", data = data })',
)
expect(server).toContain(
'notify_profile(friendship.peer_id, profile, story_id and "story_reply" or "message", nil)',
)
expect(server).toContain('profileId = actor.profile_id')
expect(phoneServer).toContain('required_app_auth')
expect(phoneServer).toContain(
'app_auth.accountEmail ~= device.account_email',
)
expect(phoneServer).toContain('has_required_app_session(device)')
expect(server.match(/}, 'skypic'\)/g)).toHaveLength(3)
expect(app).toContain("event.data?.type === 'skypic:new'")
expect(app).toContain("event.data?.type === 'skypic:changed'")
expect(app).toContain("appId: 'skypic'")
expect(app).toContain('route: skyPicNotificationRoute(data)')
expect(app).toContain("query.set('profileId', data.profileId)")
@@ -101,16 +117,17 @@ describe('SkyPic cross-runtime integration contract', () => {
'!data.device || data.device.imei === phone.device?.imei',
)
expect(app).toContain('if (phone.isOpen && targetsActiveDevice)')
expect(app).toContain('void skypic.bootstrap().then((loaded) => {')
expect(app).toContain('if (loaded) void skypic.refreshActiveThread()')
expect(app).toContain('void refreshSkyPicState(true)')
expect(app).toContain('!targetsActiveDevice || signedInOnActiveDevice')
expect(appIcon).toContain(
"if (props.app.id === 'skypic') return skypic.unreadCount",
)
})
it('scopes badge state to the active unlocked device and account session', () => {
expect(appAuth).toContain("'skypic'")
expect(app).toContain(
'() => (account.email ? skypic.bootstrap() : skypic.resetSession())',
"if (!account.email || !appAuth.isSignedIn('skypic'))",
)
expect(app).toContain(
"() => [phone.device?.imei ?? '', account.email] as const",
@@ -119,12 +136,24 @@ describe('SkyPic cross-runtime integration contract', () => {
'if (imei === previousImei && email === previousEmail) return',
)
expect(app).toContain('skypic.resetSession()')
expect(app).toContain("appAuth.signOut('skypic')")
expect(app).toContain('unlockedServicesLoaded.value = false')
expect(app).toContain(
'if (phone.isOpen && !isLocked.value && !setupRequired.value)',
)
})
it('does not let background refreshes abort auth discovery or account deletion', () => {
const refreshBlock = app
.split('async function refreshSkyPicState(refreshThread = false)')[1]
?.split('function queueCompaniesChange')[0]
expect(refreshBlock).toContain(
'if (!skypic.bootstrapPending) skypic.resetSession()',
)
expect(refreshBlock).toContain('if (skypic.accountDeletePending) return')
expect(app).toContain('!skypic.bootstrapPending')
})
it('keeps snap and story secrets out of list payload types', () => {
const snap = typeBlock('SkyPicSnap')
const story = typeBlock('SkyPicStory')
@@ -147,7 +176,9 @@ describe('SkyPic cross-runtime integration contract', () => {
expect(mockServer).toContain('const skyPicSnapContents = new Map(')
expect(mockServer).toContain('const skyPicStoryContents = new Map(')
expect(mockServer).toContain('blockedProfiles: skyPicProfiles')
expect(mockServer).toContain('skyPicIncrementOwnScore(recipients.length)')
expect(mockServer).toContain(
'skyPicIncrementOwnScore(recipients.length * mediaItems.length)',
)
expect(mockServer).toContain('.slice(offset, offset + 30)')
expect(mockServer).toContain(
"response.json({ success: false, error: 'story_unavailable' })",
+18
View File
@@ -37,6 +37,24 @@ describe('app auth store', () => {
})
})
it('persists the SkyPic session independently', () => {
const auth = useAppAuthStore()
auth.hydrate(null, 'demo@ifruit.com')
auth.signIn('skypic', 'demo@ifruit.com')
expect(auth.isSignedIn('skypic')).toBe(true)
expect(auth.isSignedIn('feather')).toBe(false)
expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
accountEmail: 'demo@ifruit.com',
signedIn: ['skypic'],
version: 1,
})
auth.signOut('skypic')
expect(auth.isSignedIn('skypic')).toBe(false)
})
it('does not restore sessions belonging to another iFruit account', () => {
const auth = useAppAuthStore()
auth.hydrate(
+2
View File
@@ -7,6 +7,7 @@ export const APP_AUTH_IDS = [
'local-pages',
'feather',
'crewlink',
'skypic',
] as const
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
@@ -23,6 +24,7 @@ function emptySessions(): Record<AppAuthId, boolean> {
'local-pages': false,
feather: false,
crewlink: false,
skypic: false,
}
}
+26
View File
@@ -231,6 +231,9 @@ describe('phone locale fallback', () => {
'create',
'creating',
].map((key) => 'onboarding.' + key),
...['title', 'body', 'eyebrow', 'login', 'loggingIn', 'noAccount'].map(
(key) => 'auth.' + key,
),
...[
'eyebrow',
'title',
@@ -284,6 +287,18 @@ describe('phone locale fallback', () => {
'unsave',
'delete',
'sendSnap',
'moreActions',
'attachPhoto',
'takePhoto',
'emoji',
'attachVideo',
'attachmentPreview',
'removeAttachment',
'moveAttachmentEarlier',
'moveAttachmentLater',
'attachmentLimit',
'sendingAttachments',
'photoAttachmentsSent',
'sending',
'failed',
].map((key) => 'chats.' + key),
@@ -361,6 +376,15 @@ describe('phone locale fallback', () => {
'showInQuickAdd',
'showInQuickAddBody',
'saved',
'account',
'logout',
'logoutTitle',
'logoutBody',
'loggingOut',
'deleteAccount',
'deleteAccountTitle',
'deleteAccountBody',
'deletingAccount',
].map((key) => 'profile.' + key),
...['close', 'timeLeft'].map((key) => 'viewer.' + key),
...[
@@ -404,6 +428,8 @@ describe('phone locale fallback', () => {
'story_limit_reached',
'story_unavailable',
'not_authorized',
'confirmation_required',
'too_many_snaps',
'rate_limited',
'request_timeout',
'request_failed',
+34
View File
@@ -1667,6 +1667,14 @@ const defaultLocales: LocaleTree = {
create: 'Create profile',
creating: 'Creating...',
},
auth: {
eyebrow: 'Your SkyPic account',
title: 'Welcome back',
body: 'Continue with your iFruit account to access your SkyPic profile, snaps, and chats.',
login: 'Continue to SkyPic',
loggingIn: 'Signing in...',
noAccount: 'New to SkyPic? Create an iFruit account to get started.',
},
camera: {
eyebrow: 'Sky camera',
title: 'Capture',
@@ -1725,6 +1733,18 @@ const defaultLocales: LocaleTree = {
unsave: 'Unsave',
delete: 'Delete',
sendSnap: 'Send a snap',
moreActions: 'More actions',
attachPhoto: 'Attach photos',
takePhoto: 'Take photo',
emoji: 'Emoji',
attachVideo: 'Attach video',
attachmentPreview: 'Selected attachments',
removeAttachment: 'Remove attachment {number}',
moveAttachmentEarlier: 'Move attachment {number} earlier',
moveAttachmentLater: 'Move attachment {number} later',
attachmentLimit: 'You can attach up to {count} photos.',
sendingAttachments: 'Sending photos...',
photoAttachmentsSent: 'Photos sent.',
sending: 'Sending...',
failed: 'Not delivered',
},
@@ -1802,6 +1822,17 @@ const defaultLocales: LocaleTree = {
showInQuickAdd: 'Show in Quick Add',
showInQuickAddBody: 'Let other profiles discover you as a suggestion.',
saved: 'Profile updated.',
account: 'Account',
logout: 'Sign out',
logoutTitle: 'Sign out of SkyPic?',
logoutBody:
'You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.',
loggingOut: 'Signing out...',
deleteAccount: 'Delete SkyPic account',
deleteAccountTitle: 'Delete your SkyPic account?',
deleteAccountBody:
'Your SkyPic profile, friends, snaps, stories, and chats will be permanently deleted. Your iFruit account and Photos library stay available.',
deletingAccount: 'Deleting account...',
},
viewer: {
close: 'Close',
@@ -1848,6 +1879,9 @@ const defaultLocales: LocaleTree = {
story_limit_reached: 'Your active story limit is reached.',
story_unavailable: 'This story is no longer available.',
not_authorized: 'You are not allowed to do that.',
confirmation_required:
'Confirm that you want to delete your SkyPic account.',
too_many_snaps: 'Choose fewer photos or recipients.',
rate_limited: 'Slow down for a moment and try again.',
request_timeout: 'The SkyPic request timed out. Try again.',
request_failed: 'SkyPic could not complete the request.',
+96 -1
View File
@@ -1,7 +1,11 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSkyPicStore } from '@/stores/skypic'
import {
isValidSkyPicHandle,
normalizeSkyPicHandle,
useSkyPicStore,
} from '@/stores/skypic'
import type {
SkyPicBootstrap,
SkyPicConversation,
@@ -166,6 +170,17 @@ describe('SkyPic store', () => {
mockNuiCall.mockReset()
})
it('matches the server handle rule including leading and trailing separators', () => {
expect(normalizeSkyPicHandle(' _Morgan ')).toBe('_morgan')
expect(isValidSkyPicHandle('_morgan')).toBe(true)
expect(isValidSkyPicHandle('morgan_')).toBe(true)
expect(isValidSkyPicHandle('.mo')).toBe(true)
expect(isValidSkyPicHandle('mo.')).toBe(true)
expect(isValidSkyPicHandle('ab')).toBe(false)
expect(isValidSkyPicHandle('a'.repeat(25))).toBe(false)
expect(isValidSkyPicHandle('morgan-')).toBe(false)
})
it('hydrates the account-bound bootstrap without exposing direct media URLs', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { ...bootstrap, blockedProfiles: [theo] },
@@ -227,6 +242,57 @@ describe('SkyPic store', () => {
expect(store.profile?.showInQuickAdd).toBe(false)
})
it('deletes only the SkyPic account after explicit confirmation and clears local state', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const store = useSkyPicStore()
store.profile = { ...self }
store.friends = [{ ...friend }]
store.conversations = [{ ...conversation }]
await expect(store.deleteAccount()).resolves.toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('skypic:delete-account', {
confirmed: true,
})
expect(store.profile).toBeNull()
expect(store.friends).toEqual([])
expect(store.conversations).toEqual([])
})
it('keeps its own changed refresh from aborting an in-flight account deletion', async () => {
const pendingDelete = deferred<NuiResponse<unknown>>()
mockNuiCall.mockReturnValueOnce(pendingDelete.promise)
const store = useSkyPicStore()
store.profile = { ...self }
const deletion = store.deleteAccount()
expect(store.accountDeletePending).toBe(true)
await expect(store.bootstrap()).resolves.toBe(false)
expect(mockNuiCall).toHaveBeenCalledTimes(1)
pendingDelete.resolve({ success: true })
await expect(deletion).resolves.toBe(true)
expect(store.accountDeletePending).toBe(false)
expect(store.profile).toBeNull()
})
it('signals a server-confirmed missing profile without conflating local resets', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { ...bootstrap, profile: null },
success: true,
})
const store = useSkyPicStore()
const discovery = store.bootstrap()
expect(store.bootstrapPending).toBe(true)
await expect(discovery).resolves.toBe(true)
expect(store.bootstrapPending).toBe(false)
expect(store.profileAbsentRevision).toBe(1)
store.resetSession()
expect(store.profileAbsentRevision).toBe(1)
})
it('updates outgoing relation state and accepts a friend request', async () => {
const outgoing: SkyPicFriendRequest = {
createdAt: now,
@@ -503,6 +569,35 @@ describe('SkyPic store', () => {
expect(store.profile.snapScore).toBe(self.snapScore + 1)
})
it('deduplicates and caps a multi-photo snap batch without legacy media fields', async () => {
mockNuiCall.mockResolvedValueOnce({ data: [], success: true })
const store = useSkyPicStore()
const mediaIds = [1, 2, 2, 0, -1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
await store.sendSnap({
allowReplay: true,
caption: '',
durationSeconds: 5,
mediaIds,
overlayColor: '#ffffff',
recipientIds: [maya.id],
textOverlay: '',
})
expect(mockNuiCall).toHaveBeenCalledWith('skypic:send-snap', {
allowReplay: true,
caption: '',
durationSeconds: 5,
mediaIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
overlayColor: '#ffffff',
recipientIds: [maya.id],
textOverlay: '',
})
const payload = mockNuiCall.mock.calls[0]?.[1]
expect(payload).not.toHaveProperty('mediaId')
expect(payload).not.toHaveProperty('mediaType')
})
it('caps snap recipients and chat bodies at the server limits', async () => {
const recipientIds = Array.from(
{ length: 22 },
+61 -5
View File
@@ -24,9 +24,21 @@ import type {
import { nuiCall, type NuiResponse } from '@/utils/nui'
const MAX_MESSAGE_CHARACTERS = 2_000
const MAX_SNAP_MEDIA = 10
const MAX_TEXT_OVERLAY_CHARACTERS = 160
const STORY_PAGE_SIZE = 30
export function normalizeSkyPicHandle(value: string): string {
return value.trim().toLowerCase()
}
export function isValidSkyPicHandle(value: string): boolean {
const handle = normalizeSkyPicHandle(value)
return (
handle.length >= 3 && handle.length <= 24 && /^[a-z0-9._]+$/.test(handle)
)
}
function arrayOrEmpty<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
@@ -77,6 +89,9 @@ export const useSkyPicStore = defineStore('skypic', () => {
const storyViewersHasMore = ref(true)
const loading = ref(false)
const bootstrapPending = ref(false)
const accountDeletePending = ref(false)
const profileAbsentRevision = ref(0)
const threadLoading = ref(false)
const searchLoading = ref(false)
const snapOpening = ref(false)
@@ -158,6 +173,7 @@ export const useSkyPicStore = defineStore('skypic', () => {
function resetSession(): void {
sessionVersion += 1
bootstrapInFlight = null
bootstrapPending.value = false
bootstrapRequest += 1
searchRequest += 1
snapRequest += 1
@@ -197,6 +213,7 @@ export const useSkyPicStore = defineStore('skypic', () => {
profile.value = data.profile ?? null
if (!profile.value) {
resetSession()
profileAbsentRevision.value += 1
return
}
@@ -212,12 +229,14 @@ export const useSkyPicStore = defineStore('skypic', () => {
}
function bootstrap(): Promise<boolean> {
if (accountDeletePending.value) return Promise.resolve(false)
const requestSession = sessionVersion
if (bootstrapInFlight?.session === requestSession) {
return bootstrapInFlight.promise
}
const requestId = ++bootstrapRequest
loading.value = true
bootstrapPending.value = true
const token = Symbol('skypic-bootstrap')
const promise = (async () => {
try {
@@ -229,7 +248,10 @@ export const useSkyPicStore = defineStore('skypic', () => {
hydrate(response.data)
return true
} finally {
if (bootstrapInFlight?.token === token) bootstrapInFlight = null
if (bootstrapInFlight?.token === token) {
bootstrapInFlight = null
bootstrapPending.value = false
}
}
})()
bootstrapInFlight = { promise, session: requestSession, token }
@@ -247,7 +269,7 @@ export const useSkyPicStore = defineStore('skypic', () => {
? {}
: { avatarSeed: input.avatarSeed }),
displayName: input.displayName.trim(),
handle: input.handle.trim().toLowerCase(),
handle: normalizeSkyPicHandle(input.handle),
}
const response = await sessionCall<SkyPicProfile>(
'skypic:create-profile',
@@ -265,7 +287,7 @@ export const useSkyPicStore = defineStore('skypic', () => {
...input,
bio: input.bio.trim(),
displayName: input.displayName.trim(),
handle: input.handle.trim().toLowerCase(),
handle: normalizeSkyPicHandle(input.handle),
}
const response = await sessionCall<SkyPicProfile>(
'skypic:update-profile',
@@ -276,6 +298,22 @@ export const useSkyPicStore = defineStore('skypic', () => {
return response
}
async function deleteAccount(): Promise<boolean> {
if (accountDeletePending.value) return false
accountDeletePending.value = true
try {
const response = await sessionCall('skypic:delete-account', {
confirmed: true,
})
setError(response)
if (!response.success) return false
resetSession()
return true
} finally {
accountDeletePending.value = false
}
}
async function search(query: string): Promise<boolean> {
const normalized = query.trim()
const requestId = ++searchRequest
@@ -520,8 +558,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
async function sendSnap(
input: SkyPicSendSnapInput,
): Promise<NuiResponse<SkyPicSnap[]>> {
const payload: SkyPicSendSnapInput = {
...input,
const common = {
allowReplay: input.allowReplay,
caption: input.caption.trim(),
durationSeconds: clampDuration(input.durationSeconds),
overlayColor: normalizeColor(input.overlayColor),
@@ -533,6 +571,20 @@ export const useSkyPicStore = defineStore('skypic', () => {
.slice(0, MAX_TEXT_OVERLAY_CHARACTERS)
.join(''),
}
const payload: SkyPicSendSnapInput = Array.isArray(input.mediaIds)
? {
...common,
mediaIds: [...new Set(input.mediaIds)]
.filter(
(mediaId) => Number.isInteger(mediaId) && Number(mediaId) > 0,
)
.slice(0, MAX_SNAP_MEDIA),
}
: {
...common,
mediaId: input.mediaId!,
mediaType: input.mediaType!,
}
const response = await sessionCall<SkyPicSnap[]>(
'skypic:send-snap',
payload,
@@ -989,17 +1041,20 @@ export const useSkyPicStore = defineStore('skypic', () => {
}
return {
accountDeletePending,
activeConversation,
activeFriendshipId,
addFriend,
block,
blockedProfiles,
bootstrap,
bootstrapPending,
clearOpenedSnap,
clearViewedStory,
closeThread,
conversations,
createProfile,
deleteAccount,
deleteMessage,
error,
friends,
@@ -1017,6 +1072,7 @@ export const useSkyPicStore = defineStore('skypic', () => {
openThread,
outgoingRequests,
profile,
profileAbsentRevision,
publishStory,
refreshActiveThread,
removeFriend,
+27 -10
View File
@@ -1,4 +1,4 @@
import type { MediaType } from '@/types/media'
import type { MediaType, PhoneMedia } from '@/types/media'
export type SkyPicStoryPrivacy = 'everyone' | 'friends'
export type SkyPicDirection = 'received' | 'sent'
@@ -181,18 +181,35 @@ export type SkyPicMediaDraftContext = {
recipientIds: string[]
}
export type SkyPicSendSnapInput = {
allowReplay: boolean
export type SkyPicThreadMediaDraftContext = {
body: string
friendshipId: string
pendingMedia: PhoneMedia[]
}
type SkyPicEditorInput = {
caption: string
durationSeconds: number
mediaId: number
mediaType: MediaType
overlayColor: string
recipientIds: string[]
textOverlay: string
}
export type SkyPicPublishStoryInput = Omit<
SkyPicSendSnapInput,
'allowReplay' | 'recipientIds'
>
type SkyPicSingleMediaInput = {
mediaId: number
mediaIds?: never
mediaType: MediaType
}
type SkyPicMultipleMediaInput = {
mediaId?: never
mediaIds: number[]
mediaType?: never
}
export type SkyPicSendSnapInput = SkyPicEditorInput &
(SkyPicSingleMediaInput | SkyPicMultipleMediaInput) & {
allowReplay: boolean
recipientIds: string[]
}
export type SkyPicPublishStoryInput = SkyPicEditorInput & SkyPicSingleMediaInput
@@ -30,6 +30,28 @@ describe('SkyPic frontend contract', () => {
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(4)
})
it('keeps Camera blue and all other tabs theme-monochrome', () => {
expect(viewSource).toContain('phone.isDarkMode')
expect(viewSource).toContain("'skypic-app--player-dark': phone.isDarkMode")
expect(viewSource).toContain(
"'skypic-app--player-light': !phone.isDarkMode",
)
expect(viewSource).toContain('class="sp-tab sp-tab--camera"')
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
3,
)
expect(viewSource).toContain('color: var(--sp-camera-blue) !important')
expect(viewSource).toContain('--sp-tab-monochrome: #000000')
expect(viewSource).toContain('--sp-tab-monochrome: #ffffff')
expect(viewSource).toContain('color: var(--sp-tab-monochrome) !important')
const cameraCss = viewSource
.split('.sp-camera-screen {')[1]
?.split('.sp-camera-header')[0]
expect(cameraCss).toBeTruthy()
expect(cameraCss).not.toContain('255, 91, 189')
expect(viewSource).not.toContain('var(--sp-cyan)')
})
it('uses the shared Camera and Gallery media handoff with a bounded draft', () => {
expect(viewSource).toContain('mediaPicker.begin(')
expect(viewSource).toContain("'skypic-draft'")
@@ -42,6 +64,98 @@ describe('SkyPic frontend contract', () => {
expect(viewSource).toContain("type MediaSource = 'camera' | 'photos'")
})
it('supports a bounded multi-photo thread handoff with preview controls', () => {
expect(viewSource).toContain('const MAX_THREAD_ATTACHMENTS = 10')
expect(viewSource).toContain("'skypic-thread-media'")
expect(viewSource).toContain(
'mediaPicker.consumeMany<SkyPicThreadMediaDraftContext>',
)
expect(viewSource).toContain('pendingThreadMedia')
expect(viewSource).toContain('mediaIds: queuedMedia.map')
expect(viewSource).toContain('removeThreadMedia(media.id)')
expect(viewSource).toContain('moveThreadMedia(index, -1)')
expect(viewSource).toContain('moveThreadMedia(index, 1)')
expect(viewSource).toContain("openThreadMedia('photos', 'photo')")
expect(viewSource).toContain("openThreadMedia('camera', 'photo')")
expect(viewSource).toContain("openThreadMedia('photos', 'video')")
expect(viewSource).toContain('openThreadEmojiPicker')
expect(typeSource).toContain('SkyPicThreadMediaDraftContext')
expect(typeSource).toContain('mediaIds: number[]')
})
it('uses app-local authentication and exposes safe account controls', () => {
expect(viewSource).toContain('useAppAuthStore()')
expect(viewSource).toContain("appAuth.isSignedIn('skypic')")
expect(viewSource).toContain("appAuth.signIn('skypic', account.email)")
expect(viewSource).toContain("appAuth.signOut('skypic')")
expect(viewSource).toContain('<AppProfileAuth')
expect(viewSource).toContain('<AccountLogoutDialog')
expect(viewSource).toContain('app-id="skypic"')
expect(viewSource).toContain('deleteSkyPicAccount')
expect(viewSource).toContain('store.resetSession()')
expect(storeSource).toContain("'skypic:delete-account'")
expect(storeSource).toContain('confirmed: true')
})
it('uses the production SkyPic handle rule for login and registration', () => {
expect(viewSource).toContain('isValidSkyPicHandle(authHandle.value)')
expect(viewSource).toContain(
'const handle = normalizeSkyPicHandle(onboarding.handle)',
)
expect(storeSource).toContain('/^[a-z0-9._]+$/')
})
it('reacts to confirmed remote account deletion with a clean register state', () => {
expect(viewSource).toContain('() => store.profileAbsentRevision')
const resetBlock = viewSource
.split('function resetAccountUiState(accountExists: boolean): void {')[1]
?.split('function handleLoggedOut')[0]
expect(resetBlock).toContain(
"authMode.value = accountExists ? 'login' : 'register'",
)
expect(resetBlock).toContain('deleteAccountDialogOpen.value = false')
expect(resetBlock).toContain('profileEditing.value = false')
expect(resetBlock).toContain('pendingThreadMedia.value = []')
expect(resetBlock).toContain('storyViewerSheetOpen.value = false')
expect(resetBlock).toContain("storyViewerSheetStoryId.value = ''")
expect(resetBlock).toContain("storyReply.value = ''")
expect(resetBlock).toContain('storyNavigationRequest += 1')
expect(resetBlock).toContain('clearStoryTimer()')
expect(resetBlock).toContain('store.clearViewedStory()')
expect(resetBlock).toContain("activeTab.value = 'camera'")
})
it('clears private SkyPic state while the local app session is signed out', () => {
const logoutBlock = viewSource
.split('function handleLoggedOut(): void {')[1]
?.split('async function deleteSkyPicAccount')[0]
const bootstrapBlock = viewSource
.split('async function bootstrapApp(): Promise<void> {')[1]
?.split('onMounted(')[0]
expect(logoutBlock).toContain('store.resetSession()')
expect(logoutBlock).not.toContain('bootstrapApp()')
expect(bootstrapBlock).toContain("!appAuth.isSignedIn('skypic')")
expect(bootstrapBlock).toContain('store.resetSession()')
expect(viewSource).toContain(
'hasSkyPicAccount.value = Boolean(store.profile)',
)
})
it('renders local registration guidance without bootstrapping an absent Sky account', () => {
const bootstrapBlock = viewSource
.split('async function bootstrapApp(): Promise<void> {')[1]
?.split('onMounted(')[0]
const noAccountGuard = bootstrapBlock?.indexOf('if (!account.email)') ?? -1
const serverBootstrap = bootstrapBlock?.indexOf(
'const loaded = await store.bootstrap()',
)
expect(noAccountGuard).toBeGreaterThanOrEqual(0)
expect(noAccountGuard).toBeLessThan(serverBootstrap ?? -1)
expect(bootstrapBlock).toContain('resetAccountUiState(false)')
expect(bootstrapBlock).toContain('bootstrapped.value = true')
})
it('keeps profile editing parse-safe and composer inputs canonical', () => {
expect(viewSource).toContain('function toggleProfileEditor(): void')
expect(viewSource.match(/@click="toggleProfileEditor"/g)).toHaveLength(2)
@@ -90,6 +204,7 @@ describe('SkyPic frontend contract', () => {
const callbacks = [
'skypic:bootstrap',
'skypic:create-profile',
'skypic:delete-account',
'skypic:update-profile',
'skypic:search',
'skypic:add-friend',
File diff suppressed because it is too large Load Diff
+164 -50
View File
@@ -3456,6 +3456,44 @@ function skyPicRecipientIds(value) {
return ids
}
function skyPicMediaItems(body) {
const legacy = body.mediaIds === undefined
if (
!legacy &&
(body.mediaId !== undefined ||
(body.mediaType !== undefined && body.mediaType !== 'photo'))
) {
return null
}
const submitted = legacy ? [body.mediaId] : body.mediaIds
if (
!Array.isArray(submitted) ||
submitted.length < 1 ||
submitted.length > 10 ||
Object.keys(submitted).length !== submitted.length
) {
return null
}
const seen = new Set()
const mediaItems = []
for (const mediaId of submitted) {
if (!Number.isInteger(mediaId) || mediaId < 1 || seen.has(mediaId)) {
return null
}
const media = mockMedia.find((item) => item.id === mediaId)
if (
!media ||
media.mediaType !==
(legacy && body.mediaType === 'video' ? 'video' : 'photo')
) {
return null
}
seen.add(mediaId)
mediaItems.push(media)
}
return mediaItems
}
function skyPicMessageBody(value) {
const body = typeof value === 'string' ? value.trim() : ''
if (!body) return { error: 'message_empty' }
@@ -3783,22 +3821,41 @@ function skyPicStoryVisible(story) {
function skyPicBootstrap(testScenario = '') {
if (testScenario === 'skypic-onboarding') {
const profile = skyPicOnboardingProfile
? { ...skyPicOnboardingProfile }
: null
return {
blockedProfiles: [],
conversations: [],
friends: [],
inbox: [],
profile: skyPicOnboardingProfile ? { ...skyPicOnboardingProfile } : null,
profile,
requests: [],
stories: [],
suggestions: skyPicProfiles
.slice(1)
.filter(
(item) =>
!skyPicBlockedProfileIds.has(item.id) &&
skyPicFriendshipStatus(item.id) === 'none',
)
.map(skyPicDiscoveryProfile),
suggestions: profile
? skyPicProfiles
.slice(1)
.filter(
(item) =>
!skyPicBlockedProfileIds.has(item.id) &&
skyPicFriendshipStatus(item.id) === 'none',
)
.map(skyPicDiscoveryProfile)
: [],
unreadCount: 0,
}
}
if (!skyPicProfile) {
return {
blockedProfiles: [],
conversations: [],
friends: [],
inbox: [],
profile: null,
requests: [],
stories: [],
suggestions: [],
unreadCount: 0,
}
}
@@ -7996,6 +8053,7 @@ app.post('/api/:endpoint', (request, response) => {
avatarUrl: avatar?.url ?? null,
displayName,
handle,
snapScore: 0,
})
skyPicProfile = {
...skyPicProfiles[0],
@@ -8010,6 +8068,40 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: { ...skyPicProfile } })
return
}
if (endpoint === 'skypic:delete-account') {
if (request.body.confirmed !== true) {
response.json({ success: false, error: 'confirmation_required' })
return
}
if (testScenario === 'skypic-onboarding') {
if (!skyPicOnboardingProfile) {
response.json({ success: false, error: 'profile_required' })
return
}
skyPicOnboardingProfile = null
response.json({ success: true })
return
}
if (!skyPicProfile) {
response.json({ success: false, error: 'profile_required' })
return
}
skyPicProfile = null
skyPicFriends = []
skyPicRequests = []
skyPicConversations = []
skyPicSnaps = []
skyPicMessages.clear()
skyPicBlockedProfileIds.clear()
for (const story of skyPicStories) {
if (!story.isOwner) continue
skyPicStoryContents.delete(story.id)
skyPicStoryViewers.delete(story.id)
}
skyPicStories = skyPicStories.filter((story) => !story.isOwner)
response.json({ success: true })
return
}
if (endpoint === 'skypic:update-profile') {
if (!skyPicProfile) {
response.json({ success: false, error: 'profile_required' })
@@ -8220,10 +8312,29 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: false, error: 'profile_required' })
return
}
const mediaId = Number(request.body.mediaId)
const media = mockMedia.find((item) => item.id === mediaId)
const mediaType = request.body.mediaType === 'video' ? 'video' : 'photo'
if (!media || media.mediaType !== mediaType) {
const recipientIds = skyPicRecipientIds(request.body.recipientIds)
if (!recipientIds) {
response.json({ success: false, error: 'invalid_recipients' })
return
}
if (
request.body.mediaIds !== undefined &&
(!Array.isArray(request.body.mediaIds) ||
request.body.mediaIds.length < 1 ||
request.body.mediaIds.length > 10)
) {
response.json({ success: false, error: 'invalid_media' })
return
}
const requestedMediaCount = Array.isArray(request.body.mediaIds)
? request.body.mediaIds.length
: 1
if (requestedMediaCount * recipientIds.length > 40) {
response.json({ success: false, error: 'too_many_snaps' })
return
}
const mediaItems = skyPicMediaItems(request.body)
if (!mediaItems) {
response.json({ success: false, error: 'invalid_media' })
return
}
@@ -8237,11 +8348,6 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: false, error: 'invalid_overlay' })
return
}
const recipientIds = skyPicRecipientIds(request.body.recipientIds)
if (!recipientIds) {
response.json({ success: false, error: 'invalid_recipients' })
return
}
const recipients = recipientIds
.map((profileId) =>
skyPicFriends.find((friend) => friend.profile.id === profileId),
@@ -8251,6 +8357,10 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: false, error: 'invalid_recipients' })
return
}
if (mediaItems.length * recipients.length > 40) {
response.json({ success: false, error: 'too_many_snaps' })
return
}
const createdAt = new Date().toISOString()
const expiresAt = new Date(
Date.parse(createdAt) + 30 * 24 * 60 * 60_000,
@@ -8262,39 +8372,43 @@ app.post('/api/:endpoint', (request, response) => {
const overlayColor = /^#[0-9a-f]{6}$/i.test(request.body.overlayColor)
? request.body.overlayColor
: '#ffffff'
skyPicIncrementOwnScore(recipients.length)
const sent = recipients.map((friend) => {
const snap = {
allowReplay: request.body.allowReplay === true,
createdAt,
direction: 'sent',
durationSeconds,
expiresAt,
friendshipId: friend.friendshipId,
id: randomUUID(),
openedAt: null,
replayedAt: null,
sender: skyPicProfiles[0],
type: mediaType === 'video' ? 'snap_video' : 'snap_photo',
skyPicIncrementOwnScore(recipients.length * mediaItems.length)
const sent = []
for (const friend of recipients) {
for (const media of mediaItems) {
const mediaType = media.mediaType === 'video' ? 'video' : 'photo'
const snap = {
allowReplay: request.body.allowReplay === true,
createdAt,
direction: 'sent',
durationSeconds,
expiresAt,
friendshipId: friend.friendshipId,
id: randomUUID(),
openedAt: null,
replayedAt: null,
sender: skyPicProfiles[0],
type: mediaType === 'video' ? 'snap_video' : 'snap_photo',
}
skyPicSnaps.push(snap)
skyPicSnapContents.set(snap.id, {
caption,
mediaType,
mimeType: mediaType === 'video' ? 'video/mp4' : 'image/jpeg',
overlayColor,
textOverlay,
url: media.url,
})
skyPicUpdateConversation(friend.friendshipId, {
createdAt,
direction: 'sent',
id: snap.id,
openedAt: null,
type: snap.type,
})
sent.push(skyPicSnapView(snap))
}
skyPicSnaps.push(snap)
skyPicSnapContents.set(snap.id, {
caption,
mediaType,
mimeType: mediaType === 'video' ? 'video/mp4' : 'image/jpeg',
overlayColor,
textOverlay,
url: media.url,
})
skyPicUpdateConversation(friend.friendshipId, {
createdAt,
direction: 'sent',
id: snap.id,
openedAt: null,
type: snap.type,
})
return skyPicSnapView(snap)
})
}
response.json({ success: true, data: sent })
return
}
+131
View File
@@ -617,6 +617,94 @@ async function verifySkyPicActions(baseUrl) {
bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true)
assert.equal(bootstrap.profile.snapScore, expectedSnapScore)
assert.deepEqual(
await post(baseUrl, 'skypic:send-snap', {
allowReplay: false,
caption: 'This batch must stay atomic.',
durationSeconds: 5,
mediaIds: [1, 999_999],
overlayColor: '#24c7ff',
recipientIds: [friend.profile.id],
textOverlay: '',
}),
{ error: 'invalid_media', success: false },
)
assert.deepEqual(
await post(baseUrl, 'skypic:send-snap', {
allowReplay: false,
caption: '',
durationSeconds: 5,
mediaIds: [1, 2],
overlayColor: '#24c7ff',
recipientIds: [friend.profile.id],
textOverlay: '',
}),
{ error: 'invalid_media', success: false },
)
assert.deepEqual(
await post(baseUrl, 'skypic:send-snap', {
allowReplay: false,
caption: '',
durationSeconds: 5,
mediaIds: [1, 1],
overlayColor: '#24c7ff',
recipientIds: [friend.profile.id],
textOverlay: '',
}),
{ error: 'invalid_media', success: false },
)
assert.deepEqual(
await post(baseUrl, 'skypic:send-snap', {
allowReplay: false,
caption: '',
durationSeconds: 5,
mediaIds: [1, 3, 4],
overlayColor: '#24c7ff',
recipientIds: recipientIds.slice(0, 20),
textOverlay: '',
}),
{ error: 'too_many_snaps', success: false },
)
assert.deepEqual(
await post(baseUrl, 'skypic:send-snap', {
allowReplay: false,
caption: '',
durationSeconds: 5,
mediaIds: Array.from({ length: 11 }, (_, index) => index + 1),
overlayColor: '#24c7ff',
recipientIds: [friend.profile.id],
textOverlay: '',
}),
{ error: 'invalid_media', success: false },
)
bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true)
assert.equal(
bootstrap.profile.snapScore,
expectedSnapScore,
'invalid SkyPic media batches changed the snap score',
)
const sentPhotoBatch = await expectSuccess(
baseUrl,
'skypic:send-snap',
{
allowReplay: false,
caption: 'Three photos from one SkyPic preview.',
durationSeconds: 5,
mediaIds: [3, 4, 5],
overlayColor: '#24c7ff',
recipientIds: [friend.profile.id],
textOverlay: '',
},
true,
)
assert.equal(sentPhotoBatch.length, 3)
assert(sentPhotoBatch.every((snap) => snap.type === 'snap_photo'))
assert.equal(new Set(sentPhotoBatch.map((snap) => snap.id)).size, 3)
expectedSnapScore += sentPhotoBatch.length
bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true)
assert.equal(bootstrap.profile.snapScore, expectedSnapScore)
const sentMessage = await expectSuccess(
baseUrl,
'skypic:send-message',
@@ -654,6 +742,12 @@ async function verifySkyPicActions(baseUrl) {
updatedThread.snaps.some((snap) => snap.id === sentSnaps[0].id),
'skypic:send-snap did not persist in the thread',
)
assert(
sentPhotoBatch.every((sent) =>
updatedThread.snaps.some((snap) => snap.id === sent.id),
),
'skypic:send-snap did not persist every item from the media batch',
)
expectSkyPicMetadataSafe(updatedThread.snaps, 'updated SkyPic thread snaps')
assert.deepEqual(
@@ -791,6 +885,27 @@ async function verifySkyPicActions(baseUrl) {
}),
{ error: 'profile_exists', success: false },
)
assert.deepEqual(
await post(baseUrl, 'skypic:delete-account', {
_testScenario: 'skypic-onboarding',
}),
{ error: 'confirmation_required', success: false },
)
await expectSuccess(baseUrl, 'skypic:delete-account', {
_testScenario: 'skypic-onboarding',
confirmed: true,
})
const deletedOnboarding = await expectSuccess(
baseUrl,
'skypic:bootstrap',
{ _testScenario: 'skypic-onboarding' },
true,
)
assert.equal(deletedOnboarding.profile, null)
assert.deepEqual(deletedOnboarding.friends, [])
assert.deepEqual(deletedOnboarding.inbox, [])
assert.deepEqual(deletedOnboarding.stories, [])
assert.deepEqual(deletedOnboarding.suggestions, [])
await expectSuccess(baseUrl, 'skypic:block', {
blocked: true,
@@ -824,6 +939,22 @@ async function verifySkyPicActions(baseUrl) {
await post(baseUrl, 'skypic:view-story', { storyId: friendStory.id }),
{ error: 'story_unavailable', success: false },
)
await expectSuccess(baseUrl, 'skypic:delete-account', { confirmed: true })
const recreatedDefaultProfile = await expectSuccess(
baseUrl,
'skypic:create-profile',
createProfile,
true,
)
assert.equal(recreatedDefaultProfile.snapScore, 0)
const recreatedDefaultBootstrap = await expectSuccess(
baseUrl,
'skypic:bootstrap',
{},
true,
)
assert.equal(recreatedDefaultBootstrap.profile.snapScore, 0)
}
async function verifyStatefulActions(baseUrl) {
+2
View File
@@ -505,6 +505,8 @@ Config.SkyPic = {
MinimumViewSeconds = 1,
MaximumViewSeconds = 10,
MaximumSnapRecipients = 20,
MaximumMediaPerSend = 10,
MaximumSnapMessagesPerSend = 40,
MaximumFriends = 500,
MaximumPendingRequests = 100,
MaximumActiveStories = 50,
+21 -1
View File
@@ -547,6 +547,12 @@ Locales["de"] = {
accountBound = "Dein SkyPic-Profil bleibt mit diesem Sky-Cloud-Konto verbunden.",
create = "Profil erstellen", creating = "Wird erstellt...",
},
auth = {
eyebrow = "Dein SkyPic-Account", title = "Willkommen zurück",
body = "Fahre mit deinem iFruit-Account fort, um auf dein SkyPic-Profil, deine Snaps und Chats zuzugreifen.",
login = "Weiter zu SkyPic", loggingIn = "Wird angemeldet...",
noAccount = "Neu bei SkyPic? Erstelle einen iFruit-Account, um loszulegen.",
},
camera = {
eyebrow = "Sky-Kamera", title = "Aufnehmen",
body = "Nimm ein Foto oder Video auf und teile es für nur wenige Sekunden.",
@@ -572,7 +578,13 @@ Locales["de"] = {
conversations = "Unterhaltungen", noConversations = "Deine Unterhaltungen erscheinen hier.",
start = "Unterhaltung beginnen", message = "Nachricht", threadPlaceholder = "Nachricht schreiben...", messageLimit = "Nachrichten dürfen bis zu {count} Zeichen enthalten.",
saved = "Im Chat gespeichert", save = "Speichern", unsave = "Nicht mehr speichern", delete = "Löschen",
sendSnap = "Snap senden", sending = "Wird gesendet...", failed = "Nicht zugestellt",
sendSnap = "Snap senden", moreActions = "Weitere Aktionen",
attachPhoto = "Fotos anhängen", takePhoto = "Foto aufnehmen", emoji = "Emoji", attachVideo = "Video anhängen",
attachmentPreview = "Ausgewählte Anhänge", removeAttachment = "Anhang {number} entfernen",
moveAttachmentEarlier = "Anhang {number} nach vorne verschieben", moveAttachmentLater = "Anhang {number} nach hinten verschieben",
attachmentLimit = "Du kannst bis zu {count} Fotos anhängen.",
sendingAttachments = "Fotos werden gesendet...", photoAttachmentsSent = "Fotos gesendet.",
sending = "Wird gesendet...", failed = "Nicht zugestellt",
},
snaps = {
newVideo = "Neuer Video-Snap", newPhoto = "Neuer Foto-Snap", replayed = "Wiederholt",
@@ -606,6 +618,12 @@ Locales["de"] = {
showInQuickAdd = "In Schnell hinzufügen anzeigen",
showInQuickAddBody = "Andere Profile dürfen dich als Vorschlag entdecken.",
saved = "Profil aktualisiert.",
account = "Account", logout = "Abmelden", logoutTitle = "Von SkyPic abmelden?",
logoutBody = "Du wirst nur von SkyPic abgemeldet. Dein Profil, deine Snaps und Chats bleiben verfügbar.",
loggingOut = "Wird abgemeldet...", deleteAccount = "SkyPic-Account löschen",
deleteAccountTitle = "Deinen SkyPic-Account löschen?",
deleteAccountBody = "Dein SkyPic-Profil, deine Freunde, Snaps, Stories und Chats werden dauerhaft gelöscht. Dein iFruit-Account und deine Fotomediathek bleiben verfügbar.",
deletingAccount = "Account wird gelöscht...",
},
viewer = { close = "Schließen", timeLeft = "{count}s" },
notifications = {
@@ -644,6 +662,8 @@ Locales["de"] = {
story_limit_reached = "Du hast das Limit aktiver Stories erreicht.",
story_unavailable = "Diese Story ist nicht mehr verfügbar.",
not_authorized = "Du darfst das nicht tun.",
confirmation_required = "Bestätige, dass du deinen SkyPic-Account löschen möchtest.",
too_many_snaps = "Wähle weniger Fotos oder Empfänger.",
rate_limited = "Warte einen Moment und versuche es erneut.",
request_timeout = "Die SkyPic-Anfrage hat zu lange gedauert. Versuche es erneut.",
request_failed = "SkyPic konnte die Anfrage nicht abschließen.",
+21 -1
View File
@@ -547,6 +547,12 @@ Locales["en"] = {
accountBound = "Your SkyPic profile stays linked to this Sky Cloud account.",
create = "Create profile", creating = "Creating...",
},
auth = {
eyebrow = "Your SkyPic account", title = "Welcome back",
body = "Continue with your iFruit account to access your SkyPic profile, snaps, and chats.",
login = "Continue to SkyPic", loggingIn = "Signing in...",
noAccount = "New to SkyPic? Create an iFruit account to get started.",
},
camera = {
eyebrow = "Sky camera", title = "Capture",
body = "Take a photo or video and share it for just a few seconds.",
@@ -572,7 +578,13 @@ Locales["en"] = {
conversations = "Conversations", noConversations = "Your conversations will appear here.",
start = "Start a conversation", message = "Message", threadPlaceholder = "Write a message...", messageLimit = "Messages can contain up to {count} characters.",
saved = "Saved in chat", save = "Save", unsave = "Unsave", delete = "Delete",
sendSnap = "Send a snap", sending = "Sending...", failed = "Not delivered",
sendSnap = "Send a snap", moreActions = "More actions",
attachPhoto = "Attach photos", takePhoto = "Take photo", emoji = "Emoji", attachVideo = "Attach video",
attachmentPreview = "Selected attachments", removeAttachment = "Remove attachment {number}",
moveAttachmentEarlier = "Move attachment {number} earlier", moveAttachmentLater = "Move attachment {number} later",
attachmentLimit = "You can attach up to {count} photos.",
sendingAttachments = "Sending photos...", photoAttachmentsSent = "Photos sent.",
sending = "Sending...", failed = "Not delivered",
},
snaps = {
newVideo = "New video snap", newPhoto = "New photo snap", replayed = "Replayed",
@@ -606,6 +618,12 @@ Locales["en"] = {
showInQuickAdd = "Show in Quick Add",
showInQuickAddBody = "Let other profiles discover you as a suggestion.",
saved = "Profile updated.",
account = "Account", logout = "Sign out", logoutTitle = "Sign out of SkyPic?",
logoutBody = "You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.",
loggingOut = "Signing out...", deleteAccount = "Delete SkyPic account",
deleteAccountTitle = "Delete your SkyPic account?",
deleteAccountBody = "Your SkyPic profile, friends, snaps, stories, and chats will be permanently deleted. Your iFruit account and Photos library stay available.",
deletingAccount = "Deleting account...",
},
viewer = { close = "Close", timeLeft = "{count}s" },
notifications = {
@@ -644,6 +662,8 @@ Locales["en"] = {
story_limit_reached = "Your active story limit is reached.",
story_unavailable = "This story is no longer available.",
not_authorized = "You are not allowed to do that.",
confirmation_required = "Confirm that you want to delete your SkyPic account.",
too_many_snaps = "Choose fewer photos or recipients.",
rate_limited = "Slow down for a moment and try again.",
request_timeout = "The SkyPic request timed out. Try again.",
request_failed = "SkyPic could not complete the request.",
+5
View File
@@ -162,6 +162,7 @@ local server_callbacks = {
"picstagram:admin-resolve-report",
"skypic:bootstrap",
"skypic:create-profile",
"skypic:delete-account",
"skypic:update-profile",
"skypic:search",
"skypic:add-friend",
@@ -897,6 +898,10 @@ RegisterNetEvent("sky_phone:skypic:new", function(data)
SendNUIMessage({ type = "skypic:new", data = data })
end)
RegisterNetEvent("sky_phone:skypic:changed", function(data)
SendNUIMessage({ type = "skypic:changed", data = data })
end)
RegisterNetEvent("sky_phone:feather:new", function(data)
local feather_locale = locale.Nui.Apps.feather
local notification_text = feather_locale.notifications[data.kind] or feather_locale.notifications.default
+30 -2
View File
@@ -910,12 +910,16 @@ function SkyPhone.NotifyAccount(account_id, event_name, data)
end
end
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
function SkyPhone.NotifyAccountDevices(account_id, event_name, data, required_app_auth)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
SELECT d.`imei`, d.`device_name`, account.`email` AS `account_email`,
settings.`payload` AS `settings`, app_auth.`payload` AS `app_auth`
FROM `sky_phone_devices` d
JOIN `sky_phone_accounts` account ON account.`id` = d.`account_id`
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
LEFT JOIN `sky_phone_device_data` app_auth
ON app_auth.`device_imei` = d.`imei` AND app_auth.`namespace` = 'appAuth'
WHERE d.`account_id` = ?
]], { account_id })
local devices = {}
@@ -923,7 +927,31 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
devices[row.imei] = row
end
local function has_required_app_session(device)
if required_app_auth == nil then
return true
end
if type(required_app_auth) ~= 'string' or type(device.app_auth) ~= 'string' then
return false
end
local decoded, app_auth = pcall(json.decode, device.app_auth)
if not decoded or type(app_auth) ~= 'table' or app_auth.version ~= 1
or app_auth.accountEmail ~= device.account_email or type(app_auth.signedIn) ~= 'table'
then
return false
end
for _, app_id in ipairs(app_auth.signedIn) do
if app_id == required_app_auth then
return true
end
end
return false
end
local function notify_device(source, device)
if not has_required_app_session(device) then
return
end
local payload = {}
for key, value in pairs(data) do
payload[key] = value
+210 -16
View File
@@ -284,7 +284,7 @@ local function notify_profile(recipient_profile_id, actor, kind, snap_id)
actor = actor.display_name,
profileId = actor.profile_id,
snapId = snap_id,
})
}, 'skypic')
end
local function safe_snap_from_row(row, viewer_id)
@@ -718,6 +718,100 @@ Bridge.Callbacks.Register("sky_phone:skypic:create-profile", function(source, da
return { success = true, data = profile_from_row(created) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:delete-account", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_profile_delete", 3, 60) then
return { success = false, error = "rate_limited" }
end
local profile, account, error_response = require_profile(source)
if not profile then
return error_response
end
if type(data) ~= "table" or data.confirmed ~= true then
return { success = false, error = "confirmation_required" }
end
local affected_accounts = Bridge.Database.Query([[
SELECT DISTINCT affected.`account_id`
FROM (
SELECT peer.`account_id`
FROM `sky_phone_skypic_friendships` friendship
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE
WHEN friendship.`profile_a_id` = ? THEN friendship.`profile_b_id`
ELSE friendship.`profile_a_id`
END
AND peer.`status` = 'active'
WHERE friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?
UNION ALL
SELECT peer.`account_id`
FROM `sky_phone_skypic_blocks` block
JOIN `sky_phone_skypic_profiles` peer
ON peer.`id` = CASE
WHEN block.`blocker_profile_id` = ? THEN block.`blocked_profile_id`
ELSE block.`blocker_profile_id`
END
AND peer.`status` = 'active'
WHERE block.`blocker_profile_id` = ? OR block.`blocked_profile_id` = ?
) affected
]], {
profile.profile_id, profile.profile_id, profile.profile_id,
profile.profile_id, profile.profile_id, profile.profile_id,
})
local ok = Bridge.Database.Transaction({
{
-- Keep the peers' denormalized limit counter correct before the
-- friendship rows disappear through the profile cascade.
query = [[
UPDATE `sky_phone_skypic_profiles` peer
JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`status` = 'accepted'
AND (
(friendship.`profile_a_id` = ? AND peer.`id` = friendship.`profile_b_id`)
OR (friendship.`profile_b_id` = ? AND peer.`id` = friendship.`profile_a_id`)
)
SET peer.`friend_count` = IF(
peer.`friend_count` > 0, peer.`friend_count` - 1, 0
)
WHERE peer.`id` <> ?
]],
params = { profile.profile_id, profile.profile_id, profile.profile_id },
},
{
query = [[
DELETE FROM `sky_phone_skypic_profiles`
WHERE `id` = ? AND `account_id` = ? AND `status` = 'active'
]],
params = { profile.profile_id, account.id },
},
})
if not ok then
return { success = false, error = "request_failed" }
end
if Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_skypic_profiles` WHERE `id` = ? LIMIT 1",
{ profile.profile_id }
)[1] then
return { success = false, error = "request_failed" }
end
-- The generic media library belongs to the Sky account, not SkyPic. The
-- cascading profile delete intentionally removes only SkyPic rows.
SkyPhone.NotifyAccountDevices(account.id, "sky_phone:skypic:changed", {
reason = "account_deleted",
profileId = profile.profile_id,
}, 'skypic')
for _, affected in ipairs(affected_accounts) do
local affected_account_id = tonumber(affected.account_id)
if affected_account_id and affected_account_id ~= tonumber(account.id) then
SkyPhone.NotifyAccountDevices(affected_account_id, "sky_phone:skypic:changed", {
reason = "account_deleted",
profileId = profile.profile_id,
}, 'skypic')
end
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:skypic:update-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_profile", limit("ProfileActionsPerMinute", 10), 60) then
return { success = false, error = "rate_limited" }
@@ -1211,6 +1305,61 @@ local function editor_payload(source, data, allow_replay)
}
end
local function snap_editor_payloads(source, data)
if type(data) ~= "table" then
return nil, "invalid_request"
end
if data.mediaIds == nil then
local editor, editor_error = editor_payload(source, data, true)
return editor and { editor } or nil, editor_error
end
if data.mediaId ~= nil or (data.mediaType ~= nil and data.mediaType ~= "photo") then
return nil, "invalid_media"
end
if type(data.mediaIds) ~= "table" then
return nil, "invalid_media"
end
local count = #data.mediaIds
if count < 1 or count > limit("MaximumMediaPerSend", 10) then
return nil, "invalid_media"
end
local key_count = 0
for key in pairs(data.mediaIds) do
if type(key) ~= "number" or key ~= math.floor(key) or key < 1 or key > count then
return nil, "invalid_media"
end
key_count = key_count + 1
end
if key_count ~= count then
return nil, "invalid_media"
end
local seen = {}
local editors = {}
for index = 1, count do
local media_id = valid_integer(data.mediaIds[index], 1, 9007199254740991)
if not media_id or seen[media_id] then
return nil, "invalid_media"
end
seen[media_id] = true
local editor, editor_error = editor_payload(source, {
mediaId = media_id,
mediaType = "photo",
durationSeconds = data.durationSeconds,
caption = data.caption,
textOverlay = data.textOverlay,
overlayColor = data.overlayColor,
allowReplay = data.allowReplay,
}, true)
if not editor then
return nil, editor_error
end
editors[#editors + 1] = editor
end
return editors
end
Bridge.Callbacks.Register("sky_phone:skypic:thread", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
@@ -1430,6 +1579,16 @@ local function recipient_ids(value)
if count < 1 or count > limit("MaximumSnapRecipients", 20) then
return nil
end
local key_count = 0
for key in pairs(value) do
if type(key) ~= "number" or key ~= math.floor(key) or key < 1 or key > count then
return nil
end
key_count = key_count + 1
end
if key_count ~= count then
return nil
end
local seen = {}
local ids = {}
for index = 1, count do
@@ -1465,10 +1624,17 @@ local function load_snap_metadata(message_ids, viewer_id)
WHERE message.`id` IN (%s) AND message.`message_type` IN ('snap_photo','snap_video')
ORDER BY message.`created_at`, message.`id`
]]):format(table.concat(placeholders, ",")), params)
local snaps = {}
local snaps_by_id = {}
for _, row in ipairs(rows) do
snaps[#snaps + 1] = safe_snap_from_row(row, viewer_id)
snaps[#snaps].recipientProfileId = row.recipient_profile_id
local snap = safe_snap_from_row(row, viewer_id)
snap.recipientProfileId = row.recipient_profile_id
snaps_by_id[row.id] = snap
end
local snaps = {}
for _, message_id in ipairs(message_ids) do
if snaps_by_id[message_id] then
snaps[#snaps + 1] = snaps_by_id[message_id]
end
end
return snaps
end
@@ -1521,15 +1687,35 @@ Bridge.Callbacks.Register("sky_phone:skypic:send-snap", function(source, data)
if not profile then
return error_response
end
local editor, editor_error = editor_payload(source, data, true)
if not editor then
return { success = false, error = editor_error }
end
local recipients = recipient_ids(data.recipientIds)
local recipients = recipient_ids(type(data) == "table" and data.recipientIds or nil)
if not recipients then
return { success = false, error = "invalid_recipients" }
end
for _ = 1, #recipients do
if type(data) == "table" and data.mediaIds ~= nil then
local raw_media_count = type(data.mediaIds) == "table" and #data.mediaIds or 0
if raw_media_count < 1 or raw_media_count > limit("MaximumMediaPerSend", 10) then
return { success = false, error = "invalid_media" }
end
end
local requested_media_count = type(data) == "table"
and type(data.mediaIds) == "table" and #data.mediaIds or 1
if requested_media_count * #recipients > limit("MaximumSnapMessagesPerSend", 40) then
return { success = false, error = "too_many_snaps" }
end
local editors, editor_error = snap_editor_payloads(source, data)
if not editors then
return { success = false, error = editor_error }
end
local message_count = #editors * #recipients
if message_count > limit("MaximumSnapMessagesPerSend", 40) then
return { success = false, error = "too_many_snaps" }
end
for _ = 2, #editors do
if not SkyPhone.AllowOperation(source, "skypic_snap", limit("SnapsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
end
for _ = 1, message_count do
if not SkyPhone.AllowOperation(
source,
"skypic_snap_recipient",
@@ -1551,14 +1737,18 @@ Bridge.Callbacks.Register("sky_phone:skypic:send-snap", function(source, data)
if are_blocked(profile.profile_id, target_id) then
return { success = false, error = "blocked" }
end
entries[#entries + 1] = {
id = new_id(),
targetId = target_id,
friendship = friendship,
}
for _, editor in ipairs(editors) do
entries[#entries + 1] = {
id = new_id(),
targetId = target_id,
friendship = friendship,
editor = editor,
}
end
end
local statements = {}
for _, entry in ipairs(entries) do
local editor = entry.editor
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_skypic_messages`
@@ -1652,11 +1842,15 @@ Bridge.Callbacks.Register("sky_phone:skypic:send-snap", function(source, data)
UPDATE `sky_phone_skypic_profiles` SET `snap_score` = `snap_score` + ? WHERE `id` = ?
]], { #sent, profile.profile_id })
local sender_score = (tonumber(profile.snap_score) or 0) + #sent
local notified_targets = {}
for _, snap in ipairs(sent) do
local target_id = snap.recipientProfileId
snap.recipientProfileId = nil
snap.sender.snapScore = sender_score
notify_profile(target_id, profile, "snap", snap.id)
if not notified_targets[target_id] then
notified_targets[target_id] = true
notify_profile(target_id, profile, "snap", snap.id)
end
end
return { success = true, data = sent }
end)