feat(skypic): add Sky UI photo messaging app

This commit is contained in:
Dominik
2026-08-20 00:38:24 +02:00
parent 3eae2b8d30
commit 12031a9c2e
34 changed files with 11886 additions and 56 deletions
+75
View File
@@ -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<void> {
const tasks: Array<() => Promise<unknown> | 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<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
@@ -889,6 +923,33 @@ function onMessage(event: MessageEvent<AppMessage>): 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) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+3
View File
@@ -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(() =>
+9
View File
@@ -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',
+15
View File
@@ -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<PhoneAppDefinition[]>([
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(
@@ -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<string, string[]> = {
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'")
})
})
@@ -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' })",
)
})
})
+210
View File
@@ -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({
+403 -36
View File
@@ -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',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -36,6 +36,7 @@ export type BuiltinPhoneAppId =
| 'flare'
| 'fliptok'
| 'picstagram'
| 'skypic'
| 'skyride'
| 'feather'
| 'crewlink'
+198
View File
@@ -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'
>
+1
View File
@@ -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' },
+1
View File
@@ -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')
})
})
+1
View File
@@ -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',
+4
View File
@@ -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')
+1
View File
@@ -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 },
@@ -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<Tab>('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(/<SkyTabButton/g)).toHaveLength(4)
})
it('uses the shared Camera and Gallery media handoff with a bounded draft', () => {
expect(viewSource).toContain('mediaPicker.begin(')
expect(viewSource).toContain("'skypic-draft'")
expect(viewSource).toContain('`/apps/skypic?compose=${purpose}`')
expect(viewSource).toContain(
"mediaPicker.consumeMany<SkyPicMediaDraftContext>('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('<SkyScrollArea')
expect(viewSource).toContain('with-tabbar')
expect(viewSource).toContain('var(--sky-touch-target)')
expect(viewSource).toContain(':focus-visible')
expect(viewSource).toContain('@media (prefers-reduced-motion: reduce)')
expect(viewSource).toContain('aria-modal="true"')
})
it('starts viewer timers only after media readiness and closes sheets safely', () => {
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 }',
)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+573 -3
View File
@@ -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',
+36
View File
@@ -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,
+127 -3
View File
@@ -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" },
+127 -3
View File
@@ -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" },
+1
View File
@@ -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',
+29
View File
@@ -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
+188
View File
@@ -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`
+1
View File
@@ -37,6 +37,7 @@ local valid_apps = {
phone = true,
photos = true,
picstagram = true,
skypic = true,
}
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
+75 -7
View File
@@ -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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -66,6 +66,7 @@ local RESERVED_APP_IDS = {
phone = true,
photos = true,
picstagram = true,
skypic = true,
radio = true,
settings = true,
["sky-flappy"] = true,
+124
View File
@@ -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;