mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a358ddd0 | |||
| 4b65d17acc | |||
| 691ba65559 | |||
| 14d5304b10 | |||
| 47c4d8c1d7 | |||
| 12031a9c2e |
@@ -577,6 +577,11 @@ 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
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import { useFlipTokStore } from '@/stores/fliptok'
|
||||
import { usePicstagramStore } from '@/stores/picstagram'
|
||||
import { useSkyPicStore } from '@/stores/skypic'
|
||||
import { useFeatherStore } from '@/stores/feather'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
@@ -96,6 +97,8 @@ type AppMessage = {
|
||||
| FlipTokNotificationData
|
||||
| PicstagramVerificationData
|
||||
| PicstagramNotificationData
|
||||
| SkyPicNotificationData
|
||||
| SkyPicChangedData
|
||||
| FeatherNotificationData
|
||||
| BankingChangedData
|
||||
| CryptoMarketChangedData
|
||||
@@ -230,6 +233,28 @@ 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
|
||||
@@ -298,6 +323,7 @@ const darkchat = useDarkChatStore()
|
||||
const flare = useFlareStore()
|
||||
const fliptok = useFlipTokStore()
|
||||
const picstagram = usePicstagramStore()
|
||||
const skypic = useSkyPicStore()
|
||||
const feather = useFeatherStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
@@ -320,6 +346,7 @@ const WHITE_STATUS_BAR_APP_IDS = new Set([
|
||||
'calculator',
|
||||
'camera',
|
||||
'fliptok',
|
||||
'skypic',
|
||||
'neon-drop',
|
||||
'sky-flappy',
|
||||
'snake',
|
||||
@@ -504,6 +531,35 @@ 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 }
|
||||
@@ -531,6 +587,7 @@ function queueCompaniesChange(change: CompanyChangedPayload): void {
|
||||
|
||||
async function bootstrapUnlockedPhoneData(): Promise<void> {
|
||||
const tasks: Array<() => Promise<unknown> | void> = [
|
||||
() => refreshSkyPicState(),
|
||||
() => calls.bootstrap(),
|
||||
() => messages.loadConversations(),
|
||||
() => billing.loadOverview(),
|
||||
@@ -670,6 +727,19 @@ function openDevelopmentPayphonePreview(): void {
|
||||
)
|
||||
}
|
||||
|
||||
function skyPicNotificationRoute(data: SkyPicNotificationData): string {
|
||||
const query = new URLSearchParams()
|
||||
query.set(
|
||||
'tab',
|
||||
data.kind === 'friend_request' || data.kind === 'friend_accepted'
|
||||
? 'friends'
|
||||
: 'chats',
|
||||
)
|
||||
if (data.profileId) query.set('profileId', data.profileId)
|
||||
if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId)
|
||||
return `/apps/skypic?${query.toString()}`
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
|
||||
@@ -901,6 +971,47 @@ 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
|
||||
@@ -1536,6 +1647,7 @@ watch(
|
||||
if (!isOpen) {
|
||||
updateTextInputFocus(false)
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
skypic.resetSession()
|
||||
appStore.cancelPendingInstalls()
|
||||
activitySuspended.value = false
|
||||
weather.stop()
|
||||
@@ -1584,6 +1696,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [phone.device?.imei ?? '', account.email] as const,
|
||||
([imei, email], [previousImei, previousEmail]) => {
|
||||
if (imei === previousImei && email === previousEmail) return
|
||||
skypic.resetSession()
|
||||
cancelUnlockedPhoneDataLoad()
|
||||
unlockedServicesLoaded.value = false
|
||||
if (phone.isOpen && !isLocked.value && !setupRequired.value) {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => phone.cameraLandscape,
|
||||
(landscape) => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -10,6 +10,7 @@ import { useBillingStore } from '@/stores/billing'
|
||||
import { useCompaniesStore } from '@/stores/companies'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useSkyPicStore } from '@/stores/skypic'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
@@ -54,6 +55,7 @@ const billing = useBillingStore()
|
||||
const companies = useCompaniesStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const skypic = useSkyPicStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const isDragging = ref(false)
|
||||
@@ -102,6 +104,7 @@ const unreadCount = computed(() => {
|
||||
if (props.app.id === 'darkchat') return darkchat.unreadCount
|
||||
if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0
|
||||
if (props.app.id === 'companies') return companies.unreadCount
|
||||
if (props.app.id === 'skypic') return skypic.unreadCount
|
||||
return 0
|
||||
})
|
||||
const calendarWeekday = computed(() =>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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,14 +30,17 @@ 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
|
||||
@@ -62,10 +65,13 @@ 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',
|
||||
@@ -101,6 +107,10 @@ const canSubmit = computed(() => {
|
||||
)
|
||||
)
|
||||
})
|
||||
const modeLoginLabel = computed(() => props.loginModeLabel || props.loginLabel)
|
||||
const modeRegisterLabel = computed(
|
||||
() => props.registerModeLabel || props.registerLabel,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,9 +129,15 @@ const canSubmit = 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',
|
||||
}"
|
||||
>
|
||||
@@ -133,7 +149,7 @@ const canSubmit = computed(() => {
|
||||
:active="mode === 'login'"
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
{{ modeLoginLabel }}
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
|
||||
@@ -143,7 +159,7 @@ const canSubmit = computed(() => {
|
||||
:active="mode === 'register'"
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
{{ modeRegisterLabel }}
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
|
||||
@@ -372,21 +388,24 @@ const canSubmit = 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 :deep(.app-profile-auth__mode-button) {
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
|
||||
:deep(.app-profile-auth__mode-button) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
.app-profile-auth__mode
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
|
||||
:deep(
|
||||
.app-profile-auth__mode-button--login.app-profile-auth__mode-button--active
|
||||
) {
|
||||
border-radius: 10px 3px 3px 10px;
|
||||
}
|
||||
.app-profile-auth__mode
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
|
||||
:deep(
|
||||
.app-profile-auth__mode-button--register.app-profile-auth__mode-button--active
|
||||
) {
|
||||
@@ -685,7 +704,8 @@ const canSubmit = computed(() => {
|
||||
padding: 14px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.app-profile-auth--centered .app-profile-auth__mode {
|
||||
.app-profile-auth--centered
|
||||
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
|
||||
@@ -170,6 +170,14 @@ describe('app registry', () => {
|
||||
expect(isPhoneAppId('music')).toBe(true)
|
||||
expect(isPhoneAppId('companies')).toBe(true)
|
||||
expect(isPhoneAppId('weazel-news')).toBe(true)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'skypic')).toMatchObject({
|
||||
category: 'social',
|
||||
dockOrder: null,
|
||||
gridOrder: 31,
|
||||
labelKey: 'Apps.skypic.name',
|
||||
route: '/apps/skypic',
|
||||
})
|
||||
expect(isPhoneAppId('skypic')).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
|
||||
).toEqual([
|
||||
@@ -188,6 +196,7 @@ describe('app registry', () => {
|
||||
).toEqual([
|
||||
'weazel-news',
|
||||
'picstagram',
|
||||
'skypic',
|
||||
'feather',
|
||||
'fliptok',
|
||||
'flare',
|
||||
|
||||
@@ -75,6 +75,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
|
||||
import flareIcon from '@/assets/img/app-icons/flare.webp'
|
||||
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
|
||||
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
|
||||
import skyPicIcon from '@/assets/img/app-icons/skypic-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'
|
||||
@@ -191,6 +192,20 @@ export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
labelKey: 'Apps.picstagram.name',
|
||||
route: '/apps/picstagram',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SkyPicApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 31,
|
||||
icon: markRaw(Camera),
|
||||
iconClass: 'app-icon--skypic',
|
||||
iconImage: skyPicIcon,
|
||||
id: 'skypic',
|
||||
labelKey: 'Apps.skypic.name',
|
||||
route: '/apps/skypic',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
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'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
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' })",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,24 @@ 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(
|
||||
|
||||
@@ -7,6 +7,7 @@ export const APP_AUTH_IDS = [
|
||||
'local-pages',
|
||||
'feather',
|
||||
'crewlink',
|
||||
'skypic',
|
||||
] as const
|
||||
|
||||
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
|
||||
@@ -23,6 +24,7 @@ function emptySessions(): Record<AppAuthId, boolean> {
|
||||
'local-pages': false,
|
||||
feather: false,
|
||||
crewlink: false,
|
||||
skypic: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,242 @@ 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({
|
||||
|
||||
+490
-36
@@ -1643,6 +1643,306 @@ 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',
|
||||
@@ -2397,6 +2697,7 @@ const defaultLocales: LocaleTree = {
|
||||
companies: 'Businesses, jobs and services',
|
||||
music: 'Songs, playlists and audio',
|
||||
picstagram: 'Photo sharing and social feed',
|
||||
skypic: 'Private snaps, stories and close friends',
|
||||
feather: 'Short posts and city conversations',
|
||||
fliptok: 'Short videos and trends',
|
||||
flare: 'Social posts and live moments',
|
||||
@@ -2432,46 +2733,195 @@ const defaultLocales: LocaleTree = {
|
||||
'neon-drop': 'Neon block-dropping puzzle',
|
||||
},
|
||||
previews: {
|
||||
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
|
||||
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
|
||||
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
|
||||
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
|
||||
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
|
||||
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
|
||||
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
|
||||
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
|
||||
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
|
||||
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
|
||||
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
|
||||
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
|
||||
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
|
||||
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
|
||||
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
|
||||
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
|
||||
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
|
||||
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
|
||||
house: { first: 'Property access', second: 'Residents', third: 'Management' },
|
||||
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
|
||||
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
|
||||
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
|
||||
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
|
||||
citywarn: {
|
||||
first: 'Live alerts',
|
||||
second: 'Safety zones',
|
||||
third: 'Incident updates',
|
||||
},
|
||||
crypto: {
|
||||
first: 'Synthetic markets',
|
||||
second: 'Portfolio',
|
||||
third: 'Wallet transfers',
|
||||
},
|
||||
health: {
|
||||
first: 'Activity rings',
|
||||
second: 'Medical ID',
|
||||
third: 'Health records',
|
||||
},
|
||||
'weazel-news': {
|
||||
first: 'Top stories',
|
||||
second: 'Local reports',
|
||||
third: 'Breaking news',
|
||||
},
|
||||
companies: {
|
||||
first: 'Business directory',
|
||||
second: 'Job requests',
|
||||
third: 'Services',
|
||||
},
|
||||
music: {
|
||||
first: 'Now playing',
|
||||
second: 'Playlists',
|
||||
third: 'Music library',
|
||||
},
|
||||
picstagram: {
|
||||
first: 'Photo feed',
|
||||
second: 'Stories',
|
||||
third: 'Profiles',
|
||||
},
|
||||
skypic: {
|
||||
first: 'Private snaps',
|
||||
second: 'Friend stories',
|
||||
third: 'Quick chats',
|
||||
},
|
||||
feather: {
|
||||
first: 'Short posts',
|
||||
second: 'Following feed',
|
||||
third: 'Conversations',
|
||||
},
|
||||
fliptok: {
|
||||
first: 'Video feed',
|
||||
second: 'Creator tools',
|
||||
third: 'Trends',
|
||||
},
|
||||
flare: {
|
||||
first: 'Discover people',
|
||||
second: 'Matches',
|
||||
third: 'Live moments',
|
||||
},
|
||||
calendar: {
|
||||
first: 'Upcoming events',
|
||||
second: 'Day planner',
|
||||
third: 'Reminders',
|
||||
},
|
||||
radio: {
|
||||
first: 'Live channels',
|
||||
second: 'Team radio',
|
||||
third: 'Favorites',
|
||||
},
|
||||
'local-pages': {
|
||||
first: 'Local pages',
|
||||
second: 'Reviews',
|
||||
third: 'City discovery',
|
||||
},
|
||||
crewlink: {
|
||||
first: 'Crew roster',
|
||||
second: 'Shared locations',
|
||||
third: 'Coordination',
|
||||
},
|
||||
phone: {
|
||||
first: 'Recent calls',
|
||||
second: 'Contacts',
|
||||
third: 'Voicemail',
|
||||
},
|
||||
messages: {
|
||||
first: 'Conversations',
|
||||
second: 'Media sharing',
|
||||
third: 'Quick replies',
|
||||
},
|
||||
darkchat: {
|
||||
first: 'Private chats',
|
||||
second: 'Secure groups',
|
||||
third: 'Invitations',
|
||||
},
|
||||
garage: {
|
||||
first: 'Vehicle list',
|
||||
second: 'Parking locations',
|
||||
third: 'Valet',
|
||||
},
|
||||
house: {
|
||||
first: 'Property access',
|
||||
second: 'Residents',
|
||||
third: 'Management',
|
||||
},
|
||||
map: {
|
||||
first: 'Live navigation',
|
||||
second: 'Nearby places',
|
||||
third: 'Route guidance',
|
||||
},
|
||||
skyride: {
|
||||
first: 'Ride booking',
|
||||
second: 'Driver tracking',
|
||||
third: 'Trip history',
|
||||
},
|
||||
banking: {
|
||||
first: 'Account balance',
|
||||
second: 'Transfers',
|
||||
third: 'Transactions',
|
||||
},
|
||||
billing: {
|
||||
first: 'Open invoices',
|
||||
second: 'Payment requests',
|
||||
third: 'Payment history',
|
||||
},
|
||||
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
|
||||
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
|
||||
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
|
||||
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
|
||||
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
|
||||
memos: {
|
||||
first: 'Voice recordings',
|
||||
second: 'Playback',
|
||||
third: 'Favorites',
|
||||
},
|
||||
calculator: {
|
||||
first: 'Basic calculation',
|
||||
second: 'Scientific tools',
|
||||
third: 'History',
|
||||
},
|
||||
camera: {
|
||||
first: 'Photo mode',
|
||||
second: 'Video capture',
|
||||
third: 'Zoom controls',
|
||||
},
|
||||
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
|
||||
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
|
||||
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
|
||||
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
|
||||
weather: {
|
||||
first: 'Current weather',
|
||||
second: 'Hourly forecast',
|
||||
third: 'Seven-day outlook',
|
||||
},
|
||||
photos: {
|
||||
first: 'Media library',
|
||||
second: 'Albums',
|
||||
third: 'Shared media',
|
||||
},
|
||||
settings: {
|
||||
first: 'Device controls',
|
||||
second: 'Privacy',
|
||||
third: 'Personalization',
|
||||
},
|
||||
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
|
||||
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
|
||||
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
|
||||
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
|
||||
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
|
||||
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
|
||||
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
|
||||
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
|
||||
memory: {
|
||||
first: 'Matched pairs',
|
||||
second: 'Best time',
|
||||
third: 'Card themes',
|
||||
},
|
||||
'number-merge': {
|
||||
first: 'Highest tile',
|
||||
second: 'Score',
|
||||
third: 'Strategy grid',
|
||||
},
|
||||
minesweeper: {
|
||||
first: 'Mine counter',
|
||||
second: 'Best time',
|
||||
third: 'Difficulty',
|
||||
},
|
||||
'tower-stack': {
|
||||
first: 'Tower height',
|
||||
second: 'Perfect drops',
|
||||
third: 'High score',
|
||||
},
|
||||
'sky-flappy': {
|
||||
first: 'Flight score',
|
||||
second: 'Best run',
|
||||
third: 'Obstacles',
|
||||
},
|
||||
citymarkt: {
|
||||
first: 'Listings',
|
||||
second: 'Categories',
|
||||
third: 'Saved offers',
|
||||
},
|
||||
'neon-drop': {
|
||||
first: 'Lines cleared',
|
||||
second: 'Level',
|
||||
third: 'Neon pieces',
|
||||
},
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
@@ -4558,6 +5008,8 @@ const defaultLocales: LocaleTree = {
|
||||
import_url_not_allowed: 'This link is not from the selected website.',
|
||||
import_url_unavailable: 'The linked media could not be reached.',
|
||||
import_size_unavailable: 'The website did not provide the media size.',
|
||||
media_in_use:
|
||||
'This media is still used by SkyPic and cannot be deleted yet.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
profile_photo_required:
|
||||
'This is the last photo on your Flare profile. Add another profile photo before deleting it.',
|
||||
@@ -4967,8 +5419,10 @@ const defaultLocales: LocaleTree = {
|
||||
pause: 'Pause',
|
||||
phone: 'Phone',
|
||||
phoneStatus: 'Phone status',
|
||||
retry: 'Try Again',
|
||||
reset: 'Reset',
|
||||
loading: 'Loading',
|
||||
loadMore: 'Load More',
|
||||
search: 'Search',
|
||||
save: 'Save',
|
||||
send: 'Send',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'flare'
|
||||
| 'fliptok'
|
||||
| 'picstagram'
|
||||
| 'skypic'
|
||||
| 'skyride'
|
||||
| 'feather'
|
||||
| 'crewlink'
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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'
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const APP_STORE_PREVIEW_STYLES = {
|
||||
companies: { accent: '#4a92ff', surface: '#0c1728' },
|
||||
music: { accent: '#fa3d71', surface: '#240b19' },
|
||||
picstagram: { accent: '#e54cff', surface: '#210b27' },
|
||||
skypic: { accent: '#24c7ff', surface: '#070f2b' },
|
||||
feather: { accent: '#3c9cff', surface: '#091c2d' },
|
||||
fliptok: { accent: '#24f0d2', surface: '#071d1b' },
|
||||
flare: { accent: '#ff567f', surface: '#260d18' },
|
||||
|
||||
@@ -158,6 +158,7 @@ describe('media utilities', () => {
|
||||
expect(mediaErrorKey('profile_photo_required')).toBe(
|
||||
'profile_photo_required',
|
||||
)
|
||||
expect(mediaErrorKey('media_in_use')).toBe('media_in_use')
|
||||
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,6 +106,7 @@ export function mediaErrorKey(error?: string): string {
|
||||
'import_url_not_allowed',
|
||||
'import_url_unavailable',
|
||||
'import_size_unavailable',
|
||||
'media_in_use',
|
||||
'not_found',
|
||||
'operation_in_progress',
|
||||
'owner_changed',
|
||||
|
||||
@@ -85,6 +85,10 @@ describe('preferences', () => {
|
||||
enabled: true,
|
||||
sounds: true,
|
||||
})
|
||||
expect(value.settings.notifications.skypic).toEqual({
|
||||
enabled: true,
|
||||
sounds: true,
|
||||
})
|
||||
expect(value.settings.phoneScale).toBe(110)
|
||||
expect(value.settings.screenBrightness).toBe(64)
|
||||
expect(value.settings.wallpaper).toBe('ember')
|
||||
|
||||
@@ -118,6 +118,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
'weazel-news': { enabled: true, sounds: true },
|
||||
'local-pages': { enabled: true, sounds: true },
|
||||
picstagram: { enabled: true, sounds: true },
|
||||
skypic: { enabled: true, sounds: true },
|
||||
fliptok: { enabled: true, sounds: true },
|
||||
feather: { enabled: true, sounds: true },
|
||||
crewlink: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
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
+1779
-13
File diff suppressed because it is too large
Load Diff
+1004
-3
File diff suppressed because it is too large
Load Diff
@@ -489,6 +489,59 @@ 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,
|
||||
|
||||
@@ -128,8 +128,8 @@ Locales["de"] = {
|
||||
},
|
||||
Common = {
|
||||
add = "Hinzufügen", back = "Zurück", cancel = "Abbrechen", clear = "Leeren", close = "Schließen", continue = "Weiter", delete = "Löschen", done = "Fertig", edit = "Bearbeiten", home = "Home", loading = "Wird geladen", pause = "Pause", use = "Verwenden",
|
||||
phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen",
|
||||
save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen",
|
||||
phone = "Telefon", phoneStatus = "Telefonstatus", reset = "Zurücksetzen", retry = "Erneut versuchen",
|
||||
loadMore = "Mehr laden", save = "Speichern", search = "Suchen", send = "Senden", start = "Starten", stop = "Stoppen",
|
||||
signOut = "Abmelden", signingOut = "Wird abgemeldet...", signOutTitle = "Von {app} abmelden?",
|
||||
signOutBody = "Du wirst nur von {app} abgemeldet. Deine anderen iFruit-Apps bleiben angemeldet.", signOutFailed = "Abmelden fehlgeschlagen. Versuche es erneut.",
|
||||
appAuth = {
|
||||
@@ -530,6 +530,175 @@ 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.",
|
||||
@@ -1880,6 +2049,7 @@ Locales["de"] = {
|
||||
import_source_unavailable = "Diese Website ist vorübergehend nicht verfügbar.",
|
||||
invalid_import_url = "Gib einen gültigen HTTPS-Medienlink ein.", import_url_not_allowed = "Dieser Link stammt nicht von der gewählten Website.",
|
||||
import_url_unavailable = "Die vernetzten Medien konnten nicht erreicht werden.", import_size_unavailable = "Die Website lieferte nicht die Mediengröße.",
|
||||
media_in_use = "Dieses Medium wird noch von SkyPic verwendet und kann noch nicht gelöscht werden.",
|
||||
not_found = "Das Medienobjekt existiert nicht mehr.", profile_photo_required = "Dies ist das letzte Foto in deinem Flare-Profil. Füge vor dem Löschen ein weiteres Profilfoto hinzu.", owner_changed = "Das aktive Telefonkonto hat sich geändert.",
|
||||
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
|
||||
rate_limited = "Zu viele Medienaktionen. Versuch es gleich erneut.", request_failed = "Die Anfrage Fotos ist fehlgeschlagen.",
|
||||
@@ -1920,7 +2090,7 @@ Locales["de"] = {
|
||||
health = "Gesundheit, Aktivität und medizinische Informationen",
|
||||
["weazel-news"] = "Lokale Nachrichten und Stadtgeschichten",
|
||||
companies = "Unternehmen, Arbeitsplätze und Dienstleistungen", music = "Lieder, Wiedergabelisten und Audio",
|
||||
picstagram = "Foto-Sharing und sozialer Feed", feather = "Kurze Beiträge und Stadtgespräche",
|
||||
picstagram = "Foto-Sharing und sozialer Feed", skypic = "Private Snaps, Stories und enge Freunde", feather = "Kurze Beiträge und Stadtgespräche",
|
||||
fliptok = "Videos und Trends", flare = "Soziale Beiträge und Live-Momente",
|
||||
calendar = "Veranstaltungen, Termine und Erinnerungen", radio = "Live-Radio und Team-Kommunikation",
|
||||
["local-pages"] = "Lokale Unternehmen und Community-Seiten", crewlink = "Crews, Mitglieder und Koordination",
|
||||
@@ -1946,6 +2116,7 @@ Locales["de"] = {
|
||||
companies = { first = "Unternehmensverzeichnis", second = "Jobanfragen", third = "Dienstleistungen" },
|
||||
music = { first = "Aktuelle Wiedergabe", second = "Playlists", third = "Musikmediathek" },
|
||||
picstagram = { first = "Foto-Feed", second = "Stories", third = "Profile" },
|
||||
skypic = { first = "Private Snaps", second = "Freundes-Stories", third = "Schnelle Chats" },
|
||||
feather = { first = "Kurzbeiträge", second = "Abonniert-Feed", third = "Unterhaltungen" },
|
||||
fliptok = { first = "Video-Feed", second = "Creator-Werkzeuge", third = "Trends" },
|
||||
flare = { first = "Personen entdecken", second = "Matches", third = "Live-Momente" },
|
||||
|
||||
@@ -128,8 +128,8 @@ Locales["en"] = {
|
||||
},
|
||||
Common = {
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", continue = "Continue", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
|
||||
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset", retry = "Try Again",
|
||||
loadMore = "Load More", save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?",
|
||||
signOutBody = "You will only be signed out of {app}. Your other iFruit apps stay signed in.", signOutFailed = "Could not sign out. Please try again.",
|
||||
appAuth = {
|
||||
@@ -530,6 +530,175 @@ 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.",
|
||||
@@ -1880,6 +2049,7 @@ Locales["en"] = {
|
||||
import_source_unavailable = "This website is temporarily unavailable.",
|
||||
invalid_import_url = "Enter a valid HTTPS media link.", import_url_not_allowed = "This link is not from the selected website.",
|
||||
import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.",
|
||||
media_in_use = "This media is still used by SkyPic and cannot be deleted yet.",
|
||||
not_found = "The media item no longer exists.", profile_photo_required = "This is the last photo on your Flare profile. Add another profile photo before deleting it.", owner_changed = "The active phone account changed.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Photos request failed.",
|
||||
@@ -1920,7 +2090,7 @@ Locales["en"] = {
|
||||
health = "Health, activity and medical information",
|
||||
["weazel-news"] = "Local news and city stories",
|
||||
companies = "Businesses, jobs and services", music = "Songs, playlists and audio",
|
||||
picstagram = "Photo sharing and social feed", feather = "Short posts and city conversations",
|
||||
picstagram = "Photo sharing and social feed", skypic = "Private snaps, stories and close friends", feather = "Short posts and city conversations",
|
||||
fliptok = "Short videos and trends", flare = "Social posts and live moments",
|
||||
calendar = "Events, schedules and reminders", radio = "Live radio and team communication",
|
||||
["local-pages"] = "Local businesses and community pages", crewlink = "Crews, members and coordination",
|
||||
@@ -1946,6 +2116,7 @@ Locales["en"] = {
|
||||
companies = { first = "Business directory", second = "Job requests", third = "Services" },
|
||||
music = { first = "Now playing", second = "Playlists", third = "Music library" },
|
||||
picstagram = { first = "Photo feed", second = "Stories", third = "Profiles" },
|
||||
skypic = { first = "Private snaps", second = "Friend stories", third = "Quick chats" },
|
||||
feather = { first = "Short posts", second = "Following feed", third = "Conversations" },
|
||||
fliptok = { first = "Video feed", second = "Creator tools", third = "Trends" },
|
||||
flare = { first = "Discover people", second = "Matches", third = "Live moments" },
|
||||
|
||||
@@ -83,6 +83,7 @@ server_scripts {
|
||||
'source/server/media_import/fivemanage.lua',
|
||||
'source/server/media_import/manifest.lua',
|
||||
'source/server/media.lua',
|
||||
'source/server/skypic.lua',
|
||||
'source/server/weazel_news.lua',
|
||||
'source/server/citywarn.lua',
|
||||
'source/server/messages.lua',
|
||||
|
||||
@@ -160,6 +160,37 @@ 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",
|
||||
@@ -873,6 +904,18 @@ 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
|
||||
|
||||
@@ -2907,9 +2907,302 @@ 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`
|
||||
|
||||
@@ -37,6 +37,7 @@ local valid_apps = {
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
skypic = true,
|
||||
}
|
||||
local valid_visibilities = { contacts = true, everyone = true, hidden = true }
|
||||
|
||||
|
||||
@@ -20,6 +20,16 @@ local allowed_remote_mimes = {
|
||||
},
|
||||
}
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
end
|
||||
if type(result) == "table" then
|
||||
return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function media_config()
|
||||
return Config.Media.FiveManage
|
||||
end
|
||||
@@ -821,6 +831,24 @@ 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 }
|
||||
@@ -838,25 +866,74 @@ 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 }
|
||||
)
|
||||
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[delete_key] = nil
|
||||
return false, delete_error
|
||||
end
|
||||
delete_remote = (tonumber(references[1] and references[1].count) or 0) <= 1
|
||||
end
|
||||
-- Delete the database parent first and repeat the SkyPic guard inside the
|
||||
-- same statement. The RESTRICT foreign keys then make this atomic against
|
||||
-- a concurrent snap/story/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)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -910,12 +910,16 @@ function SkyPhone.NotifyAccount(account_id, event_name, data)
|
||||
end
|
||||
end
|
||||
|
||||
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
||||
function SkyPhone.NotifyAccountDevices(account_id, event_name, data, required_app_auth)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
|
||||
SELECT d.`imei`, d.`device_name`, account.`email` AS `account_email`,
|
||||
settings.`payload` AS `settings`, app_auth.`payload` AS `app_auth`
|
||||
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 = {}
|
||||
@@ -923,7 +927,31 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
||||
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
@@ -66,6 +66,7 @@ local RESERVED_APP_IDS = {
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
skypic = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
|
||||
@@ -1483,3 +1483,197 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user