diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a4ff055..8094d9e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1319,6 +1319,17 @@ onMounted(() => { if (developmentParameters.has('payphonePreview')) { openDevelopmentPayphonePreview() } + if (developmentParameters.has('notificationPreview')) { + window.setTimeout(() => { + notifications.show({ + appId: 'messages', + persistent: true, + route: '/apps/messages', + text: 'You still got that spare alternator?', + title: 'Tommy V', + }) + }, 250) + } } }) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 81d6a39..eb5fe1f 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -536,19 +536,62 @@ button { border: 0 !important; opacity: 0 !important; } +.phone-notification-provider { + --phone-notification-background: rgb(247 247 248 / 94%); + --phone-notification-shadow: + 0 10px 30px rgb(0 0 0 / 18%), inset 0 0.5px 0 rgb(255 255 255 / 90%); + position: absolute; + z-index: 85; + inset: 0; + pointer-events: none; +} +.phone-notification-provider.sky-ui-provider--dark { + --phone-notification-background: rgb(36 36 39 / 94%); + --phone-notification-shadow: + 0 12px 34px rgb(0 0 0 / 36%), inset 0 0.5px 0 rgb(255 255 255 / 16%); +} .phone-notification { + --phone-notification-drag-y: 0px; z-index: 85 !important; - top: 68px !important; - right: 8px !important; - left: 8px !important; + top: 51px !important; + right: 10px !important; + left: 10px !important; width: auto !important; margin-left: 0 !important; + pointer-events: auto; + background: var(--phone-notification-background) !important; + box-shadow: var(--phone-notification-shadow) !important; + transform: translate3d(0, var(--phone-notification-drag-y), 0); + transition: transform 280ms var(--sky-ease-out, ease-out); + will-change: transform; } .phone-notification__icon { width: 38px; height: 38px; + border-radius: 10px; object-fit: contain; } +.phone-notification__open { + position: absolute; + z-index: 2; + inset: 0; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} +.phone-notification__open:active { + background: var(--sky-pressed); +} +.phone-notification__open:focus-visible { + outline: 2px solid var(--sky-app-accent); + outline-offset: -3px; +} +.phone-notification.phone-notification--dragging { + transition: none !important; +} .phone-status-bar { position: absolute; z-index: 97; diff --git a/frontend/src/components/NotificationPhonePreview.vue b/frontend/src/components/NotificationPhonePreview.vue index cccefab..8585f6a 100644 --- a/frontend/src/components/NotificationPhonePreview.vue +++ b/frontend/src/components/NotificationPhonePreview.vue @@ -73,6 +73,7 @@ const wrapperStyle = computed(() => ({ preview /> { + it('uses the shared Sky popup for every central phone notification', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).toContain( + "import { SkyNotification, SkyProvider } from '@/ui'", + ) + expect(source).toContain(' { + expect(source).toContain('getPhoneApp(props.notification.appId)?.iconImage') + expect(source).toContain('class="phone-notification__icon"') + expect(source).toContain('class="phone-notification__open"') + expect(source).toContain(':aria-label="phone.t(\'Notifications.open\')"') + expect(source).toContain('@keydown.esc.stop="emit(\'close\')"') + }) + + it('supports an upward dismiss gesture without a visible close button', () => { + expect(source).toContain('@pointerdown="beginDismissGesture"') + expect(source).toContain('@pointermove="moveDismissGesture"') + expect(source).toContain('pointerOffset <= -28') + expect(source).toContain('velocityY <= -0.3') + expect(source).not.toContain('button="close"') + expect(source).not.toContain(' + + +

{{ t('deleteVideoBody') }}

+ +
{ background: transparent; } +.comments-disabled { + margin: 0; + padding: var(--sky-space-3) var(--sky-space-2) + calc(var(--sky-safe-area-bottom) + var(--sky-space-3)); + border-top: 1px solid var(--sky-hairline); + color: var(--sky-muted); + font-size: 10px; + text-align: center; +} + .replying-to { min-height: 20px; padding: 0 var(--sky-space-2) 2px; @@ -4891,20 +5391,38 @@ onBeforeUnmount(() => { .photo-slideshow, .compose-photo-preview { - display: flex; width: 100%; height: 100%; +} + +.photo-slideshow { + overflow: hidden; +} + +.photo-slideshow__track, +.compose-photo-preview { + display: flex; +} + +.photo-slideshow__track { + width: 100%; + height: 100%; + backface-visibility: hidden; + transform: translate3d(0, 0, 0); + will-change: transform; +} + +.compose-photo-preview { overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; } -.photo-slideshow::-webkit-scrollbar, .compose-photo-preview::-webkit-scrollbar { display: none; } -.photo-slideshow img, +.photo-slideshow__track img, .compose-photo-preview img { width: 100%; height: 100%; @@ -4913,6 +5431,13 @@ onBeforeUnmount(() => { scroll-snap-align: center; } +.photo-slideshow__track img { + backface-visibility: hidden; + pointer-events: none; + transform: translateZ(0); + -webkit-user-drag: none; +} + .photo-slideshow__count { position: absolute; top: calc(var(--sky-safe-area-top) + 58px); @@ -4948,11 +5473,21 @@ onBeforeUnmount(() => { } @keyframes follow-confirmed-fade-centered { - 0%, 48% { opacity: 1; transform: translateX(-50%) scale(1); } - 100% { opacity: 0; transform: translateX(-50%) scale(0.72); } + 0%, + 48% { + opacity: 1; + transform: translateX(-50%) scale(1); + } + 100% { + opacity: 0; + transform: translateX(-50%) scale(0.72); + } } .video-actions button { + width: 44px; + min-width: 44px; + min-height: 44px; gap: 2px; } @@ -5085,6 +5620,23 @@ onBeforeUnmount(() => { color: var(--sky-app-accent); } +.media-picker > header strong, +.media-picker > header span { + max-width: 100%; + overflow-wrap: anywhere; +} + +.media-picker > header strong { + font-size: 13px; + line-height: 1.25; +} + +.media-picker > header span { + color: var(--sky-muted); + font-size: 10px; + line-height: 1.35; +} + .media-source-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -5106,9 +5658,15 @@ onBeforeUnmount(() => { color: var(--sky-text); font: inherit; font-size: 9px; + line-height: 1.2; text-align: center; } +.media-source-grid button strong { + width: 100%; + overflow-wrap: anywhere; +} + .media-source-grid svg { width: 20px; height: 20px; @@ -5270,4 +5828,229 @@ onBeforeUnmount(() => { min-width: var(--sky-touch-target); flex: 0 0 var(--sky-touch-target); } + +/* Feed interaction and photo posts stay inside the phone viewport. */ +.video-feed { + overscroll-behavior-y: contain; + contain: layout paint; +} + +.video-card { + block-size: 100%; + inline-size: 100%; +} + +.video-shade--passive { + pointer-events: none; +} + +.photo-slideshow { + position: absolute; + inset: 0; + overscroll-behavior-inline: contain; + cursor: grab; + touch-action: pan-y; + user-select: none; + contain: layout paint; +} + +.compose-photo-preview__arrow { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + padding: 0; + display: grid; + place-items: center; + border: 1px solid rgb(255 255 255 / 22%); + border-radius: 50%; + background: rgb(14 14 16 / 58%); + color: #fff; + box-shadow: 0 4px 14px rgb(0 0 0 / 24%); + backdrop-filter: blur(10px); + pointer-events: auto; +} + +.photo-slideshow--dragging, +.photo-slideshow--settling { + cursor: grabbing; +} + +.compose-photo-preview__arrow svg { + width: 17px; + height: 17px; +} + +.compose-photo-preview__arrow:disabled { + opacity: 0; + pointer-events: none; +} + +.photo-slideshow__count { + top: calc(var(--sky-safe-area-top) + 58px); + right: 12px; + z-index: 1; + pointer-events: none; +} + +/* The avatar follow control must not inherit the generic action height. */ +.video-actions .follow-dot { + width: 20px !important; + height: 20px !important; + min-width: 20px !important; + min-height: 20px !important; + padding: 0 !important; + display: grid; + place-items: center; + border: 2px solid #111; + border-radius: 50% !important; + background: var(--sky-app-accent) !important; +} + +.video-actions .follow-dot svg { + width: 11px !important; + height: 11px !important; + margin: 0; +} + +/* No unused action row is reserved above Discover. */ +.fliptok-discover-navbar.sky-navbar--no-navigation { + padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-2)); +} + +.compose-photo-preview { + position: relative; + display: block; + overflow: hidden; + background: #111; +} + +.compose-photo-preview img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.compose-photo-preview__arrow { + position: absolute; + z-index: 2; + top: 50%; + transform: translateY(-50%); +} + +.compose-photo-preview__arrow--previous { + left: 10px; +} + +.compose-photo-preview__arrow--next { + right: 10px; +} + +.compose-photo-preview__count { + position: absolute; + z-index: 2; + top: 10px; + right: 10px; + padding: 4px 8px; + border-radius: var(--sky-radius-pill); + background: rgb(14 14 16 / 58%); + color: #fff; + font-size: 10px; + font-weight: 700; + backdrop-filter: blur(10px); +} + +.compose-photo-preview__dots { + position: absolute; + z-index: 2; + right: 42px; + bottom: 10px; + left: 42px; + display: flex; + justify-content: center; + gap: 4px; +} + +.compose-photo-preview__dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: rgb(255 255 255 / 48%); + box-shadow: 0 1px 3px rgb(0 0 0 / 36%); +} + +.compose-photo-preview__dots span.active { + width: 13px; + border-radius: var(--sky-radius-pill); + background: #fff; +} + +/* Login and register center in the remaining phone area and still scroll. */ +.fliptok-auth { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.fliptok-auth__navbar { + flex: 0 0 auto; +} + +.fliptok-auth__body { + min-height: 0; + padding-top: var(--sky-space-3); + flex: 1 1 auto; + overflow-y: auto; +} + +.fliptok-auth__glass { + margin-block: auto; +} + +/* Profile actions are separate icon surfaces instead of one shared pill. */ +.fliptok-profile-navbar :deep(.sky-navbar__right) { + overflow: visible; + background: transparent; + box-shadow: none; +} + +.profile-navbar-actions { + gap: 6px; +} + +.profile-navbar-actions :deep(.sky-link) { + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + border: 1px solid var(--sky-hairline); + border-radius: var(--sky-radius-control); + background: var(--sky-surface); + color: var(--sky-text); + box-shadow: var(--sky-shadow-thumb); +} + +.connections-list :deep(.sky-list-item__media) { + width: var(--sky-touch-target); + min-width: var(--sky-touch-target); + display: grid; + place-items: center; +} + +.connections-list .connection-profile { + height: var(--sky-touch-target); + padding: 0; + display: grid; + place-items: center; +} + +.connections-list .connection-avatar { + line-height: 1; + text-align: center; +} + +.connections-list :deep(.sky-button:disabled) { + opacity: 0.72; +} diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 1dd7343..9b16da3 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -585,6 +585,14 @@ let flipTokVideos = [ music_source: '', music_url: '', music_video_id: '', + media_type: 'video', + media: [ + { + id: 2, + mediaType: 'video', + url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', + }, + ], url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', comments_enabled: true, is_liked: false, @@ -617,6 +625,14 @@ let flipTokVideos = [ music_source: 'audio', music_url: flipTokMusicTracks[0].url, music_video_id: '', + media_type: 'video', + media: [ + { + id: 6, + mediaType: 'video', + url: 'https://media.w3.org/2010/05/sintel/trailer.mp4', + }, + ], url: 'https://media.w3.org/2010/05/sintel/trailer.mp4', comments_enabled: true, is_liked: true, @@ -629,6 +645,56 @@ let flipTokVideos = [ share_count: 220, created_at: Date.now() - 7200000, }, + { + avatar_url: null, + id: 'fliptok-3', + profile_id: 1, + handle: 'skyline', + display_name: 'Skyline', + verified: true, + caption: 'Three stops through Los Santos. #CityLife', + location: '', + trim_start_ms: 0, + trim_end_ms: null, + cover_time_ms: 0, + original_volume: 0, + music_volume: 0, + music_track: '', + music_title: '', + music_artist: '', + music_source: '', + music_url: '', + music_video_id: '', + media_type: 'photo', + media: [ + { + id: 1, + mediaType: 'photo', + url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'), + }, + { + id: 3, + mediaType: 'photo', + url: mockGalleryImage('Beach Drive', '#0369a1', '#164e63', '#fbbf24'), + }, + { + id: 4, + mediaType: 'photo', + url: mockGalleryImage('Mountain Road', '#475569', '#334155', '#e2e8f0'), + }, + ], + url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'), + comments_enabled: true, + is_liked: false, + is_saved: false, + is_following: false, + is_owner: true, + like_count: 812, + comment_count: 0, + view_count: 17300, + share_count: 64, + created_at: Date.now() - 10800000, + }, ] let flipTokComments = [ { @@ -637,6 +703,7 @@ let flipTokComments = [ is_liked: false, like_count: 7, parent_id: null, + video_id: 'fliptok-1', profile_id: 2, reply_to_handle: null, handle: 'nova', @@ -6951,9 +7018,14 @@ app.post('/api/:endpoint', (request, response) => { const video = flipTokVideos.find((item) => item.id === request.body.id) if (video) { const key = request.body.kind === 'like' ? 'is_liked' : 'is_saved' + const changed = video[key] !== request.body.active video[key] = request.body.active + if (changed && request.body.kind === 'like') + video.like_count += request.body.active ? 1 : -1 } - response.json({ success: true }) + response.json( + video ? { success: true } : { success: false, error: 'video_not_found' }, + ) return } if (endpoint === 'fliptok:video') { @@ -6981,16 +7053,36 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'fliptok:comments') { - response.json({ success: true, data: flipTokComments }) + const video = flipTokVideos.find((item) => item.id === request.body.id) + response.json( + video + ? { + success: true, + data: flipTokComments.filter( + (comment) => comment.video_id === video.id, + ), + } + : { success: false, error: 'video_not_found' }, + ) return } if (endpoint === 'fliptok:comment') { + const video = flipTokVideos.find((item) => item.id === request.body.id) + if (!video) { + response.json({ success: false, error: 'video_not_found' }) + return + } + if (!video.comments_enabled) { + response.json({ success: false, error: 'comments_disabled' }) + return + } const comment = { avatar_url: flipTokProfile.avatar_url, id: `comment-${Date.now()}`, is_liked: false, like_count: 0, parent_id: request.body.parentId || null, + video_id: video.id, profile_id: 1, reply_to_handle: request.body.parentId ? flipTokComments.find((item) => item.id === request.body.parentId) @@ -7003,16 +7095,22 @@ app.post('/api/:endpoint', (request, response) => { created_at: Date.now(), } flipTokComments.push(comment) + video.comment_count += 1 response.json({ success: true, data: { id: comment.id } }) return } if (endpoint === 'fliptok:comment-react') { const comment = flipTokComments.find((item) => item.id === request.body.id) if (comment) { + const changed = comment.is_liked !== (request.body.active === true) comment.is_liked = request.body.active === true - comment.like_count += comment.is_liked ? 1 : -1 + if (changed) comment.like_count += comment.is_liked ? 1 : -1 } - response.json({ success: true }) + response.json( + comment + ? { success: true } + : { success: false, error: 'invalid_comment' }, + ) return } if (endpoint === 'fliptok:activities') { @@ -7197,13 +7295,24 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'fliptok:publish') { - const media = mockMedia.find( - (item) => item.id === request.body.mediaId && item.mediaType === 'video', + const mediaType = request.body.mediaType === 'photo' ? 'photo' : 'video' + const submittedMediaIds = Array.isArray(request.body.mediaIds) + ? request.body.mediaIds + : [request.body.mediaId] + const mediaIds = [...new Set(submittedMediaIds.map(Number))] + const mediaItems = mediaIds.map((id) => + mockMedia.find((item) => item.id === id && item.mediaType === mediaType), ) - if (!media) { + if ( + mediaItems.length < 1 || + mediaItems.length > 10 || + (mediaType === 'video' && mediaItems.length !== 1) || + mediaItems.some((item) => !item) + ) { response.json({ success: false, error: 'invalid_media' }) return } + const media = mediaItems[0] const musicTrack = String(request.body.musicTrack || '') const configuredTrack = flipTokMusicTracks.find( (track) => track.id === musicTrack, @@ -7222,7 +7331,7 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: false, error: 'invalid_music_url' }) return } - flipTokVideos.unshift({ + const createdVideo = { avatar_url: flipTokProfile.avatar_url, id: `fliptok-${Date.now()}`, profile_id: 1, @@ -7230,11 +7339,13 @@ app.post('/api/:endpoint', (request, response) => { display_name: flipTokProfile.display_name, verified: flipTokProfile.verified, caption: request.body.caption, - location: request.body.location, - trim_start_ms: request.body.trimStartMs || 0, - trim_end_ms: request.body.trimEndMs || null, - cover_time_ms: request.body.coverTimeMs || 0, - original_volume: request.body.originalVolume ?? 100, + location: String(request.body.location || ''), + trim_start_ms: mediaType === 'photo' ? 0 : request.body.trimStartMs || 0, + trim_end_ms: + mediaType === 'photo' ? null : request.body.trimEndMs || null, + cover_time_ms: mediaType === 'photo' ? 0 : request.body.coverTimeMs || 0, + original_volume: + mediaType === 'photo' ? 0 : (request.body.originalVolume ?? 100), music_volume: request.body.musicVolume || 0, music_track: configuredTrack?.id || '', music_title: configuredTrack?.title || youtubeMetadata?.title || '', @@ -7248,6 +7359,12 @@ app.post('/api/:endpoint', (request, response) => { : '', music_url: youtubeVideoId ? '' : configuredTrack?.url || customMusicUrl, music_video_id: youtubeVideoId, + media_type: mediaType, + media: mediaItems.map((item) => ({ + id: item.id, + mediaType: item.mediaType, + url: item.url, + })), url: media.url, comments_enabled: request.body.commentsEnabled, is_liked: false, @@ -7259,8 +7376,28 @@ app.post('/api/:endpoint', (request, response) => { view_count: 0, share_count: 0, created_at: Date.now(), - }) - response.json({ success: true, data: { id: flipTokVideos[0].id } }) + } + if (request.body.draft !== true) { + flipTokVideos.unshift(createdVideo) + flipTokProfile.video_count += 1 + } + response.json({ success: true, data: { id: createdVideo.id } }) + return + } + if (endpoint === 'fliptok:delete') { + const index = flipTokVideos.findIndex( + (video) => video.id === request.body.id && video.is_owner, + ) + if (index < 0) { + response.json({ success: false, error: 'video_not_found' }) + return + } + flipTokVideos.splice(index, 1) + flipTokComments = flipTokComments.filter( + (comment) => comment.video_id !== request.body.id, + ) + flipTokProfile.video_count = Math.max(0, flipTokProfile.video_count - 1) + response.json({ success: true }) return } if (endpoint.startsWith('fliptok:')) { diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 5df0991..33f2753 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -201,6 +201,80 @@ async function verifyStatefulActions(baseUrl) { .every((item) => typeof item.thumbnailUrl === 'string'), 'gallery:list returned a test video without a thumbnail', ) + const flipTokPhotos = gallery + .filter((item) => item.mediaType === 'photo') + .slice(0, 3) + const createdFlipTok = await expectSuccess( + baseUrl, + 'fliptok:publish', + { + caption: 'Browser mock photo slideshow', + commentsEnabled: true, + coverTimeMs: 0, + customMusicUrl: '', + draft: false, + mediaId: flipTokPhotos[0].id, + mediaIds: flipTokPhotos.map((item) => item.id), + mediaType: 'photo', + musicTrack: '', + musicVolume: 0, + originalVolume: 0, + trimEndMs: null, + trimStartMs: 0, + visibility: 'public', + }, + true, + ) + let flipTokFeed = await expectSuccess( + baseUrl, + 'fliptok:feed', + { mode: 'for-you', offset: 0 }, + true, + ) + const photoFlipTok = flipTokFeed.items.find( + (item) => item.id === createdFlipTok.id, + ) + assert.equal(photoFlipTok.media_type, 'photo') + assert.deepEqual( + photoFlipTok.media.map((item) => item.id), + flipTokPhotos.map((item) => item.id), + 'fliptok:publish did not preserve photo order', + ) + await expectSuccess(baseUrl, 'fliptok:comment', { + body: 'Browser mock scoped comment', + id: createdFlipTok.id, + }) + const createdComments = await expectSuccess( + baseUrl, + 'fliptok:comments', + { id: createdFlipTok.id }, + true, + ) + assert( + createdComments.some((comment) => comment.body.includes('scoped comment')), + 'fliptok:comment did not persist for its video', + ) + const otherComments = await expectSuccess( + baseUrl, + 'fliptok:comments', + { id: 'fliptok-1' }, + true, + ) + assert( + !otherComments.some((comment) => comment.body.includes('scoped comment')), + 'fliptok:comments leaked comments from another video', + ) + await expectSuccess(baseUrl, 'fliptok:delete', { id: createdFlipTok.id }) + flipTokFeed = await expectSuccess( + baseUrl, + 'fliptok:feed', + { mode: 'for-you', offset: 0 }, + true, + ) + assert( + !flipTokFeed.items.some((item) => item.id === createdFlipTok.id), + 'fliptok:delete kept the removed video in the feed', + ) await expectSuccess( baseUrl, 'gallery:favorite', diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 3d2cd2e..c040bbf 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -562,12 +562,13 @@ Locales["en"] = { name = "FlipTok", loading = "Loading FlipTok", following = "Following", forYou = "For You", verified = "Verified account", originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile", navigation = "FlipTok navigation", emptyFeed = "No videos yet", emptyFeedBody = "Follow creators or post the first FlipTok.", searchPlaceholder = "Search creators and videos", clearSearch = "Clear search", - noActivity = "No activity yet", followers = "Followers", videos = "Videos", emptyBio = "No bio yet.", editProfile = "Edit profile", - newVideo = "New FlipTok", createTitle = "Create your next FlipTok", createBody = "Record a clip or turn photos into a swipeable FlipTok.", chooseMedia = "Add media", chooseMediaHint = "Record a video, choose a clip, or select up to 10 photos.", recordVideo = "Record", camera = "Camera", chooseVideo = "Video", photoSlideshow = "Photo slides", changeMedia = "Change", + trendLosSantos = "# Los Santos", trendRoleplay = "# Roleplay", trendTrending = "# Trending", + noActivity = "No activity yet", followers = "Followers", videos = "Videos", emptyBio = "No bio yet.", editProfile = "Edit profile", more = "More", + newVideo = "New FlipTok", createTitle = "Create your next FlipTok", createBody = "Record a clip or turn photos into a swipeable FlipTok.", chooseMedia = "Choose media", chooseMediaHint = "Record a video, choose a clip, or select up to 10 photos.", recordVideo = "Record video", camera = "Camera", chooseVideo = "Choose video", photoSlideshow = "Photos as FlipTok", changeMedia = "Change", previousPhoto = "Previous photo", nextPhoto = "Next photo", caption = "Caption", captionPlaceholder = "Write a caption...", location = "Add location", whoCanWatch = "Who can watch", public = "Everyone", followersOnly = "Followers", private = "Only me", allowComments = "Allow comments", saveDraft = "Drafts", publishing = "Posting...", post = "Post", - draftSaved = "Draft saved.", published = "Your FlipTok is live.", linkCopied = "Video link copied.", reported = "Report submitted.", blocked = "Creator blocked.", - comments = "Comments", noComments = "No comments yet", addComment = "Add comment...", reply = "Reply", showReplies = "View {count} replies", hideReplies = "Hide replies", replyPlaceholder = "Write a reply...", replyingTo = "Replying to {handle}", cancelReply = "Cancel reply", report = "Report video", reportReason = "Reason", + draftSaved = "Draft saved.", published = "Your FlipTok is live.", videoDeleted = "Video removed.", deleteVideoTitle = "Remove this video?", deleteVideoBody = "The video disappears from FlipTok and cannot be restored.", deletingVideo = "Removing...", linkCopied = "Video link copied.", reported = "Report submitted.", blocked = "Creator blocked.", + comments = "Comments", noComments = "No comments yet", addComment = "Add comment...", reply = "Reply", showReplies = "Show {count} replies", hideReplies = "Hide {count} replies", replyPlaceholder = "Write a reply...", replyingTo = "Replying to {handle}", cancelReply = "Cancel reply", report = "Report video", reportReason = "Reason", reportDetails = "Additional details (optional)", submitReport = "Submit report", reportDiscordNote = "This report is sent directly to the moderation team through Discord.", reportReasons = { spam = "Spam or misleading", harassment = "Harassment or bullying", dangerous = "Dangerous activity", illegal = "Illegal content", other = "Something else" }, block = "Block creator", follow = "Follow", unfollow = "Following", backToProfile = "Back", cancel = "Cancel", @@ -576,7 +577,7 @@ Locales["en"] = { customSoundFormats = "YouTube, YouTube Music, Shorts, MP3, M4A, AAC, OGG, OPUS, WAV, or WEBM", useCustomSound = "Use sound", loadingSound = "Loading sound", invalidCustomSoundLink = "Use a YouTube link or a direct public HTTPS audio-file link.", customSoundLoadFailed = "This sound could not be loaded.", trimAndCover = "Trim & cover", trimStart = "Start", trimEnd = "End", coverFrame = "Cover", originalVolume = "Original sound", musicVolume = "Music", moderation = "Moderation", reports = "Open reports", noReports = "No open reports", removeVideo = "Remove video", dismissReport = "Dismiss", - done = "Done", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type", profilePhoto = "Profile photo", changePhoto = "Change photo", chooseFromGallery = "Photos", takePhoto = "Camera", removePhoto = "Remove photo", noConnections = "No profiles to show", + done = "Done", savingProfile = "Saving...", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type", profilePhoto = "Profile photo", changePhoto = "Change photo", chooseFromGallery = "Photos", takePhoto = "Camera", removePhoto = "Remove photo", noConnections = "No profiles to show", authTitle = "Your FlipTok account", login = "Sign In", register = "Register", createAccount = "Create Account", logout = "Sign Out", loginBody = "Sign in to continue with your videos, follows, and saved posts.", registerBody = "Create a private FlipTok login for this profile.", password = "Password", confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.", @@ -587,7 +588,7 @@ Locales["en"] = { accountTypes = { person = "Person", business = "Business", organization = "Organization", media = "Media", event = "Event" }, activityKinds = { like = "liked your video", comment = "commented on your video", follow = "started following you", verified = "verification changed" }, notifications = { like = "{actor} liked your video.", comment = "{actor} commented on your video.", follow = "{actor} started following you.", verified = "Your FlipTok account is now verified.", default = "You have new FlipTok activity." }, - errors = { invalid_video = "Check the FlipTok details.", invalid_music_url = "Use a valid YouTube or direct HTTPS audio-file link.", invalid_media = "Choose valid media from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", invalid_profile_image = "Choose a valid photo from this phone.", invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This Sky Cloud account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", report_unavailable = "Discord reporting is not configured yet.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to Sky Cloud first.", default = "FlipTok could not complete the request." }, + errors = { invalid_video = "Check the FlipTok details.", invalid_music_url = "Use a valid YouTube or direct HTTPS audio-file link.", invalid_media = "Choose valid media from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", invalid_profile_image = "Choose a valid photo from this phone.", invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This Sky Cloud account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", profile_not_found = "This profile is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", report_unavailable = "Discord reporting is not configured yet.", request_failed = "Your FlipTok request could not be completed. Try again.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to Sky Cloud first.", default = "FlipTok could not complete the request." }, }, darkchat = { name = "DarkChat", newMessage = "New DarkChat message from {sender}", privateNotification = "New DarkChat message", diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua index 7996c81..15b57c1 100644 --- a/sky_phone/source/server/fliptok.lua +++ b/sky_phone/source/server/fliptok.lua @@ -155,11 +155,31 @@ local function load_profile(profile_id, viewer_id) return rows[1] and hydrate_profile(rows[1], viewer_id) or nil end +local function load_accessible_video(video_id, viewer_id) + if type(video_id) ~= "string" or video_id == "" or #video_id > 64 then return nil end + local rows = Bridge.Database.Query([[SELECT v.`profile_id`, v.`comments_enabled` + FROM `sky_phone_fliptok_videos` v + WHERE v.`id` = ? AND v.`status` = 'published' + AND (v.`profile_id` = ? OR v.`visibility` = 'public' OR + (v.`visibility` = 'followers' AND EXISTS( + SELECT 1 FROM `sky_phone_fliptok_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = v.`profile_id`))) + AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` block WHERE + (block.`blocker_id` = ? AND block.`blocked_id` = v.`profile_id`) OR + (block.`blocked_id` = ? AND block.`blocker_id` = v.`profile_id`)) + LIMIT 1]], { video_id, viewer_id, viewer_id, viewer_id, viewer_id }) + return rows[1] +end + local function notify_profile(recipient_id, actor_id, kind, video_id) local rows = Bridge.Database.Query([[SELECT recipient.`account_id`, actor.`display_name` AS `actor_name` FROM `sky_phone_fliptok_profiles` recipient JOIN `sky_phone_fliptok_profiles` actor ON actor.`id` = ? WHERE recipient.`id` = ? LIMIT 1]], { actor_id, recipient_id }) + if not rows[1] then + Bridge.Debug("warn", "[sky_phone] FlipTok notification target or actor no longer exists.", { always = true }) + return + end SkyPhone.NotifyAccountDevices(tonumber(rows[1].account_id), "sky_phone:fliptok:new", { actor = rows[1].actor_name, kind = kind, @@ -544,7 +564,9 @@ Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data) params = { id, current_media_id, index }, } end - Bridge.Database.Transaction(queries) + if not Bridge.Database.Transaction(queries) then + return { success = false, error = "request_failed" } + end return { success = true, data = { id = id } } end) @@ -555,16 +577,13 @@ Bridge.Callbacks.Register("sky_phone:fliptok:react", function(source, data) if type(data) ~= "table" or type(data.id) ~= "string" or (data.kind ~= "like" and data.kind ~= "save") or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end - local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id }) - if not videos[1] then return { success = false, error = "video_not_found" } end - local owner_id = tonumber(videos[1].profile_id) - if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then - return { success = false, error = "blocked" } - end + local video = load_accessible_video(data.id, profile.id) + if not video then return { success = false, error = "video_not_found" } end + local owner_id = tonumber(video.profile_id) if data.active then local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) VALUES (?, ?, ?)", { data.id, profile.id, data.kind }) if affected_rows(inserted) > 0 and data.kind == "like" and owner_id ~= profile.id then - Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'like')", { new_id(), videos[1].profile_id, profile.id, data.id }) + Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'like')", { new_id(), video.profile_id, profile.id, data.id }) notify_profile(owner_id, profile.id, "like", data.id) end else @@ -576,8 +595,13 @@ end) Bridge.Callbacks.Register("sky_phone:fliptok:follow", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end + if not SkyPhone.AllowOperation(source, "fliptok:follow", 30, 60) then + return { success = false, error = "rate_limited" } + end local target_id = type(data) == "table" and tonumber(data.profileId) or nil if not target_id or target_id == profile.id or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end + local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { target_id }) + if not targets[1] then return { success = false, error = "profile_not_found" } end if are_profiles_blocked(profile.id, target_id) then return { success = false, error = "blocked" } end if data.active then local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) SELECT ?, `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ?", { profile.id, target_id }) @@ -594,7 +618,9 @@ end) Bridge.Callbacks.Register("sky_phone:fliptok:comments", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end - if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end + if type(data) ~= "table" or not load_accessible_video(data.id, profile.id) then + return { success = false, error = "video_not_found" } + end local rows = Bridge.Database.Query([[SELECT c.`id`, c.`parent_id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`, p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`, parent_author.`handle` AS `reply_to_handle`, @@ -630,25 +656,24 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment", function(source, data) local parent_id = type(data) == "table" and trim(data.parentId) or nil if type(data) ~= "table" or type(data.id) ~= "string" or not valid_text(body, 1, Config.FlipTok.CommentMaxLength) then return { success = false, error = "invalid_comment" } end if parent_id and #parent_id ~= 36 then return { success = false, error = "invalid_comment" } end - local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' AND `comments_enabled` = 1 LIMIT 1", { data.id }) - if not videos[1] then return { success = false, error = "comments_disabled" } end - local owner_id = tonumber(videos[1].profile_id) - if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then - return { success = false, error = "blocked" } - end + local video = load_accessible_video(data.id, profile.id) + if not video then return { success = false, error = "video_not_found" } end + if tonumber(video.comments_enabled) ~= 1 then return { success = false, error = "comments_disabled" } end + local owner_id = tonumber(video.profile_id) if parent_id then - local parents = Bridge.Database.Query([[SELECT c.`id`, c.`profile_id` FROM `sky_phone_fliptok_comments` c + local parents = Bridge.Database.Query([[SELECT c.`id`, c.`parent_id`, c.`profile_id` FROM `sky_phone_fliptok_comments` c WHERE c.`id` = ? AND c.`video_id` = ? AND c.`status` = 'visible' LIMIT 1]], { parent_id, data.id }) if not parents[1] or are_profiles_blocked(profile.id, tonumber(parents[1].profile_id)) then return { success = false, error = "invalid_comment" } end + parent_id = parents[1].parent_id or parents[1].id end local comment_id = new_id() Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `parent_id`, `body`) VALUES (?, ?, ?, ?, ?)", { comment_id, data.id, profile.id, parent_id, body, }) - if tonumber(videos[1].profile_id) ~= profile.id then - Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'comment')", { new_id(), videos[1].profile_id, profile.id, data.id }) + if owner_id ~= profile.id then + Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'comment')", { new_id(), video.profile_id, profile.id, data.id }) notify_profile(owner_id, profile.id, "comment", data.id) end return { success = true, data = { id = comment_id } } @@ -664,10 +689,13 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment-react", function(source, da if type(comment_id) ~= "string" or #comment_id ~= 36 or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end - local comments = Bridge.Database.Query([[SELECT c.`profile_id` FROM `sky_phone_fliptok_comments` c + local comments = Bridge.Database.Query([[SELECT c.`profile_id`, c.`video_id` FROM `sky_phone_fliptok_comments` c JOIN `sky_phone_fliptok_videos` v ON v.`id` = c.`video_id` WHERE c.`id` = ? AND c.`status` = 'visible' AND v.`status` = 'published' LIMIT 1]], { comment_id }) if not comments[1] then return { success = false, error = "invalid_comment" } end + if not load_accessible_video(comments[1].video_id, profile.id) then + return { success = false, error = "video_not_found" } + end if are_profiles_blocked(profile.id, tonumber(comments[1].profile_id)) then return { success = false, error = "blocked" } end @@ -682,19 +710,23 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment-react", function(source, da end) Bridge.Callbacks.Register("sky_phone:fliptok:view", function(source, data) - local _, error_response = require_profile(source) - if error_response then return error_response end + local profile, error_response = require_profile(source) + if not profile then return error_response end if not SkyPhone.AllowOperation(source, "fliptok:view", 120, 60) then return { success = false, error = "rate_limited" } end - if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end + if type(data) ~= "table" or not load_accessible_video(data.id, profile.id) then + return { success = false, error = "video_not_found" } + end Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `view_count` = `view_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id }) return { success = true } end) Bridge.Callbacks.Register("sky_phone:fliptok:share", function(source, data) - local _, error_response = require_profile(source) - if error_response then return error_response end + local profile, error_response = require_profile(source) + if not profile then return error_response end if not SkyPhone.AllowOperation(source, "fliptok:share", 30, 60) then return { success = false, error = "rate_limited" } end - if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end + if type(data) ~= "table" or not load_accessible_video(data.id, profile.id) then + return { success = false, error = "video_not_found" } + end Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `share_count` = `share_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id }) return { success = true } end) @@ -711,7 +743,10 @@ Bridge.Callbacks.Register("sky_phone:fliptok:profile", function(source, data) end local target = load_profile(tonumber(rows[1].id), viewer.id) if not target then return { success = false, error = "profile_not_found" } end - local videos = list_videos(viewer.id, "v.`profile_id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ?)", { target.id, viewer.id, viewer.id, viewer.id }, 60, 0, "v.`created_at` DESC") + local videos = list_videos(viewer.id, [[v.`profile_id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ? OR + (v.`visibility` = 'followers' AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` profile_follow + WHERE profile_follow.`follower_id` = ? AND profile_follow.`following_id` = v.`profile_id`)))]], + { target.id, viewer.id, viewer.id, viewer.id, viewer.id }, 60, 0, "v.`created_at` DESC") return { success = true, data = { profile = target, videos = videos } } end) @@ -856,12 +891,17 @@ end) Bridge.Callbacks.Register("sky_phone:fliptok:block", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end + if not SkyPhone.AllowOperation(source, "fliptok:block", 20, 60) then + return { success = false, error = "rate_limited" } + end local target_id = type(data) == "table" and tonumber(data.profileId) or nil if not target_id or target_id == profile.id then return { success = false, error = "invalid_request" } end - Bridge.Database.Transaction({ + local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { target_id }) + if not targets[1] then return { success = false, error = "profile_not_found" } end + if not Bridge.Database.Transaction({ { query = "INSERT IGNORE INTO `sky_phone_fliptok_blocks` (`blocker_id`, `blocked_id`) VALUES (?, ?)", params = { profile.id, target_id } }, { query = "DELETE FROM `sky_phone_fliptok_follows` WHERE (`follower_id` = ? AND `following_id` = ?) OR (`follower_id` = ? AND `following_id` = ?)", params = { profile.id, target_id, target_id, profile.id } }, - }) + }) then return { success = false, error = "request_failed" } end return { success = true } end)