mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
feat(skypic): refine layouts and streaks
This commit is contained in:
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)')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)')
|
||||
|
||||
+1267
-446
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user