ENH - overhaul Photos media experience

This commit is contained in:
smx.pusha
2026-08-14 10:46:36 +02:00
parent 6cfdd44551
commit 3fc0ebb08f
18 changed files with 1651 additions and 267 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+1
View File
@@ -6,6 +6,7 @@ import type { PhoneMedia } from '@/types/media'
const photo: PhoneMedia = { const photo: PhoneMedia = {
createdAt: 1_786_035_600, createdAt: 1_786_035_600,
favorite: false,
id: 17, id: 17,
mediaType: 'photo', mediaType: 'photo',
url: 'https://media.example/photo.jpg', url: 'https://media.example/photo.jpg',
+62 -26
View File
@@ -362,6 +362,7 @@ const defaultLocales: LocaleTree = {
document: 'Document', document: 'Document',
link: 'Link', link: 'Link',
location: 'Location', location: 'Location',
media: 'Media',
note: 'Note', note: 'Note',
photo: 'Photo', photo: 'Photo',
playlist: 'Playlist', playlist: 'Playlist',
@@ -489,7 +490,7 @@ const defaultLocales: LocaleTree = {
category: 'Category', category: 'Category',
status: 'Publication Status', status: 'Publication Status',
cover: 'Cover Image', cover: 'Cover Image',
chooseCover: 'Choose from Gallery', chooseCover: 'Choose from Photos',
changeCover: 'Change Cover', changeCover: 'Change Cover',
removeCover: 'Remove Cover', removeCover: 'Remove Cover',
coverAlt: 'Selected article cover', coverAlt: 'Selected article cover',
@@ -561,7 +562,7 @@ const defaultLocales: LocaleTree = {
login: 'Log in', login: 'Log in',
register: 'Register', register: 'Register',
ifruitEmail: 'iFruit address', ifruitEmail: 'iFruit address',
gallery: 'Gallery', gallery: 'Photos',
camera: 'Camera', camera: 'Camera',
backToLogin: 'Back to CrewLink login', backToLogin: 'Back to CrewLink login',
authErrors: { authErrors: {
@@ -783,9 +784,9 @@ const defaultLocales: LocaleTree = {
photo: 'Profile photo', photo: 'Profile photo',
profilePhotos: 'Profile photos', profilePhotos: 'Profile photos',
profilePhotosBody: 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', addPhotos: 'Add photos',
choosePhotos: 'Choose from Gallery', choosePhotos: 'Choose from Photos',
primaryPhoto: 'Main', primaryPhoto: 'Main',
removePhoto: 'Remove photo {number}', removePhoto: 'Remove photo {number}',
yourName: 'Your name', yourName: 'Your name',
@@ -904,7 +905,7 @@ const defaultLocales: LocaleTree = {
errors: { errors: {
invalid_profile: 'Check your name, age and profile text.', invalid_profile: 'Check your name, age and profile text.',
invalid_profile_photos: 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.', request_failed: 'Flare could not save those changes. Try again.',
invalid_target: 'This profile is no longer available.', invalid_target: 'This profile is no longer available.',
invalid_choice: 'That swipe could not be saved.', invalid_choice: 'That swipe could not be saved.',
@@ -985,8 +986,8 @@ const defaultLocales: LocaleTree = {
story: 'Story', story: 'Story',
choosePhotos: 'Choose photos', choosePhotos: 'Choose photos',
choosePhoto: 'Choose photo', choosePhoto: 'Choose photo',
choosePhotosHint: 'Select up to five photos from Gallery', choosePhotosHint: 'Select up to five photos from Photos',
chooseStoryHint: 'Select one photo from Gallery', chooseStoryHint: 'Select one photo from Photos',
selectedPhotos: '{count} selected', selectedPhotos: '{count} selected',
changePhotos: 'Change selection', changePhotos: 'Change selection',
caption: 'Caption', caption: 'Caption',
@@ -1231,7 +1232,7 @@ const defaultLocales: LocaleTree = {
composerAudience: 'Everyone can join the conversation', composerAudience: 'Everyone can join the conversation',
mediaTitle: 'Add to your feather', mediaTitle: 'Add to your feather',
mediaBody: 'Share up to four photos from your phone or take a new one.', 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', chooseGalleryBody: 'Choose one or more saved photos',
takePhoto: 'Camera', takePhoto: 'Camera',
takePhotoBody: 'Take a new photo now', takePhotoBody: 'Take a new photo now',
@@ -1359,7 +1360,7 @@ const defaultLocales: LocaleTree = {
createBody: createBody:
'Choose a clip, set the cover and sound, then decide who can watch it.', 'Choose a clip, set the cover and sound, then decide who can watch it.',
chooseVideo: 'Choose a video', chooseVideo: 'Choose a video',
chooseVideoHint: 'Select one from Gallery', chooseVideoHint: 'Select one from Photos',
changeVideo: 'Change', changeVideo: 'Change',
caption: 'Caption', caption: 'Caption',
captionPlaceholder: 'Write a caption...', captionPlaceholder: 'Write a caption...',
@@ -1435,7 +1436,7 @@ const defaultLocales: LocaleTree = {
accountType: 'Account type', accountType: 'Account type',
profilePhoto: 'Profile photo', profilePhoto: 'Profile photo',
changePhoto: 'Change photo', changePhoto: 'Change photo',
chooseFromGallery: 'Gallery', chooseFromGallery: 'Photos',
takePhoto: 'Camera', takePhoto: 'Camera',
removePhoto: 'Remove photo', removePhoto: 'Remove photo',
noConnections: 'No profiles to show', noConnections: 'No profiles to show',
@@ -1797,7 +1798,7 @@ const defaultLocales: LocaleTree = {
phoneNumber: 'Phone Number', phoneNumber: 'Phone Number',
officialContact: 'Official company contact', officialContact: 'Official company contact',
choosePhoto: 'Choose Contact Photo', choosePhoto: 'Choose Contact Photo',
chooseGallery: 'Gallery', chooseGallery: 'Photos',
takePhoto: 'Camera', takePhoto: 'Camera',
removePhoto: 'Remove Photo', removePhoto: 'Remove Photo',
call: 'Call', call: 'Call',
@@ -2348,7 +2349,7 @@ const defaultLocales: LocaleTree = {
editProfile: 'Edit Profile', editProfile: 'Edit Profile',
editProfileBody: 'Change your public name and profile photo', editProfileBody: 'Change your public name and profile photo',
profilePhoto: 'Profile photo', profilePhoto: 'Profile photo',
gallery: 'Gallery', gallery: 'Photos',
camera: 'Camera', camera: 'Camera',
profileDetails: 'Profile details', profileDetails: 'Profile details',
profileName: 'Name', profileName: 'Name',
@@ -2854,13 +2855,13 @@ const defaultLocales: LocaleTree = {
publish: 'Publish', publish: 'Publish',
addPhotos: 'Add photos', addPhotos: 'Add photos',
addPhotosBody: addPhotosBody:
'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.', 'Choose up to six photos from Photos or take new ones. Photos are optional and the first becomes the cover.',
chooseGallery: 'Choose from gallery', chooseGallery: 'Choose from Photos',
chooseGalleryBody: 'Use photos saved on this phone.', chooseGalleryBody: 'Use photos saved on this phone.',
takePhotos: 'Take photos', takePhotos: 'Take photos',
takePhotosBody: 'Take several new photos for this listing.', takePhotosBody: 'Take several new photos for this listing.',
selectedPhotos: 'Selected photos', selectedPhotos: 'Selected photos',
gallery: 'Gallery', gallery: 'Photos',
camera: 'Camera', camera: 'Camera',
takePhoto: 'Take photo', takePhoto: 'Take photo',
cameraHint: cameraHint:
@@ -3006,7 +3007,7 @@ const defaultLocales: LocaleTree = {
profileBio: 'Bio', profileBio: 'Bio',
profileBioPlaceholder: 'Tell the city a little about yourself...', profileBioPlaceholder: 'Tell the city a little about yourself...',
profilePhoto: 'Profile photo', 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', removeProfilePhoto: 'Remove photo',
profileSave: 'Save profile', profileSave: 'Save profile',
profileCancel: 'Cancel', profileCancel: 'Cancel',
@@ -3031,7 +3032,7 @@ const defaultLocales: LocaleTree = {
photos: 'Photos', photos: 'Photos',
optional: 'optional', optional: 'optional',
camera: 'Camera', camera: 'Camera',
gallery: 'Gallery', gallery: 'Photos',
photoLimit: 'You can add up to six photos.', photoLimit: 'You can add up to six photos.',
cityMarktShare: 'Share to Local Pages', cityMarktShare: 'Share to Local Pages',
cityMarktShared: 'Shared to Local Pages.', cityMarktShared: 'Shared to Local Pages.',
@@ -3176,11 +3177,11 @@ const defaultLocales: LocaleTree = {
returnHelp: 'Space to return', returnHelp: 'Space to return',
uploading: '{count} uploading', uploading: '{count} uploading',
saving: 'Saving video...', saving: 'Saving video...',
openGallery: 'Open Gallery', openGallery: 'Open Photos',
takePhoto: 'Take photo', takePhoto: 'Take photo',
startRecording: 'Start recording', startRecording: 'Start recording',
stopRecording: 'Stop recording', stopRecording: 'Stop recording',
saved: 'Saved to Gallery.', saved: 'Saved to Photos.',
zoom: 'Set camera zoom to {zoom}', zoom: 'Set camera zoom to {zoom}',
errors: { errors: {
cancelled: 'Capture cancelled.', cancelled: 'Capture cancelled.',
@@ -3607,15 +3608,50 @@ const defaultLocales: LocaleTree = {
}, },
}, },
photos: { photos: {
name: 'Gallery', name: 'Photos',
count: '{count} items', library: 'Library',
loading: 'Loading Gallery...', 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', emptyTitle: 'No Photos or Videos',
emptyBody: 'Captures from Camera will appear here.', emptyBody: 'Captures from Camera will appear here.',
photo: 'Photo', photo: 'Photo',
video: 'Video', video: 'Video',
photoAlt: 'Gallery photo', photoAlt: 'Photo library image',
videoAlt: 'Gallery video', videoAlt: 'Photo library video',
today: 'Today',
yesterday: 'Yesterday',
addFavorite: 'Add to Favorites',
removeFavorite: 'Remove from Favorites',
delete: 'Delete media', delete: 'Delete media',
deleteTitle: 'Delete Media?', deleteTitle: 'Delete Media?',
deleteBody: 'This photo or video will be permanently deleted.', 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_media_type: 'The media type is invalid.',
invalid_upload: 'The upload could not be verified.', invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.', 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_not_allowed: 'This media item is not allowed.',
import_media_too_large: 'This media item is too large.', import_media_too_large: 'This media item is too large.',
import_media_unavailable: 'This media item is no longer available.', import_media_unavailable: 'This media item is no longer available.',
@@ -3667,7 +3703,7 @@ const defaultLocales: LocaleTree = {
'Another media operation is already in progress.', 'Another media operation is already in progress.',
owner_changed: 'The active phone account changed.', owner_changed: 'The active phone account changed.',
rate_limited: 'Too many media actions. Try again shortly.', 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.', request_timeout: 'The media service timed out.',
unsupported: 'This media format is not supported.', unsupported: 'This media format is not supported.',
upload_failed: 'The media upload failed.', upload_failed: 'The media upload failed.',
+1
View File
@@ -5,6 +5,7 @@ export type EasyShareKind =
| 'document' | 'document'
| 'link' | 'link'
| 'location' | 'location'
| 'media'
| 'note' | 'note'
| 'photo' | 'photo'
| 'playlist' | 'playlist'
+25
View File
@@ -3,11 +3,29 @@ export type GalleryFilter = 'all' | MediaType
export type PhoneMedia = { export type PhoneMedia = {
createdAt: number createdAt: number
favorite: boolean
id: number id: number
mediaType: MediaType mediaType: MediaType
thumbnailUrl?: string
url: 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 = { export type MediaImportSource = {
id: string id: string
label: string label: string
@@ -75,3 +93,10 @@ export type DeleteResult = {
id?: number id?: number
success: boolean success: boolean
} }
export type DeleteManyResult = {
correlationId: string
deletedIds?: number[]
error?: string
success: boolean
}
+29 -4
View File
@@ -1,17 +1,20 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
bottomRightGridPosition,
filterMedia, filterMedia,
formatMediaSize, formatMediaSize,
formatRecordingDuration, formatRecordingDuration,
hasNextMediaPage, hasNextMediaPage,
mediaErrorKey, mediaErrorKey,
mergeMedia, mergeMedia,
orderMedia,
orderMediaOldestFirst,
} from './media' } from './media'
const media = [ const media = [
{ createdAt: 10, id: 1, mediaType: 'photo' as const, url: 'photo' }, { createdAt: 10, favorite: false, id: 1, mediaType: 'photo' as const, url: 'photo' },
{ createdAt: 20, id: 2, mediaType: 'video' as const, url: 'video' }, { createdAt: 20, favorite: false, id: 2, mediaType: 'video' as const, url: 'video' },
] ]
describe('media utilities', () => { describe('media utilities', () => {
@@ -24,8 +27,8 @@ describe('media utilities', () => {
it('merges pages without duplicates and keeps newest first', () => { it('merges pages without duplicates and keeps newest first', () => {
expect( expect(
mergeMedia(media, [ mergeMedia(media, [
{ createdAt: 30, id: 1, mediaType: 'photo', url: 'updated' }, { createdAt: 30, favorite: true, id: 1, mediaType: 'photo', url: 'updated' },
{ createdAt: 25, id: 3, mediaType: 'photo', url: 'new' }, { createdAt: 25, favorite: false, id: 3, mediaType: 'photo', url: 'new' },
]).map((entry) => [entry.id, entry.url]), ]).map((entry) => [entry.id, entry.url]),
).toEqual([ ).toEqual([
[1, 'updated'], [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', () => { it('loads another gallery page only after a full 30-item batch', () => {
expect(hasNextMediaPage(30)).toBe(true) expect(hasNextMediaPage(30)).toBe(true)
expect(hasNextMediaPage(29)).toBe(false) expect(hasNextMediaPage(29)).toBe(false)
+37 -1
View File
@@ -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 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 { export function hasNextMediaPage(pageLength: number): boolean {
return pageLength === MEDIA_PAGE_SIZE return pageLength === MEDIA_PAGE_SIZE
} }
@@ -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('<template #right>'),
source.indexOf('</template>', source.indexOf('<template #right>')),
)
describe('GalleryApp import action', () => {
it('uses the photo viewer toolbar button design', () => {
expect(headerActions).toContain('<SkyToolbarPane')
expect(headerActions).toContain('gallery-header-tool--icon')
expect(headerActions).toContain('<SkyButton')
expect(headerActions).toContain('clear')
expect(headerActions).toContain(
'<Download :size="21" aria-hidden="true" />',
)
expect(headerActions).toContain(
':aria-label="phone.t(\'Apps.photos.import.action\')"',
)
expect(headerActions).toContain(
':title="phone.t(\'Apps.photos.import.action\')"',
)
expect(headerActions).not.toContain(
"{{ phone.t('Apps.photos.import.action') }}",
)
expect(headerActions).not.toContain('tonal')
})
it('renders an iPhone-style large library header with filtered counts', () => {
expect(source).toContain('<SkyNavbar')
expect(source).toContain('variant="large"')
expect(source).toContain(':title="phone.t(\'Apps.photos.library\')"')
expect(source).toContain(':subtitle="countText"')
expect(source).toContain("nuiCall<GalleryCounts>('gallery:counts')")
expect(source).toContain("`Apps.photos.counts.${translationKey}`")
expect(source).toContain('gridColumnStart: bottomRightGridPosition(')
expect(source).toContain('gridRowStart: bottomRightGridPosition(')
expect(source).toContain('var(--sky-navbar-large-title-height) - 30px')
})
it('opens an accessible sort menu from the large header', () => {
expect(headerActions).toContain('<ListFilter')
expect(headerActions).toContain('sortMenuOpened = true')
expect(source).toContain('<SkyActionSheet')
expect(source).toContain("selectSortOrder('newest')")
expect(source).toContain("selectSortOrder('oldest')")
expect(source).toContain('orderMedia(media.value, sortOrder.value)')
})
it('supports selecting, sharing, and deleting multiple media items', () => {
expect(headerActions).toContain("phone.t('Apps.photos.selection.action')")
expect(headerActions).toContain('enterSelectionMode')
expect(source).toContain('v-if="selectionMode"')
expect(source).toContain('selectedCountText')
expect(source).toContain('shareSelection')
expect(source).toContain("kind: 'media'")
expect(source).toContain("nuiCall('gallery:delete-many'")
expect(source).toContain('media:deleteManyResult')
})
it('shows capture time and iPhone-style actions in the media viewer', () => {
expect(source).toContain(':title="selectedCaptureDay"')
expect(source).toContain(':subtitle="selectedCaptureTime"')
expect(source).not.toContain("'Apps.photos.photo'\n : 'Apps.photos.video'")
expect(source).toContain("nuiCall<FavoriteResult>('gallery:favorite'")
expect(source).toContain('<Heart')
expect(source).toContain('gallery-detail-toolbar')
expect(source).toContain('color: var(--sky-text)')
expect(source).toContain('shareSelected')
expect(source).toContain('deleteDialogOpened = true')
})
it('uses complete local media fixtures when no browser API port is set', () => {
expect(source).toContain("developmentParameters?.has('apiPort')")
expect(source).toContain('isDevelopment && !developmentApiEnabled')
expect(source).toContain('all: developmentMedia.length')
expect(source).toContain('developmentMedia.filter(')
expect(source).toContain("mockGalleryImage(\n 'Flower Video'")
expect(source).toContain("mockGalleryImage(\n 'Sintel Video'")
expect(source).toContain("mockGalleryImage(\n 'Bunny Video'")
expect(source).toContain('const additionalPhotos = [')
expect(source).toContain("['Campfire', '#c2410c', '#431407', '#fef08a']")
expect(source).toContain('id: 13 + index')
expect(source).not.toContain('picsum.photos')
})
})
File diff suppressed because it is too large Load Diff
+165 -10
View File
@@ -2472,38 +2472,140 @@ let recentCalls = [
status: 'missed', status: 'missed',
}, },
] ]
function mockGalleryImage(title, sky, landscape, accent) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1200"><defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="${sky}"/><stop offset="1" stop-color="${accent}"/></linearGradient><linearGradient id="land" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${landscape}"/><stop offset="1" stop-color="#101114"/></linearGradient></defs><rect width="900" height="1200" fill="url(#sky)"/><circle cx="690" cy="260" r="105" fill="#fff" opacity=".72"/><path d="M0 690 210 440 390 650 585 360 900 720V1200H0Z" fill="${landscape}" opacity=".84"/><path d="M0 790 230 620 410 765 650 525 900 770V1200H0Z" fill="url(#land)"/><path d="M360 1200 475 690 560 690 690 1200Z" fill="${accent}" opacity=".48"/><text x="54" y="1100" fill="#fff" font-family="system-ui,sans-serif" font-size="62" font-weight="700">${title}</text><text x="57" y="1160" fill="#fff" opacity=".72" font-family="system-ui,sans-serif" font-size="30">Sky Phone test media</text></svg>`
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
}
let mockMedia = [ let mockMedia = [
{ {
createdAt: Date.now() - 60_000, createdAt: Date.now() - 4 * 60_000,
favorite: false,
id: 1, id: 1,
mediaType: 'photo', 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, id: 2,
mediaType: 'video', mediaType: 'video',
thumbnailUrl: mockGalleryImage('Flower Video', '#7f1d1d', '#365314', '#fb7185'),
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', 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, id: 3,
mediaType: 'photo', 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, id: 4,
mediaType: 'photo', 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, id: 5,
mediaType: 'photo', 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'] const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
let weazelNewsSequence = 8 let weazelNewsSequence = 8
let weazelNewsArticles = [ let weazelNewsArticles = [
@@ -8265,6 +8367,7 @@ app.post('/api/:endpoint', (request, response) => {
const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video') const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video')
const media = { const media = {
createdAt: Date.now(), createdAt: Date.now(),
favorite: false,
id, id,
mediaType, mediaType,
url: url:
@@ -8353,6 +8456,24 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: media }) response.json({ success: true, data: media })
return 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 (endpoint === 'gallery:list') {
if (request.body.mockState === 'error') { if (request.body.mockState === 'error') {
response.json({ success: false, error: 'service_unavailable' }) response.json({ success: false, error: 'service_unavailable' })
@@ -8362,9 +8483,12 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: [] }) response.json({ success: true, data: [] })
return return
} }
const filtered = request.body.mediaType const typeFiltered = request.body.mediaType
? mockMedia.filter((item) => item.mediaType === request.body.mediaType) ? mockMedia.filter((item) => item.mediaType === request.body.mediaType)
: mockMedia : mockMedia
const filtered = request.body.favoriteOnly
? typeFiltered.filter((item) => item.favorite)
: typeFiltered
const offset = Number(request.body.offset) || 0 const offset = Number(request.body.offset) || 0
const limit = Number(request.body.limit) || 30 const limit = Number(request.body.limit) || 30
response.json({ response.json({
@@ -8387,6 +8511,37 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true }) response.json({ success: true })
return 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') { if (endpoint === 'account:login' || endpoint === 'account:register') {
authenticated = true authenticated = true
linkedAccount = { linkedAccount = {
+22
View File
@@ -31,6 +31,7 @@ const browserDataRequests = [
['fliptok:discover', { limit: 20 }], ['fliptok:discover', { limit: 20 }],
['fliptok:activities', {}], ['fliptok:activities', {}],
['gallery:list', {}], ['gallery:list', {}],
['gallery:counts', {}],
['garage:vehicles', {}], ['garage:vehicles', {}],
['garage:valet-state', {}], ['garage:valet-state', {}],
['housing:overview', {}], ['housing:overview', {}],
@@ -83,6 +84,27 @@ async function expectSuccess(baseUrl, endpoint, body = {}, data = false) {
} }
async function verifyStatefulActions(baseUrl) { 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( const memoBootstrap = await expectSuccess(
baseUrl, baseUrl,
'development:bootstrap', 'development:bootstrap',
+42 -20
View File
@@ -210,7 +210,7 @@ Locales["en"] = {
category = "Category", category = "Category",
status = "Publication Status", status = "Publication Status",
cover = "Cover Image", cover = "Cover Image",
chooseCover = "Choose from Gallery", chooseCover = "Choose from Photos",
changeCover = "Change Cover", changeCover = "Change Cover",
removeCover = "Remove Cover", removeCover = "Remove Cover",
coverAlt = "Selected article 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", 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", authEyebrow = "Private crew network", authTitle = "Welcome to CrewLink",
authBody = "Your iFruit email is linked automatically. Use your CrewLink username to continue.", 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 = { authErrors = {
no_ifruit_account = "Sign in to your Sky Cloud account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", 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.", 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 = { flare = {
name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private Sky Cloud account.", 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.", 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...", 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", 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.", 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}", 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" }, lookingFor = { longTerm = "Long-term connection", dates = "Good dates", friends = "New friends" },
errors = { 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.", 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.", 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.", noResults = "No results", noResultsBody = "Try another username, caption, place, or hashtag.",
newPost = "New Post", newStory = "New Story", post = "Post", story = "Story", newPost = "New Post", newStory = "New Story", post = "Post", story = "Story",
choosePhotos = "Choose photos", choosePhoto = "Choose photo", 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", selectedPhotos = "{count} selected", changePhotos = "Change selection", caption = "Caption",
captionPlaceholder = "Write a caption...", storyTextPlaceholder = "Add a short story text...", captionPlaceholder = "Write a caption...", storyTextPlaceholder = "Add a short story text...",
location = "Location", locationPlaceholder = "Add a place", allowComments = "Allow comments", 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", 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", 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...", 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.", 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", 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" }, 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", 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", 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", 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", 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", 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.", 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.", 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", 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", 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", 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.", 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.", 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", 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", 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", 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", 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", 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", 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", driverModeBody = "Accept ride requests and track your earnings", completedRides = "Completed",
cancelledRides = "Cancelled", acceptance = "Acceptance", rating = "Rating", notAvailable = "N/A", cancelledRides = "Cancelled", acceptance = "Acceptance", rating = "Rating", notAvailable = "N/A",
profile = "Your profile", editProfile = "Edit Profile", editProfileBody = "Change your public name and profile photo", 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", profileName = "Name", profileNamePlaceholder = "Your SkyRide name", removeProfilePhoto = "Remove profile photo",
saveProfile = "Save Profile", profileSaved = "Your SkyRide profile was updated.", saveProfile = "Save Profile", profileSaved = "Your SkyRide profile was updated.",
memberSince = "SkyRide member since {date}", rideComplete = "Ride Complete", rateRide = "Rate your ride", 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", 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", 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", focusHelp = "Space for movement", returnHelp = "Space to return", uploading = "{count} uploading",
saving = "Saving video...", openGallery = "Open Gallery", takePhoto = "Take photo", saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Gallery.", startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
zoom = "Set camera zoom to {zoom}", zoom = "Set camera zoom to {zoom}",
errors = { errors = {
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.", 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", createProfile = "Create your CityMarkt profile", editProfile = "Edit profile",
profileIntro = "Your iFruit email stays linked to this profile.", profileEmail = "iFruit email", profileIntro = "Your iFruit email stays linked to this profile.", profileEmail = "iFruit email",
displayName = "Display name", profileBio = "About you", saveProfile = "Save profile", 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", takePhoto = "Camera", removeProfilePhoto = "Remove photo",
phone = "Phone", contactSeller = "Contact seller", messagePlaceholder = "Hi, is this still available?", phone = "Phone", contactSeller = "Contact seller", messagePlaceholder = "Hi, is this still available?",
signInToMessage = "Sign in to your Sky Cloud account to send a message.", 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.", createProfile = "Create your profile", editProfile = "Edit profile", profileSetupBody = "Your iFruit email is linked automatically.",
profileEmail = "iFruit email", profileHandle = "Username", profileHandlePlaceholder = "your.name", profileEmail = "iFruit email", profileHandle = "Username", profileHandlePlaceholder = "your.name",
profileHandleHint = "Use 3-24 lowercase letters, numbers, dots or underscores.", profileBio = "Bio", 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", 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.", 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...", 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", 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", cityMarktAlreadyShared = "Already published", cityMarktOpenSharedHint = "Open the Local Pages post",
cityMarktAppMissing = "Local Pages is not installed", cityMarktInstallHint = "Install Local Pages to share this listing", 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.", destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
shareProfile = "Share Profile", chooseConversation = "Choose a conversation", shareProfile = "Share Profile", chooseConversation = "Choose a conversation",
kinds = { 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", note = "Note", photo = "Photo", playlist = "Playlist", post = "Post",
profile = "Profile", text = "Message", track = "Track", video = "Video", profile = "Profile", text = "Message", track = "Track", video = "Video",
}, },
@@ -1616,9 +1616,31 @@ Locales["en"] = {
}, },
}, },
photos = { 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.", 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?", delete = "Delete media", deleteTitle = "Delete Media?",
deleteBody = "This photo or video will be permanently deleted.", deleted = "Media deleted.", deleteBody = "This photo or video will be permanently deleted.", deleted = "Media deleted.",
zoomIn = "Zoom in", zoomOut = "Zoom out", resetZoom = "Reset zoom", 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.", 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_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_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_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_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.", 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.", 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.", 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.", 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.", 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.", upload_failed = "The media upload failed.", upload_timeout = "The media upload timed out.",
}, },
+13
View File
@@ -453,6 +453,15 @@ RegisterNUICallback("gallery:delete", function(data, cb)
cb({ success = true }) cb({ success = true })
end) 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) RegisterNUICallback("memos:requestUpload", function(data, cb)
if type(data) ~= "table" then if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" }) cb({ success = false, error = "invalid_request" })
@@ -501,6 +510,10 @@ RegisterNetEvent("sky_phone:media:delete-result", function(data)
SendNUIMessage({ type = "media:deleteResult", data = data }) SendNUIMessage({ type = "media:deleteResult", data = data })
end) 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) RegisterNetEvent("sky_phone:memos:upload-ready", function(data)
SendNUIMessage({ type = "memos:uploadReady", data = data }) SendNUIMessage({ type = "memos:uploadReady", data = data })
end) end)
+2
View File
@@ -274,6 +274,8 @@ local server_callbacks = {
"flare:thread", "flare:thread",
"flare:send", "flare:send",
"gallery:list", "gallery:list",
"gallery:counts",
"gallery:favorite",
"media:config", "media:config",
"media:import:sources", "media:import:sources",
"media:import:list", "media:import:list",
+1
View File
@@ -386,6 +386,7 @@ local schema = {
collation = "ascii_bin", collation = "ascii_bin",
}, },
{ name = "verified_at", type = "DATETIME NULL" }, { 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" }, { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
}, },
primaryKey = "id", primaryKey = "id",
+40
View File
@@ -7,6 +7,7 @@ local valid_kinds = {
document = true, document = true,
link = true, link = true,
location = true, location = true,
media = true,
note = true, note = true,
photo = true, photo = true,
playlist = true, playlist = true,
@@ -671,6 +672,45 @@ local function sanitize_payload(source, device, data)
payload.title = note.title ~= "" and note.title or title payload.title = note.title ~= "" and note.title or title
payload.copyText = plain_note_body(note.body) payload.copyText = plain_note_body(note.body)
payload.meta = { body = note.body, title = note.title } 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 elseif data.kind == "photo" or data.kind == "video" then
local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind) local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind)
if not url then if not url then
+168 -32
View File
@@ -305,6 +305,15 @@ local function delete_result(source, correlation_id, success, error_code, media_
}) })
end 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) local function parse_metadata(value)
if type(value) == "table" then if type(value) == "table" then
return value return value
@@ -412,10 +421,13 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
condition = condition .. " AND `media_type` = ?" condition = condition .. " AND `media_type` = ?"
params[#params + 1] = media_type params[#params + 1] = media_type
end end
if data.favoriteOnly == true then
condition = condition .. " AND `favorite` = 1"
end
params[#params + 1] = limit params[#params + 1] = limit
params[#params + 1] = offset params[#params + 1] = offset
local rows = Bridge.Database.Query(([[ 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` UNIX_TIMESTAMP(`created_at`) * 1000 AS `createdAt`
FROM `sky_phone_media` FROM `sky_phone_media`
WHERE %s WHERE %s
@@ -425,10 +437,85 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
for _, row in ipairs(rows) do for _, row in ipairs(rows) do
row.id = tonumber(row.id) row.id = tonumber(row.id)
row.createdAt = tonumber(row.createdAt) or 0 row.createdAt = tonumber(row.createdAt) or 0
row.favorite = tonumber(row.favorite) == 1
end end
return { success = true, data = rows } return { success = true, data = rows }
end) 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 function current_messaging_device(source)
local session, error_response = SkyPhone.RequireSession(source) local session, error_response = SkyPhone.RequireSession(source)
if not session then if not session then
@@ -676,6 +763,7 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
id = media_id, id = media_id,
url = verified.url, url = verified.url,
mediaType = state.media_type, mediaType = state.media_type,
favorite = false,
createdAt = os.time() * 1000, createdAt = os.time() * 1000,
}) })
end) end)
@@ -708,6 +796,43 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
upload_result(src, state.correlation_id, false, error_code) upload_result(src, state.correlation_id, false, error_code)
end) 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) RegisterNetEvent("sky_phone:media:delete", function(data)
local src = source local src = source
data = type(data) == "table" and data or {} 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) delete_result(src, correlation_id, false, error_response.error, media_id)
return return
end end
local condition, params = owner_condition(owner) local deleted, delete_error = delete_owned_media(src, owner, media_id)
local query_params = { media_id } if not deleted then
for _, value in ipairs(params) do delete_result(src, correlation_id, false, delete_error, media_id)
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)
return return
end end
local delete_key = row.origin == "phone_upload" and row.remote_id or ("import:%s"):format(media_id) delete_result(src, correlation_id, true, nil, media_id)
if pending_deletes[delete_key] then end)
delete_result(src, correlation_id, false, "operation_in_progress", media_id)
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 return
end end
pending_deletes[delete_key] = src local media_ids = {}
if row.origin == "phone_upload" then local seen_ids = {}
local references = Bridge.Database.Query( for _, value in ipairs(data.ids) do
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?", local media_id = tonumber(value)
{ row.remote_id } 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
if (tonumber(references[1] and references[1].count) or 0) <= 1 then media_ids[#media_ids + 1] = media_id
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
end end
end end
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params) if #media_ids < 1 or #media_ids > 50 then
pending_deletes[delete_key] = nil delete_many_result(src, correlation_id, false, "invalid_request", {})
delete_result(src, correlation_id, true, nil, media_id) 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) end)
function SkyPhoneMedia.GetDeviceRemoteIds(imei) function SkyPhoneMedia.GetDeviceRemoteIds(imei)
+1
View File
@@ -351,6 +351,7 @@ local function store_import(owner, media)
return { return {
createdAt = os.time() * 1000, createdAt = os.time() * 1000,
favorite = false,
id = media_id, id = media_id,
mediaType = media.mediaType, mediaType = media.mediaType,
url = media.url, url = media.url,