feat(skypic): add spotlight and polish social flows

This commit is contained in:
Dominik
2026-08-21 23:01:06 +02:00
parent 4b65d17acc
commit 54a358ddd0
19 changed files with 2765 additions and 154 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+1 -1
View File
@@ -75,7 +75,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.webp'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import skyPicIcon from '@/assets/img/app-icons/skypic.png'
import 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'
+44 -1
View File
@@ -54,6 +54,15 @@ const callbacks = [
'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',
@@ -133,6 +142,39 @@ const tables: Record<string, string[]> = {
'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', () => {
@@ -150,7 +192,7 @@ describe('SkyPic backend contracts', () => {
}
}
for (const table of ['messages', 'stories']) {
for (const table of ['messages', 'stories', 'spotlights']) {
expect(migrationTable(table)).toContain('ON DELETE RESTRICT')
expect(installTable(table)).toContain('ON DELETE RESTRICT')
}
@@ -445,6 +487,7 @@ describe('SkyPic backend contracts', () => {
)
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'")
@@ -35,6 +35,15 @@ const callbacks = [
'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',
+55 -2
View File
@@ -1657,7 +1657,7 @@ const defaultLocales: LocaleTree = {
title: 'Welcome to SkyPic',
eyebrow: 'Your private camera network',
heading: 'Share the moment',
body: 'Create a profile for private snaps, close-friend stories, and quick chats.',
body: 'Create a profile for private snaps, close-friend stories, Spotlight videos, and quick chats.',
displayName: 'Display name',
displayNamePlaceholder: 'Your name',
handle: 'Handle',
@@ -1692,8 +1692,10 @@ const defaultLocales: LocaleTree = {
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',
@@ -1708,15 +1710,56 @@ const defaultLocales: LocaleTree = {
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',
@@ -1831,7 +1874,7 @@ const defaultLocales: LocaleTree = {
deleteAccount: 'Delete SkyPic account',
deleteAccountTitle: 'Delete your SkyPic account?',
deleteAccountBody:
'Your SkyPic profile, friends, snaps, stories, and chats will be permanently deleted. Your iFruit account and Photos library stay available.',
'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: {
@@ -1878,6 +1921,16 @@ const defaultLocales: LocaleTree = {
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.',
+266
View File
@@ -12,9 +12,13 @@ import type {
SkyPicOpenedSnap,
SkyPicProfile,
SkyPicProfileSummary,
SkyPicPublishSpotlightInput,
SkyPicPublishStoryInput,
SkyPicSendSnapInput,
SkyPicSnap,
SkyPicSpotlight,
SkyPicSpotlightComment,
SkyPicSpotlightReportReason,
SkyPicStory,
SkyPicStoryViewer,
SkyPicThread,
@@ -27,6 +31,8 @@ const MAX_MESSAGE_CHARACTERS = 2_000
const MAX_SNAP_MEDIA = 10
const MAX_TEXT_OVERLAY_CHARACTERS = 160
const STORY_PAGE_SIZE = 30
const SPOTLIGHT_PAGE_SIZE = 12
const SPOTLIGHT_COMMENT_PAGE_SIZE = 50
export function normalizeSkyPicHandle(value: string): string {
return value.trim().toLowerCase()
@@ -75,6 +81,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
const conversations = ref<SkyPicConversation[]>([])
const inbox = ref<SkyPicSnap[]>([])
const stories = ref<SkyPicStory[]>([])
const spotlights = ref<SkyPicSpotlight[]>([])
const spotlightComments = ref<SkyPicSpotlightComment[]>([])
const suggestions = ref<SkyPicProfileSummary[]>([])
const searchResults = ref<SkyPicProfileSummary[]>([])
const unreadCount = ref(0)
@@ -87,6 +95,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
const storyViewers = ref<SkyPicStoryViewer[]>([])
const storiesHasMore = ref(true)
const storyViewersHasMore = ref(true)
const spotlightsHasMore = ref(true)
const spotlightCommentsHasMore = ref(true)
const loading = ref(false)
const bootstrapPending = ref(false)
@@ -98,6 +108,9 @@ export const useSkyPicStore = defineStore('skypic', () => {
const storyViewing = ref(false)
const storiesLoadingMore = ref(false)
const storyViewersLoadingMore = ref(false)
const spotlightsLoading = ref(false)
const spotlightsLoadingMore = ref(false)
const spotlightCommentsLoading = ref(false)
const error = ref<string | null>(null)
let bootstrapRequest = 0
let bootstrapGeneration = 0
@@ -109,6 +122,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
let sessionVersion = 0
let hydrationRevision = 0
let storyViewersStoryId: string | null = null
let spotlightCommentsSpotlightId: string | null = null
const viewedSpotlightIds = new Set<string>()
let bootstrapInFlight: {
promise: Promise<boolean>
session: number
@@ -207,6 +222,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
storyViewersRequest += 1
threadRequest += 1
storyViewersStoryId = null
spotlightCommentsSpotlightId = null
viewedSpotlightIds.clear()
profile.value = null
blockedProfiles.value = []
friends.value = []
@@ -214,6 +231,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
conversations.value = []
inbox.value = []
stories.value = []
spotlights.value = []
spotlightComments.value = []
suggestions.value = []
searchResults.value = []
unreadCount.value = 0
@@ -225,6 +244,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
storyViewers.value = []
storiesHasMore.value = true
storyViewersHasMore.value = true
spotlightsHasMore.value = true
spotlightCommentsHasMore.value = true
loading.value = false
threadLoading.value = false
searchLoading.value = false
@@ -232,6 +253,9 @@ export const useSkyPicStore = defineStore('skypic', () => {
storyViewing.value = false
storiesLoadingMore.value = false
storyViewersLoadingMore.value = false
spotlightsLoading.value = false
spotlightsLoadingMore.value = false
spotlightCommentsLoading.value = false
error.value = null
}
@@ -799,6 +823,230 @@ export const useSkyPicStore = defineStore('skypic', () => {
return response
}
async function loadSpotlights(): Promise<boolean> {
spotlightsLoading.value = true
const response = await sessionCall<SkyPicSpotlight[]>(
'skypic:spotlight-feed',
{ offset: 0 },
)
spotlightsLoading.value = false
setError(response)
if (!response.success || !response.data) return false
spotlights.value = response.data
spotlightsHasMore.value = response.data.length >= SPOTLIGHT_PAGE_SIZE
return true
}
async function loadMoreSpotlights(): Promise<boolean> {
if (!spotlightsHasMore.value || spotlightsLoadingMore.value) return true
spotlightsLoadingMore.value = true
const response = await sessionCall<SkyPicSpotlight[]>(
'skypic:spotlight-feed',
{ offset: spotlights.value.length },
)
spotlightsLoadingMore.value = false
setError(response)
if (!response.success || !response.data) return false
spotlights.value = uniqueById([...spotlights.value, ...response.data])
spotlightsHasMore.value = response.data.length >= SPOTLIGHT_PAGE_SIZE
return true
}
async function publishSpotlight(
input: SkyPicPublishSpotlightInput,
): Promise<NuiResponse<SkyPicSpotlight>> {
const payload: SkyPicPublishSpotlightInput = {
...input,
adHeadline: Array.from(input.adHeadline.trim()).slice(0, 80).join(''),
caption: input.caption.trim(),
durationSeconds: clampDuration(input.durationSeconds),
mediaType: 'video',
overlayColor: normalizeColor(input.overlayColor),
textOverlay: Array.from(input.textOverlay.trim())
.slice(0, MAX_TEXT_OVERLAY_CHARACTERS)
.join(''),
}
const response = await sessionCall<SkyPicSpotlight>(
'skypic:publish-spotlight',
payload,
)
setError(response)
if (response.success && response.data) {
spotlights.value = uniqueById([response.data, ...spotlights.value])
}
return response
}
async function viewSpotlight(spotlightId: string): Promise<boolean> {
if (viewedSpotlightIds.has(spotlightId)) return true
viewedSpotlightIds.add(spotlightId)
const response = await sessionCall<{ viewCount: number }>(
'skypic:view-spotlight',
{ spotlightId },
)
setError(response)
if (!response.success || !response.data) {
viewedSpotlightIds.delete(spotlightId)
return false
}
spotlights.value = spotlights.value.map((spotlight) =>
spotlight.id === spotlightId
? {
...spotlight,
isViewed: true,
viewCount: Math.max(
0,
Number(response.data?.viewCount) || spotlight.viewCount,
),
}
: spotlight,
)
return true
}
async function likeSpotlight(
spotlightId: string,
active: boolean,
): Promise<boolean> {
const response = await sessionCall<{ active: boolean; likeCount: number }>(
'skypic:like-spotlight',
{ active, spotlightId },
)
setError(response)
if (!response.success || !response.data) return false
spotlights.value = spotlights.value.map((spotlight) =>
spotlight.id === spotlightId
? {
...spotlight,
isLiked: response.data?.active ?? active,
likeCount: Math.max(0, Number(response.data?.likeCount) || 0),
}
: spotlight,
)
return true
}
async function loadSpotlightComments(spotlightId: string): Promise<boolean> {
spotlightCommentsLoading.value = true
const response = await sessionCall<SkyPicSpotlightComment[]>(
'skypic:spotlight-comments',
{ offset: 0, spotlightId },
)
spotlightCommentsLoading.value = false
setError(response)
if (!response.success || !response.data) return false
spotlightCommentsSpotlightId = spotlightId
spotlightComments.value = response.data
spotlightCommentsHasMore.value =
response.data.length >= SPOTLIGHT_COMMENT_PAGE_SIZE
return true
}
async function loadMoreSpotlightComments(
spotlightId: string,
): Promise<boolean> {
if (
spotlightCommentsSpotlightId !== spotlightId ||
!spotlightCommentsHasMore.value ||
spotlightCommentsLoading.value
) {
return true
}
spotlightCommentsLoading.value = true
const response = await sessionCall<SkyPicSpotlightComment[]>(
'skypic:spotlight-comments',
{ offset: spotlightComments.value.length, spotlightId },
)
spotlightCommentsLoading.value = false
setError(response)
if (!response.success || !response.data) return false
spotlightComments.value = uniqueById([
...spotlightComments.value,
...response.data,
])
spotlightCommentsHasMore.value =
response.data.length >= SPOTLIGHT_COMMENT_PAGE_SIZE
return true
}
async function commentSpotlight(
spotlightId: string,
body: string,
): Promise<boolean> {
const response = await sessionCall<SkyPicSpotlightComment>(
'skypic:comment-spotlight',
{ body: Array.from(body.trim()).slice(0, 500).join(''), spotlightId },
)
setError(response)
if (!response.success || !response.data) return false
spotlightCommentsSpotlightId = spotlightId
spotlightComments.value = uniqueById([
response.data,
...spotlightComments.value,
])
spotlights.value = spotlights.value.map((spotlight) =>
spotlight.id === spotlightId
? { ...spotlight, commentCount: spotlight.commentCount + 1 }
: spotlight,
)
return true
}
async function deleteSpotlightComment(commentId: string): Promise<boolean> {
const comment = spotlightComments.value.find(
(item) => item.id === commentId,
)
const response = await sessionCall('skypic:delete-spotlight-comment', {
commentId,
})
setError(response)
if (!response.success) return false
spotlightComments.value = spotlightComments.value.filter(
(item) => item.id !== commentId,
)
if (comment) {
spotlights.value = spotlights.value.map((spotlight) =>
spotlight.id === comment.spotlightId
? {
...spotlight,
commentCount: Math.max(0, spotlight.commentCount - 1),
}
: spotlight,
)
}
return true
}
async function removeSpotlight(spotlightId: string): Promise<boolean> {
const response = await sessionCall('skypic:remove-spotlight', {
spotlightId,
})
setError(response)
if (!response.success) return false
spotlights.value = spotlights.value.filter(
(spotlight) => spotlight.id !== spotlightId,
)
return true
}
async function reportSpotlight(
spotlightId: string,
reason: SkyPicSpotlightReportReason,
details = '',
): Promise<boolean> {
const response = await sessionCall('skypic:report-spotlight', {
details: Array.from(details.trim()).slice(0, 500).join(''),
reason,
spotlightId,
})
setError(response)
if (!response.success) return false
spotlights.value = spotlights.value.filter(
(spotlight) => spotlight.id !== spotlightId,
)
return true
}
async function viewStory(
storyId: string,
): Promise<NuiResponse<SkyPicViewedStory>> {
@@ -1104,15 +1352,21 @@ export const useSkyPicStore = defineStore('skypic', () => {
clearViewedStory,
closeThread,
conversations,
commentSpotlight,
createProfile,
deleteAccount,
deleteMessage,
deleteSpotlightComment,
error,
friends,
hasProfile,
inbox,
incomingRequests,
loadStories,
loadSpotlights,
loadMoreSpotlights,
loadSpotlightComments,
loadMoreSpotlightComments,
loadStoryViewers,
loadMoreStories,
loadMoreStoryViewers,
@@ -1124,10 +1378,13 @@ export const useSkyPicStore = defineStore('skypic', () => {
outgoingRequests,
profile,
profileAbsentRevision,
publishSpotlight,
publishStory,
refreshActiveThread,
removeFriend,
removeSpotlight,
removeStory,
reportSpotlight,
replaySnap,
resetSession,
requests,
@@ -1139,6 +1396,13 @@ export const useSkyPicStore = defineStore('skypic', () => {
sendMessage,
sendSnap,
snapOpening,
spotlights,
spotlightsHasMore,
spotlightsLoading,
spotlightsLoadingMore,
spotlightComments,
spotlightCommentsHasMore,
spotlightCommentsLoading,
stories,
storiesHasMore,
storiesLoadingMore,
@@ -1152,6 +1416,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
threadSnaps,
unreadCount,
updateProfile,
likeSpotlight,
viewSpotlight,
viewedStory,
viewStory,
}
+47 -1
View File
@@ -146,6 +146,44 @@ 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 = {
@@ -178,7 +216,7 @@ export type SkyPicBootstrap = {
unreadCount: number
}
export type SkyPicDraftPurpose = 'snap' | 'story'
export type SkyPicDraftPurpose = 'snap' | 'spotlight' | 'story'
export type SkyPicMediaDraftContext = {
purpose: SkyPicDraftPurpose
@@ -217,3 +255,11 @@ export type SkyPicSendSnapInput = SkyPicEditorInput &
}
export type SkyPicPublishStoryInput = SkyPicEditorInput & SkyPicSingleMediaInput
export type SkyPicPublishSpotlightInput = SkyPicEditorInput & {
adHeadline: string
commentsEnabled: boolean
isSponsored: boolean
mediaId: number
mediaType: 'video'
}
@@ -16,31 +16,34 @@ const typeSource = readFileSync(
)
describe('SkyPic frontend contract', () => {
it('uses Sky UI and exposes the camera-first four-tab shell', () => {
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' | 'stories'",
"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.match(/<SkyTabButton/g)).toHaveLength(4)
expect(viewSource).toContain("activeTab === 'spotlight'")
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(5)
})
it('keeps Camera blue and all other tabs theme-monochrome', () => {
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"')
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
3,
expect(viewSource).toContain(
'class="sp-tab sp-tab--camera sp-tab--monochrome"',
)
expect(viewSource).toContain('color: var(--sp-camera-blue) !important')
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')
@@ -48,22 +51,49 @@ describe('SkyPic frontend contract', () => {
.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 without inventing camera filters or Spotlight', () => {
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).not.toContain('Spotlight')
expect(viewSource).not.toContain('advertisement')
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', () => {
@@ -131,6 +161,27 @@ describe('SkyPic frontend contract', () => {
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')")
File diff suppressed because it is too large Load Diff
+360
View File
@@ -3734,6 +3734,92 @@ const skyPicStoryViewers = new Map([
],
],
])
let skyPicSpotlights = [
{
adHeadline: '',
author: skyPicProfiles[1],
caption: 'Sunset laps through the city.',
commentCount: 2,
commentsEnabled: true,
createdAt: isoTime(-12 * 60_000),
expiresAt: isoTime(29 * 86_400_000),
id: '60000000-0000-4000-8000-000000000001',
isLiked: false,
isOwner: false,
isSponsored: false,
isViewed: false,
likeCount: 128,
mimeType: 'video/mp4',
overlayColor: '#FFFFFF',
textOverlay: 'Los Santos after dark',
url: mockMedia.find((item) => item.id === 2).url,
viewCount: 1432,
},
{
adHeadline: 'Discover the city after sunset',
author: skyPicProfiles[3],
caption: 'Your next night route starts here.',
commentCount: 0,
commentsEnabled: true,
createdAt: isoTime(-35 * 60_000),
expiresAt: isoTime(6 * 86_400_000),
id: '60000000-0000-4000-8000-000000000002',
isLiked: true,
isOwner: false,
isSponsored: true,
isViewed: true,
likeCount: 84,
mimeType: 'video/mp4',
overlayColor: '#F8FAFC',
textOverlay: '',
url: mockMedia.find((item) => item.id === 6).url,
viewCount: 908,
},
{
adHeadline: '',
author: skyPicProfiles[0],
caption: 'My first SkyPic Spotlight.',
commentCount: 0,
commentsEnabled: false,
createdAt: isoTime(-75 * 60_000),
expiresAt: isoTime(29 * 86_400_000),
id: '60000000-0000-4000-8000-000000000003',
isLiked: false,
isOwner: true,
isSponsored: false,
isViewed: true,
likeCount: 7,
mimeType: 'video/mp4',
overlayColor: '#FFFFFF',
textOverlay: 'Night drive',
url: mockMedia.find((item) => item.id === 10).url,
viewCount: 61,
},
]
const skyPicSpotlightComments = new Map([
[
'60000000-0000-4000-8000-000000000001',
[
{
author: skyPicProfiles[2],
body: 'That route looks incredible.',
createdAt: isoTime(-7 * 60_000),
id: '70000000-0000-4000-8000-000000000001',
isOwner: false,
spotlightId: '60000000-0000-4000-8000-000000000001',
},
{
author: skyPicProfiles[0],
body: 'Adding this to my next drive.',
createdAt: isoTime(-4 * 60_000),
id: '70000000-0000-4000-8000-000000000002',
isOwner: true,
spotlightId: '60000000-0000-4000-8000-000000000001',
},
],
],
])
const skyPicReportedSpotlightIds = new Set()
const skyPicMessages = new Map([
[
'20000000-0000-4000-8000-000000000001',
@@ -3814,6 +3900,20 @@ function skyPicStoryView(story) {
}
}
function skyPicSpotlightVisible(spotlight) {
return (
!skyPicReportedSpotlightIds.has(spotlight.id) &&
(spotlight.isOwner || !skyPicBlockedProfileIds.has(spotlight.author.id))
)
}
function skyPicSpotlightView(spotlight) {
return {
...spotlight,
author: skyPicSummary(spotlight.author),
}
}
function skyPicUnreadCount() {
return skyPicConversations.reduce(
(sum, conversation) => sum + Math.max(0, conversation.unreadCount),
@@ -8698,6 +8798,266 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'skypic:spotlight-feed') {
const offset = skyPicOffset(request.body.offset)
if (offset === null) {
response.json({ success: false, error: 'invalid_request' })
return
}
response.json({
success: true,
data: skyPicSpotlights
.filter(skyPicSpotlightVisible)
.slice(offset, offset + 12)
.map(skyPicSpotlightView),
})
return
}
if (endpoint === 'skypic:publish-spotlight') {
if (!skyPicProfile) {
response.json({ success: false, error: 'profile_required' })
return
}
const media = mockMedia.find(
(item) =>
item.id === Number(request.body.mediaId) && item.mediaType === 'video',
)
if (!media || request.body.mediaType !== 'video') {
response.json({ success: false, error: 'invalid_media_type' })
return
}
if (
typeof request.body.isSponsored !== 'boolean' ||
typeof request.body.commentsEnabled !== 'boolean'
) {
response.json({ success: false, error: 'invalid_request' })
return
}
const caption = skyPicCaption(request.body.caption)
const textOverlay = skyPicTextOverlay(request.body.textOverlay)
if (caption === null || textOverlay === null) {
response.json({
success: false,
error: caption === null ? 'invalid_caption' : 'invalid_overlay',
})
return
}
const adHeadline = String(request.body.adHeadline ?? '').trim()
if (
(request.body.isSponsored &&
(adHeadline.length < 3 || [...adHeadline].length > 80)) ||
(!request.body.isSponsored && adHeadline)
) {
response.json({ success: false, error: 'invalid_ad_headline' })
return
}
const ownActive = skyPicSpotlights.filter((item) => item.isOwner)
if (ownActive.length >= 20) {
response.json({ success: false, error: 'spotlight_limit_reached' })
return
}
if (
request.body.isSponsored &&
ownActive.filter((item) => item.isSponsored).length >= 3
) {
response.json({ success: false, error: 'sponsored_limit_reached' })
return
}
const spotlight = {
adHeadline: request.body.isSponsored ? adHeadline : '',
author: skyPicProfiles[0],
caption,
commentCount: 0,
commentsEnabled: request.body.commentsEnabled,
createdAt: new Date().toISOString(),
expiresAt: isoTime((request.body.isSponsored ? 7 : 30) * 86_400_000),
id: randomUUID(),
isLiked: false,
isOwner: true,
isSponsored: request.body.isSponsored,
isViewed: true,
likeCount: 0,
mimeType: 'video/mp4',
overlayColor: /^#[0-9a-f]{6}$/i.test(request.body.overlayColor)
? request.body.overlayColor
: '#ffffff',
textOverlay,
url: media.url,
viewCount: 0,
}
skyPicSpotlights.unshift(spotlight)
skyPicSpotlightComments.set(spotlight.id, [])
response.json({ success: true, data: skyPicSpotlightView(spotlight) })
return
}
if (endpoint === 'skypic:view-spotlight') {
const spotlight = skyPicSpotlights.find(
(item) =>
item.id === String(request.body.spotlightId ?? '') &&
skyPicSpotlightVisible(item),
)
if (!spotlight) {
response.json({ success: false, error: 'spotlight_unavailable' })
return
}
if (!spotlight.isOwner && !spotlight.isViewed) {
spotlight.isViewed = true
spotlight.viewCount += 1
}
response.json({
success: true,
data: { viewCount: spotlight.viewCount },
})
return
}
if (endpoint === 'skypic:like-spotlight') {
const spotlight = skyPicSpotlights.find(
(item) =>
item.id === String(request.body.spotlightId ?? '') &&
skyPicSpotlightVisible(item),
)
if (!spotlight || typeof request.body.active !== 'boolean') {
response.json({ success: false, error: 'spotlight_unavailable' })
return
}
if (spotlight.isLiked !== request.body.active) {
spotlight.isLiked = request.body.active
spotlight.likeCount = Math.max(
0,
spotlight.likeCount + (request.body.active ? 1 : -1),
)
}
response.json({
success: true,
data: {
active: spotlight.isLiked,
likeCount: spotlight.likeCount,
},
})
return
}
if (endpoint === 'skypic:spotlight-comments') {
const spotlightId = String(request.body.spotlightId ?? '')
const offset = skyPicOffset(request.body.offset)
const spotlight = skyPicSpotlights.find(
(item) => item.id === spotlightId && skyPicSpotlightVisible(item),
)
if (!spotlight || offset === null) {
response.json({ success: false, error: 'spotlight_unavailable' })
return
}
response.json({
success: true,
data: (skyPicSpotlightComments.get(spotlightId) ?? [])
.slice(offset, offset + 50)
.map((comment) => ({
...comment,
author: skyPicSummary(comment.author),
isOwner: comment.author.id === skyPicProfile?.id,
})),
})
return
}
if (endpoint === 'skypic:comment-spotlight') {
const spotlightId = String(request.body.spotlightId ?? '')
const spotlight = skyPicSpotlights.find(
(item) => item.id === spotlightId && skyPicSpotlightVisible(item),
)
const body = String(request.body.body ?? '').trim()
if (!spotlight) {
response.json({ success: false, error: 'spotlight_unavailable' })
return
}
if (!spotlight.commentsEnabled) {
response.json({ success: false, error: 'comments_disabled' })
return
}
if (!body || [...body].length > 500) {
response.json({ success: false, error: 'invalid_comment' })
return
}
const comment = {
author: skyPicProfiles[0],
body,
createdAt: new Date().toISOString(),
id: randomUUID(),
isOwner: true,
spotlightId,
}
const comments = skyPicSpotlightComments.get(spotlightId) ?? []
comments.unshift(comment)
skyPicSpotlightComments.set(spotlightId, comments)
spotlight.commentCount += 1
response.json({
success: true,
data: { ...comment, author: skyPicSummary(comment.author) },
})
return
}
if (endpoint === 'skypic:delete-spotlight-comment') {
const commentId = String(request.body.commentId ?? '')
let removed = false
for (const spotlight of skyPicSpotlights) {
const comments = skyPicSpotlightComments.get(spotlight.id) ?? []
const comment = comments.find((item) => item.id === commentId)
if (!comment) continue
if (!spotlight.isOwner && comment.author.id !== skyPicProfile?.id) {
response.json({ success: false, error: 'comment_not_found' })
return
}
skyPicSpotlightComments.set(
spotlight.id,
comments.filter((item) => item.id !== commentId),
)
spotlight.commentCount = Math.max(0, spotlight.commentCount - 1)
removed = true
break
}
response.json(
removed
? { success: true }
: { success: false, error: 'comment_not_found' },
)
return
}
if (endpoint === 'skypic:remove-spotlight') {
const spotlightId = String(request.body.spotlightId ?? '')
const spotlight = skyPicSpotlights.find((item) => item.id === spotlightId)
if (!spotlight || !spotlight.isOwner) {
response.json({ success: false, error: 'spotlight_unavailable' })
return
}
skyPicSpotlights = skyPicSpotlights.filter(
(item) => item.id !== spotlightId,
)
skyPicSpotlightComments.delete(spotlightId)
response.json({ success: true })
return
}
if (endpoint === 'skypic:report-spotlight') {
const spotlightId = String(request.body.spotlightId ?? '')
const spotlight = skyPicSpotlights.find(
(item) => item.id === spotlightId && skyPicSpotlightVisible(item),
)
const reasons = new Set([
'spam',
'harassment',
'dangerous',
'illegal',
'other',
])
if (!spotlight || spotlight.isOwner) {
response.json({ success: false, error: 'spotlight_unavailable' })
return
}
if (!reasons.has(request.body.reason)) {
response.json({ success: false, error: 'invalid_report' })
return
}
skyPicReportedSpotlightIds.add(spotlightId)
response.json({ success: true })
return
}
if (endpoint === 'skypic:thread') {
const friendshipId = String(request.body.friendshipId ?? '')
if (!skyPicFriends.some((friend) => friend.friendshipId === friendshipId)) {
+103
View File
@@ -75,6 +75,7 @@ const browserDataRequests = [
['picstagram:activities', {}],
['skypic:bootstrap', {}],
['skypic:stories', { offset: 0 }],
['skypic:spotlight-feed', { offset: 0 }],
['radio:get', {}],
['skyride:bootstrap', {}],
['skyride:history', {}],
@@ -289,6 +290,23 @@ function verifyBrowserTestData(dataByEndpoint) {
dataByEndpoint.get('skypic:stories'),
'SkyPic stories endpoint',
)
const spotlightFeed = dataByEndpoint.get('skypic:spotlight-feed')
expectItems(spotlightFeed, 'SkyPic Spotlight feed', 3)
assert(
spotlightFeed.every(
(spotlight) =>
spotlight.mimeType === 'video/mp4' &&
typeof spotlight.url === 'string' &&
spotlight.url.startsWith('https://'),
),
'SkyPic Spotlight feed must contain playable HTTPS videos',
)
assert(
spotlightFeed.some(
(spotlight) => spotlight.isSponsored && spotlight.adHeadline,
),
'SkyPic Spotlight feed must expose clearly labeled sponsored metadata',
)
expectItems(dataByEndpoint.get('radio:get').history, 'radio history', 2)
expectItems(dataByEndpoint.get('skyride:history').items, 'SkyRide history', 2)
expectItems(
@@ -917,6 +935,91 @@ async function verifySkyPicActions(baseUrl) {
storyId: publishedStory.id,
})
const spotlightFeed = await expectSuccess(
baseUrl,
'skypic:spotlight-feed',
{ offset: 0 },
true,
)
const communitySpotlight = spotlightFeed.find(
(spotlight) => !spotlight.isOwner,
)
assert(communitySpotlight, 'SkyPic Spotlight community item missing')
const viewedSpotlight = await expectSuccess(
baseUrl,
'skypic:view-spotlight',
{ spotlightId: communitySpotlight.id },
true,
)
assert(viewedSpotlight.viewCount >= communitySpotlight.viewCount)
const likedSpotlight = await expectSuccess(
baseUrl,
'skypic:like-spotlight',
{ active: true, spotlightId: communitySpotlight.id },
true,
)
assert.equal(likedSpotlight.active, true)
const spotlightComment = await expectSuccess(
baseUrl,
'skypic:comment-spotlight',
{
body: 'Stateful Spotlight smoke comment',
spotlightId: communitySpotlight.id,
},
true,
)
const spotlightComments = await expectSuccess(
baseUrl,
'skypic:spotlight-comments',
{ offset: 0, spotlightId: communitySpotlight.id },
true,
)
assert(
spotlightComments.some((comment) => comment.id === spotlightComment.id),
'SkyPic Spotlight comment did not persist',
)
await expectSuccess(baseUrl, 'skypic:delete-spotlight-comment', {
commentId: spotlightComment.id,
})
const sponsoredSpotlight = await expectSuccess(
baseUrl,
'skypic:publish-spotlight',
{
adHeadline: 'Drive the skyline tonight',
caption: 'Clearly labeled browser smoke promotion.',
commentsEnabled: true,
durationSeconds: 8,
isSponsored: true,
mediaId: 2,
mediaType: 'video',
overlayColor: '#ffffff',
textOverlay: 'Sponsored route',
},
true,
)
assert.equal(sponsoredSpotlight.isSponsored, true)
assert.equal(sponsoredSpotlight.adHeadline, 'Drive the skyline tonight')
await expectSuccess(baseUrl, 'skypic:remove-spotlight', {
spotlightId: sponsoredSpotlight.id,
})
await expectSuccess(baseUrl, 'skypic:report-spotlight', {
reason: 'spam',
spotlightId: communitySpotlight.id,
})
const feedAfterReport = await expectSuccess(
baseUrl,
'skypic:spotlight-feed',
{ offset: 0 },
true,
)
assert(
!feedAfterReport.some(
(spotlight) => spotlight.id === communitySpotlight.id,
),
'reported SkyPic Spotlight remained visible',
)
const profileUpdate = {
allowStoryReplies: false,
avatarMediaId: 12,
+15
View File
@@ -510,10 +510,20 @@ Config.SkyPic = {
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,
@@ -525,6 +535,11 @@ Config.SkyPic = {
OpensPerMinute = 120,
StoriesPerMinute = 6,
StoryViewsPerMinute = 120,
SpotlightsPerMinute = 4,
SpotlightViewsPerMinute = 120,
SpotlightReactionsPerMinute = 120,
SpotlightCommentsPerMinute = 20,
SpotlightReportsPerMinute = 10,
}
Config.Feather = {
+31 -4
View File
@@ -541,7 +541,7 @@ Locales["de"] = {
onboarding = {
title = "Willkommen bei SkyPic", eyebrow = "Dein privates Kameranetzwerk",
heading = "Teile den Moment",
body = "Erstelle ein Profil für private Snaps, Stories mit Freunden und schnelle Chats.",
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.",
@@ -562,7 +562,8 @@ Locales["de"] = {
storyHint = "Teile es 24 Stunden lang in deiner Story.",
},
composer = {
snapTitle = "Neuer Snap", storyTitle = "Neue Story", send = "Senden", addStory = "Zur Story hinzufügen",
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",
@@ -570,9 +571,28 @@ Locales["de"] = {
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",
},
},
tabs = { camera = "Kamera", chats = "Chats", stories = "Stories", friends = "Freunde" },
chats = {
title = "Chats", incoming = "Neue Snaps", noSnaps = "Keine ungeöffneten Snaps",
conversations = "Unterhaltungen", noConversations = "Deine Unterhaltungen erscheinen hier.",
@@ -622,7 +642,7 @@ Locales["de"] = {
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 und Chats werden dauerhaft gelöscht. Dein iFruit-Account und deine Fotomediathek bleiben verfügbar.",
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" },
@@ -661,6 +681,13 @@ Locales["de"] = {
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.",
+31 -4
View File
@@ -541,7 +541,7 @@ Locales["en"] = {
onboarding = {
title = "Welcome to SkyPic", eyebrow = "Your private camera network",
heading = "Share the moment",
body = "Create a profile for private snaps, close-friend stories, and quick chats.",
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.",
@@ -562,7 +562,8 @@ Locales["en"] = {
storyHint = "Share it with your story for 24 hours.",
},
composer = {
snapTitle = "New snap", storyTitle = "New story", send = "Send", addStory = "Add to story",
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",
@@ -570,9 +571,28 @@ Locales["en"] = {
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",
},
},
tabs = { camera = "Camera", chats = "Chats", stories = "Stories", friends = "Friends" },
chats = {
title = "Chats", incoming = "New snaps", noSnaps = "No unopened snaps",
conversations = "Conversations", noConversations = "Your conversations will appear here.",
@@ -622,7 +642,7 @@ Locales["en"] = {
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, and chats will be permanently deleted. Your iFruit account and Photos library stay available.",
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" },
@@ -661,6 +681,13 @@ Locales["en"] = {
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.",
+9
View File
@@ -177,6 +177,15 @@ local server_callbacks = {
"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",
+105
View File
@@ -3077,6 +3077,111 @@ local schema = {
},
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)
+12 -3
View File
@@ -840,8 +840,12 @@ local function is_referenced_by_skypic(media_id)
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, media_id, media_id })
return rows[1] ~= nil
end
@@ -862,7 +866,7 @@ local function delete_owned_media(src, owner, media_id)
if row.media_type == "photo" and is_required_flare_profile_photo(media_id) then
return false, "profile_photo_required"
end
-- Both SkyPic foreign keys are RESTRICT. Keep the remote object intact until
-- 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"
@@ -882,7 +886,7 @@ local function delete_owned_media(src, owner, media_id)
end
-- Delete the database parent first and repeat the SkyPic guard inside the
-- same statement. The RESTRICT foreign keys then make this atomic against
-- a concurrent snap/story insert: either that insert wins and this DELETE
-- 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 = {}
@@ -891,6 +895,7 @@ local function delete_owned_media(src, owner, media_id)
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
@@ -902,6 +907,10 @@ local function delete_owned_media(src, owner, media_id)
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
+490
View File
@@ -1,6 +1,15 @@
Bridge.Database.AfterMigration("sky_phone", function()
local settings = Config.SkyPic or {}
local json_null = type(json) == "table" and json.null or nil
local spotlight_report_reasons = {}
for _, reason in ipairs(settings.SpotlightReportReasons or {
"spam", "harassment", "dangerous", "illegal", "other",
}) do
if type(reason) == "string" then
spotlight_report_reasons[reason] = true
end
end
local function nullable(value)
if value == nil then
@@ -563,6 +572,132 @@ local function list_stories(profile_id, offset)
return stories
end
local function spotlight_from_row(row, profile_id)
if not row then
return nil
end
return {
id = row.id,
author = summary_from_row(
row,
row.profile_id == profile_id and "none" or (row.friendship_id and "friends" or "none"),
row.friendship_id
),
url = row.url,
mimeType = nullable(row.mime_type),
caption = row.caption or "",
textOverlay = row.overlay_text or "",
overlayColor = row.overlay_color,
isSponsored = row.kind == "sponsored",
adHeadline = row.ad_headline or "",
commentsEnabled = is_true(row.comments_enabled),
expiresAt = row.expires_at,
createdAt = row.created_at,
isOwner = row.profile_id == profile_id,
isLiked = is_true(row.is_liked),
isViewed = is_true(row.is_viewed),
likeCount = tonumber(row.like_count) or 0,
viewCount = tonumber(row.view_count) or 0,
commentCount = tonumber(row.comment_count) or 0,
}
end
local function list_spotlights(profile_id, offset, spotlight_id)
local exact_id = spotlight_id or ""
local rows = Bridge.Database.Query([[
SELECT spotlight.`id`, spotlight.`profile_id`, spotlight.`caption`,
spotlight.`overlay_text`, spotlight.`overlay_color`, spotlight.`kind`,
spotlight.`ad_headline`, spotlight.`comments_enabled`, spotlight.`expires_at`,
spotlight.`created_at`, media.`url`, media.`mime_type`,
author.`handle`, author.`display_name`, author.`avatar_seed`, author.`snap_score`,
avatar.`url` AS `avatar_url`, friendship.`id` AS `friendship_id`,
EXISTS(
SELECT 1 FROM `sky_phone_skypic_spotlight_likes` spotlight_like
WHERE spotlight_like.`spotlight_id` = spotlight.`id`
AND spotlight_like.`profile_id` = ?
) AS `is_liked`,
EXISTS(
SELECT 1 FROM `sky_phone_skypic_spotlight_views` spotlight_view
WHERE spotlight_view.`spotlight_id` = spotlight.`id`
AND spotlight_view.`viewer_profile_id` = ?
) AS `is_viewed`,
(SELECT COUNT(*) FROM `sky_phone_skypic_spotlight_likes` spotlight_like
WHERE spotlight_like.`spotlight_id` = spotlight.`id`) AS `like_count`,
(SELECT COUNT(*) FROM `sky_phone_skypic_spotlight_views` spotlight_view
WHERE spotlight_view.`spotlight_id` = spotlight.`id`) AS `view_count`,
(SELECT COUNT(*) FROM `sky_phone_skypic_spotlight_comments` comment
WHERE comment.`spotlight_id` = spotlight.`id`
AND comment.`status` = 'visible') AS `comment_count`
FROM `sky_phone_skypic_spotlights` spotlight
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = spotlight.`profile_id` AND author.`status` = 'active'
JOIN `sky_phone_media` media
ON media.`id` = spotlight.`media_id` AND media.`media_type` = 'video'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, spotlight.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, spotlight.`profile_id`)
AND friendship.`status` = 'accepted'
WHERE spotlight.`status` = 'active'
AND spotlight.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = spotlight.`profile_id`)
OR (block.`blocker_profile_id` = spotlight.`profile_id` AND block.`blocked_profile_id` = ?)
)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_spotlight_reports` report
WHERE report.`spotlight_id` = spotlight.`id`
AND report.`reporter_profile_id` = ?
)
AND (? = '' OR spotlight.`id` = ?)
ORDER BY spotlight.`created_at` DESC, spotlight.`id` DESC
LIMIT ? OFFSET ?
]], {
profile_id, profile_id, profile_id, profile_id, profile_id, profile_id, profile_id,
exact_id, exact_id, spotlight_id and 1 or limit("SpotlightPageSize", 12), offset or 0,
})
local spotlights = {}
for _, row in ipairs(rows) do
spotlights[#spotlights + 1] = spotlight_from_row(row, profile_id)
end
return spotlights
end
local function accessible_spotlight(spotlight_id, profile_id)
local rows = Bridge.Database.Query([[
SELECT spotlight.`id`, spotlight.`profile_id`, spotlight.`comments_enabled`
FROM `sky_phone_skypic_spotlights` spotlight
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = spotlight.`profile_id` AND author.`status` = 'active'
WHERE spotlight.`id` = ? AND spotlight.`status` = 'active'
AND spotlight.`expires_at` > CURRENT_TIMESTAMP(6)
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = spotlight.`profile_id`)
OR (block.`blocker_profile_id` = spotlight.`profile_id` AND block.`blocked_profile_id` = ?)
)
LIMIT 1
]], { spotlight_id, profile_id, profile_id })
return rows[1]
end
local function spotlight_comment_from_row(row, profile_id)
return {
id = row.id,
spotlightId = row.spotlight_id,
body = row.body,
createdAt = row.created_at,
isOwner = row.profile_id == profile_id,
author = summary_from_row(
row,
row.profile_id == profile_id and "none"
or friendship_status(profile_id, row.requested_by_id, row.friendship_status),
row.friendship_id
),
}
end
local function list_conversations(profile_id)
local rows = Bridge.Database.Query([[
SELECT friendship.`id` AS `friendship_id`,
@@ -2243,6 +2378,357 @@ Bridge.Callbacks.Register("sky_phone:skypic:remove-story", function(source, data
or { success = false, error = "story_unavailable" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:spotlight-feed", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local offset = type(data) == "table" and (data.offset or 0) or 0
offset = valid_integer(offset, 0, 100000)
if not offset then
return { success = false, error = "invalid_request" }
end
return { success = true, data = list_spotlights(profile.profile_id, offset) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:publish-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight", limit("SpotlightsPerMinute", 4), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local editor, editor_error = editor_payload(source, data, false)
if not editor then
return { success = false, error = editor_error }
end
if editor.mediaType ~= "video" then
return { success = false, error = "invalid_media_type" }
end
if type(data.isSponsored) ~= "boolean" or type(data.commentsEnabled) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local sponsored = data.isSponsored
if sponsored and settings.AllowSponsoredSpotlights ~= true then
return { success = false, error = "ads_disabled" }
end
local headline = trim(data.adHeadline)
if sponsored then
if not valid_text(headline, 3, limit("SpotlightAdHeadlineMaxLength", 80)) then
return { success = false, error = "invalid_ad_headline" }
end
elseif headline ~= "" then
return { success = false, error = "invalid_ad_headline" }
end
local maximum_active = limit("MaximumActiveSpotlights", 20)
local maximum_sponsored = limit("MaximumActiveSponsoredSpotlights", 3)
local spotlight_id = new_id()
local ok = Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_skypic_profiles`
SET `friend_count` = `friend_count`
WHERE `id` = ?
]],
params = { profile.profile_id },
},
{
query = [[
INSERT INTO `sky_phone_skypic_spotlights`
(`id`, `profile_id`, `media_id`, `caption`, `overlay_text`,
`overlay_color`, `kind`, `ad_headline`, `comments_enabled`, `expires_at`)
SELECT ?, profile.`id`, ?, ?, ?, ?, ?, ?, ?,
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL ? SECOND)
FROM `sky_phone_skypic_profiles` profile
WHERE profile.`id` = ? AND profile.`status` = 'active'
AND (
SELECT COUNT(*) FROM `sky_phone_skypic_spotlights`
WHERE `profile_id` = profile.`id` AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
) < ?
AND (? = 'organic' OR (
SELECT COUNT(*) FROM `sky_phone_skypic_spotlights`
WHERE `profile_id` = profile.`id` AND `kind` = 'sponsored'
AND `status` = 'active' AND `expires_at` > CURRENT_TIMESTAMP(6)
) < ?)
]],
params = {
spotlight_id, editor.mediaId, editor.caption, editor.textOverlay, editor.overlayColor,
sponsored and "sponsored" or "organic", headline, data.commentsEnabled and 1 or 0,
sponsored and limit("SponsoredSpotlightLifetimeSeconds", 604800)
or limit("SpotlightLifetimeSeconds", 2592000),
profile.profile_id, maximum_active, sponsored and "sponsored" or "organic",
maximum_sponsored,
},
},
})
if not ok then
return { success = false, error = "request_failed" }
end
local spotlight = list_spotlights(profile.profile_id, 0, spotlight_id)[1]
if not spotlight then
local counts = Bridge.Database.Query([[
SELECT COUNT(*) AS `total`,
SUM(CASE WHEN `kind` = 'sponsored' THEN 1 ELSE 0 END) AS `sponsored`
FROM `sky_phone_skypic_spotlights`
WHERE `profile_id` = ? AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
]], { profile.profile_id })
local total = tonumber(counts[1] and counts[1].total) or 0
local sponsored_count = tonumber(counts[1] and counts[1].sponsored) or 0
if total >= maximum_active then
return { success = false, error = "spotlight_limit_reached" }
end
if sponsored and sponsored_count >= maximum_sponsored then
return { success = false, error = "sponsored_limit_reached" }
end
return { success = false, error = "request_failed" }
end
return { success = true, data = spotlight }
end)
Bridge.Callbacks.Register("sky_phone:skypic:view-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_view", limit("SpotlightViewsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
if not valid_id(spotlight_id) or not accessible_spotlight(spotlight_id, profile.profile_id) then
return { success = false, error = "spotlight_unavailable" }
end
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skypic_spotlight_views`
(`spotlight_id`, `viewer_profile_id`)
SELECT `id`, ? FROM `sky_phone_skypic_spotlights`
WHERE `id` = ? AND `profile_id` <> ? AND `status` = 'active'
AND `expires_at` > CURRENT_TIMESTAMP(6)
]], { profile.profile_id, spotlight_id, profile.profile_id })
local rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_spotlight_views`
WHERE `spotlight_id` = ?
]], { spotlight_id })
return { success = true, data = { viewCount = tonumber(rows[1] and rows[1].count) or 0 } }
end)
Bridge.Callbacks.Register("sky_phone:skypic:like-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_reaction", limit("SpotlightReactionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local active = type(data) == "table" and data.active or nil
if not valid_id(spotlight_id) or type(active) ~= "boolean"
or not accessible_spotlight(spotlight_id, profile.profile_id)
then
return { success = false, error = "spotlight_unavailable" }
end
if active then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_skypic_spotlight_likes`
(`spotlight_id`, `profile_id`) VALUES (?, ?)
]], { spotlight_id, profile.profile_id })
else
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_spotlight_likes`
WHERE `spotlight_id` = ? AND `profile_id` = ?
]], { spotlight_id, profile.profile_id })
end
local rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count` FROM `sky_phone_skypic_spotlight_likes`
WHERE `spotlight_id` = ?
]], { spotlight_id })
return {
success = true,
data = { active = active, likeCount = tonumber(rows[1] and rows[1].count) or 0 },
}
end)
Bridge.Callbacks.Register("sky_phone:skypic:spotlight-comments", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_read", limit("ReadActionsPerMinute", 120), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local offset = type(data) == "table" and (data.offset or 0) or 0
offset = valid_integer(offset, 0, 100000)
if not valid_id(spotlight_id) or not offset
or not accessible_spotlight(spotlight_id, profile.profile_id)
then
return { success = false, error = "spotlight_unavailable" }
end
local rows = Bridge.Database.Query([[
SELECT comment.`id`, comment.`spotlight_id`, comment.`profile_id`, comment.`body`,
comment.`created_at`, author.`handle`, author.`display_name`, author.`avatar_seed`,
author.`snap_score`, avatar.`url` AS `avatar_url`,
friendship.`id` AS `friendship_id`, friendship.`status` AS `friendship_status`,
friendship.`requested_by_id`
FROM `sky_phone_skypic_spotlight_comments` comment
JOIN `sky_phone_skypic_profiles` author
ON author.`id` = comment.`profile_id` AND author.`status` = 'active'
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
LEFT JOIN `sky_phone_skypic_friendships` friendship
ON friendship.`profile_a_id` = LEAST(?, comment.`profile_id`)
AND friendship.`profile_b_id` = GREATEST(?, comment.`profile_id`)
WHERE comment.`spotlight_id` = ? AND comment.`status` = 'visible'
AND NOT EXISTS (
SELECT 1 FROM `sky_phone_skypic_blocks` block
WHERE (block.`blocker_profile_id` = ? AND block.`blocked_profile_id` = comment.`profile_id`)
OR (block.`blocker_profile_id` = comment.`profile_id` AND block.`blocked_profile_id` = ?)
)
ORDER BY comment.`created_at` DESC, comment.`id` DESC
LIMIT ? OFFSET ?
]], {
profile.profile_id, profile.profile_id, spotlight_id,
profile.profile_id, profile.profile_id,
limit("SpotlightCommentPageSize", 50), offset,
})
local comments = {}
for _, row in ipairs(rows) do
comments[#comments + 1] = spotlight_comment_from_row(row, profile.profile_id)
end
return { success = true, data = comments }
end)
Bridge.Callbacks.Register("sky_phone:skypic:comment-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_comment", limit("SpotlightCommentsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local body = type(data) == "table" and trim(data.body) or ""
if not valid_id(spotlight_id)
or not valid_text(body, 1, limit("SpotlightCommentMaxLength", 500))
then
return { success = false, error = "invalid_comment" }
end
local spotlight = accessible_spotlight(spotlight_id, profile.profile_id)
if not spotlight then
return { success = false, error = "spotlight_unavailable" }
end
if not is_true(spotlight.comments_enabled) then
return { success = false, error = "comments_disabled" }
end
local comment_id = new_id()
Bridge.Database.Query([[
INSERT INTO `sky_phone_skypic_spotlight_comments`
(`id`, `spotlight_id`, `profile_id`, `body`)
SELECT ?, spotlight.`id`, ?, ?
FROM `sky_phone_skypic_spotlights` spotlight
WHERE spotlight.`id` = ? AND spotlight.`status` = 'active'
AND spotlight.`comments_enabled` = 1
AND spotlight.`expires_at` > CURRENT_TIMESTAMP(6)
]], { comment_id, profile.profile_id, body, spotlight_id })
local rows = Bridge.Database.Query([[
SELECT comment.`id`, comment.`spotlight_id`, comment.`profile_id`, comment.`body`,
comment.`created_at`, author.`handle`, author.`display_name`, author.`avatar_seed`,
author.`snap_score`, avatar.`url` AS `avatar_url`
FROM `sky_phone_skypic_spotlight_comments` comment
JOIN `sky_phone_skypic_profiles` author ON author.`id` = comment.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id`
WHERE comment.`id` = ? AND comment.`profile_id` = ?
LIMIT 1
]], { comment_id, profile.profile_id })
if not rows[1] then
return { success = false, error = "spotlight_unavailable" }
end
return { success = true, data = spotlight_comment_from_row(rows[1], profile.profile_id) }
end)
Bridge.Callbacks.Register("sky_phone:skypic:delete-spotlight-comment", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_comment", limit("SpotlightCommentsPerMinute", 20), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local comment_id = type(data) == "table" and data.commentId or nil
if not valid_id(comment_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_spotlight_comments` comment
JOIN `sky_phone_skypic_spotlights` spotlight
ON spotlight.`id` = comment.`spotlight_id`
SET comment.`status` = 'removed'
WHERE comment.`id` = ? AND comment.`status` = 'visible'
AND (comment.`profile_id` = ? OR spotlight.`profile_id` = ?)
]], { comment_id, profile.profile_id, profile.profile_id })
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "comment_not_found" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:remove-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight", limit("SpotlightsPerMinute", 4), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
if not valid_id(spotlight_id) then
return { success = false, error = "invalid_request" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_skypic_spotlights` SET `status` = 'removed'
WHERE `id` = ? AND `profile_id` = ? AND `status` = 'active'
]], { spotlight_id, profile.profile_id })
return affected_rows(result) == 1 and { success = true }
or { success = false, error = "spotlight_unavailable" }
end)
Bridge.Callbacks.Register("sky_phone:skypic:report-spotlight", function(source, data)
if not SkyPhone.AllowOperation(source, "skypic_spotlight_report", limit("SpotlightReportsPerMinute", 10), 60) then
return { success = false, error = "rate_limited" }
end
local profile, _, error_response = require_profile(source)
if not profile then
return error_response
end
local spotlight_id = type(data) == "table" and data.spotlightId or nil
local reason = type(data) == "table" and trim(data.reason):lower() or ""
local details = type(data) == "table" and trim(data.details) or ""
if not valid_id(spotlight_id) or not spotlight_report_reasons[reason]
or not valid_text(details, 0, 500)
then
return { success = false, error = "invalid_report" }
end
local spotlight = accessible_spotlight(spotlight_id, profile.profile_id)
if not spotlight then
return { success = false, error = "spotlight_unavailable" }
end
if spotlight.profile_id == profile.profile_id then
return { success = false, error = "not_authorized" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_skypic_spotlight_reports`
(`spotlight_id`, `reporter_profile_id`, `reason`, `details`)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`reason` = VALUES(`reason`), `details` = VALUES(`details`),
`status` = 'pending', `created_at` = CURRENT_TIMESTAMP(6)
]], { spotlight_id, profile.profile_id, reason, details })
return { success = true }
end)
CreateThread(function()
while true do
Wait(limit("CleanupIntervalSeconds", 45) * 1000)
@@ -2259,6 +2745,10 @@ CreateThread(function()
DELETE FROM `sky_phone_skypic_stories`
WHERE `status` = 'removed' OR `expires_at` <= CURRENT_TIMESTAMP(6)
]], {})
Bridge.Database.Query([[
DELETE FROM `sky_phone_skypic_spotlights`
WHERE `status` = 'removed' OR `expires_at` <= CURRENT_TIMESTAMP(6)
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_skypic_friendships`
SET `streak_count` = 0
+70
View File
@@ -1607,3 +1607,73 @@ CREATE TABLE IF NOT EXISTS `sky_phone_skypic_story_views` (
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;