From 12031a9c2ef1cbce851e932f1e6feee3ea8df925 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 20 Aug 2026 00:38:24 +0200 Subject: [PATCH] feat(skypic): add Sky UI photo messaging app --- frontend/src/App.vue | 75 + frontend/src/assets/img/app-icons/skypic.png | Bin 0 -> 132027 bytes .../src/assets/img/app-previews/skypic.jpg | Bin 0 -> 66357 bytes frontend/src/components/AppIcon.vue | 3 + frontend/src/config/apps.test.ts | 9 + frontend/src/config/apps.ts | 15 + frontend/src/skypic.backend.contract.test.ts | 395 ++ .../src/skypic.integration.contract.test.ts | 156 + frontend/src/stores/phone-locales.test.ts | 210 + frontend/src/stores/phone.ts | 439 +- frontend/src/stores/skypic.test.ts | 1027 +++++ frontend/src/stores/skypic.ts | 1051 +++++ frontend/src/types/apps.ts | 1 + frontend/src/types/skypic.ts | 198 + frontend/src/utils/appStorePreviews.ts | 1 + frontend/src/utils/media.test.ts | 1 + frontend/src/utils/media.ts | 1 + frontend/src/utils/preferences.test.ts | 4 + frontend/src/utils/preferences.ts | 1 + .../src/views/apps/SkyPicApp.contract.test.ts | 228 ++ frontend/src/views/apps/SkyPicApp.vue | 3603 +++++++++++++++++ frontend/testserver/index.cjs | 1164 +++++- frontend/testserver/smoke.cjs | 576 ++- sky_phone/config/config.lua | 36 + sky_phone/config/locales/de.lua | 130 +- sky_phone/config/locales/en.lua | 130 +- sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 29 + sky_phone/source/server/db_migrate.lua | 188 + sky_phone/source/server/easyshare.lua | 1 + sky_phone/source/server/media.lua | 82 +- sky_phone/source/server/skypic.lua | 2062 ++++++++++ sky_phone/source/shared/custom_apps.lua | 1 + sky_phone/sql/install.sql | 124 + 34 files changed, 11886 insertions(+), 56 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/skypic.png create mode 100644 frontend/src/assets/img/app-previews/skypic.jpg create mode 100644 frontend/src/skypic.backend.contract.test.ts create mode 100644 frontend/src/skypic.integration.contract.test.ts create mode 100644 frontend/src/stores/skypic.test.ts create mode 100644 frontend/src/stores/skypic.ts create mode 100644 frontend/src/types/skypic.ts create mode 100644 frontend/src/views/apps/SkyPicApp.contract.test.ts create mode 100644 frontend/src/views/apps/SkyPicApp.vue create mode 100644 sky_phone/source/server/skypic.lua diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5289d74..921601b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -45,6 +45,7 @@ import { useDarkChatStore } from '@/stores/darkchat' import { useFlareStore } from '@/stores/flare' import { useFlipTokStore } from '@/stores/fliptok' import { usePicstagramStore } from '@/stores/picstagram' +import { useSkyPicStore } from '@/stores/skypic' import { useFeatherStore } from '@/stores/feather' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' @@ -95,6 +96,7 @@ type AppMessage = { | FlipTokNotificationData | PicstagramVerificationData | PicstagramNotificationData + | SkyPicNotificationData | FeatherNotificationData | BankingChangedData | CryptoMarketChangedData @@ -229,6 +231,22 @@ type PicstagramNotificationData = { title?: string } +type SkyPicNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: + | 'friend_request' + | 'friend_accepted' + | 'snap' + | 'message' + | 'story_reply' + | 'snap_opened' + profileId?: string + snapId?: string + text?: string + title?: string +} + type FeatherNotificationData = { actor?: string device?: PhoneNotificationDevicePayload @@ -295,6 +313,7 @@ const darkchat = useDarkChatStore() const flare = useFlareStore() const fliptok = useFlipTokStore() const picstagram = usePicstagramStore() +const skypic = useSkyPicStore() const feather = useFeatherStore() const media = useMediaStore() const marketplace = useMarketplaceStore() @@ -317,6 +336,7 @@ const WHITE_STATUS_BAR_APP_IDS = new Set([ 'calculator', 'camera', 'fliptok', + 'skypic', 'neon-drop', 'sky-flappy', 'snake', @@ -520,6 +540,7 @@ function queueCompaniesChange(change: CompanyChangedPayload): void { async function bootstrapUnlockedPhoneData(): Promise { const tasks: Array<() => Promise | void> = [ + () => (account.email ? skypic.bootstrap() : skypic.resetSession()), () => calls.bootstrap(), () => messages.loadConversations(), () => billing.loadOverview(), @@ -658,6 +679,19 @@ function openDevelopmentPayphonePreview(): void { ) } +function skyPicNotificationRoute(data: SkyPicNotificationData): string { + const query = new URLSearchParams() + query.set( + 'tab', + data.kind === 'friend_request' || data.kind === 'friend_accepted' + ? 'friends' + : 'chats', + ) + if (data.profileId) query.set('profileId', data.profileId) + if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId) + return `/apps/skypic?${query.toString()}` +} + function onMessage(event: MessageEvent): void { if (!isTrustedRootMessageSource(event.source, window)) return @@ -889,6 +923,33 @@ function onMessage(event: MessageEvent): void { } notifications.show(notification) if (phone.isOpen) void picstagram.loadActivities() + } else if (event.data?.type === 'skypic:new' && event.data.data) { + const data = event.data.data as SkyPicNotificationData + const targetsActiveDevice = + !data.device || data.device.imei === phone.device?.imei + const notification: PhoneNotificationInput = { + appId: 'skypic', + route: skyPicNotificationRoute(data), + subtitle: data.actor, + text: data.text ?? phone.t('Apps.skypic.notifications.default'), + title: data.title ?? phone.t('Apps.skypic.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) + if (phone.isOpen && targetsActiveDevice) { + void skypic.bootstrap().then((loaded) => { + if (loaded) void skypic.refreshActiveThread() + }) + } } else if ( event.data?.type === 'marketplace:new-message' && event.data.data @@ -1501,6 +1562,7 @@ watch( if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) if (!isOpen) { cancelUnlockedPhoneDataLoad() + skypic.resetSession() appStore.cancelPendingInstalls() activitySuspended.value = false weather.stop() @@ -1549,6 +1611,19 @@ watch( }, ) +watch( + () => [phone.device?.imei ?? '', account.email] as const, + ([imei, email], [previousImei, previousEmail]) => { + if (imei === previousImei && email === previousEmail) return + skypic.resetSession() + cancelUnlockedPhoneDataLoad() + unlockedServicesLoaded.value = false + if (phone.isOpen && !isLocked.value && !setupRequired.value) { + loadUnlockedPhoneData() + } + }, +) + watch( () => phone.cameraLandscape, (landscape) => { diff --git a/frontend/src/assets/img/app-icons/skypic.png b/frontend/src/assets/img/app-icons/skypic.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c46510204c845ad95a8ed49cfc804d930b3da2 GIT binary patch literal 132027 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

@aS;m0%Z%GN0q&@gp#GMc=+V+ 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 GQU3=}qpM&5 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/img/app-previews/skypic.jpg b/frontend/src/assets/img/app-previews/skypic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c50ef7ee804eb494c335ad2fc51c8f8982c530b7 GIT binary patch literal 66357 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 230fb3a..52b95a2 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -10,6 +10,7 @@ import { useBillingStore } from '@/stores/billing' import { useCompaniesStore } from '@/stores/companies' import { useMarketplaceStore } from '@/stores/marketplace' import { useDarkChatStore } from '@/stores/darkchat' +import { useSkyPicStore } from '@/stores/skypic' import { usePhoneStore } from '@/stores/phone' import type { PhoneAppDefinition } from '@/types/apps' import { @@ -54,6 +55,7 @@ const billing = useBillingStore() const companies = useCompaniesStore() const marketplace = useMarketplaceStore() const darkchat = useDarkChatStore() +const skypic = useSkyPicStore() const router = useRouter() const iconFailed = ref(false) const isDragging = ref(false) @@ -102,6 +104,7 @@ const unreadCount = computed(() => { if (props.app.id === 'darkchat') return darkchat.unreadCount if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0 if (props.app.id === 'companies') return companies.unreadCount + if (props.app.id === 'skypic') return skypic.unreadCount return 0 }) const calendarWeekday = computed(() => diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index ad9117c..7822b05 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -170,6 +170,14 @@ describe('app registry', () => { expect(isPhoneAppId('music')).toBe(true) expect(isPhoneAppId('companies')).toBe(true) expect(isPhoneAppId('weazel-news')).toBe(true) + expect(PHONE_APPS.find((app) => app.id === 'skypic')).toMatchObject({ + category: 'social', + dockOrder: null, + gridOrder: 31, + labelKey: 'Apps.skypic.name', + route: '/apps/skypic', + }) + expect(isPhoneAppId('skypic')).toBe(true) expect( PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id), ).toEqual([ @@ -188,6 +196,7 @@ describe('app registry', () => { ).toEqual([ 'weazel-news', 'picstagram', + 'skypic', 'feather', 'fliptok', 'flare', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 0cda34d..ae9ffd9 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -75,6 +75,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import flareIcon from '@/assets/img/app-icons/flare.webp' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' +import skyPicIcon from '@/assets/img/app-icons/skypic.png' import skyRideIcon from '@/assets/img/app-icons/skyride.webp' import musicIcon from '@/assets/img/app-icons/music.webp' import featherIcon from '@/assets/img/app-icons/feather.webp' @@ -191,6 +192,20 @@ export const PHONE_APPS = shallowReactive([ labelKey: 'Apps.picstagram.name', route: '/apps/picstagram', }, + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/SkyPicApp.vue')), + ), + dockOrder: null, + gridOrder: 31, + icon: markRaw(Camera), + iconClass: 'app-icon--skypic', + iconImage: skyPicIcon, + id: 'skypic', + labelKey: 'Apps.skypic.name', + route: '/apps/skypic', + }, { category: 'social', component: markRaw( diff --git a/frontend/src/skypic.backend.contract.test.ts b/frontend/src/skypic.backend.contract.test.ts new file mode 100644 index 0000000..b013b1f --- /dev/null +++ b/frontend/src/skypic.backend.contract.test.ts @@ -0,0 +1,395 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const read = (path: string) => + readFileSync(new URL(path, import.meta.url), 'utf8') + +const server = read('../../sky_phone/source/server/skypic.lua') +const migration = read('../../sky_phone/source/server/db_migrate.lua') +const install = read('../../sky_phone/sql/install.sql') +const media = read('../../sky_phone/source/server/media.lua') +const config = read('../../sky_phone/config/config.lua') +const mediaUtils = read('./utils/media.ts') +const fallbackLocales = read('./stores/phone.ts') +const englishLocale = read('../../sky_phone/config/locales/en.lua') +const germanLocale = read('../../sky_phone/config/locales/de.lua') +const mockServer = read('../testserver/index.cjs') + +function block(source: string, startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start + startMarker.length) + expect(start, `missing ${startMarker}`).toBeGreaterThanOrEqual(0) + expect(end, `missing ${endMarker}`).toBeGreaterThan(start) + return source.slice(start, end) +} + +function migrationTable(name: string): string { + return block(migration, `name = "sky_phone_skypic_${name}"`, 'tableOptions =') +} + +function installTable(name: string): string { + return block( + install, + `CREATE TABLE IF NOT EXISTS \`sky_phone_skypic_${name}\``, + ') ENGINE=InnoDB', + ) +} + +const callbacks = [ + 'bootstrap', + 'create-profile', + 'update-profile', + 'search', + 'add-friend', + 'respond-friend', + 'remove-friend', + 'block', + 'send-snap', + 'open-snap', + 'replay-snap', + 'publish-story', + 'stories', + 'view-story', + 'story-viewers', + 'remove-story', + 'thread', + 'send-message', + 'mark-thread', + 'save-message', + 'delete-message', +] as const + +const tables: Record = { + profiles: [ + 'id', + 'account_id', + 'handle', + 'display_name', + 'bio', + 'avatar_media_id', + 'avatar_seed', + 'story_privacy', + 'quick_add', + 'allow_story_replies', + 'snap_score', + 'friend_count', + 'status', + 'created_at', + 'updated_at', + ], + friendships: [ + 'id', + 'profile_a_id', + 'profile_b_id', + 'requested_by_id', + 'status', + 'profile_a_last_snap_on', + 'profile_b_last_snap_on', + 'streak_updated_on', + 'streak_count', + 'best_streak', + 'accepted_at', + 'created_at', + 'updated_at', + ], + blocks: ['blocker_profile_id', 'blocked_profile_id', 'created_at'], + messages: [ + 'id', + 'friendship_id', + 'sender_profile_id', + 'recipient_profile_id', + 'message_type', + 'body', + 'caption', + 'overlay_text', + 'overlay_color', + 'media_id', + 'view_seconds', + 'allow_replay', + 'read_at', + 'opened_at', + 'replayed_at', + 'saved_at', + 'expires_at', + 'sender_deleted_at', + 'recipient_deleted_at', + 'deleted_at', + 'created_at', + ], + stories: [ + 'id', + 'profile_id', + 'media_id', + 'caption', + 'overlay_text', + 'overlay_color', + 'view_seconds', + 'privacy', + 'status', + 'expires_at', + 'created_at', + 'updated_at', + ], + story_views: ['story_id', 'viewer_profile_id', 'viewed_at'], +} + +describe('SkyPic backend contracts', () => { + it('keeps migration and clean-install schemas synchronized', () => { + for (const [table, columns] of Object.entries(tables)) { + const migrationSource = migrationTable(table) + const installSource = installTable(table) + for (const column of columns) { + expect(migrationSource, `${table}.${column} migration`).toContain( + `name = "${column}"`, + ) + expect(installSource, `${table}.${column} install`).toContain( + `\`${column}\``, + ) + } + } + + for (const table of ['messages', 'stories']) { + expect(migrationTable(table)).toContain('ON DELETE RESTRICT') + expect(installTable(table)).toContain('ON DELETE RESTRICT') + } + }) + + it('backfills every normalized unique key on existing databases', () => { + const afterMigrate = migration.slice( + migration.indexOf('Bridge.Database.Migrate("sky_phone", schema)'), + ) + for (const index of [ + 'uniq_sky_phone_skypic_profile_account', + 'uniq_sky_phone_skypic_profile_handle', + 'uniq_sky_phone_skypic_friend_pair', + ]) { + expect(afterMigrate).toContain(`"${index}"`) + } + expect( + afterMigrate.match(/\{ unique = true \}/g)?.length ?? 0, + ).toBeGreaterThanOrEqual(3) + }) + + it('registers the complete canonical callback surface', () => { + for (const callback of callbacks) { + expect(server).toContain( + `Bridge.Callbacks.Register("sky_phone:skypic:${callback}"`, + ) + } + expect(server).toContain('Bridge.Database.AfterMigration("sky_phone"') + }) + + it('validates all submitted media through the owned-media resolver', () => { + const editor = block( + server, + 'local function editor_payload', + 'Bridge.Callbacks.Register("sky_phone:skypic:thread"', + ) + expect(editor).toContain( + 'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)', + ) + expect( + server.match(/SkyPhoneMedia\.ResolveOwnedMedia\(/g)?.length, + ).toBeGreaterThanOrEqual(3) + expect(server).toContain( + 'SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo")', + ) + }) + + 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)') + expect(mockServer).toContain('candidate <= 2_147_483_647') + expect(mockServer).toContain( + 'return [...caption].length <= 160 ? caption : null', + ) + expect(mockServer).toContain("error: 'invalid_avatar_seed'") + expect(mockServer).toContain("error: 'invalid_caption'") + expect(mockServer).toContain('value.length > 20') + expect(mockServer).toContain("error: 'message_too_long'") + expect(mockServer).not.toContain('body.slice(0, 1000)') + expect(mockServer).toContain("error: 'profile_exists'") + expect(mockServer).toContain( + "const onboardingScenario = testScenario === 'skypic-onboarding'", + ) + expect(mockServer).toContain( + 'onboardingScenario && skyPicOnboardingProfile', + ) + expect(mockServer).toContain('profile: skyPicOnboardingProfile') + }) + + it('keeps direct snap secrets out of bootstrap and thread serializers', () => { + const safeSnap = block( + server, + 'local function safe_snap_from_row', + 'local function text_message_from_row', + ) + for (const secret of [ + 'url = row.url', + 'caption =', + 'textOverlay =', + 'overlayColor =', + ]) { + expect(safeSnap).not.toContain(secret) + } + + const thread = block( + server, + 'local function list_thread', + 'local function editor_payload', + ) + expect(thread).not.toContain('message.`caption`') + expect(thread).not.toContain('message.`overlay_text`') + expect(thread).not.toContain('message.`overlay_color`') + expect(thread).not.toContain('message.`media_id`') + + const storyList = block( + server, + 'local function list_stories', + 'local function list_conversations', + ) + expect(storyList).not.toContain('story.`caption`') + expect(storyList).not.toContain('story.`overlay_text`') + expect(storyList).not.toContain('story.`overlay_color`') + expect(storyList).not.toContain('story.`media_id`') + expect(storyList).toContain( + 'ORDER BY (story.`profile_id` = ?) DESC, story.`created_at` DESC, story.`id` DESC', + ) + expect(storyList).not.toContain( + 'ORDER BY (story.`profile_id` = ?) DESC, `seen`', + ) + }) + + it('releases snap contents only after atomic one-time state changes', () => { + const open = block( + server, + 'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"', + 'Bridge.Callbacks.Register("sky_phone:skypic:replay-snap"', + ) + expect(open).toContain('message.`opened_at` IS NULL') + expect(open).toContain('message.`recipient_profile_id` = ?') + expect(open).toContain( + 'IF(message.`allow_replay` = 1, ?, message.`view_seconds`)', + ) + expect(open.indexOf('affected_rows(result) ~= 1')).toBeLessThan( + open.indexOf('released_snap('), + ) + + const replay = block( + server, + 'Bridge.Callbacks.Register("sky_phone:skypic:replay-snap"', + 'local function own_story_metadata', + ) + expect(replay).toContain('message.`allow_replay` = 1') + expect(replay).toContain('message.`opened_at` IS NOT NULL') + expect(replay).toContain('message.`replayed_at` IS NULL') + expect(replay.indexOf('affected_rows(result) ~= 1')).toBeLessThan( + replay.indexOf('released_snap('), + ) + }) + + it('atomically protects every database media reference before remote delete', () => { + const guard = block( + media, + 'local function is_referenced_by_skypic', + 'local function delete_owned_media', + ) + expect(guard).toContain('FROM `sky_phone_skypic_messages`') + expect(guard).toContain('FROM `sky_phone_skypic_stories`') + expect(guard).not.toContain('`expires_at` >') + expect(guard).not.toContain("`status` = 'active'") + + const deletion = block( + media, + 'local function delete_owned_media', + 'RegisterNetEvent("sky_phone:media:delete"', + ) + expect(deletion).toContain('return false, "media_in_use"') + expect(deletion).toContain('AND NOT EXISTS (') + expect(deletion).toContain('if affected_rows(result) ~= 1 then') + expect(deletion.indexOf('DELETE FROM `sky_phone_media`')).toBeLessThan( + deletion.indexOf('delete_remote_file(row.remote_id)'), + ) + expect(mediaUtils).toContain("'media_in_use'") + expect(fallbackLocales).toContain( + "media_in_use:\n 'This media is still used by SkyPic and cannot be deleted yet.'", + ) + expect(englishLocale).toContain( + 'media_in_use = "This media is still used by SkyPic and cannot be deleted yet."', + ) + expect(germanLocale).toContain( + 'media_in_use = "Dieses Medium wird noch von SkyPic verwendet und kann noch nicht gelöscht werden."', + ) + }) + + it('enforces viewer-specific deletion and bounded expiry cleanup', () => { + expect(server).toContain('`sender_deleted_at`') + expect(server).toContain('`recipient_deleted_at`') + expect(server).toContain('SET `deleted_at` = CURRENT_TIMESTAMP(6)') + expect(server).toContain('AND `sender_profile_id` = ?') + expect(server).toContain('Wait(limit("CleanupIntervalSeconds", 45) * 1000)') + expect(config).toContain('StoryLifetimeSeconds = 24 * 60 * 60') + expect(config).toContain('ReplayWindowSeconds = 5 * 60') + expect(config).toContain('TextAfterReadLifetimeSeconds = 24 * 60 * 60') + }) + + it('gates optional story replies in the message insert itself', () => { + const sendMessage = block( + server, + 'Bridge.Callbacks.Register("sky_phone:skypic:send-message"', + 'Bridge.Callbacks.Register("sky_phone:skypic:mark-thread"', + ) + expect(sendMessage).toContain('local story_id =') + expect(sendMessage).toContain('story.`profile_id` = ?') + expect(sendMessage).toContain("story.`status` = 'active'") + expect(sendMessage).toContain('story.`expires_at` > CURRENT_TIMESTAMP(6)') + expect(sendMessage).toContain('author.`allow_story_replies` = 1') + expect(sendMessage).toContain("friendship.`status` = 'accepted'") + expect(sendMessage).toContain('NOT EXISTS (') + expect(sendMessage).toContain('story_id and "story_reply" or "message"') + }) + + it('returns a rich outgoing friend request from add-friend', () => { + const addFriend = block( + server, + 'Bridge.Callbacks.Register("sky_phone:skypic:add-friend"', + 'Bridge.Callbacks.Register("sky_phone:skypic:respond-friend"', + ) + expect(addFriend).toContain('target.friendshipId = friendship_id') + expect(addFriend).toContain('target.friendshipStatus = "outgoing"') + expect(addFriend).toContain('direction = "outgoing"') + expect(addFriend).toContain('profile = target') + }) + + it('enforces the friend limit atomically while accepting requests', () => { + const respondFriend = block( + server, + 'Bridge.Callbacks.Register("sky_phone:skypic:respond-friend"', + 'Bridge.Callbacks.Register("sky_phone:skypic:remove-friend"', + ) + expect(respondFriend).toContain( + 'profile_a.`friend_count` = profile_a.`friend_count` + 1', + ) + expect(respondFriend).toContain( + 'profile_b.`friend_count` = profile_b.`friend_count` + 1', + ) + expect(respondFriend).toContain('profile_a.`friend_count` < ?') + expect(respondFriend).toContain('profile_b.`friend_count` < ?') + expect(respondFriend).toContain( + 'return { success = false, error = "friend_limit_reached" }', + ) + }) + + it('keeps pending requests out of quick-add suggestions', () => { + const profiles = block( + server, + 'local function list_profiles', + 'local function list_conversations', + ) + expect(profiles).toContain( + 'filters[#filters + 1] = "friendship.`id` IS NULL"', + ) + expect(profiles).not.toContain("friendship.`status` = 'pending'") + }) +}) diff --git a/frontend/src/skypic.integration.contract.test.ts b/frontend/src/skypic.integration.contract.test.ts new file mode 100644 index 0000000..4d666ed --- /dev/null +++ b/frontend/src/skypic.integration.contract.test.ts @@ -0,0 +1,156 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const read = (path: string) => + readFileSync(new URL(path, import.meta.url), 'utf8') + +const app = read('./App.vue') +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 reservedApps = read('../../sky_phone/source/shared/custom_apps.lua') +const server = read('../../sky_phone/source/server/skypic.lua') +const types = read('./types/skypic.ts') + +const callbacks = [ + 'skypic:bootstrap', + 'skypic:create-profile', + 'skypic:update-profile', + 'skypic:search', + 'skypic:add-friend', + 'skypic:respond-friend', + 'skypic:remove-friend', + 'skypic:block', + 'skypic:send-snap', + 'skypic:open-snap', + 'skypic:replay-snap', + 'skypic:publish-story', + 'skypic:stories', + 'skypic:view-story', + 'skypic:story-viewers', + 'skypic:remove-story', + 'skypic:thread', + 'skypic:send-message', + 'skypic:mark-thread', + 'skypic:save-message', + 'skypic:delete-message', +] as const + +function typeBlock(name: string): string { + const start = types.indexOf(`export type ${name} = {`) + const end = types.indexOf(String.fromCharCode(10) + '}', start) + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + return types.slice(start, end) +} + +describe('SkyPic cross-runtime integration contract', () => { + it('bridges every canonical callback through client, server, and browser mock', () => { + for (const callback of callbacks) { + expect(client, `missing client callback ${callback}`).toContain( + `"${callback}"`, + ) + expect(server, `missing server callback ${callback}`).toContain( + `"sky_phone:${callback}"`, + ) + expect(mockServer, `missing browser mock ${callback}`).toContain( + `endpoint === '${callback}'`, + ) + } + }) + + it('loads the server after media and reserves the app for built-in sharing', () => { + const mediaIndex = manifest.indexOf("'source/server/media.lua'") + const skyPicIndex = manifest.indexOf("'source/server/skypic.lua'") + expect(mediaIndex).toBeGreaterThanOrEqual(0) + expect(skyPicIndex).toBeGreaterThan(mediaIndex) + expect(reservedApps).toContain('skypic = true') + expect(easyShare).toContain('skypic = true') + }) + + it('routes localized device-aware notifications and refreshes live state', () => { + expect(client).toContain( + 'RegisterNetEvent("sky_phone:skypic:new", function(data)', + ) + expect(client).toContain('locale.Nui.Apps.skypic') + expect(client).toContain( + 'notification_text:gsub("{actor}", tostring(data.actor or ""))', + ) + expect(client).toContain( + 'SendNUIMessage({ type = "skypic:new", 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(app).toContain("event.data?.type === 'skypic:new'") + expect(app).toContain("appId: 'skypic'") + expect(app).toContain('route: skyPicNotificationRoute(data)') + expect(app).toContain("query.set('profileId', data.profileId)") + expect(app).toContain( + "if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId)", + ) + expect(app).toContain( + 'preferences: parsePhonePreferences(data.device.settings ?? null)', + ) + expect(app).toContain( + '!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(appIcon).toContain( + "if (props.app.id === 'skypic') return skypic.unreadCount", + ) + }) + + it('scopes badge state to the active unlocked device and account session', () => { + expect(app).toContain( + '() => (account.email ? skypic.bootstrap() : skypic.resetSession())', + ) + expect(app).toContain( + "() => [phone.device?.imei ?? '', account.email] as const", + ) + expect(app).toContain( + 'if (imei === previousImei && email === previousEmail) return', + ) + expect(app).toContain('skypic.resetSession()') + expect(app).toContain('unlockedServicesLoaded.value = false') + expect(app).toContain( + 'if (phone.isOpen && !isLocked.value && !setupRequired.value)', + ) + }) + + it('keeps snap and story secrets out of list payload types', () => { + const snap = typeBlock('SkyPicSnap') + const story = typeBlock('SkyPicStory') + const openedSnap = typeBlock('SkyPicOpenedSnap') + const viewedStory = typeBlock('SkyPicViewedStory') + + for (const metadata of [snap, story]) { + expect(metadata).not.toContain('url:') + expect(metadata).not.toContain('caption:') + expect(metadata).not.toContain('textOverlay:') + expect(metadata).not.toContain('overlayColor:') + } + for (const opened of [openedSnap, viewedStory]) { + expect(opened).toContain('url:') + expect(opened).toContain('caption:') + expect(opened).toContain('textOverlay:') + expect(opened).toContain('overlayColor:') + } + expect(viewedStory).toContain('canReply:') + 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('.slice(offset, offset + 30)') + expect(mockServer).toContain( + "response.json({ success: false, error: 'story_unavailable' })", + ) + }) +}) diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts index f4f03ce..57f85ba 100644 --- a/frontend/src/stores/phone-locales.test.ts +++ b/frontend/src/stores/phone-locales.test.ts @@ -209,6 +209,216 @@ describe('phone locale fallback', () => { ) }) + it('keeps the complete SkyPic copy translated with a partial server locale', () => { + const phone = usePhoneStore() + phone.open({ locales: { Apps: { skypic: { name: 'SkyPic' } } } }) + + const skyPicKeys = [ + 'name', + 'loading', + 'navigation', + ...['seconds', 'minutes', 'hours', 'days'].map((key) => 'time.' + key), + ...[ + 'title', + 'eyebrow', + 'heading', + 'body', + 'displayName', + 'displayNamePlaceholder', + 'handle', + 'handlePlaceholder', + 'accountBound', + 'create', + 'creating', + ].map((key) => 'onboarding.' + key), + ...[ + 'eyebrow', + 'title', + 'body', + 'snap', + 'story', + 'photo', + 'video', + 'gallery', + 'capturePhoto', + 'captureVideo', + 'snapHint', + 'storyHint', + ].map((key) => 'camera.' + key), + ...[ + 'snapTitle', + 'storyTitle', + 'send', + 'addStory', + 'caption', + 'captionPlaceholder', + 'textOverlay', + 'textPlaceholder', + 'color', + 'duration', + 'seconds', + 'replay', + 'replayBody', + 'recipients', + 'recipientsHint', + 'recipientLimit', + 'selectedCount', + 'noFriends', + 'changeMedia', + 'sent', + 'storyPublished', + ].map((key) => 'composer.' + key), + ...['camera', 'chats', 'stories', 'friends'].map((key) => 'tabs.' + key), + ...[ + 'title', + 'incoming', + 'noSnaps', + 'conversations', + 'noConversations', + 'start', + 'message', + 'threadPlaceholder', + 'messageLimit', + 'saved', + 'save', + 'unsave', + 'delete', + 'sendSnap', + 'sending', + 'failed', + ].map((key) => 'chats.' + key), + ...[ + 'newVideo', + 'newPhoto', + 'replayed', + 'opened', + 'video', + 'photo', + 'replay', + ].map((key) => 'snaps.' + key), + ...[ + 'title', + 'add', + 'yours', + 'friends', + 'emptyTitle', + 'emptyBody', + 'views', + 'viewers', + 'noViewers', + 'replyPlaceholder', + 'replySent', + 'replyLimit', + 'delete', + 'deleted', + 'seen', + 'unseen', + ].map((key) => 'stories.' + key), + ...[ + 'title', + 'searchPlaceholder', + 'searchResults', + 'score', + 'requests', + 'sentRequests', + 'accept', + 'decline', + 'quickAdd', + 'add', + 'pending', + 'cancelRequest', + 'all', + 'empty', + 'remove', + 'block', + 'blockedProfiles', + 'unblock', + 'chat', + 'sendSnap', + 'respond', + 'friends', + 'requestSent', + 'requestCanceled', + 'removed', + 'blocked', + 'unblocked', + ].map((key) => 'friends.' + key), + ...[ + 'title', + 'edit', + 'save', + 'cancel', + 'score', + 'streaks', + 'friends', + 'bio', + 'bioPlaceholder', + 'storyPrivacy', + 'privacyFriends', + 'privacyEveryone', + 'allowStoryReplies', + 'allowStoryRepliesBody', + 'showInQuickAdd', + 'showInQuickAddBody', + 'saved', + ].map((key) => 'profile.' + key), + ...['close', 'timeLeft'].map((key) => 'viewer.' + key), + ...[ + 'friend_request', + 'friend_accepted', + 'snap', + 'message', + 'story_reply', + 'snap_opened', + 'default', + ].map((key) => 'notifications.' + key), + ...[ + 'profile_required', + 'profile_exists', + 'invalid_handle', + 'handle_taken', + 'invalid_display_name', + 'invalid_bio', + 'invalid_avatar', + 'invalid_avatar_seed', + 'invalid_privacy', + 'invalid_request', + 'profile_not_found', + 'blocked', + 'friendship_not_found', + 'friend_request_exists', + 'friend_limit_reached', + 'request_limit_reached', + 'invalid_recipients', + 'invalid_media', + 'invalid_media_type', + 'invalid_duration', + 'invalid_caption', + 'invalid_overlay', + 'invalid_color', + 'snap_unavailable', + 'replay_unavailable', + 'message_empty', + 'message_too_long', + 'message_not_found', + 'story_limit_reached', + 'story_unavailable', + 'not_authorized', + 'rate_limited', + 'request_timeout', + 'request_failed', + 'not_authenticated', + 'unknown_error', + 'default', + ].map((key) => 'errors.' + key), + ] + + for (const key of skyPicKeys) { + const path = 'Apps.skypic.' + key + expect(phone.t(path), path).not.toBe(path) + } + }) + it('uses the English Lua payload before the bundled emergency fallback', () => { const phone = usePhoneStore() phone.open({ diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0d3ce1b..5e37bde 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1643,6 +1643,219 @@ const defaultLocales: LocaleTree = { default: 'Picstagram could not complete the request.', }, }, + skypic: { + name: 'SkyPic', + loading: 'Loading SkyPic', + navigation: 'SkyPic navigation', + time: { + seconds: '{count}s ago', + minutes: '{count}m ago', + hours: '{count}h ago', + days: '{count}d ago', + }, + onboarding: { + title: 'Welcome to SkyPic', + eyebrow: 'Your private camera network', + heading: 'Share the moment', + body: 'Create a profile for private snaps, close-friend stories, and quick chats.', + displayName: 'Display name', + displayNamePlaceholder: 'Your name', + handle: 'Handle', + handlePlaceholder: 'your.handle', + accountBound: + 'Your SkyPic profile stays linked to this Sky Cloud account.', + create: 'Create profile', + creating: 'Creating...', + }, + camera: { + eyebrow: 'Sky camera', + title: 'Capture', + body: 'Take a photo or video and share it for just a few seconds.', + snap: 'Snap', + story: 'Story', + photo: 'Photo', + video: 'Video', + gallery: 'Open gallery', + capturePhoto: 'Take photo', + captureVideo: 'Record video', + snapHint: 'Send it privately to one or more friends.', + storyHint: 'Share it with your story for 24 hours.', + }, + composer: { + snapTitle: 'New snap', + storyTitle: 'New story', + send: 'Send', + addStory: 'Add to story', + caption: 'Caption', + captionPlaceholder: 'Add a caption...', + textOverlay: 'Text overlay', + textPlaceholder: 'Put words on the moment...', + color: 'Text color', + duration: 'View duration', + seconds: '{count} seconds', + replay: 'Allow one replay', + replayBody: 'Recipients may open this snap one additional time.', + recipients: 'Recipients', + recipientsHint: 'Choose one or more friends.', + recipientLimit: 'Choose up to {count} friends.', + selectedCount: '{count} selected', + noFriends: 'Add a friend before sending a snap.', + changeMedia: 'Change photo or video', + sent: 'Snap sent.', + storyPublished: 'Story published.', + }, + tabs: { + camera: 'Camera', + chats: 'Chats', + stories: 'Stories', + friends: 'Friends', + }, + chats: { + title: 'Chats', + incoming: 'New snaps', + noSnaps: 'No unopened snaps', + 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', + }, + snaps: { + newVideo: 'New video snap', + newPhoto: 'New photo snap', + replayed: 'Replayed', + opened: 'Opened', + video: 'Video snap', + photo: 'Photo snap', + replay: 'Replay snap', + }, + stories: { + title: 'Stories', + add: 'Add story', + yours: 'Your story', + friends: 'Friends', + emptyTitle: 'No stories right now', + emptyBody: 'Stories from your friends will appear here for 24 hours.', + views: '{count} views', + viewers: 'Viewers', + noViewers: 'No views yet', + replyPlaceholder: 'Reply to this story...', + replySent: 'Reply sent.', + replyLimit: 'Replies can contain up to {count} characters.', + delete: 'Delete story', + deleted: 'Story deleted.', + seen: 'Seen', + unseen: 'New', + }, + friends: { + title: 'Friends', + searchPlaceholder: 'Search name or @handle', + searchResults: 'Search results', + score: '{count} points', + requests: 'Friend requests', + sentRequests: 'Sent requests', + accept: 'Accept', + decline: 'Decline', + quickAdd: 'Quick Add', + add: 'Add', + pending: 'Pending', + cancelRequest: 'Cancel request', + all: 'Your friends', + empty: 'Add friends to start snapping.', + remove: 'Remove friend', + block: 'Block', + blockedProfiles: 'Blocked profiles', + unblock: 'Unblock', + chat: 'Chat', + sendSnap: 'Send snap', + respond: 'Respond', + friends: 'Friends', + requestSent: 'Friend request sent to {name}.', + requestCanceled: 'Friend request to {name} canceled.', + removed: '{name} was removed from your friends.', + blocked: '{name} was blocked.', + unblocked: '{name} was unblocked.', + }, + profile: { + title: 'Profile', + edit: 'Edit profile', + save: 'Save', + cancel: 'Cancel', + score: 'Snap score', + streaks: 'Streaks', + friends: 'Friends', + bio: 'Bio', + bioPlaceholder: 'Tell your friends a little about you...', + storyPrivacy: 'Story privacy', + privacyFriends: 'Friends only', + privacyEveryone: 'Everyone', + allowStoryReplies: 'Allow story replies', + allowStoryRepliesBody: 'Friends can reply to your stories in chat.', + showInQuickAdd: 'Show in Quick Add', + showInQuickAddBody: 'Let other profiles discover you as a suggestion.', + saved: 'Profile updated.', + }, + viewer: { + close: 'Close', + timeLeft: '{count}s', + }, + notifications: { + friend_request: '{actor} sent you a friend request.', + friend_accepted: '{actor} accepted your friend request.', + snap: '{actor} sent you a snap.', + message: '{actor} sent you a message.', + story_reply: '{actor} replied to your story.', + snap_opened: '{actor} opened your snap.', + default: 'You have new SkyPic activity.', + }, + errors: { + profile_required: 'Create your SkyPic profile first.', + profile_exists: 'This account already has a SkyPic profile.', + invalid_handle: 'Use 3-24 letters, numbers, dots, or underscores.', + handle_taken: 'This handle is already taken.', + invalid_display_name: 'Enter a display name.', + invalid_bio: 'Your bio is too long.', + invalid_avatar: 'Choose a valid profile photo.', + invalid_avatar_seed: 'Choose a valid avatar.', + invalid_privacy: 'Choose a valid story privacy setting.', + invalid_request: 'This friend request is invalid.', + profile_not_found: 'This SkyPic profile is unavailable.', + blocked: 'This profile is blocked.', + friendship_not_found: 'This friendship is unavailable.', + friend_request_exists: 'A friend request already exists.', + friend_limit_reached: 'Your friends list is full.', + request_limit_reached: 'Too many open friend requests.', + invalid_recipients: 'Choose at least one current friend.', + invalid_media: 'Choose media from this phone.', + invalid_media_type: 'This media type is not supported.', + invalid_duration: 'Choose a view time from 1 to 10 seconds.', + invalid_caption: 'This caption is too long.', + invalid_overlay: 'This text overlay is too long.', + invalid_color: 'Choose a valid overlay color.', + snap_unavailable: 'This snap is no longer available.', + replay_unavailable: 'This snap cannot be replayed again.', + message_empty: 'Write a message before sending.', + message_too_long: 'Messages can contain at most 2000 characters.', + message_not_found: 'This message is unavailable.', + 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.', + 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.', + not_authenticated: 'Sign in to Sky Cloud first.', + unknown_error: 'SkyPic could not complete the request.', + default: 'SkyPic could not complete the request.', + }, + }, feather: { name: 'Feather', loading: 'Loading Feather', @@ -2397,6 +2610,7 @@ const defaultLocales: LocaleTree = { companies: 'Businesses, jobs and services', music: 'Songs, playlists and audio', picstagram: 'Photo sharing and social feed', + skypic: 'Private snaps, stories and close friends', feather: 'Short posts and city conversations', fliptok: 'Short videos and trends', flare: 'Social posts and live moments', @@ -2432,46 +2646,195 @@ const defaultLocales: LocaleTree = { 'neon-drop': 'Neon block-dropping puzzle', }, previews: { - citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' }, - crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' }, - health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' }, - 'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' }, - companies: { first: 'Business directory', second: 'Job requests', third: 'Services' }, - music: { first: 'Now playing', second: 'Playlists', third: 'Music library' }, - picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' }, - feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' }, - fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' }, - flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' }, - calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' }, - radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' }, - 'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' }, - crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' }, - phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' }, - messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' }, - darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' }, - garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' }, - house: { first: 'Property access', second: 'Residents', third: 'Management' }, - map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' }, - skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' }, - banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' }, - billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' }, + citywarn: { + first: 'Live alerts', + second: 'Safety zones', + third: 'Incident updates', + }, + crypto: { + first: 'Synthetic markets', + second: 'Portfolio', + third: 'Wallet transfers', + }, + health: { + first: 'Activity rings', + second: 'Medical ID', + third: 'Health records', + }, + 'weazel-news': { + first: 'Top stories', + second: 'Local reports', + third: 'Breaking news', + }, + companies: { + first: 'Business directory', + second: 'Job requests', + third: 'Services', + }, + music: { + first: 'Now playing', + second: 'Playlists', + third: 'Music library', + }, + picstagram: { + first: 'Photo feed', + second: 'Stories', + third: 'Profiles', + }, + skypic: { + first: 'Private snaps', + second: 'Friend stories', + third: 'Quick chats', + }, + feather: { + first: 'Short posts', + second: 'Following feed', + third: 'Conversations', + }, + fliptok: { + first: 'Video feed', + second: 'Creator tools', + third: 'Trends', + }, + flare: { + first: 'Discover people', + second: 'Matches', + third: 'Live moments', + }, + calendar: { + first: 'Upcoming events', + second: 'Day planner', + third: 'Reminders', + }, + radio: { + first: 'Live channels', + second: 'Team radio', + third: 'Favorites', + }, + 'local-pages': { + first: 'Local pages', + second: 'Reviews', + third: 'City discovery', + }, + crewlink: { + first: 'Crew roster', + second: 'Shared locations', + third: 'Coordination', + }, + phone: { + first: 'Recent calls', + second: 'Contacts', + third: 'Voicemail', + }, + messages: { + first: 'Conversations', + second: 'Media sharing', + third: 'Quick replies', + }, + darkchat: { + first: 'Private chats', + second: 'Secure groups', + third: 'Invitations', + }, + garage: { + first: 'Vehicle list', + second: 'Parking locations', + third: 'Valet', + }, + house: { + first: 'Property access', + second: 'Residents', + third: 'Management', + }, + map: { + first: 'Live navigation', + second: 'Nearby places', + third: 'Route guidance', + }, + skyride: { + first: 'Ride booking', + second: 'Driver tracking', + third: 'Trip history', + }, + banking: { + first: 'Account balance', + second: 'Transfers', + third: 'Transactions', + }, + billing: { + first: 'Open invoices', + second: 'Payment requests', + third: 'Payment history', + }, mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' }, notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' }, - memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' }, - calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' }, - camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' }, + memos: { + first: 'Voice recordings', + second: 'Playback', + third: 'Favorites', + }, + calculator: { + first: 'Basic calculation', + second: 'Scientific tools', + third: 'History', + }, + camera: { + first: 'Photo mode', + second: 'Video capture', + third: 'Zoom controls', + }, clock: { first: 'World clock', second: 'Alarms', third: 'Timers' }, - weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' }, - photos: { first: 'Media library', second: 'Albums', third: 'Shared media' }, - settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' }, + weather: { + first: 'Current weather', + second: 'Hourly forecast', + third: 'Seven-day outlook', + }, + photos: { + first: 'Media library', + second: 'Albums', + third: 'Shared media', + }, + settings: { + first: 'Device controls', + second: 'Privacy', + third: 'Personalization', + }, snake: { first: 'High score', second: 'Speed', third: 'Classic grid' }, - memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' }, - 'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' }, - minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' }, - 'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' }, - 'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' }, - citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' }, - 'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' }, + memory: { + first: 'Matched pairs', + second: 'Best time', + third: 'Card themes', + }, + 'number-merge': { + first: 'Highest tile', + second: 'Score', + third: 'Strategy grid', + }, + minesweeper: { + first: 'Mine counter', + second: 'Best time', + third: 'Difficulty', + }, + 'tower-stack': { + first: 'Tower height', + second: 'Perfect drops', + third: 'High score', + }, + 'sky-flappy': { + first: 'Flight score', + second: 'Best run', + third: 'Obstacles', + }, + citymarkt: { + first: 'Listings', + second: 'Categories', + third: 'Saved offers', + }, + 'neon-drop': { + first: 'Lines cleared', + second: 'Level', + third: 'Neon pieces', + }, }, search: { recommended: 'Recommended', @@ -4558,6 +4921,8 @@ const defaultLocales: LocaleTree = { import_url_not_allowed: 'This link is not from the selected website.', import_url_unavailable: 'The linked media could not be reached.', import_size_unavailable: 'The website did not provide the media size.', + media_in_use: + 'This media is still used by SkyPic and cannot be deleted yet.', not_found: 'The media item no longer exists.', profile_photo_required: 'This is the last photo on your Flare profile. Add another profile photo before deleting it.', @@ -4967,8 +5332,10 @@ const defaultLocales: LocaleTree = { pause: 'Pause', phone: 'Phone', phoneStatus: 'Phone status', + retry: 'Try Again', reset: 'Reset', loading: 'Loading', + loadMore: 'Load More', search: 'Search', save: 'Save', send: 'Send', diff --git a/frontend/src/stores/skypic.test.ts b/frontend/src/stores/skypic.test.ts new file mode 100644 index 0000000..6f1ad37 --- /dev/null +++ b/frontend/src/stores/skypic.test.ts @@ -0,0 +1,1027 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSkyPicStore } from '@/stores/skypic' +import type { + SkyPicBootstrap, + SkyPicConversation, + SkyPicFriend, + SkyPicFriendRequest, + SkyPicMessage, + SkyPicOpenedSnap, + SkyPicProfile, + SkyPicProfileSummary, + SkyPicSnap, + SkyPicStory, + SkyPicStoryViewer, + SkyPicThread, + SkyPicViewedStory, +} from '@/types/skypic' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const now = '2026-08-19T12:00:00.000Z' +const later = '2026-08-19T13:00:00.000Z' + +function deferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void +} { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +const self: SkyPicProfile = { + allowStoryReplies: true, + avatarMediaId: null, + avatarSeed: 212, + avatarUrl: null, + bio: 'Night rides and city lights.', + displayName: 'Nova', + friendCount: 1, + friendshipStatus: 'none', + handle: 'nova', + id: 'profile-self', + showInQuickAdd: true, + snapScore: 140, + storyPrivacy: 'friends', +} + +const maya: SkyPicProfileSummary = { + avatarSeed: 318, + avatarUrl: null, + displayName: 'Maya', + friendshipId: 'friendship-maya', + friendshipStatus: 'friends', + handle: 'maya', + id: 'profile-maya', + snapScore: 98, +} + +const theo: SkyPicProfileSummary = { + avatarSeed: 72, + avatarUrl: null, + displayName: 'Theo', + friendshipStatus: 'none', + handle: 'theo', + id: 'profile-theo', + snapScore: 61, +} + +const friend: SkyPicFriend = { + bestStreak: 12, + createdAt: now, + friendshipId: 'friendship-maya', + profile: maya, + streakCount: 5, +} + +const request: SkyPicFriendRequest = { + createdAt: now, + direction: 'incoming', + friendshipId: 'friendship-theo', + profile: { + ...theo, + friendshipId: 'friendship-theo', + friendshipStatus: 'incoming', + }, +} + +const conversation: SkyPicConversation = { + bestStreak: 12, + friendshipId: friend.friendshipId, + lastItem: { + body: 'Meet at the pier?', + createdAt: now, + direction: 'received', + id: 'message-last', + openedAt: null, + type: 'text', + }, + profile: maya, + streakCount: 5, + unreadCount: 1, +} + +const snap: SkyPicSnap = { + allowReplay: true, + createdAt: now, + direction: 'received', + durationSeconds: 5, + expiresAt: later, + friendshipId: friend.friendshipId, + id: 'snap-1', + openedAt: null, + replayedAt: null, + sender: maya, + type: 'snap_photo', +} + +const story: SkyPicStory = { + author: maya, + createdAt: now, + durationSeconds: 6, + expiresAt: later, + id: 'story-1', + isOwner: false, + seen: false, + viewCount: 4, +} + +const bootstrap: SkyPicBootstrap = { + blockedProfiles: [], + conversations: [conversation], + friends: [friend], + inbox: [snap], + profile: self, + requests: [request], + stories: [story], + suggestions: [theo], + unreadCount: 2, +} + +const openedSnap: SkyPicOpenedSnap = { + allowReplay: true, + caption: 'Downtown', + durationSeconds: 5, + expiresAt: later, + id: snap.id, + mediaType: 'photo', + mimeType: 'image/webp', + openedAt: now, + overlayColor: '#ffffff', + replayedAt: null, + textOverlay: 'Tonight', + url: 'https://media.example.test/snap.webp', +} + +describe('SkyPic store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('hydrates the account-bound bootstrap without exposing direct media URLs', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { ...bootstrap, blockedProfiles: [theo] }, + success: true, + }) + const store = useSkyPicStore() + + expect(await store.bootstrap()).toBe(true) + expect(store.profile?.handle).toBe('nova') + expect(store.friends).toEqual([friend]) + expect(store.incomingRequests).toEqual([request]) + expect(store.blockedProfiles).toEqual([theo]) + expect(store.unreadCount).toBe(2) + expect(store.inbox[0]).not.toHaveProperty('url') + expect(store.inbox[0]).not.toHaveProperty('caption') + expect(mockNuiCall).toHaveBeenCalledWith('skypic:bootstrap', {}) + }) + + it('normalizes profile payloads and keeps the server profile authoritative', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: self, success: true }) + .mockResolvedValueOnce({ + data: { ...self, bio: 'Updated', showInQuickAdd: false }, + success: true, + }) + const store = useSkyPicStore() + + await store.createProfile({ + avatarSeed: 212, + displayName: ' Nova ', + handle: ' NOVA ', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'skypic:create-profile', { + avatarSeed: 212, + displayName: 'Nova', + handle: 'nova', + }) + + await store.updateProfile({ + allowStoryReplies: false, + avatarMediaId: null, + avatarSeed: 212, + bio: ' Updated ', + displayName: ' Nova ', + handle: ' NOVA ', + showInQuickAdd: false, + storyPrivacy: 'everyone', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'skypic:update-profile', { + allowStoryReplies: false, + avatarMediaId: null, + avatarSeed: 212, + bio: 'Updated', + displayName: 'Nova', + handle: 'nova', + showInQuickAdd: false, + storyPrivacy: 'everyone', + }) + expect(store.profile?.showInQuickAdd).toBe(false) + }) + + it('updates outgoing relation state and accepts a friend request', async () => { + const outgoing: SkyPicFriendRequest = { + createdAt: now, + direction: 'outgoing', + friendshipId: 'friendship-theo', + profile: { + ...theo, + friendshipId: 'friendship-theo', + friendshipStatus: 'outgoing', + }, + } + const accepted: SkyPicFriend = { + bestStreak: 0, + createdAt: later, + friendshipId: request.friendshipId, + profile: { + ...request.profile, + friendshipStatus: 'friends', + }, + streakCount: 0, + } + mockNuiCall + .mockResolvedValueOnce({ data: outgoing, success: true }) + .mockResolvedValueOnce({ data: accepted, success: true }) + const store = useSkyPicStore() + store.profile = { ...self } + store.suggestions = [{ ...theo }, { ...request.profile }] + store.searchResults = [{ ...theo }, { ...request.profile }] + store.requests = [{ ...request }] + + expect((await store.addFriend(theo.id)).success).toBe(true) + expect(store.suggestions[0].friendshipStatus).toBe('outgoing') + expect(store.searchResults[0].friendshipId).toBe('friendship-theo') + expect(store.outgoingRequests).toEqual([outgoing]) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'skypic:add-friend', { + profileId: theo.id, + }) + + expect( + (await store.respondFriend(request.friendshipId, true)).success, + ).toBe(true) + expect(store.requests).toEqual([]) + expect(store.friends).toEqual([accepted]) + expect(store.profile.friendCount).toBe(2) + expect(store.suggestions[1]).toMatchObject({ + friendshipId: accepted.friendshipId, + friendshipStatus: 'friends', + }) + expect(store.searchResults[1]).toMatchObject({ + friendshipId: accepted.friendshipId, + friendshipStatus: 'friends', + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'skypic:respond-friend', { + accept: true, + friendshipId: request.friendshipId, + }) + }) + + it('returns declined incoming profiles to a neutral relationship state', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.requests = [{ ...request }] + store.suggestions = [{ ...request.profile }] + store.searchResults = [{ ...request.profile }] + + expect( + (await store.respondFriend(request.friendshipId, false)).success, + ).toBe(true) + expect(store.requests).toEqual([]) + expect(store.suggestions[0]).toMatchObject({ + friendshipId: null, + friendshipStatus: 'none', + }) + expect(store.searchResults[0]).toMatchObject({ + friendshipId: null, + friendshipStatus: 'none', + }) + }) + + it('cancels an outgoing request without decrementing the friend count', async () => { + const outgoing: SkyPicFriendRequest = { + createdAt: now, + direction: 'outgoing', + friendshipId: 'friendship-theo', + profile: { + ...theo, + friendshipId: 'friendship-theo', + friendshipStatus: 'outgoing', + }, + } + mockNuiCall.mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.profile = { ...self } + store.requests = [outgoing] + store.suggestions = [{ ...outgoing.profile }] + store.searchResults = [{ ...outgoing.profile }] + + expect(await store.removeFriend(outgoing.friendshipId)).toBe(true) + expect(store.outgoingRequests).toEqual([]) + expect(store.profile.friendCount).toBe(self.friendCount) + expect(store.suggestions[0]).toMatchObject({ + friendshipId: null, + friendshipStatus: 'none', + }) + expect(mockNuiCall).toHaveBeenCalledWith('skypic:remove-friend', { + friendshipId: outgoing.friendshipId, + }) + }) + + it('removes a blocked profile from every local surface', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.profile = { ...self } + store.friends = [{ ...friend }] + store.requests = [ + { + ...request, + friendshipId: friend.friendshipId, + profile: { ...maya, friendshipStatus: 'incoming' }, + }, + ] + store.conversations = [{ ...conversation }] + store.inbox = [{ ...snap }] + store.stories = [{ ...story }] + store.suggestions = [{ ...maya }] + store.searchResults = [{ ...maya }] + store.activeFriendshipId = friend.friendshipId + store.threadSnaps = [{ ...snap }] + + expect(await store.block(maya.id)).toBe(true) + expect(store.friends).toEqual([]) + expect(store.requests).toEqual([]) + expect(store.conversations).toEqual([]) + expect(store.inbox).toEqual([]) + expect(store.stories).toEqual([]) + expect(store.suggestions).toEqual([]) + expect(store.searchResults).toEqual([]) + expect(store.activeFriendshipId).toBeNull() + expect(store.profile.friendCount).toBe(0) + expect(store.blockedProfiles).toEqual([ + expect.objectContaining({ + friendshipId: null, + friendshipStatus: 'none', + id: maya.id, + }), + ]) + expect(mockNuiCall).toHaveBeenCalledWith('skypic:block', { + blocked: true, + profileId: maya.id, + }) + }) + + it('does not decrement the friend count when blocking a request-only profile', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.profile = { ...self } + store.requests = [{ ...request }] + + expect(await store.block(theo.id)).toBe(true) + expect(store.requests).toEqual([]) + expect(store.profile.friendCount).toBe(self.friendCount) + }) + + it('removes a profile from the blocked list when unblocking', async () => { + mockNuiCall.mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.blockedProfiles = [{ ...theo }] + + expect(await store.block(theo.id, false)).toBe(true) + expect(store.blockedProfiles).toEqual([]) + expect(mockNuiCall).toHaveBeenCalledWith('skypic:block', { + blocked: false, + profileId: theo.id, + }) + }) + + it('releases direct media only through open and replay and updates unread state', async () => { + mockNuiCall + .mockResolvedValueOnce({ data: openedSnap, success: true }) + .mockResolvedValueOnce({ + data: { ...openedSnap, replayedAt: later }, + success: true, + }) + const store = useSkyPicStore() + store.inbox = [{ ...snap }] + store.conversations = [{ ...conversation }] + store.profile = { ...self } + store.unreadCount = 2 + + expect((await store.openSnap(snap.id)).success).toBe(true) + expect(store.openedSnap?.url).toBe(openedSnap.url) + expect(store.inbox[0]).not.toHaveProperty('url') + expect(store.inbox[0].openedAt).toBe(now) + expect(store.unreadCount).toBe(1) + expect(store.conversations[0].unreadCount).toBe(0) + expect(store.profile.snapScore).toBe(self.snapScore + 1) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'skypic:open-snap', { + snapId: snap.id, + }) + + store.clearOpenedSnap() + expect(store.openedSnap).toBeNull() + expect((await store.replaySnap(snap.id)).success).toBe(true) + expect(store.inbox[0].replayedAt).toBe(later) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'skypic:replay-snap', { + snapId: snap.id, + }) + }) + + it('allows only one direct snap open or replay request at a time', async () => { + const pending = deferred>() + mockNuiCall.mockReturnValueOnce(pending.promise) + const store = useSkyPicStore() + + const firstOpen = store.openSnap(snap.id) + expect(store.snapOpening).toBe(true) + await expect(store.openSnap('snap-2')).resolves.toEqual({ + error: 'snap_open_in_progress', + success: false, + }) + await expect(store.replaySnap(snap.id)).resolves.toEqual({ + error: 'snap_open_in_progress', + success: false, + }) + expect(mockNuiCall).toHaveBeenCalledTimes(1) + + pending.resolve({ data: openedSnap, success: true }) + await expect(firstOpen).resolves.toMatchObject({ success: true }) + expect(store.snapOpening).toBe(false) + }) + + it('bounds composer data, deduplicates recipients and appends sent snaps', async () => { + const sent: SkyPicSnap = { + ...snap, + createdAt: later, + direction: 'sent', + id: 'snap-sent', + openedAt: null, + sender: { ...self, snapScore: self.snapScore + 1 }, + } + mockNuiCall.mockResolvedValueOnce({ data: [sent], success: true }) + const store = useSkyPicStore() + store.activeFriendshipId = friend.friendshipId + store.conversations = [{ ...conversation }] + store.profile = { ...self } + + const response = await store.sendSnap({ + allowReplay: true, + caption: ' Hello ', + durationSeconds: 99, + mediaId: 42, + mediaType: 'photo', + overlayColor: 'not-a-color', + recipientIds: [maya.id, maya.id], + textOverlay: ' Tonight ', + }) + + expect(response.success).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('skypic:send-snap', { + allowReplay: true, + caption: 'Hello', + durationSeconds: 10, + mediaId: 42, + mediaType: 'photo', + overlayColor: '#ffffff', + recipientIds: [maya.id], + textOverlay: 'Tonight', + }) + expect(store.threadSnaps).toEqual([sent]) + expect(store.conversations[0].lastItem).toMatchObject({ + id: sent.id, + type: sent.type, + }) + expect(store.profile.snapScore).toBe(self.snapScore + 1) + }) + + it('caps snap recipients and chat bodies at the server limits', async () => { + const recipientIds = Array.from( + { length: 22 }, + (_, index) => 'profile-' + index, + ) + const longBody = 'x'.repeat(2_000) + 'tail' + const message: SkyPicMessage = { + body: 'x'.repeat(2_000), + createdAt: later, + direction: 'sent', + friendshipId: friend.friendshipId, + id: 'message-capped', + readAt: null, + savedAt: null, + type: 'text', + } + mockNuiCall + .mockResolvedValueOnce({ data: [], success: true }) + .mockResolvedValueOnce({ data: message, success: true }) + const store = useSkyPicStore() + + await store.sendSnap({ + allowReplay: false, + caption: '', + durationSeconds: 5, + mediaId: 42, + mediaType: 'photo', + overlayColor: '#ffffff', + recipientIds, + textOverlay: 'y'.repeat(161), + }) + expect(mockNuiCall).toHaveBeenNthCalledWith( + 1, + 'skypic:send-snap', + expect.objectContaining({ recipientIds: recipientIds.slice(0, 20) }), + ) + const snapPayload = mockNuiCall.mock.calls[0]?.[1] + expect(Array.from(String(snapPayload?.textOverlay))).toHaveLength(160) + + await store.sendMessage(friend.friendshipId, longBody) + const sentPayload = mockNuiCall.mock.calls[1]?.[1] + expect(Array.from(String(sentPayload?.body))).toHaveLength(2_000) + expect(sentPayload).toMatchObject({ + friendshipId: friend.friendshipId, + }) + }) + + it('releases story media through view-story and keeps bootstrap metadata clean', async () => { + const viewed: SkyPicViewedStory = { + author: maya, + canReply: true, + caption: 'Pier lights', + durationSeconds: 6, + expiresAt: later, + id: story.id, + mediaType: 'video', + mimeType: 'video/webm', + overlayColor: '#42e8ff', + textOverlay: 'Los Santos', + url: 'https://media.example.test/story.webm', + viewedAt: now, + } + mockNuiCall + .mockResolvedValueOnce({ data: viewed, success: true }) + .mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.stories = [{ ...story }] + + expect((await store.viewStory(story.id)).success).toBe(true) + expect(store.viewedStory?.url).toBe(viewed.url) + expect(store.stories[0]).not.toHaveProperty('url') + expect(store.stories[0].seen).toBe(true) + expect(store.stories[0].viewCount).toBe(5) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'skypic:view-story', { + storyId: story.id, + }) + + expect(await store.removeStory(story.id)).toBe(true) + expect(store.stories).toEqual([]) + expect(store.viewedStory).toBeNull() + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'skypic:remove-story', { + storyId: story.id, + }) + }) + + it('allows only one story release request and invalidates it on reset', async () => { + const viewed: SkyPicViewedStory = { + author: maya, + canReply: true, + caption: '', + durationSeconds: 6, + expiresAt: later, + id: story.id, + mediaType: 'photo', + mimeType: 'image/webp', + overlayColor: '#ffffff', + textOverlay: '', + url: 'https://media.example.test/story.webp', + viewedAt: now, + } + const pendingStory = deferred>() + mockNuiCall.mockReturnValueOnce(pendingStory.promise) + const store = useSkyPicStore() + + const first = store.viewStory(story.id) + expect(store.storyViewing).toBe(true) + await expect(store.viewStory('story-2')).resolves.toEqual({ + error: 'story_view_in_progress', + success: false, + }) + expect(mockNuiCall).toHaveBeenCalledTimes(1) + + store.resetSession() + pendingStory.resolve({ data: viewed, success: true }) + await expect(first).resolves.toEqual({ + error: 'request_aborted', + success: false, + }) + expect(store.viewedStory).toBeNull() + expect(store.storyViewing).toBe(false) + }) + + it('keeps only the newest story-viewer request and cancels it on reset', async () => { + const firstViewers = deferred>() + const secondViewers = deferred>() + mockNuiCall + .mockReturnValueOnce(firstViewers.promise) + .mockReturnValueOnce(secondViewers.promise) + const store = useSkyPicStore() + + const first = store.loadStoryViewers('story-a') + const second = store.loadStoryViewers('story-b') + secondViewers.resolve({ + data: [{ ...maya, viewedAt: now }], + success: true, + }) + await expect(second).resolves.toBe(true) + firstViewers.resolve({ + data: [{ ...theo, viewedAt: later }], + success: true, + }) + await expect(first).resolves.toBe(false) + expect(store.storyViewers).toEqual([{ ...maya, viewedAt: now }]) + + const pendingAfterReset = deferred>() + mockNuiCall.mockReturnValueOnce(pendingAfterReset.promise) + const refresh = store.loadStoryViewers('story-c') + store.resetSession() + pendingAfterReset.resolve({ + data: [{ ...theo, viewedAt: later }], + success: true, + }) + await expect(refresh).resolves.toBe(false) + expect(store.storyViewers).toEqual([]) + }) + + it('publishes story metadata and reconciles the authoritative score', async () => { + const published: SkyPicStory = { + ...story, + author: { ...self, snapScore: self.snapScore + 1 }, + id: 'story-own', + isOwner: true, + seen: true, + viewCount: 0, + } + mockNuiCall.mockResolvedValueOnce({ data: published, success: true }) + const store = useSkyPicStore() + store.profile = { ...self } + + expect( + ( + await store.publishStory({ + caption: ' New story ', + durationSeconds: 6, + mediaId: 44, + mediaType: 'photo', + overlayColor: '#42e8ff', + textOverlay: ' Sky ', + }) + ).success, + ).toBe(true) + expect(store.stories).toEqual([published]) + expect(store.profile.snapScore).toBe(self.snapScore + 1) + expect(mockNuiCall).toHaveBeenCalledWith('skypic:publish-story', { + caption: 'New story', + durationSeconds: 6, + mediaId: 44, + mediaType: 'photo', + overlayColor: '#42e8ff', + textOverlay: 'Sky', + }) + }) + + it('loads a thread, sends text optimistically, marks, saves and deletes it', async () => { + const received: SkyPicMessage = { + body: 'Hey', + createdAt: now, + direction: 'received', + friendshipId: friend.friendshipId, + id: 'message-received', + readAt: null, + savedAt: null, + type: 'text', + } + const sent: SkyPicMessage = { + ...received, + body: 'On my way', + createdAt: later, + direction: 'sent', + id: 'message-sent', + } + const previousSnap: SkyPicSnap = { + ...snap, + createdAt: '2026-08-19T12:30:00.000Z', + } + mockNuiCall + .mockResolvedValueOnce({ + data: { messages: [received], snaps: [previousSnap] }, + success: true, + }) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ data: sent, success: true }) + .mockResolvedValueOnce({ + data: { ...sent, savedAt: later }, + success: true, + }) + .mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.conversations = [ + { + ...conversation, + friendshipId: 'friendship-other', + profile: { + ...theo, + friendshipId: 'friendship-other', + friendshipStatus: 'friends', + }, + unreadCount: 0, + }, + { ...conversation, unreadCount: 2 }, + ] + store.unreadCount = 2 + + expect(await store.openThread(friend.friendshipId)).toBe(true) + expect(store.threadMessages).toEqual([received]) + expect(store.threadSnaps).toEqual([previousSnap]) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'skypic:thread', { + friendshipId: friend.friendshipId, + }) + + expect(await store.markThread(friend.friendshipId)).toBe(true) + expect(store.threadMessages[0].readAt).not.toBeNull() + expect(store.unreadCount).toBe(1) + expect( + store.conversations.find( + (item) => item.friendshipId === friend.friendshipId, + )?.unreadCount, + ).toBe(1) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'skypic:mark-thread', { + friendshipId: friend.friendshipId, + }) + + expect( + (await store.sendMessage(friend.friendshipId, ' On my way ')).success, + ).toBe(true) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'skypic:send-message', { + body: 'On my way', + friendshipId: friend.friendshipId, + }) + expect(store.threadMessages.at(-1)?.deliveryStatus).toBe('delivered') + expect(store.conversations[0].friendshipId).toBe(friend.friendshipId) + expect(store.conversations[0].lastItem?.id).toBe(sent.id) + + expect(await store.saveMessage(sent.id, true)).toBe(true) + expect(store.threadMessages.at(-1)?.savedAt).toBe(later) + expect(mockNuiCall).toHaveBeenNthCalledWith(4, 'skypic:save-message', { + messageId: sent.id, + saved: true, + }) + + expect(await store.deleteMessage(sent.id, true)).toBe(true) + expect(store.threadMessages.some((message) => message.id === sent.id)).toBe( + false, + ) + expect(store.conversations[0].lastItem).toMatchObject({ + id: previousSnap.id, + type: previousSnap.type, + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(5, 'skypic:delete-message', { + forEveryone: true, + messageId: sent.id, + }) + }) + + it('clears the conversation preview when its only thread item is deleted', async () => { + const onlyMessage: SkyPicMessage = { + body: 'Only message', + createdAt: later, + direction: 'sent', + friendshipId: friend.friendshipId, + id: 'message-only', + readAt: null, + savedAt: null, + type: 'text', + } + mockNuiCall.mockResolvedValueOnce({ success: true }) + const store = useSkyPicStore() + store.conversations = [ + { + ...conversation, + lastItem: { + body: onlyMessage.body, + createdAt: onlyMessage.createdAt, + direction: onlyMessage.direction, + id: onlyMessage.id, + openedAt: null, + type: 'text', + }, + }, + ] + store.threadMessages = [onlyMessage] + + expect(await store.deleteMessage(onlyMessage.id)).toBe(true) + expect(store.threadMessages).toEqual([]) + expect(store.conversations[0].lastItem).toBeNull() + expect(mockNuiCall).toHaveBeenCalledWith('skypic:delete-message', { + forEveryone: false, + messageId: onlyMessage.id, + }) + }) + + it('links a story reply to the exact story in the send-message payload', async () => { + const reply: SkyPicMessage = { + body: 'Looks great', + createdAt: later, + direction: 'sent', + friendshipId: friend.friendshipId, + id: 'message-story-reply', + readAt: null, + savedAt: null, + type: 'text', + } + mockNuiCall.mockResolvedValueOnce({ data: reply, success: true }) + const store = useSkyPicStore() + + expect( + (await store.sendMessage(friend.friendshipId, ' Looks great ', story.id)) + .success, + ).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('skypic:send-message', { + body: 'Looks great', + friendshipId: friend.friendshipId, + storyId: story.id, + }) + }) + + it('keeps the newest thread when overlapping requests resolve out of order', async () => { + const firstThread = deferred>() + const secondThread = deferred>() + const refreshedMessage: SkyPicMessage = { + body: 'Latest', + createdAt: later, + direction: 'received', + friendshipId: 'friendship-b', + id: 'message-b', + readAt: null, + savedAt: null, + type: 'text', + } + mockNuiCall + .mockReturnValueOnce(firstThread.promise) + .mockReturnValueOnce(secondThread.promise) + .mockResolvedValueOnce({ + data: { messages: [refreshedMessage], snaps: [] }, + success: true, + }) + const store = useSkyPicStore() + + const first = store.openThread('friendship-a') + const second = store.openThread('friendship-b') + secondThread.resolve({ data: { messages: [], snaps: [] }, success: true }) + await expect(second).resolves.toBe(true) + firstThread.resolve({ + data: { messages: [], snaps: [snap] }, + success: true, + }) + await expect(first).resolves.toBe(false) + expect(store.activeFriendshipId).toBe('friendship-b') + expect(store.threadSnaps).toEqual([]) + + await expect(store.refreshActiveThread()).resolves.toBe(true) + expect(store.threadMessages).toEqual([refreshedMessage]) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'skypic:thread', { + friendshipId: 'friendship-b', + }) + }) + + it('resets every account-bound surface and ignores an older bootstrap response', async () => { + const pendingBootstrap = deferred>() + mockNuiCall.mockReturnValueOnce(pendingBootstrap.promise) + const store = useSkyPicStore() + store.profile = { ...self } + store.blockedProfiles = [{ ...theo }] + store.friends = [{ ...friend }] + store.requests = [{ ...request }] + store.conversations = [{ ...conversation }] + store.inbox = [{ ...snap }] + store.stories = [{ ...story }] + store.suggestions = [{ ...theo }] + store.searchResults = [{ ...maya }] + store.unreadCount = 7 + store.activeFriendshipId = friend.friendshipId + store.threadSnaps = [{ ...snap }] + store.openedSnap = { ...openedSnap } + + const refresh = store.bootstrap() + store.resetSession() + pendingBootstrap.resolve({ data: bootstrap, success: true }) + + await expect(refresh).resolves.toBe(false) + expect(store.profile).toBeNull() + expect(store.blockedProfiles).toEqual([]) + expect(store.friends).toEqual([]) + expect(store.requests).toEqual([]) + expect(store.conversations).toEqual([]) + expect(store.inbox).toEqual([]) + expect(store.stories).toEqual([]) + expect(store.suggestions).toEqual([]) + expect(store.searchResults).toEqual([]) + expect(store.unreadCount).toBe(0) + expect(store.activeFriendshipId).toBeNull() + expect(store.threadSnaps).toEqual([]) + expect(store.openedSnap).toBeNull() + expect(store.loading).toBe(false) + }) + + it('deduplicates concurrent bootstrap requests in the same session', async () => { + const pendingBootstrap = deferred>() + mockNuiCall.mockReturnValueOnce(pendingBootstrap.promise) + const store = useSkyPicStore() + + const first = store.bootstrap() + const second = store.bootstrap() + expect(mockNuiCall).toHaveBeenCalledTimes(1) + + pendingBootstrap.resolve({ data: bootstrap, success: true }) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(store.profile).toEqual(self) + expect(store.loading).toBe(false) + }) + + it('paginates stories and viewer lists with offsets and ID deduplication', async () => { + const firstStories = Array.from({ length: 30 }, (_, index) => ({ + ...story, + id: 'story-' + index, + })) + const moreStories = [ + { ...story, id: 'story-29' }, + { ...story, id: 'story-30' }, + ] + const firstViewers: SkyPicStoryViewer[] = Array.from( + { length: 30 }, + (_, index) => ({ + ...maya, + handle: 'viewer-' + index, + id: 'viewer-' + index, + viewedAt: now, + }), + ) + const moreViewers: SkyPicStoryViewer[] = [ + { ...firstViewers[29] }, + { ...theo, id: 'viewer-30', viewedAt: later }, + ] + mockNuiCall + .mockResolvedValueOnce({ data: firstStories, success: true }) + .mockResolvedValueOnce({ data: moreStories, success: true }) + .mockResolvedValueOnce({ data: firstViewers, success: true }) + .mockResolvedValueOnce({ data: moreViewers, success: true }) + const store = useSkyPicStore() + + expect(await store.loadStories()).toBe(true) + expect(store.storiesHasMore).toBe(true) + expect(await store.loadMoreStories()).toBe(true) + expect(store.stories).toHaveLength(31) + expect(store.storiesHasMore).toBe(false) + expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'skypic:stories', { + offset: 0, + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'skypic:stories', { + offset: 30, + }) + + expect(await store.loadStoryViewers(story.id)).toBe(true) + expect(store.storyViewersHasMore).toBe(true) + expect(await store.loadMoreStoryViewers(story.id)).toBe(true) + expect(store.storyViewers).toHaveLength(31) + expect(store.storyViewersHasMore).toBe(false) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'skypic:story-viewers', { + offset: 0, + storyId: story.id, + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(4, 'skypic:story-viewers', { + offset: 30, + storyId: story.id, + }) + }) + + it('keeps stale data when a refresh fails', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'request_timeout', + success: false, + }) + const store = useSkyPicStore() + store.profile = { ...self } + store.friends = [{ ...friend }] + + expect(await store.bootstrap()).toBe(false) + expect(store.profile).toEqual(self) + expect(store.friends).toEqual([friend]) + expect(store.error).toBe('request_timeout') + }) +}) diff --git a/frontend/src/stores/skypic.ts b/frontend/src/stores/skypic.ts new file mode 100644 index 0000000..1616f8b --- /dev/null +++ b/frontend/src/stores/skypic.ts @@ -0,0 +1,1051 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import type { + SkyPicBootstrap, + SkyPicConversation, + SkyPicConversationLastItem, + SkyPicCreateProfileInput, + SkyPicFriend, + SkyPicFriendRequest, + SkyPicMessage, + SkyPicOpenedSnap, + SkyPicProfile, + SkyPicProfileSummary, + SkyPicPublishStoryInput, + SkyPicSendSnapInput, + SkyPicSnap, + SkyPicStory, + SkyPicStoryViewer, + SkyPicThread, + SkyPicUpdateProfileInput, + SkyPicViewedStory, +} from '@/types/skypic' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +const MAX_MESSAGE_CHARACTERS = 2_000 +const MAX_TEXT_OVERLAY_CHARACTERS = 160 +const STORY_PAGE_SIZE = 30 + +function arrayOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +function clampDuration(value: number): number { + return Math.max(1, Math.min(10, Math.round(Number(value) || 1))) +} + +function normalizeColor(value: string): string { + return /^#[0-9a-f]{6}$/i.test(value) ? value : '#ffffff' +} + +function uniqueById(items: T[]): T[] { + return [...new Map(items.map((item) => [item.id, item])).values()] +} + +function messageTime(value: string | null | undefined): number { + const parsed = value ? Date.parse(value) : 0 + return Number.isFinite(parsed) ? parsed : 0 +} + +function sortByLastItem(items: SkyPicConversation[]): SkyPicConversation[] { + return [...items].sort( + (a, b) => + messageTime(b.lastItem?.createdAt) - messageTime(a.lastItem?.createdAt), + ) +} + +export const useSkyPicStore = defineStore('skypic', () => { + const profile = ref(null) + const blockedProfiles = ref([]) + const friends = ref([]) + const requests = ref([]) + const conversations = ref([]) + const inbox = ref([]) + const stories = ref([]) + const suggestions = ref([]) + const searchResults = ref([]) + const unreadCount = ref(0) + + const activeFriendshipId = ref(null) + const threadMessages = ref([]) + const threadSnaps = ref([]) + const openedSnap = ref(null) + const viewedStory = ref(null) + const storyViewers = ref([]) + const storiesHasMore = ref(true) + const storyViewersHasMore = ref(true) + + const loading = ref(false) + const threadLoading = ref(false) + const searchLoading = ref(false) + const snapOpening = ref(false) + const storyViewing = ref(false) + const storiesLoadingMore = ref(false) + const storyViewersLoadingMore = ref(false) + const error = ref(null) + let bootstrapRequest = 0 + let searchRequest = 0 + let snapRequest = 0 + let storyRequest = 0 + let storyViewersRequest = 0 + let threadRequest = 0 + let sessionVersion = 0 + let storyViewersStoryId: string | null = null + let bootstrapInFlight: { + promise: Promise + session: number + token: symbol + } | null = null + + const hasProfile = computed(() => profile.value !== null) + const incomingRequests = computed(() => + requests.value.filter((request) => request.direction === 'incoming'), + ) + const outgoingRequests = computed(() => + requests.value.filter((request) => request.direction === 'outgoing'), + ) + const activeConversation = computed( + () => + conversations.value.find( + (conversation) => + conversation.friendshipId === activeFriendshipId.value, + ) ?? null, + ) + + function upsertConversationLastItem( + friendshipId: string, + lastItem: SkyPicConversationLastItem, + ): void { + let conversation = conversations.value.find( + (item) => item.friendshipId === friendshipId, + ) + if (!conversation) { + const friend = friends.value.find( + (item) => item.friendshipId === friendshipId, + ) + if (!friend) return + conversation = { + bestStreak: friend.bestStreak, + friendshipId, + lastItem: null, + profile: friend.profile, + streakCount: friend.streakCount, + unreadCount: 0, + } + conversations.value.push(conversation) + } + conversation.lastItem = lastItem + conversations.value = sortByLastItem(conversations.value) + } + + function setError(response: NuiResponse): void { + if (response.error === 'request_aborted') return + error.value = response.success ? null : (response.error ?? 'unknown_error') + } + + async function sessionCall( + endpoint: string, + data: Record = {}, + ): Promise> { + const requestSession = sessionVersion + const response = await nuiCall(endpoint, data) + return requestSession === sessionVersion + ? response + : { error: 'request_aborted', success: false } + } + + function resetSession(): void { + sessionVersion += 1 + bootstrapInFlight = null + bootstrapRequest += 1 + searchRequest += 1 + snapRequest += 1 + storyRequest += 1 + storyViewersRequest += 1 + threadRequest += 1 + storyViewersStoryId = null + profile.value = null + blockedProfiles.value = [] + friends.value = [] + requests.value = [] + conversations.value = [] + inbox.value = [] + stories.value = [] + suggestions.value = [] + searchResults.value = [] + unreadCount.value = 0 + activeFriendshipId.value = null + threadMessages.value = [] + threadSnaps.value = [] + openedSnap.value = null + viewedStory.value = null + storyViewers.value = [] + storiesHasMore.value = true + storyViewersHasMore.value = true + loading.value = false + threadLoading.value = false + searchLoading.value = false + snapOpening.value = false + storyViewing.value = false + storiesLoadingMore.value = false + storyViewersLoadingMore.value = false + error.value = null + } + + function hydrate(data: SkyPicBootstrap): void { + profile.value = data.profile ?? null + if (!profile.value) { + resetSession() + return + } + + blockedProfiles.value = arrayOrEmpty(data.blockedProfiles) + friends.value = arrayOrEmpty(data.friends) + requests.value = arrayOrEmpty(data.requests) + conversations.value = sortByLastItem(arrayOrEmpty(data.conversations)) + inbox.value = arrayOrEmpty(data.inbox) + stories.value = arrayOrEmpty(data.stories) + storiesHasMore.value = stories.value.length >= STORY_PAGE_SIZE + suggestions.value = arrayOrEmpty(data.suggestions) + unreadCount.value = Math.max(0, Number(data.unreadCount) || 0) + } + + function bootstrap(): Promise { + const requestSession = sessionVersion + if (bootstrapInFlight?.session === requestSession) { + return bootstrapInFlight.promise + } + const requestId = ++bootstrapRequest + loading.value = true + const token = Symbol('skypic-bootstrap') + const promise = (async () => { + try { + const response = await sessionCall('skypic:bootstrap') + if (requestId !== bootstrapRequest) return false + loading.value = false + setError(response) + if (!response.success || !response.data) return false + hydrate(response.data) + return true + } finally { + if (bootstrapInFlight?.token === token) bootstrapInFlight = null + } + })() + bootstrapInFlight = { promise, session: requestSession, token } + return promise + } + + async function createProfile( + input: SkyPicCreateProfileInput, + ): Promise> { + const payload: SkyPicCreateProfileInput = { + ...(input.avatarMediaId === undefined + ? {} + : { avatarMediaId: input.avatarMediaId }), + ...(input.avatarSeed === undefined + ? {} + : { avatarSeed: input.avatarSeed }), + displayName: input.displayName.trim(), + handle: input.handle.trim().toLowerCase(), + } + const response = await sessionCall( + 'skypic:create-profile', + payload, + ) + setError(response) + if (response.success && response.data) profile.value = response.data + return response + } + + async function updateProfile( + input: SkyPicUpdateProfileInput, + ): Promise> { + const payload: SkyPicUpdateProfileInput = { + ...input, + bio: input.bio.trim(), + displayName: input.displayName.trim(), + handle: input.handle.trim().toLowerCase(), + } + const response = await sessionCall( + 'skypic:update-profile', + payload, + ) + setError(response) + if (response.success && response.data) profile.value = response.data + return response + } + + async function search(query: string): Promise { + const normalized = query.trim() + const requestId = ++searchRequest + if (!normalized) { + searchResults.value = [] + searchLoading.value = false + return true + } + + searchLoading.value = true + const response = await sessionCall( + 'skypic:search', + { + query: normalized, + }, + ) + if (requestId !== searchRequest) return false + searchLoading.value = false + setError(response) + if (!response.success || !response.data) { + searchResults.value = [] + return false + } + searchResults.value = response.data + return true + } + + async function addFriend( + profileId: string, + ): Promise> { + const response = await sessionCall( + 'skypic:add-friend', + { + profileId, + }, + ) + setError(response) + if (!response.success) return response + + const markOutgoing = (item: SkyPicProfileSummary): SkyPicProfileSummary => + item.id === profileId + ? { + ...item, + friendshipId: response.data?.friendshipId ?? item.friendshipId, + friendshipStatus: 'outgoing', + } + : item + suggestions.value = suggestions.value.map(markOutgoing) + searchResults.value = searchResults.value.map(markOutgoing) + if (response.data) { + requests.value = [ + response.data, + ...requests.value.filter( + (request) => request.friendshipId !== response.data?.friendshipId, + ), + ] + } + return response + } + + async function respondFriend( + friendshipId: string, + accept: boolean, + ): Promise> { + const response = await sessionCall('skypic:respond-friend', { + accept, + friendshipId, + }) + setError(response) + if (!response.success) return response + + const pendingRequest = requests.value.find( + (request) => request.friendshipId === friendshipId, + ) + requests.value = requests.value.filter( + (request) => request.friendshipId !== friendshipId, + ) + if (accept && response.data) { + friends.value = [ + ...friends.value.filter( + (friend) => friend.friendshipId !== response.data?.friendshipId, + ), + response.data, + ] + if (profile.value) profile.value.friendCount += 1 + } + const relatedProfileId = + response.data?.profile.id ?? pendingRequest?.profile.id + if (relatedProfileId) { + const updateRelationship = ( + item: SkyPicProfileSummary, + ): SkyPicProfileSummary => + item.id === relatedProfileId + ? { + ...item, + friendshipId: + accept && response.data ? response.data.friendshipId : null, + friendshipStatus: accept && response.data ? 'friends' : 'none', + } + : item + suggestions.value = suggestions.value.map(updateRelationship) + searchResults.value = searchResults.value.map(updateRelationship) + } + return response + } + + async function removeFriend(friendshipId: string): Promise { + const response = await sessionCall('skypic:remove-friend', { friendshipId }) + setError(response) + if (!response.success) return false + + const removedFriend = friends.value.find( + (friend) => friend.friendshipId === friendshipId, + ) + const removedRequest = requests.value.find( + (request) => request.friendshipId === friendshipId, + ) + const profileId = removedFriend?.profile.id ?? removedRequest?.profile.id + friends.value = friends.value.filter( + (friend) => friend.friendshipId !== friendshipId, + ) + requests.value = requests.value.filter( + (request) => request.friendshipId !== friendshipId, + ) + conversations.value = conversations.value.filter( + (conversation) => conversation.friendshipId !== friendshipId, + ) + inbox.value = inbox.value.filter( + (snap) => snap.friendshipId !== friendshipId, + ) + const clearRelation = (item: SkyPicProfileSummary): SkyPicProfileSummary => + item.id === profileId + ? { ...item, friendshipId: null, friendshipStatus: 'none' } + : item + suggestions.value = suggestions.value.map(clearRelation) + searchResults.value = searchResults.value.map(clearRelation) + if (activeFriendshipId.value === friendshipId) closeThread() + if (profile.value && removedFriend) { + profile.value.friendCount = Math.max(0, profile.value.friendCount - 1) + } + return true + } + + async function block(profileId: string, blocked = true): Promise { + const response = await sessionCall('skypic:block', { blocked, profileId }) + setError(response) + if (!response.success) return false + if (!blocked) { + blockedProfiles.value = blockedProfiles.value.filter( + (item) => item.id !== profileId, + ) + return true + } + + const removedFriend = friends.value.some( + (friend) => friend.profile.id === profileId, + ) + const blockedProfile = + friends.value.find((friend) => friend.profile.id === profileId) + ?.profile ?? + requests.value.find((request) => request.profile.id === profileId) + ?.profile ?? + conversations.value.find( + (conversation) => conversation.profile.id === profileId, + )?.profile ?? + suggestions.value.find((item) => item.id === profileId) ?? + searchResults.value.find((item) => item.id === profileId) + const friendshipIds = new Set([ + ...friends.value + .filter((friend) => friend.profile.id === profileId) + .map((friend) => friend.friendshipId), + ...requests.value + .filter((request) => request.profile.id === profileId) + .map((request) => request.friendshipId), + ...conversations.value + .filter((conversation) => conversation.profile.id === profileId) + .map((conversation) => conversation.friendshipId), + ]) + friends.value = friends.value.filter( + (friend) => friend.profile.id !== profileId, + ) + requests.value = requests.value.filter( + (request) => request.profile.id !== profileId, + ) + conversations.value = conversations.value.filter( + (conversation) => conversation.profile.id !== profileId, + ) + suggestions.value = suggestions.value.filter( + (item) => item.id !== profileId, + ) + searchResults.value = searchResults.value.filter( + (item) => item.id !== profileId, + ) + stories.value = stories.value.filter( + (story) => story.author.id !== profileId, + ) + inbox.value = inbox.value.filter( + (snap) => + snap.sender.id !== profileId && !friendshipIds.has(snap.friendshipId), + ) + threadSnaps.value = threadSnaps.value.filter( + (snap) => + snap.sender.id !== profileId && !friendshipIds.has(snap.friendshipId), + ) + if ( + activeFriendshipId.value && + friendshipIds.has(activeFriendshipId.value) + ) { + closeThread() + } + if (profile.value && removedFriend) { + profile.value.friendCount = Math.max(0, profile.value.friendCount - 1) + } + if (blockedProfile) { + blockedProfiles.value = uniqueById([ + { + ...blockedProfile, + friendshipId: null, + friendshipStatus: 'none', + }, + ...blockedProfiles.value, + ]) + } + return true + } + + function updateSnapMetadata( + snapId: string, + fields: Pick, + ): SkyPicSnap | null { + let previous: SkyPicSnap | null = null + const update = (snap: SkyPicSnap): SkyPicSnap => { + if (snap.id !== snapId) return snap + previous ??= snap + return { ...snap, ...fields } + } + inbox.value = inbox.value.map(update) + threadSnaps.value = threadSnaps.value.map(update) + return previous + } + + async function sendSnap( + input: SkyPicSendSnapInput, + ): Promise> { + const payload: SkyPicSendSnapInput = { + ...input, + caption: input.caption.trim(), + durationSeconds: clampDuration(input.durationSeconds), + overlayColor: normalizeColor(input.overlayColor), + recipientIds: [...new Set(input.recipientIds.filter(Boolean))].slice( + 0, + 20, + ), + textOverlay: Array.from(input.textOverlay.trim()) + .slice(0, MAX_TEXT_OVERLAY_CHARACTERS) + .join(''), + } + const response = await sessionCall( + 'skypic:send-snap', + payload, + ) + setError(response) + if (response.success && response.data) { + const active = activeFriendshipId.value + threadSnaps.value = uniqueById([ + ...threadSnaps.value, + ...response.data.filter((snap) => snap.friendshipId === active), + ]) + for (const snap of response.data) { + upsertConversationLastItem(snap.friendshipId, { + createdAt: snap.createdAt, + direction: snap.direction, + id: snap.id, + openedAt: snap.openedAt, + type: snap.type, + }) + } + if (profile.value && response.data.length) { + const serverScore = response.data.find( + (snap) => snap.sender.id === profile.value?.id, + )?.sender.snapScore + profile.value.snapScore = + serverScore ?? profile.value.snapScore + response.data.length + } + } + return response + } + + async function openSnap( + snapId: string, + ): Promise> { + if (snapOpening.value) { + return { error: 'snap_open_in_progress', success: false } + } + const requestId = ++snapRequest + snapOpening.value = true + try { + const response = await sessionCall('skypic:open-snap', { + snapId, + }) + if (requestId !== snapRequest) { + return { error: 'request_aborted', success: false } + } + setError(response) + if (!response.success || !response.data) return response + + const previous = updateSnapMetadata(snapId, { + openedAt: response.data.openedAt, + replayedAt: response.data.replayedAt, + }) + if (previous && !previous.openedAt && previous.direction === 'received') { + unreadCount.value = Math.max(0, unreadCount.value - 1) + const conversation = conversations.value.find( + (item) => item.friendshipId === previous.friendshipId, + ) + if (conversation) { + conversation.unreadCount = Math.max(0, conversation.unreadCount - 1) + if (conversation.lastItem?.id === snapId) { + conversation.lastItem.openedAt = response.data.openedAt + } + } + if (profile.value) profile.value.snapScore += 1 + } + openedSnap.value = response.data + return response + } finally { + if (requestId === snapRequest) snapOpening.value = false + } + } + + async function replaySnap( + snapId: string, + ): Promise> { + if (snapOpening.value) { + return { error: 'snap_open_in_progress', success: false } + } + const requestId = ++snapRequest + snapOpening.value = true + try { + const response = await sessionCall( + 'skypic:replay-snap', + { + snapId, + }, + ) + if (requestId !== snapRequest) { + return { error: 'request_aborted', success: false } + } + setError(response) + if (response.success && response.data) { + updateSnapMetadata(snapId, { + openedAt: response.data.openedAt, + replayedAt: response.data.replayedAt, + }) + openedSnap.value = response.data + } + return response + } finally { + if (requestId === snapRequest) snapOpening.value = false + } + } + + function clearOpenedSnap(): void { + openedSnap.value = null + } + + async function loadStories(): Promise { + const response = await sessionCall('skypic:stories', { + offset: 0, + }) + setError(response) + if (!response.success || !response.data) return false + stories.value = response.data + storiesHasMore.value = response.data.length >= STORY_PAGE_SIZE + return true + } + + async function loadMoreStories(): Promise { + if (!storiesHasMore.value || storiesLoadingMore.value) return true + storiesLoadingMore.value = true + const response = await sessionCall('skypic:stories', { + offset: stories.value.length, + }) + storiesLoadingMore.value = false + setError(response) + if (!response.success || !response.data) return false + stories.value = uniqueById([...stories.value, ...response.data]) + storiesHasMore.value = response.data.length >= STORY_PAGE_SIZE + return true + } + + async function publishStory( + input: SkyPicPublishStoryInput, + ): Promise> { + const payload: SkyPicPublishStoryInput = { + ...input, + caption: input.caption.trim(), + durationSeconds: clampDuration(input.durationSeconds), + overlayColor: normalizeColor(input.overlayColor), + textOverlay: Array.from(input.textOverlay.trim()) + .slice(0, MAX_TEXT_OVERLAY_CHARACTERS) + .join(''), + } + const response = await sessionCall( + 'skypic:publish-story', + payload, + ) + setError(response) + if (response.success && response.data) { + stories.value = uniqueById([response.data, ...stories.value]) + if (profile.value) { + profile.value.snapScore = + response.data.author.id === profile.value.id + ? response.data.author.snapScore + : profile.value.snapScore + 1 + } + } + return response + } + + async function viewStory( + storyId: string, + ): Promise> { + if (storyViewing.value) { + return { error: 'story_view_in_progress', success: false } + } + const requestId = ++storyRequest + storyViewing.value = true + try { + const response = await sessionCall( + 'skypic:view-story', + { storyId }, + ) + if (requestId !== storyRequest) { + return { error: 'request_aborted', success: false } + } + setError(response) + if (!response.success || !response.data) return response + + stories.value = stories.value.map((story) => + story.id === storyId + ? { + ...story, + seen: true, + viewCount: + story.isOwner || story.seen + ? story.viewCount + : story.viewCount + 1, + } + : story, + ) + viewedStory.value = response.data + return response + } finally { + if (requestId === storyRequest) storyViewing.value = false + } + } + + function clearViewedStory(): void { + storyRequest += 1 + storyViewing.value = false + viewedStory.value = null + } + + async function loadStoryViewers(storyId: string): Promise { + const requestId = ++storyViewersRequest + const response = await sessionCall( + 'skypic:story-viewers', + { offset: 0, storyId }, + ) + if (requestId !== storyViewersRequest) return false + setError(response) + if (!response.success || !response.data) return false + storyViewers.value = response.data + storyViewersStoryId = storyId + storyViewersHasMore.value = response.data.length >= STORY_PAGE_SIZE + return true + } + + async function loadMoreStoryViewers(storyId: string): Promise { + if ( + storyViewersStoryId !== storyId || + !storyViewersHasMore.value || + storyViewersLoadingMore.value + ) { + return true + } + storyViewersLoadingMore.value = true + const requestId = storyViewersRequest + const response = await sessionCall( + 'skypic:story-viewers', + { offset: storyViewers.value.length, storyId }, + ) + storyViewersLoadingMore.value = false + if (requestId !== storyViewersRequest || storyViewersStoryId !== storyId) { + return false + } + setError(response) + if (!response.success || !response.data) return false + storyViewers.value = uniqueById([...storyViewers.value, ...response.data]) + storyViewersHasMore.value = response.data.length >= STORY_PAGE_SIZE + return true + } + + async function removeStory(storyId: string): Promise { + const response = await sessionCall('skypic:remove-story', { storyId }) + setError(response) + if (!response.success) return false + stories.value = stories.value.filter((story) => story.id !== storyId) + if (viewedStory.value?.id === storyId) clearViewedStory() + storyViewers.value = [] + return true + } + + async function openThread(friendshipId: string): Promise { + const requestId = ++threadRequest + threadLoading.value = true + const response = await sessionCall('skypic:thread', { + friendshipId, + }) + if (requestId !== threadRequest) return false + threadLoading.value = false + setError(response) + if (!response.success || !response.data) return false + activeFriendshipId.value = friendshipId + threadMessages.value = arrayOrEmpty(response.data.messages) + threadSnaps.value = arrayOrEmpty(response.data.snaps) + return true + } + + async function refreshActiveThread(): Promise { + const friendshipId = activeFriendshipId.value + if (!friendshipId) return false + return openThread(friendshipId) + } + + function closeThread(): void { + threadRequest += 1 + activeFriendshipId.value = null + threadMessages.value = [] + threadSnaps.value = [] + threadLoading.value = false + } + + async function sendMessage( + friendshipId: string, + body: string, + storyId?: string, + ): Promise> { + const trimmed = Array.from(body.trim()) + .slice(0, MAX_MESSAGE_CHARACTERS) + .join('') + if (!trimmed) return { error: 'message_empty', success: false } + const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const optimistic: SkyPicMessage = { + body: trimmed, + clientId, + createdAt: new Date().toISOString(), + deliveryStatus: 'sending', + direction: 'sent', + friendshipId, + id: clientId, + readAt: null, + savedAt: null, + type: 'text', + } + if (activeFriendshipId.value === friendshipId) { + threadMessages.value.push(optimistic) + } + + const response = await sessionCall('skypic:send-message', { + body: trimmed, + friendshipId, + ...(storyId ? { storyId } : {}), + }) + setError(response) + const index = threadMessages.value.findIndex( + (message) => message.clientId === clientId, + ) + if (!response.success || !response.data) { + if (index >= 0) threadMessages.value[index].deliveryStatus = 'failed' + return response + } + + if (index >= 0) { + threadMessages.value[index] = { + ...response.data, + clientId, + deliveryStatus: 'delivered', + } + } + upsertConversationLastItem(friendshipId, { + body: response.data.body, + createdAt: response.data.createdAt, + direction: response.data.direction, + id: response.data.id, + openedAt: null, + type: 'text', + }) + return response + } + + async function markThread(friendshipId: string): Promise { + const response = await sessionCall('skypic:mark-thread', { friendshipId }) + setError(response) + if (!response.success) return false + const conversation = conversations.value.find( + (item) => item.friendshipId === friendshipId, + ) + if (conversation) { + const unopenedSnapIds = new Set( + [...inbox.value, ...threadSnaps.value] + .filter( + (snap) => + snap.friendshipId === friendshipId && + snap.direction === 'received' && + !snap.openedAt, + ) + .map((snap) => snap.id), + ) + const remainingSnapCount = unopenedSnapIds.size + const markedTextCount = Math.max( + 0, + conversation.unreadCount - remainingSnapCount, + ) + unreadCount.value = Math.max(0, unreadCount.value - markedTextCount) + conversation.unreadCount = remainingSnapCount + } + const readAt = new Date().toISOString() + threadMessages.value = threadMessages.value.map((message) => + message.direction === 'received' && !message.readAt + ? { ...message, readAt } + : message, + ) + return true + } + + async function saveMessage( + messageId: string, + saved: boolean, + ): Promise { + const response = await sessionCall('skypic:save-message', { + messageId, + saved, + }) + setError(response) + if (!response.success) return false + threadMessages.value = threadMessages.value.map((message) => + message.id === messageId + ? (response.data ?? { + ...message, + savedAt: saved ? new Date().toISOString() : null, + }) + : message, + ) + return true + } + + async function deleteMessage( + messageId: string, + forEveryone = false, + ): Promise { + const response = await sessionCall('skypic:delete-message', { + forEveryone, + messageId, + }) + setError(response) + if (!response.success) return false + const deletedMessage = threadMessages.value.find( + (message) => message.id === messageId, + ) + threadMessages.value = threadMessages.value.filter( + (message) => message.id !== messageId, + ) + if (!deletedMessage) return true + + const conversation = conversations.value.find( + (item) => item.friendshipId === deletedMessage.friendshipId, + ) + if (conversation?.lastItem?.id === messageId) { + const remainingItems: SkyPicConversationLastItem[] = [ + ...threadMessages.value + .filter( + (message) => message.friendshipId === deletedMessage.friendshipId, + ) + .map((message) => ({ + body: message.body, + createdAt: message.createdAt, + direction: message.direction, + id: message.id, + openedAt: null, + type: 'text' as const, + })), + ...threadSnaps.value + .filter((snap) => snap.friendshipId === deletedMessage.friendshipId) + .map((snap) => ({ + createdAt: snap.createdAt, + direction: snap.direction, + id: snap.id, + openedAt: snap.openedAt, + type: snap.type, + })), + ] + conversation.lastItem = + remainingItems.sort( + (a, b) => messageTime(b.createdAt) - messageTime(a.createdAt), + )[0] ?? null + conversations.value = sortByLastItem(conversations.value) + } + return true + } + + return { + activeConversation, + activeFriendshipId, + addFriend, + block, + blockedProfiles, + bootstrap, + clearOpenedSnap, + clearViewedStory, + closeThread, + conversations, + createProfile, + deleteMessage, + error, + friends, + hasProfile, + inbox, + incomingRequests, + loadStories, + loadStoryViewers, + loadMoreStories, + loadMoreStoryViewers, + loading, + markThread, + openSnap, + openedSnap, + openThread, + outgoingRequests, + profile, + publishStory, + refreshActiveThread, + removeFriend, + removeStory, + replaySnap, + resetSession, + requests, + respondFriend, + saveMessage, + search, + searchLoading, + searchResults, + sendMessage, + sendSnap, + snapOpening, + stories, + storiesHasMore, + storiesLoadingMore, + storyViewers, + storyViewersHasMore, + storyViewersLoadingMore, + storyViewing, + suggestions, + threadLoading, + threadMessages, + threadSnaps, + unreadCount, + updateProfile, + viewedStory, + viewStory, + } +}) diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index f0cae91..8cc585f 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -36,6 +36,7 @@ export type BuiltinPhoneAppId = | 'flare' | 'fliptok' | 'picstagram' + | 'skypic' | 'skyride' | 'feather' | 'crewlink' diff --git a/frontend/src/types/skypic.ts b/frontend/src/types/skypic.ts new file mode 100644 index 0000000..8f3bd03 --- /dev/null +++ b/frontend/src/types/skypic.ts @@ -0,0 +1,198 @@ +import type { MediaType } from '@/types/media' + +export type SkyPicStoryPrivacy = 'everyone' | 'friends' +export type SkyPicDirection = 'received' | 'sent' +export type SkyPicSnapType = 'snap_photo' | 'snap_video' +export type SkyPicFriendshipStatus = + | 'friends' + | 'incoming' + | 'none' + | 'outgoing' + +export type SkyPicProfileSummary = { + avatarSeed: number + avatarUrl: string | null + displayName: string + friendshipId?: string | null + friendshipStatus: SkyPicFriendshipStatus + handle: string + id: string + snapScore: number +} + +export type SkyPicProfile = SkyPicProfileSummary & { + allowStoryReplies: boolean + avatarMediaId: number | null + bio: string + friendCount: number + showInQuickAdd: boolean + storyPrivacy: SkyPicStoryPrivacy +} + +export type SkyPicCreateProfileInput = { + avatarMediaId?: number + avatarSeed?: number + displayName: string + handle: string +} + +export type SkyPicUpdateProfileInput = { + allowStoryReplies: boolean + avatarMediaId?: number | null + avatarSeed?: number + bio: string + displayName: string + handle: string + showInQuickAdd: boolean + storyPrivacy: SkyPicStoryPrivacy +} + +export type SkyPicFriend = { + bestStreak: number + createdAt: string + friendshipId: string + profile: SkyPicProfileSummary + streakCount: number +} + +export type SkyPicFriendRequest = { + createdAt: string + direction: 'incoming' | 'outgoing' + friendshipId: string + profile: SkyPicProfileSummary +} + +export type SkyPicConversationLastItem = { + body?: string + createdAt: string + direction: SkyPicDirection + id: string + openedAt: string | null + type: 'snap_photo' | 'snap_video' | 'text' +} + +export type SkyPicConversation = { + bestStreak: number + friendshipId: string + lastItem: SkyPicConversationLastItem | null + profile: SkyPicProfileSummary + streakCount: number + unreadCount: number +} + +/** Direct snap lists intentionally contain no media URL or editor contents. */ +export type SkyPicSnap = { + allowReplay: boolean + createdAt: string + direction: SkyPicDirection + durationSeconds: number + expiresAt: string + friendshipId: string + id: string + openedAt: string | null + replayedAt: string | null + sender: SkyPicProfileSummary + type: SkyPicSnapType +} + +/** Media and editor contents are released only by open/replay callbacks. */ +export type SkyPicOpenedSnap = { + allowReplay: boolean + caption: string + durationSeconds: number + expiresAt: string + id: string + mediaType: MediaType + mimeType: string | null + openedAt: string + overlayColor: string + replayedAt: string | null + textOverlay: string + url: string +} + +/** Bootstrap returns story metadata only; URLs are released by view-story. */ +export type SkyPicStory = { + author: SkyPicProfileSummary + createdAt: string + durationSeconds: number + expiresAt: string + id: string + isOwner: boolean + seen: boolean + viewCount: number +} + +export type SkyPicViewedStory = { + author: SkyPicProfileSummary + canReply: boolean + caption: string + durationSeconds: number + expiresAt: string + id: string + mediaType: MediaType + mimeType: string | null + overlayColor: string + textOverlay: string + url: string + viewedAt: string +} + +export type SkyPicStoryViewer = SkyPicProfileSummary & { + viewedAt: string +} + +export type SkyPicMessageDeliveryStatus = 'delivered' | 'failed' | 'sending' + +export type SkyPicMessage = { + body: string + clientId?: string + createdAt: string + deliveryStatus?: SkyPicMessageDeliveryStatus + direction: SkyPicDirection + friendshipId: string + id: string + readAt: string | null + savedAt: string | null + type: 'text' +} + +export type SkyPicThread = { + messages: SkyPicMessage[] + snaps: SkyPicSnap[] +} + +export type SkyPicBootstrap = { + blockedProfiles: SkyPicProfileSummary[] + conversations: SkyPicConversation[] + friends: SkyPicFriend[] + inbox: SkyPicSnap[] + profile: SkyPicProfile | null + requests: SkyPicFriendRequest[] + stories: SkyPicStory[] + suggestions: SkyPicProfileSummary[] + unreadCount: number +} + +export type SkyPicDraftPurpose = 'snap' | 'story' + +export type SkyPicMediaDraftContext = { + purpose: SkyPicDraftPurpose + recipientIds: string[] +} + +export type SkyPicSendSnapInput = { + allowReplay: boolean + caption: string + durationSeconds: number + mediaId: number + mediaType: MediaType + overlayColor: string + recipientIds: string[] + textOverlay: string +} + +export type SkyPicPublishStoryInput = Omit< + SkyPicSendSnapInput, + 'allowReplay' | 'recipientIds' +> diff --git a/frontend/src/utils/appStorePreviews.ts b/frontend/src/utils/appStorePreviews.ts index c393593..f2ba220 100644 --- a/frontend/src/utils/appStorePreviews.ts +++ b/frontend/src/utils/appStorePreviews.ts @@ -15,6 +15,7 @@ const APP_STORE_PREVIEW_STYLES = { companies: { accent: '#4a92ff', surface: '#0c1728' }, music: { accent: '#fa3d71', surface: '#240b19' }, picstagram: { accent: '#e54cff', surface: '#210b27' }, + skypic: { accent: '#24c7ff', surface: '#070f2b' }, feather: { accent: '#3c9cff', surface: '#091c2d' }, fliptok: { accent: '#24f0d2', surface: '#071d1b' }, flare: { accent: '#ff567f', surface: '#260d18' }, diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts index 9dfd136..225f496 100644 --- a/frontend/src/utils/media.test.ts +++ b/frontend/src/utils/media.test.ts @@ -158,6 +158,7 @@ describe('media utilities', () => { expect(mediaErrorKey('profile_photo_required')).toBe( 'profile_photo_required', ) + expect(mediaErrorKey('media_in_use')).toBe('media_in_use') expect(mediaErrorKey('private_provider_error')).toBe('request_failed') }) }) diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts index 2ec67c6..e8bae28 100644 --- a/frontend/src/utils/media.ts +++ b/frontend/src/utils/media.ts @@ -106,6 +106,7 @@ export function mediaErrorKey(error?: string): string { 'import_url_not_allowed', 'import_url_unavailable', 'import_size_unavailable', + 'media_in_use', 'not_found', 'operation_in_progress', 'owner_changed', diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts index 0b19a15..05ae644 100644 --- a/frontend/src/utils/preferences.test.ts +++ b/frontend/src/utils/preferences.test.ts @@ -85,6 +85,10 @@ describe('preferences', () => { enabled: true, sounds: true, }) + expect(value.settings.notifications.skypic).toEqual({ + enabled: true, + sounds: true, + }) expect(value.settings.phoneScale).toBe(110) expect(value.settings.screenBrightness).toBe(64) expect(value.settings.wallpaper).toBe('ember') diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 48b0358..99d926c 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -118,6 +118,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'weazel-news': { enabled: true, sounds: true }, 'local-pages': { enabled: true, sounds: true }, picstagram: { enabled: true, sounds: true }, + skypic: { enabled: true, sounds: true }, fliptok: { enabled: true, sounds: true }, feather: { enabled: true, sounds: true }, crewlink: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/SkyPicApp.contract.test.ts b/frontend/src/views/apps/SkyPicApp.contract.test.ts new file mode 100644 index 0000000..2f71cc3 --- /dev/null +++ b/frontend/src/views/apps/SkyPicApp.contract.test.ts @@ -0,0 +1,228 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const viewSource = readFileSync( + new URL('./SkyPicApp.vue', import.meta.url), + 'utf8', +) +const storeSource = readFileSync( + new URL('../../stores/skypic.ts', import.meta.url), + 'utf8', +) +const typeSource = readFileSync( + new URL('../../types/skypic.ts', import.meta.url), + 'utf8', +) + +describe('SkyPic frontend contract', () => { + it('uses Sky UI and exposes the camera-first four-tab shell', () => { + expect(viewSource).toContain("from '@/ui'") + expect(viewSource).not.toContain("from 'konsta/vue'") + expect(viewSource).toContain("const activeTab = ref('camera')") + expect(viewSource).toContain( + "type Tab = 'camera' | 'chats' | 'friends' | 'stories'", + ) + expect(viewSource).toContain("activeTab === 'camera'") + expect(viewSource).toContain("activeTab === 'chats'") + expect(viewSource).toContain("activeTab === 'stories'") + expect(viewSource).toContain("activeTab === 'friends'") + expect(viewSource.match(/ { + expect(viewSource).toContain('mediaPicker.begin(') + expect(viewSource).toContain("'skypic-draft'") + expect(viewSource).toContain('`/apps/skypic?compose=${purpose}`') + expect(viewSource).toContain( + "mediaPicker.consumeMany('skypic-draft')", + ) + expect(viewSource).toContain('path: `/apps/${source}`') + expect(viewSource).toContain('query: { mediaAttachment: mediaType }') + expect(viewSource).toContain("type MediaSource = 'camera' | 'photos'") + }) + + it('keeps profile editing parse-safe and composer inputs canonical', () => { + expect(viewSource).toContain('function toggleProfileEditor(): void') + expect(viewSource.match(/@click="toggleProfileEditor"/g)).toHaveLength(2) + expect(viewSource).toContain('const MAX_CAPTION_LENGTH = 160') + expect(viewSource).toContain(':maxlength="MAX_CAPTION_LENGTH"') + expect(viewSource).toContain('.slice(0, MAX_CAPTION_LENGTH)') + expect(viewSource).not.toContain('maxlength="240"') + expect(viewSource).toContain('const MAX_TEXT_OVERLAY_LENGTH = 160') + expect(viewSource).toContain(':maxlength="MAX_TEXT_OVERLAY_LENGTH"') + expect(storeSource).toContain('MAX_TEXT_OVERLAY_CHARACTERS = 160') + expect(viewSource).toContain( + 'avatarSeed: Math.floor(Math.random() * 360) + 1', + ) + }) + + it('keeps direct and story secrets behind explicit release callbacks', () => { + const snapBlock = typeSource.match( + /export type SkyPicSnap = \{([\s\S]*?)\n\}/, + )?.[1] + const openedSnapBlock = typeSource.match( + /export type SkyPicOpenedSnap = \{([\s\S]*?)\n\}/, + )?.[1] + const storyBlock = typeSource.match( + /export type SkyPicStory = \{([\s\S]*?)\n\}/, + )?.[1] + const viewedStoryBlock = typeSource.match( + /export type SkyPicViewedStory = \{([\s\S]*?)\n\}/, + )?.[1] + + expect(snapBlock).toBeTruthy() + expect(snapBlock).not.toMatch(/\burl\b|caption|textOverlay|overlayColor/) + expect(openedSnapBlock).toMatch(/\burl: string\b/) + expect(openedSnapBlock).toMatch(/caption: string/) + expect(openedSnapBlock).toMatch(/textOverlay: string/) + expect(openedSnapBlock).toMatch(/overlayColor: string/) + expect(storyBlock).not.toMatch(/\burl\b|caption|textOverlay|overlayColor/) + expect(viewedStoryBlock).toMatch(/\burl: string\b/) + expect(storeSource).toContain("'skypic:open-snap'") + expect(storeSource).toContain("'skypic:replay-snap'") + expect(storeSource).toContain("'skypic:view-story'") + expect(viewSource).toContain('store.clearOpenedSnap()') + expect(viewSource).toContain('store.clearViewedStory()') + }) + + it('implements all agreed callback names exactly', () => { + const callbacks = [ + 'skypic:bootstrap', + 'skypic:create-profile', + 'skypic:update-profile', + 'skypic:search', + 'skypic:add-friend', + 'skypic:respond-friend', + 'skypic:remove-friend', + 'skypic:block', + 'skypic:send-snap', + 'skypic:open-snap', + 'skypic:replay-snap', + 'skypic:publish-story', + 'skypic:stories', + 'skypic:view-story', + 'skypic:story-viewers', + 'skypic:remove-story', + 'skypic:thread', + 'skypic:send-message', + 'skypic:mark-thread', + 'skypic:save-message', + 'skypic:delete-message', + ] + callbacks.forEach((callback) => + expect(storeSource).toContain("'" + callback + "'"), + ) + }) + + it('reacts to query-only notification and media-return deep links', () => { + expect(viewSource).toContain('() => route.query') + expect(viewSource).toContain('{ deep: true }') + expect(viewSource).toContain('query.compose') + expect(viewSource).toContain('query.profileId') + expect(viewSource).toContain('query.friendship') + expect(viewSource).toContain('query.snap') + expect(viewSource).toContain('query.story') + expect(viewSource).toContain('conversationForProfile(profileId)') + expect(viewSource).toContain( + 'if (!requestedFriendship && store.activeFriendshipId)', + ) + expect(viewSource).toContain("chatBody.value = ''") + }) + + it('only opens incoming direct snaps', () => { + const canOpenBlock = viewSource.match( + /function snapCanOpen\(snap: SkyPicSnap\): boolean \{([\s\S]*?)\n\}/, + )?.[1] + + expect(canOpenBlock).toContain("snap.direction === 'received'") + expect(canOpenBlock).toContain('snap.allowReplay') + }) + + it('implements server-authorized story replies', () => { + expect(typeSource).toContain('canReply: boolean') + expect(viewSource).toContain('store.viewedStory.canReply') + expect(viewSource).toContain('stories.replyPlaceholder') + expect(viewSource).toContain( + 'store.sendMessage(friendshipId, body, story.id)', + ) + expect(viewSource).toContain('@focus="pauseStoryCountdown"') + expect(viewSource).toContain('@blur="resumeStoryCountdown"') + expect(storeSource).toContain('...(storyId ? { storyId } : {})') + }) + + it('renders outgoing requests with a cancellable relationship action', () => { + expect(viewSource).toContain('store.outgoingRequests') + expect(viewSource).toContain('cancelFriendRequest(request)') + expect(viewSource).toContain("'friends.cancelRequest'") + expect(storeSource).toContain("'skypic:remove-friend'") + }) + + it('has timer cleanup, conditional scroll owners and accessible controls', () => { + expect(viewSource).toContain('beginCountdown(') + expect(viewSource).toContain('clearSnapTimer()') + expect(viewSource).toContain('clearStoryTimer()') + expect(viewSource).toContain('onBeforeUnmount(') + expect(viewSource).toContain(' { + expect(viewSource).toContain( + "prepareViewerMedia('snap', snap.durationSeconds)", + ) + expect(viewSource).toContain( + "prepareViewerMedia('story', story.durationSeconds)", + ) + expect(viewSource).toContain("handleViewerMediaReady('snap')") + expect(viewSource).toContain("handleViewerMediaReady('story')") + expect(viewSource).toContain("handleViewerVideoCanPlay('snap', $event)") + expect(viewSource).toContain("handleViewerVideoCanPlay('story', $event)") + expect(viewSource).toContain('await video.play()') + expect(viewSource).toContain('mediaLoading.value = true') + expect(viewSource).toContain('mediaError.value = true') + expect(viewSource).toContain('pauseStoryCountdown()') + expect(viewSource).toContain('storyViewerSheetOpen.value = false') + }) + + it('guards stale tab, story and viewer-sheet requests', () => { + expect(viewSource).toContain( + 'navigationRequest !== threadNavigationRequest', + ) + expect(viewSource).toContain('activeTab.value !== next') + expect(viewSource).toContain( + 'if (!appMounted || store.storyViewing) return', + ) + expect(viewSource).toContain('const requestId = ++storyNavigationRequest') + expect(viewSource).toContain('const requestId = ++storyViewerRequest') + expect(viewSource).toContain('store.viewedStory?.id === story.id') + expect(storeSource).toContain("error: 'story_view_in_progress'") + expect(storeSource).toContain('requestId !== storyRequest') + expect(storeSource).toContain('requestId !== storyViewersRequest') + expect(viewSource).toContain('if (!appMounted) return') + expect(viewSource).toContain('threadNavigationRequest += 1') + expect(viewSource).toContain('store.closeThread()') + }) + + it('bounds discovery search and clears failed results', () => { + expect(viewSource).toContain('const MAX_SEARCH_CHARACTERS = 64') + expect(viewSource).toContain("slice(0, MAX_SEARCH_CHARACTERS).join('')") + expect(viewSource).toContain('@update:model-value="updateSearchQuery"') + expect(viewSource).toContain('notify(errorText(store.error ?? undefined))') + expect(storeSource).toContain('searchResults.value = []') + }) + + it('deduplicates concurrent account bootstraps', () => { + expect(storeSource).toContain( + 'bootstrapInFlight?.session === requestSession', + ) + expect(storeSource).toContain('return bootstrapInFlight.promise') + expect(storeSource).toContain( + 'bootstrapInFlight = { promise, session: requestSession, token }', + ) + }) +}) diff --git a/frontend/src/views/apps/SkyPicApp.vue b/frontend/src/views/apps/SkyPicApp.vue new file mode 100644 index 0000000..3786409 --- /dev/null +++ b/frontend/src/views/apps/SkyPicApp.vue @@ -0,0 +1,3603 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index c70b2ab..d9824d5 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -2914,6 +2914,7 @@ const demoInstalledAppIds = [ 'companies', 'music', 'picstagram', + 'skypic', 'feather', 'fliptok', 'flare', @@ -3096,9 +3097,7 @@ const deviceData = { ringtoneVolume: 80, streamerMode: false, wallpaper: 'custom', - wallpaperHistory: [ - { imageUrl: demoWallpaperUrl, wallpaper: 'custom' }, - ], + wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }], wallpaperImageUrl: demoWallpaperUrl, }, version: 1, @@ -3416,6 +3415,483 @@ function mockPhotoUrls(ids) { }) } +function skyPicCaption(value) { + const caption = typeof value === 'string' ? value.trim() : '' + return [...caption].length <= 160 ? caption : null +} + +function skyPicTextOverlay(value) { + const textOverlay = typeof value === 'string' ? value.trim() : '' + return [...textOverlay].length <= 160 ? textOverlay : null +} + +function skyPicAvatarSeed(value, fallback) { + const candidate = value === undefined ? fallback : value + return Number.isInteger(candidate) && + candidate >= 1 && + candidate <= 2_147_483_647 + ? candidate + : null +} + +function skyPicRecipientIds(value) { + if (!Array.isArray(value) || value.length < 1 || value.length > 20) { + return null + } + const seen = new Set() + const ids = [] + for (const profileId of value) { + if ( + typeof profileId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + profileId, + ) || + seen.has(profileId) + ) { + return null + } + seen.add(profileId) + ids.push(profileId) + } + return ids +} + +function skyPicMessageBody(value) { + const body = typeof value === 'string' ? value.trim() : '' + if (!body) return { error: 'message_empty' } + if ([...body].length > 2000) return { error: 'message_too_long' } + return { body } +} + +function skyPicOffset(value) { + const offset = value === undefined ? 0 : value + return Number.isInteger(offset) && offset >= 0 && offset <= 100_000 + ? offset + : null +} + +const skyPicProfiles = [ + { + avatarSeed: 8, + avatarUrl: mockPhotoUrls([12])[0], + displayName: 'Alex Morgan', + handle: 'alexm', + id: '10000000-0000-4000-8000-000000000001', + snapScore: 1842, + }, + { + avatarSeed: 3, + avatarUrl: mockPhotoUrls([3])[0], + displayName: 'Maya Rivera', + handle: 'mayar', + id: '10000000-0000-4000-8000-000000000002', + snapScore: 5214, + }, + { + avatarSeed: 11, + avatarUrl: mockPhotoUrls([7])[0], + displayName: 'Noah Williams', + handle: 'noahw', + id: '10000000-0000-4000-8000-000000000003', + snapScore: 2940, + }, + { + avatarSeed: 19, + avatarUrl: mockPhotoUrls([11])[0], + displayName: 'Sofia Chen', + handle: 'sofiac', + id: '10000000-0000-4000-8000-000000000004', + snapScore: 4091, + }, + { + avatarSeed: 27, + avatarUrl: mockPhotoUrls([9])[0], + displayName: 'Jade Brooks', + handle: 'jadeb', + id: '10000000-0000-4000-8000-000000000005', + snapScore: 1678, + }, +] +const skyPicStoryReplyEnabledProfileIds = new Set([ + skyPicProfiles[1].id, + skyPicProfiles[2].id, + skyPicProfiles[3].id, + skyPicProfiles[4].id, +]) +let skyPicProfile = { + ...skyPicProfiles[0], + allowStoryReplies: true, + avatarMediaId: 12, + bio: 'Night drives, neon skies and the people who make the city feel small.', + friendCount: 1, + friendshipStatus: 'none', + showInQuickAdd: true, + storyPrivacy: 'friends', +} +let skyPicOnboardingProfile = null +let skyPicFriends = [ + { + bestStreak: 18, + createdAt: isoTime(-46 * 86_400_000), + friendshipId: '20000000-0000-4000-8000-000000000001', + profile: skyPicProfiles[1], + streakCount: 7, + }, +] +let skyPicRequests = [ + { + createdAt: isoTime(-35 * 60_000), + direction: 'incoming', + friendshipId: '20000000-0000-4000-8000-000000000002', + profile: skyPicProfiles[2], + }, + { + createdAt: isoTime(-2 * 60 * 60_000), + direction: 'outgoing', + friendshipId: '20000000-0000-4000-8000-000000000003', + profile: skyPicProfiles[4], + }, +] +let skyPicConversations = [ + { + bestStreak: 18, + friendshipId: '20000000-0000-4000-8000-000000000001', + lastItem: { + createdAt: isoTime(-4 * 60_000), + direction: 'received', + id: '30000000-0000-4000-8000-000000000001', + openedAt: null, + type: 'snap_photo', + }, + profile: skyPicProfiles[1], + streakCount: 7, + unreadCount: 2, + }, +] +let skyPicSnaps = [ + { + allowReplay: true, + createdAt: isoTime(-4 * 60_000), + direction: 'received', + durationSeconds: 7, + expiresAt: isoTime(23 * 60 * 60_000), + friendshipId: '20000000-0000-4000-8000-000000000001', + id: '30000000-0000-4000-8000-000000000001', + openedAt: null, + replayedAt: null, + sender: skyPicProfiles[1], + type: 'snap_photo', + }, + { + allowReplay: false, + createdAt: isoTime(-48 * 60_000), + direction: 'sent', + durationSeconds: 5, + expiresAt: isoTime(23 * 60 * 60_000), + friendshipId: '20000000-0000-4000-8000-000000000001', + id: '30000000-0000-4000-8000-000000000002', + openedAt: isoTime(-42 * 60_000), + replayedAt: null, + sender: skyPicProfiles[0], + type: 'snap_photo', + }, +] +const skyPicSnapContents = new Map([ + [ + '30000000-0000-4000-8000-000000000001', + { + caption: 'Meet me where the city meets the ocean.', + mediaType: 'photo', + mimeType: 'image/jpeg', + overlayColor: '#24c7ff', + textOverlay: 'Vespucci after dark', + url: mockPhotoUrls([9])[0], + }, + ], + [ + '30000000-0000-4000-8000-000000000002', + { + caption: 'One last lap.', + mediaType: 'photo', + mimeType: 'image/jpeg', + overlayColor: '#ffffff', + textOverlay: 'Night drive', + url: mockPhotoUrls([8])[0], + }, + ], +]) +let skyPicStories = [ + { + author: skyPicProfiles[1], + createdAt: isoTime(-52 * 60_000), + durationSeconds: 7, + expiresAt: isoTime(22 * 60 * 60_000), + id: '40000000-0000-4000-8000-000000000001', + isOwner: false, + seen: false, + viewCount: 14, + }, + { + author: skyPicProfiles[0], + createdAt: isoTime(-3 * 60 * 60_000), + durationSeconds: 5, + expiresAt: isoTime(20 * 60 * 60_000), + id: '40000000-0000-4000-8000-000000000002', + isOwner: true, + seen: true, + viewCount: 3, + }, +] +const skyPicStoryContents = new Map([ + [ + '40000000-0000-4000-8000-000000000001', + { + caption: 'Blue hour.', + mediaType: 'photo', + mimeType: 'image/jpeg', + overlayColor: '#24c7ff', + textOverlay: 'Vespucci', + url: mockPhotoUrls([7])[0], + }, + ], + [ + '40000000-0000-4000-8000-000000000002', + { + caption: 'The skyline never gets old.', + mediaType: 'photo', + mimeType: 'image/jpeg', + overlayColor: '#ffffff', + textOverlay: 'Los Santos', + url: mockPhotoUrls([11])[0], + }, + ], +]) +const skyPicStoryViewers = new Map([ + [ + '40000000-0000-4000-8000-000000000002', + [ + { + ...skyPicProfiles[1], + viewedAt: isoTime(-95 * 60_000), + }, + { + ...skyPicProfiles[2], + viewedAt: isoTime(-64 * 60_000), + }, + { + ...skyPicProfiles[3], + viewedAt: isoTime(-22 * 60_000), + }, + ], + ], +]) +const skyPicMessages = new Map([ + [ + '20000000-0000-4000-8000-000000000001', + [ + { + body: 'That route looks unreal.', + createdAt: isoTime(-24 * 60_000), + direction: 'sent', + friendshipId: '20000000-0000-4000-8000-000000000001', + id: '50000000-0000-4000-8000-000000000001', + readAt: isoTime(-21 * 60_000), + savedAt: null, + type: 'text', + }, + { + body: 'Wait until you see it after sunset.', + createdAt: isoTime(-6 * 60_000), + direction: 'received', + friendshipId: '20000000-0000-4000-8000-000000000001', + id: '50000000-0000-4000-8000-000000000002', + readAt: null, + savedAt: null, + type: 'text', + }, + ], + ], +]) +const skyPicBlockedProfileIds = new Set() + +function skyPicFriendshipStatus(profileId) { + if (skyPicFriends.some((friend) => friend.profile.id === profileId)) { + return 'friends' + } + const request = skyPicRequests.find((item) => item.profile.id === profileId) + return request?.direction ?? 'none' +} + +function skyPicSummary(profile) { + const friendship = + skyPicFriends.find((friend) => friend.profile.id === profile.id) ?? + skyPicRequests.find((request) => request.profile.id === profile.id) + return { + ...profile, + ...(friendship ? { friendshipId: friendship.friendshipId } : {}), + friendshipStatus: skyPicFriendshipStatus(profile.id), + } +} + +function skyPicDiscoveryProfile(profile) { + return skyPicSummary(profile) +} + +function skyPicFriendView(friend) { + return { + ...friend, + profile: skyPicSummary(friend.profile), + } +} + +function skyPicRequestView(request) { + return { + ...request, + profile: skyPicSummary(request.profile), + } +} + +function skyPicSnapView(snap) { + return { + ...snap, + sender: skyPicSummary(snap.sender), + } +} + +function skyPicStoryView(story) { + return { + ...story, + author: skyPicSummary(story.author), + } +} + +function skyPicUnreadCount() { + return skyPicConversations.reduce( + (sum, conversation) => sum + Math.max(0, conversation.unreadCount), + 0, + ) +} + +function skyPicIncrementOwnScore(amount = 1) { + skyPicProfiles[0].snapScore += amount + if (skyPicProfile) skyPicProfile.snapScore = skyPicProfiles[0].snapScore +} + +function skyPicStoryVisible(story) { + return story.isOwner || !skyPicBlockedProfileIds.has(story.author.id) +} + +function skyPicBootstrap(testScenario = '') { + if (testScenario === 'skypic-onboarding') { + return { + blockedProfiles: [], + conversations: [], + friends: [], + inbox: [], + profile: skyPicOnboardingProfile ? { ...skyPicOnboardingProfile } : null, + requests: [], + stories: [], + suggestions: skyPicProfiles + .slice(1) + .filter( + (item) => + !skyPicBlockedProfileIds.has(item.id) && + skyPicFriendshipStatus(item.id) === 'none', + ) + .map(skyPicDiscoveryProfile), + unreadCount: 0, + } + } + + return { + blockedProfiles: skyPicProfiles + .filter((profile) => skyPicBlockedProfileIds.has(profile.id)) + .map(skyPicSummary), + conversations: skyPicConversations.map((conversation) => ({ + ...conversation, + profile: skyPicSummary(conversation.profile), + })), + friends: skyPicFriends.map(skyPicFriendView), + inbox: skyPicSnaps + .filter((snap) => snap.direction === 'received') + .map(skyPicSnapView), + profile: skyPicProfile ? { ...skyPicProfile } : null, + requests: skyPicRequests.map(skyPicRequestView), + stories: skyPicStories.filter(skyPicStoryVisible).map(skyPicStoryView), + suggestions: skyPicProfiles + .slice(1) + .filter( + (item) => + !skyPicBlockedProfileIds.has(item.id) && + skyPicFriendshipStatus(item.id) === 'none', + ) + .map(skyPicDiscoveryProfile), + unreadCount: skyPicUnreadCount(), + } +} + +function skyPicOpenedSnap(snap) { + const contents = skyPicSnapContents.get(snap.id) + if (!contents) return null + return { + ...contents, + allowReplay: snap.allowReplay, + durationSeconds: snap.durationSeconds, + expiresAt: snap.expiresAt, + id: snap.id, + openedAt: snap.openedAt, + replayedAt: snap.replayedAt, + } +} + +function skyPicViewedStory(story, viewedAt) { + const contents = skyPicStoryContents.get(story.id) + if (!contents) return null + const friendship = skyPicFriends.find( + (friend) => friend.profile.id === story.author.id, + ) + return { + ...contents, + author: skyPicSummary(story.author), + canReply: + !story.isOwner && + Boolean(friendship) && + skyPicStoryReplyEnabledProfileIds.has(story.author.id), + durationSeconds: story.durationSeconds, + expiresAt: story.expiresAt, + id: story.id, + viewedAt, + } +} + +function skyPicConversation(friendshipId) { + return skyPicConversations.find( + (conversation) => conversation.friendshipId === friendshipId, + ) +} + +function skyPicUpdateConversation(friendshipId, lastItem) { + const conversation = skyPicConversation(friendshipId) + if (conversation) { + conversation.lastItem = lastItem + return conversation + } + const friend = skyPicFriends.find( + (item) => item.friendshipId === friendshipId, + ) + if (!friend) return null + const created = { + bestStreak: friend.bestStreak, + friendshipId, + lastItem, + profile: friend.profile, + streakCount: friend.streakCount, + unreadCount: 0, + } + skyPicConversations.unshift(created) + return created +} + const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business'] const weazelNewsMaxImages = 6 let weazelNewsSequence = 8 @@ -7454,6 +7930,681 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: message }) return } + if (endpoint === 'skypic:bootstrap') { + response.json({ success: true, data: skyPicBootstrap(testScenario) }) + return + } + if (endpoint === 'skypic:create-profile') { + const displayName = String(request.body.displayName ?? '').trim() + const handle = String(request.body.handle ?? '') + .trim() + .toLowerCase() + if (!displayName) { + response.json({ success: false, error: 'invalid_display_name' }) + return + } + if (!/^[a-z0-9._]{3,24}$/.test(handle)) { + response.json({ success: false, error: 'invalid_handle' }) + return + } + const avatarSeed = skyPicAvatarSeed( + request.body.avatarSeed, + Math.floor(Math.random() * 2_147_483_647) + 1, + ) + if (avatarSeed === null) { + response.json({ success: false, error: 'invalid_avatar_seed' }) + return + } + const avatarMediaId = Number(request.body.avatarMediaId) + const avatar = Number.isFinite(avatarMediaId) + ? mockMedia.find( + (item) => item.id === avatarMediaId && item.mediaType === 'photo', + ) + : null + const onboardingScenario = testScenario === 'skypic-onboarding' + if ( + (onboardingScenario && skyPicOnboardingProfile) || + (!onboardingScenario && skyPicProfile) + ) { + response.json({ success: false, error: 'profile_exists' }) + return + } + if (onboardingScenario) { + skyPicOnboardingProfile = { + allowStoryReplies: true, + avatarMediaId: avatar?.id ?? null, + avatarSeed, + avatarUrl: avatar?.url ?? null, + bio: '', + displayName, + friendCount: 0, + friendshipStatus: 'none', + handle, + id: skyPicProfiles[0].id, + showInQuickAdd: true, + snapScore: 0, + storyPrivacy: 'friends', + } + response.json({ + success: true, + data: { ...skyPicOnboardingProfile }, + }) + return + } + Object.assign(skyPicProfiles[0], { + avatarSeed, + avatarUrl: avatar?.url ?? null, + displayName, + handle, + }) + skyPicProfile = { + ...skyPicProfiles[0], + allowStoryReplies: true, + avatarMediaId: avatar?.id ?? null, + bio: '', + friendCount: skyPicFriends.length, + friendshipStatus: 'none', + showInQuickAdd: true, + storyPrivacy: 'friends', + } + response.json({ success: true, data: { ...skyPicProfile } }) + return + } + if (endpoint === 'skypic:update-profile') { + if (!skyPicProfile) { + response.json({ success: false, error: 'profile_required' }) + return + } + const displayName = String(request.body.displayName ?? '').trim() + const handle = String(request.body.handle ?? '') + .trim() + .toLowerCase() + const storyPrivacy = String(request.body.storyPrivacy ?? '') + if (!displayName) { + response.json({ success: false, error: 'invalid_display_name' }) + return + } + if (!/^[a-z0-9._]{3,24}$/.test(handle)) { + response.json({ success: false, error: 'invalid_handle' }) + return + } + if (!['everyone', 'friends'].includes(storyPrivacy)) { + response.json({ success: false, error: 'invalid_privacy' }) + return + } + const avatarSeed = skyPicAvatarSeed( + request.body.avatarSeed, + skyPicProfile.avatarSeed, + ) + if (avatarSeed === null) { + response.json({ success: false, error: 'invalid_avatar_seed' }) + return + } + const avatarMediaId = + request.body.avatarMediaId === null + ? null + : Number(request.body.avatarMediaId) + const avatar = + avatarMediaId === null + ? null + : mockMedia.find( + (item) => item.id === avatarMediaId && item.mediaType === 'photo', + ) + Object.assign(skyPicProfiles[0], { + avatarSeed, + avatarUrl: + request.body.avatarMediaId === undefined + ? skyPicProfile.avatarUrl + : (avatar?.url ?? null), + displayName, + handle, + }) + skyPicProfile = { + ...skyPicProfile, + ...skyPicProfiles[0], + allowStoryReplies: request.body.allowStoryReplies === true, + avatarMediaId: + request.body.avatarMediaId === undefined + ? skyPicProfile.avatarMediaId + : (avatar?.id ?? null), + bio: String(request.body.bio ?? '') + .trim() + .slice(0, 160), + showInQuickAdd: request.body.showInQuickAdd === true, + storyPrivacy, + } + response.json({ success: true, data: { ...skyPicProfile } }) + return + } + if (endpoint === 'skypic:search') { + const query = String(request.body.query ?? '') + .trim() + .toLowerCase() + const results = query + ? skyPicProfiles + .filter( + (profile) => + profile.id !== skyPicProfile?.id && + !skyPicBlockedProfileIds.has(profile.id) && + (profile.displayName.toLowerCase().includes(query) || + profile.handle.toLowerCase().includes(query)), + ) + .map(skyPicDiscoveryProfile) + : [] + response.json({ success: true, data: results }) + return + } + if (endpoint === 'skypic:add-friend') { + const profileId = String(request.body.profileId ?? '') + const profile = skyPicProfiles.find((item) => item.id === profileId) + if ( + !profile || + profile.id === skyPicProfile?.id || + skyPicBlockedProfileIds.has(profile.id) + ) { + response.json({ success: false, error: 'profile_not_found' }) + return + } + if (skyPicFriendshipStatus(profileId) !== 'none') { + response.json({ success: false, error: 'friend_request_exists' }) + return + } + const friendRequest = { + createdAt: new Date().toISOString(), + direction: 'outgoing', + friendshipId: randomUUID(), + profile, + } + skyPicRequests.push(friendRequest) + response.json({ success: true, data: skyPicRequestView(friendRequest) }) + return + } + if (endpoint === 'skypic:respond-friend') { + const friendshipId = String(request.body.friendshipId ?? '') + const friendRequest = skyPicRequests.find( + (item) => + item.friendshipId === friendshipId && item.direction === 'incoming', + ) + if (!friendRequest) { + response.json({ success: false, error: 'friendship_not_found' }) + return + } + skyPicRequests = skyPicRequests.filter( + (item) => item.friendshipId !== friendshipId, + ) + if (request.body.accept !== true) { + response.json({ success: true }) + return + } + const friend = { + bestStreak: 0, + createdAt: new Date().toISOString(), + friendshipId, + profile: friendRequest.profile, + streakCount: 0, + } + skyPicFriends.push(friend) + if (skyPicProfile) skyPicProfile.friendCount = skyPicFriends.length + response.json({ success: true, data: skyPicFriendView(friend) }) + return + } + if (endpoint === 'skypic:remove-friend') { + const friendshipId = String(request.body.friendshipId ?? '') + const existed = + skyPicFriends.some((item) => item.friendshipId === friendshipId) || + skyPicRequests.some((item) => item.friendshipId === friendshipId) + if (!existed) { + response.json({ success: false, error: 'friendship_not_found' }) + return + } + skyPicFriends = skyPicFriends.filter( + (item) => item.friendshipId !== friendshipId, + ) + skyPicRequests = skyPicRequests.filter( + (item) => item.friendshipId !== friendshipId, + ) + skyPicConversations = skyPicConversations.filter( + (item) => item.friendshipId !== friendshipId, + ) + skyPicSnaps = skyPicSnaps.filter( + (item) => item.friendshipId !== friendshipId, + ) + skyPicMessages.delete(friendshipId) + if (skyPicProfile) skyPicProfile.friendCount = skyPicFriends.length + response.json({ success: true }) + return + } + if (endpoint === 'skypic:block') { + const profileId = String(request.body.profileId ?? '') + const profile = skyPicProfiles.find((item) => item.id === profileId) + if (!profile || profile.id === skyPicProfile?.id) { + response.json({ success: false, error: 'profile_not_found' }) + return + } + if (request.body.blocked === false) { + skyPicBlockedProfileIds.delete(profileId) + response.json({ success: true }) + return + } + skyPicBlockedProfileIds.add(profileId) + const friendshipIds = new Set([ + ...skyPicFriends + .filter((item) => item.profile.id === profileId) + .map((item) => item.friendshipId), + ...skyPicRequests + .filter((item) => item.profile.id === profileId) + .map((item) => item.friendshipId), + ]) + skyPicFriends = skyPicFriends.filter( + (item) => item.profile.id !== profileId, + ) + skyPicRequests = skyPicRequests.filter( + (item) => item.profile.id !== profileId, + ) + skyPicConversations = skyPicConversations.filter( + (item) => item.profile.id !== profileId, + ) + skyPicSnaps = skyPicSnaps.filter( + (item) => + item.sender.id !== profileId && !friendshipIds.has(item.friendshipId), + ) + for (const friendshipId of friendshipIds) { + skyPicMessages.delete(friendshipId) + } + if (skyPicProfile) skyPicProfile.friendCount = skyPicFriends.length + response.json({ success: true }) + return + } + if (endpoint === 'skypic:send-snap') { + if (!skyPicProfile) { + 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) { + response.json({ success: false, error: 'invalid_media' }) + return + } + const caption = skyPicCaption(request.body.caption) + if (caption === null) { + response.json({ success: false, error: 'invalid_caption' }) + return + } + const textOverlay = skyPicTextOverlay(request.body.textOverlay) + if (textOverlay === null) { + 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), + ) + .filter(Boolean) + if (!recipients.length || recipients.length !== recipientIds.length) { + response.json({ success: false, error: 'invalid_recipients' }) + return + } + const createdAt = new Date().toISOString() + const expiresAt = new Date( + Date.parse(createdAt) + 30 * 24 * 60 * 60_000, + ).toISOString() + const durationSeconds = Math.max( + 1, + Math.min(10, Math.round(Number(request.body.durationSeconds) || 1)), + ) + 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', + } + 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 + } + if (endpoint === 'skypic:open-snap') { + const snapId = String(request.body.snapId ?? '') + const snap = skyPicSnaps.find( + (item) => item.id === snapId && item.direction === 'received', + ) + if (!snap || !skyPicSnapContents.has(snapId)) { + response.json({ success: false, error: 'snap_unavailable' }) + return + } + if (snap.openedAt) { + response.json({ success: false, error: 'snap_unavailable' }) + return + } + snap.openedAt = new Date().toISOString() + skyPicIncrementOwnScore() + const conversation = skyPicConversation(snap.friendshipId) + if (conversation) { + conversation.unreadCount = Math.max(0, conversation.unreadCount - 1) + if (conversation.lastItem?.id === snap.id) { + conversation.lastItem.openedAt = snap.openedAt + } + } + response.json({ success: true, data: skyPicOpenedSnap(snap) }) + return + } + if (endpoint === 'skypic:replay-snap') { + const snapId = String(request.body.snapId ?? '') + const snap = skyPicSnaps.find( + (item) => item.id === snapId && item.direction === 'received', + ) + if (!snap || !skyPicSnapContents.has(snapId)) { + response.json({ success: false, error: 'snap_unavailable' }) + return + } + if (!snap.openedAt) { + response.json({ success: false, error: 'snap_unavailable' }) + return + } + if (!snap.allowReplay || snap.replayedAt) { + response.json({ success: false, error: 'replay_unavailable' }) + return + } + snap.replayedAt = new Date().toISOString() + response.json({ success: true, data: skyPicOpenedSnap(snap) }) + return + } + if (endpoint === 'skypic:stories') { + const offset = skyPicOffset(request.body.offset) + if (offset === null) { + response.json({ success: false, error: 'invalid_request' }) + return + } + const visibleStories = skyPicStories.filter(skyPicStoryVisible) + response.json({ + success: true, + data: visibleStories.slice(offset, offset + 30).map(skyPicStoryView), + }) + return + } + if (endpoint === 'skypic:publish-story') { + if (!skyPicProfile) { + 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) { + response.json({ success: false, error: 'invalid_media' }) + return + } + const caption = skyPicCaption(request.body.caption) + if (caption === null) { + response.json({ success: false, error: 'invalid_caption' }) + return + } + const textOverlay = skyPicTextOverlay(request.body.textOverlay) + if (textOverlay === null) { + response.json({ success: false, error: 'invalid_overlay' }) + return + } + const durationSeconds = Math.max( + 1, + Math.min(10, Math.round(Number(request.body.durationSeconds) || 1)), + ) + skyPicIncrementOwnScore() + const story = { + author: skyPicProfiles[0], + createdAt: new Date().toISOString(), + durationSeconds, + expiresAt: isoTime(24 * 60 * 60_000), + id: randomUUID(), + isOwner: true, + seen: true, + viewCount: 0, + } + skyPicStories.unshift(story) + skyPicStoryContents.set(story.id, { + caption, + mediaType, + mimeType: mediaType === 'video' ? 'video/mp4' : 'image/jpeg', + overlayColor: /^#[0-9a-f]{6}$/i.test(request.body.overlayColor) + ? request.body.overlayColor + : '#ffffff', + textOverlay, + url: media.url, + }) + skyPicStoryViewers.set(story.id, []) + response.json({ success: true, data: skyPicStoryView(story) }) + return + } + if (endpoint === 'skypic:view-story') { + const storyId = String(request.body.storyId ?? '') + const story = skyPicStories.find((item) => item.id === storyId) + if ( + !story || + !skyPicStoryVisible(story) || + !skyPicStoryContents.has(storyId) + ) { + response.json({ success: false, error: 'story_unavailable' }) + return + } + const viewedAt = new Date().toISOString() + if (!story.isOwner && !story.seen) { + story.seen = true + story.viewCount += 1 + } + response.json({ + success: true, + data: skyPicViewedStory(story, viewedAt), + }) + return + } + if (endpoint === 'skypic:story-viewers') { + const storyId = String(request.body.storyId ?? '') + const offset = skyPicOffset(request.body.offset) + if (offset === null) { + response.json({ success: false, error: 'invalid_request' }) + return + } + const story = skyPicStories.find((item) => item.id === storyId) + if (!story) { + response.json({ success: false, error: 'story_unavailable' }) + return + } + if (!story.isOwner) { + response.json({ success: false, error: 'not_authorized' }) + return + } + response.json({ + success: true, + data: (skyPicStoryViewers.get(storyId) ?? []) + .slice(offset, offset + 30) + .map(skyPicSummary), + }) + return + } + if (endpoint === 'skypic:remove-story') { + const storyId = String(request.body.storyId ?? '') + const story = skyPicStories.find((item) => item.id === storyId) + if (!story) { + response.json({ success: false, error: 'story_unavailable' }) + return + } + if (!story.isOwner) { + response.json({ success: false, error: 'not_authorized' }) + return + } + skyPicStories = skyPicStories.filter((item) => item.id !== storyId) + skyPicStoryContents.delete(storyId) + skyPicStoryViewers.delete(storyId) + response.json({ success: true }) + return + } + if (endpoint === 'skypic:thread') { + const friendshipId = String(request.body.friendshipId ?? '') + if (!skyPicFriends.some((friend) => friend.friendshipId === friendshipId)) { + response.json({ success: false, error: 'friendship_not_found' }) + return + } + response.json({ + success: true, + data: { + messages: [...(skyPicMessages.get(friendshipId) ?? [])], + snaps: skyPicSnaps + .filter((snap) => snap.friendshipId === friendshipId) + .map(skyPicSnapView), + }, + }) + return + } + if (endpoint === 'skypic:send-message') { + const friendshipId = String(request.body.friendshipId ?? '') + const messageInput = skyPicMessageBody(request.body.body) + const friend = skyPicFriends.find( + (item) => item.friendshipId === friendshipId, + ) + if (!friend) { + response.json({ success: false, error: 'friendship_not_found' }) + return + } + if (messageInput.error) { + response.json({ success: false, error: messageInput.error }) + return + } + const body = messageInput.body + const storyId = request.body.storyId ? String(request.body.storyId) : null + if (storyId) { + const story = skyPicStories.find((item) => item.id === storyId) + if ( + !story || + story.isOwner || + story.author.id !== friend.profile.id || + !skyPicStoryReplyEnabledProfileIds.has(story.author.id) || + Date.parse(story.expiresAt) <= Date.now() + ) { + response.json({ success: false, error: 'story_unavailable' }) + return + } + } + const message = { + body, + createdAt: new Date().toISOString(), + direction: 'sent', + friendshipId, + id: randomUUID(), + readAt: null, + savedAt: null, + type: 'text', + } + const messages = skyPicMessages.get(friendshipId) ?? [] + messages.push(message) + skyPicMessages.set(friendshipId, messages) + skyPicUpdateConversation(friendshipId, { + body: message.body, + createdAt: message.createdAt, + direction: message.direction, + id: message.id, + openedAt: null, + type: 'text', + }) + response.json({ success: true, data: message }) + return + } + if (endpoint === 'skypic:mark-thread') { + const friendshipId = String(request.body.friendshipId ?? '') + if (!skyPicFriends.some((friend) => friend.friendshipId === friendshipId)) { + response.json({ success: false, error: 'friendship_not_found' }) + return + } + const readAt = new Date().toISOString() + const messages = skyPicMessages.get(friendshipId) ?? [] + for (const message of messages) { + if (message.direction === 'received' && !message.readAt) { + message.readAt = readAt + } + } + const conversation = skyPicConversation(friendshipId) + if (conversation) { + conversation.unreadCount = skyPicSnaps.filter( + (snap) => + snap.friendshipId === friendshipId && + snap.direction === 'received' && + !snap.openedAt, + ).length + } + response.json({ success: true }) + return + } + if (endpoint === 'skypic:save-message') { + const messageId = String(request.body.messageId ?? '') + let message = null + for (const messages of skyPicMessages.values()) { + message = messages.find((item) => item.id === messageId) ?? null + if (message) break + } + if (!message) { + response.json({ success: false, error: 'message_not_found' }) + return + } + message.savedAt = + request.body.saved === true ? new Date().toISOString() : null + response.json({ success: true, data: { ...message } }) + return + } + if (endpoint === 'skypic:delete-message') { + const messageId = String(request.body.messageId ?? '') + let deleted = false + for (const [friendshipId, messages] of skyPicMessages.entries()) { + const filtered = messages.filter((item) => item.id !== messageId) + if (filtered.length !== messages.length) { + skyPicMessages.set(friendshipId, filtered) + deleted = true + break + } + } + if (!deleted) { + response.json({ success: false, error: 'message_not_found' }) + return + } + response.json({ success: true }) + return + } + if (endpoint.startsWith('skypic:')) { + response.json({ success: false, error: 'mock_endpoint_missing' }) + return + } if (endpoint === 'picstagram:bootstrap') { response.json({ success: true, @@ -11671,4 +12822,9 @@ if (require.main === module) { }) } -module.exports = { app } +module.exports = { + app, + skyPicMessageBody, + skyPicRecipientIds, + skyPicTextOverlay, +} diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index fb00c9e..cd0875a 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -1,7 +1,12 @@ const assert = require('node:assert/strict') const { once } = require('node:events') -const { app } = require('./index.cjs') +const { + app, + skyPicMessageBody, + skyPicRecipientIds, + skyPicTextOverlay, +} = require('./index.cjs') const browserDataRequests = [ ['development:bootstrap', {}], @@ -67,6 +72,8 @@ const browserDataRequests = [ ['picstagram:saved', {}], ['picstagram:stories', {}], ['picstagram:activities', {}], + ['skypic:bootstrap', {}], + ['skypic:stories', { offset: 0 }], ['radio:get', {}], ['skyride:bootstrap', {}], ['skyride:history', {}], @@ -101,6 +108,19 @@ function expectItems(value, label, minimum = 1) { ) } +function expectSkyPicMetadataSafe(items, label) { + expectItems(items, label) + for (const item of items) { + for (const secret of ['url', 'caption', 'textOverlay', 'overlayColor']) { + assert.equal( + Object.hasOwn(item, secret), + false, + `${label} leaked ${secret}`, + ) + } + } +} + function verifyBrowserTestData(dataByEndpoint) { const development = dataByEndpoint.get('development:bootstrap') expectItems(development.device.data.alarms.payload, 'clock alarms', 3) @@ -112,11 +132,11 @@ function verifyBrowserTestData(dataByEndpoint) { expectItems( development.device.data.apps.payload.claimedApps, 'installed demo apps', - 41, + 42, ) assert.equal( new Set(development.device.data.apps.payload.claimedApps).size, - 41, + 42, 'browser demo apps were not uniquely installed', ) assert.equal( @@ -244,6 +264,26 @@ function verifyBrowserTestData(dataByEndpoint) { 'Picstagram posts', 2, ) + const skyPic = dataByEndpoint.get('skypic:bootstrap') + assert.equal(skyPic.profile.handle, 'alexm') + expectItems(skyPic.friends, 'SkyPic friends') + expectItems(skyPic.requests, 'SkyPic friend requests', 2) + assert.deepEqual( + [...new Set(skyPic.requests.map((request) => request.direction))].sort(), + ['incoming', 'outgoing'], + ) + expectItems(skyPic.conversations, 'SkyPic conversations') + expectSkyPicMetadataSafe(skyPic.inbox, 'SkyPic inbox') + expectSkyPicMetadataSafe(skyPic.stories, 'SkyPic bootstrap stories') + expectItems(skyPic.suggestions, 'SkyPic suggestions') + assert( + skyPic.suggestions.every((profile) => profile.friendshipStatus === 'none'), + 'SkyPic suggestions included an existing relationship', + ) + expectSkyPicMetadataSafe( + dataByEndpoint.get('skypic:stories'), + 'SkyPic stories endpoint', + ) expectItems(dataByEndpoint.get('radio:get').history, 'radio history', 2) expectItems(dataByEndpoint.get('skyride:history').items, 'SkyRide history', 2) expectItems( @@ -258,7 +298,537 @@ function verifyBrowserTestData(dataByEndpoint) { ) } +async function verifySkyPicActions(baseUrl) { + const recipientIds = Array.from( + { length: 21 }, + (_, index) => + `50000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + ) + assert.equal(skyPicRecipientIds(recipientIds.slice(0, 20)).length, 20) + assert.equal(skyPicRecipientIds(recipientIds), null) + const maximumTextOverlay = '😀'.repeat(160) + assert.equal(skyPicTextOverlay(maximumTextOverlay), maximumTextOverlay) + assert.equal(skyPicTextOverlay(`${maximumTextOverlay}😀`), null) + assert.equal([...skyPicMessageBody('😀'.repeat(2000)).body].length, 2000) + assert.deepEqual(skyPicMessageBody('😀'.repeat(2001)), { + error: 'message_too_long', + }) + + let bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + const friend = bootstrap.friends[0] + const receivedSnap = bootstrap.inbox.find( + (snap) => snap.direction === 'received' && !snap.openedAt, + ) + assert(friend, 'skypic:bootstrap did not include a friend') + assert(receivedSnap, 'skypic:bootstrap did not include an unopened snap') + assert.equal(friend.profile.friendshipStatus, 'friends') + let expectedSnapScore = bootstrap.profile.snapScore + + const thread = await expectSuccess( + baseUrl, + 'skypic:thread', + { friendshipId: friend.friendshipId }, + true, + ) + expectItems(thread.messages, 'SkyPic thread messages') + expectSkyPicMetadataSafe(thread.snaps, 'SkyPic thread snaps') + + await expectSuccess(baseUrl, 'skypic:mark-thread', { + friendshipId: friend.friendshipId, + }) + bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + assert.equal( + bootstrap.conversations.find( + (conversation) => conversation.friendshipId === friend.friendshipId, + ).unreadCount, + 1, + ) + assert.equal(bootstrap.unreadCount, 1) + + const opened = await expectSuccess( + baseUrl, + 'skypic:open-snap', + { snapId: receivedSnap.id }, + true, + ) + assert.equal(typeof opened.url, 'string') + assert.equal(typeof opened.caption, 'string') + assert.equal(typeof opened.textOverlay, 'string') + assert.match(opened.overlayColor, /^#[0-9a-f]{6}$/i) + expectedSnapScore += 1 + bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + assert.equal(bootstrap.profile.snapScore, expectedSnapScore) + assert.equal(bootstrap.unreadCount, 0) + const repeatedOpen = await post(baseUrl, 'skypic:open-snap', { + snapId: receivedSnap.id, + }) + assert.deepEqual(repeatedOpen, { + error: 'snap_unavailable', + success: false, + }) + const replayed = await expectSuccess( + baseUrl, + 'skypic:replay-snap', + { snapId: receivedSnap.id }, + true, + ) + assert.equal(replayed.id, receivedSnap.id) + assert.equal(typeof replayed.replayedAt, 'string') + const repeatedReplay = await post(baseUrl, 'skypic:replay-snap', { + snapId: receivedSnap.id, + }) + assert.deepEqual(repeatedReplay, { + error: 'replay_unavailable', + success: false, + }) + + const stories = await expectSuccess( + baseUrl, + 'skypic:stories', + { offset: 0 }, + true, + ) + expectSkyPicMetadataSafe(stories, 'SkyPic story metadata') + const offsetStories = await expectSuccess( + baseUrl, + 'skypic:stories', + { offset: 1 }, + true, + ) + assert.deepEqual( + offsetStories.map((story) => story.id), + stories.slice(1).map((story) => story.id), + ) + assert.deepEqual( + await expectSuccess(baseUrl, 'skypic:stories', { offset: 30 }, true), + [], + ) + assert.deepEqual(await post(baseUrl, 'skypic:stories', { offset: -1 }), { + error: 'invalid_request', + success: false, + }) + const friendStory = stories.find((story) => !story.isOwner) + const viewedStory = await expectSuccess( + baseUrl, + 'skypic:view-story', + { storyId: friendStory.id }, + true, + ) + assert.equal(typeof viewedStory.url, 'string') + assert.equal(typeof viewedStory.caption, 'string') + assert.equal(typeof viewedStory.textOverlay, 'string') + assert.match(viewedStory.overlayColor, /^#[0-9a-f]{6}$/i) + assert.equal(viewedStory.canReply, true) + const storyReply = await expectSuccess( + baseUrl, + 'skypic:send-message', + { + body: 'Replying to this story from the smoke test.', + friendshipId: friend.friendshipId, + storyId: friendStory.id, + }, + true, + ) + assert.equal(storyReply.body, 'Replying to this story from the smoke test.') + const ownStory = stories.find((story) => story.isOwner) + const storyViewers = await expectSuccess( + baseUrl, + 'skypic:story-viewers', + { offset: 0, storyId: ownStory.id }, + true, + ) + expectItems(storyViewers, 'SkyPic story viewers', 3) + assert.deepEqual( + ( + await expectSuccess( + baseUrl, + 'skypic:story-viewers', + { offset: 1, storyId: ownStory.id }, + true, + ) + ).map((viewer) => viewer.id), + storyViewers.slice(1).map((viewer) => viewer.id), + ) + assert.deepEqual( + await expectSuccess( + baseUrl, + 'skypic:story-viewers', + { offset: 30, storyId: ownStory.id }, + true, + ), + [], + ) + assert.deepEqual( + await post(baseUrl, 'skypic:story-viewers', { + offset: -1, + storyId: ownStory.id, + }), + { error: 'invalid_request', success: false }, + ) + assert.deepEqual( + await post(baseUrl, 'skypic:send-message', { + body: 'This reply must not reach a non-peer story.', + friendshipId: friend.friendshipId, + storyId: ownStory.id, + }), + { error: 'story_unavailable', success: false }, + ) + + const outgoingProfile = ( + await expectSuccess(baseUrl, 'skypic:search', { query: 'jade' }, true) + )[0] + assert.equal(outgoingProfile.friendshipStatus, 'outgoing') + assert.equal(typeof outgoingProfile.friendshipId, 'string') + + const suggestion = bootstrap.suggestions[0] + const outgoingRequest = await expectSuccess( + baseUrl, + 'skypic:add-friend', + { profileId: suggestion.id }, + true, + ) + assert.equal(outgoingRequest.direction, 'outgoing') + assert.equal(outgoingRequest.profile.friendshipStatus, 'outgoing') + assert.equal( + outgoingRequest.profile.friendshipId, + outgoingRequest.friendshipId, + ) + const searchedSuggestion = ( + await expectSuccess( + baseUrl, + 'skypic:search', + { query: suggestion.handle }, + true, + ) + )[0] + assert.equal(searchedSuggestion.friendshipStatus, 'outgoing') + assert.equal(searchedSuggestion.friendshipId, outgoingRequest.friendshipId) + await expectSuccess(baseUrl, 'skypic:remove-friend', { + friendshipId: outgoingRequest.friendshipId, + }) + + bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + const incomingRequest = bootstrap.requests.find( + (request) => request.direction === 'incoming', + ) + const acceptedFriend = await expectSuccess( + baseUrl, + 'skypic:respond-friend', + { accept: true, friendshipId: incomingRequest.friendshipId }, + true, + ) + assert.equal(acceptedFriend.friendshipId, incomingRequest.friendshipId) + await expectSuccess(baseUrl, 'skypic:remove-friend', { + friendshipId: acceptedFriend.friendshipId, + }) + + await expectSuccess(baseUrl, 'skypic:block', { + blocked: true, + profileId: outgoingProfile.id, + }) + let blockBootstrap = await expectSuccess( + baseUrl, + 'skypic:bootstrap', + {}, + true, + ) + assert( + blockBootstrap.blockedProfiles.some( + (profile) => profile.id === outgoingProfile.id, + ), + 'skypic:block did not expose the blocked profile in bootstrap', + ) + assert.deepEqual( + await expectSuccess( + baseUrl, + 'skypic:search', + { query: outgoingProfile.handle }, + true, + ), + [], + ) + await expectSuccess(baseUrl, 'skypic:block', { + blocked: false, + profileId: outgoingProfile.id, + }) + blockBootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + assert( + !blockBootstrap.blockedProfiles.some( + (profile) => profile.id === outgoingProfile.id, + ), + 'skypic:unblock left the profile in bootstrap', + ) + assert( + blockBootstrap.suggestions.some( + (profile) => profile.id === outgoingProfile.id, + ), + 'skypic:unblock did not restore the profile to discovery', + ) + + assert.deepEqual( + await post(baseUrl, 'skypic:send-snap', { + allowReplay: true, + caption: 'x'.repeat(161), + durationSeconds: 6, + mediaId: 1, + mediaType: 'photo', + overlayColor: '#24c7ff', + recipientIds: [friend.profile.id], + textOverlay: 'Smoke test', + }), + { error: 'invalid_caption', success: false }, + ) + assert.deepEqual( + await post(baseUrl, 'skypic:send-snap', { + allowReplay: true, + caption: '', + durationSeconds: 6, + mediaId: 1, + mediaType: 'photo', + overlayColor: '#24c7ff', + recipientIds: [friend.profile.id], + textOverlay: `${maximumTextOverlay}😀`, + }), + { error: 'invalid_overlay', success: false }, + ) + const sentSnaps = await expectSuccess( + baseUrl, + 'skypic:send-snap', + { + allowReplay: true, + caption: 'x'.repeat(160), + durationSeconds: 6, + mediaId: 1, + mediaType: 'photo', + overlayColor: '#24c7ff', + recipientIds: [friend.profile.id], + textOverlay: maximumTextOverlay, + }, + true, + ) + assert.equal(sentSnaps.length, 1) + assert.equal( + Date.parse(sentSnaps[0].expiresAt) - Date.parse(sentSnaps[0].createdAt), + 30 * 24 * 60 * 60_000, + ) + expectSkyPicMetadataSafe(sentSnaps, 'SkyPic sent snap response') + expectedSnapScore += sentSnaps.length + assert.equal(sentSnaps[0].sender.snapScore, expectedSnapScore) + bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + assert.equal(bootstrap.profile.snapScore, expectedSnapScore) + + const sentMessage = await expectSuccess( + baseUrl, + 'skypic:send-message', + { + body: 'Stateful SkyPic smoke message', + friendshipId: friend.friendshipId, + }, + true, + ) + const savedMessage = await expectSuccess( + baseUrl, + 'skypic:save-message', + { messageId: sentMessage.id, saved: true }, + true, + ) + assert.equal(typeof savedMessage.savedAt, 'string') + await expectSuccess(baseUrl, 'skypic:mark-thread', { + friendshipId: friend.friendshipId, + }) + await expectSuccess(baseUrl, 'skypic:delete-message', { + forEveryone: true, + messageId: sentMessage.id, + }) + const updatedThread = await expectSuccess( + baseUrl, + 'skypic:thread', + { friendshipId: friend.friendshipId }, + true, + ) + assert( + !updatedThread.messages.some((message) => message.id === sentMessage.id), + 'skypic:delete-message did not persist', + ) + assert( + updatedThread.snaps.some((snap) => snap.id === sentSnaps[0].id), + 'skypic:send-snap did not persist in the thread', + ) + expectSkyPicMetadataSafe(updatedThread.snaps, 'updated SkyPic thread snaps') + + assert.deepEqual( + await post(baseUrl, 'skypic:publish-story', { + caption: 'y'.repeat(161), + durationSeconds: 8, + mediaId: 3, + mediaType: 'photo', + overlayColor: '#070f2b', + textOverlay: 'Sky UI', + }), + { error: 'invalid_caption', success: false }, + ) + assert.deepEqual( + await post(baseUrl, 'skypic:publish-story', { + caption: '', + durationSeconds: 8, + mediaId: 3, + mediaType: 'photo', + overlayColor: '#070f2b', + textOverlay: `${maximumTextOverlay}😀`, + }), + { error: 'invalid_overlay', success: false }, + ) + const publishedStory = await expectSuccess( + baseUrl, + 'skypic:publish-story', + { + caption: 'y'.repeat(160), + durationSeconds: 8, + mediaId: 3, + mediaType: 'photo', + overlayColor: '#070f2b', + textOverlay: maximumTextOverlay, + }, + true, + ) + expectSkyPicMetadataSafe([publishedStory], 'published SkyPic story') + expectedSnapScore += 1 + assert.equal(publishedStory.author.snapScore, expectedSnapScore) + bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true) + assert.equal(bootstrap.profile.snapScore, expectedSnapScore) + const openedPublishedStory = await expectSuccess( + baseUrl, + 'skypic:view-story', + { storyId: publishedStory.id }, + true, + ) + assert.equal(openedPublishedStory.caption, 'y'.repeat(160)) + assert.equal(openedPublishedStory.textOverlay, maximumTextOverlay) + assert.deepEqual( + await expectSuccess( + baseUrl, + 'skypic:story-viewers', + { offset: 0, storyId: publishedStory.id }, + true, + ), + [], + ) + await expectSuccess(baseUrl, 'skypic:remove-story', { + storyId: publishedStory.id, + }) + + const profileUpdate = { + allowStoryReplies: false, + avatarMediaId: 12, + avatarSeed: 2_147_483_647, + bio: 'Updated by the browser smoke test.', + displayName: 'Alex Sky', + handle: 'alex.sky', + showInQuickAdd: false, + storyPrivacy: 'everyone', + } + for (const avatarSeed of [0, -1, 1.5, 2_147_483_648]) { + assert.deepEqual( + await post(baseUrl, 'skypic:update-profile', { + ...profileUpdate, + avatarSeed, + }), + { error: 'invalid_avatar_seed', success: false }, + ) + } + const profile = await expectSuccess( + baseUrl, + 'skypic:update-profile', + profileUpdate, + true, + ) + assert.equal(profile.handle, 'alex.sky') + assert.equal(profile.storyPrivacy, 'everyone') + assert.equal(profile.avatarSeed, 2_147_483_647) + assert.equal(profile.snapScore, expectedSnapScore) + const createProfile = { + avatarMediaId: 12, + avatarSeed: 8, + displayName: 'Alex Morgan', + handle: 'alexm', + } + assert.deepEqual( + await post(baseUrl, 'skypic:create-profile', createProfile), + { + error: 'profile_exists', + success: false, + }, + ) + const onboarding = await expectSuccess( + baseUrl, + 'skypic:bootstrap', + { _testScenario: 'skypic-onboarding' }, + true, + ) + assert.equal(onboarding.profile, null) + const recreatedProfile = await expectSuccess( + baseUrl, + 'skypic:create-profile', + { + ...createProfile, + _testScenario: 'skypic-onboarding', + }, + true, + ) + assert.equal(recreatedProfile.handle, 'alexm') + const createdOnboarding = await expectSuccess( + baseUrl, + 'skypic:bootstrap', + { _testScenario: 'skypic-onboarding' }, + true, + ) + assert.equal(createdOnboarding.profile.handle, 'alexm') + assert.equal(createdOnboarding.profile.snapScore, 0) + assert.deepEqual( + await post(baseUrl, 'skypic:create-profile', { + ...createProfile, + _testScenario: 'skypic-onboarding', + }), + { error: 'profile_exists', success: false }, + ) + + await expectSuccess(baseUrl, 'skypic:block', { + blocked: true, + profileId: friendStory.author.id, + }) + const storiesAfterBlock = await expectSuccess( + baseUrl, + 'skypic:stories', + { offset: 0 }, + true, + ) + assert( + storiesAfterBlock.every( + (story) => story.author.id !== friendStory.author.id, + ), + 'skypic:stories restored a blocked author', + ) + const bootstrapAfterBlock = await expectSuccess( + baseUrl, + 'skypic:bootstrap', + {}, + true, + ) + assert( + bootstrapAfterBlock.stories.every( + (story) => story.author.id !== friendStory.author.id, + ), + 'skypic:bootstrap restored a blocked author story', + ) + assert.deepEqual( + await post(baseUrl, 'skypic:view-story', { storyId: friendStory.id }), + { error: 'story_unavailable', success: false }, + ) +} + async function verifyStatefulActions(baseUrl) { + await verifySkyPicActions(baseUrl) + const cryptoBeforeTransfer = await expectSuccess( baseUrl, 'crypto:bootstrap', diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 7051f35..bfab6f2 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -489,6 +489,42 @@ Config.Picstagram = { AdminGroups = { "admin" }, } +Config.SkyPic = { + PageSize = 30, + ThreadPageSize = 200, + InboxPageSize = 100, + SuggestionLimit = 20, + SearchLimit = 30, + HandleMinLength = 3, + HandleMaxLength = 24, + DisplayNameMaxLength = 40, + BioMaxLength = 160, + MessageMaxLength = 2000, + CaptionMaxLength = 160, + OverlayTextMaxLength = 160, + MinimumViewSeconds = 1, + MaximumViewSeconds = 10, + MaximumSnapRecipients = 20, + MaximumFriends = 500, + MaximumPendingRequests = 100, + MaximumActiveStories = 50, + UnopenedSnapLifetimeSeconds = 30 * 24 * 60 * 60, + ReplayWindowSeconds = 5 * 60, + TextAfterReadLifetimeSeconds = 24 * 60 * 60, + StoryLifetimeSeconds = 24 * 60 * 60, + CleanupIntervalSeconds = 45, + ProfileActionsPerMinute = 10, + ReadActionsPerMinute = 120, + SearchActionsPerMinute = 30, + FriendActionsPerMinute = 30, + MessagesPerMinute = 30, + SnapsPerMinute = 20, + SnapRecipientsPerMinute = 120, + OpensPerMinute = 120, + StoriesPerMinute = 6, + StoryViewsPerMinute = 120, +} + Config.Feather = { PageSize = 20, TextMaxLength = 360, diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 2b035d8..d3d8a0f 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -128,8 +128,8 @@ Locales["de"] = { }, Common = { add = "Hinzufügen", back = "Zurück", cancel = "Abbrechen", clear = "Leeren", close = "Schließen", continue = "Weiter", delete = "Löschen", done = "Fertig", edit = "Bearbeiten", home = "Home", loading = "Wird geladen", pause = "Pause", use = "Verwenden", - phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen", - save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen", + phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen", retry = "Erneut versuchen", + loadMore = "Mehr laden", save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen", signOut = "Abmelden", signingOut = "Wird abgemeldet...", signOutTitle = "Von {app} abmelden?", signOutBody = "Du wirst nur von {app} abgemeldet. Deine anderen iFruit-Apps bleiben angemeldet.", signOutFailed = "Abmelden fehlgeschlagen. Versuche es erneut.", appAuth = { @@ -530,6 +530,128 @@ Locales["de"] = { default = "Picstagram konnte die Anfrage nicht abschließen." }, }, + skypic = { + name = "SkyPic", + loading = "SkyPic wird geladen", + navigation = "SkyPic-Navigation", + time = { + seconds = "vor {count} Sek.", minutes = "vor {count} Min.", + hours = "vor {count} Std.", days = "vor {count} Tagen", + }, + onboarding = { + title = "Willkommen bei SkyPic", eyebrow = "Dein privates Kameranetzwerk", + heading = "Teile den Moment", + body = "Erstelle ein Profil für private Snaps, Stories mit Freunden und schnelle Chats.", + displayName = "Anzeigename", displayNamePlaceholder = "Dein Name", + handle = "Handle", handlePlaceholder = "dein.handle", + accountBound = "Dein SkyPic-Profil bleibt mit diesem Sky-Cloud-Konto verbunden.", + create = "Profil erstellen", creating = "Wird erstellt...", + }, + camera = { + eyebrow = "Sky-Kamera", title = "Aufnehmen", + body = "Nimm ein Foto oder Video auf und teile es für nur wenige Sekunden.", + snap = "Snap", story = "Story", photo = "Foto", video = "Video", + gallery = "Galerie öffnen", capturePhoto = "Foto aufnehmen", captureVideo = "Video aufnehmen", + snapHint = "Sende es privat an einen oder mehrere Freunde.", + storyHint = "Teile es 24 Stunden lang in deiner Story.", + }, + composer = { + snapTitle = "Neuer Snap", storyTitle = "Neue Story", send = "Senden", addStory = "Zur Story hinzufügen", + caption = "Bildunterschrift", captionPlaceholder = "Bildunterschrift hinzufügen...", + textOverlay = "Text im Bild", textPlaceholder = "Schreib etwas auf den Moment...", + color = "Textfarbe", duration = "Anzeigedauer", seconds = "{count} Sekunden", + replay = "Eine Wiederholung erlauben", + replayBody = "Empfänger dürfen diesen Snap ein weiteres Mal öffnen.", + recipients = "Empfänger", recipientsHint = "Wähle einen oder mehrere Freunde.", recipientLimit = "Wähle bis zu {count} Freunde aus.", + selectedCount = "{count} ausgewählt", noFriends = "Füge vor dem Senden einen Freund hinzu.", + changeMedia = "Foto oder Video ändern", sent = "Snap gesendet.", storyPublished = "Story veröffentlicht.", + }, + tabs = { camera = "Kamera", chats = "Chats", stories = "Stories", friends = "Freunde" }, + chats = { + title = "Chats", incoming = "Neue Snaps", noSnaps = "Keine ungeöffneten Snaps", + 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", + }, + snaps = { + newVideo = "Neuer Video-Snap", newPhoto = "Neuer Foto-Snap", replayed = "Wiederholt", + opened = "Geöffnet", video = "Video-Snap", photo = "Foto-Snap", replay = "Snap wiederholen", + }, + stories = { + title = "Stories", add = "Story hinzufügen", yours = "Deine Story", friends = "Freunde", + emptyTitle = "Gerade keine Stories", + emptyBody = "Stories deiner Freunde erscheinen hier für 24 Stunden.", + views = "{count} Aufrufe", viewers = "Zuschauer", noViewers = "Noch keine Aufrufe", + replyPlaceholder = "Auf diese Story antworten...", replySent = "Antwort gesendet.", replyLimit = "Antworten dürfen bis zu {count} Zeichen enthalten.", + delete = "Story löschen", deleted = "Story gelöscht.", seen = "Gesehen", unseen = "Neu", + }, + friends = { + title = "Freunde", searchPlaceholder = "Name oder @Handle suchen", + searchResults = "Suchergebnisse", score = "{count} Punkte", + requests = "Freundschaftsanfragen", sentRequests = "Gesendete Anfragen", accept = "Annehmen", decline = "Ablehnen", + quickAdd = "Schnell hinzufügen", add = "Hinzufügen", pending = "Ausstehend", cancelRequest = "Anfrage zurückziehen", all = "Deine Freunde", + empty = "Füge Freunde hinzu, um Snaps zu senden.", remove = "Freund entfernen", block = "Blockieren", blockedProfiles = "Blockierte Profile", unblock = "Blockierung aufheben", + chat = "Chat", sendSnap = "Snap senden", respond = "Antworten", friends = "Freunde", + requestSent = "Freundschaftsanfrage an {name} gesendet.", requestCanceled = "Freundschaftsanfrage an {name} zurückgezogen.", + removed = "{name} wurde aus deinen Freunden entfernt.", blocked = "{name} wurde blockiert.", unblocked = "{name} wurde entsperrt.", + }, + profile = { + title = "Profil", edit = "Profil bearbeiten", save = "Speichern", cancel = "Abbrechen", + score = "Snap-Score", streaks = "Serien", friends = "Freunde", bio = "Bio", + bioPlaceholder = "Erzähl deinen Freunden ein wenig über dich...", + storyPrivacy = "Story-Sichtbarkeit", privacyFriends = "Nur Freunde", privacyEveryone = "Alle", + allowStoryReplies = "Story-Antworten erlauben", + allowStoryRepliesBody = "Freunde können im Chat auf deine Stories antworten.", + showInQuickAdd = "In Schnell hinzufügen anzeigen", + showInQuickAddBody = "Andere Profile dürfen dich als Vorschlag entdecken.", + saved = "Profil aktualisiert.", + }, + viewer = { close = "Schließen", timeLeft = "{count}s" }, + notifications = { + friend_request = "{actor} hat dir eine Freundschaftsanfrage gesendet.", + friend_accepted = "{actor} hat deine Freundschaftsanfrage angenommen.", + snap = "{actor} hat dir einen Snap gesendet.", message = "{actor} hat dir eine Nachricht gesendet.", + story_reply = "{actor} hat auf deine Story geantwortet.", snap_opened = "{actor} hat deinen Snap geöffnet.", + default = "Du hast neue SkyPic-Aktivität.", + }, + errors = { + profile_required = "Erstelle zuerst dein SkyPic-Profil.", + profile_exists = "Dieses Konto hat bereits ein SkyPic-Profil.", + invalid_handle = "Verwende 3-24 Buchstaben, Zahlen, Punkte oder Unterstriche.", + handle_taken = "Dieser Handle ist bereits vergeben.", + invalid_display_name = "Gib einen Anzeigenamen ein.", invalid_bio = "Deine Bio ist zu lang.", + invalid_avatar = "Wähle ein gültiges Profilfoto.", invalid_avatar_seed = "Wähle einen gültigen Avatar.", + invalid_privacy = "Wähle eine gültige Story-Sichtbarkeit.", + invalid_request = "Diese Freundschaftsanfrage ist ungültig.", + profile_not_found = "Dieses SkyPic-Profil ist nicht verfügbar.", blocked = "Dieses Profil ist blockiert.", + friendship_not_found = "Diese Freundschaft ist nicht verfügbar.", + friend_request_exists = "Eine Freundschaftsanfrage besteht bereits.", + friend_limit_reached = "Deine Freundesliste ist voll.", + request_limit_reached = "Zu viele offene Freundschaftsanfragen.", + invalid_recipients = "Wähle mindestens einen aktuellen Freund.", + invalid_media = "Wähle Medien von diesem Telefon.", + invalid_media_type = "Dieser Medientyp wird nicht unterstützt.", + invalid_duration = "Wähle eine Anzeigedauer von 1 bis 10 Sekunden.", + invalid_caption = "Diese Bildunterschrift ist zu lang.", + invalid_overlay = "Dieser eingeblendete Text ist zu lang.", + invalid_color = "Wähle eine gültige Textfarbe.", + snap_unavailable = "Dieser Snap ist nicht mehr verfügbar.", + replay_unavailable = "Dieser Snap kann nicht noch einmal wiederholt werden.", + message_empty = "Schreibe vor dem Senden eine Nachricht.", + message_too_long = "Nachrichten dürfen höchstens 2000 Zeichen enthalten.", + message_not_found = "Diese Nachricht ist nicht verfügbar.", + 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.", + 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.", + not_authenticated = "Melde dich zuerst bei Sky Cloud an.", + unknown_error = "SkyPic konnte die Anfrage nicht abschließen.", + default = "SkyPic konnte die Anfrage nicht abschließen.", + }, + }, feather = { name = "Feather", loading = "Feather wird geladen...", home = "Home", explore = "Erkunden", activity = "Mitteilungen", activityNav = "Mitteilungen", profile = "Profil", back = "Zurück", settings = "Einstellungen", settingsEyebrow = "Feather-Einstellungen", compactMode = "Kompakte Timeline", compactModeBody = "Mehr Beiträge auf dem Bildschirm anzeigen.", showSuggestions = "Profilvorschläge", showSuggestionsBody = "Zeige Profile an, denen du vielleicht folgen möchtest.", @@ -1880,6 +2002,7 @@ Locales["de"] = { import_source_unavailable = "Diese Website ist vorübergehend nicht verfügbar.", invalid_import_url = "Gib einen gültigen HTTPS-Medienlink ein.", import_url_not_allowed = "Dieser Link stammt nicht von der gewählten Website.", import_url_unavailable = "Die vernetzten Medien konnten nicht erreicht werden.", import_size_unavailable = "Die Website lieferte nicht die Mediengröße.", + media_in_use = "Dieses Medium wird noch von SkyPic verwendet und kann noch nicht gelöscht werden.", not_found = "Das Medienobjekt existiert nicht mehr.", profile_photo_required = "Dies ist das letzte Foto in deinem Flare-Profil. Füge vor dem Löschen ein weiteres Profilfoto hinzu.", owner_changed = "Das aktive Telefonkonto hat sich geändert.", operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.", rate_limited = "Zu viele Medienaktionen. Versuch es gleich erneut.", request_failed = "Die Anfrage Fotos ist fehlgeschlagen.", @@ -1920,7 +2043,7 @@ Locales["de"] = { health = "Gesundheit, Aktivität und medizinische Informationen", ["weazel-news"] = "Lokale Nachrichten und Stadtgeschichten", companies = "Unternehmen, Arbeitsplätze und Dienstleistungen", music = "Lieder, Wiedergabelisten und Audio", - picstagram = "Foto-Sharing und sozialer Feed", feather = "Kurze Beiträge und Stadtgespräche", + picstagram = "Foto-Sharing und sozialer Feed", skypic = "Private Snaps, Stories und enge Freunde", feather = "Kurze Beiträge und Stadtgespräche", fliptok = "Videos und Trends", flare = "Soziale Beiträge und Live-Momente", calendar = "Veranstaltungen, Termine und Erinnerungen", radio = "Live-Radio und Team-Kommunikation", ["local-pages"] = "Lokale Unternehmen und Community-Seiten", crewlink = "Crews, Mitglieder und Koordination", @@ -1946,6 +2069,7 @@ Locales["de"] = { companies = { first = "Unternehmensverzeichnis", second = "Jobanfragen", third = "Dienstleistungen" }, music = { first = "Aktuelle Wiedergabe", second = "Playlists", third = "Musikmediathek" }, picstagram = { first = "Foto-Feed", second = "Stories", third = "Profile" }, + skypic = { first = "Private Snaps", second = "Freundes-Stories", third = "Schnelle Chats" }, feather = { first = "Kurzbeiträge", second = "Abonniert-Feed", third = "Unterhaltungen" }, fliptok = { first = "Video-Feed", second = "Creator-Werkzeuge", third = "Trends" }, flare = { first = "Personen entdecken", second = "Matches", third = "Live-Momente" }, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index d5b8f70..580f6a5 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -128,8 +128,8 @@ Locales["en"] = { }, Common = { add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", continue = "Continue", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use", - phone = "Phone", phoneStatus = "Phone status", reset = "Reset", - save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop", + phone = "Phone", phoneStatus = "Phone status", reset = "Reset", retry = "Try Again", + loadMore = "Load More", save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop", signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?", signOutBody = "You will only be signed out of {app}. Your other iFruit apps stay signed in.", signOutFailed = "Could not sign out. Please try again.", appAuth = { @@ -530,6 +530,128 @@ Locales["en"] = { default = "Picstagram could not complete the request." }, }, + skypic = { + name = "SkyPic", + loading = "Loading SkyPic", + navigation = "SkyPic navigation", + time = { + seconds = "{count}s ago", minutes = "{count}m ago", + hours = "{count}h ago", days = "{count}d ago", + }, + onboarding = { + title = "Welcome to SkyPic", eyebrow = "Your private camera network", + heading = "Share the moment", + body = "Create a profile for private snaps, close-friend stories, and quick chats.", + displayName = "Display name", displayNamePlaceholder = "Your name", + handle = "Handle", handlePlaceholder = "your.handle", + accountBound = "Your SkyPic profile stays linked to this Sky Cloud account.", + create = "Create profile", creating = "Creating...", + }, + camera = { + eyebrow = "Sky camera", title = "Capture", + body = "Take a photo or video and share it for just a few seconds.", + snap = "Snap", story = "Story", photo = "Photo", video = "Video", + gallery = "Open gallery", capturePhoto = "Take photo", captureVideo = "Record video", + snapHint = "Send it privately to one or more friends.", + storyHint = "Share it with your story for 24 hours.", + }, + composer = { + snapTitle = "New snap", storyTitle = "New story", send = "Send", addStory = "Add to story", + caption = "Caption", captionPlaceholder = "Add a caption...", + textOverlay = "Text overlay", textPlaceholder = "Put words on the moment...", + color = "Text color", duration = "View duration", seconds = "{count} seconds", + replay = "Allow one replay", + replayBody = "Recipients may open this snap one additional time.", + recipients = "Recipients", recipientsHint = "Choose one or more friends.", recipientLimit = "Choose up to {count} friends.", + selectedCount = "{count} selected", noFriends = "Add a friend before sending a snap.", + changeMedia = "Change photo or video", sent = "Snap sent.", storyPublished = "Story published.", + }, + tabs = { camera = "Camera", chats = "Chats", stories = "Stories", friends = "Friends" }, + chats = { + title = "Chats", incoming = "New snaps", noSnaps = "No unopened snaps", + 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", + }, + snaps = { + newVideo = "New video snap", newPhoto = "New photo snap", replayed = "Replayed", + opened = "Opened", video = "Video snap", photo = "Photo snap", replay = "Replay snap", + }, + stories = { + title = "Stories", add = "Add story", yours = "Your story", friends = "Friends", + emptyTitle = "No stories right now", + emptyBody = "Stories from your friends will appear here for 24 hours.", + views = "{count} views", viewers = "Viewers", noViewers = "No views yet", + replyPlaceholder = "Reply to this story...", replySent = "Reply sent.", replyLimit = "Replies can contain up to {count} characters.", + delete = "Delete story", deleted = "Story deleted.", seen = "Seen", unseen = "New", + }, + friends = { + title = "Friends", searchPlaceholder = "Search name or @handle", + searchResults = "Search results", score = "{count} points", + requests = "Friend requests", sentRequests = "Sent requests", accept = "Accept", decline = "Decline", + quickAdd = "Quick Add", add = "Add", pending = "Pending", cancelRequest = "Cancel request", all = "Your friends", + empty = "Add friends to start snapping.", remove = "Remove friend", block = "Block", blockedProfiles = "Blocked profiles", unblock = "Unblock", + chat = "Chat", sendSnap = "Send snap", respond = "Respond", friends = "Friends", + requestSent = "Friend request sent to {name}.", requestCanceled = "Friend request to {name} canceled.", + removed = "{name} was removed from your friends.", blocked = "{name} was blocked.", unblocked = "{name} was unblocked.", + }, + profile = { + title = "Profile", edit = "Edit profile", save = "Save", cancel = "Cancel", + score = "Snap score", streaks = "Streaks", friends = "Friends", bio = "Bio", + bioPlaceholder = "Tell your friends a little about you...", + storyPrivacy = "Story privacy", privacyFriends = "Friends only", privacyEveryone = "Everyone", + allowStoryReplies = "Allow story replies", + allowStoryRepliesBody = "Friends can reply to your stories in chat.", + showInQuickAdd = "Show in Quick Add", + showInQuickAddBody = "Let other profiles discover you as a suggestion.", + saved = "Profile updated.", + }, + viewer = { close = "Close", timeLeft = "{count}s" }, + notifications = { + friend_request = "{actor} sent you a friend request.", + friend_accepted = "{actor} accepted your friend request.", + snap = "{actor} sent you a snap.", message = "{actor} sent you a message.", + story_reply = "{actor} replied to your story.", snap_opened = "{actor} opened your snap.", + default = "You have new SkyPic activity.", + }, + errors = { + profile_required = "Create your SkyPic profile first.", + profile_exists = "This account already has a SkyPic profile.", + invalid_handle = "Use 3-24 letters, numbers, dots, or underscores.", + handle_taken = "This handle is already taken.", + invalid_display_name = "Enter a display name.", invalid_bio = "Your bio is too long.", + invalid_avatar = "Choose a valid profile photo.", invalid_avatar_seed = "Choose a valid avatar.", + invalid_privacy = "Choose a valid story privacy setting.", + invalid_request = "This friend request is invalid.", + profile_not_found = "This SkyPic profile is unavailable.", blocked = "This profile is blocked.", + friendship_not_found = "This friendship is unavailable.", + friend_request_exists = "A friend request already exists.", + friend_limit_reached = "Your friends list is full.", + request_limit_reached = "Too many open friend requests.", + invalid_recipients = "Choose at least one current friend.", + invalid_media = "Choose media from this phone.", + invalid_media_type = "This media type is not supported.", + invalid_duration = "Choose a view time from 1 to 10 seconds.", + invalid_caption = "This caption is too long.", + invalid_overlay = "This text overlay is too long.", + invalid_color = "Choose a valid overlay color.", + snap_unavailable = "This snap is no longer available.", + replay_unavailable = "This snap cannot be replayed again.", + message_empty = "Write a message before sending.", + message_too_long = "Messages can contain at most 2000 characters.", + message_not_found = "This message is unavailable.", + 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.", + 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.", + not_authenticated = "Sign in to Sky Cloud first.", + unknown_error = "SkyPic could not complete the request.", + default = "SkyPic could not complete the request.", + }, + }, feather = { name = "Feather", loading = "Loading Feather", home = "Home", explore = "Explore", activity = "Notifications", activityNav = "Alerts", profile = "Profile", back = "Back", settings = "Settings", settingsEyebrow = "Feather preferences", compactMode = "Compact timeline", compactModeBody = "Show more conversation on the screen.", showSuggestions = "Profile suggestions", showSuggestionsBody = "Show people to follow on your profile.", @@ -1880,6 +2002,7 @@ Locales["en"] = { import_source_unavailable = "This website is temporarily unavailable.", invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.", import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.", + media_in_use = "This media is still used by SkyPic and cannot be deleted yet.", not_found = "The media item no longer exists.", profile_photo_required = "This is the last photo on your Flare profile. Add another profile photo before deleting it.", owner_changed = "The active phone account changed.", operation_in_progress = "Another media operation is already in progress.", rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Photos request failed.", @@ -1920,7 +2043,7 @@ Locales["en"] = { health = "Health, activity and medical information", ["weazel-news"] = "Local news and city stories", companies = "Businesses, jobs and services", music = "Songs, playlists and audio", - picstagram = "Photo sharing and social feed", feather = "Short posts and city conversations", + picstagram = "Photo sharing and social feed", skypic = "Private snaps, stories and close friends", feather = "Short posts and city conversations", fliptok = "Short videos and trends", flare = "Social posts and live moments", calendar = "Events, schedules and reminders", radio = "Live radio and team communication", ["local-pages"] = "Local businesses and community pages", crewlink = "Crews, members and coordination", @@ -1946,6 +2069,7 @@ Locales["en"] = { companies = { first = "Business directory", second = "Job requests", third = "Services" }, music = { first = "Now playing", second = "Playlists", third = "Music library" }, picstagram = { first = "Photo feed", second = "Stories", third = "Profiles" }, + skypic = { first = "Private snaps", second = "Friend stories", third = "Quick chats" }, feather = { first = "Short posts", second = "Following feed", third = "Conversations" }, fliptok = { first = "Video feed", second = "Creator tools", third = "Trends" }, flare = { first = "Discover people", second = "Matches", third = "Live moments" }, diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index cb8bca6..0b9f368 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -83,6 +83,7 @@ server_scripts { 'source/server/media_import/fivemanage.lua', 'source/server/media_import/manifest.lua', 'source/server/media.lua', + 'source/server/skypic.lua', 'source/server/weazel_news.lua', 'source/server/citywarn.lua', 'source/server/messages.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 72824fc..fded633 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -160,6 +160,27 @@ local server_callbacks = { "picstagram:report", "picstagram:admin-reports", "picstagram:admin-resolve-report", + "skypic:bootstrap", + "skypic:create-profile", + "skypic:update-profile", + "skypic:search", + "skypic:add-friend", + "skypic:respond-friend", + "skypic:remove-friend", + "skypic:block", + "skypic:send-snap", + "skypic:open-snap", + "skypic:replay-snap", + "skypic:publish-story", + "skypic:stories", + "skypic:view-story", + "skypic:story-viewers", + "skypic:remove-story", + "skypic:thread", + "skypic:send-message", + "skypic:mark-thread", + "skypic:save-message", + "skypic:delete-message", "feather:bootstrap", "feather:create-profile", "feather:update-profile", @@ -868,6 +889,14 @@ RegisterNetEvent("sky_phone:picstagram:new", function(data) SendNUIMessage({ type = "picstagram:new", data = data }) end) +RegisterNetEvent("sky_phone:skypic:new", function(data) + local skypic_locale = locale.Nui.Apps.skypic + local notification_text = skypic_locale.notifications[data.kind] or skypic_locale.notifications.default + data.title = skypic_locale.name + data.text = notification_text:gsub("{actor}", tostring(data.actor or "")) + SendNUIMessage({ type = "skypic:new", 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/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 5b93561..5e7bd8d 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -2907,9 +2907,197 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_skypic_profiles", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "handle", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "display_name", type = "VARCHAR(40) NOT NULL" }, + { name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" }, + { name = "avatar_seed", type = "INT UNSIGNED NOT NULL DEFAULT 1" }, + { name = "story_privacy", type = "ENUM('friends', 'everyone') NOT NULL DEFAULT 'friends'" }, + { name = "quick_add", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "allow_story_replies", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "snap_score", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "friend_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "status", type = "ENUM('active', 'hidden', 'removed') NOT NULL DEFAULT 'active'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_skypic_profile_account", columns = "(`account_id`)" }, + { name = "uniq_sky_phone_skypic_profile_handle", columns = "(`handle`)" }, + }, + indexes = { + { name = "idx_sky_phone_skypic_quick_add", columns = "(`status`, `quick_add`, `updated_at`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + { column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_skypic_friendships", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_a_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_b_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "requested_by_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "status", type = "ENUM('pending', 'accepted') NOT NULL DEFAULT 'pending'" }, + { name = "profile_a_last_snap_on", type = "DATE NULL" }, + { name = "profile_b_last_snap_on", type = "DATE NULL" }, + { name = "streak_updated_on", type = "DATE NULL" }, + { name = "streak_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "best_streak", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "accepted_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_skypic_friend_pair", columns = "(`profile_a_id`, `profile_b_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_skypic_friend_a", columns = "(`profile_a_id`, `status`, `updated_at`)" }, + { name = "idx_sky_phone_skypic_friend_b", columns = "(`profile_b_id`, `status`, `updated_at`)" }, + { name = "idx_sky_phone_skypic_friend_requests", columns = "(`status`, `requested_by_id`, `created_at`)" }, + { name = "idx_sky_phone_skypic_streaks", columns = "(`status`, `streak_updated_on`)" }, + }, + foreignKeys = { + { column = "profile_a_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + { column = "profile_b_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + { column = "requested_by_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_skypic_blocks", + columns = { + { name = "blocker_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "blocked_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = { "blocker_profile_id", "blocked_profile_id" }, + indexes = { + { name = "idx_sky_phone_skypic_blocked", columns = "(`blocked_profile_id`, `created_at`)" }, + }, + foreignKeys = { + { column = "blocker_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + { column = "blocked_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_skypic_messages", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "friendship_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "sender_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "recipient_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "message_type", type = "ENUM('text', 'snap_photo', 'snap_video') NOT NULL" }, + { name = "body", type = "VARCHAR(2000) NOT NULL DEFAULT ''" }, + { name = "caption", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "overlay_text", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "overlay_color", type = "CHAR(7) NOT NULL DEFAULT '#FFFFFF'", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NULL" }, + { name = "view_seconds", type = "TINYINT UNSIGNED NULL" }, + { name = "allow_replay", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "read_at", type = "DATETIME(6) NULL" }, + { name = "opened_at", type = "DATETIME(6) NULL" }, + { name = "replayed_at", type = "DATETIME(6) NULL" }, + { name = "saved_at", type = "DATETIME(6) NULL" }, + { name = "expires_at", type = "DATETIME(6) NULL" }, + { name = "sender_deleted_at", type = "DATETIME(6) NULL" }, + { name = "recipient_deleted_at", type = "DATETIME(6) NULL" }, + { name = "deleted_at", type = "DATETIME(6) NULL" }, + { name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_skypic_message_thread", columns = "(`friendship_id`, `created_at`, `id`)" }, + { name = "idx_sky_phone_skypic_message_inbox", columns = "(`recipient_profile_id`, `read_at`, `created_at`)" }, + { name = "idx_sky_phone_skypic_message_expiry", columns = "(`message_type`, `expires_at`)" }, + { name = "idx_sky_phone_skypic_message_deleted", columns = "(`deleted_at`)" }, + { name = "idx_sky_phone_skypic_message_media", columns = "(`media_id`)" }, + }, + foreignKeys = { + { column = "friendship_id", references = "`sky_phone_skypic_friendships` (`id`) ON DELETE CASCADE" }, + { column = "sender_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + { column = "recipient_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE RESTRICT" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_skypic_stories", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "caption", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "overlay_text", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "overlay_color", type = "CHAR(7) NOT NULL DEFAULT '#FFFFFF'", characterSet = "ascii", collation = "ascii_bin" }, + { name = "view_seconds", type = "TINYINT UNSIGNED NOT NULL" }, + { name = "privacy", type = "ENUM('friends', 'everyone') NOT NULL" }, + { name = "status", type = "ENUM('active', 'removed') NOT NULL DEFAULT 'active'" }, + { name = "expires_at", type = "DATETIME(6) NOT NULL" }, + { name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" }, + { name = "updated_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_skypic_story_profile", columns = "(`profile_id`, `status`, `expires_at`)" }, + { name = "idx_sky_phone_skypic_story_expiry", columns = "(`status`, `expires_at`)" }, + { name = "idx_sky_phone_skypic_story_media", columns = "(`media_id`)" }, + }, + foreignKeys = { + { column = "profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE RESTRICT" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_skypic_story_views", + columns = { + { name = "story_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "viewer_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "viewed_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" }, + }, + primaryKey = { "story_id", "viewer_profile_id" }, + indexes = { + { name = "idx_sky_phone_skypic_story_viewer", columns = "(`viewer_profile_id`, `viewed_at`)" }, + }, + foreignKeys = { + { column = "story_id", references = "`sky_phone_skypic_stories` (`id`) ON DELETE CASCADE" }, + { column = "viewer_profile_id", references = "`sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) +Bridge.Database.EnsureIndex( + "sky_phone_skypic_profiles", + "uniq_sky_phone_skypic_profile_account", + "(`account_id`)", + { unique = true } +) +Bridge.Database.EnsureIndex( + "sky_phone_skypic_profiles", + "uniq_sky_phone_skypic_profile_handle", + "(`handle`)", + { unique = true } +) +Bridge.Database.EnsureIndex( + "sky_phone_skypic_friendships", + "uniq_sky_phone_skypic_friend_pair", + "(`profile_a_id`, `profile_b_id`)", + { unique = true } +) Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_fliptok_video_media` (`video_id`, `media_id`, `sort_order`) SELECT `id`, `media_id`, 1 FROM `sky_phone_fliptok_videos` diff --git a/sky_phone/source/server/easyshare.lua b/sky_phone/source/server/easyshare.lua index ac7f7b6..70dd606 100644 --- a/sky_phone/source/server/easyshare.lua +++ b/sky_phone/source/server/easyshare.lua @@ -37,6 +37,7 @@ local valid_apps = { phone = true, photos = true, picstagram = true, + skypic = true, } local valid_visibilities = { contacts = true, everyone = true, hidden = true } diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index cec2e17..879ef97 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -20,6 +20,16 @@ local allowed_remote_mimes = { }, } +local function affected_rows(result) + if type(result) == "number" then + return result + end + if type(result) == "table" then + return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 + end + return 0 +end + local function media_config() return Config.Media.FiveManage end @@ -821,6 +831,20 @@ local function is_required_flare_profile_photo(media_id) return rows[1] ~= nil end +local function is_referenced_by_skypic(media_id) + local rows = Bridge.Database.Query([[ + SELECT 1 AS `in_use` + FROM `sky_phone_skypic_messages` + WHERE `media_id` = ? + UNION ALL + SELECT 1 AS `in_use` + FROM `sky_phone_skypic_stories` + WHERE `media_id` = ? + LIMIT 1 + ]], { media_id, media_id }) + return rows[1] ~= nil +end + local function delete_owned_media(src, owner, media_id) local condition, params = owner_condition(owner) local query_params = { media_id } @@ -838,25 +862,69 @@ local function delete_owned_media(src, owner, media_id) if row.media_type == "photo" and is_required_flare_profile_photo(media_id) then return false, "profile_photo_required" end + -- Both SkyPic foreign keys are RESTRICT. Keep the remote object intact until + -- cleanup has physically removed every referencing row. + if is_referenced_by_skypic(media_id) then + return false, "media_in_use" + end local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id) if pending_deletes[delete_key] then return false, "operation_in_progress" end pending_deletes[delete_key] = src + local delete_remote = false if row.origin == "phone_upload" then local references = Bridge.Database.Query( "SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?", { row.remote_id } ) - if (tonumber(references[1] and references[1].count) or 0) <= 1 then - local deleted, delete_error = delete_remote_file(row.remote_id) - if not deleted then - pending_deletes[delete_key] = nil - return false, delete_error - end + delete_remote = (tonumber(references[1] and references[1].count) or 0) <= 1 + end + -- Delete the database parent first and repeat the SkyPic guard inside the + -- same statement. The RESTRICT foreign keys then make this atomic against + -- a concurrent snap/story insert: either that insert wins and this DELETE + -- affects zero rows, or this DELETE wins and the later insert cannot refer + -- to a missing media row. + local delete_params = {} + for _, value in ipairs(query_params) do + delete_params[#delete_params + 1] = value + end + delete_params[#delete_params + 1] = media_id + delete_params[#delete_params + 1] = media_id + local result = Bridge.Database.Query(([[ + DELETE FROM `sky_phone_media` + WHERE `id` = ? AND %s + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_messages` + WHERE `media_id` = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_stories` + WHERE `media_id` = ? + ) + ]]):format(condition), delete_params) + if affected_rows(result) ~= 1 then + pending_deletes[delete_key] = nil + if is_referenced_by_skypic(media_id) then + return false, "media_in_use" + end + return false, "not_found" + end + if delete_remote then + local deleted, delete_error = delete_remote_file(row.remote_id) + if not deleted then + -- The user-visible record is already gone and no new FK reference + -- can be created. Report the logical delete as complete and leave a + -- precise audit trail for provider-side orphan cleanup. + Bridge.Debug( + "error", + "[sky_phone] Media %s was deleted locally but remote object %s could not be removed (%s).", + tostring(media_id), + tostring(row.remote_id), + tostring(delete_error) + ) end end - Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params) pending_deletes[delete_key] = nil return true end diff --git a/sky_phone/source/server/skypic.lua b/sky_phone/source/server/skypic.lua new file mode 100644 index 0000000..4ebe5d5 --- /dev/null +++ b/sky_phone/source/server/skypic.lua @@ -0,0 +1,2062 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local settings = Config.SkyPic or {} +local json_null = type(json) == "table" and json.null or nil + +local function nullable(value) + if value == nil then + return json_null + end + return value +end + +local function limit(name, fallback) + local value = tonumber(settings[name]) + if not value or value < 1 then + return fallback + end + return math.floor(value) +end + +local function trim(value) + if type(value) ~= "string" then + return "" + end + return value:match("^%s*(.-)%s*$") or "" +end + +local function text_length(value) + local ok, length = pcall(utf8.len, value) + if not ok then + return nil + end + return length +end + +local function valid_text(value, minimum, maximum) + if type(value) ~= "string" then + return false + end + local length = text_length(value) + return length ~= nil and length >= minimum and length <= maximum +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + if type(result) == "table" then + return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 + end + return 0 +end + +-- Keep the denormalized counters authoritative for strict, concurrent friend +-- limit enforcement and repair any drift left by older resource versions. +Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_profiles` profile + LEFT JOIN ( + SELECT sides.`profile_id`, COUNT(*) AS `friend_count` + FROM ( + SELECT friendship.`profile_a_id` AS `profile_id` + FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`status` = 'accepted' + UNION ALL + SELECT friendship.`profile_b_id` AS `profile_id` + FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`status` = 'accepted' + ) sides + GROUP BY sides.`profile_id` + ) counts ON counts.`profile_id` = profile.`id` + SET profile.`friend_count` = COALESCE(counts.`friend_count`, 0) +]], {}) + +local function new_id() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate a SkyPic id.") + end + return rows[1].id +end + +local function valid_id(value) + return type(value) == "string" + and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil +end + +local function valid_integer(value, minimum, maximum) + if type(value) ~= "number" or value ~= math.floor(value) or value < minimum or value > maximum then + return nil + end + return value +end + +local function normalize_handle(value) + local handle = trim(value):lower() + if #handle < limit("HandleMinLength", 3) or #handle > limit("HandleMaxLength", 24) + or handle:match("^[a-z0-9._]+$") == nil + then + return nil + end + return handle +end + +local function normalize_color(value) + if type(value) ~= "string" or value:match("^#%x%x%x%x%x%x$") == nil then + return nil + end + return value:upper() +end + +local function normalized_pair(first_id, second_id) + if first_id < second_id then + return first_id, second_id + end + return second_id, first_id +end + +local function is_true(value) + return value == true or tonumber(value) == 1 +end + +local function summary_from_row(row, status_override, friendship_id_override) + if not row then + return nil + end + local friendship_id = friendship_id_override + if friendship_id == nil then + friendship_id = row.friendship_id + end + local summary = { + id = row.profile_id or row.id, + handle = row.handle, + displayName = row.display_name, + avatarUrl = nullable(row.avatar_url), + avatarSeed = tonumber(row.avatar_seed) or 1, + snapScore = tonumber(row.snap_score) or 0, + } + summary.friendshipId = nullable(friendship_id) + summary.friendshipStatus = status_override or row.friendship_status or "none" + return summary +end + +local function profile_from_row(row) + local profile = summary_from_row(row, nil, nil) + profile.avatarMediaId = nullable(tonumber(row.avatar_media_id)) + profile.bio = row.bio or "" + profile.storyPrivacy = row.story_privacy == "everyone" and "everyone" or "friends" + profile.showInQuickAdd = is_true(row.quick_add) + profile.allowStoryReplies = is_true(row.allow_story_replies) + profile.friendCount = tonumber(row.friend_count) or 0 + return profile +end + +local function require_profile(source) + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return nil, nil, error_response + end + local rows = Bridge.Database.Query([[ + SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`bio`, + profile.`avatar_media_id`, profile.`avatar_seed`, profile.`story_privacy`, profile.`quick_add`, + profile.`allow_story_replies`, profile.`snap_score`, avatar.`url` AS `avatar_url`, + (SELECT COUNT(*) FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`status` = 'accepted' + AND (friendship.`profile_a_id` = profile.`id` OR friendship.`profile_b_id` = profile.`id`)) AS `friend_count` + FROM `sky_phone_skypic_profiles` profile + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id` + WHERE profile.`account_id` = ? AND profile.`status` = 'active' + LIMIT 1 + ]], { account.id }) + if not rows[1] then + return nil, account, { success = false, error = "profile_required" } + end + return rows[1], account +end + +local function optional_profile(source) + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return nil, nil, error_response + end + local rows = Bridge.Database.Query([[ + SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`bio`, + profile.`avatar_media_id`, profile.`avatar_seed`, profile.`story_privacy`, profile.`quick_add`, + profile.`allow_story_replies`, profile.`snap_score`, avatar.`url` AS `avatar_url`, + (SELECT COUNT(*) FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`status` = 'accepted' + AND (friendship.`profile_a_id` = profile.`id` OR friendship.`profile_b_id` = profile.`id`)) AS `friend_count` + FROM `sky_phone_skypic_profiles` profile + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id` + WHERE profile.`account_id` = ? AND profile.`status` = 'active' + LIMIT 1 + ]], { account.id }) + return rows[1], account +end + +local function friendship_status(actor_id, requested_by_id, status) + if status == "accepted" then + return "friends" + end + if status == "pending" then + return requested_by_id == actor_id and "outgoing" or "incoming" + end + return "none" +end + +local function active_friendship(profile_id, friendship_id) + local rows = Bridge.Database.Query([[ + SELECT friendship.*, + CASE WHEN friendship.`profile_a_id` = ? THEN friendship.`profile_b_id` ELSE friendship.`profile_a_id` END AS `peer_id` + FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`id` = ? AND friendship.`status` = 'accepted' + AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = friendship.`profile_a_id` AND block.`blocked_profile_id` = friendship.`profile_b_id`) + OR (block.`blocker_profile_id` = friendship.`profile_b_id` AND block.`blocked_profile_id` = friendship.`profile_a_id`) + ) + LIMIT 1 + ]], { profile_id, friendship_id, profile_id, profile_id }) + return rows[1] +end + +local function friendship_with_target(profile_id, target_id, accepted_only) + local profile_a_id, profile_b_id = normalized_pair(profile_id, target_id) + local query = [[ + SELECT * FROM `sky_phone_skypic_friendships` + WHERE `profile_a_id` = ? AND `profile_b_id` = ? + ]] + if accepted_only then + query = query .. " AND `status` = 'accepted'" + end + query = query .. " LIMIT 1" + local rows = Bridge.Database.Query(query, { profile_a_id, profile_b_id }) + return rows[1] +end + +local function are_blocked(first_id, second_id) + local rows = Bridge.Database.Query([[ + SELECT 1 AS `blocked` FROM `sky_phone_skypic_blocks` + WHERE (`blocker_profile_id` = ? AND `blocked_profile_id` = ?) + OR (`blocker_profile_id` = ? AND `blocked_profile_id` = ?) + LIMIT 1 + ]], { first_id, second_id, second_id, first_id }) + return rows[1] ~= nil +end + +local function load_summary(profile_id, viewer_id) + local rows = Bridge.Database.Query([[ + SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`avatar_seed`, + profile.`snap_score`, avatar.`url` AS `avatar_url`, friendship.`id` AS `friendship_id`, + friendship.`status`, friendship.`requested_by_id` + FROM `sky_phone_skypic_profiles` profile + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id` + LEFT JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = LEAST(?, profile.`id`) + AND friendship.`profile_b_id` = GREATEST(?, profile.`id`) + WHERE profile.`id` = ? AND profile.`status` = 'active' + LIMIT 1 + ]], { viewer_id, viewer_id, profile_id }) + if not rows[1] then + return nil + end + return summary_from_row( + rows[1], + friendship_status(viewer_id, rows[1].requested_by_id, rows[1].status), + rows[1].friendship_id + ) +end + +local function notify_profile(recipient_profile_id, actor, kind, snap_id) + if recipient_profile_id == actor.profile_id then + return + end + local rows = Bridge.Database.Query([[ + SELECT `account_id` FROM `sky_phone_skypic_profiles` + WHERE `id` = ? AND `status` = 'active' LIMIT 1 + ]], { recipient_profile_id }) + local account_id = tonumber(rows[1] and rows[1].account_id) + if not account_id then + return + end + SkyPhone.NotifyAccountDevices(account_id, "sky_phone:skypic:new", { + kind = kind, + actor = actor.display_name, + profileId = actor.profile_id, + snapId = snap_id, + }) +end + +local function safe_snap_from_row(row, viewer_id) + return { + id = row.id, + friendshipId = row.friendship_id, + type = row.message_type, + direction = row.sender_profile_id == viewer_id and "sent" or "received", + durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1), + allowReplay = is_true(row.allow_replay), + openedAt = nullable(row.opened_at), + replayedAt = nullable(row.replayed_at), + expiresAt = row.expires_at, + createdAt = row.created_at, + sender = summary_from_row({ + profile_id = row.sender_profile_id, + handle = row.sender_handle, + display_name = row.sender_display_name, + avatar_seed = row.sender_avatar_seed, + avatar_url = row.sender_avatar_url, + snap_score = row.sender_snap_score, + friendship_status = "friends", + friendship_id = row.friendship_id, + }, row.sender_profile_id == viewer_id and "none" or "friends", + row.sender_profile_id == viewer_id and nil or row.friendship_id), + } +end + +local function text_message_from_row(row, viewer_id) + return { + id = row.id, + friendshipId = row.friendship_id, + type = "text", + direction = row.sender_profile_id == viewer_id and "sent" or "received", + body = row.body or "", + readAt = nullable(row.read_at), + savedAt = nullable(row.saved_at), + createdAt = row.created_at, + } +end + +local function empty_bootstrap() + return { + profile = json_null, + blockedProfiles = {}, + friends = {}, + requests = {}, + conversations = {}, + inbox = {}, + stories = {}, + suggestions = {}, + unreadCount = 0, + } +end + +local function list_friends(profile_id) + local rows = Bridge.Database.Query([[ + SELECT friendship.`id` AS `friendship_id`, friendship.`streak_count`, friendship.`best_streak`, + friendship.`accepted_at`, friendship.`created_at`, peer.`id` AS `profile_id`, peer.`handle`, + peer.`display_name`, peer.`avatar_seed`, peer.`snap_score`, avatar.`url` AS `avatar_url` + 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' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = peer.`avatar_media_id` + WHERE friendship.`status` = 'accepted' + AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = peer.`id`) + OR (block.`blocker_profile_id` = peer.`id` AND block.`blocked_profile_id` = ?) + ) + ORDER BY peer.`display_name`, peer.`handle` + LIMIT ? + ]], { profile_id, profile_id, profile_id, profile_id, profile_id, limit("MaximumFriends", 500) }) + local friends = {} + for _, row in ipairs(rows) do + friends[#friends + 1] = { + friendshipId = row.friendship_id, + profile = summary_from_row(row, "friends", row.friendship_id), + streakCount = tonumber(row.streak_count) or 0, + bestStreak = tonumber(row.best_streak) or 0, + createdAt = row.accepted_at or row.created_at, + } + end + return friends +end + +local function list_requests(profile_id) + local rows = Bridge.Database.Query([[ + SELECT friendship.`id` AS `friendship_id`, friendship.`requested_by_id`, friendship.`created_at`, + peer.`id` AS `profile_id`, peer.`handle`, peer.`display_name`, peer.`avatar_seed`, + peer.`snap_score`, avatar.`url` AS `avatar_url` + 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' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = peer.`avatar_media_id` + WHERE friendship.`status` = 'pending' + AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = peer.`id`) + OR (block.`blocker_profile_id` = peer.`id` AND block.`blocked_profile_id` = ?) + ) + ORDER BY friendship.`created_at` DESC + LIMIT ? + ]], { profile_id, profile_id, profile_id, profile_id, profile_id, limit("MaximumPendingRequests", 100) * 2 }) + local requests = {} + for _, row in ipairs(rows) do + local direction = row.requested_by_id == profile_id and "outgoing" or "incoming" + requests[#requests + 1] = { + friendshipId = row.friendship_id, + direction = direction, + profile = summary_from_row(row, direction, row.friendship_id), + createdAt = row.created_at, + } + end + return requests +end + +local function list_blocked_profiles(profile_id) + local rows = Bridge.Database.Query([[ + SELECT blocked.`id` AS `profile_id`, blocked.`handle`, + blocked.`display_name`, blocked.`avatar_seed`, + blocked.`snap_score`, avatar.`url` AS `avatar_url` + FROM `sky_phone_skypic_blocks` block + JOIN `sky_phone_skypic_profiles` blocked + ON blocked.`id` = block.`blocked_profile_id` + AND blocked.`status` = 'active' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = blocked.`avatar_media_id` + WHERE block.`blocker_profile_id` = ? + ORDER BY block.`created_at` DESC + ]], { profile_id }) + local profiles = {} + for _, row in ipairs(rows) do + profiles[#profiles + 1] = summary_from_row(row, "none", nil) + end + return profiles +end + +local function list_profiles(profile_id, search, suggestions_only) + local params = { profile_id, profile_id, profile_id, profile_id, profile_id } + local filters = {} + if suggestions_only then + filters[#filters + 1] = "profile.`quick_add` = 1" + filters[#filters + 1] = "friendship.`id` IS NULL" + else + filters[#filters + 1] = "(profile.`handle` LIKE ? OR profile.`display_name` LIKE ?)" + local term = "%" .. search .. "%" + params[#params + 1] = term + params[#params + 1] = term + end + params[#params + 1] = suggestions_only and limit("SuggestionLimit", 20) or limit("SearchLimit", 30) + local rows = Bridge.Database.Query(([[ + SELECT profile.`id` AS `profile_id`, profile.`handle`, profile.`display_name`, profile.`avatar_seed`, + profile.`snap_score`, avatar.`url` AS `avatar_url`, friendship.`id` AS `friendship_id`, + friendship.`status`, friendship.`requested_by_id` + FROM `sky_phone_skypic_profiles` profile + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id` + LEFT JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = LEAST(?, profile.`id`) + AND friendship.`profile_b_id` = GREATEST(?, profile.`id`) + WHERE profile.`status` = 'active' AND profile.`id` <> ? + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = profile.`id`) + OR (block.`blocker_profile_id` = profile.`id` AND block.`blocked_profile_id` = ?) + ) + AND %s + ORDER BY %s + LIMIT ? + ]]):format( + table.concat(filters, " AND "), + suggestions_only and "profile.`updated_at` DESC" or "(profile.`handle` = ?) DESC, profile.`handle`" + ), suggestions_only and params or { + params[1], params[2], params[3], params[4], params[5], params[6], params[7], search, params[8] + }) + local profiles = {} + for _, row in ipairs(rows) do + profiles[#profiles + 1] = summary_from_row( + row, + friendship_status(profile_id, row.requested_by_id, row.status), + row.friendship_id + ) + end + return profiles +end + +local function list_inbox(profile_id) + local rows = Bridge.Database.Query([[ + SELECT message.`id`, message.`friendship_id`, message.`sender_profile_id`, message.`message_type`, + message.`view_seconds`, message.`allow_replay`, message.`opened_at`, message.`replayed_at`, + message.`expires_at`, message.`created_at`, sender.`handle` AS `sender_handle`, + sender.`display_name` AS `sender_display_name`, sender.`avatar_seed` AS `sender_avatar_seed`, + sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url` + FROM `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted' + JOIN `sky_phone_skypic_profiles` sender + ON sender.`id` = message.`sender_profile_id` AND sender.`status` = 'active' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = sender.`avatar_media_id` + WHERE message.`recipient_profile_id` = ? + AND message.`message_type` IN ('snap_photo', 'snap_video') + AND message.`deleted_at` IS NULL AND message.`recipient_deleted_at` IS NULL + AND message.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = message.`sender_profile_id`) + OR (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = ?) + ) + ORDER BY message.`created_at` DESC + LIMIT ? + ]], { profile_id, profile_id, profile_id, limit("InboxPageSize", 100) }) + local inbox = {} + for _, row in ipairs(rows) do + inbox[#inbox + 1] = safe_snap_from_row(row, profile_id) + end + return inbox +end + +local function list_stories(profile_id, offset) + local rows = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id`, story.`view_seconds`, story.`expires_at`, story.`created_at`, + author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`, + avatar.`url` AS `avatar_url`, + EXISTS(SELECT 1 FROM `sky_phone_skypic_story_views` view + WHERE view.`story_id` = story.`id` AND view.`viewer_profile_id` = ?) AS `seen`, + (SELECT COUNT(*) FROM `sky_phone_skypic_story_views` view + WHERE view.`story_id` = story.`id`) AS `view_count`, + friendship.`id` AS `friendship_id` + FROM `sky_phone_skypic_stories` story + JOIN `sky_phone_skypic_profiles` author + ON author.`id` = story.`profile_id` AND author.`status` = 'active' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id` + LEFT JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = LEAST(?, story.`profile_id`) + AND friendship.`profile_b_id` = GREATEST(?, story.`profile_id`) + AND friendship.`status` = 'accepted' + WHERE story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = story.`profile_id`) + OR (block.`blocker_profile_id` = story.`profile_id` AND block.`blocked_profile_id` = ?) + ) + AND (story.`profile_id` = ? OR story.`privacy` = 'everyone' OR friendship.`id` IS NOT NULL) + ORDER BY (story.`profile_id` = ?) DESC, story.`created_at` DESC, story.`id` DESC + LIMIT ? OFFSET ? + ]], { + profile_id, profile_id, profile_id, profile_id, profile_id, profile_id, profile_id, + limit("PageSize", 30), offset or 0, + }) + local stories = {} + for _, row in ipairs(rows) do + stories[#stories + 1] = { + id = row.id, + author = summary_from_row( + row, + row.profile_id == profile_id and "none" or (row.friendship_id and "friends" or "none"), + row.friendship_id + ), + durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1), + expiresAt = row.expires_at, + createdAt = row.created_at, + isOwner = row.profile_id == profile_id, + seen = is_true(row.seen), + viewCount = tonumber(row.view_count) or 0, + } + end + return stories +end + +local function list_conversations(profile_id) + local rows = Bridge.Database.Query([[ + SELECT friendship.`id` AS `friendship_id`, friendship.`streak_count`, friendship.`best_streak`, + peer.`id` AS `profile_id`, peer.`handle`, peer.`display_name`, peer.`avatar_seed`, + peer.`snap_score`, avatar.`url` AS `avatar_url`, message.`id` AS `last_id`, + message.`message_type` AS `last_type`, + CASE WHEN message.`message_type` = 'text' THEN message.`body` ELSE NULL END AS `last_body`, + message.`sender_profile_id` AS `last_sender_id`, message.`opened_at` AS `last_opened_at`, + message.`created_at` AS `last_created_at`, + (SELECT COUNT(*) FROM `sky_phone_skypic_messages` unread + WHERE unread.`friendship_id` = friendship.`id` + AND unread.`recipient_profile_id` = ? AND unread.`deleted_at` IS NULL + AND unread.`recipient_deleted_at` IS NULL + AND (unread.`expires_at` IS NULL OR unread.`expires_at` > CURRENT_TIMESTAMP(6)) + AND ((unread.`message_type` = 'text' AND unread.`read_at` IS NULL) + OR (unread.`message_type` IN ('snap_photo','snap_video') AND unread.`opened_at` IS NULL))) AS `unread_count` + 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' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = peer.`avatar_media_id` + LEFT JOIN `sky_phone_skypic_messages` message ON message.`id` = ( + SELECT candidate.`id` FROM `sky_phone_skypic_messages` candidate + WHERE candidate.`friendship_id` = friendship.`id` AND candidate.`deleted_at` IS NULL + AND ((candidate.`sender_profile_id` = ? AND candidate.`sender_deleted_at` IS NULL) + OR (candidate.`recipient_profile_id` = ? AND candidate.`recipient_deleted_at` IS NULL)) + AND (candidate.`expires_at` IS NULL OR candidate.`expires_at` > CURRENT_TIMESTAMP(6)) + ORDER BY candidate.`created_at` DESC, candidate.`id` DESC LIMIT 1 + ) + WHERE friendship.`status` = 'accepted' + AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = peer.`id`) + OR (block.`blocker_profile_id` = peer.`id` AND block.`blocked_profile_id` = ?) + ) + ORDER BY message.`created_at` DESC, peer.`display_name` + LIMIT ? + ]], { + profile_id, profile_id, profile_id, profile_id, profile_id, profile_id, + profile_id, profile_id, limit("MaximumFriends", 500), + }) + local conversations = {} + for _, row in ipairs(rows) do + local last_item = nil + if row.last_id then + last_item = { + id = row.last_id, + type = row.last_type, + direction = row.last_sender_id == profile_id and "sent" or "received", + openedAt = nullable(row.last_opened_at), + createdAt = row.last_created_at, + } + if row.last_type == "text" then + last_item.body = row.last_body or "" + end + end + conversations[#conversations + 1] = { + friendshipId = row.friendship_id, + profile = summary_from_row(row, "friends", row.friendship_id), + streakCount = tonumber(row.streak_count) or 0, + bestStreak = tonumber(row.best_streak) or 0, + unreadCount = tonumber(row.unread_count) or 0, + lastItem = nullable(last_item), + } + end + return conversations +end + +Bridge.Callbacks.Register("sky_phone:skypic:bootstrap", function(source) + if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = optional_profile(source) + if error_response then + return error_response + end + if not profile then + return { success = true, data = empty_bootstrap() } + end + local profile_id = profile.profile_id + local conversations = list_conversations(profile_id) + local unread_count = 0 + for _, conversation in ipairs(conversations) do + unread_count = unread_count + conversation.unreadCount + end + return { + success = true, + data = { + profile = profile_from_row(profile), + blockedProfiles = list_blocked_profiles(profile_id), + friends = list_friends(profile_id), + requests = list_requests(profile_id), + conversations = conversations, + inbox = list_inbox(profile_id), + stories = list_stories(profile_id, 0), + suggestions = list_profiles(profile_id, "", true), + unreadCount = unread_count, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:create-profile", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_profile", limit("ProfileActionsPerMinute", 10), 60) then + return { success = false, error = "rate_limited" } + end + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + if not handle then + return { success = false, error = "invalid_handle" } + end + if not valid_text(display_name, 1, limit("DisplayNameMaxLength", 40)) then + return { success = false, error = "invalid_display_name" } + end + local avatar_seed = data.avatarSeed == nil and math.random(1, 2147483647) + or valid_integer(data.avatarSeed, 1, 2147483647) + if not avatar_seed then + return { success = false, error = "invalid_avatar_seed" } + end + local avatar_media_id = nil + if data.avatarMediaId ~= nil then + avatar_media_id = valid_integer(data.avatarMediaId, 1, 9007199254740991) + if not avatar_media_id or not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then + return { success = false, error = "invalid_avatar" } + end + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_skypic_profiles` WHERE `account_id` = ? LIMIT 1", + { account.id } + )[1] then + return { success = false, error = "profile_exists" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_skypic_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + )[1] then + return { success = false, error = "handle_taken" } + end + local profile_id = new_id() + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_skypic_profiles` + (`id`, `account_id`, `handle`, `display_name`, `avatar_media_id`, `avatar_seed`) + VALUES (?, ?, ?, ?, ?, ?) + ]], { profile_id, account.id, handle, display_name, avatar_media_id, avatar_seed }) + if affected_rows(result) ~= 1 then + return { success = false, error = "handle_taken" } + end + local created = require_profile(source) + return { success = true, data = profile_from_row(created) } +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" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + local bio = trim(data.bio) + if not handle then + return { success = false, error = "invalid_handle" } + end + if not valid_text(display_name, 1, limit("DisplayNameMaxLength", 40)) then + return { success = false, error = "invalid_display_name" } + end + if not valid_text(bio, 0, limit("BioMaxLength", 160)) then + return { success = false, error = "invalid_bio" } + end + if data.storyPrivacy ~= "friends" and data.storyPrivacy ~= "everyone" then + return { success = false, error = "invalid_privacy" } + end + if type(data.showInQuickAdd) ~= "boolean" or type(data.allowStoryReplies) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + local avatar_seed = data.avatarSeed == nil and tonumber(profile.avatar_seed) + or valid_integer(data.avatarSeed, 1, 2147483647) + if not avatar_seed then + return { success = false, error = "invalid_avatar_seed" } + end + local avatar_media_id = tonumber(profile.avatar_media_id) + local avatar_value = rawget(data, "avatarMediaId") + if avatar_value ~= nil then + local is_null = type(json) == "table" and json.null ~= nil and avatar_value == json.null + if is_null or avatar_value == false or avatar_value == 0 then + avatar_media_id = nil + else + avatar_media_id = valid_integer(avatar_value, 1, 9007199254740991) + if not avatar_media_id or not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then + return { success = false, error = "invalid_avatar" } + end + end + end + if Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_skypic_profiles` + WHERE `handle` = ? AND `id` <> ? LIMIT 1 + ]], { handle, profile.profile_id })[1] then + return { success = false, error = "handle_taken" } + end + local result = Bridge.Database.Query([[ + UPDATE IGNORE `sky_phone_skypic_profiles` + SET `handle` = ?, `display_name` = ?, `bio` = ?, `avatar_media_id` = ?, `avatar_seed` = ?, + `story_privacy` = ?, `quick_add` = ?, `allow_story_replies` = ? + WHERE `id` = ? AND `status` = 'active' + ]], { + handle, display_name, bio, avatar_media_id, avatar_seed, data.storyPrivacy, + data.showInQuickAdd and 1 or 0, data.allowStoryReplies and 1 or 0, profile.profile_id, + }) + if affected_rows(result) == 0 and handle ~= profile.handle then + return { success = false, error = "handle_taken" } + end + local updated = require_profile(source) + return { success = true, data = profile_from_row(updated) } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:search", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_search", limit("SearchActionsPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local query = trim(type(data) == "table" and data.query or nil) + if not valid_text(query, 1, 64) then + return { success = false, error = "invalid_request" } + end + return { success = true, data = list_profiles(profile.profile_id, query, false) } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:add-friend", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local target_id = type(data) == "table" and data.profileId or nil + if not valid_id(target_id) or target_id == profile.profile_id then + return { success = false, error = "invalid_request" } + end + local target = load_summary(target_id, profile.profile_id) + if not target then + return { success = false, error = "profile_not_found" } + end + if are_blocked(profile.profile_id, target_id) then + return { success = false, error = "blocked" } + end + local existing = friendship_with_target(profile.profile_id, target_id, false) + if existing then + return { success = false, error = "friend_request_exists" } + end + local own_friends = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships` + WHERE `status` = 'accepted' AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ]], { profile.profile_id, profile.profile_id }) + local target_friends = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships` + WHERE `status` = 'accepted' AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ]], { target_id, target_id }) + if (tonumber(own_friends[1] and own_friends[1].count) or 0) >= limit("MaximumFriends", 500) + or (tonumber(target_friends[1] and target_friends[1].count) or 0) >= limit("MaximumFriends", 500) + then + return { success = false, error = "friend_limit_reached" } + end + local maximum_pending_requests = limit("MaximumPendingRequests", 100) + local own_pending = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships` + WHERE `status` = 'pending' AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ]], { profile.profile_id, profile.profile_id }) + local target_pending = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_friendships` + WHERE `status` = 'pending' AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ]], { target_id, target_id }) + if (tonumber(own_pending[1] and own_pending[1].count) or 0) >= maximum_pending_requests + or (tonumber(target_pending[1] and target_pending[1].count) or 0) >= maximum_pending_requests + then + return { success = false, error = "request_limit_reached" } + end + local friendship_id = new_id() + local profile_a_id, profile_b_id = normalized_pair(profile.profile_id, target_id) + local ok = Bridge.Database.Transaction({ + { + -- Serialize all cap-changing operations that involve either + -- profile. InnoDB keeps these row locks until the transaction ends. + query = [[ + UPDATE `sky_phone_skypic_profiles` + SET `friend_count` = `friend_count` + WHERE `id` IN (?, ?) + ORDER BY `id` + ]], + params = { profile_a_id, profile_b_id }, + }, + { + query = [[ + INSERT IGNORE INTO `sky_phone_skypic_friendships` + (`id`, `profile_a_id`, `profile_b_id`, `requested_by_id`, `status`) + SELECT ?, ?, ?, ?, 'pending' + WHERE NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` + WHERE (`blocker_profile_id` = ? AND `blocked_profile_id` = ?) + OR (`blocker_profile_id` = ? AND `blocked_profile_id` = ?) + ) + AND ( + SELECT COUNT(*) FROM `sky_phone_skypic_friendships` + WHERE `status` = 'pending' + AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ) < ? + AND ( + SELECT COUNT(*) FROM `sky_phone_skypic_friendships` + WHERE `status` = 'pending' + AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ) < ? + ]], + params = { + friendship_id, profile_a_id, profile_b_id, profile.profile_id, + profile.profile_id, target_id, target_id, profile.profile_id, + profile.profile_id, profile.profile_id, maximum_pending_requests, + target_id, target_id, maximum_pending_requests, + }, + }, + }) + if not ok then + return { success = false, error = "request_failed" } + end + local created_rows = Bridge.Database.Query([[ + SELECT `created_at` FROM `sky_phone_skypic_friendships` WHERE `id` = ? LIMIT 1 + ]], { friendship_id }) + if not created_rows[1] then + local pending_counts = Bridge.Database.Query([[ + SELECT + (SELECT COUNT(*) FROM `sky_phone_skypic_friendships` + WHERE `status` = 'pending' + AND (`profile_a_id` = ? OR `profile_b_id` = ?)) AS `own_count`, + (SELECT COUNT(*) FROM `sky_phone_skypic_friendships` + WHERE `status` = 'pending' + AND (`profile_a_id` = ? OR `profile_b_id` = ?)) AS `target_count` + ]], { profile.profile_id, profile.profile_id, target_id, target_id }) + if (tonumber(pending_counts[1] and pending_counts[1].own_count) or 0) >= maximum_pending_requests + or (tonumber(pending_counts[1] and pending_counts[1].target_count) or 0) >= maximum_pending_requests + then + return { success = false, error = "request_limit_reached" } + end + if are_blocked(profile.profile_id, target_id) then + return { success = false, error = "blocked" } + end + if friendship_with_target(profile.profile_id, target_id, false) then + return { success = false, error = "friend_request_exists" } + end + return { success = false, error = "request_failed" } + end + notify_profile(target_id, profile, "friend_request", nil) + target.friendshipId = friendship_id + target.friendshipStatus = "outgoing" + return { + success = true, + data = { + friendshipId = friendship_id, + createdAt = created_rows[1].created_at, + direction = "outgoing", + profile = target, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:respond-friend", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local friendship_id = type(data) == "table" and data.friendshipId or nil + if not valid_id(friendship_id) or type(data.accept) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if not data.accept then + local result = Bridge.Database.Query([[ + DELETE FROM `sky_phone_skypic_friendships` + WHERE `id` = ? AND `status` = 'pending' AND `requested_by_id` <> ? + AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ]], { friendship_id, profile.profile_id, profile.profile_id, profile.profile_id }) + return affected_rows(result) == 1 and { success = true } + or { success = false, error = "friendship_not_found" } + end + local maximum_friends = limit("MaximumFriends", 500) + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_friendships` friendship + JOIN `sky_phone_skypic_profiles` profile_a + ON profile_a.`id` = friendship.`profile_a_id` + JOIN `sky_phone_skypic_profiles` profile_b + ON profile_b.`id` = friendship.`profile_b_id` + SET friendship.`status` = 'accepted', + friendship.`accepted_at` = CURRENT_TIMESTAMP, + profile_a.`friend_count` = profile_a.`friend_count` + 1, + profile_b.`friend_count` = profile_b.`friend_count` + 1 + WHERE friendship.`id` = ? AND friendship.`status` = 'pending' + AND friendship.`requested_by_id` <> ? + AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?) + AND profile_a.`friend_count` < ? + AND profile_b.`friend_count` < ? + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = friendship.`profile_a_id` AND block.`blocked_profile_id` = friendship.`profile_b_id`) + OR (block.`blocker_profile_id` = friendship.`profile_b_id` AND block.`blocked_profile_id` = friendship.`profile_a_id`) + ) + ]], { + friendship_id, profile.profile_id, profile.profile_id, profile.profile_id, + maximum_friends, maximum_friends, + }) + if affected_rows(result) == 0 then + local pending = Bridge.Database.Query([[ + SELECT profile_a.`friend_count` AS `profile_a_friend_count`, + profile_b.`friend_count` AS `profile_b_friend_count` + FROM `sky_phone_skypic_friendships` friendship + JOIN `sky_phone_skypic_profiles` profile_a + ON profile_a.`id` = friendship.`profile_a_id` + JOIN `sky_phone_skypic_profiles` profile_b + ON profile_b.`id` = friendship.`profile_b_id` + WHERE friendship.`id` = ? AND friendship.`status` = 'pending' + AND friendship.`requested_by_id` <> ? + AND (friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ?) + LIMIT 1 + ]], { friendship_id, profile.profile_id, profile.profile_id, profile.profile_id }) + if pending[1] + and ((tonumber(pending[1].profile_a_friend_count) or 0) >= maximum_friends + or (tonumber(pending[1].profile_b_friend_count) or 0) >= maximum_friends) + then + return { success = false, error = "friend_limit_reached" } + end + return { success = false, error = "friendship_not_found" } + end + local friendship = active_friendship(profile.profile_id, friendship_id) + if not friendship then + return { success = false, error = "request_failed" } + end + local peer = load_summary(friendship.peer_id, profile.profile_id) + notify_profile(friendship.peer_id, profile, "friend_accepted", nil) + return { + success = true, + data = { + friendshipId = friendship.id, + profile = peer, + streakCount = tonumber(friendship.streak_count) or 0, + bestStreak = tonumber(friendship.best_streak) or 0, + createdAt = friendship.accepted_at or friendship.created_at, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:remove-friend", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local friendship_id = type(data) == "table" and data.friendshipId or nil + if not valid_id(friendship_id) then + return { success = false, error = "invalid_request" } + end + if not Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_skypic_friendships` + WHERE `id` = ? AND (`profile_a_id` = ? OR `profile_b_id` = ?) + LIMIT 1 + ]], { friendship_id, profile.profile_id, profile.profile_id })[1] then + return { success = false, error = "friendship_not_found" } + end + local ok = Bridge.Database.Transaction({ + { + query = [[ + UPDATE `sky_phone_skypic_profiles` profile + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`id` = ? AND friendship.`status` = 'accepted' + AND (profile.`id` = friendship.`profile_a_id` + OR profile.`id` = friendship.`profile_b_id`) + SET profile.`friend_count` = IF( + profile.`friend_count` > 0, + profile.`friend_count` - 1, + 0 + ) + WHERE friendship.`profile_a_id` = ? OR friendship.`profile_b_id` = ? + ]], + params = { friendship_id, profile.profile_id, profile.profile_id }, + }, + { + query = [[ + DELETE FROM `sky_phone_skypic_friendships` + WHERE `id` = ? AND (`profile_a_id` = ? OR `profile_b_id` = ?) + ]], + params = { friendship_id, profile.profile_id, profile.profile_id }, + }, + }) + return ok and { success = true } or { success = false, error = "request_failed" } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:block", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_friend", limit("FriendActionsPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local target_id = type(data) == "table" and data.profileId or nil + if not valid_id(target_id) or target_id == profile.profile_id or type(data.blocked) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if not load_summary(target_id, profile.profile_id) then + return { success = false, error = "profile_not_found" } + end + if not data.blocked then + Bridge.Database.Query([[ + DELETE FROM `sky_phone_skypic_blocks` + WHERE `blocker_profile_id` = ? AND `blocked_profile_id` = ? + ]], { profile.profile_id, target_id }) + return { success = true } + end + local profile_a_id, profile_b_id = normalized_pair(profile.profile_id, target_id) + local ok = Bridge.Database.Transaction({ + { + query = [[ + INSERT IGNORE INTO `sky_phone_skypic_blocks` + (`blocker_profile_id`, `blocked_profile_id`) VALUES (?, ?) + ]], + params = { profile.profile_id, target_id }, + }, + { + query = [[ + UPDATE `sky_phone_skypic_profiles` profile + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ? + AND friendship.`status` = 'accepted' + AND (profile.`id` = friendship.`profile_a_id` + OR profile.`id` = friendship.`profile_b_id`) + SET profile.`friend_count` = IF( + profile.`friend_count` > 0, + profile.`friend_count` - 1, + 0 + ) + ]], + params = { profile_a_id, profile_b_id }, + }, + { + query = [[ + DELETE FROM `sky_phone_skypic_friendships` + WHERE `profile_a_id` = ? AND `profile_b_id` = ? + ]], + params = { profile_a_id, profile_b_id }, + }, + }) + return ok and { success = true } or { success = false, error = "request_failed" } +end) + +local function list_thread(profile_id, friendship_id) + local rows = Bridge.Database.Query([[ + SELECT message.`id`, message.`friendship_id`, message.`sender_profile_id`, message.`message_type`, + message.`body`, message.`view_seconds`, message.`allow_replay`, message.`read_at`, + message.`opened_at`, message.`replayed_at`, message.`saved_at`, message.`expires_at`, + message.`created_at`, sender.`handle` AS `sender_handle`, + sender.`display_name` AS `sender_display_name`, sender.`avatar_seed` AS `sender_avatar_seed`, + sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url` + FROM `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_profiles` sender ON sender.`id` = message.`sender_profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = sender.`avatar_media_id` + WHERE message.`friendship_id` = ? AND message.`deleted_at` IS NULL + AND ((message.`sender_profile_id` = ? AND message.`sender_deleted_at` IS NULL) + OR (message.`recipient_profile_id` = ? AND message.`recipient_deleted_at` IS NULL)) + AND (message.`expires_at` IS NULL OR message.`expires_at` > CURRENT_TIMESTAMP(6)) + ORDER BY message.`created_at` DESC, message.`id` DESC + LIMIT ? + ]], { friendship_id, profile_id, profile_id, limit("ThreadPageSize", 200) }) + local messages = {} + local snaps = {} + for index = #rows, 1, -1 do + local row = rows[index] + if row.message_type == "text" then + messages[#messages + 1] = text_message_from_row(row, profile_id) + else + snaps[#snaps + 1] = safe_snap_from_row(row, profile_id) + end + end + return { messages = messages, snaps = snaps } +end + +local function editor_payload(source, data, allow_replay) + if type(data) ~= "table" then + return nil, "invalid_request" + end + if data.mediaType ~= "photo" and data.mediaType ~= "video" then + return nil, "invalid_media_type" + end + local media_id = valid_integer(data.mediaId, 1, 9007199254740991) + if not media_id then + return nil, "invalid_media" + end + local duration = valid_integer( + data.durationSeconds, + limit("MinimumViewSeconds", 1), + limit("MaximumViewSeconds", 10) + ) + if not duration then + return nil, "invalid_duration" + end + local caption = trim(data.caption) + if not valid_text(caption, 0, limit("CaptionMaxLength", 160)) then + return nil, "invalid_caption" + end + local overlay_text = trim(data.textOverlay) + if not valid_text(overlay_text, 0, limit("OverlayTextMaxLength", 160)) then + return nil, "invalid_overlay" + end + local overlay_color = normalize_color(data.overlayColor) + if not overlay_color then + return nil, "invalid_color" + end + if allow_replay and type(data.allowReplay) ~= "boolean" then + return nil, "invalid_request" + end + local url, _, mime_type = SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType) + if not url then + return nil, "invalid_media" + end + return { + mediaId = media_id, + mediaType = data.mediaType, + messageType = data.mediaType == "photo" and "snap_photo" or "snap_video", + durationSeconds = duration, + caption = caption, + textOverlay = overlay_text, + overlayColor = overlay_color, + allowReplay = allow_replay and data.allowReplay or false, + mimeType = mime_type, + } +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" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local friendship_id = type(data) == "table" and data.friendshipId or nil + if not valid_id(friendship_id) then + return { success = false, error = "invalid_request" } + end + if not active_friendship(profile.profile_id, friendship_id) then + return { success = false, error = "friendship_not_found" } + end + return { success = true, data = list_thread(profile.profile_id, friendship_id) } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:send-message", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_message", limit("MessagesPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local friendship_id = type(data) == "table" and data.friendshipId or nil + local body = trim(type(data) == "table" and data.body or nil) + local story_id = type(data) == "table" and data.storyId or nil + if not valid_id(friendship_id) then + return { success = false, error = "invalid_request" } + end + if story_id ~= nil and not valid_id(story_id) then + return { success = false, error = "invalid_request" } + end + local body_length = text_length(body) + if not body_length or body_length < 1 then + return { success = false, error = "message_empty" } + end + if body_length > limit("MessageMaxLength", 2000) then + return { success = false, error = "message_too_long" } + end + local friendship = active_friendship(profile.profile_id, friendship_id) + if not friendship then + return { success = false, error = "friendship_not_found" } + end + local message_id = new_id() + local insert_query = [[ + INSERT INTO `sky_phone_skypic_messages` + (`id`, `friendship_id`, `sender_profile_id`, `recipient_profile_id`, `message_type`, `body`) + SELECT ?, friendship.`id`, ?, ?, 'text', ? + FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`id` = ? AND friendship.`status` = 'accepted' + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?) + OR (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?) + ) + ]] + local insert_params = { + message_id, profile.profile_id, friendship.peer_id, body, friendship_id, + profile.profile_id, friendship.peer_id, friendship.peer_id, profile.profile_id, + } + if story_id ~= nil then + insert_query = [[ + INSERT INTO `sky_phone_skypic_messages` + (`id`, `friendship_id`, `sender_profile_id`, `recipient_profile_id`, `message_type`, `body`) + SELECT ?, friendship.`id`, ?, ?, 'text', ? + FROM `sky_phone_skypic_friendships` friendship + JOIN `sky_phone_skypic_stories` story + ON story.`id` = ? AND story.`profile_id` = ? + AND story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP(6) + JOIN `sky_phone_skypic_profiles` author + ON author.`id` = story.`profile_id` AND author.`status` = 'active' + AND author.`allow_story_replies` = 1 + WHERE friendship.`id` = ? AND friendship.`status` = 'accepted' + AND ((friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?) + OR (friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?)) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?) + OR (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?) + ) + ]] + insert_params = { + message_id, profile.profile_id, friendship.peer_id, body, story_id, friendship.peer_id, + friendship_id, profile.profile_id, friendship.peer_id, friendship.peer_id, profile.profile_id, + profile.profile_id, friendship.peer_id, friendship.peer_id, profile.profile_id, + } + end + local result = Bridge.Database.Query(insert_query, insert_params) + if affected_rows(result) ~= 1 then + return { success = false, error = story_id and "story_unavailable" or "blocked" } + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `friendship_id`, `sender_profile_id`, `body`, `read_at`, `saved_at`, `created_at` + FROM `sky_phone_skypic_messages` WHERE `id` = ? LIMIT 1 + ]], { message_id }) + if not rows[1] then + return { success = false, error = "request_failed" } + end + notify_profile(friendship.peer_id, profile, story_id and "story_reply" or "message", nil) + return { success = true, data = text_message_from_row(rows[1], profile.profile_id) } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:mark-thread", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_open", limit("OpensPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local friendship_id = type(data) == "table" and data.friendshipId or nil + if not valid_id(friendship_id) then + return { success = false, error = "invalid_request" } + end + if not active_friendship(profile.profile_id, friendship_id) then + return { success = false, error = "friendship_not_found" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_messages` + SET `read_at` = CURRENT_TIMESTAMP(6), + `expires_at` = CASE WHEN `saved_at` IS NULL + THEN DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND) ELSE NULL END + WHERE `friendship_id` = ? AND `recipient_profile_id` = ? AND `message_type` = 'text' + AND `read_at` IS NULL AND `deleted_at` IS NULL AND `recipient_deleted_at` IS NULL + ]], { limit("TextAfterReadLifetimeSeconds", 86400), friendship_id, profile.profile_id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:save-message", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_message", limit("MessagesPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local message_id = type(data) == "table" and data.messageId or nil + if not valid_id(message_id) or type(data.saved) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted' + SET message.`saved_at` = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP(6) ELSE NULL END, + message.`expires_at` = CASE + WHEN ? = 1 THEN NULL + WHEN message.`read_at` IS NOT NULL THEN DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND) + ELSE NULL + END + WHERE message.`id` = ? AND message.`message_type` = 'text' AND message.`deleted_at` IS NULL + AND ((message.`sender_profile_id` = ? AND message.`sender_deleted_at` IS NULL) + OR (message.`recipient_profile_id` = ? AND message.`recipient_deleted_at` IS NULL)) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`) + OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`) + ) + ]], { + data.saved and 1 or 0, data.saved and 1 or 0, limit("TextAfterReadLifetimeSeconds", 86400), + message_id, profile.profile_id, profile.profile_id, + }) + if affected_rows(result) ~= 1 then + return { success = false, error = "message_not_found" } + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `friendship_id`, `sender_profile_id`, `body`, `read_at`, `saved_at`, `created_at` + FROM `sky_phone_skypic_messages` WHERE `id` = ? LIMIT 1 + ]], { message_id }) + return rows[1] and { success = true, data = text_message_from_row(rows[1], profile.profile_id) } + or { success = false, error = "message_not_found" } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:delete-message", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_message", limit("MessagesPerMinute", 30), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local message_id = type(data) == "table" and data.messageId or nil + if not valid_id(message_id) or type(data.forEveryone) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + local result + if data.forEveryone then + result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_messages` + SET `deleted_at` = CURRENT_TIMESTAMP(6) + WHERE `id` = ? AND `sender_profile_id` = ? AND `deleted_at` IS NULL + ]], { message_id, profile.profile_id }) + else + result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_messages` + SET `sender_deleted_at` = CASE WHEN `sender_profile_id` = ? THEN CURRENT_TIMESTAMP(6) ELSE `sender_deleted_at` END, + `recipient_deleted_at` = CASE WHEN `recipient_profile_id` = ? THEN CURRENT_TIMESTAMP(6) ELSE `recipient_deleted_at` END + WHERE `id` = ? AND `deleted_at` IS NULL + AND ((`sender_profile_id` = ? AND `sender_deleted_at` IS NULL) + OR (`recipient_profile_id` = ? AND `recipient_deleted_at` IS NULL)) + ]], { + profile.profile_id, profile.profile_id, message_id, profile.profile_id, profile.profile_id, + }) + end + return affected_rows(result) == 1 and { success = true } + or { success = false, error = "message_not_found" } +end) + +local function recipient_ids(value) + if type(value) ~= "table" then + return nil + end + local count = #value + if count < 1 or count > limit("MaximumSnapRecipients", 20) then + return nil + end + local seen = {} + local ids = {} + for index = 1, count do + local profile_id = value[index] + if not valid_id(profile_id) or seen[profile_id] then + return nil + end + seen[profile_id] = true + ids[#ids + 1] = profile_id + end + return ids +end + +local function load_snap_metadata(message_ids, viewer_id) + if #message_ids == 0 then + return {} + end + local placeholders = {} + local params = {} + for _, message_id in ipairs(message_ids) do + placeholders[#placeholders + 1] = "?" + params[#params + 1] = message_id + end + local rows = Bridge.Database.Query(([[ + SELECT message.`id`, message.`friendship_id`, message.`sender_profile_id`, message.`recipient_profile_id`, + message.`message_type`, message.`view_seconds`, message.`allow_replay`, message.`opened_at`, + message.`replayed_at`, message.`expires_at`, message.`created_at`, sender.`handle` AS `sender_handle`, + sender.`display_name` AS `sender_display_name`, sender.`avatar_seed` AS `sender_avatar_seed`, + sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url` + FROM `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_profiles` sender ON sender.`id` = message.`sender_profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = sender.`avatar_media_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 = {} + for _, row in ipairs(rows) do + snaps[#snaps + 1] = safe_snap_from_row(row, viewer_id) + snaps[#snaps].recipientProfileId = row.recipient_profile_id + end + return snaps +end + +local function opened_snap_from_row(row) + return { + id = row.id, + url = row.url, + mediaType = row.message_type == "snap_photo" and "photo" or "video", + mimeType = nullable(row.mime_type), + caption = row.caption or "", + textOverlay = row.overlay_text or "", + overlayColor = row.overlay_color, + durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1), + allowReplay = is_true(row.allow_replay), + openedAt = row.opened_at, + replayedAt = nullable(row.replayed_at), + expiresAt = row.expires_at, + } +end + +local function released_snap(message_id, recipient_profile_id) + local rows = Bridge.Database.Query([[ + SELECT message.`id`, message.`sender_profile_id`, message.`message_type`, message.`caption`, + message.`overlay_text`, message.`overlay_color`, message.`view_seconds`, message.`allow_replay`, + message.`opened_at`, message.`replayed_at`, message.`expires_at`, media.`url`, media.`mime_type` + FROM `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted' + JOIN `sky_phone_media` media ON media.`id` = message.`media_id` + WHERE message.`id` = ? AND message.`recipient_profile_id` = ? + AND message.`message_type` IN ('snap_photo','snap_video') + AND message.`opened_at` IS NOT NULL AND message.`deleted_at` IS NULL + AND message.`recipient_deleted_at` IS NULL AND message.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`) + OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`) + ) + LIMIT 1 + ]], { message_id, recipient_profile_id }) + return rows[1] +end + +Bridge.Callbacks.Register("sky_phone:skypic:send-snap", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_snap", limit("SnapsPerMinute", 20), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + 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) + if not recipients then + return { success = false, error = "invalid_recipients" } + end + for _ = 1, #recipients do + if not SkyPhone.AllowOperation( + source, + "skypic_snap_recipient", + limit("SnapRecipientsPerMinute", 120), + 60 + ) then + return { success = false, error = "rate_limited" } + end + end + local entries = {} + for _, target_id in ipairs(recipients) do + if target_id == profile.profile_id then + return { success = false, error = "invalid_recipients" } + end + local friendship = friendship_with_target(profile.profile_id, target_id, true) + if not friendship then + return { success = false, error = "friendship_not_found" } + end + 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, + } + end + local statements = {} + for _, entry in ipairs(entries) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_skypic_messages` + (`id`, `friendship_id`, `sender_profile_id`, `recipient_profile_id`, `message_type`, + `caption`, `overlay_text`, `overlay_color`, `media_id`, `view_seconds`, `allow_replay`, `expires_at`) + SELECT ?, friendship.`id`, ?, ?, ?, ?, ?, ?, ?, ?, ?, + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND) + FROM `sky_phone_skypic_friendships` friendship + WHERE friendship.`id` = ? AND friendship.`status` = 'accepted' + AND ((friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?) + OR (friendship.`profile_a_id` = ? AND friendship.`profile_b_id` = ?)) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?) + OR (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = ?) + ) + ]], + params = { + entry.id, profile.profile_id, entry.targetId, editor.messageType, editor.caption, + editor.textOverlay, editor.overlayColor, editor.mediaId, editor.durationSeconds, + editor.allowReplay and 1 or 0, limit("UnopenedSnapLifetimeSeconds", 2592000), + entry.friendship.id, profile.profile_id, entry.targetId, entry.targetId, profile.profile_id, + profile.profile_id, entry.targetId, entry.targetId, profile.profile_id, + }, + } + local directional_column = entry.friendship.profile_a_id == profile.profile_id + and "`profile_a_last_snap_on`" or "`profile_b_last_snap_on`" + statements[#statements + 1] = { + query = ([=[ + UPDATE `sky_phone_skypic_friendships` + SET %s = UTC_DATE() + WHERE `id` = ? AND EXISTS ( + SELECT 1 FROM `sky_phone_skypic_messages` message WHERE message.`id` = ? + ) + ]=]):format(directional_column), + params = { entry.friendship.id, entry.id }, + } + statements[#statements + 1] = { + query = [[ + UPDATE `sky_phone_skypic_friendships` + SET `best_streak` = GREATEST(`best_streak`, + CASE WHEN `streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY) + THEN `streak_count` + 1 ELSE 1 END), + `streak_count` = CASE WHEN `streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY) + THEN `streak_count` + 1 ELSE 1 END, + `streak_updated_on` = UTC_DATE() + WHERE `id` = ? AND `profile_a_last_snap_on` = UTC_DATE() + AND `profile_b_last_snap_on` = UTC_DATE() + AND (`streak_updated_on` IS NULL OR `streak_updated_on` < UTC_DATE()) + ]], + params = { entry.friendship.id }, + } + end + local assertion_placeholders = {} + local assertion_params = { profile.profile_id } + for _, entry in ipairs(entries) do + assertion_placeholders[#assertion_placeholders + 1] = "?" + assertion_params[#assertion_params + 1] = entry.id + end + assertion_params[#assertion_params + 1] = #entries + statements[#statements + 1] = { + -- A mismatch deliberately attempts to duplicate the sender profile. + -- The primary-key error makes oxmysql roll the whole transaction back, + -- including earlier inserts and streak mutations. + query = (([[ + INSERT INTO `sky_phone_skypic_profiles` + (`id`, `account_id`, `handle`, `display_name`, `avatar_seed`) + SELECT profile.`id`, profile.`account_id`, profile.`handle`, + profile.`display_name`, profile.`avatar_seed` + FROM `sky_phone_skypic_profiles` profile + WHERE profile.`id` = ? + AND ( + SELECT COUNT(*) FROM `sky_phone_skypic_messages` + WHERE `id` IN (%s) + ) <> ? + ]]):format(table.concat(assertion_placeholders, ", "))), + params = assertion_params, + } + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + local message_ids = {} + for _, entry in ipairs(entries) do + message_ids[#message_ids + 1] = entry.id + end + local sent = load_snap_metadata(message_ids, profile.profile_id) + if #sent ~= #entries then + return { success = false, error = "request_failed" } + end + Bridge.Database.Query([[ + 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 + 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) + end + return { success = true, data = sent } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:open-snap", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_open", limit("OpensPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local snap_id = type(data) == "table" and data.snapId or nil + if not valid_id(snap_id) then + return { success = false, error = "invalid_request" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted' + SET message.`opened_at` = CURRENT_TIMESTAMP(6), message.`read_at` = CURRENT_TIMESTAMP(6), + message.`expires_at` = TIMESTAMPADD( + SECOND, + IF(message.`allow_replay` = 1, ?, message.`view_seconds`), + CURRENT_TIMESTAMP(6) + ) + WHERE message.`id` = ? AND message.`recipient_profile_id` = ? + AND message.`message_type` IN ('snap_photo','snap_video') + AND message.`opened_at` IS NULL AND message.`deleted_at` IS NULL + AND message.`recipient_deleted_at` IS NULL AND message.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`) + OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`) + ) + ]], { limit("ReplayWindowSeconds", 300), snap_id, profile.profile_id }) + if affected_rows(result) ~= 1 then + return { success = false, error = "snap_unavailable" } + end + local row = released_snap(snap_id, profile.profile_id) + if not row then + return { success = false, error = "snap_unavailable" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_profiles` SET `snap_score` = `snap_score` + 1 WHERE `id` = ? + ]], { profile.profile_id }) + notify_profile(row.sender_profile_id, profile, "snap_opened", snap_id) + return { success = true, data = opened_snap_from_row(row) } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:replay-snap", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_open", limit("OpensPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local snap_id = type(data) == "table" and data.snapId or nil + if not valid_id(snap_id) then + return { success = false, error = "invalid_request" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_messages` message + JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`id` = message.`friendship_id` AND friendship.`status` = 'accepted' + SET message.`replayed_at` = CURRENT_TIMESTAMP(6), + message.`expires_at` = DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL message.`view_seconds` SECOND) + WHERE message.`id` = ? AND message.`recipient_profile_id` = ? + AND message.`message_type` IN ('snap_photo','snap_video') AND message.`allow_replay` = 1 + AND message.`opened_at` IS NOT NULL AND message.`replayed_at` IS NULL + AND message.`deleted_at` IS NULL AND message.`recipient_deleted_at` IS NULL + AND message.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = message.`sender_profile_id` AND block.`blocked_profile_id` = message.`recipient_profile_id`) + OR (block.`blocker_profile_id` = message.`recipient_profile_id` AND block.`blocked_profile_id` = message.`sender_profile_id`) + ) + ]], { snap_id, profile.profile_id }) + if affected_rows(result) ~= 1 then + return { success = false, error = "replay_unavailable" } + end + local row = released_snap(snap_id, profile.profile_id) + return row and { success = true, data = opened_snap_from_row(row) } + or { success = false, error = "replay_unavailable" } +end) + +local function own_story_metadata(story_id, profile_id) + local rows = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id`, story.`view_seconds`, story.`expires_at`, story.`created_at`, + author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`, + avatar.`url` AS `avatar_url`, + EXISTS(SELECT 1 FROM `sky_phone_skypic_story_views` view + WHERE view.`story_id` = story.`id` AND view.`viewer_profile_id` = ?) AS `seen`, + (SELECT COUNT(*) FROM `sky_phone_skypic_story_views` view + WHERE view.`story_id` = story.`id`) AS `view_count` + FROM `sky_phone_skypic_stories` story + JOIN `sky_phone_skypic_profiles` author ON author.`id` = story.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id` + WHERE story.`id` = ? AND story.`profile_id` = ? AND story.`status` = 'active' + AND story.`expires_at` > CURRENT_TIMESTAMP(6) + LIMIT 1 + ]], { profile_id, story_id, profile_id }) + local row = rows[1] + if not row then + return nil + end + return { + id = row.id, + author = summary_from_row(row, "none", nil), + durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1), + expiresAt = row.expires_at, + createdAt = row.created_at, + isOwner = true, + seen = is_true(row.seen), + viewCount = tonumber(row.view_count) or 0, + } +end + +Bridge.Callbacks.Register("sky_phone:skypic:publish-story", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_story", limit("StoriesPerMinute", 6), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local editor, editor_error = editor_payload(source, data, false) + if not editor then + return { success = false, error = editor_error } + end + local maximum_active_stories = limit("MaximumActiveStories", 50) + local counts = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_stories` + WHERE `profile_id` = ? AND `status` = 'active' AND `expires_at` > CURRENT_TIMESTAMP(6) + ]], { profile.profile_id }) + if (tonumber(counts[1] and counts[1].count) or 0) >= maximum_active_stories then + return { success = false, error = "story_limit_reached" } + end + local story_id = new_id() + local ok = Bridge.Database.Transaction({ + { + query = [[ + UPDATE `sky_phone_skypic_profiles` + SET `friend_count` = `friend_count` + WHERE `id` = ? + ]], + params = { profile.profile_id }, + }, + { + query = [[ + INSERT INTO `sky_phone_skypic_stories` + (`id`, `profile_id`, `media_id`, `caption`, `overlay_text`, `overlay_color`, + `view_seconds`, `privacy`, `expires_at`) + SELECT ?, profile.`id`, ?, ?, ?, ?, ?, profile.`story_privacy`, + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND) + FROM `sky_phone_skypic_profiles` profile + WHERE profile.`id` = ? AND profile.`status` = 'active' + AND ( + SELECT COUNT(*) FROM `sky_phone_skypic_stories` + WHERE `profile_id` = profile.`id` + AND `status` = 'active' + AND `expires_at` > CURRENT_TIMESTAMP(6) + ) < ? + ]], + params = { + story_id, editor.mediaId, editor.caption, editor.textOverlay, editor.overlayColor, + editor.durationSeconds, limit("StoryLifetimeSeconds", 86400), profile.profile_id, + maximum_active_stories, + }, + }, + { + query = [[ + UPDATE `sky_phone_skypic_profiles` + SET `snap_score` = `snap_score` + 1 + WHERE `id` = ? AND EXISTS ( + SELECT 1 FROM `sky_phone_skypic_stories` WHERE `id` = ? + ) + ]], + params = { profile.profile_id, story_id }, + }, + }) + if not ok then + return { success = false, error = "request_failed" } + end + local story = own_story_metadata(story_id, profile.profile_id) + if not story then + local current_count = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_stories` + WHERE `profile_id` = ? AND `status` = 'active' + AND `expires_at` > CURRENT_TIMESTAMP(6) + ]], { profile.profile_id }) + if (tonumber(current_count[1] and current_count[1].count) or 0) >= maximum_active_stories then + return { success = false, error = "story_limit_reached" } + end + return { success = false, error = "request_failed" } + end + return { success = true, data = story } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:stories", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local offset = type(data) == "table" and data.offset or 0 + offset = valid_integer(offset, 0, 100000) + if not offset then + return { success = false, error = "invalid_request" } + end + return { success = true, data = list_stories(profile.profile_id, offset) } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:view-story", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_story_view", limit("StoryViewsPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local story_id = type(data) == "table" and data.storyId or nil + if not valid_id(story_id) then + return { success = false, error = "invalid_request" } + end + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_skypic_story_views` (`story_id`, `viewer_profile_id`) + SELECT story.`id`, ? + FROM `sky_phone_skypic_stories` story + LEFT JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = LEAST(?, story.`profile_id`) + AND friendship.`profile_b_id` = GREATEST(?, story.`profile_id`) + AND friendship.`status` = 'accepted' + WHERE story.`id` = ? AND story.`profile_id` <> ? AND story.`status` = 'active' + AND story.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = story.`profile_id`) + OR (block.`blocker_profile_id` = story.`profile_id` AND block.`blocked_profile_id` = ?) + ) + AND (story.`privacy` = 'everyone' OR friendship.`id` IS NOT NULL) + ]], { + profile.profile_id, profile.profile_id, profile.profile_id, story_id, profile.profile_id, + profile.profile_id, profile.profile_id, + }) + local rows = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id`, story.`caption`, story.`overlay_text`, story.`overlay_color`, + story.`view_seconds`, story.`expires_at`, media.`url`, media.`media_type`, media.`mime_type`, + author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`, + author.`allow_story_replies`, friendship.`id` AS `friendship_id`, + avatar.`url` AS `avatar_url`, COALESCE(view.`viewed_at`, CURRENT_TIMESTAMP(6)) AS `viewed_at` + FROM `sky_phone_skypic_stories` story + JOIN `sky_phone_media` media ON media.`id` = story.`media_id` + JOIN `sky_phone_skypic_profiles` author + ON author.`id` = story.`profile_id` AND author.`status` = 'active' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id` + LEFT JOIN `sky_phone_skypic_story_views` view + ON view.`story_id` = story.`id` AND view.`viewer_profile_id` = ? + LEFT JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = LEAST(?, story.`profile_id`) + AND friendship.`profile_b_id` = GREATEST(?, story.`profile_id`) + AND friendship.`status` = 'accepted' + WHERE story.`id` = ? AND story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP(6) + AND NOT EXISTS ( + SELECT 1 FROM `sky_phone_skypic_blocks` block + WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = story.`profile_id`) + OR (block.`blocker_profile_id` = story.`profile_id` AND block.`blocked_profile_id` = ?) + ) + AND (story.`profile_id` = ? OR story.`privacy` = 'everyone' OR friendship.`id` IS NOT NULL) + LIMIT 1 + ]], { + profile.profile_id, profile.profile_id, profile.profile_id, story_id, + profile.profile_id, profile.profile_id, profile.profile_id, + }) + local row = rows[1] + if not row then + return { success = false, error = "story_unavailable" } + end + return { + success = true, + data = { + id = row.id, + author = summary_from_row( + row, + row.profile_id == profile.profile_id and "none" or (row.friendship_id and "friends" or "none"), + row.friendship_id + ), + url = row.url, + mediaType = row.media_type, + mimeType = nullable(row.mime_type), + caption = row.caption or "", + textOverlay = row.overlay_text or "", + overlayColor = row.overlay_color, + durationSeconds = tonumber(row.view_seconds) or limit("MinimumViewSeconds", 1), + expiresAt = row.expires_at, + viewedAt = row.viewed_at, + canReply = row.profile_id ~= profile.profile_id + and row.friendship_id ~= nil + and is_true(row.allow_story_replies), + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:story-viewers", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local story_id = type(data) == "table" and data.storyId or nil + local offset = type(data) == "table" and (data.offset or 0) or 0 + offset = valid_integer(offset, 0, 100000) + if not valid_id(story_id) or not offset then + return { success = false, error = "invalid_request" } + end + if not Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_skypic_stories` WHERE `id` = ? AND `profile_id` = ? LIMIT 1 + ]], { story_id, profile.profile_id })[1] then + return { success = false, error = "not_authorized" } + end + local rows = Bridge.Database.Query([[ + SELECT viewer.`id` AS `profile_id`, viewer.`handle`, viewer.`display_name`, viewer.`avatar_seed`, + viewer.`snap_score`, avatar.`url` AS `avatar_url`, story_view.`viewed_at`, + friendship.`id` AS `friendship_id`, friendship.`status`, friendship.`requested_by_id` + FROM `sky_phone_skypic_story_views` story_view + JOIN `sky_phone_skypic_profiles` viewer + ON viewer.`id` = story_view.`viewer_profile_id` AND viewer.`status` = 'active' + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = viewer.`avatar_media_id` + LEFT JOIN `sky_phone_skypic_friendships` friendship + ON friendship.`profile_a_id` = LEAST(?, viewer.`id`) + AND friendship.`profile_b_id` = GREATEST(?, viewer.`id`) + WHERE story_view.`story_id` = ? + ORDER BY story_view.`viewed_at` DESC + LIMIT ? OFFSET ? + ]], { + profile.profile_id, profile.profile_id, story_id, + limit("PageSize", 30), offset, + }) + local viewers = {} + for _, row in ipairs(rows) do + local viewer = summary_from_row( + row, + friendship_status(profile.profile_id, row.requested_by_id, row.status), + row.friendship_id + ) + viewer.viewedAt = row.viewed_at + viewers[#viewers + 1] = viewer + end + return { success = true, data = viewers } +end) + +Bridge.Callbacks.Register("sky_phone:skypic:remove-story", function(source, data) + if not SkyPhone.AllowOperation(source, "skypic_story", limit("StoriesPerMinute", 6), 60) then + return { success = false, error = "rate_limited" } + end + local profile, _, error_response = require_profile(source) + if not profile then + return error_response + end + local story_id = type(data) == "table" and data.storyId or nil + if not valid_id(story_id) then + return { success = false, error = "invalid_request" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_stories` SET `status` = 'removed' + WHERE `id` = ? AND `profile_id` = ? AND `status` = 'active' + ]], { story_id, profile.profile_id }) + return affected_rows(result) == 1 and { success = true } + or { success = false, error = "story_unavailable" } +end) + +CreateThread(function() + while true do + Wait(limit("CleanupIntervalSeconds", 45) * 1000) + Bridge.Database.Query([[ + DELETE FROM `sky_phone_skypic_messages` + WHERE `deleted_at` IS NOT NULL + OR (`sender_deleted_at` IS NOT NULL AND `recipient_deleted_at` IS NOT NULL) + OR (`message_type` IN ('snap_photo','snap_video') + AND `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP(6)) + OR (`message_type` = 'text' AND `saved_at` IS NULL + AND `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP(6)) + ]], {}) + Bridge.Database.Query([[ + DELETE FROM `sky_phone_skypic_stories` + WHERE `status` = 'removed' OR `expires_at` <= CURRENT_TIMESTAMP(6) + ]], {}) + Bridge.Database.Query([[ + UPDATE `sky_phone_skypic_friendships` + SET `streak_count` = 0 + WHERE `status` = 'accepted' AND `streak_count` > 0 + AND (`streak_updated_on` IS NULL + OR `streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)) + ]], {}) + end +end) + +end) diff --git a/sky_phone/source/shared/custom_apps.lua b/sky_phone/source/shared/custom_apps.lua index d78a916..b0c644c 100644 --- a/sky_phone/source/shared/custom_apps.lua +++ b/sky_phone/source/shared/custom_apps.lua @@ -66,6 +66,7 @@ local RESERVED_APP_IDS = { phone = true, photos = true, picstagram = true, + skypic = true, radio = true, settings = true, ["sky-flappy"] = true, diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index eb1795f..4ca59cd 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -1483,3 +1483,127 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` ( PRIMARY KEY (`id`), KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`) ) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `sky_phone_skypic_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NOT NULL, + `handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `display_name` VARCHAR(40) NOT NULL, + `bio` VARCHAR(160) NOT NULL DEFAULT '', + `avatar_media_id` BIGINT UNSIGNED NULL, + `avatar_seed` INT UNSIGNED NOT NULL DEFAULT 1, + `story_privacy` ENUM('friends','everyone') NOT NULL DEFAULT 'friends', + `quick_add` TINYINT(1) NOT NULL DEFAULT 1, + `allow_story_replies` TINYINT(1) NOT NULL DEFAULT 1, + `snap_score` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `friend_count` INT UNSIGNED NOT NULL DEFAULT 0, + `status` ENUM('active','hidden','removed') NOT NULL DEFAULT 'active', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_skypic_profile_account` (`account_id`), + UNIQUE KEY `uniq_sky_phone_skypic_profile_handle` (`handle`), + KEY `idx_sky_phone_skypic_quick_add` (`status`,`quick_add`,`updated_at`), + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skypic_friendships` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_a_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_b_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `requested_by_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('pending','accepted') NOT NULL DEFAULT 'pending', + `profile_a_last_snap_on` DATE NULL, + `profile_b_last_snap_on` DATE NULL, + `streak_updated_on` DATE NULL, + `streak_count` INT UNSIGNED NOT NULL DEFAULT 0, + `best_streak` INT UNSIGNED NOT NULL DEFAULT 0, + `accepted_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_skypic_friend_pair` (`profile_a_id`,`profile_b_id`), + KEY `idx_sky_phone_skypic_friend_a` (`profile_a_id`,`status`,`updated_at`), + KEY `idx_sky_phone_skypic_friend_b` (`profile_b_id`,`status`,`updated_at`), + KEY `idx_sky_phone_skypic_friend_requests` (`status`,`requested_by_id`,`created_at`), + KEY `idx_sky_phone_skypic_streaks` (`status`,`streak_updated_on`), + FOREIGN KEY (`profile_a_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_b_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`requested_by_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skypic_blocks` ( + `blocker_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `blocked_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`blocker_profile_id`,`blocked_profile_id`), + KEY `idx_sky_phone_skypic_blocked` (`blocked_profile_id`,`created_at`), + FOREIGN KEY (`blocker_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`blocked_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skypic_messages` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `friendship_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `sender_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `recipient_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `message_type` ENUM('text','snap_photo','snap_video') NOT NULL, + `body` VARCHAR(2000) NOT NULL DEFAULT '', + `caption` VARCHAR(160) NOT NULL DEFAULT '', + `overlay_text` VARCHAR(160) NOT NULL DEFAULT '', + `overlay_color` CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '#FFFFFF', + `media_id` BIGINT UNSIGNED NULL, + `view_seconds` TINYINT UNSIGNED NULL, + `allow_replay` TINYINT(1) NOT NULL DEFAULT 0, + `read_at` DATETIME(6) NULL, + `opened_at` DATETIME(6) NULL, + `replayed_at` DATETIME(6) NULL, + `saved_at` DATETIME(6) NULL, + `expires_at` DATETIME(6) NULL, + `sender_deleted_at` DATETIME(6) NULL, + `recipient_deleted_at` DATETIME(6) NULL, + `deleted_at` DATETIME(6) NULL, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_sky_phone_skypic_message_thread` (`friendship_id`,`created_at`,`id`), + KEY `idx_sky_phone_skypic_message_inbox` (`recipient_profile_id`,`read_at`,`created_at`), + KEY `idx_sky_phone_skypic_message_expiry` (`message_type`,`expires_at`), + KEY `idx_sky_phone_skypic_message_deleted` (`deleted_at`), + KEY `idx_sky_phone_skypic_message_media` (`media_id`), + FOREIGN KEY (`friendship_id`) REFERENCES `sky_phone_skypic_friendships` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`sender_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`recipient_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skypic_stories` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `caption` VARCHAR(160) NOT NULL DEFAULT '', + `overlay_text` VARCHAR(160) NOT NULL DEFAULT '', + `overlay_color` CHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '#FFFFFF', + `view_seconds` TINYINT UNSIGNED NOT NULL, + `privacy` ENUM('friends','everyone') NOT NULL, + `status` ENUM('active','removed') NOT NULL DEFAULT 'active', + `expires_at` DATETIME(6) NOT NULL, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_sky_phone_skypic_story_profile` (`profile_id`,`status`,`expires_at`), + KEY `idx_sky_phone_skypic_story_expiry` (`status`,`expires_at`), + KEY `idx_sky_phone_skypic_story_media` (`media_id`), + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skypic_story_views` ( + `story_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `viewer_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `viewed_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`story_id`,`viewer_profile_id`), + KEY `idx_sky_phone_skypic_story_viewer` (`viewer_profile_id`,`viewed_at`), + FOREIGN KEY (`story_id`) REFERENCES `sky_phone_skypic_stories` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`viewer_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;