mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
feat(skypic): add account controls and media batches
This commit is contained in:
+55
-5
@@ -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' &&
|
||||
|
||||
@@ -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' })",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -505,6 +505,8 @@ Config.SkyPic = {
|
||||
MinimumViewSeconds = 1,
|
||||
MaximumViewSeconds = 10,
|
||||
MaximumSnapRecipients = 20,
|
||||
MaximumMediaPerSend = 10,
|
||||
MaximumSnapMessagesPerSend = 40,
|
||||
MaximumFriends = 500,
|
||||
MaximumPendingRequests = 100,
|
||||
MaximumActiveStories = 50,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user