diff --git a/frontend/src/assets/img/app-icons/skypic-v2.jpg b/frontend/src/assets/img/app-icons/skypic-v2.jpg new file mode 100644 index 0000000..0f4ff5e Binary files /dev/null and b/frontend/src/assets/img/app-icons/skypic-v2.jpg differ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index ae9ffd9..ccb2e76 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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' diff --git a/frontend/src/skypic.backend.contract.test.ts b/frontend/src/skypic.backend.contract.test.ts index 21cc2ac..d32c289 100644 --- a/frontend/src/skypic.backend.contract.test.ts +++ b/frontend/src/skypic.backend.contract.test.ts @@ -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 = { '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'") diff --git a/frontend/src/skypic.integration.contract.test.ts b/frontend/src/skypic.integration.contract.test.ts index cc7c97f..cb8b69b 100644 --- a/frontend/src/skypic.integration.contract.test.ts +++ b/frontend/src/skypic.integration.contract.test.ts @@ -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', diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index fb99a62..153938c 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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.', diff --git a/frontend/src/stores/skypic.ts b/frontend/src/stores/skypic.ts index d1bf5f0..48c8330 100644 --- a/frontend/src/stores/skypic.ts +++ b/frontend/src/stores/skypic.ts @@ -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([]) const inbox = ref([]) const stories = ref([]) + const spotlights = ref([]) + const spotlightComments = ref([]) const suggestions = ref([]) const searchResults = ref([]) const unreadCount = ref(0) @@ -87,6 +95,8 @@ export const useSkyPicStore = defineStore('skypic', () => { const storyViewers = ref([]) 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(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() let bootstrapInFlight: { promise: Promise 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 { + spotlightsLoading.value = true + const response = await sessionCall( + '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 { + if (!spotlightsHasMore.value || spotlightsLoadingMore.value) return true + spotlightsLoadingMore.value = true + const response = await sessionCall( + '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> { + 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( + '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 { + 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 { + 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 { + spotlightCommentsLoading.value = true + const response = await sessionCall( + '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 { + if ( + spotlightCommentsSpotlightId !== spotlightId || + !spotlightCommentsHasMore.value || + spotlightCommentsLoading.value + ) { + return true + } + spotlightCommentsLoading.value = true + const response = await sessionCall( + '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 { + const response = await sessionCall( + '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 { + 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 { + 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 { + 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> { @@ -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, } diff --git a/frontend/src/types/skypic.ts b/frontend/src/types/skypic.ts index 74aa2db..853b816 100644 --- a/frontend/src/types/skypic.ts +++ b/frontend/src/types/skypic.ts @@ -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' +} diff --git a/frontend/src/views/apps/SkyPicApp.contract.test.ts b/frontend/src/views/apps/SkyPicApp.contract.test.ts index 4f43339..f1139e5 100644 --- a/frontend/src/views/apps/SkyPicApp.contract.test.ts +++ b/frontend/src/views/apps/SkyPicApp.contract.test.ts @@ -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('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(/ { + 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(' + @@ -3364,6 +3833,150 @@ onBeforeUnmount(() => { + +
+
+ {{ t('spotlight.comments') }} + +
+
+
+ + + + + + @{{ comment.author.handle }} +

{{ comment.body }}

+ {{ relativeTime(comment.createdAt) }} +
+ +
+ + + {{ + store.spotlightCommentsLoading + ? phone.t('Common.loading') + : phone.t('Common.loadMore') + }} + +
+
+ + + + +

+ {{ t('spotlight.commentsDisabled') }} +

+
+
+ + +
+
+ + {{ t('spotlight.report') }} + {{ t('spotlight.reportBody') }} + + +
+
+ +
+ + +
+
+ span { - display: grid; -} - -.sp-camera-header small { - color: rgba(255, 255, 255, 0.62); - font-size: 10px; - font-weight: 800; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.sp-camera-header h1 { - margin: 2px 0 0; - font-size: 24px; - line-height: 1; + min-height: var(--sky-navbar-height); } .sp-camera-screen :deep(.sky-segmented) { @@ -3890,7 +4474,7 @@ input:focus-visible { width: 72px; height: 72px; border: 3px solid white; - background: rgba(255, 255, 255, 0.18); + background: var(--sky-glass, rgba(50, 50, 50, 0.5)); } .sp-shutter > span { @@ -3898,12 +4482,10 @@ input:focus-visible { width: 58px; height: 58px; place-items: center; + border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 50%; - background: linear-gradient( - 145deg, - var(--sp-camera-blue), - var(--sp-camera-blue-strong) - ); + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.16); } .sp-content-navbar :deep(.sky-navbar__inner) { @@ -3926,21 +4508,16 @@ input:focus-visible { .sp-camera-screen { position: relative; isolation: isolate; - padding: calc(var(--sky-safe-area-top) + 8px) var(--sky-page-gutter) + padding: var(--sky-safe-area-top) var(--sky-page-gutter) calc(var(--sky-safe-area-bottom) + 82px); gap: 8px; background: radial-gradient( - circle at 12% 4%, - rgba(44, 157, 255, 0.42), - transparent 34% + circle at 50% 0%, + rgba(255, 255, 255, 0.06), + transparent 36% ), - radial-gradient( - circle at 88% 64%, - rgba(5, 105, 210, 0.36), - transparent 40% - ), - linear-gradient(160deg, #06182d 0%, #0b2748 48%, #04111f 100%); + linear-gradient(180deg, #0b0b0b 0%, #000 100%); } .sp-camera-screen::before { @@ -3958,24 +4535,34 @@ input:focus-visible { .sp-camera-header { position: relative; z-index: 3; - display: grid; - grid-template-columns: var(--sky-touch-target) 1fr var(--sky-touch-target); + display: flex; + justify-content: space-between; + gap: var(--sky-space-3); +} + +.sp-camera-header__actions { + display: flex; + align-items: center; gap: var(--sky-space-2); } -.sp-camera-header__title { - justify-items: center; - text-align: center; -} - -.sp-camera-header h1 { - font-size: 21px; - letter-spacing: -0.02em; +.sp-camera-header__glass { + display: grid; + width: var(--sky-touch-target); + height: var(--sky-touch-target); + padding: 0; + overflow: hidden; + place-items: center; + border-color: rgba(255, 255, 255, 0.2); + border-radius: 50%; + background: var(--sky-glass, rgba(50, 50, 50, 0.5)); + color: #fff; + backdrop-filter: blur(18px) saturate(1.18); } .sp-camera-profile { border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(5, 16, 30, 0.42); + background: rgba(20, 20, 20, 0.68); } .sp-camera-viewfinder { @@ -3989,10 +4576,10 @@ input:focus-visible { background: radial-gradient( circle at 50% 32%, - rgba(49, 161, 255, 0.22), - transparent 27% + rgba(255, 255, 255, 0.05), + transparent 28% ), - linear-gradient(180deg, rgba(12, 33, 57, 0.78), rgba(3, 12, 24, 0.96)); + linear-gradient(180deg, rgba(18, 18, 18, 0.92), rgba(0, 0, 0, 0.98)); box-shadow: inset 0 1px rgba(255, 255, 255, 0.1), 0 26px 60px rgba(0, 7, 19, 0.34); @@ -4030,7 +4617,7 @@ input:focus-visible { padding: 6px 9px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: var(--sky-radius-pill); - background: rgba(3, 12, 24, 0.34); + background: rgba(0, 0, 0, 0.52); color: rgba(255, 255, 255, 0.76); font-size: 9px; font-weight: 800; @@ -4112,7 +4699,7 @@ input:focus-visible { .sp-camera-dock :deep(.sky-segmented) { width: min(228px, 76%); border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(4, 14, 28, 0.5); + background: rgba(18, 18, 18, 0.76); } .sp-camera-actions { @@ -4132,7 +4719,8 @@ input:focus-visible { width: 78px; height: 78px; border-width: 4px; - background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.9); + background: var(--sky-glass, rgba(50, 50, 50, 0.5)); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); } @@ -4294,7 +4882,8 @@ input:focus-visible { } .sp-thread :deep(.sky-messages) { - display: grid; + display: flex; + flex-direction: column; gap: 8px; } @@ -4321,6 +4910,7 @@ input:focus-visible { .sp-thread-snap { display: flex; align-items: center; + align-self: flex-start; gap: 8px; width: min(78%, 260px); min-height: 58px; @@ -4333,7 +4923,7 @@ input:focus-visible { } .sp-thread-snap--sent { - justify-self: end; + align-self: flex-end; border-color: rgba(90, 108, 255, 0.34); border-radius: 18px 18px 6px 18px; background: var(--sky-app-accent-soft); @@ -4891,10 +5481,6 @@ input:focus-visible { gap: var(--sky-space-2); } -.sp-discovery :deep(.sky-searchbar) { - margin: var(--sky-space-3); -} - .sp-request-actions { display: flex; gap: 4px; @@ -4988,6 +5574,9 @@ input:focus-visible { } .sp-discovery :deep(.sky-searchbar) { + box-sizing: border-box; + width: calc(100% - (var(--sky-space-3) * 2)); + min-width: 0; margin: var(--sky-space-3) var(--sky-space-3) var(--sky-space-2); } @@ -5075,11 +5664,344 @@ input:focus-visible { .sp-bottom-nav--camera :deep(.sky-tabbar__pane) { border-color: rgba(255, 255, 255, 0.13); - background: rgba(3, 12, 24, 0.7); - box-shadow: 0 14px 38px rgba(0, 5, 15, 0.36); + background: rgba(18, 18, 18, 0.78); + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.38); backdrop-filter: blur(22px) saturate(1.18); } +.sp-spotlight-screen { + position: relative; + width: 100%; + min-height: 100%; + overflow: hidden; + background: #020308; + color: #fff; +} + +.sp-spotlight-header { + position: absolute; + z-index: 8; + top: 0; + right: 0; + left: 0; + box-sizing: border-box; + height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height)); + display: grid; + align-items: center; + gap: var(--sky-space-2); + padding: var(--sky-safe-area-top) var(--sky-page-gutter) 0; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.sp-spotlight-header > strong { + font-size: 19px; + text-align: center; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.48); +} + +.sp-spotlight-header__actions { + display: flex; + align-items: center; + gap: var(--sky-space-2); +} + +.sp-spotlight-header__actions--right { + justify-content: flex-end; +} + +.sp-spotlight-header__glass { + display: grid; + width: var(--sky-touch-target); + height: var(--sky-touch-target); + padding: 0; + overflow: hidden; + place-items: center; + border-color: rgba(255, 255, 255, 0.2); + border-radius: 50%; + background: var(--sky-glass, rgba(50, 50, 50, 0.5)); + color: #fff; + backdrop-filter: blur(18px) saturate(1.18); +} + +.sp-spotlight-loading, +.sp-spotlight-empty { + position: absolute; + inset: 0; + display: grid; + place-content: center; + padding: 90px var(--sky-space-5) 120px; + text-align: center; +} + +.sp-spotlight-player, +.sp-spotlight-player > video, +.sp-spotlight-shade { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.sp-spotlight-player { + overflow: hidden; +} + +.sp-spotlight-player > video { + background: #020308; + object-fit: cover; +} + +.sp-spotlight-shade { + pointer-events: none; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.4), transparent 24%), + linear-gradient(0deg, rgba(0, 0, 0, 0.78), transparent 48%); +} + +.sp-spotlight-overlay { + position: absolute; + z-index: 2; + top: 43%; + right: 76px; + left: 18px; + font-size: clamp(22px, 7vw, 34px); + line-height: 1.08; + overflow-wrap: anywhere; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8); +} + +.sp-spotlight-copy { + position: absolute; + z-index: 3; + right: 78px; + bottom: 118px; + left: 17px; + display: grid; + justify-items: start; + gap: 8px; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.76); +} + +.sp-sponsored-label { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 27px; + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: var(--sky-radius-pill); + background: rgba(9, 12, 22, 0.58); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + backdrop-filter: blur(14px); +} + +.sp-spotlight-author { + display: flex; + align-items: center; + gap: 9px; + min-height: var(--sky-touch-target); + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.sp-spotlight-author > span:last-child { + display: grid; + gap: 1px; +} + +.sp-spotlight-author small { + color: rgba(255, 255, 255, 0.78); + font-size: 10px; +} + +.sp-spotlight-copy h2, +.sp-spotlight-copy p { + margin: 0; + overflow-wrap: anywhere; +} + +.sp-spotlight-copy h2 { + font-size: 19px; + line-height: 1.15; +} + +.sp-spotlight-copy p { + font-size: 13px; + line-height: 1.42; +} + +.sp-spotlight-actions { + position: absolute; + z-index: 4; + right: 11px; + bottom: 112px; + display: grid; + justify-items: center; + gap: 8px; +} + +.sp-spotlight-actions button, +.sp-spotlight-stat, +.sp-spotlight-pager button { + display: grid; + place-items: center; + min-width: var(--sky-touch-target); + min-height: var(--sky-touch-target); + padding: 5px; + border: 0; + border-radius: var(--sky-radius-pill); + background: rgba(4, 7, 14, 0.46); + color: #fff; + backdrop-filter: blur(15px); +} + +.sp-spotlight-actions button { + font: inherit; +} + +.sp-spotlight-actions svg { + width: 23px; + height: 23px; +} + +.sp-spotlight-actions small, +.sp-spotlight-stat small { + font-size: 10px; + font-weight: 800; +} + +.sp-spotlight-actions .is-active { + color: var(--sp-pink); +} + +.sp-spotlight-pager { + position: absolute; + z-index: 4; + top: 43%; + right: 12px; + display: grid; + gap: 6px; +} + +.sp-spotlight-pager button:disabled { + opacity: 0.28; +} + +.sp-spotlight-sheet { + display: grid; + gap: var(--sky-space-3); + max-height: min(72vh, 620px); + padding: var(--sky-space-4); +} + +.sp-spotlight-sheet > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sky-space-3); +} + +.sp-spotlight-sheet > header > span { + display: grid; + gap: 3px; +} + +.sp-spotlight-sheet > header small { + color: var(--sky-muted); +} + +.sp-spotlight-sheet__scroll { + display: grid; + gap: var(--sky-space-2); + min-height: 80px; + max-height: 42vh; + overflow-y: auto; + overscroll-behavior: contain; +} + +.sp-spotlight-comment { + display: grid; + align-items: start; + gap: 9px; + padding: 10px; + border: 1px solid var(--sky-hairline); + border-radius: var(--sky-radius-control); + background: var(--sky-surface-muted); + grid-template-columns: auto 1fr auto; +} + +.sp-spotlight-comment > span:nth-child(2) { + min-width: 0; +} + +.sp-spotlight-comment p { + margin: 3px 0; + overflow-wrap: anywhere; + font-size: 13px; + line-height: 1.35; +} + +.sp-spotlight-comment small { + color: var(--sky-muted); + font-size: 10px; +} + +.sp-spotlight-comment > button { + display: grid; + place-items: center; + min-width: var(--sky-touch-target); + min-height: var(--sky-touch-target); + border: 0; + background: transparent; + color: var(--sky-danger, #ff3b30); +} + +.sp-spotlight-comment-form { + display: grid; + align-items: end; + gap: var(--sky-space-2); + grid-template-columns: 1fr auto; +} + +.sp-spotlight-comments-disabled { + margin: 0; + color: var(--sky-muted); + font-size: 12px; + text-align: center; +} + +.sp-report-reasons { + display: grid; + gap: 6px; +} + +.sp-report-reasons button { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--sky-touch-target); + padding: 0 13px; + border: 1px solid var(--sky-hairline); + border-radius: var(--sky-radius-control); + background: var(--sky-surface-muted); + color: var(--sky-text); + font: inherit; + font-size: 13px; + font-weight: 700; +} + +.sp-report-reasons button.is-active { + border-color: var(--sky-accent); + color: var(--sky-accent); +} + :deep(.sp-tab) { transition: color var(--sky-transition-normal) ease, @@ -5090,11 +6012,6 @@ input:focus-visible { opacity: 0.58; } -:deep(.sp-tab--camera), -:deep(.sp-tab--camera.sky-tab-button--active) { - color: var(--sp-camera-blue) !important; -} - :deep(.sp-tab--monochrome), :deep(.sp-tab--monochrome.sky-tab-button--active) { color: var(--sp-tab-monochrome) !important; @@ -5242,6 +6159,8 @@ input:focus-visible { } .sp-story-reply { + box-sizing: border-box; + width: auto; position: absolute; z-index: 3; right: var(--sky-page-gutter); @@ -5249,6 +6168,15 @@ input:focus-visible { left: var(--sky-page-gutter); } +.sp-story-reply :deep(.sky-messagebar__inner) { + width: 100%; + min-width: 0; +} + +.sp-story-reply .sp-send-button { + flex: 0 0 var(--sky-touch-target); +} + .sp-media-viewer__owner-actions button { display: flex; align-items: center; diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 8898187..284403c 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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)) { diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 939b579..50a4db2 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -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, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index ae142e0..b79a479 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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 = { diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 04329a3..c044dec 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -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.", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index f17dc5d..2151d90 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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.", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 9feebf2..53ff6a7 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -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", diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 5e7bd8d..3a953fa 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -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) diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index 879ef97..caa4599 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -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 diff --git a/sky_phone/source/server/skypic.lua b/sky_phone/source/server/skypic.lua index 48a89c5..1f98b70 100644 --- a/sky_phone/source/server/skypic.lua +++ b/sky_phone/source/server/skypic.lua @@ -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 diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 4ca59cd..751cdda 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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;