diff --git a/frontend/src/assets/img/app-previews/skypic.jpg b/frontend/src/assets/img/app-previews/skypic.jpg index c50ef7e..f7ee46b 100644 Binary files a/frontend/src/assets/img/app-previews/skypic.jpg and b/frontend/src/assets/img/app-previews/skypic.jpg differ diff --git a/frontend/src/skypic.backend.contract.test.ts b/frontend/src/skypic.backend.contract.test.ts index 893f097..21cc2ac 100644 --- a/frontend/src/skypic.backend.contract.test.ts +++ b/frontend/src/skypic.backend.contract.test.ts @@ -243,6 +243,77 @@ describe('SkyPic backend contracts', () => { expect(mockServer).toContain('mediaItems.length * recipients.length > 40') }) + it('maintains reciprocal UTC-day streaks and returns reconciled values', () => { + const friendshipMigration = migrationTable('friendships') + const friendshipInstall = installTable('friendships') + const friends = block( + server, + 'local function list_friends', + 'local function list_requests', + ) + const conversations = block( + server, + 'local function list_conversations', + 'Bridge.Callbacks.Register("sky_phone:skypic:bootstrap"', + ) + const metadata = block( + server, + 'local function load_snap_metadata', + 'local function opened_snap_from_row', + ) + const sendSnap = block( + server, + 'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"', + 'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"', + ) + + for (const schema of [friendshipMigration, friendshipInstall]) { + expect(schema).toContain('profile_a_last_snap_on') + expect(schema).toContain('profile_b_last_snap_on') + expect(schema).toContain('streak_updated_on') + expect(schema).toContain('streak_count') + expect(schema).toContain('best_streak') + expect(schema).toContain('idx_sky_phone_skypic_streaks') + } + + expect(sendSnap).toContain('SET %s = UTC_DATE()') + expect(sendSnap).toContain( + 'SELECT 1 FROM `sky_phone_skypic_messages` message WHERE message.`id` = ?', + ) + expect(sendSnap).toContain('friendship.profile_a_id == profile.profile_id') + expect(sendSnap).toContain('`profile_a_last_snap_on` = UTC_DATE()') + expect(sendSnap).toContain('`profile_b_last_snap_on` = UTC_DATE()') + expect(sendSnap).toContain( + '`streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)', + ) + expect(sendSnap).toContain( + '`streak_updated_on` IS NULL OR `streak_updated_on` < UTC_DATE()', + ) + expect(sendSnap).toContain('Bridge.Database.Transaction(statements)') + + for (const serializer of [friends, conversations, metadata]) { + expect(serializer).toContain( + '`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)', + ) + expect(serializer).toContain( + 'THEN 0 ELSE friendship.`streak_count` END AS `streak_count`', + ) + expect(serializer).toContain('friendship.`best_streak`') + } + expect(metadata).toContain("friendship.`status` = 'accepted'") + expect(metadata).toContain( + 'snap.streakCount = tonumber(row.streak_count) or 0', + ) + expect(metadata).toContain( + 'snap.bestStreak = tonumber(row.best_streak) or 0', + ) + + expect(server).toContain('SET `streak_count` = 0') + expect(server).toContain( + "WHERE `status` = 'accepted' AND `streak_count` > 0", + ) + }) + 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)') diff --git a/frontend/src/stores/skypic.test.ts b/frontend/src/stores/skypic.test.ts index 9da59d7..02c9143 100644 --- a/frontend/src/stores/skypic.test.ts +++ b/frontend/src/stores/skypic.test.ts @@ -537,6 +537,7 @@ describe('SkyPic store', () => { const store = useSkyPicStore() store.activeFriendshipId = friend.friendshipId store.conversations = [{ ...conversation }] + store.friends = [{ ...friend }] store.profile = { ...self } const response = await store.sendSnap({ @@ -566,9 +567,294 @@ describe('SkyPic store', () => { id: sent.id, type: sent.type, }) + expect(store.friends[0]).toMatchObject({ + bestStreak: friend.bestStreak, + streakCount: friend.streakCount, + }) + expect(store.conversations[0]).toMatchObject({ + bestStreak: conversation.bestStreak, + streakCount: conversation.streakCount, + }) expect(store.profile.snapScore).toBe(self.snapScore + 1) }) + it('reconciles one authoritative streak value across an atomic snap batch', async () => { + const sentBatch = [1, 2, 3].map( + (index): SkyPicSnap => ({ + ...snap, + bestStreak: 18, + direction: 'sent', + id: `snap-batch-${index}`, + sender: { ...self, snapScore: self.snapScore + 3 }, + streakCount: 1, + }), + ) + mockNuiCall.mockResolvedValueOnce({ data: sentBatch, success: true }) + const store = useSkyPicStore() + store.activeFriendshipId = friend.friendshipId + store.conversations = [{ ...conversation }] + store.friends = [{ ...friend }] + store.profile = { ...self } + + await store.sendSnap({ + allowReplay: false, + caption: '', + durationSeconds: 5, + mediaIds: [1, 2, 3], + overlayColor: '#ffffff', + recipientIds: [maya.id], + textOverlay: '', + }) + + expect(store.threadSnaps).toHaveLength(3) + expect(store.friends[0]).toMatchObject({ + bestStreak: 18, + streakCount: 1, + }) + expect(store.conversations[0]).toMatchObject({ + bestStreak: 18, + streakCount: 1, + }) + expect(store.profile.snapScore).toBe(self.snapScore + 3) + }) + + it('does not let an older send response overwrite streaks from a newer bootstrap', async () => { + const pendingSend = deferred>() + const refreshedProfile = { ...self, snapScore: self.snapScore + 5 } + const refreshedFriend = { + ...friend, + bestStreak: 20, + streakCount: 9, + } + const refreshedConversation: SkyPicConversation = { + ...conversation, + bestStreak: 20, + lastItem: { + body: 'Authoritative newer item', + createdAt: '2026-08-19T14:00:00.000Z', + direction: 'received', + id: 'message-newer-hydration', + openedAt: null, + type: 'text', + }, + streakCount: 9, + } + const refreshedBootstrap: SkyPicBootstrap = { + ...bootstrap, + conversations: [refreshedConversation], + friends: [refreshedFriend], + profile: refreshedProfile, + } + mockNuiCall + .mockReturnValueOnce(pendingSend.promise) + .mockResolvedValueOnce({ data: refreshedBootstrap, success: true }) + .mockResolvedValueOnce({ data: refreshedBootstrap, success: true }) + const store = useSkyPicStore() + + const sending = store.sendSnap({ + allowReplay: false, + caption: '', + durationSeconds: 5, + mediaId: 42, + mediaType: 'photo', + overlayColor: '#ffffff', + recipientIds: [maya.id], + textOverlay: '', + }) + await expect(store.bootstrap()).resolves.toBe(true) + + const staleSent: SkyPicSnap = { + ...snap, + bestStreak: 18, + direction: 'sent', + id: 'snap-stale-streak', + sender: refreshedProfile, + streakCount: 1, + } + pendingSend.resolve({ data: [staleSent], success: true }) + await expect(sending).resolves.toMatchObject({ success: true }) + + expect(store.friends[0]).toMatchObject({ + bestStreak: 20, + streakCount: 9, + }) + expect(store.conversations[0]).toMatchObject({ + bestStreak: 20, + lastItem: { id: 'message-newer-hydration' }, + streakCount: 9, + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'skypic:bootstrap', {}) + }) + + it('waits for a pending pre-commit bootstrap before its causal refresh', async () => { + const pendingSend = deferred>() + const pendingBootstrap = deferred>() + const committedSent: SkyPicSnap = { + ...snap, + bestStreak: 12, + createdAt: later, + direction: 'sent', + id: 'snap-after-pending-bootstrap', + sender: { ...self, snapScore: self.snapScore + 1 }, + streakCount: 6, + } + const authoritativeConversation: SkyPicConversation = { + ...conversation, + bestStreak: 12, + lastItem: { + createdAt: committedSent.createdAt, + direction: 'sent', + id: committedSent.id, + openedAt: null, + type: committedSent.type, + }, + streakCount: 6, + } + const postCommitBootstrap: SkyPicBootstrap = { + ...bootstrap, + conversations: [authoritativeConversation], + friends: [{ ...friend, streakCount: 6 }], + profile: { ...self, snapScore: self.snapScore + 1 }, + } + mockNuiCall + .mockReturnValueOnce(pendingSend.promise) + .mockReturnValueOnce(pendingBootstrap.promise) + .mockResolvedValueOnce({ data: postCommitBootstrap, success: true }) + const store = useSkyPicStore() + + const sending = store.sendSnap({ + allowReplay: false, + caption: '', + durationSeconds: 5, + mediaId: 42, + mediaType: 'photo', + overlayColor: '#ffffff', + recipientIds: [maya.id], + textOverlay: '', + }) + const staleRefresh = store.bootstrap() + pendingSend.resolve({ data: [committedSent], success: true }) + await Promise.resolve() + await Promise.resolve() + pendingBootstrap.resolve({ data: bootstrap, success: true }) + + await expect(staleRefresh).resolves.toBe(true) + await expect(sending).resolves.toMatchObject({ success: true }) + expect(store.friends[0]).toMatchObject({ + bestStreak: 12, + streakCount: 6, + }) + expect(store.conversations[0]).toMatchObject({ + bestStreak: 12, + lastItem: { id: committedSent.id }, + streakCount: 6, + }) + expect(store.profile?.snapScore).toBe(self.snapScore + 1) + expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'skypic:bootstrap', {}) + }) + + it('refreshes after a bootstrap that read before the pending send committed', async () => { + const pendingSend = deferred>() + const pendingPreCommitBootstrap = deferred>() + const staleHydration: SkyPicBootstrap = { + ...bootstrap, + conversations: [{ ...conversation }], + friends: [{ ...friend }], + profile: { ...self }, + } + const committedSent: SkyPicSnap = { + ...snap, + bestStreak: 12, + createdAt: later, + direction: 'sent', + id: 'snap-committed-after-bootstrap-read', + sender: { ...self, snapScore: self.snapScore + 1 }, + streakCount: 6, + } + const committedConversation: SkyPicConversation = { + ...conversation, + bestStreak: 12, + lastItem: { + createdAt: committedSent.createdAt, + direction: 'sent', + id: committedSent.id, + openedAt: null, + type: committedSent.type, + }, + streakCount: 6, + } + const postCommitBootstrap: SkyPicBootstrap = { + ...bootstrap, + conversations: [committedConversation], + friends: [{ ...friend, streakCount: 6 }], + profile: { ...self, snapScore: self.snapScore + 1 }, + } + mockNuiCall + .mockReturnValueOnce(pendingSend.promise) + .mockResolvedValueOnce({ data: staleHydration, success: true }) + .mockReturnValueOnce(pendingPreCommitBootstrap.promise) + .mockResolvedValueOnce({ data: postCommitBootstrap, success: true }) + const store = useSkyPicStore() + + const sending = store.sendSnap({ + allowReplay: false, + caption: '', + durationSeconds: 5, + mediaId: 42, + mediaType: 'photo', + overlayColor: '#ffffff', + recipientIds: [maya.id], + textOverlay: '', + }) + await expect(store.bootstrap()).resolves.toBe(true) + expect(store.friends[0].streakCount).toBe(friend.streakCount) + + const overlappingPreCommitRefresh = store.bootstrap() + pendingSend.resolve({ data: [committedSent], success: true }) + pendingPreCommitBootstrap.resolve({ + data: staleHydration, + success: true, + }) + await expect(overlappingPreCommitRefresh).resolves.toBe(true) + await expect(sending).resolves.toMatchObject({ success: true }) + + expect(store.friends[0]).toMatchObject({ + bestStreak: 12, + streakCount: 6, + }) + expect(store.conversations[0]).toMatchObject({ + bestStreak: 12, + lastItem: { id: committedSent.id }, + streakCount: 6, + }) + expect(mockNuiCall).toHaveBeenNthCalledWith(4, 'skypic:bootstrap', {}) + }) + + it('does not refresh an obsolete session after a pending send resolves', async () => { + const pendingSend = deferred>() + mockNuiCall.mockReturnValueOnce(pendingSend.promise) + const store = useSkyPicStore() + + const sending = store.sendSnap({ + allowReplay: false, + caption: '', + durationSeconds: 5, + mediaId: 42, + mediaType: 'photo', + overlayColor: '#ffffff', + recipientIds: [maya.id], + textOverlay: '', + }) + store.resetSession() + pendingSend.resolve({ data: [snap], success: true }) + + await expect(sending).resolves.toEqual({ + error: 'request_aborted', + success: false, + }) + expect(mockNuiCall).toHaveBeenCalledTimes(1) + }) + it('deduplicates and caps a multi-photo snap batch without legacy media fields', async () => { mockNuiCall.mockResolvedValueOnce({ data: [], success: true }) const store = useSkyPicStore() diff --git a/frontend/src/stores/skypic.ts b/frontend/src/stores/skypic.ts index 7348483..d1bf5f0 100644 --- a/frontend/src/stores/skypic.ts +++ b/frontend/src/stores/skypic.ts @@ -100,12 +100,14 @@ export const useSkyPicStore = defineStore('skypic', () => { const storyViewersLoadingMore = ref(false) const error = ref(null) let bootstrapRequest = 0 + let bootstrapGeneration = 0 let searchRequest = 0 let snapRequest = 0 let storyRequest = 0 let storyViewersRequest = 0 let threadRequest = 0 let sessionVersion = 0 + let hydrationRevision = 0 let storyViewersStoryId: string | null = null let bootstrapInFlight: { promise: Promise @@ -154,6 +156,29 @@ export const useSkyPicStore = defineStore('skypic', () => { conversations.value = sortByLastItem(conversations.value) } + function reconcileFriendshipStreak(snap: SkyPicSnap): void { + const streakCount = Number(snap.streakCount) + const bestStreak = Number(snap.bestStreak) + if ( + !Number.isInteger(streakCount) || + streakCount < 0 || + !Number.isInteger(bestStreak) || + bestStreak < streakCount + ) { + return + } + friends.value = friends.value.map((friend) => + friend.friendshipId === snap.friendshipId + ? { ...friend, bestStreak, streakCount } + : friend, + ) + conversations.value = conversations.value.map((conversation) => + conversation.friendshipId === snap.friendshipId + ? { ...conversation, bestStreak, streakCount } + : conversation, + ) + } + function setError(response: NuiResponse): void { if (response.error === 'request_aborted') return error.value = response.success ? null : (response.error ?? 'unknown_error') @@ -172,6 +197,7 @@ export const useSkyPicStore = defineStore('skypic', () => { function resetSession(): void { sessionVersion += 1 + hydrationRevision += 1 bootstrapInFlight = null bootstrapPending.value = false bootstrapRequest += 1 @@ -217,6 +243,8 @@ export const useSkyPicStore = defineStore('skypic', () => { return } + hydrationRevision += 1 + blockedProfiles.value = arrayOrEmpty(data.blockedProfiles) friends.value = arrayOrEmpty(data.friends) requests.value = arrayOrEmpty(data.requests) @@ -234,6 +262,7 @@ export const useSkyPicStore = defineStore('skypic', () => { if (bootstrapInFlight?.session === requestSession) { return bootstrapInFlight.promise } + bootstrapGeneration += 1 const requestId = ++bootstrapRequest loading.value = true bootstrapPending.value = true @@ -258,6 +287,15 @@ export const useSkyPicStore = defineStore('skypic', () => { return promise } + async function refreshAfterMutation(expectedSession: number): Promise { + const pendingBootstrap = bootstrapInFlight + if (pendingBootstrap?.session === expectedSession) { + await pendingBootstrap.promise + } + if (expectedSession !== sessionVersion) return + await bootstrap() + } + async function createProfile( input: SkyPicCreateProfileInput, ): Promise> { @@ -585,6 +623,9 @@ export const useSkyPicStore = defineStore('skypic', () => { mediaId: input.mediaId!, mediaType: input.mediaType!, } + const requestHydrationRevision = hydrationRevision + const requestBootstrapGeneration = bootstrapGeneration + const requestSession = sessionVersion const response = await sessionCall( 'skypic:send-snap', payload, @@ -592,25 +633,35 @@ export const useSkyPicStore = defineStore('skypic', () => { setError(response) if (response.success && response.data) { const active = activeFriendshipId.value + const canReconcileResponse = + requestSession === sessionVersion && + requestHydrationRevision === hydrationRevision && + requestBootstrapGeneration === bootstrapGeneration && + bootstrapInFlight?.session !== requestSession 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 + if (canReconcileResponse) { + for (const snap of response.data) { + upsertConversationLastItem(snap.friendshipId, { + createdAt: snap.createdAt, + direction: snap.direction, + id: snap.id, + openedAt: snap.openedAt, + type: snap.type, + }) + reconcileFriendshipStreak(snap) + } + 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 + } + } else if (requestSession === sessionVersion) { + await refreshAfterMutation(requestSession) } } return response diff --git a/frontend/src/types/skypic.ts b/frontend/src/types/skypic.ts index 2e2d9a0..74aa2db 100644 --- a/frontend/src/types/skypic.ts +++ b/frontend/src/types/skypic.ts @@ -83,6 +83,8 @@ export type SkyPicConversation = { /** Direct snap lists intentionally contain no media URL or editor contents. */ export type SkyPicSnap = { allowReplay: boolean + /** Present on successful sends so friendship streak UI can reconcile immediately. */ + bestStreak?: number createdAt: string direction: SkyPicDirection durationSeconds: number @@ -92,6 +94,8 @@ export type SkyPicSnap = { openedAt: string | null replayedAt: string | null sender: SkyPicProfileSummary + /** Present on successful sends so friendship streak UI can reconcile immediately. */ + streakCount?: number type: SkyPicSnapType } diff --git a/frontend/src/views/apps/SkyPicApp.contract.test.ts b/frontend/src/views/apps/SkyPicApp.contract.test.ts index b255f03..4f43339 100644 --- a/frontend/src/views/apps/SkyPicApp.contract.test.ts +++ b/frontend/src/views/apps/SkyPicApp.contract.test.ts @@ -52,6 +52,54 @@ describe('SkyPic frontend contract', () => { expect(viewSource).not.toContain('var(--sp-cyan)') }) + it('uses the reference-led Sky UI hierarchy without inventing camera filters or Spotlight', () => { + expect(viewSource).toContain('class="sp-camera-viewfinder"') + expect(viewSource).toContain('class="sp-camera-dock"') + expect(viewSource).toContain('class="sp-chat-list"') + expect(viewSource).toContain('class="sp-chat-row') + expect(viewSource).toContain('class="sp-story-rail"') + expect(viewSource).toContain('class="sp-story-grid"') + expect(viewSource).toContain('class="sp-discovery-grid"') + expect(viewSource).toContain('class="sp-bottom-nav"') + expect(viewSource).not.toContain('sp-camera-filter') + expect(viewSource).not.toContain('Spotlight') + expect(viewSource).not.toContain('advertisement') + }) + + it('builds story discovery only from safe metadata until view-story releases media', () => { + const storyFeedBlock = viewSource + .split('
')[1] + ?.split('
')[0] + + expect(viewSource).toContain('const friendStoryRail = computed(') + expect(storyFeedBlock).toBeTruthy() + expect(storyFeedBlock).toContain('v-for="story in store.stories"') + expect(storyFeedBlock).toContain('story.author.avatarUrl') + expect(storyFeedBlock).toContain('story.durationSeconds') + expect(storyFeedBlock).not.toContain('story.url') + expect(storyFeedBlock).not.toContain('store.viewedStory.url') + }) + + it('renders every visible streak as an accessible Flame icon', () => { + expect(viewSource.match(/ { + expect(viewSource).toContain( + 'class="sp-media-viewer sp-media-viewer--snap"', + ) + expect(viewSource).toContain( + 'class="sp-media-viewer sp-media-viewer--story"', + ) + expect(viewSource).toContain('object-fit: cover') + expect(viewSource).toContain('.sp-media-viewer::before') + expect(viewSource).toContain('.sp-media-viewer::after') + }) + it('uses the shared Camera and Gallery media handoff with a bounded draft', () => { expect(viewSource).toContain('mediaPicker.begin(') expect(viewSource).toContain("'skypic-draft'") @@ -161,7 +209,7 @@ describe('SkyPic frontend contract', () => { 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.match(/@click="toggleProfileEditor"/g)).toHaveLength(3) expect(viewSource).toContain('const MAX_CAPTION_LENGTH = 160') expect(viewSource).toContain(':maxlength="MAX_CAPTION_LENGTH"') expect(viewSource).toContain('.slice(0, MAX_CAPTION_LENGTH)') diff --git a/frontend/src/views/apps/SkyPicApp.vue b/frontend/src/views/apps/SkyPicApp.vue index dd7fb32..eb81241 100644 --- a/frontend/src/views/apps/SkyPicApp.vue +++ b/frontend/src/views/apps/SkyPicApp.vue @@ -7,6 +7,7 @@ import { ChevronRight, CirclePlay, Eye, + Flame, Image as ImageIcon, Images, LogOut, @@ -20,7 +21,6 @@ import { Shield, Timer, Trash2, - UserRound, UsersRound, Video, X, @@ -211,6 +211,17 @@ const ownStories = computed(() => const communityStories = computed(() => store.stories.filter((story) => !story.isOwner), ) +const friendStoryRail = computed(() => { + const authors = new Set() + return communityStories.value.filter((story) => { + if (authors.has(story.author.id)) return false + authors.add(story.author.id) + return true + }) +}) +const bestStreak = computed(() => + store.friends.reduce((best, friend) => Math.max(best, friend.bestStreak), 0), +) const viewedStorySummary = computed( () => store.stories.find((story) => story.id === store.viewedStory?.id) ?? null, @@ -1779,14 +1790,23 @@ onBeforeUnmount(() => { +