Compare commits

..

1 Commits

Author SHA1 Message Date
Leon.Schmidt b361922e2a FIX - allow movement outside phone text fields 2026-08-20 15:10:20 +02:00
41 changed files with 73 additions and 18248 deletions
-5
View File
@@ -577,11 +577,6 @@ pnpm install
pnpm dev
```
The browser mock links `demo@ifruit.com` and signs in the seeded SkyPic profile
`@alexm` by default. To exercise SkyPic registration with an empty profile, open
`http://localhost:5174/?testScenario=skypic-onboarding#/apps/skypic` while the
development server is running.
Create a production frontend build with:
```powershell
-125
View File
@@ -45,7 +45,6 @@ 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'
@@ -97,8 +96,6 @@ type AppMessage = {
| FlipTokNotificationData
| PicstagramVerificationData
| PicstagramNotificationData
| SkyPicNotificationData
| SkyPicChangedData
| FeatherNotificationData
| BankingChangedData
| CryptoMarketChangedData
@@ -233,28 +230,6 @@ 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 SkyPicChangedData = {
device?: PhoneNotificationDevicePayload
profileId?: string
reason?: 'account_deleted'
}
type FeatherNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
@@ -323,7 +298,6 @@ const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const picstagram = usePicstagramStore()
const skypic = useSkyPicStore()
const feather = useFeatherStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
@@ -346,7 +320,6 @@ const WHITE_STATUS_BAR_APP_IDS = new Set([
'calculator',
'camera',
'fliptok',
'skypic',
'neon-drop',
'sky-flappy',
'snake',
@@ -531,35 +504,6 @@ function cancelUnlockedPhoneDataLoad(): void {
unlockedServicesIdle = undefined
}
async function refreshSkyPicState(refreshThread = false): Promise<void> {
if (!account.email || !appAuth.isSignedIn('skypic')) {
if (!account.email) {
skypic.resetSession()
} else {
if (!skypic.bootstrapPending) skypic.resetSession()
}
return
}
if (skypic.accountDeletePending) return
const accountEmail = account.email
const imei = phone.device?.imei ?? ''
const loaded = await skypic.bootstrap()
if (
!loaded ||
account.email !== accountEmail ||
(phone.device?.imei ?? '') !== imei ||
!appAuth.isSignedIn('skypic')
) {
return
}
if (!skypic.profile) {
appAuth.signOut('skypic')
skypic.resetSession()
return
}
if (refreshThread) await skypic.refreshActiveThread()
}
function queueCompaniesChange(change: CompanyChangedPayload): void {
if (!pendingCompaniesChange) {
pendingCompaniesChange = { ...change }
@@ -587,7 +531,6 @@ function queueCompaniesChange(change: CompanyChangedPayload): void {
async function bootstrapUnlockedPhoneData(): Promise<void> {
const tasks: Array<() => Promise<unknown> | void> = [
() => refreshSkyPicState(),
() => calls.bootstrap(),
() => messages.loadConversations(),
() => billing.loadOverview(),
@@ -727,19 +670,6 @@ 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
@@ -971,47 +901,6 @@ 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 signedInOnActiveDevice = appAuth.isSignedIn('skypic')
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),
}
}
if (!targetsActiveDevice || signedInOnActiveDevice) {
notifications.show(notification)
}
if (phone.isOpen && targetsActiveDevice && signedInOnActiveDevice) {
void refreshSkyPicState(true)
} else if (
targetsActiveDevice &&
!signedInOnActiveDevice &&
!skypic.bootstrapPending
) {
skypic.resetSession()
}
} else if (event.data?.type === 'skypic:changed' && event.data.data) {
const data = event.data.data as SkyPicChangedData
const targetsActiveDevice =
!data.device || data.device.imei === phone.device?.imei
if (phone.isOpen && targetsActiveDevice) {
void refreshSkyPicState(true)
}
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
@@ -1647,7 +1536,6 @@ watch(
if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
skypic.resetSession()
appStore.cancelPendingInstalls()
activitySuspended.value = false
weather.stop()
@@ -1696,19 +1584,6 @@ 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.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-3
View File
@@ -10,7 +10,6 @@ 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 {
@@ -55,7 +54,6 @@ 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)
@@ -104,7 +102,6 @@ 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(() =>
@@ -1,80 +0,0 @@
import { readFileSync } from 'node:fs'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import AppProfileAuth from './AppProfileAuth.vue'
const source = readFileSync(
new URL('./AppProfileAuth.vue', import.meta.url),
'utf8',
)
async function renderAuth(
mode: 'login' | 'register',
movingModeHighlight = true,
): Promise<string> {
return renderToString(
createSSRApp({
render: () =>
h(AppProfileAuth, {
avatarUrl: null,
body: 'Use the linked account.',
cameraLabel: 'Camera',
email: 'demo@ifruit.com',
emailLabel: 'SkyPic account',
error: '',
eyebrow: 'Your SkyPic account',
galleryLabel: 'Photos',
loginLabel: 'Continue to SkyPic',
loginModeLabel: 'Login',
mode,
movingModeHighlight,
pending: false,
registerLabel: 'Create profile',
registerModeLabel: 'Register',
title: 'Welcome back',
username: mode === 'login' ? 'alexm' : 'newprofile',
usernameLabel: 'Handle',
}),
}),
)
}
describe('AppProfileAuth', () => {
it('uses the rounded Sky UI moving highlight for its mode switch', async () => {
const html = await renderAuth('login')
expect(html).toContain('sky-segmented--strong')
expect(html).toContain('sky-segmented--rounded')
expect(html).toContain('sky-segmented__highlight')
expect(html).toContain('app-profile-auth__mode--moving-highlight')
expect(html).toContain('width:calc(50% - 4px)')
expect(html).toContain('--sky-segmented-indicator-offset:calc(0% + 0px)')
expect(html).toContain('aria-label="Your SkyPic account"')
})
it('moves the highlight and keeps short mode labels separate from actions', async () => {
const html = await renderAuth('register')
expect(html).toContain('--sky-segmented-indicator-offset:calc(100% + 4px)')
expect(html.match(/Login/g)).toHaveLength(1)
expect(html.match(/Register/g)).toHaveLength(1)
expect(html).toContain('Create profile')
expect(html).not.toContain('>Continue to SkyPic</button>')
})
it('keeps the moving highlight opt-in for SkyPic', async () => {
const html = await renderAuth('login', false)
expect(html).not.toContain('sky-segmented--strong')
expect(html).not.toContain('sky-segmented__highlight')
expect(html).toContain('app-profile-auth__mode-button--login')
expect(html).toContain('app-profile-auth__mode-button--active')
expect(source).toContain(':strong="movingModeHighlight"')
expect(source).toContain(
':not(.app-profile-auth__mode--moving-highlight)',
)
})
})
@@ -30,17 +30,14 @@ const props = withDefaults(
eyebrow: string
galleryLabel: string
loginLabel: string
loginModeLabel?: string
maxUsernameLength?: number
minUsernameLength?: number
mode: 'login' | 'register'
movingModeHighlight?: boolean
password?: string
passwordLabel?: string
passwordPlaceholder?: string
pending: boolean
registerLabel: string
registerModeLabel?: string
requirePassword?: boolean
submitEnabled?: boolean
title: string
@@ -65,13 +62,10 @@ const props = withDefaults(
emailAsField: false,
maxUsernameLength: 40,
minUsernameLength: 2,
loginModeLabel: '',
movingModeHighlight: false,
password: '',
passwordLabel: 'Password',
passwordPlaceholder: '',
requirePassword: false,
registerModeLabel: '',
showConfirmPassword: false,
submitEnabled: undefined,
usernameAutocomplete: 'username',
@@ -107,10 +101,6 @@ const canSubmit = computed(() => {
)
)
})
const modeLoginLabel = computed(() => props.loginModeLabel || props.loginLabel)
const modeRegisterLabel = computed(
() => props.registerModeLabel || props.registerLabel,
)
</script>
<template>
@@ -129,15 +119,9 @@ const modeRegisterLabel = computed(
<SkyGlass class="app-profile-auth__card">
<SkySegmented
:active-index="movingModeHighlight ? (mode === 'login' ? 0 : 1) : undefined"
:aria-label="eyebrow"
:item-count="movingModeHighlight ? 2 : undefined"
raised
:rounded="movingModeHighlight"
:strong="movingModeHighlight"
class="app-profile-auth__mode"
:class="{
'app-profile-auth__mode--moving-highlight': movingModeHighlight,
'app-profile-auth__mode--register': mode === 'register',
}"
>
@@ -149,7 +133,7 @@ const modeRegisterLabel = computed(
:active="mode === 'login'"
@click="emit('update:mode', 'login')"
>
{{ modeLoginLabel }}
{{ loginLabel }}
</SkySegmentedButton>
<SkySegmentedButton
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
@@ -159,7 +143,7 @@ const modeRegisterLabel = computed(
:active="mode === 'register'"
@click="emit('update:mode', 'register')"
>
{{ modeRegisterLabel }}
{{ registerLabel }}
</SkySegmentedButton>
</SkySegmented>
@@ -388,24 +372,21 @@ const modeRegisterLabel = computed(
position: relative;
z-index: 1;
margin: 0 0 12px;
}
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(0, 0, 0, 0.16);
}
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
:deep(.app-profile-auth__mode-button) {
.app-profile-auth__mode :deep(.app-profile-auth__mode-button) {
border-radius: 3px;
}
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
.app-profile-auth__mode
:deep(
.app-profile-auth__mode-button--login.app-profile-auth__mode-button--active
) {
border-radius: 10px 3px 3px 10px;
}
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
.app-profile-auth__mode
:deep(
.app-profile-auth__mode-button--register.app-profile-auth__mode-button--active
) {
@@ -704,8 +685,7 @@ const modeRegisterLabel = computed(
padding: 14px;
border-radius: 28px;
}
.app-profile-auth--centered
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
.app-profile-auth--centered .app-profile-auth__mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
-9
View File
@@ -170,14 +170,6 @@ 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([
@@ -196,7 +188,6 @@ describe('app registry', () => {
).toEqual([
'weazel-news',
'picstagram',
'skypic',
'feather',
'fliptok',
'flare',
-15
View File
@@ -75,7 +75,6 @@ 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-v2.jpg'
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'
@@ -192,20 +191,6 @@ 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(
@@ -1,586 +0,0 @@
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',
'delete-account',
'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',
'spotlight-feed',
'publish-spotlight',
'view-spotlight',
'like-spotlight',
'spotlight-comments',
'comment-spotlight',
'delete-spotlight-comment',
'remove-spotlight',
'report-spotlight',
'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'],
spotlights: [
'id',
'profile_id',
'media_id',
'caption',
'overlay_text',
'overlay_color',
'kind',
'ad_headline',
'comments_enabled',
'status',
'expires_at',
'created_at',
'updated_at',
],
spotlight_views: ['spotlight_id', 'viewer_profile_id', 'viewed_at'],
spotlight_likes: ['spotlight_id', 'profile_id', 'created_at'],
spotlight_comments: [
'id',
'spotlight_id',
'profile_id',
'body',
'status',
'created_at',
],
spotlight_reports: [
'spotlight_id',
'reporter_profile_id',
'reason',
'details',
'status',
'created_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', 'spotlights']) {
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('validates dense photo batches before creating their cartesian snap set', () => {
const editors = block(
server,
'local function snap_editor_payloads',
'Bridge.Callbacks.Register("sky_phone:skypic:thread"',
)
expect(config).toContain('MaximumMediaPerSend = 10')
expect(config).toContain('MaximumSnapMessagesPerSend = 40')
expect(editors).toContain('count > limit("MaximumMediaPerSend", 10)')
expect(editors).toContain('if key_count ~= count then')
expect(editors).toContain('seen[media_id]')
expect(editors).toContain('mediaType = "photo"')
expect(server).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)',
)
const sendSnap = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
)
expect(sendSnap).toContain(
'raw_media_count > limit("MaximumMediaPerSend", 10)',
)
expect(sendSnap).toContain(
'message_count > limit("MaximumSnapMessagesPerSend", 40)',
)
expect(sendSnap).toContain('editor = editor')
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
expect(sendSnap).toContain('SELECT COUNT(*) FROM')
expect(sendSnap).toContain(') <> ?')
expect(sendSnap).toContain(
'assertion_params[#assertion_params + 1] = #entries',
)
expect(sendSnap).toContain('message_ids[#message_ids + 1] = entry.id')
expect(sendSnap).toContain(
'local sent = load_snap_metadata(message_ids, profile.profile_id)',
)
expect(mockServer).toContain('function skyPicMediaItems(body)')
expect(mockServer).toContain('submitted.length > 10')
expect(mockServer).toContain('seen.has(mediaId)')
expect(mockServer).toContain('mediaItems.length * recipients.length > 40')
})
it('maintains reciprocal UTC-day streaks and returns reconciled values', () => {
const friendshipMigration = migrationTable('friendships')
const friendshipInstall = installTable('friendships')
const friends = block(
server,
'local function list_friends',
'local function list_requests',
)
const conversations = block(
server,
'local function list_conversations',
'Bridge.Callbacks.Register("sky_phone:skypic:bootstrap"',
)
const metadata = block(
server,
'local function load_snap_metadata',
'local function opened_snap_from_row',
)
const sendSnap = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
)
for (const schema of [friendshipMigration, friendshipInstall]) {
expect(schema).toContain('profile_a_last_snap_on')
expect(schema).toContain('profile_b_last_snap_on')
expect(schema).toContain('streak_updated_on')
expect(schema).toContain('streak_count')
expect(schema).toContain('best_streak')
expect(schema).toContain('idx_sky_phone_skypic_streaks')
}
expect(sendSnap).toContain('SET %s = UTC_DATE()')
expect(sendSnap).toContain(
'SELECT 1 FROM `sky_phone_skypic_messages` message WHERE message.`id` = ?',
)
expect(sendSnap).toContain('friendship.profile_a_id == profile.profile_id')
expect(sendSnap).toContain('`profile_a_last_snap_on` = UTC_DATE()')
expect(sendSnap).toContain('`profile_b_last_snap_on` = UTC_DATE()')
expect(sendSnap).toContain(
'`streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)',
)
expect(sendSnap).toContain(
'`streak_updated_on` IS NULL OR `streak_updated_on` < UTC_DATE()',
)
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
for (const serializer of [friends, conversations, metadata]) {
expect(serializer).toContain(
'`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)',
)
expect(serializer).toContain(
'THEN 0 ELSE friendship.`streak_count` END AS `streak_count`',
)
expect(serializer).toContain('friendship.`best_streak`')
}
expect(metadata).toContain("friendship.`status` = 'accepted'")
expect(metadata).toContain(
'snap.streakCount = tonumber(row.streak_count) or 0',
)
expect(metadata).toContain(
'snap.bestStreak = tonumber(row.best_streak) or 0',
)
expect(server).toContain('SET `streak_count` = 0')
expect(server).toContain(
"WHERE `status` = 'accepted' AND `streak_count` > 0",
)
})
it('keeps browser payload limits and profile creation aligned with production', () => {
expect(config).toContain('CaptionMaxLength = 160')
expect(server).toContain('valid_integer(data.avatarSeed, 1, 2147483647)')
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('const profile = skyPicOnboardingProfile')
expect(mockServer).toContain('suggestions: profile')
})
it('deletes only the confirmed SkyPic account and silently refreshes peers', () => {
const deletion = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:delete-account"',
'Bridge.Callbacks.Register("sky_phone:skypic:update-profile"',
)
expect(deletion).toContain('data.confirmed ~= true')
expect(deletion).toContain('error = "confirmation_required"')
expect(deletion).toContain('Bridge.Database.Transaction({')
expect(deletion).toContain('SET peer.')
expect(deletion).toContain('friend_count')
expect(deletion).toContain(' > 0, peer.')
expect(deletion).toContain('- 1, 0')
expect(deletion).toContain('DELETE FROM')
expect(deletion).toContain('sky_phone_skypic_profiles')
expect(deletion).toContain('sky_phone_skypic_blocks')
expect(deletion).toContain('UNION ALL')
expect(deletion).toContain(
"AND status = 'active'".replace(
'status',
String.fromCharCode(96) + 'status' + String.fromCharCode(96),
),
)
expect(deletion).not.toContain('sky_phone_media')
expect(deletion).toContain('"sky_phone:skypic:changed"')
expect(deletion).not.toContain('"sky_phone:skypic:new"')
expect(mockServer).toContain("if (endpoint === 'skypic:delete-account')")
expect(mockServer).toContain("error: 'confirmation_required'")
})
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).toContain('FROM `sky_phone_skypic_spotlights`')
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).toMatch(
/media_in_use:\s*'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'")
})
})
@@ -1,196 +0,0 @@
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 appAuth = read('./stores/app-auth.ts')
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 phoneServer = read('../../sky_phone/source/server/phone.lua')
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:delete-account',
'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:spotlight-feed',
'skypic:publish-spotlight',
'skypic:view-spotlight',
'skypic:like-spotlight',
'skypic:spotlight-comments',
'skypic:comment-spotlight',
'skypic:delete-spotlight-comment',
'skypic:remove-spotlight',
'skypic:report-spotlight',
'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(client).toContain(
'RegisterNetEvent("sky_phone:skypic:changed", function(data)',
)
expect(client).toContain(
'SendNUIMessage({ type = "skypic:changed", 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(phoneServer).toContain('required_app_auth')
expect(phoneServer).toContain(
'app_auth.accountEmail ~= device.account_email',
)
expect(phoneServer).toContain('has_required_app_session(device)')
expect(server.match(/}, 'skypic'\)/g)).toHaveLength(3)
expect(app).toContain("event.data?.type === 'skypic:new'")
expect(app).toContain("event.data?.type === 'skypic:changed'")
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 refreshSkyPicState(true)')
expect(app).toContain('!targetsActiveDevice || signedInOnActiveDevice')
expect(appIcon).toContain(
"if (props.app.id === 'skypic') return skypic.unreadCount",
)
})
it('scopes badge state to the active unlocked device and account session', () => {
expect(appAuth).toContain("'skypic'")
expect(app).toContain(
"if (!account.email || !appAuth.isSignedIn('skypic'))",
)
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("appAuth.signOut('skypic')")
expect(app).toContain('unlockedServicesLoaded.value = false')
expect(app).toContain(
'if (phone.isOpen && !isLocked.value && !setupRequired.value)',
)
})
it('does not let background refreshes abort auth discovery or account deletion', () => {
const refreshBlock = app
.split('async function refreshSkyPicState(refreshThread = false)')[1]
?.split('function queueCompaniesChange')[0]
expect(refreshBlock).toContain(
'if (!skypic.bootstrapPending) skypic.resetSession()',
)
expect(refreshBlock).toContain('if (skypic.accountDeletePending) return')
expect(app).toContain('!skypic.bootstrapPending')
})
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 * mediaItems.length)',
)
expect(mockServer).toContain('.slice(offset, offset + 30)')
expect(mockServer).toContain(
"response.json({ success: false, error: 'story_unavailable' })",
)
})
})
-18
View File
@@ -37,24 +37,6 @@ describe('app auth store', () => {
})
})
it('persists the SkyPic session independently', () => {
const auth = useAppAuthStore()
auth.hydrate(null, 'demo@ifruit.com')
auth.signIn('skypic', 'demo@ifruit.com')
expect(auth.isSignedIn('skypic')).toBe(true)
expect(auth.isSignedIn('feather')).toBe(false)
expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
accountEmail: 'demo@ifruit.com',
signedIn: ['skypic'],
version: 1,
})
auth.signOut('skypic')
expect(auth.isSignedIn('skypic')).toBe(false)
})
it('does not restore sessions belonging to another iFruit account', () => {
const auth = useAppAuthStore()
auth.hydrate(
-2
View File
@@ -7,7 +7,6 @@ export const APP_AUTH_IDS = [
'local-pages',
'feather',
'crewlink',
'skypic',
] as const
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
@@ -24,7 +23,6 @@ function emptySessions(): Record<AppAuthId, boolean> {
'local-pages': false,
feather: false,
crewlink: false,
skypic: false,
}
}
-236
View File
@@ -209,242 +209,6 @@ 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),
...['title', 'body', 'eyebrow', 'login', 'loggingIn', 'noAccount'].map(
(key) => 'auth.' + 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',
'moreActions',
'attachPhoto',
'takePhoto',
'emoji',
'attachVideo',
'attachmentPreview',
'removeAttachment',
'moveAttachmentEarlier',
'moveAttachmentLater',
'attachmentLimit',
'sendingAttachments',
'photoAttachmentsSent',
'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',
'account',
'logout',
'logoutTitle',
'logoutBody',
'loggingOut',
'deleteAccount',
'deleteAccountTitle',
'deleteAccountBody',
'deletingAccount',
].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',
'confirmation_required',
'too_many_snaps',
'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({
+36 -490
View File
@@ -1643,306 +1643,6 @@ 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, Spotlight videos, 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...',
},
auth: {
eyebrow: 'Your SkyPic account',
title: 'Welcome back',
body: 'Continue with your iFruit account to access your SkyPic profile, snaps, and chats.',
login: 'Continue to SkyPic',
loggingIn: 'Signing in...',
noAccount: 'New to SkyPic? Create an iFruit account to get started.',
},
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',
spotlightTitle: 'New Spotlight',
send: 'Send',
addStory: 'Add to story',
publishSpotlight: 'Publish',
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.',
sponsored: 'Sponsored post',
sponsoredBody: 'Clearly label this Spotlight as advertising.',
adHeadline: 'Ad headline',
adHeadlinePlaceholder: 'What should people discover?',
allowComments: 'Allow comments',
allowCommentsBody: 'People can comment on this Spotlight.',
changeMedia: 'Change photo or video',
sent: 'Snap sent.',
storyPublished: 'Story published.',
spotlightPublished: 'Spotlight published.',
},
tabs: {
camera: 'Camera',
chats: 'Chats',
stories: 'Stories',
friends: 'Friends',
spotlight: 'Spotlight',
},
spotlight: {
title: 'Spotlight',
add: 'Create Spotlight',
empty: 'No Spotlight videos yet',
emptyBody: 'Record the first short video for the SkyPic community.',
videoBy: 'Spotlight video by {name}',
sponsored: 'Sponsored',
viewProfile: 'View creator profile',
like: 'Like',
comments: 'Comments',
views: 'Views',
noComments: 'No comments yet',
commentPlaceholder: 'Write a comment...',
sendComment: 'Send',
commentsDisabled: 'Comments are disabled for this Spotlight.',
deleteComment: 'Delete comment',
delete: 'Delete Spotlight',
deleted: 'Spotlight deleted.',
report: 'Report Spotlight',
reportBody: 'Tell us why this Spotlight should be reviewed.',
submitReport: 'Submit report',
reported: 'Spotlight reported.',
navigation: 'Spotlight navigation',
previous: 'Previous Spotlight',
next: 'Next Spotlight',
reportReasons: {
spam: 'Spam or misleading',
harassment: 'Harassment',
dangerous: 'Dangerous content',
illegal: 'Illegal content',
other: 'Other',
},
},
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',
moreActions: 'More actions',
attachPhoto: 'Attach photos',
takePhoto: 'Take photo',
emoji: 'Emoji',
attachVideo: 'Attach video',
attachmentPreview: 'Selected attachments',
removeAttachment: 'Remove attachment {number}',
moveAttachmentEarlier: 'Move attachment {number} earlier',
moveAttachmentLater: 'Move attachment {number} later',
attachmentLimit: 'You can attach up to {count} photos.',
sendingAttachments: 'Sending photos...',
photoAttachmentsSent: 'Photos sent.',
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.',
account: 'Account',
logout: 'Sign out',
logoutTitle: 'Sign out of SkyPic?',
logoutBody:
'You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.',
loggingOut: 'Signing out...',
deleteAccount: 'Delete SkyPic account',
deleteAccountTitle: 'Delete your SkyPic account?',
deleteAccountBody:
'Your SkyPic profile, friends, snaps, stories, Spotlights, comments, and chats will be permanently deleted. Your iFruit account and Photos library stay available.',
deletingAccount: 'Deleting account...',
},
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.',
spotlight_unavailable: 'This Spotlight is no longer available.',
spotlight_limit_reached: 'Your active Spotlight limit is reached.',
sponsored_limit_reached:
'Your active sponsored Spotlight limit is reached.',
ads_disabled: 'Sponsored Spotlights are disabled.',
invalid_ad_headline: 'Enter an ad headline with 3 to 80 characters.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
comment_not_found: 'This comment is unavailable.',
invalid_report: 'Choose a valid report reason.',
not_authorized: 'You are not allowed to do that.',
confirmation_required:
'Confirm that you want to delete your SkyPic account.',
too_many_snaps: 'Choose fewer photos or recipients.',
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',
@@ -2697,7 +2397,6 @@ 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',
@@ -2733,195 +2432,46 @@ 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',
},
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',
},
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' },
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',
@@ -5008,8 +4558,6 @@ 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.',
@@ -5419,10 +4967,8 @@ 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,7 +36,6 @@ export type BuiltinPhoneAppId =
| 'flare'
| 'fliptok'
| 'picstagram'
| 'skypic'
| 'skyride'
| 'feather'
| 'crewlink'
-265
View File
@@ -1,265 +0,0 @@
import type { MediaType, PhoneMedia } 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
/** Present on successful sends so friendship streak UI can reconcile immediately. */
bestStreak?: number
createdAt: string
direction: SkyPicDirection
durationSeconds: number
expiresAt: string
friendshipId: string
id: string
openedAt: string | null
replayedAt: string | null
sender: SkyPicProfileSummary
/** Present on successful sends so friendship streak UI can reconcile immediately. */
streakCount?: number
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 SkyPicSpotlightReportReason =
| 'dangerous'
| 'harassment'
| 'illegal'
| 'other'
| 'spam'
/** Spotlight is public content, so its media URL is intentionally feed-visible. */
export type SkyPicSpotlight = {
adHeadline: string
author: SkyPicProfileSummary
caption: string
commentCount: number
commentsEnabled: boolean
createdAt: string
expiresAt: string
id: string
isLiked: boolean
isOwner: boolean
isSponsored: boolean
isViewed: boolean
likeCount: number
mimeType: string | null
overlayColor: string
textOverlay: string
url: string
viewCount: number
}
export type SkyPicSpotlightComment = {
author: SkyPicProfileSummary
body: string
createdAt: string
id: string
isOwner: boolean
spotlightId: 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' | 'spotlight' | 'story'
export type SkyPicMediaDraftContext = {
purpose: SkyPicDraftPurpose
recipientIds: string[]
}
export type SkyPicThreadMediaDraftContext = {
body: string
friendshipId: string
pendingMedia: PhoneMedia[]
}
type SkyPicEditorInput = {
caption: string
durationSeconds: number
overlayColor: string
textOverlay: string
}
type SkyPicSingleMediaInput = {
mediaId: number
mediaIds?: never
mediaType: MediaType
}
type SkyPicMultipleMediaInput = {
mediaId?: never
mediaIds: number[]
mediaType?: never
}
export type SkyPicSendSnapInput = SkyPicEditorInput &
(SkyPicSingleMediaInput | SkyPicMultipleMediaInput) & {
allowReplay: boolean
recipientIds: string[]
}
export type SkyPicPublishStoryInput = SkyPicEditorInput & SkyPicSingleMediaInput
export type SkyPicPublishSpotlightInput = SkyPicEditorInput & {
adHeadline: string
commentsEnabled: boolean
isSponsored: boolean
mediaId: number
mediaType: 'video'
}
-1
View File
@@ -15,7 +15,6 @@ 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,7 +158,6 @@ 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,7 +106,6 @@ 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,10 +85,6 @@ 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,7 +118,6 @@ 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 },
@@ -1,445 +0,0 @@
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 five-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' | 'spotlight' | 'stories'",
)
expect(viewSource).toContain("activeTab === 'camera'")
expect(viewSource).toContain("activeTab === 'chats'")
expect(viewSource).toContain("activeTab === 'stories'")
expect(viewSource).toContain("activeTab === 'friends'")
expect(viewSource).toContain("activeTab === 'spotlight'")
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(5)
})
it('keeps every bottom tab and app surface theme-monochrome', () => {
expect(viewSource).toContain('phone.isDarkMode')
expect(viewSource).toContain("'skypic-app--player-dark': phone.isDarkMode")
expect(viewSource).toContain(
"'skypic-app--player-light': !phone.isDarkMode",
)
expect(viewSource).toContain(
'class="sp-tab sp-tab--camera sp-tab--monochrome"',
)
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
4,
)
expect(viewSource).not.toContain('color: var(--sp-camera-blue) !important')
expect(viewSource).toContain('--sp-tab-monochrome: #000000')
expect(viewSource).toContain('--sp-tab-monochrome: #ffffff')
expect(viewSource).toContain('color: var(--sp-tab-monochrome) !important')
const cameraCss = viewSource
.split('.sp-camera-screen {')[1]
?.split('.sp-camera-header')[0]
expect(cameraCss).toBeTruthy()
expect(cameraCss).toContain('background: #000')
expect(cameraCss).not.toContain('255, 91, 189')
expect(viewSource).toContain('rgba(18, 18, 18, 0.92)')
expect(viewSource).not.toContain('var(--sp-cyan)')
})
it('uses the reference-led Sky UI hierarchy with Spotlight and no camera filters', () => {
expect(viewSource.match(/class="sp-camera-header__actions"/g)).toHaveLength(
2,
)
expect(viewSource.match(/class="sp-camera-header__glass"/g)).toHaveLength(3)
expect(viewSource).toContain('class="sp-camera-viewfinder"')
expect(viewSource).toContain('class="sp-camera-dock"')
const cameraMarkup = viewSource
.split('v-if="activeTab === \'camera\'"')[1]
?.split('<template v-else-if="activeTab === \'chats\'">')[0]
expect(cameraMarkup).toContain('<SkyGlass')
expect(cameraMarkup).toContain('class="sp-shutter"')
expect(viewSource).toContain('class="sp-chat-list"')
expect(viewSource).toContain('class="sp-chat-row')
expect(viewSource).toContain('class="sp-story-rail"')
expect(viewSource).toContain('class="sp-story-grid"')
expect(viewSource).toContain('class="sp-discovery-grid"')
expect(viewSource).toContain('width: calc(100% - (var(--sky-space-3) * 2))')
expect(viewSource).toContain('class="sp-bottom-nav"')
expect(viewSource).not.toContain('sp-camera-filter')
expect(viewSource).toContain('class="sp-spotlight-player"')
expect(
viewSource.match(/class="sp-spotlight-header__actions/g),
).toHaveLength(2)
expect(
viewSource.match(/class="sp-spotlight-header__glass"/g),
).toHaveLength(3)
expect(viewSource).toContain(
'height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height))',
)
expect(viewSource).toContain(
'padding: var(--sky-safe-area-top) var(--sky-page-gutter)',
)
expect(viewSource).toContain('min-height: var(--sky-navbar-height)')
expect(viewSource).toContain("t('spotlight.sponsored')")
expect(viewSource).toContain('store.publishSpotlight')
expect(viewSource).not.toContain('href=')
})
it('builds story discovery only from safe metadata until view-story releases media', () => {
const storyFeedBlock = viewSource
.split('<section class="sp-story-feed">')[1]
?.split('</section>')[0]
expect(viewSource).toContain('const friendStoryRail = computed(')
expect(storyFeedBlock).toBeTruthy()
expect(storyFeedBlock).toContain('v-for="story in store.stories"')
expect(storyFeedBlock).toContain('story.author.avatarUrl')
expect(storyFeedBlock).toContain('story.durationSeconds')
expect(storyFeedBlock).not.toContain('story.url')
expect(storyFeedBlock).not.toContain('store.viewedStory.url')
})
it('renders every visible streak as an accessible Flame icon', () => {
expect(viewSource.match(/<Flame/g)?.length).toBeGreaterThanOrEqual(4)
expect(viewSource).toContain("t('profile.streaks') +")
expect(viewSource).toContain('class="sp-navbar-streak"')
expect(viewSource).toContain('class="sp-profile-streak-stat"')
expect(viewSource).not.toContain('🔥')
})
it('keeps existing Snap and Story viewers full-bleed with ordered overlays', () => {
expect(viewSource).toContain(
'class="sp-media-viewer sp-media-viewer--snap"',
)
expect(viewSource).toContain(
'class="sp-media-viewer sp-media-viewer--story"',
)
expect(viewSource).toContain('object-fit: cover')
expect(viewSource).toContain('.sp-media-viewer::before')
expect(viewSource).toContain('.sp-media-viewer::after')
})
it('uses the shared Camera and Gallery media handoff with a bounded draft', () => {
expect(viewSource).toContain('mediaPicker.begin(')
expect(viewSource).toContain("'skypic-draft'")
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('supports a bounded multi-photo thread handoff with preview controls', () => {
expect(viewSource).toContain('const MAX_THREAD_ATTACHMENTS = 10')
expect(viewSource).toContain("'skypic-thread-media'")
expect(viewSource).toContain(
'mediaPicker.consumeMany<SkyPicThreadMediaDraftContext>',
)
expect(viewSource).toContain('pendingThreadMedia')
expect(viewSource).toContain('mediaIds: queuedMedia.map')
expect(viewSource).toContain('removeThreadMedia(media.id)')
expect(viewSource).toContain('moveThreadMedia(index, -1)')
expect(viewSource).toContain('moveThreadMedia(index, 1)')
expect(viewSource).toContain("openThreadMedia('photos', 'photo')")
expect(viewSource).toContain("openThreadMedia('camera', 'photo')")
expect(viewSource).toContain("openThreadMedia('photos', 'video')")
expect(viewSource).toContain('openThreadEmojiPicker')
expect(typeSource).toContain('SkyPicThreadMediaDraftContext')
expect(typeSource).toContain('mediaIds: number[]')
})
it('aligns chat ownership and keeps the story reply action inside the viewer', () => {
expect(viewSource).toContain(
`:type="entry.value.direction === 'sent' ? 'sent' : 'received'"`,
)
const threadLayoutCss = viewSource
.split('.sp-thread :deep(.sky-messages) {')[1]
?.split('.sp-thread :deep(.sky-message__footer)')[0]
expect(threadLayoutCss).toContain('display: flex')
expect(threadLayoutCss).toContain('flex-direction: column')
const sentSnapCss = viewSource
.split('.sp-thread-snap--sent {')[1]
?.split('.sp-thread-snap > span')[0]
expect(sentSnapCss).toContain('align-self: flex-end')
const storyReplyCss = viewSource
.split('.sp-story-reply {')[1]
?.split('.sp-media-viewer__owner-actions button')[0]
expect(storyReplyCss).toContain('width: auto')
expect(storyReplyCss).toContain('right: var(--sky-page-gutter)')
expect(storyReplyCss).toContain('left: var(--sky-page-gutter)')
})
it('uses app-local authentication and exposes safe account controls', () => {
expect(viewSource).toContain('useAppAuthStore()')
expect(viewSource).toContain("appAuth.isSignedIn('skypic')")
expect(viewSource).toContain("appAuth.signIn('skypic', account.email)")
expect(viewSource).toContain("appAuth.signOut('skypic')")
expect(viewSource).toContain('<AppProfileAuth')
expect(viewSource).toContain('moving-mode-highlight')
expect(viewSource).toContain(':login-mode-label=')
expect(viewSource).toContain(':register-mode-label=')
expect(viewSource).toContain('<AccountLogoutDialog')
expect(viewSource).toContain('app-id="skypic"')
expect(viewSource).toContain('deleteSkyPicAccount')
expect(viewSource).toContain('store.resetSession()')
expect(storeSource).toContain("'skypic:delete-account'")
expect(storeSource).toContain('confirmed: true')
})
it('uses the production SkyPic handle rule for login and registration', () => {
expect(viewSource).toContain('isValidSkyPicHandle(authHandle.value)')
expect(viewSource).toContain(
'const handle = normalizeSkyPicHandle(onboarding.handle)',
)
expect(storeSource).toContain('/^[a-z0-9._]+$/')
})
it('reacts to confirmed remote account deletion with a clean register state', () => {
expect(viewSource).toContain('() => store.profileAbsentRevision')
const resetBlock = viewSource
.split('function resetAccountUiState(accountExists: boolean): void {')[1]
?.split('function handleLoggedOut')[0]
expect(resetBlock).toContain(
"authMode.value = accountExists ? 'login' : 'register'",
)
expect(resetBlock).toContain('deleteAccountDialogOpen.value = false')
expect(resetBlock).toContain('profileEditing.value = false')
expect(resetBlock).toContain('pendingThreadMedia.value = []')
expect(resetBlock).toContain('storyViewerSheetOpen.value = false')
expect(resetBlock).toContain("storyViewerSheetStoryId.value = ''")
expect(resetBlock).toContain("storyReply.value = ''")
expect(resetBlock).toContain('storyNavigationRequest += 1')
expect(resetBlock).toContain('clearStoryTimer()')
expect(resetBlock).toContain('store.clearViewedStory()')
expect(resetBlock).toContain("activeTab.value = 'camera'")
})
it('clears private SkyPic state while the local app session is signed out', () => {
const logoutBlock = viewSource
.split('function handleLoggedOut(): void {')[1]
?.split('async function deleteSkyPicAccount')[0]
const bootstrapBlock = viewSource
.split('async function bootstrapApp(): Promise<void> {')[1]
?.split('onMounted(')[0]
expect(logoutBlock).toContain('store.resetSession()')
expect(logoutBlock).not.toContain('bootstrapApp()')
expect(bootstrapBlock).toContain("!appAuth.isSignedIn('skypic')")
expect(bootstrapBlock).toContain('store.resetSession()')
expect(viewSource).toContain(
'hasSkyPicAccount.value = Boolean(store.profile)',
)
})
it('renders local registration guidance without bootstrapping an absent Sky account', () => {
const bootstrapBlock = viewSource
.split('async function bootstrapApp(): Promise<void> {')[1]
?.split('onMounted(')[0]
const noAccountGuard = bootstrapBlock?.indexOf('if (!account.email)') ?? -1
const serverBootstrap = bootstrapBlock?.indexOf(
'const loaded = await store.bootstrap()',
)
expect(noAccountGuard).toBeGreaterThanOrEqual(0)
expect(noAccountGuard).toBeLessThan(serverBootstrap ?? -1)
expect(bootstrapBlock).toContain('resetAccountUiState(false)')
expect(bootstrapBlock).toContain('bootstrapped.value = true')
})
it('keeps profile editing parse-safe and composer inputs canonical', () => {
expect(viewSource).toContain('function toggleProfileEditor(): void')
expect(viewSource.match(/@click="toggleProfileEditor"/g)).toHaveLength(3)
expect(viewSource).toContain('const MAX_CAPTION_LENGTH = 160')
expect(viewSource).toContain(':maxlength="MAX_CAPTION_LENGTH"')
expect(viewSource).toContain('.slice(0, MAX_CAPTION_LENGTH)')
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:delete-account',
'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
File diff suppressed because it is too large Load Diff
-53
View File
@@ -489,59 +489,6 @@ 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,
MaximumMediaPerSend = 10,
MaximumSnapMessagesPerSend = 40,
MaximumFriends = 500,
MaximumPendingRequests = 100,
MaximumActiveStories = 50,
MaximumActiveSpotlights = 20,
MaximumActiveSponsoredSpotlights = 3,
UnopenedSnapLifetimeSeconds = 30 * 24 * 60 * 60,
ReplayWindowSeconds = 5 * 60,
TextAfterReadLifetimeSeconds = 24 * 60 * 60,
StoryLifetimeSeconds = 24 * 60 * 60,
SpotlightLifetimeSeconds = 30 * 24 * 60 * 60,
SponsoredSpotlightLifetimeSeconds = 7 * 24 * 60 * 60,
SpotlightPageSize = 12,
SpotlightCommentPageSize = 50,
SpotlightCommentMaxLength = 500,
SpotlightAdHeadlineMaxLength = 80,
AllowSponsoredSpotlights = true,
SpotlightReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" },
CleanupIntervalSeconds = 45,
ProfileActionsPerMinute = 10,
ReadActionsPerMinute = 120,
SearchActionsPerMinute = 30,
FriendActionsPerMinute = 30,
MessagesPerMinute = 30,
SnapsPerMinute = 20,
SnapRecipientsPerMinute = 120,
OpensPerMinute = 120,
StoriesPerMinute = 6,
StoryViewsPerMinute = 120,
SpotlightsPerMinute = 4,
SpotlightViewsPerMinute = 120,
SpotlightReactionsPerMinute = 120,
SpotlightCommentsPerMinute = 20,
SpotlightReportsPerMinute = 10,
}
Config.Feather = {
PageSize = 20,
TextMaxLength = 360,
+3 -174
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", retry = "Erneut versuchen",
loadMore = "Mehr laden", save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen",
phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen",
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,175 +530,6 @@ 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, Spotlight-Videos 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...",
},
auth = {
eyebrow = "Dein SkyPic-Account", title = "Willkommen zurück",
body = "Fahre mit deinem iFruit-Account fort, um auf dein SkyPic-Profil, deine Snaps und Chats zuzugreifen.",
login = "Weiter zu SkyPic", loggingIn = "Wird angemeldet...",
noAccount = "Neu bei SkyPic? Erstelle einen iFruit-Account, um loszulegen.",
},
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", spotlightTitle = "Neuer Spotlight",
send = "Senden", addStory = "Zur Story hinzufügen", publishSpotlight = "Veröffentlichen",
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.",
sponsored = "Gesponserter Beitrag", sponsoredBody = "Kennzeichnet diesen Spotlight eindeutig als Werbung.",
adHeadline = "Werbeheadline", adHeadlinePlaceholder = "Was sollen andere entdecken?",
allowComments = "Kommentare erlauben", allowCommentsBody = "Andere können diesen Spotlight kommentieren.",
changeMedia = "Foto oder Video ändern", sent = "Snap gesendet.", storyPublished = "Story veröffentlicht.",
spotlightPublished = "Spotlight veröffentlicht.",
},
tabs = { camera = "Kamera", chats = "Chats", stories = "Stories", friends = "Freunde", spotlight = "Spotlight" },
spotlight = {
title = "Spotlight", add = "Spotlight erstellen", empty = "Noch keine Spotlight-Videos",
emptyBody = "Nimm das erste Kurzvideo für die SkyPic-Community auf.",
videoBy = "Spotlight-Video von {name}", sponsored = "Gesponsert", viewProfile = "Creator-Profil ansehen",
like = "Gefällt mir", comments = "Kommentare", views = "Aufrufe", noComments = "Noch keine Kommentare",
commentPlaceholder = "Kommentar schreiben...", sendComment = "Senden", commentsDisabled = "Kommentare sind für diesen Spotlight deaktiviert.",
deleteComment = "Kommentar löschen", delete = "Spotlight löschen", deleted = "Spotlight gelöscht.",
report = "Spotlight melden", reportBody = "Warum soll dieser Spotlight überprüft werden?",
submitReport = "Meldung senden", reported = "Spotlight gemeldet.",
navigation = "Spotlight-Navigation", previous = "Vorheriger Spotlight", next = "Nächster Spotlight",
reportReasons = {
spam = "Spam oder irreführend", harassment = "Belästigung", dangerous = "Gefährlicher Inhalt",
illegal = "Illegaler Inhalt", other = "Sonstiges",
},
},
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", moreActions = "Weitere Aktionen",
attachPhoto = "Fotos anhängen", takePhoto = "Foto aufnehmen", emoji = "Emoji", attachVideo = "Video anhängen",
attachmentPreview = "Ausgewählte Anhänge", removeAttachment = "Anhang {number} entfernen",
moveAttachmentEarlier = "Anhang {number} nach vorne verschieben", moveAttachmentLater = "Anhang {number} nach hinten verschieben",
attachmentLimit = "Du kannst bis zu {count} Fotos anhängen.",
sendingAttachments = "Fotos werden gesendet...", photoAttachmentsSent = "Fotos gesendet.",
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.",
account = "Account", logout = "Abmelden", logoutTitle = "Von SkyPic abmelden?",
logoutBody = "Du wirst nur von SkyPic abgemeldet. Dein Profil, deine Snaps und Chats bleiben verfügbar.",
loggingOut = "Wird abgemeldet...", deleteAccount = "SkyPic-Account löschen",
deleteAccountTitle = "Deinen SkyPic-Account löschen?",
deleteAccountBody = "Dein SkyPic-Profil, deine Freunde, Snaps, Stories, Spotlights, Kommentare und Chats werden dauerhaft gelöscht. Dein iFruit-Account und deine Fotomediathek bleiben verfügbar.",
deletingAccount = "Account wird gelöscht...",
},
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.",
spotlight_unavailable = "Dieser Spotlight ist nicht mehr verfügbar.",
spotlight_limit_reached = "Du hast das Limit aktiver Spotlights erreicht.",
sponsored_limit_reached = "Du hast das Limit aktiver gesponserter Spotlights erreicht.",
ads_disabled = "Gesponserte Spotlights sind deaktiviert.",
invalid_ad_headline = "Gib eine Werbeheadline mit 3 bis 80 Zeichen ein.",
invalid_comment = "Gib einen gültigen Kommentar ein.", comments_disabled = "Kommentare sind deaktiviert.",
comment_not_found = "Dieser Kommentar ist nicht verfügbar.", invalid_report = "Wähle einen gültigen Meldegrund.",
not_authorized = "Du darfst das nicht tun.",
confirmation_required = "Bestätige, dass du deinen SkyPic-Account löschen möchtest.",
too_many_snaps = "Wähle weniger Fotos oder Empfänger.",
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.",
@@ -2049,7 +1880,6 @@ 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.",
@@ -2090,7 +1920,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", skypic = "Private Snaps, Stories und enge Freunde", feather = "Kurze Beiträge und Stadtgespräche",
picstagram = "Foto-Sharing und sozialer Feed", 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",
@@ -2116,7 +1946,6 @@ 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" },
+3 -174
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", retry = "Try Again",
loadMore = "Load More", save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
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,175 +530,6 @@ 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, Spotlight videos, 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...",
},
auth = {
eyebrow = "Your SkyPic account", title = "Welcome back",
body = "Continue with your iFruit account to access your SkyPic profile, snaps, and chats.",
login = "Continue to SkyPic", loggingIn = "Signing in...",
noAccount = "New to SkyPic? Create an iFruit account to get started.",
},
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", spotlightTitle = "New Spotlight",
send = "Send", addStory = "Add to story", publishSpotlight = "Publish",
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.",
sponsored = "Sponsored post", sponsoredBody = "Clearly label this Spotlight as advertising.",
adHeadline = "Ad headline", adHeadlinePlaceholder = "What should people discover?",
allowComments = "Allow comments", allowCommentsBody = "People can comment on this Spotlight.",
changeMedia = "Change photo or video", sent = "Snap sent.", storyPublished = "Story published.",
spotlightPublished = "Spotlight published.",
},
tabs = { camera = "Camera", chats = "Chats", stories = "Stories", friends = "Friends", spotlight = "Spotlight" },
spotlight = {
title = "Spotlight", add = "Create Spotlight", empty = "No Spotlight videos yet",
emptyBody = "Record the first short video for the SkyPic community.",
videoBy = "Spotlight video by {name}", sponsored = "Sponsored", viewProfile = "View creator profile",
like = "Like", comments = "Comments", views = "Views", noComments = "No comments yet",
commentPlaceholder = "Write a comment...", sendComment = "Send", commentsDisabled = "Comments are disabled for this Spotlight.",
deleteComment = "Delete comment", delete = "Delete Spotlight", deleted = "Spotlight deleted.",
report = "Report Spotlight", reportBody = "Tell us why this Spotlight should be reviewed.",
submitReport = "Submit report", reported = "Spotlight reported.",
navigation = "Spotlight navigation", previous = "Previous Spotlight", next = "Next Spotlight",
reportReasons = {
spam = "Spam or misleading", harassment = "Harassment", dangerous = "Dangerous content",
illegal = "Illegal content", other = "Other",
},
},
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", moreActions = "More actions",
attachPhoto = "Attach photos", takePhoto = "Take photo", emoji = "Emoji", attachVideo = "Attach video",
attachmentPreview = "Selected attachments", removeAttachment = "Remove attachment {number}",
moveAttachmentEarlier = "Move attachment {number} earlier", moveAttachmentLater = "Move attachment {number} later",
attachmentLimit = "You can attach up to {count} photos.",
sendingAttachments = "Sending photos...", photoAttachmentsSent = "Photos sent.",
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.",
account = "Account", logout = "Sign out", logoutTitle = "Sign out of SkyPic?",
logoutBody = "You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.",
loggingOut = "Signing out...", deleteAccount = "Delete SkyPic account",
deleteAccountTitle = "Delete your SkyPic account?",
deleteAccountBody = "Your SkyPic profile, friends, snaps, stories, Spotlights, comments, and chats will be permanently deleted. Your iFruit account and Photos library stay available.",
deletingAccount = "Deleting account...",
},
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.",
spotlight_unavailable = "This Spotlight is no longer available.",
spotlight_limit_reached = "Your active Spotlight limit is reached.",
sponsored_limit_reached = "Your active sponsored Spotlight limit is reached.",
ads_disabled = "Sponsored Spotlights are disabled.",
invalid_ad_headline = "Enter an ad headline with 3 to 80 characters.",
invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.",
comment_not_found = "This comment is unavailable.", invalid_report = "Choose a valid report reason.",
not_authorized = "You are not allowed to do that.",
confirmation_required = "Confirm that you want to delete your SkyPic account.",
too_many_snaps = "Choose fewer photos or recipients.",
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.",
@@ -2049,7 +1880,6 @@ 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.",
@@ -2090,7 +1920,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", skypic = "Private snaps, stories and close friends", feather = "Short posts and city conversations",
picstagram = "Photo sharing and social feed", 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",
@@ -2116,7 +1946,6 @@ 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,7 +83,6 @@ 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',
-43
View File
@@ -160,37 +160,6 @@ local server_callbacks = {
"picstagram:report",
"picstagram:admin-reports",
"picstagram:admin-resolve-report",
"skypic:bootstrap",
"skypic:create-profile",
"skypic:delete-account",
"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:spotlight-feed",
"skypic:publish-spotlight",
"skypic:view-spotlight",
"skypic:like-spotlight",
"skypic:spotlight-comments",
"skypic:comment-spotlight",
"skypic:delete-spotlight-comment",
"skypic:remove-spotlight",
"skypic:report-spotlight",
"skypic:thread",
"skypic:send-message",
"skypic:mark-thread",
"skypic:save-message",
"skypic:delete-message",
"feather:bootstrap",
"feather:create-profile",
"feather:update-profile",
@@ -904,18 +873,6 @@ 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:skypic:changed", function(data)
SendNUIMessage({ type = "skypic:changed", 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
-293
View File
@@ -2907,302 +2907,9 @@ 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",
},
{
name = "sky_phone_skypic_spotlights",
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 = "kind", type = "ENUM('organic', 'sponsored') NOT NULL DEFAULT 'organic'" },
{ name = "ad_headline", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "comments_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ 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_spotlight_feed", columns = "(`status`, `created_at`, `id`)" },
{ name = "idx_sky_phone_skypic_spotlight_profile", columns = "(`profile_id`, `status`, `created_at`)" },
{ name = "idx_sky_phone_skypic_spotlight_expiry", columns = "(`status`, `expires_at`)" },
{ name = "idx_sky_phone_skypic_spotlight_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_spotlight_views",
columns = {
{ name = "spotlight_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 = { "spotlight_id", "viewer_profile_id" },
indexes = {
{ name = "idx_sky_phone_skypic_spotlight_viewer", columns = "(`viewer_profile_id`, `viewed_at`)" },
},
foreignKeys = {
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`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",
},
{
name = "sky_phone_skypic_spotlight_likes",
columns = {
{ name = "spotlight_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 = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
},
primaryKey = { "spotlight_id", "profile_id" },
indexes = {
{ name = "idx_sky_phone_skypic_spotlight_liker", columns = "(`profile_id`, `created_at`)" },
},
foreignKeys = {
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
{ column = "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_spotlight_comments",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "spotlight_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 = "body", type = "VARCHAR(500) NOT NULL" },
{ name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" },
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_skypic_spotlight_comments", columns = "(`spotlight_id`, `status`, `created_at`, `id`)" },
{ name = "idx_sky_phone_skypic_spotlight_comment_author", columns = "(`profile_id`, `created_at`)" },
},
foreignKeys = {
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
{ column = "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_spotlight_reports",
columns = {
{ name = "spotlight_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "reporter_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "reason", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "status", type = "ENUM('pending', 'reviewed', 'dismissed') NOT NULL DEFAULT 'pending'" },
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
},
primaryKey = { "spotlight_id", "reporter_profile_id" },
indexes = {
{ name = "idx_sky_phone_skypic_spotlight_report_queue", columns = "(`status`, `created_at`)" },
},
foreignKeys = {
{ column = "spotlight_id", references = "`sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE" },
{ column = "reporter_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,7 +37,6 @@ local valid_apps = {
phone = true,
photos = true,
picstagram = true,
skypic = true,
}
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
+7 -84
View File
@@ -20,16 +20,6 @@ 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
@@ -831,24 +821,6 @@ 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` = ?
UNION ALL
SELECT 1 AS `in_use`
FROM `sky_phone_skypic_spotlights`
WHERE `media_id` = ?
LIMIT 1
]], { media_id, 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 }
@@ -866,74 +838,25 @@ 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
-- All SkyPic media 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 }
)
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/spotlight 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
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` = ?
)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_spotlights`
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)
)
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
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
+2 -30
View File
@@ -910,16 +910,12 @@ function SkyPhone.NotifyAccount(account_id, event_name, data)
end
end
function SkyPhone.NotifyAccountDevices(account_id, event_name, data, required_app_auth)
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, account.`email` AS `account_email`,
settings.`payload` AS `settings`, app_auth.`payload` AS `app_auth`
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
JOIN `sky_phone_accounts` account ON account.`id` = d.`account_id`
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
LEFT JOIN `sky_phone_device_data` app_auth
ON app_auth.`device_imei` = d.`imei` AND app_auth.`namespace` = 'appAuth'
WHERE d.`account_id` = ?
]], { account_id })
local devices = {}
@@ -927,31 +923,7 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data, required_ap
devices[row.imei] = row
end
local function has_required_app_session(device)
if required_app_auth == nil then
return true
end
if type(required_app_auth) ~= 'string' or type(device.app_auth) ~= 'string' then
return false
end
local decoded, app_auth = pcall(json.decode, device.app_auth)
if not decoded or type(app_auth) ~= 'table' or app_auth.version ~= 1
or app_auth.accountEmail ~= device.account_email or type(app_auth.signedIn) ~= 'table'
then
return false
end
for _, app_id in ipairs(app_auth.signedIn) do
if app_id == required_app_auth then
return true
end
end
return false
end
local function notify_device(source, device)
if not has_required_app_session(device) then
return
end
local payload = {}
for key, value in pairs(data) do
payload[key] = value
File diff suppressed because it is too large Load Diff
-1
View File
@@ -66,7 +66,6 @@ local RESERVED_APP_IDS = {
phone = true,
photos = true,
picstagram = true,
skypic = true,
radio = true,
settings = true,
["sky-flappy"] = true,
-194
View File
@@ -1483,197 +1483,3 @@ 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;
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlights` (
`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',
`kind` ENUM('organic','sponsored') NOT NULL DEFAULT 'organic',
`ad_headline` VARCHAR(80) NOT NULL DEFAULT '',
`comments_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`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_spotlight_feed` (`status`,`created_at`,`id`),
KEY `idx_sky_phone_skypic_spotlight_profile` (`profile_id`,`status`,`created_at`),
KEY `idx_sky_phone_skypic_spotlight_expiry` (`status`,`expires_at`),
KEY `idx_sky_phone_skypic_spotlight_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_spotlight_views` (
`spotlight_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 (`spotlight_id`,`viewer_profile_id`),
KEY `idx_sky_phone_skypic_spotlight_viewer` (`viewer_profile_id`,`viewed_at`),
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`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;
CREATE TABLE IF NOT EXISTS `sky_phone_skypic_spotlight_likes` (
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`spotlight_id`,`profile_id`),
KEY `idx_sky_phone_skypic_spotlight_liker` (`profile_id`,`created_at`),
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`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_spotlight_comments` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`body` VARCHAR(500) NOT NULL,
`status` ENUM('visible','removed') NOT NULL DEFAULT 'visible',
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
KEY `idx_sky_phone_skypic_spotlight_comments` (`spotlight_id`,`status`,`created_at`,`id`),
KEY `idx_sky_phone_skypic_spotlight_comment_author` (`profile_id`,`created_at`),
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`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_spotlight_reports` (
`spotlight_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`reporter_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`reason` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`details` VARCHAR(500) NOT NULL DEFAULT '',
`status` ENUM('pending','reviewed','dismissed') NOT NULL DEFAULT 'pending',
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`spotlight_id`,`reporter_profile_id`),
KEY `idx_sky_phone_skypic_spotlight_report_queue` (`status`,`created_at`),
FOREIGN KEY (`spotlight_id`) REFERENCES `sky_phone_skypic_spotlights` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`reporter_profile_id`) REFERENCES `sky_phone_skypic_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;