diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 921601b..ae7a63e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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 { + 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 { const tasks: Array<() => Promise | void> = [ - () => (account.email ? skypic.bootstrap() : skypic.resetSession()), + () => refreshSkyPicState(), () => calls.bootstrap(), () => messages.loadConversations(), () => billing.loadOverview(), @@ -927,6 +963,7 @@ function onMessage(event: MessageEvent): 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): 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' && diff --git a/frontend/src/skypic.backend.contract.test.ts b/frontend/src/skypic.backend.contract.test.ts index b013b1f..e4e9f76 100644 --- a/frontend/src/skypic.backend.contract.test.ts +++ b/frontend/src/skypic.backend.contract.test.ts @@ -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', () => { diff --git a/frontend/src/skypic.integration.contract.test.ts b/frontend/src/skypic.integration.contract.test.ts index 4d666ed..cc7c97f 100644 --- a/frontend/src/skypic.integration.contract.test.ts +++ b/frontend/src/skypic.integration.contract.test.ts @@ -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' })", diff --git a/frontend/src/stores/app-auth.test.ts b/frontend/src/stores/app-auth.test.ts index b275e8f..555a6ca 100644 --- a/frontend/src/stores/app-auth.test.ts +++ b/frontend/src/stores/app-auth.test.ts @@ -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( diff --git a/frontend/src/stores/app-auth.ts b/frontend/src/stores/app-auth.ts index 753bdaa..b4fa47f 100644 --- a/frontend/src/stores/app-auth.ts +++ b/frontend/src/stores/app-auth.ts @@ -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 { 'local-pages': false, feather: false, crewlink: false, + skypic: false, } } diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts index 57f85ba..d828289 100644 --- a/frontend/src/stores/phone-locales.test.ts +++ b/frontend/src/stores/phone-locales.test.ts @@ -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', diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 5e37bde..fb99a62 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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.', diff --git a/frontend/src/stores/skypic.test.ts b/frontend/src/stores/skypic.test.ts index 6f1ad37..9da59d7 100644 --- a/frontend/src/stores/skypic.test.ts +++ b/frontend/src/stores/skypic.test.ts @@ -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>() + 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 }, diff --git a/frontend/src/stores/skypic.ts b/frontend/src/stores/skypic.ts index 1616f8b..7348483 100644 --- a/frontend/src/stores/skypic.ts +++ b/frontend/src/stores/skypic.ts @@ -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(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 { + 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( '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( 'skypic:update-profile', @@ -276,6 +298,22 @@ export const useSkyPicStore = defineStore('skypic', () => { return response } + async function deleteAccount(): Promise { + 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 { const normalized = query.trim() const requestId = ++searchRequest @@ -520,8 +558,8 @@ export const useSkyPicStore = defineStore('skypic', () => { async function sendSnap( input: SkyPicSendSnapInput, ): Promise> { - 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( '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, diff --git a/frontend/src/types/skypic.ts b/frontend/src/types/skypic.ts index 8f3bd03..2e2d9a0 100644 --- a/frontend/src/types/skypic.ts +++ b/frontend/src/types/skypic.ts @@ -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 diff --git a/frontend/src/views/apps/SkyPicApp.contract.test.ts b/frontend/src/views/apps/SkyPicApp.contract.test.ts index 2f71cc3..4eae695 100644 --- a/frontend/src/views/apps/SkyPicApp.contract.test.ts +++ b/frontend/src/views/apps/SkyPicApp.contract.test.ts @@ -30,6 +30,28 @@ describe('SkyPic frontend contract', () => { expect(viewSource.match(/ { + 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', + ) + 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(' { + 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 {')[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 {')[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', diff --git a/frontend/src/views/apps/SkyPicApp.vue b/frontend/src/views/apps/SkyPicApp.vue index 3786409..fba851f 100644 --- a/frontend/src/views/apps/SkyPicApp.vue +++ b/frontend/src/views/apps/SkyPicApp.vue @@ -4,10 +4,12 @@ import { Camera, Check, ChevronLeft, + ChevronRight, CirclePlay, Eye, Image as ImageIcon, Images, + LogOut, MessageCircle, Palette, Plus, @@ -34,9 +36,21 @@ import { } from 'vue' import { useRoute, useRouter, type LocationQuery } from 'vue-router' -import { useMessageMediaStore } from '@/stores/messageMedia' +import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue' +import AppProfileAuth from '@/components/account/AppProfileAuth.vue' +import FullEmojiPicker from '@/components/FullEmojiPicker.vue' +import { useAccountStore } from '@/stores/account' +import { useAppAuthStore } from '@/stores/app-auth' +import { + useMessageMediaStore, + type MediaSelectionResult, +} from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' -import { useSkyPicStore } from '@/stores/skypic' +import { + isValidSkyPicHandle, + normalizeSkyPicHandle, + useSkyPicStore, +} from '@/stores/skypic' import type { MediaType, PhoneMedia } from '@/types/media' import type { SkyPicDraftPurpose, @@ -48,14 +62,18 @@ import type { SkyPicSnap, SkyPicStory, SkyPicStoryPrivacy, + SkyPicThreadMediaDraftContext, } from '@/types/skypic' import { SkyAppPage, SkyBadge, SkyButton, SkyCheckbox, + SkyDialog, + SkyDialogButton, SkyEmptyState, SkyField, + SkyGlass, SkyMessage, SkyMessagebar, SkyMessages, @@ -79,14 +97,22 @@ type ViewerKind = 'snap' | 'story' type ThreadEntry = | { createdAt: string; id: string; kind: 'message'; value: SkyPicMessage } | { createdAt: string; id: string; kind: 'snap'; value: SkyPicSnap } +type SkyPicAuthMediaContext = { + handle: string + mode: 'login' | 'register' + selectedPhoto: PhoneMedia | null +} const MAX_CAPTION_LENGTH = 160 const MAX_MESSAGE_CHARACTERS = 2_000 const MAX_SEARCH_CHARACTERS = 64 const MAX_SNAP_RECIPIENTS = 20 +const MAX_THREAD_ATTACHMENTS = 10 const MAX_TEXT_OVERLAY_LENGTH = 160 const phone = usePhoneStore() +const account = useAccountStore() +const appAuth = useAppAuthStore() const store = useSkyPicStore() const mediaPicker = useMessageMediaStore() const route = useRoute() @@ -104,6 +130,12 @@ const durationSeconds = ref(5) const allowReplay = ref(true) const selectedRecipientIds = ref([]) const publishing = ref(false) +const authMode = ref<'login' | 'register'>('register') +const authHandle = ref('') +const authPhoto = ref(null) +const authSubmitting = ref(false) +const authError = ref('') +const hasSkyPicAccount = ref(false) const onboarding = reactive({ avatarSeed: Math.floor(Math.random() * 360) + 1, @@ -125,6 +157,13 @@ const profileSaving = ref(false) const searchQuery = ref('') const highlightedProfileId = ref('') const chatBody = ref('') +const pendingThreadMedia = ref([]) +const threadAttachmentMenuOpen = ref(false) +const threadEmojiOpen = ref(false) +const threadSending = ref(false) +const logoutDialogOpen = ref(false) +const deleteAccountDialogOpen = ref(false) +const accountActionPending = ref(false) const storyReply = ref('') const feedback = ref('') const storyViewerSheetOpen = ref(false) @@ -153,7 +192,15 @@ const isDarkPage = computed( activeTab.value === 'camera' || Boolean(composerMedia.value) || Boolean(store.openedSnap) || - Boolean(store.viewedStory), + Boolean(store.viewedStory) || + phone.isDarkMode, +) +const isAuthenticated = computed(() => appAuth.isSignedIn('skypic')) +const authSubmitEnabled = computed( + () => Boolean(account.email) && isValidSkyPicHandle(authHandle.value), +) +const threadComposerHasContent = computed( + () => Boolean(chatBody.value.trim()) || pendingThreadMedia.value.length > 0, ) const incomingSnaps = computed(() => store.inbox.filter((snap) => snap.direction === 'received'), @@ -279,8 +326,115 @@ function toggleProfileEditor(): void { if (profileEditing.value) syncProfileDraft() } +function switchAuthMode(mode: 'login' | 'register'): void { + authMode.value = mode + authError.value = '' + authPhoto.value = null +} + +function displayNameFromHandle(handle: string): string { + const displayName = handle + .split(/[._]+/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') + return Array.from(displayName || handle) + .slice(0, 40) + .join('') +} + +function openAuthMedia(source: MediaSource): void { + mediaPicker.begin( + 'skypic-auth-avatar', + 'photo', + `/apps/skypic?auth=${authMode.value}`, + 1, + { + handle: authHandle.value, + mode: authMode.value, + selectedPhoto: authPhoto.value, + } satisfies SkyPicAuthMediaContext, + ) + void router.push({ + path: `/apps/${source}`, + query: { mediaAttachment: 'photo' }, + }) +} + +function consumeAuthMediaDraft(): boolean { + const selection = + mediaPicker.consumeMany('skypic-auth-avatar') + if (!selection) return false + authMode.value = selection.context?.mode ?? authMode.value + authHandle.value = selection.context?.handle ?? authHandle.value + authPhoto.value = + selection.media[0] ?? selection.context?.selectedPhoto ?? null + return true +} + +async function submitAuthentication(): Promise { + if (authSubmitting.value) return + authError.value = '' + const handle = normalizeSkyPicHandle(authHandle.value) + if (!account.email) { + authError.value = errorText('not_authenticated') + return + } + if (!isValidSkyPicHandle(handle)) { + authError.value = errorText('invalid_handle') + return + } + + authSubmitting.value = true + if (authMode.value === 'login') { + const loaded = await store.bootstrap() + authSubmitting.value = false + if (!loaded || !store.profile || store.profile.handle !== handle) { + authError.value = errorText('profile_not_found') + hasSkyPicAccount.value = Boolean(store.profile) + store.resetSession() + return + } + hasSkyPicAccount.value = true + appAuth.signIn('skypic', account.email) + authHandle.value = '' + authPhoto.value = null + syncProfileDraft() + await applyRouteQuery(route.query) + return + } + + if (hasSkyPicAccount.value || store.profile) { + authSubmitting.value = false + authError.value = errorText('profile_exists') + return + } + const response = await store.createProfile({ + ...(authPhoto.value ? { avatarMediaId: authPhoto.value.id } : {}), + avatarSeed: onboarding.avatarSeed, + displayName: displayNameFromHandle(handle), + handle, + }) + authSubmitting.value = false + if (!response.success) { + authError.value = errorText(response.error) + return + } + appAuth.signIn('skypic', account.email) + hasSkyPicAccount.value = true + authHandle.value = '' + authPhoto.value = null + syncProfileDraft() + await store.bootstrap() +} + async function submitOnboarding(): Promise { - if (!onboarding.handle.trim() || !onboarding.displayName.trim()) { + const handle = normalizeSkyPicHandle(onboarding.handle) + if (!isValidSkyPicHandle(handle)) { + notify(errorText('invalid_handle')) + return + } + if (!onboarding.displayName.trim()) { notify(t('errors.profile_required')) return } @@ -288,17 +442,78 @@ async function submitOnboarding(): Promise { const response = await store.createProfile({ avatarSeed: onboarding.avatarSeed, displayName: onboarding.displayName, - handle: onboarding.handle, + handle, }) onboardingSubmitting.value = false if (!response.success) { notify(errorText(response.error)) return } + appAuth.signIn('skypic', account.email) await store.bootstrap() syncProfileDraft() } +function resetAccountUiState(accountExists: boolean): void { + threadNavigationRequest += 1 + storyNavigationRequest += 1 + storyViewerRequest += 1 + mediaPlayRequest += 1 + clearSnapTimer() + clearStoryTimer() + profileEditing.value = false + profileSaving.value = false + logoutDialogOpen.value = false + deleteAccountDialogOpen.value = false + authMode.value = accountExists ? 'login' : 'register' + authHandle.value = '' + authPhoto.value = null + authError.value = '' + pendingThreadMedia.value = [] + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = false + threadSending.value = false + chatBody.value = '' + searchQuery.value = '' + highlightedProfileId.value = '' + selectedRecipientIds.value = [] + storyReply.value = '' + storyViewerSheetOpen.value = false + storyViewerSheetStoryId.value = '' + activeViewerKind.value = null + mediaLoading.value = false + mediaError.value = false + snapRemaining.value = 0 + snapProgress.value = 100 + storyRemaining.value = 0 + storyProgress.value = 100 + resetComposer() + store.closeThread() + store.clearOpenedSnap() + store.clearViewedStory() + hasSkyPicAccount.value = accountExists + activeTab.value = 'camera' +} + +function handleLoggedOut(): void { + resetAccountUiState(true) + store.resetSession() +} + +async function deleteSkyPicAccount(): Promise { + if (accountActionPending.value) return + accountActionPending.value = true + const deleted = await store.deleteAccount() + accountActionPending.value = false + if (!deleted) { + notify(errorText(store.error ?? undefined)) + return + } + appAuth.signOut('skypic') + resetAccountUiState(false) + await router.replace({ path: '/apps/skypic', query: { tab: 'camera' } }) +} + async function saveProfile(): Promise { if (!store.profile || profileSaving.value) return profileSaving.value = true @@ -364,6 +579,132 @@ function consumeMediaDraft(): boolean { return true } +function openThreadMedia(source: MediaSource, mediaType: MediaType): void { + const conversation = store.activeConversation + if (!conversation) return + const hasVideo = pendingThreadMedia.value.some( + (media) => media.mediaType === 'video', + ) + if ( + (mediaType === 'video' && pendingThreadMedia.value.length > 0) || + (mediaType === 'photo' && hasVideo) + ) { + notify( + t('chats.attachmentLimit', { + count: String(MAX_THREAD_ATTACHMENTS), + }), + ) + return + } + const remainingSlots = + mediaType === 'photo' + ? MAX_THREAD_ATTACHMENTS - pendingThreadMedia.value.length + : 1 + if (remainingSlots < 1) { + notify( + t('chats.attachmentLimit', { + count: String(MAX_THREAD_ATTACHMENTS), + }), + ) + return + } + + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = false + mediaPicker.begin( + 'skypic-thread-media', + mediaType, + `/apps/skypic?tab=chats&friendship=${encodeURIComponent(conversation.friendshipId)}`, + source === 'photos' && mediaType === 'photo' ? remainingSlots : 1, + { + body: chatBody.value, + friendshipId: conversation.friendshipId, + pendingMedia: [...pendingThreadMedia.value], + } satisfies SkyPicThreadMediaDraftContext, + ) + void router.push({ + path: `/apps/${source}`, + query: { mediaAttachment: mediaType }, + }) +} + +function restoreThreadMediaSelection( + selection: MediaSelectionResult | null, +): boolean { + if (!selection) return false + const context = selection.context + if ( + !context?.friendshipId || + !store.friends.some( + (friend) => friend.friendshipId === context.friendshipId, + ) + ) { + pendingThreadMedia.value = [] + return false + } + chatBody.value = Array.from(context.body ?? '') + .slice(0, MAX_MESSAGE_CHARACTERS) + .join('') + const selectedVideo = selection.media.find( + (media) => media.mediaType === 'video', + ) + if (selectedVideo) { + pendingThreadMedia.value = [selectedVideo] + return true + } + + const seen = new Set() + pendingThreadMedia.value = [ + ...(context.pendingMedia ?? []), + ...selection.media, + ] + .filter((media) => { + if (media.mediaType !== 'photo' || seen.has(media.id)) return false + seen.add(media.id) + return true + }) + .slice(0, MAX_THREAD_ATTACHMENTS) + return true +} + +function consumeThreadMediaDraft(): boolean { + return restoreThreadMediaSelection( + mediaPicker.consumeMany( + 'skypic-thread-media', + ), + ) +} + +function toggleThreadAttachmentMenu(): void { + threadAttachmentMenuOpen.value = !threadAttachmentMenuOpen.value + threadEmojiOpen.value = false +} + +function openThreadEmojiPicker(): void { + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = true +} + +function appendThreadEmoji(emoji: string): void { + updateChatBody(`${chatBody.value}${emoji}`) +} + +function removeThreadMedia(mediaId: number): void { + if (threadSending.value) return + pendingThreadMedia.value = pendingThreadMedia.value.filter( + (media) => media.id !== mediaId, + ) +} + +function moveThreadMedia(index: number, direction: -1 | 1): void { + if (threadSending.value) return + const target = index + direction + if (target < 0 || target >= pendingThreadMedia.value.length) return + const ordered = [...pendingThreadMedia.value] + ;[ordered[index], ordered[target]] = [ordered[target], ordered[index]] + pendingThreadMedia.value = ordered +} + function resetComposer(): void { composerMedia.value = null caption.value = '' @@ -482,6 +823,10 @@ async function setTab(next: Tab): Promise { (store.activeFriendshipId || store.threadLoading) ) { store.closeThread() + chatBody.value = '' + pendingThreadMedia.value = [] + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = false } activeTab.value = next if (next === 'stories') await store.loadStories() @@ -544,20 +889,72 @@ function closeThread(): void { threadNavigationRequest += 1 store.closeThread() chatBody.value = '' + pendingThreadMedia.value = [] + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = false void router.replace({ path: '/apps/skypic', query: { tab: 'chats' } }) } async function submitMessage(): Promise { const friendshipId = store.activeFriendshipId + const recipientId = store.activeConversation?.profile.id const body = boundedMessage(chatBody.value, 'chats.messageLimit') chatBody.value = body - if (!friendshipId || !body.trim()) return - const response = await store.sendMessage(friendshipId, body) - if (!response.success) { - notify(errorText(response.error)) + if ( + !friendshipId || + !recipientId || + !threadComposerHasContent.value || + threadSending.value + ) { return } - chatBody.value = '' + threadSending.value = true + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = false + + const queuedMedia = [...pendingThreadMedia.value] + if (queuedMedia.length) { + const video = queuedMedia.length === 1 ? queuedMedia[0] : null + const response = + video?.mediaType === 'video' + ? await store.sendSnap({ + allowReplay: true, + caption: '', + durationSeconds: 5, + mediaId: video.id, + mediaType: 'video', + overlayColor: '#ffffff', + recipientIds: [recipientId], + textOverlay: '', + }) + : await store.sendSnap({ + allowReplay: true, + caption: '', + durationSeconds: 5, + mediaIds: queuedMedia.map((media) => media.id), + overlayColor: '#ffffff', + recipientIds: [recipientId], + textOverlay: '', + }) + if (!response.success) { + threadSending.value = false + notify(errorText(response.error)) + return + } + pendingThreadMedia.value = [] + notify(t('chats.photoAttachmentsSent')) + } + + if (body.trim()) { + const response = await store.sendMessage(friendshipId, body) + if (!response.success) { + threadSending.value = false + notify(errorText(response.error)) + return + } + chatBody.value = '' + } + threadSending.value = false } function messageKeydown(event: KeyboardEvent): void { @@ -967,6 +1364,8 @@ async function applyRouteQuery(query: LocationQuery): Promise { activeTab.value = requestedTab } + consumeAuthMediaDraft() + consumeThreadMediaDraft() if (queryValue(query.compose)) consumeMediaDraft() const profileId = queryValue(query.profileId) highlightedProfileId.value = profileId @@ -978,6 +1377,9 @@ async function applyRouteQuery(query: LocationQuery): Promise { if (!requestedFriendship && store.activeFriendshipId) { store.closeThread() chatBody.value = '' + pendingThreadMedia.value = [] + threadAttachmentMenuOpen.value = false + threadEmojiOpen.value = false } if (requestedFriendship && requestedFriendship !== store.activeFriendshipId) { const opened = await store.openThread(requestedFriendship) @@ -1008,6 +1410,15 @@ async function applyRouteQuery(query: LocationQuery): Promise { } watch(searchQuery, scheduleSearch) +watch( + () => store.profileAbsentRevision, + () => { + if (store.profile) return + if (appAuth.isSignedIn('skypic')) appAuth.signOut('skypic') + resetAccountUiState(false) + void router.replace({ path: '/apps/skypic', query: { tab: 'camera' } }) + }, +) watch( () => store.openedSnap, (snap) => { @@ -1038,12 +1449,26 @@ async function bootstrapApp(): Promise { bootstrapped.value = false bootstrapFailed.value = false store.resetSession() + if (!account.email) { + resetAccountUiState(false) + bootstrapped.value = true + return + } const loaded = await store.bootstrap() if (!appMounted) return if (!loaded) { bootstrapFailed.value = true return } + hasSkyPicAccount.value = Boolean(store.profile) + if (!appAuth.isSignedIn('skypic')) { + authMode.value = hasSkyPicAccount.value ? 'login' : 'register' + store.resetSession() + } else if (!store.profile) { + appAuth.signOut('skypic') + authMode.value = 'register' + store.resetSession() + } bootstrapped.value = true syncProfileDraft() await applyRouteQuery(route.query) @@ -1077,6 +1502,10 @@ onBeforeUnmount(() => { :dark="isDarkPage" :label="t('name')" class="skypic-app" + :class="{ + 'skypic-app--player-dark': phone.isDarkMode, + 'skypic-app--player-light': !phone.isDarkMode, + }" >
@@ -1089,6 +1518,38 @@ onBeforeUnmount(() => {
+
+ +

+ {{ t('auth.noAccount') }} +

+
+ { { { { + + + + +

{{ t('profile.deleteAccountBody') }}

+ +
+ { .skypic-app { --sp-accent: #5a6cff; --sp-accent-strong: #4254f2; - --sp-cyan: #42e8ff; + --sp-camera-blue: #0a84ff; + --sp-camera-blue-strong: #0067d8; --sp-pink: #ff5bbd; position: relative; display: flex; @@ -2522,6 +3209,36 @@ onBeforeUnmount(() => { flex-direction: column; } +.skypic-app--player-light { + --sp-tab-monochrome: #000000; +} + +.skypic-app--player-dark { + --sp-tab-monochrome: #ffffff; +} + +.sp-auth { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: calc(var(--sky-safe-area-top) + var(--sky-space-3)) + var(--sky-page-gutter) + calc(var(--sky-safe-area-bottom) + var(--sky-space-5)); +} + +.sp-auth__hint { + max-width: 290px; + margin: 0 auto; + color: var(--sky-muted); + font-size: 12px; + line-height: 1.45; + text-align: center; +} + +.sp-auth :deep(.app-profile-auth) { + --auth-accent: #0a84ff; +} + .sp-loading { display: grid; flex: 1; @@ -2877,6 +3594,8 @@ input:focus-visible { } .sp-camera-screen { + --sky-app-accent: var(--sp-camera-blue); + --sky-app-accent-soft: rgba(10, 132, 255, 0.2); display: flex; flex: 1; min-height: 0; @@ -2887,15 +3606,15 @@ input:focus-visible { background: radial-gradient( circle at 12% 14%, - rgba(84, 102, 255, 0.34), - transparent 36% + rgba(37, 149, 255, 0.44), + transparent 38% ), radial-gradient( circle at 88% 58%, - rgba(255, 91, 189, 0.25), - transparent 34% + rgba(10, 132, 255, 0.24), + transparent 36% ), - linear-gradient(165deg, #10152a 0%, #151b38 50%, #0e1120 100%); + linear-gradient(165deg, #07172d 0%, #0b2444 50%, #061326 100%); color: white; } @@ -2945,7 +3664,7 @@ input:focus-visible { width: 180px; height: 180px; border-radius: 50%; - background: rgba(90, 108, 255, 0.34); + background: rgba(10, 132, 255, 0.38); filter: blur(40px); } @@ -2957,8 +3676,8 @@ input:focus-visible { .sp-camera-preview > svg { margin-bottom: var(--sky-space-4); - color: var(--sp-cyan); - filter: drop-shadow(0 7px 18px rgba(66, 232, 255, 0.35)); + color: #55b4ff; + filter: drop-shadow(0 7px 18px rgba(10, 132, 255, 0.42)); } .sp-camera-preview p { @@ -3019,7 +3738,11 @@ input:focus-visible { height: 58px; place-items: center; border-radius: 50%; - background: linear-gradient(145deg, var(--sp-accent), var(--sp-pink)); + background: linear-gradient( + 145deg, + var(--sp-camera-blue), + var(--sp-camera-blue-strong) + ); } .sp-snap-strip { @@ -3151,6 +3874,178 @@ input:focus-visible { opacity: 0.58; } +.sp-thread-attachment-menu { + position: absolute; + z-index: 46; + bottom: calc(var(--sky-safe-area-bottom) + 68px); + left: var(--sky-page-gutter); + display: grid; + justify-items: start; + gap: 8px; +} + +.sp-thread-attachment-menu--with-preview { + bottom: calc(var(--sky-safe-area-bottom) + 188px); +} + +.sp-thread-attachment-menu :deep(.sky-glass) { + width: auto; + min-width: 154px; + min-height: 48px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 9px; + padding: 5px 14px 5px 7px; + border-radius: var(--sky-radius-pill); + background: color-mix(in srgb, var(--sky-glass-solid) 88%, transparent); + color: var(--sky-text); + font-size: 13px; + font-weight: 750; + backdrop-filter: blur(22px) saturate(1.25); +} + +.sp-thread-attachment-menu__icon, +.sp-thread-attachment-menu__emoji { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 36px; + place-items: center; + border-radius: 50%; + background: var(--sky-surface); +} + +.sp-thread-attachment-menu__icon.is-photo { + color: #0a84ff; +} + +.sp-thread-attachment-menu__icon.is-camera { + color: #30c76c; +} + +.sp-thread-attachment-menu__icon.is-video { + color: #0a84ff; +} + +.sp-thread-attachment-menu__emoji { + font-size: 21px; +} + +.sp-thread-composer { + position: relative; + z-index: 42; + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: var(--sky-space-2); + padding: 6px var(--sky-page-gutter) calc(var(--sky-safe-area-bottom) + 6px); + border-top: 1px solid var(--sky-hairline); + background: var(--sky-bg); +} + +.sp-thread-media-preview { + display: flex; + gap: var(--sky-space-2); + overflow-x: auto; + padding: 4px 3px 2px; + scrollbar-width: none; +} + +.sp-thread-media-preview::-webkit-scrollbar { + display: none; +} + +.sp-thread-media-preview__item { + position: relative; + width: 104px; + height: 104px; + flex: 0 0 104px; + border-radius: var(--sky-radius-card); + background: var(--sky-surface-muted); +} + +.sp-thread-media-preview__item > img, +.sp-thread-media-preview__item > video { + width: 100%; + height: 100%; + display: block; + overflow: hidden; + border-radius: inherit; + object-fit: cover; +} + +.sp-thread-media-preview__remove, +.sp-thread-media-preview__order button { + display: grid; + place-items: center; + border: 1px solid var(--sky-bg); + border-radius: 50%; + background: var(--sky-text); + color: var(--sky-bg); +} + +.sp-thread-media-preview__remove { + position: absolute; + top: -4px; + right: -4px; + width: var(--sky-touch-target); + height: var(--sky-touch-target); +} + +.sp-thread-media-preview__order { + position: absolute; + right: 4px; + bottom: 4px; + left: 4px; + display: flex; + justify-content: space-between; +} + +.sp-thread-media-preview__order button { + width: var(--sky-touch-target); + height: var(--sky-touch-target); + border-color: rgba(255, 255, 255, 0.52); + background: rgba(0, 0, 0, 0.64); + color: #fff; + backdrop-filter: blur(8px); +} + +.sp-thread-media-preview__order button:disabled { + visibility: hidden; +} + +.sp-thread-messagebar { + min-width: 0; + padding: 0; + background: transparent; +} + +.sp-thread-messagebar :deep(.sky-toolbar__inner) { + gap: var(--sky-space-2); +} + +.sp-thread-messagebar :deep(.sky-messagebar__area) { + min-height: 48px; + border-radius: var(--sky-radius-pill); +} + +.sp-thread-plus { + width: 46px; + height: 46px; + display: grid; + padding: 0; + place-items: center; + border-radius: 50%; + color: var(--sky-text); + transition: transform var(--sky-transition-normal) ease; +} + +.sp-thread-plus.is-active { + color: #0a84ff; + transform: rotate(45deg); +} + .sp-send-button { background: var(--sky-app-accent); color: white; @@ -3341,6 +4236,44 @@ input:focus-visible { background: var(--sky-surface); } +.sp-account-settings { + display: grid; + gap: var(--sky-space-2); + min-width: 0; + margin: 0; + padding: var(--sky-space-3); + border: 1px solid var(--sky-hairline); + border-radius: var(--sky-radius-control); +} + +.sp-account-settings legend { + padding: 0 5px; + color: var(--sky-muted); + font-size: 11px; + font-weight: 750; +} + +.sp-account-settings button { + display: flex; + align-items: center; + gap: var(--sky-space-2); + min-height: var(--sky-touch-target); + padding: 0 12px; + border: 0; + border-radius: var(--sky-radius-control); + background: var(--sky-surface-variant); + color: var(--sky-text); + font: inherit; + font-size: 13px; + font-weight: 700; + text-align: left; +} + +.sp-account-settings__danger, +.sp-delete-account-confirm { + color: var(--sky-danger, #ff3b30) !important; +} + .sp-settings-label { display: grid; gap: var(--sky-space-2); @@ -3405,6 +4338,26 @@ input:focus-visible { gap: 2px; } +:deep(.sp-tab) { + transition: + color var(--sky-transition-normal) ease, + opacity var(--sky-transition-normal) ease; +} + +:deep(.sp-tab:not(.sky-tab-button--active)) { + opacity: 0.58; +} + +:deep(.sp-tab--camera), +:deep(.sp-tab--camera.sky-tab-button--active) { + color: var(--sp-camera-blue) !important; +} + +:deep(.sp-tab--monochrome), +:deep(.sp-tab--monochrome.sky-tab-button--active) { + color: var(--sp-tab-monochrome) !important; +} + .sp-tab-icon { position: relative; display: inline-grid; @@ -3596,6 +4549,11 @@ input:focus-visible { transition: none; } + .sp-thread-plus, + :deep(.sp-tab) { + transition: none; + } + .sp-camera-preview__glow { filter: none; } diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index d9824d5..df388f7 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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 } diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index cd0875a..a9eea7b 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -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) { diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index bfab6f2..ae142e0 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -505,6 +505,8 @@ Config.SkyPic = { MinimumViewSeconds = 1, MaximumViewSeconds = 10, MaximumSnapRecipients = 20, + MaximumMediaPerSend = 10, + MaximumSnapMessagesPerSend = 40, MaximumFriends = 500, MaximumPendingRequests = 100, MaximumActiveStories = 50, diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index d3d8a0f..04329a3 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -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.", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 580f6a5..f17dc5d 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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.", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index fded633..d9f49e5 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -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 diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index b74c58b..562c17b 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -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 diff --git a/sky_phone/source/server/skypic.lua b/sky_phone/source/server/skypic.lua index 4ebe5d5..2fec2f0 100644 --- a/sky_phone/source/server/skypic.lua +++ b/sky_phone/source/server/skypic.lua @@ -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)