From 3fc0ebb08feebafd6587ea9b8e93edf2f6798cd1 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:46:36 +0200 Subject: [PATCH] ENH - overhaul Photos media experience --- .../src/assets/img/app-icons/gallery.webp | Bin 5182 -> 19540 bytes frontend/src/stores/messageMedia.test.ts | 1 + frontend/src/stores/phone.ts | 88 +- frontend/src/types/easyshare.ts | 1 + frontend/src/types/media.ts | 25 + frontend/src/utils/media.test.ts | 33 +- frontend/src/utils/media.ts | 38 +- .../views/apps/GalleryApp.contract.test.ts | 89 ++ frontend/src/views/apps/GalleryApp.vue | 1127 ++++++++++++++--- frontend/testserver/index.cjs | 175 ++- frontend/testserver/smoke.cjs | 22 + sky_phone/config/locales/en.lua | 62 +- sky_phone/source/client/camera.lua | 13 + sky_phone/source/client/main.lua | 2 + sky_phone/source/server/db_migrate.lua | 1 + sky_phone/source/server/easyshare.lua | 40 + sky_phone/source/server/media.lua | 200 ++- sky_phone/source/server/media_import.lua | 1 + 18 files changed, 1651 insertions(+), 267 deletions(-) create mode 100644 frontend/src/views/apps/GalleryApp.contract.test.ts diff --git a/frontend/src/assets/img/app-icons/gallery.webp b/frontend/src/assets/img/app-icons/gallery.webp index 123f373c5ee246ba4fb70c56b3ec152bb36ea1ee..b342b156a8285a7063a9f654a4cb5abfc36a2977 100644 GIT binary patch literal 19540 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 DM#6Sd literal 5182 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 diff --git a/frontend/src/stores/messageMedia.test.ts b/frontend/src/stores/messageMedia.test.ts index e792ca1..266a9e3 100644 --- a/frontend/src/stores/messageMedia.test.ts +++ b/frontend/src/stores/messageMedia.test.ts @@ -6,6 +6,7 @@ import type { PhoneMedia } from '@/types/media' const photo: PhoneMedia = { createdAt: 1_786_035_600, + favorite: false, id: 17, mediaType: 'photo', url: 'https://media.example/photo.jpg', diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0f7e2f5..c01e077 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -362,6 +362,7 @@ const defaultLocales: LocaleTree = { document: 'Document', link: 'Link', location: 'Location', + media: 'Media', note: 'Note', photo: 'Photo', playlist: 'Playlist', @@ -489,7 +490,7 @@ const defaultLocales: LocaleTree = { category: 'Category', status: 'Publication Status', cover: 'Cover Image', - chooseCover: 'Choose from Gallery', + chooseCover: 'Choose from Photos', changeCover: 'Change Cover', removeCover: 'Remove Cover', coverAlt: 'Selected article cover', @@ -561,7 +562,7 @@ const defaultLocales: LocaleTree = { login: 'Log in', register: 'Register', ifruitEmail: 'iFruit address', - gallery: 'Gallery', + gallery: 'Photos', camera: 'Camera', backToLogin: 'Back to CrewLink login', authErrors: { @@ -783,9 +784,9 @@ const defaultLocales: LocaleTree = { photo: 'Profile photo', profilePhotos: 'Profile photos', profilePhotosBody: - 'Choose up to six photos from Gallery. Your first photo is shown first.', + 'Choose up to six photos from Photos. Your first photo is shown first.', addPhotos: 'Add photos', - choosePhotos: 'Choose from Gallery', + choosePhotos: 'Choose from Photos', primaryPhoto: 'Main', removePhoto: 'Remove photo {number}', yourName: 'Your name', @@ -904,7 +905,7 @@ const defaultLocales: LocaleTree = { errors: { invalid_profile: 'Check your name, age and profile text.', invalid_profile_photos: - 'Choose up to six photos from your own Gallery.', + 'Choose up to six photos from your own Photos library.', request_failed: 'Flare could not save those changes. Try again.', invalid_target: 'This profile is no longer available.', invalid_choice: 'That swipe could not be saved.', @@ -985,8 +986,8 @@ const defaultLocales: LocaleTree = { story: 'Story', choosePhotos: 'Choose photos', choosePhoto: 'Choose photo', - choosePhotosHint: 'Select up to five photos from Gallery', - chooseStoryHint: 'Select one photo from Gallery', + choosePhotosHint: 'Select up to five photos from Photos', + chooseStoryHint: 'Select one photo from Photos', selectedPhotos: '{count} selected', changePhotos: 'Change selection', caption: 'Caption', @@ -1231,7 +1232,7 @@ const defaultLocales: LocaleTree = { composerAudience: 'Everyone can join the conversation', mediaTitle: 'Add to your feather', mediaBody: 'Share up to four photos from your phone or take a new one.', - chooseGallery: 'Gallery', + chooseGallery: 'Photos', chooseGalleryBody: 'Choose one or more saved photos', takePhoto: 'Camera', takePhotoBody: 'Take a new photo now', @@ -1359,7 +1360,7 @@ const defaultLocales: LocaleTree = { createBody: 'Choose a clip, set the cover and sound, then decide who can watch it.', chooseVideo: 'Choose a video', - chooseVideoHint: 'Select one from Gallery', + chooseVideoHint: 'Select one from Photos', changeVideo: 'Change', caption: 'Caption', captionPlaceholder: 'Write a caption...', @@ -1435,7 +1436,7 @@ const defaultLocales: LocaleTree = { accountType: 'Account type', profilePhoto: 'Profile photo', changePhoto: 'Change photo', - chooseFromGallery: 'Gallery', + chooseFromGallery: 'Photos', takePhoto: 'Camera', removePhoto: 'Remove photo', noConnections: 'No profiles to show', @@ -1797,7 +1798,7 @@ const defaultLocales: LocaleTree = { phoneNumber: 'Phone Number', officialContact: 'Official company contact', choosePhoto: 'Choose Contact Photo', - chooseGallery: 'Gallery', + chooseGallery: 'Photos', takePhoto: 'Camera', removePhoto: 'Remove Photo', call: 'Call', @@ -2348,7 +2349,7 @@ const defaultLocales: LocaleTree = { editProfile: 'Edit Profile', editProfileBody: 'Change your public name and profile photo', profilePhoto: 'Profile photo', - gallery: 'Gallery', + gallery: 'Photos', camera: 'Camera', profileDetails: 'Profile details', profileName: 'Name', @@ -2854,13 +2855,13 @@ const defaultLocales: LocaleTree = { publish: 'Publish', addPhotos: 'Add photos', addPhotosBody: - 'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.', - chooseGallery: 'Choose from gallery', + 'Choose up to six photos from Photos or take new ones. Photos are optional and the first becomes the cover.', + chooseGallery: 'Choose from Photos', chooseGalleryBody: 'Use photos saved on this phone.', takePhotos: 'Take photos', takePhotosBody: 'Take several new photos for this listing.', selectedPhotos: 'Selected photos', - gallery: 'Gallery', + gallery: 'Photos', camera: 'Camera', takePhoto: 'Take photo', cameraHint: @@ -3006,7 +3007,7 @@ const defaultLocales: LocaleTree = { profileBio: 'Bio', profileBioPlaceholder: 'Tell the city a little about yourself...', profilePhoto: 'Profile photo', - profilePhotoHint: 'Choose a photo from your Gallery or take a new one.', + profilePhotoHint: 'Choose a photo from Photos or take a new one.', removeProfilePhoto: 'Remove photo', profileSave: 'Save profile', profileCancel: 'Cancel', @@ -3031,7 +3032,7 @@ const defaultLocales: LocaleTree = { photos: 'Photos', optional: 'optional', camera: 'Camera', - gallery: 'Gallery', + gallery: 'Photos', photoLimit: 'You can add up to six photos.', cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', @@ -3176,11 +3177,11 @@ const defaultLocales: LocaleTree = { returnHelp: 'Space to return', uploading: '{count} uploading', saving: 'Saving video...', - openGallery: 'Open Gallery', + openGallery: 'Open Photos', takePhoto: 'Take photo', startRecording: 'Start recording', stopRecording: 'Stop recording', - saved: 'Saved to Gallery.', + saved: 'Saved to Photos.', zoom: 'Set camera zoom to {zoom}', errors: { cancelled: 'Capture cancelled.', @@ -3607,15 +3608,50 @@ const defaultLocales: LocaleTree = { }, }, photos: { - name: 'Gallery', - count: '{count} items', - loading: 'Loading Gallery...', + name: 'Photos', + library: 'Library', + counts: { + all: '{count} Objects', + allOne: '{count} Object', + favorite: '{count} Favorite', + favorites: '{count} Favorites', + photo: '{count} Photo', + photos: '{count} Photos', + video: '{count} Video', + videos: '{count} Videos', + }, + sorting: { + action: 'Sort Photos', + title: 'Sort Order', + show: 'Show', + allItems: 'All Items', + favorites: 'Favorites', + newestFirst: 'Newest First', + oldestFirst: 'Oldest First', + }, + selection: { + action: 'Select', + selected: '{count} Selected', + share: 'Share Selected', + shareTitle: '{count} Media Items', + shareCopy: '{count} photos and videos shared from Photos.', + delete: 'Delete Selected', + deleteTitle: 'Delete Selected Media?', + deleteBody: 'The selected photos and videos will be permanently deleted.', + deleted: '{count} media items deleted.', + limit: 'You can select up to 50 media items.', + }, + loading: 'Loading Photos...', emptyTitle: 'No Photos or Videos', emptyBody: 'Captures from Camera will appear here.', photo: 'Photo', video: 'Video', - photoAlt: 'Gallery photo', - videoAlt: 'Gallery video', + photoAlt: 'Photo library image', + videoAlt: 'Photo library video', + today: 'Today', + yesterday: 'Yesterday', + addFavorite: 'Add to Favorites', + removeFavorite: 'Remove from Favorites', delete: 'Delete media', deleteTitle: 'Delete Media?', deleteBody: 'This photo or video will be permanently deleted.', @@ -3650,7 +3686,7 @@ const defaultLocales: LocaleTree = { invalid_media_type: 'The media type is invalid.', invalid_upload: 'The upload could not be verified.', invalid_upload_token: 'The upload session is no longer valid.', - missing_config: 'Gallery uploads are not configured.', + missing_config: 'Photos uploads are not configured.', import_media_not_allowed: 'This media item is not allowed.', import_media_too_large: 'This media item is too large.', import_media_unavailable: 'This media item is no longer available.', @@ -3667,7 +3703,7 @@ const defaultLocales: LocaleTree = { 'Another media operation is already in progress.', owner_changed: 'The active phone account changed.', rate_limited: 'Too many media actions. Try again shortly.', - request_failed: 'The Gallery request failed.', + request_failed: 'The Photos request failed.', request_timeout: 'The media service timed out.', unsupported: 'This media format is not supported.', upload_failed: 'The media upload failed.', diff --git a/frontend/src/types/easyshare.ts b/frontend/src/types/easyshare.ts index 8b9665d..f8f1f85 100644 --- a/frontend/src/types/easyshare.ts +++ b/frontend/src/types/easyshare.ts @@ -5,6 +5,7 @@ export type EasyShareKind = | 'document' | 'link' | 'location' + | 'media' | 'note' | 'photo' | 'playlist' diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts index e39ae62..de819b3 100644 --- a/frontend/src/types/media.ts +++ b/frontend/src/types/media.ts @@ -3,11 +3,29 @@ export type GalleryFilter = 'all' | MediaType export type PhoneMedia = { createdAt: number + favorite: boolean id: number mediaType: MediaType + thumbnailUrl?: string url: string } +export type FavoriteResult = { + favorite: boolean + id: number +} + +export type GalleryCounts = { + all: number + favoritePhotos: number + favorites: number + favoriteVideos: number + photos: number + videos: number +} + +export type GallerySortOrder = 'newest' | 'oldest' + export type MediaImportSource = { id: string label: string @@ -75,3 +93,10 @@ export type DeleteResult = { id?: number success: boolean } + +export type DeleteManyResult = { + correlationId: string + deletedIds?: number[] + error?: string + success: boolean +} diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts index c2fd8de..8a6e1a9 100644 --- a/frontend/src/utils/media.test.ts +++ b/frontend/src/utils/media.test.ts @@ -1,17 +1,20 @@ import { describe, expect, it } from 'vitest' import { + bottomRightGridPosition, filterMedia, formatMediaSize, formatRecordingDuration, hasNextMediaPage, mediaErrorKey, mergeMedia, + orderMedia, + orderMediaOldestFirst, } from './media' const media = [ - { createdAt: 10, id: 1, mediaType: 'photo' as const, url: 'photo' }, - { createdAt: 20, id: 2, mediaType: 'video' as const, url: 'video' }, + { createdAt: 10, favorite: false, id: 1, mediaType: 'photo' as const, url: 'photo' }, + { createdAt: 20, favorite: false, id: 2, mediaType: 'video' as const, url: 'video' }, ] describe('media utilities', () => { @@ -24,8 +27,8 @@ describe('media utilities', () => { it('merges pages without duplicates and keeps newest first', () => { expect( mergeMedia(media, [ - { createdAt: 30, id: 1, mediaType: 'photo', url: 'updated' }, - { createdAt: 25, id: 3, mediaType: 'photo', url: 'new' }, + { createdAt: 30, favorite: true, id: 1, mediaType: 'photo', url: 'updated' }, + { createdAt: 25, favorite: false, id: 3, mediaType: 'photo', url: 'new' }, ]).map((entry) => [entry.id, entry.url]), ).toEqual([ [1, 'updated'], @@ -34,6 +37,28 @@ describe('media utilities', () => { ]) }) + it('orders the photo grid from oldest to newest', () => { + expect(orderMediaOldestFirst(media).map((entry) => entry.id)).toEqual([ + 1, 2, + ]) + }) + + it('orders the photo grid in the selected direction', () => { + expect(orderMedia(media, 'oldest').map((entry) => entry.id)).toEqual([1, 2]) + expect(orderMedia(media, 'newest').map((entry) => entry.id)).toEqual([2, 1]) + }) + + it('fills the gallery from bottom-right to top-left', () => { + expect(bottomRightGridPosition(0, 1)).toEqual({ column: 3, row: 1 }) + expect(bottomRightGridPosition(0, 3)).toEqual({ column: 3, row: 1 }) + expect(bottomRightGridPosition(1, 3)).toEqual({ column: 2, row: 1 }) + expect(bottomRightGridPosition(2, 3)).toEqual({ column: 1, row: 1 }) + expect(bottomRightGridPosition(0, 4)).toEqual({ column: 3, row: 2 }) + expect(bottomRightGridPosition(3, 4)).toEqual({ column: 3, row: 1 }) + expect(bottomRightGridPosition(4, 5)).toEqual({ column: 2, row: 1 }) + expect(bottomRightGridPosition(10, 11)).toEqual({ column: 2, row: 1 }) + }) + it('loads another gallery page only after a full 30-item batch', () => { expect(hasNextMediaPage(30)).toBe(true) expect(hasNextMediaPage(29)).toBe(false) diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts index d38bca8..afb0618 100644 --- a/frontend/src/utils/media.ts +++ b/frontend/src/utils/media.ts @@ -1,4 +1,9 @@ -import type { GalleryFilter, MediaType, PhoneMedia } from '@/types/media' +import type { + GalleryFilter, + GallerySortOrder, + MediaType, + PhoneMedia, +} from '@/types/media' export const MEDIA_PAGE_SIZE = 30 @@ -26,6 +31,37 @@ export function mergeMedia( ) } +export function orderMediaOldestFirst(media: PhoneMedia[]): PhoneMedia[] { + return [...media].sort( + (left, right) => left.createdAt - right.createdAt || left.id - right.id, + ) +} + +export function orderMedia( + media: PhoneMedia[], + sortOrder: GallerySortOrder, +): PhoneMedia[] { + return sortOrder === 'oldest' + ? orderMediaOldestFirst(media) + : [...media].sort( + (left, right) => + right.createdAt - left.createdAt || right.id - left.id, + ) +} + +export function bottomRightGridPosition( + itemIndex: number, + itemCount: number, + columnCount = 3, +): { column: number; row: number } { + const count = Math.max(1, itemCount) + const index = Math.max(0, Math.min(itemIndex, count - 1)) + return { + column: columnCount - (index % columnCount), + row: Math.ceil(count / columnCount) - Math.floor(index / columnCount), + } +} + export function hasNextMediaPage(pageLength: number): boolean { return pageLength === MEDIA_PAGE_SIZE } diff --git a/frontend/src/views/apps/GalleryApp.contract.test.ts b/frontend/src/views/apps/GalleryApp.contract.test.ts new file mode 100644 index 0000000..7baac40 --- /dev/null +++ b/frontend/src/views/apps/GalleryApp.contract.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./GalleryApp.vue', import.meta.url), 'utf8') +const headerActions = source.slice( + source.indexOf('', source.indexOf(' - - + + + + {{ selectedCountText }} + + + + + + + + - - + - - + + + + + + + + + + + + + + + + + + + {{ phone.t('Apps.photos.sorting.title') }} + + + {{ phone.t('Apps.photos.sorting.newestFirst') }} + + + {{ phone.t('Apps.photos.sorting.oldestFirst') }} + + + + + {{ phone.t('Apps.photos.sorting.show') }} + + + {{ phone.t('Apps.photos.sorting.allItems') }} + + + {{ phone.t('Apps.photos.sorting.favorites') }} + + + + + {{ phone.t('Common.cancel') }} + + + + + + +

{{ phone.t('Apps.photos.selection.deleteBody') }}

+ +
+ { top: auto !important; bottom: 24px; } +.gallery-filter-navbar :deep(> div:nth-child(-n + 2)) { + display: none; +} .gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; - padding: 8px 2px 0; + margin-top: auto; + padding: 8px 2px 98px; +} +.gallery-library-navbar { + min-height: calc( + var(--sky-safe-area-top) + var(--sky-navbar-height) + + var(--sky-navbar-large-title-height) - 30px + ); +} +.gallery-library-navbar :deep(.sky-navbar__right) { + z-index: 2; + transform: translateY(calc(var(--sky-navbar-height) - 30px)); +} +.gallery-library-navbar :deep(.sky-navbar__inner) { + margin-bottom: 0; +} +.gallery-library-navbar :deep(.sky-navbar__title-container) { + padding-right: calc(200px + var(--sky-safe-area-right)); + transform: translateY( + calc(0px - var(--sky-navbar-collapse-offset) - 30px) + ); +} +.gallery-header-actions { + width: max-content; + flex: none; + display: flex; + align-items: center; + gap: 6px; +} +.gallery-header-tool { + flex: none; +} +.gallery-header-tool--icon { + width: var(--sky-touch-target); + justify-content: center; +} +.gallery-header-action { + color: var(--sky-text); + font-size: 14px; + font-weight: 600; +} +.gallery-sort-option { + justify-content: space-between; + text-align: left; +} +.gallery-selection-toolbar { + position: absolute; + right: 0; + bottom: 0; + left: 0; +} +.gallery-selection-toolbar :deep(.sky-toolbar__inner) { + width: 100%; +} +.gallery-selection-count { + padding: 0 14px; + color: var(--sky-muted); + font-size: 14px; + font-weight: 600; +} +.gallery-selection-actions { + gap: var(--sky-space-2); } .gallery-grid--fill { flex: 1; @@ -1052,10 +1833,23 @@ onBeforeUnmount(() => { display: flex; flex-direction: column; overflow: hidden; + background: #000; + color: #fff; +} +.gallery-detail-navbar { + flex: 0 0 auto; +} +.gallery-detail-navbar :deep(.sky-navbar__title) { + color: #fff; + font-size: 17px; +} +.gallery-detail-navbar :deep(.sky-navbar__subtitle) { + color: #fff; + font-size: 13px; + font-weight: 600; } .gallery-detail-stage { position: relative; - container-type: size; min-height: 0; flex: 1; overflow: hidden; @@ -1072,16 +1866,6 @@ onBeforeUnmount(() => { transform-origin: center; transition: transform 0.25s ease; } -.gallery-detail--landscape .gallery-detail-media { - position: absolute; - top: 50%; - left: 50%; - width: 720px; - height: 368px; - width: 100cqh; - height: 100cqw; - transform: translate(-50%, -50%) rotate(90deg); -} .gallery-detail-media img, .gallery-detail-media video { max-width: 100%; @@ -1093,25 +1877,20 @@ onBeforeUnmount(() => { .gallery-detail-media img { transition: transform 0.12s ease-out; } -.gallery-detail :deep(svg) { - transition: transform 0.25s ease; -} -.gallery-detail--landscape :deep(svg) { - transform: rotate(90deg); -} -.gallery-zoom-controls { - flex: 0 0 48px; - display: flex; - align-items: center; - justify-content: center; - gap: 30px; - border-bottom: 1px solid #8e8e9333; -} -.gallery-detail-date { +.gallery-detail-toolbar { flex: 0 0 auto; - padding: 8px 18px 10px; - color: #8e8e93; - text-align: center; - font-size: 12px; +} +.gallery-detail-toolbar :deep(.sky-toolbar__inner) { + width: 100%; + gap: 8px; +} +.gallery-detail-toolbar :deep(.sky-toolbar__background) { + background: linear-gradient(to top, #000 72%, transparent); +} +.gallery-detail-tools { + flex: 1; + justify-content: center; + gap: 0; + padding: 0 2px; } diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 566c4d5..cbfe76d 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -2472,38 +2472,140 @@ let recentCalls = [ status: 'missed', }, ] +function mockGalleryImage(title, sky, landscape, accent) { + const svg = `${title}Sky Phone test media` + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}` +} + let mockMedia = [ { - createdAt: Date.now() - 60_000, + createdAt: Date.now() - 4 * 60_000, + favorite: false, id: 1, mediaType: 'photo', - url: 'https://picsum.photos/seed/sky-phone-1/600/800', + url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'), }, { - createdAt: Date.now() - 120_000, + createdAt: Date.now() - 18 * 60_000, + favorite: true, id: 2, mediaType: 'video', + thumbnailUrl: mockGalleryImage('Flower Video', '#7f1d1d', '#365314', '#fb7185'), url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', }, { - createdAt: Date.now() - 180_000, + createdAt: Date.now() - 47 * 60_000, + favorite: true, id: 3, mediaType: 'photo', - url: 'https://picsum.photos/seed/sky-phone-3/800/600', + url: mockGalleryImage('Beach Drive', '#0369a1', '#164e63', '#fbbf24'), }, { - createdAt: Date.now() - 240_000, + createdAt: Date.now() - 2 * 60 * 60_000, + favorite: false, id: 4, mediaType: 'photo', - url: 'https://picsum.photos/seed/sky-phone-4/800/600', + url: mockGalleryImage('Mountain Road', '#475569', '#334155', '#e2e8f0'), }, { - createdAt: Date.now() - 300_000, + createdAt: Date.now() - 5 * 60 * 60_000, + favorite: false, id: 5, mediaType: 'photo', - url: 'https://picsum.photos/seed/sky-phone-5/800/600', + url: mockGalleryImage('Downtown', '#312e81', '#1e293b', '#f472b6'), + }, + { + createdAt: Date.now() - 8 * 60 * 60_000, + favorite: false, + id: 6, + mediaType: 'video', + thumbnailUrl: mockGalleryImage('Sintel Video', '#9a3412', '#431407', '#fdba74'), + url: 'https://media.w3.org/2010/05/sintel/trailer.mp4', + }, + { + createdAt: Date.now() - 12 * 60 * 60_000, + favorite: false, + id: 7, + mediaType: 'photo', + url: mockGalleryImage('Palm Sunset', '#c2410c', '#422006', '#facc15'), + }, + { + createdAt: Date.now() - 26 * 60 * 60_000, + favorite: true, + id: 8, + mediaType: 'photo', + url: mockGalleryImage('Sports Car', '#1f2937', '#111827', '#ef4444'), + }, + { + createdAt: Date.now() - 31 * 60 * 60_000, + favorite: false, + id: 9, + mediaType: 'photo', + url: mockGalleryImage('Boardwalk', '#0e7490', '#713f12', '#67e8f9'), + }, + { + createdAt: Date.now() - 46 * 60 * 60_000, + favorite: false, + id: 10, + mediaType: 'video', + thumbnailUrl: mockGalleryImage('Bunny Video', '#166534', '#14532d', '#86efac'), + url: 'https://www.w3schools.com/html/mov_bbb.mp4', + }, + { + createdAt: Date.now() - 3 * 86_400_000, + favorite: false, + id: 11, + mediaType: 'photo', + url: mockGalleryImage('Vinewood', '#1d4ed8', '#166534', '#f8fafc'), + }, + { + createdAt: Date.now() - 5 * 86_400_000, + favorite: true, + id: 12, + mediaType: 'photo', + url: mockGalleryImage('Friends', '#7e22ce', '#4c1d95', '#f0abfc'), }, ] +mockMedia.push( + ...[ + ['Airport Lights', '#172554', '#1e3a8a', '#38bdf8'], + ['Desert Route', '#fb923c', '#7c2d12', '#fde047'], + ['Harbor Morning', '#0e7490', '#164e63', '#a5f3fc'], + ['Forest Trail', '#166534', '#14532d', '#bef264'], + ['Neon Alley', '#581c87', '#1e1b4b', '#f472b6'], + ['Lake House', '#0369a1', '#3f6212', '#fef08a'], + ['Snow Pass', '#94a3b8', '#334155', '#f8fafc'], + ['Night Drive', '#111827', '#312e81', '#22d3ee'], + ['Canyon View', '#b45309', '#78350f', '#fdba74'], + ['Green Hills', '#15803d', '#365314', '#86efac'], + ['Purple Sky', '#6b21a8', '#312e81', '#e879f9'], + ['Ocean Road', '#0284c7', '#0f766e', '#67e8f9'], + ['City Park', '#4d7c0f', '#14532d', '#facc15'], + ['Sunrise Pier', '#ea580c', '#7c2d12', '#fef3c7'], + ['Rainy Street', '#334155', '#0f172a', '#60a5fa'], + ['Golden Fields', '#ca8a04', '#713f12', '#fef08a'], + ['Metro Station', '#1f2937', '#374151', '#f43f5e'], + ['Island Bay', '#0891b2', '#115e59', '#f0fdfa'], + ['Cliff Road', '#7c3aed', '#3f3f46', '#c4b5fd'], + ['Old Town', '#9a3412', '#451a03', '#fed7aa'], + ['Racing Night', '#991b1b', '#111827', '#fb7185'], + ['Quiet Beach', '#0ea5e9', '#155e75', '#fde68a'], + ['Hilltop', '#65a30d', '#3f6212', '#d9f99d'], + ['Downtown Rain', '#3730a3', '#1e293b', '#93c5fd'], + ['Coastal Sunset', '#be123c', '#7c2d12', '#fbbf24'], + ['Country Road', '#854d0e', '#365314', '#fde047'], + ['Blue Mountains', '#1d4ed8', '#334155', '#bfdbfe'], + ['Palm Beach', '#0d9488', '#166534', '#fcd34d'], + ['Skyline', '#4338ca', '#111827', '#a78bfa'], + ['Campfire', '#c2410c', '#431407', '#fef08a'], + ].map(([title, sky, landscape, accent], index) => ({ + createdAt: Date.now() - (6 + index) * 86_400_000, + favorite: index % 7 === 0, + id: 13 + index, + mediaType: 'photo', + url: mockGalleryImage(title, sky, landscape, accent), + })), +) const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business'] let weazelNewsSequence = 8 let weazelNewsArticles = [ @@ -8265,6 +8367,7 @@ app.post('/api/:endpoint', (request, response) => { const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video') const media = { createdAt: Date.now(), + favorite: false, id, mediaType, url: @@ -8353,6 +8456,24 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: media }) return } + if (endpoint === 'gallery:counts') { + response.json({ + success: true, + data: { + all: mockMedia.length, + favoritePhotos: mockMedia.filter( + (item) => item.mediaType === 'photo' && item.favorite, + ).length, + favorites: mockMedia.filter((item) => item.favorite).length, + favoriteVideos: mockMedia.filter( + (item) => item.mediaType === 'video' && item.favorite, + ).length, + photos: mockMedia.filter((item) => item.mediaType === 'photo').length, + videos: mockMedia.filter((item) => item.mediaType === 'video').length, + }, + }) + return + } if (endpoint === 'gallery:list') { if (request.body.mockState === 'error') { response.json({ success: false, error: 'service_unavailable' }) @@ -8362,9 +8483,12 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: [] }) return } - const filtered = request.body.mediaType + const typeFiltered = request.body.mediaType ? mockMedia.filter((item) => item.mediaType === request.body.mediaType) : mockMedia + const filtered = request.body.favoriteOnly + ? typeFiltered.filter((item) => item.favorite) + : typeFiltered const offset = Number(request.body.offset) || 0 const limit = Number(request.body.limit) || 30 response.json({ @@ -8387,6 +8511,37 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true }) return } + if (endpoint === 'gallery:favorite') { + const item = mockMedia.find((entry) => entry.id === Number(request.body.id)) + if (!item || typeof request.body.favorite !== 'boolean') { + response.json({ success: false, error: 'media_not_found' }) + return + } + item.favorite = request.body.favorite + response.json({ + success: true, + data: { favorite: item.favorite, id: item.id }, + }) + return + } + if (endpoint === 'gallery:delete-many') { + const ids = Array.isArray(request.body.ids) + ? request.body.ids.map(Number).filter(Number.isInteger) + : [] + const deletedIds = mockMedia + .filter((item) => ids.includes(item.id)) + .map((item) => item.id) + mockMedia = mockMedia.filter((item) => !deletedIds.includes(item.id)) + response.json({ + success: true, + data: { + correlationId: request.body.correlationId, + deletedIds, + success: true, + }, + }) + return + } if (endpoint === 'account:login' || endpoint === 'account:register') { authenticated = true linkedAccount = { diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 1b2c204..79206a1 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -31,6 +31,7 @@ const browserDataRequests = [ ['fliptok:discover', { limit: 20 }], ['fliptok:activities', {}], ['gallery:list', {}], + ['gallery:counts', {}], ['garage:vehicles', {}], ['garage:valet-state', {}], ['housing:overview', {}], @@ -83,6 +84,27 @@ async function expectSuccess(baseUrl, endpoint, body = {}, data = false) { } async function verifyStatefulActions(baseUrl) { + let gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true) + assert(gallery.length >= 10, 'gallery:list did not include enough test media') + assert( + gallery.filter((item) => item.mediaType === 'video').length >= 3, + 'gallery:list did not include enough test videos', + ) + assert( + gallery + .filter((item) => item.mediaType === 'video') + .every((item) => typeof item.thumbnailUrl === 'string'), + 'gallery:list returned a test video without a thumbnail', + ) + await expectSuccess( + baseUrl, + 'gallery:favorite', + { favorite: true, id: gallery[0].id }, + true, + ) + gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true) + assert.equal(gallery.find((item) => item.id === gallery[0].id)?.favorite, true) + const memoBootstrap = await expectSuccess( baseUrl, 'development:bootstrap', diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index afdb1f8..f984c98 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -210,7 +210,7 @@ Locales["en"] = { category = "Category", status = "Publication Status", cover = "Cover Image", - chooseCover = "Choose from Gallery", + chooseCover = "Choose from Photos", changeCover = "Change Cover", removeCover = "Remove Cover", coverAlt = "Selected article cover", @@ -269,7 +269,7 @@ Locales["en"] = { signInTitle = "Connect your Sky Cloud account", signInBody = "CrewLink uses your private Sky Cloud identity to keep groups and roles available across your phones.", openSettings = "Open Sky Cloud settings", authEyebrow = "Private crew network", authTitle = "Welcome to CrewLink", authBody = "Your iFruit email is linked automatically. Use your CrewLink username to continue.", - login = "Log in", register = "Register", ifruitEmail = "iFruit address", gallery = "Gallery", camera = "Camera", backToLogin = "Back to CrewLink login", + login = "Log in", register = "Register", ifruitEmail = "iFruit address", gallery = "Photos", camera = "Camera", backToLogin = "Back to CrewLink login", authErrors = { no_ifruit_account = "Sign in to your Sky Cloud account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", profile_not_found = "No CrewLink profile exists for this iFruit email.", profile_exists = "This iFruit email already has a CrewLink profile.", @@ -314,7 +314,7 @@ Locales["en"] = { flare = { name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private Sky Cloud account.", createProfile = "Create your profile", welcome = "Find your spark", welcomeBody = "Build a profile and meet people across Los Santos.", - photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Choose up to six photos from Gallery. Your first photo is shown first.", addPhotos = "Add photos", choosePhotos = "Choose from Gallery", primaryPhoto = "Main", removePhoto = "Remove photo {number}", + photo = "Profile photo", profilePhotos = "Profile photos", profilePhotosBody = "Choose up to six photos from Photos. Your first photo is shown first.", addPhotos = "Add photos", choosePhotos = "Choose from Photos", primaryPhoto = "Main", removePhoto = "Remove photo {number}", yourName = "Your name", namePlaceholder = "What should people call you?", age = "Age", bio = "About you", bioPlaceholder = "A short line that starts a conversation...", gender = "I am", showMe = "Show me", woman = "Woman", man = "Man", nonbinary = "Non-binary", women = "Women", men = "Men", nonbinaryPeople = "Non-binary people", everyone = "Everyone", minimumAge = "Minimum age", maximumAge = "Maximum age", start = "Start exploring", saveProfile = "Save profile", editProfileBody = "Keep your profile fresh and unmistakably you.", @@ -336,7 +336,7 @@ Locales["en"] = { messagePlaceholder = "Write a message", itsAMatch = "It's a spark!", matchBody = "You and {name} liked each other.", sayHello = "Say hello", keepSwiping = "Keep exploring", newMatchNotification = "You and {sender} found a spark!", newMessageNotification = "New message from {sender}", lookingFor = { longTerm = "Long-term connection", dates = "Good dates", friends = "New friends" }, errors = { - invalid_profile = "Check your name, age and profile text.", invalid_profile_photos = "Choose up to six photos from your own Gallery.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.", + invalid_profile = "Check your name, age and profile text.", invalid_profile_photos = "Choose up to six photos from your own Photos library.", request_failed = "Flare could not save those changes. Try again.", invalid_target = "This profile is no longer available.", invalid_choice = "That swipe could not be saved.", invalid_discovery = "That Discovery setting is invalid.", discovery_disabled = "Enable Discovery before swiping.", nothing_to_rewind = "There is no recent swipe to rewind.", cannot_rewind_match = "A swipe that created a match cannot be rewound.", match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.", }, @@ -365,7 +365,7 @@ Locales["en"] = { noResults = "No results", noResultsBody = "Try another username, caption, place, or hashtag.", newPost = "New Post", newStory = "New Story", post = "Post", story = "Story", choosePhotos = "Choose photos", choosePhoto = "Choose photo", - choosePhotosHint = "Select up to five photos from Gallery", chooseStoryHint = "Select one photo from Gallery", + choosePhotosHint = "Select up to five photos from Photos", chooseStoryHint = "Select one photo from Photos", selectedPhotos = "{count} selected", changePhotos = "Change selection", caption = "Caption", captionPlaceholder = "Write a caption...", storyTextPlaceholder = "Add a short story text...", location = "Location", locationPlaceholder = "Add a place", allowComments = "Allow comments", @@ -435,7 +435,7 @@ Locales["en"] = { createProfile = "Create Feather profile", profileDetailsHint = "Choose how people will recognize you.", displayName = "Display name", displayNamePlaceholder = "Your name", handle = "Username", handlePlaceholder = "your_username", handleHint = "3-30 letters, numbers or underscores", bio = "Bio", bioPlaceholder = "What should people know about you?", start = "Join Feather", chooseAvatar = "Choose profile photo", changeAvatar = "Change photo", newPost = "New feather", reply = "Reply", replyingTo = "Replying to @{handle}", quote = "Quote", post = "Post", posting = "Posting...", - composerPlaceholder = "What's happening?", replyPlaceholder = "Post your reply", addPhotos = "Add photos", removePhoto = "Remove photo {number}", charactersLeft = "{count} left", composerAudience = "Everyone can join the conversation", mediaTitle = "Add to your feather", mediaBody = "Share up to four photos from your phone or take a new one.", chooseGallery = "Gallery", chooseGalleryBody = "Choose one or more saved photos", takePhoto = "Camera", takePhotoBody = "Take a new photo now", selectedPhotos = "Selected photos", photoLimit = "You can add up to four photos.", + composerPlaceholder = "What's happening?", replyPlaceholder = "Post your reply", addPhotos = "Add photos", removePhoto = "Remove photo {number}", charactersLeft = "{count} left", composerAudience = "Everyone can join the conversation", mediaTitle = "Add to your feather", mediaBody = "Share up to four photos from your phone or take a new one.", chooseGallery = "Photos", chooseGalleryBody = "Choose one or more saved photos", takePhoto = "Camera", takePhotoBody = "Take a new photo now", selectedPhotos = "Selected photos", photoLimit = "You can add up to four photos.", emptyFeed = "Your timeline is quiet", emptyFeedBody = "Follow people or post the first feather.", emptyExplore = "No results found", emptyExploreBody = "Try another search or explore a topic.", noTrendingHashtags = "No hashtags yet", noTrendingHashtagsBody = "Hashtags from published posts will appear here.", happeningNow = "What's happening", trends = "Trends for you", trendingIn = "Trending in Los Santos", openTopic = "View conversation", postsCount = "{count} posts", exploreTabs = { explore = "Explore", trending = "Trends", news = "News" }, @@ -455,7 +455,7 @@ Locales["en"] = { 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 = "Choose a clip, set the cover and sound, then decide who can watch it.", chooseVideo = "Choose a video", chooseVideoHint = "Select one from Gallery", changeVideo = "Change", + newVideo = "New FlipTok", createTitle = "Create your next FlipTok", createBody = "Choose a clip, set the cover and sound, then decide who can watch it.", chooseVideo = "Choose a video", chooseVideoHint = "Select one from Photos", changeVideo = "Change", 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.", @@ -468,7 +468,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 = "Gallery", takePhoto = "Camera", removePhoto = "Remove photo", noConnections = "No profiles to show", + 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", 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.", @@ -850,7 +850,7 @@ Locales["en"] = { noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites", addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number", officialContact = "Official company contact", - choosePhoto = "Choose Contact Photo", chooseGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove Photo", + choosePhoto = "Choose Contact Photo", chooseGallery = "Photos", takePhoto = "Camera", removePhoto = "Remove Photo", message = "Message", video = "Video", mail = "Mail", defaultLabel = "Default", privateLabel = "Private", contactCard = "Contact Card", removeContact = "Remove Contact", returnToCall = "Return to Call", mobile = "Mobile", messagesProfile = "Messages", notes = "Notes", sendMessage = "Send Message", shareContact = "Share Contact", addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites", @@ -1097,7 +1097,7 @@ Locales["en"] = { driverModeBody = "Accept ride requests and track your earnings", completedRides = "Completed", cancelledRides = "Cancelled", acceptance = "Acceptance", rating = "Rating", notAvailable = "N/A", profile = "Your profile", editProfile = "Edit Profile", editProfileBody = "Change your public name and profile photo", - profilePhoto = "Profile photo", gallery = "Gallery", camera = "Camera", profileDetails = "Profile details", + profilePhoto = "Profile photo", gallery = "Photos", camera = "Camera", profileDetails = "Profile details", profileName = "Name", profileNamePlaceholder = "Your SkyRide name", removeProfilePhoto = "Remove profile photo", saveProfile = "Save Profile", profileSaved = "Your SkyRide profile was updated.", memberSince = "SkyRide member since {date}", rideComplete = "Ride Complete", rateRide = "Rate your ride", @@ -1257,8 +1257,8 @@ Locales["en"] = { name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape", portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted", focusHelp = "Space for movement", returnHelp = "Space to return", uploading = "{count} uploading", - saving = "Saving video...", openGallery = "Open Gallery", takePhoto = "Take photo", - startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Gallery.", + saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo", + startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.", zoom = "Set camera zoom to {zoom}", errors = { cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.", @@ -1389,7 +1389,7 @@ Locales["en"] = { createProfile = "Create your CityMarkt profile", editProfile = "Edit profile", profileIntro = "Your iFruit email stays linked to this profile.", profileEmail = "iFruit email", displayName = "Display name", profileBio = "About you", saveProfile = "Save profile", - cancel = "Cancel", profileSaved = "Your profile was saved.", chooseGallery = "Gallery", + cancel = "Cancel", profileSaved = "Your profile was saved.", chooseGallery = "Photos", takePhoto = "Camera", removeProfilePhoto = "Remove photo", phone = "Phone", contactSeller = "Contact seller", messagePlaceholder = "Hi, is this still available?", signInToMessage = "Sign in to your Sky Cloud account to send a message.", @@ -1450,11 +1450,11 @@ Locales["en"] = { createProfile = "Create your profile", editProfile = "Edit profile", profileSetupBody = "Your iFruit email is linked automatically.", profileEmail = "iFruit email", profileHandle = "Username", profileHandlePlaceholder = "your.name", profileHandleHint = "Use 3-24 lowercase letters, numbers, dots or underscores.", profileBio = "Bio", - profileBioPlaceholder = "Tell the city a little about yourself...", profilePhoto = "Profile photo", profilePhotoHint = "Choose a photo from your Gallery or take a new one.", removeProfilePhoto = "Remove photo", profileSave = "Save profile", profileCancel = "Cancel", profileSaved = "Your profile was saved.", + profileBioPlaceholder = "Tell the city a little about yourself...", profilePhoto = "Profile photo", profilePhotoHint = "Choose a photo from Photos or take a new one.", removeProfilePhoto = "Remove photo", profileSave = "Save profile", profileCancel = "Cancel", profileSaved = "Your profile was saved.", location = "Location", sharedFrom = "Shared from CityMarkt", openCityMarkt = "Open CityMarkt listing", newPost = "New local post", shareWithCity = "Share with the city", publish = "Publish", published = "Your post is live.", deleted = "Post deleted.", title = "Title", body = "Your story", category = "Category", titlePlaceholder = "What should people know?", bodyPlaceholder = "Add details, a recommendation or directions...", - photos = "Photos", optional = "optional", camera = "Camera", gallery = "Gallery", photoLimit = "You can add up to six photos.", + photos = "Photos", optional = "optional", camera = "Camera", gallery = "Photos", photoLimit = "You can add up to six photos.", cityMarktShare = "Share to Local Pages", cityMarktShared = "Shared to Local Pages.", cityMarktShareHint = "One CityMarkt share per day", cityMarktAlreadyShared = "Already published", cityMarktOpenSharedHint = "Open the Local Pages post", cityMarktAppMissing = "Local Pages is not installed", cityMarktInstallHint = "Install Local Pages to share this listing", @@ -1592,7 +1592,7 @@ Locales["en"] = { destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.", shareProfile = "Share Profile", chooseConversation = "Choose a conversation", kinds = { - contact = "Contact", document = "Document", link = "Link", location = "Location", + contact = "Contact", document = "Document", link = "Link", location = "Location", media = "Media", note = "Note", photo = "Photo", playlist = "Playlist", post = "Post", profile = "Profile", text = "Message", track = "Track", video = "Video", }, @@ -1616,9 +1616,31 @@ Locales["en"] = { }, }, photos = { - name = "Gallery", count = "{count} items", loading = "Loading Gallery...", + name = "Photos", library = "Library", + counts = { + all = "{count} Objects", allOne = "{count} Object", + favorite = "{count} Favorite", favorites = "{count} Favorites", + photo = "{count} Photo", photos = "{count} Photos", + video = "{count} Video", videos = "{count} Videos", + }, + sorting = { + action = "Sort Photos", title = "Sort Order", + show = "Show", allItems = "All Items", favorites = "Favorites", + newestFirst = "Newest First", oldestFirst = "Oldest First", + }, + selection = { + action = "Select", selected = "{count} Selected", + share = "Share Selected", shareTitle = "{count} Media Items", + shareCopy = "{count} photos and videos shared from Photos.", + delete = "Delete Selected", deleteTitle = "Delete Selected Media?", + deleteBody = "The selected photos and videos will be permanently deleted.", + deleted = "{count} media items deleted.", limit = "You can select up to 50 media items.", + }, + loading = "Loading Photos...", emptyTitle = "No Photos or Videos", emptyBody = "Captures from Camera will appear here.", - photo = "Photo", video = "Video", photoAlt = "Gallery photo", videoAlt = "Gallery video", + photo = "Photo", video = "Video", photoAlt = "Photo library image", videoAlt = "Photo library video", + today = "Today", yesterday = "Yesterday", + addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites", delete = "Delete media", deleteTitle = "Delete Media?", deleteBody = "This photo or video will be permanently deleted.", deleted = "Media deleted.", zoomIn = "Zoom in", zoomOut = "Zoom out", resetZoom = "Reset zoom", @@ -1636,7 +1658,7 @@ Locales["en"] = { cancelled = "The media action was cancelled.", capture_failed = "Unable to capture the game view.", invalid_import_request = "The import request is invalid.", invalid_import_media = "This media item cannot be imported.", invalid_media_type = "The media type is invalid.", invalid_upload = "The upload could not be verified.", - invalid_upload_token = "The upload session is no longer valid.", missing_config = "Gallery uploads are not configured.", + invalid_upload_token = "The upload session is no longer valid.", missing_config = "Photos uploads are not configured.", import_media_not_allowed = "This media item is not allowed.", import_media_too_large = "This media item is too large.", import_media_unavailable = "This media item is no longer available.", import_provider_failed = "The website could not load its media.", import_provider_unauthorized = "The website credentials are invalid.", import_source_not_found = "This website is not registered.", @@ -1645,7 +1667,7 @@ Locales["en"] = { import_url_unavailable = "The linked media could not be reached.", import_size_unavailable = "The website did not provide the media size.", not_found = "The media item no longer exists.", owner_changed = "The active phone account changed.", operation_in_progress = "Another media operation is already in progress.", - rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Gallery request failed.", + rate_limited = "Too many media actions. Try again shortly.", request_failed = "The Photos request failed.", request_timeout = "The media service timed out.", unsupported = "This media format is not supported.", upload_failed = "The media upload failed.", upload_timeout = "The media upload timed out.", }, diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua index c994d62..0b9be53 100644 --- a/sky_phone/source/client/camera.lua +++ b/sky_phone/source/client/camera.lua @@ -453,6 +453,15 @@ RegisterNUICallback("gallery:delete", function(data, cb) cb({ success = true }) end) +RegisterNUICallback("gallery:delete-many", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + TriggerServerEvent("sky_phone:media:delete-many", data) + cb({ success = true }) +end) + RegisterNUICallback("memos:requestUpload", function(data, cb) if type(data) ~= "table" then cb({ success = false, error = "invalid_request" }) @@ -501,6 +510,10 @@ RegisterNetEvent("sky_phone:media:delete-result", function(data) SendNUIMessage({ type = "media:deleteResult", data = data }) end) +RegisterNetEvent("sky_phone:media:delete-many-result", function(data) + SendNUIMessage({ type = "media:deleteManyResult", data = data }) +end) + RegisterNetEvent("sky_phone:memos:upload-ready", function(data) SendNUIMessage({ type = "memos:uploadReady", data = data }) end) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 7a390d3..38b9208 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -274,6 +274,8 @@ local server_callbacks = { "flare:thread", "flare:send", "gallery:list", + "gallery:counts", + "gallery:favorite", "media:config", "media:import:sources", "media:import:list", diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 2e6a38d..dd89ef4 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -386,6 +386,7 @@ local schema = { collation = "ascii_bin", }, { name = "verified_at", type = "DATETIME NULL" }, + { name = "favorite", type = "TINYINT(1) NOT NULL DEFAULT 0" }, { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, }, primaryKey = "id", diff --git a/sky_phone/source/server/easyshare.lua b/sky_phone/source/server/easyshare.lua index 5ccc284..83f57f3 100644 --- a/sky_phone/source/server/easyshare.lua +++ b/sky_phone/source/server/easyshare.lua @@ -7,6 +7,7 @@ local valid_kinds = { document = true, link = true, location = true, + media = true, note = true, photo = true, playlist = true, @@ -671,6 +672,45 @@ local function sanitize_payload(source, device, data) payload.title = note.title ~= "" and note.title or title payload.copyText = plain_note_body(note.body) payload.meta = { body = note.body, title = note.title } + elseif data.kind == "media" and app_id == "photos" and type(data.meta) == "table" and type(data.meta.mediaIds) == "table" then + local media_ids = {} + local seen_ids = {} + for _, value in ipairs(data.meta.mediaIds) do + local media_id = tonumber(value) + if media_id and media_id > 0 and media_id == math.floor(media_id) and not seen_ids[media_id] then + seen_ids[media_id] = true + media_ids[#media_ids + 1] = media_id + end + end + if #media_ids < 1 or #media_ids > 50 then + return nil, "invalid_payload" + end + local placeholders = {} + local media_params = {} + for _, media_id in ipairs(media_ids) do + placeholders[#placeholders + 1] = "?" + media_params[#media_params + 1] = media_id + end + append_params(media_params, owner_params) + local media_rows = Bridge.Database.Query(([[ + SELECT `id`, `url`, `media_type` AS `mediaType` + FROM `sky_phone_media` + WHERE `id` IN (%s) AND %s AND `media_type` IN ('photo', 'video') + ORDER BY `created_at` ASC, `id` ASC + ]]):format(table.concat(placeholders, ", "), condition), media_params) + if #media_rows ~= #media_ids then + return nil, "not_owned" + end + local items = {} + for _, row in ipairs(media_rows) do + items[#items + 1] = { + id = tonumber(row.id), + mediaType = row.mediaType, + url = row.url, + } + end + payload.imageUrl = items[1].url + payload.meta = { items = items } elseif data.kind == "photo" or data.kind == "video" then local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind) if not url then diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index e0fc945..bedb45b 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -305,6 +305,15 @@ local function delete_result(source, correlation_id, success, error_code, media_ }) end +local function delete_many_result(source, correlation_id, success, error_code, deleted_ids) + TriggerClientEvent("sky_phone:media:delete-many-result", source, { + correlationId = correlation_id, + success = success, + error = error_code, + deletedIds = deleted_ids, + }) +end + local function parse_metadata(value) if type(value) == "table" then return value @@ -412,10 +421,13 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data) condition = condition .. " AND `media_type` = ?" params[#params + 1] = media_type end + if data.favoriteOnly == true then + condition = condition .. " AND `favorite` = 1" + end params[#params + 1] = limit params[#params + 1] = offset local rows = Bridge.Database.Query(([[ - SELECT `id`, `url`, `media_type` AS `mediaType`, + SELECT `id`, `url`, `media_type` AS `mediaType`, `favorite`, UNIX_TIMESTAMP(`created_at`) * 1000 AS `createdAt` FROM `sky_phone_media` WHERE %s @@ -425,10 +437,85 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data) for _, row in ipairs(rows) do row.id = tonumber(row.id) row.createdAt = tonumber(row.createdAt) or 0 + row.favorite = tonumber(row.favorite) == 1 end return { success = true, data = rows } end) +Bridge.Callbacks.Register("sky_phone:gallery:favorite", function(source, data) + if not SkyPhone.AllowOperation(source, "media_favorite", 60, 60) + or type(data) ~= "table" + or type(data.favorite) ~= "boolean" + then + return { success = false, error = "invalid_request" } + end + local media_id = tonumber(data.id) + if not media_id or media_id < 1 or media_id ~= math.floor(media_id) then + return { success = false, error = "invalid_request" } + end + local owner, error_response = session_owner(source) + if not owner then + return error_response + end + local condition, owner_params = owner_condition(owner) + local params = { media_id } + for _, value in ipairs(owner_params) do + params[#params + 1] = value + end + local rows = Bridge.Database.Query(([[ + SELECT `id` + FROM `sky_phone_media` + WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') + LIMIT 1 + ]]):format(condition), params) + if not rows[1] then + return { success = false, error = "media_not_found" } + end + local update_params = { data.favorite and 1 or 0, media_id } + for _, value in ipairs(owner_params) do + update_params[#update_params + 1] = value + end + Bridge.Database.Query( + ("UPDATE `sky_phone_media` SET `favorite` = ? WHERE `id` = ? AND %s"):format(condition), + update_params + ) + return { + success = true, + data = { id = media_id, favorite = data.favorite }, + } +end) + +Bridge.Callbacks.Register("sky_phone:gallery:counts", function(source) + local owner, error_response = session_owner(source) + if not owner then + return error_response + end + local condition, params = owner_condition(owner) + local rows = Bridge.Database.Query(([[ + SELECT + COUNT(*) AS `all_count`, + COUNT(CASE WHEN `favorite` = 1 THEN 1 END) AS `favorite_count`, + COUNT(CASE WHEN `media_type` = 'photo' AND `favorite` = 1 THEN 1 END) AS `favorite_photo_count`, + COUNT(CASE WHEN `media_type` = 'video' AND `favorite` = 1 THEN 1 END) AS `favorite_video_count`, + COUNT(CASE WHEN `media_type` = 'photo' THEN 1 END) AS `photo_count`, + COUNT(CASE WHEN `media_type` = 'video' THEN 1 END) AS `video_count` + FROM `sky_phone_media` + WHERE %s AND `media_type` IN ('photo', 'video') + ]]):format(condition), params) + local counts = rows[1] or {} + return { + success = true, + data = { + all = tonumber(counts.all_count) or 0, + favoritePhotos = tonumber(counts.favorite_photo_count) or 0, + favorites = tonumber(counts.favorite_count) or 0, + favoriteVideos = tonumber(counts.favorite_video_count) or 0, + photos = tonumber(counts.photo_count) or 0, + videos = tonumber(counts.video_count) or 0, + }, + } +end) + local function current_messaging_device(source) local session, error_response = SkyPhone.RequireSession(source) if not session then @@ -676,6 +763,7 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data) id = media_id, url = verified.url, mediaType = state.media_type, + favorite = false, createdAt = os.time() * 1000, }) end) @@ -708,6 +796,43 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data) upload_result(src, state.correlation_id, false, error_code) end) +local function delete_owned_media(src, owner, media_id) + local condition, params = owner_condition(owner) + local query_params = { media_id } + for _, value in ipairs(params) do + query_params[#query_params + 1] = value + end + local rows = Bridge.Database.Query(([[ + SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media` + WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') LIMIT 1 + ]]):format(condition), query_params) + local row = rows[1] + if not row then + return false, "not_found" + end + local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id) + if pending_deletes[delete_key] then + return false, "operation_in_progress" + end + pending_deletes[delete_key] = src + if row.origin == "phone_upload" then + local references = Bridge.Database.Query( + "SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?", + { row.remote_id } + ) + if (tonumber(references[1] and references[1].count) or 0) <= 1 then + local deleted, delete_error = delete_remote_file(row.remote_id) + if not deleted then + pending_deletes[delete_key] = nil + return false, delete_error + end + end + end + Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params) + pending_deletes[delete_key] = nil + return true +end + RegisterNetEvent("sky_phone:media:delete", function(data) local src = source data = type(data) == "table" and data or {} @@ -726,43 +851,54 @@ RegisterNetEvent("sky_phone:media:delete", function(data) delete_result(src, correlation_id, false, error_response.error, media_id) return end - local condition, params = owner_condition(owner) - local query_params = { media_id } - for _, value in ipairs(params) do - query_params[#query_params + 1] = value - end - local rows = Bridge.Database.Query(([[ - SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media` - WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') LIMIT 1 - ]]):format(condition), query_params) - local row = rows[1] - if not row then - delete_result(src, correlation_id, false, "not_found", media_id) + local deleted, delete_error = delete_owned_media(src, owner, media_id) + if not deleted then + delete_result(src, correlation_id, false, delete_error, media_id) return end - local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id) - if pending_deletes[delete_key] then - delete_result(src, correlation_id, false, "operation_in_progress", media_id) + delete_result(src, correlation_id, true, nil, media_id) +end) + +RegisterNetEvent("sky_phone:media:delete-many", function(data) + local src = source + data = type(data) == "table" and data or {} + local correlation_id = data.correlationId + if type(correlation_id) ~= "string" or #correlation_id > 80 or type(data.ids) ~= "table" then + delete_many_result(src, correlation_id, false, "invalid_request", {}) return end - pending_deletes[delete_key] = src - if row.origin == "phone_upload" then - local references = Bridge.Database.Query( - "SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?", - { row.remote_id } - ) - if (tonumber(references[1] and references[1].count) or 0) <= 1 then - local deleted, delete_error = delete_remote_file(row.remote_id) - if not deleted then - pending_deletes[delete_key] = nil - delete_result(src, correlation_id, false, delete_error, media_id) - return - end + local media_ids = {} + local seen_ids = {} + for _, value in ipairs(data.ids) do + local media_id = tonumber(value) + if media_id and media_id > 0 and media_id == math.floor(media_id) and not seen_ids[media_id] then + seen_ids[media_id] = true + media_ids[#media_ids + 1] = media_id end end - Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params) - pending_deletes[delete_key] = nil - delete_result(src, correlation_id, true, nil, media_id) + if #media_ids < 1 or #media_ids > 50 then + delete_many_result(src, correlation_id, false, "invalid_request", {}) + return + end + if not SkyPhone.AllowOperation(src, "media_delete_many", 10, 60) then + delete_many_result(src, correlation_id, false, "rate_limited", {}) + return + end + local owner, error_response = session_owner(src) + if not owner then + delete_many_result(src, correlation_id, false, error_response.error, {}) + return + end + local deleted_ids = {} + for _, media_id in ipairs(media_ids) do + local deleted, delete_error = delete_owned_media(src, owner, media_id) + if not deleted then + delete_many_result(src, correlation_id, false, delete_error, deleted_ids) + return + end + deleted_ids[#deleted_ids + 1] = media_id + end + delete_many_result(src, correlation_id, true, nil, deleted_ids) end) function SkyPhoneMedia.GetDeviceRemoteIds(imei) diff --git a/sky_phone/source/server/media_import.lua b/sky_phone/source/server/media_import.lua index d422847..22ef403 100644 --- a/sky_phone/source/server/media_import.lua +++ b/sky_phone/source/server/media_import.lua @@ -351,6 +351,7 @@ local function store_import(owner, media) return { createdAt = os.time() * 1000, + favorite = false, id = media_id, mediaType = media.mediaType, url = media.url,