mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 22:01:40 +00:00
feat(skypic): add spotlight and polish social flows
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -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'
|
||||
|
||||
@@ -54,6 +54,15 @@ const callbacks = [
|
||||
'view-story',
|
||||
'story-viewers',
|
||||
'remove-story',
|
||||
'spotlight-feed',
|
||||
'publish-spotlight',
|
||||
'view-spotlight',
|
||||
'like-spotlight',
|
||||
'spotlight-comments',
|
||||
'comment-spotlight',
|
||||
'delete-spotlight-comment',
|
||||
'remove-spotlight',
|
||||
'report-spotlight',
|
||||
'thread',
|
||||
'send-message',
|
||||
'mark-thread',
|
||||
@@ -133,6 +142,39 @@ const tables: Record<string, string[]> = {
|
||||
'updated_at',
|
||||
],
|
||||
story_views: ['story_id', 'viewer_profile_id', 'viewed_at'],
|
||||
spotlights: [
|
||||
'id',
|
||||
'profile_id',
|
||||
'media_id',
|
||||
'caption',
|
||||
'overlay_text',
|
||||
'overlay_color',
|
||||
'kind',
|
||||
'ad_headline',
|
||||
'comments_enabled',
|
||||
'status',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
spotlight_views: ['spotlight_id', 'viewer_profile_id', 'viewed_at'],
|
||||
spotlight_likes: ['spotlight_id', 'profile_id', 'created_at'],
|
||||
spotlight_comments: [
|
||||
'id',
|
||||
'spotlight_id',
|
||||
'profile_id',
|
||||
'body',
|
||||
'status',
|
||||
'created_at',
|
||||
],
|
||||
spotlight_reports: [
|
||||
'spotlight_id',
|
||||
'reporter_profile_id',
|
||||
'reason',
|
||||
'details',
|
||||
'status',
|
||||
'created_at',
|
||||
],
|
||||
}
|
||||
|
||||
describe('SkyPic backend contracts', () => {
|
||||
@@ -150,7 +192,7 @@ describe('SkyPic backend contracts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of ['messages', 'stories']) {
|
||||
for (const table of ['messages', 'stories', 'spotlights']) {
|
||||
expect(migrationTable(table)).toContain('ON DELETE RESTRICT')
|
||||
expect(installTable(table)).toContain('ON DELETE RESTRICT')
|
||||
}
|
||||
@@ -445,6 +487,7 @@ describe('SkyPic backend contracts', () => {
|
||||
)
|
||||
expect(guard).toContain('FROM `sky_phone_skypic_messages`')
|
||||
expect(guard).toContain('FROM `sky_phone_skypic_stories`')
|
||||
expect(guard).toContain('FROM `sky_phone_skypic_spotlights`')
|
||||
expect(guard).not.toContain('`expires_at` >')
|
||||
expect(guard).not.toContain("`status` = 'active'")
|
||||
|
||||
|
||||
@@ -35,6 +35,15 @@ const callbacks = [
|
||||
'skypic:view-story',
|
||||
'skypic:story-viewers',
|
||||
'skypic:remove-story',
|
||||
'skypic:spotlight-feed',
|
||||
'skypic:publish-spotlight',
|
||||
'skypic:view-spotlight',
|
||||
'skypic:like-spotlight',
|
||||
'skypic:spotlight-comments',
|
||||
'skypic:comment-spotlight',
|
||||
'skypic:delete-spotlight-comment',
|
||||
'skypic:remove-spotlight',
|
||||
'skypic:report-spotlight',
|
||||
'skypic:thread',
|
||||
'skypic:send-message',
|
||||
'skypic:mark-thread',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -12,9 +12,13 @@ import type {
|
||||
SkyPicOpenedSnap,
|
||||
SkyPicProfile,
|
||||
SkyPicProfileSummary,
|
||||
SkyPicPublishSpotlightInput,
|
||||
SkyPicPublishStoryInput,
|
||||
SkyPicSendSnapInput,
|
||||
SkyPicSnap,
|
||||
SkyPicSpotlight,
|
||||
SkyPicSpotlightComment,
|
||||
SkyPicSpotlightReportReason,
|
||||
SkyPicStory,
|
||||
SkyPicStoryViewer,
|
||||
SkyPicThread,
|
||||
@@ -27,6 +31,8 @@ const MAX_MESSAGE_CHARACTERS = 2_000
|
||||
const MAX_SNAP_MEDIA = 10
|
||||
const MAX_TEXT_OVERLAY_CHARACTERS = 160
|
||||
const STORY_PAGE_SIZE = 30
|
||||
const SPOTLIGHT_PAGE_SIZE = 12
|
||||
const SPOTLIGHT_COMMENT_PAGE_SIZE = 50
|
||||
|
||||
export function normalizeSkyPicHandle(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
@@ -75,6 +81,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
const conversations = ref<SkyPicConversation[]>([])
|
||||
const inbox = ref<SkyPicSnap[]>([])
|
||||
const stories = ref<SkyPicStory[]>([])
|
||||
const spotlights = ref<SkyPicSpotlight[]>([])
|
||||
const spotlightComments = ref<SkyPicSpotlightComment[]>([])
|
||||
const suggestions = ref<SkyPicProfileSummary[]>([])
|
||||
const searchResults = ref<SkyPicProfileSummary[]>([])
|
||||
const unreadCount = ref(0)
|
||||
@@ -87,6 +95,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
const storyViewers = ref<SkyPicStoryViewer[]>([])
|
||||
const storiesHasMore = ref(true)
|
||||
const storyViewersHasMore = ref(true)
|
||||
const spotlightsHasMore = ref(true)
|
||||
const spotlightCommentsHasMore = ref(true)
|
||||
|
||||
const loading = ref(false)
|
||||
const bootstrapPending = ref(false)
|
||||
@@ -98,6 +108,9 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
const storyViewing = ref(false)
|
||||
const storiesLoadingMore = ref(false)
|
||||
const storyViewersLoadingMore = ref(false)
|
||||
const spotlightsLoading = ref(false)
|
||||
const spotlightsLoadingMore = ref(false)
|
||||
const spotlightCommentsLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let bootstrapRequest = 0
|
||||
let bootstrapGeneration = 0
|
||||
@@ -109,6 +122,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
let sessionVersion = 0
|
||||
let hydrationRevision = 0
|
||||
let storyViewersStoryId: string | null = null
|
||||
let spotlightCommentsSpotlightId: string | null = null
|
||||
const viewedSpotlightIds = new Set<string>()
|
||||
let bootstrapInFlight: {
|
||||
promise: Promise<boolean>
|
||||
session: number
|
||||
@@ -207,6 +222,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
storyViewersRequest += 1
|
||||
threadRequest += 1
|
||||
storyViewersStoryId = null
|
||||
spotlightCommentsSpotlightId = null
|
||||
viewedSpotlightIds.clear()
|
||||
profile.value = null
|
||||
blockedProfiles.value = []
|
||||
friends.value = []
|
||||
@@ -214,6 +231,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
conversations.value = []
|
||||
inbox.value = []
|
||||
stories.value = []
|
||||
spotlights.value = []
|
||||
spotlightComments.value = []
|
||||
suggestions.value = []
|
||||
searchResults.value = []
|
||||
unreadCount.value = 0
|
||||
@@ -225,6 +244,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
storyViewers.value = []
|
||||
storiesHasMore.value = true
|
||||
storyViewersHasMore.value = true
|
||||
spotlightsHasMore.value = true
|
||||
spotlightCommentsHasMore.value = true
|
||||
loading.value = false
|
||||
threadLoading.value = false
|
||||
searchLoading.value = false
|
||||
@@ -232,6 +253,9 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
storyViewing.value = false
|
||||
storiesLoadingMore.value = false
|
||||
storyViewersLoadingMore.value = false
|
||||
spotlightsLoading.value = false
|
||||
spotlightsLoadingMore.value = false
|
||||
spotlightCommentsLoading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
@@ -799,6 +823,230 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
return response
|
||||
}
|
||||
|
||||
async function loadSpotlights(): Promise<boolean> {
|
||||
spotlightsLoading.value = true
|
||||
const response = await sessionCall<SkyPicSpotlight[]>(
|
||||
'skypic:spotlight-feed',
|
||||
{ offset: 0 },
|
||||
)
|
||||
spotlightsLoading.value = false
|
||||
setError(response)
|
||||
if (!response.success || !response.data) return false
|
||||
spotlights.value = response.data
|
||||
spotlightsHasMore.value = response.data.length >= SPOTLIGHT_PAGE_SIZE
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadMoreSpotlights(): Promise<boolean> {
|
||||
if (!spotlightsHasMore.value || spotlightsLoadingMore.value) return true
|
||||
spotlightsLoadingMore.value = true
|
||||
const response = await sessionCall<SkyPicSpotlight[]>(
|
||||
'skypic:spotlight-feed',
|
||||
{ offset: spotlights.value.length },
|
||||
)
|
||||
spotlightsLoadingMore.value = false
|
||||
setError(response)
|
||||
if (!response.success || !response.data) return false
|
||||
spotlights.value = uniqueById([...spotlights.value, ...response.data])
|
||||
spotlightsHasMore.value = response.data.length >= SPOTLIGHT_PAGE_SIZE
|
||||
return true
|
||||
}
|
||||
|
||||
async function publishSpotlight(
|
||||
input: SkyPicPublishSpotlightInput,
|
||||
): Promise<NuiResponse<SkyPicSpotlight>> {
|
||||
const payload: SkyPicPublishSpotlightInput = {
|
||||
...input,
|
||||
adHeadline: Array.from(input.adHeadline.trim()).slice(0, 80).join(''),
|
||||
caption: input.caption.trim(),
|
||||
durationSeconds: clampDuration(input.durationSeconds),
|
||||
mediaType: 'video',
|
||||
overlayColor: normalizeColor(input.overlayColor),
|
||||
textOverlay: Array.from(input.textOverlay.trim())
|
||||
.slice(0, MAX_TEXT_OVERLAY_CHARACTERS)
|
||||
.join(''),
|
||||
}
|
||||
const response = await sessionCall<SkyPicSpotlight>(
|
||||
'skypic:publish-spotlight',
|
||||
payload,
|
||||
)
|
||||
setError(response)
|
||||
if (response.success && response.data) {
|
||||
spotlights.value = uniqueById([response.data, ...spotlights.value])
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function viewSpotlight(spotlightId: string): Promise<boolean> {
|
||||
if (viewedSpotlightIds.has(spotlightId)) return true
|
||||
viewedSpotlightIds.add(spotlightId)
|
||||
const response = await sessionCall<{ viewCount: number }>(
|
||||
'skypic:view-spotlight',
|
||||
{ spotlightId },
|
||||
)
|
||||
setError(response)
|
||||
if (!response.success || !response.data) {
|
||||
viewedSpotlightIds.delete(spotlightId)
|
||||
return false
|
||||
}
|
||||
spotlights.value = spotlights.value.map((spotlight) =>
|
||||
spotlight.id === spotlightId
|
||||
? {
|
||||
...spotlight,
|
||||
isViewed: true,
|
||||
viewCount: Math.max(
|
||||
0,
|
||||
Number(response.data?.viewCount) || spotlight.viewCount,
|
||||
),
|
||||
}
|
||||
: spotlight,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function likeSpotlight(
|
||||
spotlightId: string,
|
||||
active: boolean,
|
||||
): Promise<boolean> {
|
||||
const response = await sessionCall<{ active: boolean; likeCount: number }>(
|
||||
'skypic:like-spotlight',
|
||||
{ active, spotlightId },
|
||||
)
|
||||
setError(response)
|
||||
if (!response.success || !response.data) return false
|
||||
spotlights.value = spotlights.value.map((spotlight) =>
|
||||
spotlight.id === spotlightId
|
||||
? {
|
||||
...spotlight,
|
||||
isLiked: response.data?.active ?? active,
|
||||
likeCount: Math.max(0, Number(response.data?.likeCount) || 0),
|
||||
}
|
||||
: spotlight,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadSpotlightComments(spotlightId: string): Promise<boolean> {
|
||||
spotlightCommentsLoading.value = true
|
||||
const response = await sessionCall<SkyPicSpotlightComment[]>(
|
||||
'skypic:spotlight-comments',
|
||||
{ offset: 0, spotlightId },
|
||||
)
|
||||
spotlightCommentsLoading.value = false
|
||||
setError(response)
|
||||
if (!response.success || !response.data) return false
|
||||
spotlightCommentsSpotlightId = spotlightId
|
||||
spotlightComments.value = response.data
|
||||
spotlightCommentsHasMore.value =
|
||||
response.data.length >= SPOTLIGHT_COMMENT_PAGE_SIZE
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadMoreSpotlightComments(
|
||||
spotlightId: string,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
spotlightCommentsSpotlightId !== spotlightId ||
|
||||
!spotlightCommentsHasMore.value ||
|
||||
spotlightCommentsLoading.value
|
||||
) {
|
||||
return true
|
||||
}
|
||||
spotlightCommentsLoading.value = true
|
||||
const response = await sessionCall<SkyPicSpotlightComment[]>(
|
||||
'skypic:spotlight-comments',
|
||||
{ offset: spotlightComments.value.length, spotlightId },
|
||||
)
|
||||
spotlightCommentsLoading.value = false
|
||||
setError(response)
|
||||
if (!response.success || !response.data) return false
|
||||
spotlightComments.value = uniqueById([
|
||||
...spotlightComments.value,
|
||||
...response.data,
|
||||
])
|
||||
spotlightCommentsHasMore.value =
|
||||
response.data.length >= SPOTLIGHT_COMMENT_PAGE_SIZE
|
||||
return true
|
||||
}
|
||||
|
||||
async function commentSpotlight(
|
||||
spotlightId: string,
|
||||
body: string,
|
||||
): Promise<boolean> {
|
||||
const response = await sessionCall<SkyPicSpotlightComment>(
|
||||
'skypic:comment-spotlight',
|
||||
{ body: Array.from(body.trim()).slice(0, 500).join(''), spotlightId },
|
||||
)
|
||||
setError(response)
|
||||
if (!response.success || !response.data) return false
|
||||
spotlightCommentsSpotlightId = spotlightId
|
||||
spotlightComments.value = uniqueById([
|
||||
response.data,
|
||||
...spotlightComments.value,
|
||||
])
|
||||
spotlights.value = spotlights.value.map((spotlight) =>
|
||||
spotlight.id === spotlightId
|
||||
? { ...spotlight, commentCount: spotlight.commentCount + 1 }
|
||||
: spotlight,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function deleteSpotlightComment(commentId: string): Promise<boolean> {
|
||||
const comment = spotlightComments.value.find(
|
||||
(item) => item.id === commentId,
|
||||
)
|
||||
const response = await sessionCall('skypic:delete-spotlight-comment', {
|
||||
commentId,
|
||||
})
|
||||
setError(response)
|
||||
if (!response.success) return false
|
||||
spotlightComments.value = spotlightComments.value.filter(
|
||||
(item) => item.id !== commentId,
|
||||
)
|
||||
if (comment) {
|
||||
spotlights.value = spotlights.value.map((spotlight) =>
|
||||
spotlight.id === comment.spotlightId
|
||||
? {
|
||||
...spotlight,
|
||||
commentCount: Math.max(0, spotlight.commentCount - 1),
|
||||
}
|
||||
: spotlight,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function removeSpotlight(spotlightId: string): Promise<boolean> {
|
||||
const response = await sessionCall('skypic:remove-spotlight', {
|
||||
spotlightId,
|
||||
})
|
||||
setError(response)
|
||||
if (!response.success) return false
|
||||
spotlights.value = spotlights.value.filter(
|
||||
(spotlight) => spotlight.id !== spotlightId,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function reportSpotlight(
|
||||
spotlightId: string,
|
||||
reason: SkyPicSpotlightReportReason,
|
||||
details = '',
|
||||
): Promise<boolean> {
|
||||
const response = await sessionCall('skypic:report-spotlight', {
|
||||
details: Array.from(details.trim()).slice(0, 500).join(''),
|
||||
reason,
|
||||
spotlightId,
|
||||
})
|
||||
setError(response)
|
||||
if (!response.success) return false
|
||||
spotlights.value = spotlights.value.filter(
|
||||
(spotlight) => spotlight.id !== spotlightId,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function viewStory(
|
||||
storyId: string,
|
||||
): Promise<NuiResponse<SkyPicViewedStory>> {
|
||||
@@ -1104,15 +1352,21 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
clearViewedStory,
|
||||
closeThread,
|
||||
conversations,
|
||||
commentSpotlight,
|
||||
createProfile,
|
||||
deleteAccount,
|
||||
deleteMessage,
|
||||
deleteSpotlightComment,
|
||||
error,
|
||||
friends,
|
||||
hasProfile,
|
||||
inbox,
|
||||
incomingRequests,
|
||||
loadStories,
|
||||
loadSpotlights,
|
||||
loadMoreSpotlights,
|
||||
loadSpotlightComments,
|
||||
loadMoreSpotlightComments,
|
||||
loadStoryViewers,
|
||||
loadMoreStories,
|
||||
loadMoreStoryViewers,
|
||||
@@ -1124,10 +1378,13 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
outgoingRequests,
|
||||
profile,
|
||||
profileAbsentRevision,
|
||||
publishSpotlight,
|
||||
publishStory,
|
||||
refreshActiveThread,
|
||||
removeFriend,
|
||||
removeSpotlight,
|
||||
removeStory,
|
||||
reportSpotlight,
|
||||
replaySnap,
|
||||
resetSession,
|
||||
requests,
|
||||
@@ -1139,6 +1396,13 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
sendMessage,
|
||||
sendSnap,
|
||||
snapOpening,
|
||||
spotlights,
|
||||
spotlightsHasMore,
|
||||
spotlightsLoading,
|
||||
spotlightsLoadingMore,
|
||||
spotlightComments,
|
||||
spotlightCommentsHasMore,
|
||||
spotlightCommentsLoading,
|
||||
stories,
|
||||
storiesHasMore,
|
||||
storiesLoadingMore,
|
||||
@@ -1152,6 +1416,8 @@ export const useSkyPicStore = defineStore('skypic', () => {
|
||||
threadSnaps,
|
||||
unreadCount,
|
||||
updateProfile,
|
||||
likeSpotlight,
|
||||
viewSpotlight,
|
||||
viewedStory,
|
||||
viewStory,
|
||||
}
|
||||
|
||||
@@ -146,6 +146,44 @@ export type SkyPicStoryViewer = SkyPicProfileSummary & {
|
||||
viewedAt: string
|
||||
}
|
||||
|
||||
export type SkyPicSpotlightReportReason =
|
||||
| 'dangerous'
|
||||
| 'harassment'
|
||||
| 'illegal'
|
||||
| 'other'
|
||||
| 'spam'
|
||||
|
||||
/** Spotlight is public content, so its media URL is intentionally feed-visible. */
|
||||
export type SkyPicSpotlight = {
|
||||
adHeadline: string
|
||||
author: SkyPicProfileSummary
|
||||
caption: string
|
||||
commentCount: number
|
||||
commentsEnabled: boolean
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
id: string
|
||||
isLiked: boolean
|
||||
isOwner: boolean
|
||||
isSponsored: boolean
|
||||
isViewed: boolean
|
||||
likeCount: number
|
||||
mimeType: string | null
|
||||
overlayColor: string
|
||||
textOverlay: string
|
||||
url: string
|
||||
viewCount: number
|
||||
}
|
||||
|
||||
export type SkyPicSpotlightComment = {
|
||||
author: SkyPicProfileSummary
|
||||
body: string
|
||||
createdAt: string
|
||||
id: string
|
||||
isOwner: boolean
|
||||
spotlightId: string
|
||||
}
|
||||
|
||||
export type SkyPicMessageDeliveryStatus = 'delivered' | 'failed' | 'sending'
|
||||
|
||||
export type SkyPicMessage = {
|
||||
@@ -178,7 +216,7 @@ export type SkyPicBootstrap = {
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export type SkyPicDraftPurpose = 'snap' | 'story'
|
||||
export type SkyPicDraftPurpose = 'snap' | 'spotlight' | 'story'
|
||||
|
||||
export type SkyPicMediaDraftContext = {
|
||||
purpose: SkyPicDraftPurpose
|
||||
@@ -217,3 +255,11 @@ export type SkyPicSendSnapInput = SkyPicEditorInput &
|
||||
}
|
||||
|
||||
export type SkyPicPublishStoryInput = SkyPicEditorInput & SkyPicSingleMediaInput
|
||||
|
||||
export type SkyPicPublishSpotlightInput = SkyPicEditorInput & {
|
||||
adHeadline: string
|
||||
commentsEnabled: boolean
|
||||
isSponsored: boolean
|
||||
mediaId: number
|
||||
mediaType: 'video'
|
||||
}
|
||||
|
||||
@@ -16,31 +16,34 @@ const typeSource = readFileSync(
|
||||
)
|
||||
|
||||
describe('SkyPic frontend contract', () => {
|
||||
it('uses Sky UI and exposes the camera-first four-tab shell', () => {
|
||||
it('uses Sky UI and exposes the camera-first five-tab shell', () => {
|
||||
expect(viewSource).toContain("from '@/ui'")
|
||||
expect(viewSource).not.toContain("from 'konsta/vue'")
|
||||
expect(viewSource).toContain("const activeTab = ref<Tab>('camera')")
|
||||
expect(viewSource).toContain(
|
||||
"type Tab = 'camera' | 'chats' | 'friends' | 'stories'",
|
||||
"type Tab = 'camera' | 'chats' | 'friends' | 'spotlight' | 'stories'",
|
||||
)
|
||||
expect(viewSource).toContain("activeTab === 'camera'")
|
||||
expect(viewSource).toContain("activeTab === 'chats'")
|
||||
expect(viewSource).toContain("activeTab === 'stories'")
|
||||
expect(viewSource).toContain("activeTab === 'friends'")
|
||||
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(4)
|
||||
expect(viewSource).toContain("activeTab === 'spotlight'")
|
||||
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('keeps Camera blue and all other tabs theme-monochrome', () => {
|
||||
it('keeps every bottom tab and app surface theme-monochrome', () => {
|
||||
expect(viewSource).toContain('phone.isDarkMode')
|
||||
expect(viewSource).toContain("'skypic-app--player-dark': phone.isDarkMode")
|
||||
expect(viewSource).toContain(
|
||||
"'skypic-app--player-light': !phone.isDarkMode",
|
||||
)
|
||||
expect(viewSource).toContain('class="sp-tab sp-tab--camera"')
|
||||
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
|
||||
3,
|
||||
expect(viewSource).toContain(
|
||||
'class="sp-tab sp-tab--camera sp-tab--monochrome"',
|
||||
)
|
||||
expect(viewSource).toContain('color: var(--sp-camera-blue) !important')
|
||||
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
|
||||
4,
|
||||
)
|
||||
expect(viewSource).not.toContain('color: var(--sp-camera-blue) !important')
|
||||
expect(viewSource).toContain('--sp-tab-monochrome: #000000')
|
||||
expect(viewSource).toContain('--sp-tab-monochrome: #ffffff')
|
||||
expect(viewSource).toContain('color: var(--sp-tab-monochrome) !important')
|
||||
@@ -48,22 +51,49 @@ describe('SkyPic frontend contract', () => {
|
||||
.split('.sp-camera-screen {')[1]
|
||||
?.split('.sp-camera-header')[0]
|
||||
expect(cameraCss).toBeTruthy()
|
||||
expect(cameraCss).toContain('background: #000')
|
||||
expect(cameraCss).not.toContain('255, 91, 189')
|
||||
expect(viewSource).toContain('rgba(18, 18, 18, 0.92)')
|
||||
expect(viewSource).not.toContain('var(--sp-cyan)')
|
||||
})
|
||||
|
||||
it('uses the reference-led Sky UI hierarchy without inventing camera filters or Spotlight', () => {
|
||||
it('uses the reference-led Sky UI hierarchy with Spotlight and no camera filters', () => {
|
||||
expect(viewSource.match(/class="sp-camera-header__actions"/g)).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(viewSource.match(/class="sp-camera-header__glass"/g)).toHaveLength(3)
|
||||
expect(viewSource).toContain('class="sp-camera-viewfinder"')
|
||||
expect(viewSource).toContain('class="sp-camera-dock"')
|
||||
const cameraMarkup = viewSource
|
||||
.split('v-if="activeTab === \'camera\'"')[1]
|
||||
?.split('<template v-else-if="activeTab === \'chats\'">')[0]
|
||||
expect(cameraMarkup).toContain('<SkyGlass')
|
||||
expect(cameraMarkup).toContain('class="sp-shutter"')
|
||||
expect(viewSource).toContain('class="sp-chat-list"')
|
||||
expect(viewSource).toContain('class="sp-chat-row')
|
||||
expect(viewSource).toContain('class="sp-story-rail"')
|
||||
expect(viewSource).toContain('class="sp-story-grid"')
|
||||
expect(viewSource).toContain('class="sp-discovery-grid"')
|
||||
expect(viewSource).toContain('width: calc(100% - (var(--sky-space-3) * 2))')
|
||||
expect(viewSource).toContain('class="sp-bottom-nav"')
|
||||
expect(viewSource).not.toContain('sp-camera-filter')
|
||||
expect(viewSource).not.toContain('Spotlight')
|
||||
expect(viewSource).not.toContain('advertisement')
|
||||
expect(viewSource).toContain('class="sp-spotlight-player"')
|
||||
expect(
|
||||
viewSource.match(/class="sp-spotlight-header__actions/g),
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
viewSource.match(/class="sp-spotlight-header__glass"/g),
|
||||
).toHaveLength(3)
|
||||
expect(viewSource).toContain(
|
||||
'height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height))',
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'padding: var(--sky-safe-area-top) var(--sky-page-gutter)',
|
||||
)
|
||||
expect(viewSource).toContain('min-height: var(--sky-navbar-height)')
|
||||
expect(viewSource).toContain("t('spotlight.sponsored')")
|
||||
expect(viewSource).toContain('store.publishSpotlight')
|
||||
expect(viewSource).not.toContain('href=')
|
||||
})
|
||||
|
||||
it('builds story discovery only from safe metadata until view-story releases media', () => {
|
||||
@@ -131,6 +161,27 @@ describe('SkyPic frontend contract', () => {
|
||||
expect(typeSource).toContain('mediaIds: number[]')
|
||||
})
|
||||
|
||||
it('aligns chat ownership and keeps the story reply action inside the viewer', () => {
|
||||
expect(viewSource).toContain(
|
||||
`:type="entry.value.direction === 'sent' ? 'sent' : 'received'"`,
|
||||
)
|
||||
const threadLayoutCss = viewSource
|
||||
.split('.sp-thread :deep(.sky-messages) {')[1]
|
||||
?.split('.sp-thread :deep(.sky-message__footer)')[0]
|
||||
expect(threadLayoutCss).toContain('display: flex')
|
||||
expect(threadLayoutCss).toContain('flex-direction: column')
|
||||
const sentSnapCss = viewSource
|
||||
.split('.sp-thread-snap--sent {')[1]
|
||||
?.split('.sp-thread-snap > span')[0]
|
||||
expect(sentSnapCss).toContain('align-self: flex-end')
|
||||
const storyReplyCss = viewSource
|
||||
.split('.sp-story-reply {')[1]
|
||||
?.split('.sp-media-viewer__owner-actions button')[0]
|
||||
expect(storyReplyCss).toContain('width: auto')
|
||||
expect(storyReplyCss).toContain('right: var(--sky-page-gutter)')
|
||||
expect(storyReplyCss).toContain('left: var(--sky-page-gutter)')
|
||||
})
|
||||
|
||||
it('uses app-local authentication and exposes safe account controls', () => {
|
||||
expect(viewSource).toContain('useAppAuthStore()')
|
||||
expect(viewSource).toContain("appAuth.isSignedIn('skypic')")
|
||||
|
||||
+1055
-127
File diff suppressed because it is too large
Load Diff
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user