feat(skypic): refine layouts and streaks

This commit is contained in:
Dominik
2026-08-21 19:35:54 +02:00
parent 691ba65559
commit 4b65d17acc
10 changed files with 1961 additions and 465 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -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)')
+286
View File
@@ -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<NuiResponse<SkyPicSnap[]>>()
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<NuiResponse<SkyPicSnap[]>>()
const pendingBootstrap = deferred<NuiResponse<SkyPicBootstrap>>()
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<NuiResponse<SkyPicSnap[]>>()
const pendingPreCommitBootstrap = deferred<NuiResponse<SkyPicBootstrap>>()
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<NuiResponse<SkyPicSnap[]>>()
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()
+66 -15
View File
@@ -100,12 +100,14 @@ export const useSkyPicStore = defineStore('skypic', () => {
const storyViewersLoadingMore = ref(false)
const error = ref<string | null>(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<boolean>
@@ -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<unknown>): 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<void> {
const pendingBootstrap = bootstrapInFlight
if (pendingBootstrap?.session === expectedSession) {
await pendingBootstrap.promise
}
if (expectedSession !== sessionVersion) return
await bootstrap()
}
async function createProfile(
input: SkyPicCreateProfileInput,
): Promise<NuiResponse<SkyPicProfile>> {
@@ -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<SkyPicSnap[]>(
'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
+4
View File
@@ -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
}
@@ -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('<section class="sp-story-feed">')[1]
?.split('</section>')[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(/<Flame/g)?.length).toBeGreaterThanOrEqual(4)
expect(viewSource).toContain("t('profile.streaks') +")
expect(viewSource).toContain('class="sp-navbar-streak"')
expect(viewSource).toContain('class="sp-profile-streak-stat"')
expect(viewSource).not.toContain('🔥')
})
it('keeps existing Snap and Story viewers full-bleed with ordered overlays', () => {
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)')
File diff suppressed because it is too large Load Diff
+97
View File
@@ -3577,6 +3577,16 @@ let skyPicFriends = [
streakCount: 7,
},
]
const skyPicStreakStates = new Map([
[
'20000000-0000-4000-8000-000000000001',
{
friendLastSnapOn: skyPicUtcDay(),
selfLastSnapOn: skyPicUtcDay(),
streakUpdatedOn: skyPicUtcDay(),
},
],
])
let skyPicRequests = [
{
createdAt: isoTime(-35 * 60_000),
@@ -3816,11 +3826,79 @@ function skyPicIncrementOwnScore(amount = 1) {
if (skyPicProfile) skyPicProfile.snapScore = skyPicProfiles[0].snapScore
}
function skyPicUtcDay(timestamp = Date.now()) {
const value = new Date(timestamp)
return Number.isNaN(value.getTime()) ? null : value.toISOString().slice(0, 10)
}
function skyPicPreviousUtcDay(day) {
const value = new Date(`${day}T00:00:00.000Z`)
value.setUTCDate(value.getUTCDate() - 1)
return value.toISOString().slice(0, 10)
}
function skyPicApplyStreakActivity(
friend,
state,
side,
timestamp = Date.now(),
) {
const day = skyPicUtcDay(timestamp)
if (!day || !state || !['friend', 'self'].includes(side)) return false
const previousDay = skyPicPreviousUtcDay(day)
if (
friend.streakCount > 0 &&
(!state.streakUpdatedOn || state.streakUpdatedOn < previousDay)
) {
friend.streakCount = 0
}
state[side === 'self' ? 'selfLastSnapOn' : 'friendLastSnapOn'] = day
if (
state.selfLastSnapOn !== day ||
state.friendLastSnapOn !== day ||
state.streakUpdatedOn === day
) {
return false
}
friend.streakCount =
state.streakUpdatedOn === previousDay ? friend.streakCount + 1 : 1
friend.bestStreak = Math.max(friend.bestStreak, friend.streakCount)
state.streakUpdatedOn = day
return true
}
function skyPicSyncStreak(friend) {
const conversation = skyPicConversation(friend.friendshipId)
if (!conversation) return
conversation.streakCount = friend.streakCount
conversation.bestStreak = friend.bestStreak
}
function skyPicRefreshStreaks(timestamp = Date.now()) {
const day = skyPicUtcDay(timestamp)
if (!day) return
const previousDay = skyPicPreviousUtcDay(day)
for (const friend of skyPicFriends) {
const state = skyPicStreakStates.get(friend.friendshipId)
if (
friend.streakCount > 0 &&
(!state?.streakUpdatedOn || state.streakUpdatedOn < previousDay)
) {
friend.streakCount = 0
skyPicSyncStreak(friend)
}
}
}
function skyPicStoryVisible(story) {
return story.isOwner || !skyPicBlockedProfileIds.has(story.author.id)
}
function skyPicBootstrap(testScenario = '') {
skyPicRefreshStreaks()
if (testScenario === 'skypic-onboarding') {
const profile = skyPicOnboardingProfile
? { ...skyPicOnboardingProfile }
@@ -8089,6 +8167,7 @@ app.post('/api/:endpoint', (request, response) => {
}
skyPicProfile = null
skyPicFriends = []
skyPicStreakStates.clear()
skyPicRequests = []
skyPicConversations = []
skyPicSnaps = []
@@ -8252,6 +8331,11 @@ app.post('/api/:endpoint', (request, response) => {
streakCount: 0,
}
skyPicFriends.push(friend)
skyPicStreakStates.set(friendshipId, {
friendLastSnapOn: null,
selfLastSnapOn: null,
streakUpdatedOn: null,
})
if (skyPicProfile) skyPicProfile.friendCount = skyPicFriends.length
response.json({ success: true, data: skyPicFriendView(friend) })
return
@@ -8277,6 +8361,7 @@ app.post('/api/:endpoint', (request, response) => {
skyPicSnaps = skyPicSnaps.filter(
(item) => item.friendshipId !== friendshipId,
)
skyPicStreakStates.delete(friendshipId)
skyPicMessages.delete(friendshipId)
if (skyPicProfile) skyPicProfile.friendCount = skyPicFriends.length
response.json({ success: true })
@@ -8317,6 +8402,7 @@ app.post('/api/:endpoint', (request, response) => {
item.sender.id !== profileId && !friendshipIds.has(item.friendshipId),
)
for (const friendshipId of friendshipIds) {
skyPicStreakStates.delete(friendshipId)
skyPicMessages.delete(friendshipId)
}
if (skyPicProfile) skyPicProfile.friendCount = skyPicFriends.length
@@ -8392,9 +8478,18 @@ app.post('/api/:endpoint', (request, response) => {
const sent = []
for (const friend of recipients) {
for (const media of mediaItems) {
const streakState = skyPicStreakStates.get(friend.friendshipId) ?? {
friendLastSnapOn: null,
selfLastSnapOn: null,
streakUpdatedOn: null,
}
skyPicStreakStates.set(friend.friendshipId, streakState)
skyPicApplyStreakActivity(friend, streakState, 'self', createdAt)
skyPicSyncStreak(friend)
const mediaType = media.mediaType === 'video' ? 'video' : 'photo'
const snap = {
allowReplay: request.body.allowReplay === true,
bestStreak: friend.bestStreak,
createdAt,
direction: 'sent',
durationSeconds,
@@ -8404,6 +8499,7 @@ app.post('/api/:endpoint', (request, response) => {
openedAt: null,
replayedAt: null,
sender: skyPicProfiles[0],
streakCount: friend.streakCount,
type: mediaType === 'video' ? 'snap_video' : 'snap_photo',
}
skyPicSnaps.push(snap)
@@ -12988,6 +13084,7 @@ if (require.main === module) {
module.exports = {
app,
skyPicApplyStreakActivity,
skyPicMessageBody,
skyPicRecipientIds,
skyPicTextOverlay,
+102
View File
@@ -3,6 +3,7 @@ const { once } = require('node:events')
const {
app,
skyPicApplyStreakActivity,
skyPicMessageBody,
skyPicRecipientIds,
skyPicTextOverlay,
@@ -318,6 +319,83 @@ async function verifySkyPicActions(baseUrl) {
error: 'message_too_long',
})
const streakFixture = { bestStreak: 18, streakCount: 7 }
const streakState = {
friendLastSnapOn: null,
selfLastSnapOn: null,
streakUpdatedOn: '2026-08-17',
}
assert.equal(
skyPicApplyStreakActivity(
streakFixture,
streakState,
'self',
'2026-08-18T08:00:00.000Z',
),
false,
)
assert.equal(
skyPicApplyStreakActivity(
streakFixture,
streakState,
'self',
'2026-08-18T08:01:00.000Z',
),
false,
)
assert.equal(
skyPicApplyStreakActivity(
streakFixture,
streakState,
'friend',
'2026-08-18T09:00:00.000Z',
),
true,
)
assert.equal(streakFixture.streakCount, 8)
assert.equal(
skyPicApplyStreakActivity(
streakFixture,
streakState,
'friend',
'2026-08-18T09:01:00.000Z',
),
false,
)
skyPicApplyStreakActivity(
streakFixture,
streakState,
'friend',
'2026-08-19T08:00:00.000Z',
)
assert.equal(
skyPicApplyStreakActivity(
streakFixture,
streakState,
'self',
'2026-08-19T09:00:00.000Z',
),
true,
)
assert.equal(streakFixture.streakCount, 9)
skyPicApplyStreakActivity(
streakFixture,
streakState,
'self',
'2026-08-21T08:00:00.000Z',
)
assert.equal(streakFixture.streakCount, 0)
assert.equal(
skyPicApplyStreakActivity(
streakFixture,
streakState,
'friend',
'2026-08-21T09:00:00.000Z',
),
true,
)
assert.deepEqual(streakFixture, { bestStreak: 18, streakCount: 1 })
let bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true)
const friend = bootstrap.friends[0]
const receivedSnap = bootstrap.inbox.find(
@@ -326,6 +404,16 @@ async function verifySkyPicActions(baseUrl) {
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')
const streakConversation = bootstrap.conversations.find(
(conversation) => conversation.friendshipId === friend.friendshipId,
)
assert(streakConversation, 'SkyPic friend was missing its conversation')
assert.equal(typeof friend.streakCount, 'number')
assert.equal(typeof friend.bestStreak, 'number')
assert.equal(streakConversation.streakCount, friend.streakCount)
assert.equal(streakConversation.bestStreak, friend.bestStreak)
const expectedStreakCount = friend.streakCount
const expectedBestStreak = friend.bestStreak
let expectedSnapScore = bootstrap.profile.snapScore
const thread = await expectSuccess(
@@ -616,10 +704,14 @@ async function verifySkyPicActions(baseUrl) {
30 * 24 * 60 * 60_000,
)
expectSkyPicMetadataSafe(sentSnaps, 'SkyPic sent snap response')
assert.equal(sentSnaps[0].streakCount, expectedStreakCount)
assert.equal(sentSnaps[0].bestStreak, expectedBestStreak)
expectedSnapScore += sentSnaps.length
assert.equal(sentSnaps[0].sender.snapScore, expectedSnapScore)
bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true)
assert.equal(bootstrap.profile.snapScore, expectedSnapScore)
assert.equal(bootstrap.friends[0].streakCount, expectedStreakCount)
assert.equal(bootstrap.friends[0].bestStreak, expectedBestStreak)
assert.deepEqual(
await post(baseUrl, 'skypic:send-snap', {
@@ -704,10 +796,20 @@ async function verifySkyPicActions(baseUrl) {
)
assert.equal(sentPhotoBatch.length, 3)
assert(sentPhotoBatch.every((snap) => snap.type === 'snap_photo'))
assert(
sentPhotoBatch.every(
(snap) =>
snap.streakCount === expectedStreakCount &&
snap.bestStreak === expectedBestStreak,
),
'an atomic SkyPic photo batch advanced one friendship more than once per UTC day',
)
assert.equal(new Set(sentPhotoBatch.map((snap) => snap.id)).size, 3)
expectedSnapScore += sentPhotoBatch.length
bootstrap = await expectSuccess(baseUrl, 'skypic:bootstrap', {}, true)
assert.equal(bootstrap.profile.snapScore, expectedSnapScore)
assert.equal(bootstrap.friends[0].streakCount, expectedStreakCount)
assert.equal(bootstrap.friends[0].bestStreak, expectedBestStreak)
const sentMessage = await expectSuccess(
baseUrl,
+19 -3
View File
@@ -342,7 +342,11 @@ end
local function list_friends(profile_id)
local rows = Bridge.Database.Query([[
SELECT friendship.`id` AS `friendship_id`, friendship.`streak_count`, friendship.`best_streak`,
SELECT friendship.`id` AS `friendship_id`,
CASE WHEN friendship.`streak_updated_on` IS NULL
OR friendship.`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN 0 ELSE friendship.`streak_count` END AS `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
@@ -561,7 +565,11 @@ end
local function list_conversations(profile_id)
local rows = Bridge.Database.Query([[
SELECT friendship.`id` AS `friendship_id`, friendship.`streak_count`, friendship.`best_streak`,
SELECT friendship.`id` AS `friendship_id`,
CASE WHEN friendship.`streak_updated_on` IS NULL
OR friendship.`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN 0 ELSE friendship.`streak_count` END AS `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`,
@@ -1617,8 +1625,14 @@ local function load_snap_metadata(message_ids, viewer_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`
sender.`snap_score` AS `sender_snap_score`, avatar.`url` AS `sender_avatar_url`,
CASE WHEN friendship.`streak_updated_on` IS NULL
OR friendship.`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)
THEN 0 ELSE friendship.`streak_count` END AS `streak_count`,
friendship.`best_streak`
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`
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')
@@ -1628,6 +1642,8 @@ local function load_snap_metadata(message_ids, viewer_id)
for _, row in ipairs(rows) do
local snap = safe_snap_from_row(row, viewer_id)
snap.recipientProfileId = row.recipient_profile_id
snap.streakCount = tonumber(row.streak_count) or 0
snap.bestStreak = tonumber(row.best_streak) or 0
snaps_by_id[row.id] = snap
end
local snaps = {}