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 0000000..b0c4651 Binary files /dev/null and b/frontend/src/assets/img/app-icons/skypic.png differ 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 0000000..c50ef7e Binary files /dev/null and b/frontend/src/assets/img/app-previews/skypic.jpg differ 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;