diff --git a/frontend/src/assets/img/app-icons/gallery.webp b/frontend/src/assets/img/app-icons/gallery.webp
index 123f373..b342b15 100644
Binary files a/frontend/src/assets/img/app-icons/gallery.webp and b/frontend/src/assets/img/app-icons/gallery.webp differ
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('', source.indexOf('')),
+)
+
+describe('GalleryApp import action', () => {
+ it('uses the photo viewer toolbar button design', () => {
+ expect(headerActions).toContain('',
+ )
+ 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('('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(' {
+ 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('gallery:favorite'")
+ expect(source).toContain(' {
+ 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')
+ })
+})
diff --git a/frontend/src/views/apps/GalleryApp.vue b/frontend/src/views/apps/GalleryApp.vue
index 9fd25ce..4a9b82b 100644
--- a/frontend/src/views/apps/GalleryApp.vue
+++ b/frontend/src/views/apps/GalleryApp.vue
@@ -16,9 +16,14 @@ import {
kToast,
} from 'konsta/vue'
import {
+ Check,
+ ChevronLeft,
ChevronRight,
+ Download,
Globe2,
+ Heart,
Link2,
+ ListFilter,
Play,
RotateCcw,
Share2,
@@ -32,26 +37,44 @@ import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import { useEasyShareStore } from '@/stores/easyshare'
+import {
+ SkyActionButton,
+ SkyActionGroup,
+ SkyActionSheet,
+ SkyActionsLabel,
+ SkyButton,
+ SkyNavbar,
+ SkyToolbar,
+ SkyToolbarPane,
+} from '@/ui'
import type {
+ DeleteManyResult,
DeleteResult,
+ FavoriteResult,
+ GalleryCounts,
GalleryFilter,
+ GallerySortOrder,
MediaImportSource,
MediaImportSources,
PhoneMedia,
} from '@/types/media'
import {
+ bottomRightGridPosition,
hasNextMediaPage,
MEDIA_PAGE_SIZE,
mediaErrorKey,
mergeMedia,
+ orderMedia,
} from '@/utils/media'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
const isDevelopment = import.meta.env.DEV
-const developmentGalleryState = isDevelopment
- ? new URLSearchParams(window.location.search).get('galleryMock')
+const developmentParameters = isDevelopment
+ ? new URLSearchParams(window.location.search)
: null
+const developmentApiEnabled = Boolean(developmentParameters?.has('apiPort'))
+const developmentGalleryState = developmentParameters?.get('galleryMock') ?? null
const filterItems = [
{ id: 'all', label: 'all' },
{ id: 'photo', label: 'photos' },
@@ -72,8 +95,20 @@ const multipleSelection = computed(
(messageMedia.request?.maxSelection ?? 1) > 1,
)
const selectedMediaIds = ref([])
+const selectionMode = ref(false)
const media = ref([])
+const counts = ref({
+ all: 0,
+ favoritePhotos: 0,
+ favorites: 0,
+ favoriteVideos: 0,
+ photos: 0,
+ videos: 0,
+})
const filter = ref(requestedMessageMedia.value ?? 'all')
+const favoritesOnly = ref(false)
+const sortOrder = ref('oldest')
+const sortMenuOpened = ref(false)
const loading = ref(true)
const fetching = ref(false)
const hasMore = ref(true)
@@ -86,6 +121,7 @@ const importUrl = ref('')
const importError = ref('')
const importing = ref(false)
const deleteDialogOpened = ref(false)
+const deleteManyDialogOpened = ref(false)
const cancelButtonColors = {
fillBgIos: 'bg-[#8e8e93] active:bg-[#7a7a7f]',
fillBgMaterial: 'bg-[#8e8e93] active:bg-[#7a7a7f]',
@@ -102,18 +138,21 @@ const filterBarColors = {
strongHighlightBgIos: 'bg-[#63636680]',
}
const deleting = ref(false)
+const deletingMany = ref(false)
+const favoriting = ref(false)
const toastOpened = ref(false)
const toastText = ref('')
const loadTrigger = ref(null)
+const galleryContent = ref(null)
const imageZoom = ref(1)
const imagePan = ref({ x: 0, y: 0 })
-const landscapeViewer = ref(false)
const dragging = ref(false)
const videoPlaybackError = ref(false)
const dragStart = ref({ panX: 0, panY: 0, x: 0, y: 0 })
let observer: IntersectionObserver | null = null
let toastTimer: number | undefined
let pendingDeleteCorrelation = ''
+let pendingDeleteManyCorrelation = ''
let dragTarget: HTMLElement | null = null
let dragPointerId: number | null = null
@@ -122,48 +161,238 @@ const imageStyle = computed(() => ({
imageZoom.value > 1 ? (dragging.value ? 'grabbing' : 'grab') : 'zoom-in',
transform: `translate3d(${imagePan.value.x}px, ${imagePan.value.y}px, 0) scale(${imageZoom.value})`,
}))
-
-function buildMockPhoto(
- id: number,
- label: string,
- first: string,
- second: string,
- landscape = false,
-): PhoneMedia {
- const width = landscape ? 1600 : 900
- const height = landscape ? 900 : 1200
- const svg = ``
- return {
- createdAt: Date.now() - id * 3_600_000,
- id,
- mediaType: 'photo',
- url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
+const orderedMedia = computed(() => orderMedia(media.value, sortOrder.value))
+const countText = computed(() => {
+ if (favoritesOnly.value) {
+ const favoriteCount =
+ filter.value === 'photo'
+ ? counts.value.favoritePhotos
+ : filter.value === 'video'
+ ? counts.value.favoriteVideos
+ : counts.value.favorites
+ return phone.t(
+ `Apps.photos.counts.${favoriteCount === 1 ? 'favorite' : 'favorites'}`,
+ { count: new Intl.NumberFormat(phone.lang).format(favoriteCount) },
+ )
}
+ const countKey = filter.value === 'all' ? 'all' : `${filter.value}s`
+ const count = counts.value[countKey as keyof GalleryCounts]
+ const translationKey =
+ count === 1
+ ? filter.value === 'all'
+ ? 'allOne'
+ : filter.value
+ : countKey
+ return phone.t(`Apps.photos.counts.${translationKey}`, {
+ count: new Intl.NumberFormat(phone.lang).format(count),
+ })
+})
+const selectedMedia = computed(() =>
+ selectedMediaIds.value.flatMap((id) => {
+ const entry = media.value.find((item) => item.id === id)
+ return entry ? [entry] : []
+ }),
+)
+const selectedCountText = computed(() =>
+ phone.t('Apps.photos.selection.selected', {
+ count: new Intl.NumberFormat(phone.lang).format(selectedMediaIds.value.length),
+ }),
+)
+const selectedCaptureDay = computed(() => {
+ if (!selected.value) return ''
+ const captured = new Date(selected.value.createdAt)
+ const today = new Date()
+ const capturedDay = new Date(
+ captured.getFullYear(),
+ captured.getMonth(),
+ captured.getDate(),
+ ).getTime()
+ const todayStart = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate(),
+ ).getTime()
+ const difference = Math.round((capturedDay - todayStart) / 86_400_000)
+ if (difference === 0) return phone.t('Apps.photos.today')
+ if (difference === -1) return phone.t('Apps.photos.yesterday')
+ return new Intl.DateTimeFormat(phone.lang, {
+ day: 'numeric',
+ month: 'long',
+ weekday: 'long',
+ }).format(captured)
+})
+const selectedCaptureTime = computed(() =>
+ selected.value
+ ? new Intl.DateTimeFormat(phone.lang, {
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date(selected.value.createdAt))
+ : '',
+)
+
+function mockGalleryImage(
+ title: string,
+ sky: string,
+ landscape: string,
+ accent: string,
+): string {
+ const svg = ``
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
}
function mockMedia(): PhoneMedia[] {
- const photos = [
- buildMockPhoto(1, 'Vespucci', '#23567b', '#e08d5c', true),
- buildMockPhoto(2, 'Downtown', '#442c69', '#c86a77'),
- buildMockPhoto(3, 'Paleto Bay', '#1f6653', '#d1a85b'),
- buildMockPhoto(4, 'Mirror Park', '#355c7d', '#6c5b7b'),
- buildMockPhoto(5, 'Del Perro', '#b06ab3', '#4568dc'),
- buildMockPhoto(6, 'Sandy Shores', '#7b4f35', '#d2a35f'),
- buildMockPhoto(7, 'Rockford', '#203a43', '#2c5364'),
- buildMockPhoto(8, 'Little Seoul', '#8e2de2', '#4a00e0'),
- ]
- return [
- photos[0],
+ const media: PhoneMedia[] = [
{
- createdAt: Date.now() - 1_800_000,
- id: 100,
+ createdAt: Date.now() - 4 * 60_000,
+ favorite: false,
+ id: 1,
+ mediaType: 'photo',
+ url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'),
+ },
+ {
+ 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',
},
- ...photos.slice(1),
+ {
+ createdAt: Date.now() - 47 * 60_000,
+ favorite: true,
+ id: 3,
+ mediaType: 'photo',
+ url: mockGalleryImage('Beach Drive', '#0369a1', '#164e63', '#fbbf24'),
+ },
+ {
+ createdAt: Date.now() - 2 * 60 * 60_000,
+ favorite: false,
+ id: 4,
+ mediaType: 'photo',
+ url: mockGalleryImage('Mountain Road', '#475569', '#334155', '#e2e8f0'),
+ },
+ {
+ createdAt: Date.now() - 5 * 60 * 60_000,
+ favorite: false,
+ id: 5,
+ mediaType: 'photo',
+ 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'),
+ },
+ ]
+
+ const additionalPhotos = [
+ ['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'],
+ ] as const
+
+ return [
+ ...media,
+ ...additionalPhotos.map(([title, sky, landscape, accent], index) => ({
+ createdAt: Date.now() - (6 + index) * 86_400_000,
+ favorite: index % 7 === 0,
+ id: 13 + index,
+ mediaType: 'photo' as const,
+ url: mockGalleryImage(title, sky, landscape, accent),
+ })),
]
}
+const developmentMedia = isDevelopment ? mockMedia() : []
+
function showToast(text: string): void {
if (toastTimer !== undefined) window.clearTimeout(toastTimer)
toastText.value = text
@@ -174,6 +403,7 @@ function showToast(text: string): void {
}
async function loadImportSources(): Promise {
+ if (isDevelopment && !developmentApiEnabled) return
const response = await nuiCall('media:import:sources')
if (!response.success || !response.data) return
importSources.value = response.data.sources
@@ -234,6 +464,7 @@ async function commitUrlImport(): Promise {
return
}
media.value = mergeMedia(media.value, [response.data])
+ await fetchCounts()
closeImport()
showToast(phone.t('Apps.photos.import.linkCompleted'))
}
@@ -249,8 +480,21 @@ async function fetchMore(): Promise {
if (fetching.value || !hasMore.value) return
fetching.value = true
const offset = media.value.length
+ if (isDevelopment && !developmentApiEnabled) {
+ media.value = developmentMedia.filter(
+ (entry) =>
+ (filter.value === 'all' || entry.mediaType === filter.value) &&
+ (!favoritesOnly.value || entry.favorite),
+ )
+ hasMore.value = false
+ fetching.value = false
+ return
+ }
+ const previousScrollHeight = galleryContent.value?.scrollHeight ?? 0
+ const previousScrollTop = galleryContent.value?.scrollTop ?? 0
const response = await nuiCall('gallery:list', {
limit: MEDIA_PAGE_SIZE,
+ favoriteOnly: favoritesOnly.value || undefined,
mediaType: filter.value === 'all' ? undefined : filter.value,
mockState: developmentGalleryState ?? undefined,
offset,
@@ -258,13 +502,24 @@ async function fetchMore(): Promise {
if (response.success && Array.isArray(response.data)) {
media.value = mergeMedia(media.value, response.data)
hasMore.value = hasNextMediaPage(response.data.length)
+ if (offset > 0) {
+ await nextTick()
+ if (galleryContent.value) {
+ galleryContent.value.scrollTop =
+ previousScrollTop +
+ galleryContent.value.scrollHeight -
+ previousScrollHeight
+ }
+ }
} else if (
isDevelopment &&
developmentGalleryState !== 'error' &&
offset === 0
) {
- const mock = mockMedia().filter(
- (entry) => filter.value === 'all' || entry.mediaType === filter.value,
+ const mock = developmentMedia.filter(
+ (entry) =>
+ (filter.value === 'all' || entry.mediaType === filter.value) &&
+ (!favoritesOnly.value || entry.favorite),
)
media.value = mock
hasMore.value = false
@@ -279,17 +534,83 @@ async function fetchMore(): Promise {
fetching.value = false
}
+async function fetchCounts(): Promise {
+ if (isDevelopment && !developmentApiEnabled) {
+ counts.value = {
+ all: developmentMedia.length,
+ favoritePhotos: developmentMedia.filter(
+ (entry) => entry.mediaType === 'photo' && entry.favorite,
+ ).length,
+ favorites: developmentMedia.filter((entry) => entry.favorite).length,
+ favoriteVideos: developmentMedia.filter(
+ (entry) => entry.mediaType === 'video' && entry.favorite,
+ ).length,
+ photos: developmentMedia.filter((entry) => entry.mediaType === 'photo')
+ .length,
+ videos: developmentMedia.filter((entry) => entry.mediaType === 'video')
+ .length,
+ }
+ return
+ }
+ const response = await nuiCall('gallery:counts')
+ if (response.success && response.data) {
+ counts.value = response.data
+ return
+ }
+ if (isDevelopment && developmentGalleryState !== 'error') {
+ counts.value = {
+ all: developmentMedia.length,
+ favoritePhotos: developmentMedia.filter(
+ (entry) => entry.mediaType === 'photo' && entry.favorite,
+ ).length,
+ favorites: developmentMedia.filter((entry) => entry.favorite).length,
+ favoriteVideos: developmentMedia.filter(
+ (entry) => entry.mediaType === 'video' && entry.favorite,
+ ).length,
+ photos: developmentMedia.filter((entry) => entry.mediaType === 'photo')
+ .length,
+ videos: developmentMedia.filter((entry) => entry.mediaType === 'video')
+ .length,
+ }
+ return
+ }
+ loadError.value = phone.t(
+ `Apps.photos.errors.${mediaErrorKey(response.error)}`,
+ )
+}
+
async function loadGallery(): Promise {
media.value = []
hasMore.value = true
loadError.value = ''
loading.value = true
- await fetchMore()
+ await Promise.all([fetchMore(), fetchCounts()])
loading.value = false
await nextTick()
+ if (galleryContent.value) {
+ galleryContent.value.scrollTop =
+ sortOrder.value === 'oldest' ? galleryContent.value.scrollHeight : 0
+ }
observeMore()
}
+async function selectSortOrder(value: GallerySortOrder): Promise {
+ sortOrder.value = value
+ sortMenuOpened.value = false
+ await nextTick()
+ if (galleryContent.value) {
+ galleryContent.value.scrollTop =
+ value === 'oldest' ? galleryContent.value.scrollHeight : 0
+ }
+ observeMore()
+}
+
+async function selectFavoritesOnly(value: boolean): Promise {
+ favoritesOnly.value = value
+ sortMenuOpened.value = false
+ await loadGallery()
+}
+
function observeMore(): void {
observer?.disconnect()
observer = null
@@ -298,26 +619,46 @@ function observeMore(): void {
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void fetchMore()
},
- { rootMargin: '180px' },
+ { root: galleryContent.value, rootMargin: '180px 0px 0px' },
)
observer.observe(loadTrigger.value)
}
+function enterSelectionMode(): void {
+ selectedMediaIds.value = []
+ selectionMode.value = true
+}
+
+function exitSelectionMode(): void {
+ selectedMediaIds.value = []
+ selectionMode.value = false
+ deleteManyDialogOpened.value = false
+}
+
+function toggleMediaSelection(entry: PhoneMedia, maximum = 50): void {
+ const index = selectedMediaIds.value.indexOf(entry.id)
+ if (index >= 0) {
+ selectedMediaIds.value.splice(index, 1)
+ return
+ }
+ if (selectedMediaIds.value.length >= maximum) {
+ showToast(phone.t('Apps.photos.selection.limit'))
+ return
+ }
+ selectedMediaIds.value.push(entry.id)
+}
+
function openMedia(entry: PhoneMedia): void {
+ if (selectionMode.value) {
+ toggleMediaSelection(entry)
+ return
+ }
if (requestedMessageMedia.value) {
if (multipleSelection.value) {
- const index = selectedMediaIds.value.indexOf(entry.id)
- if (index >= 0) selectedMediaIds.value.splice(index, 1)
- else if (
- selectedMediaIds.value.length <
- (messageMedia.request?.maxSelection ?? 1)
- ) {
- selectedMediaIds.value.push(entry.id)
- }
+ toggleMediaSelection(entry, messageMedia.request?.maxSelection ?? 1)
return
}
}
- landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = entry
videoPlaybackError.value = false
@@ -345,7 +686,6 @@ function cancelMessageSelection(): void {
}
function closeMedia(): void {
- landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = null
videoPlaybackError.value = false
@@ -368,16 +708,124 @@ function shareSelected(): void {
})
}
-function orientToMedia(event: Event): void {
- const mediaElement = event.currentTarget as
- | HTMLImageElement
- | HTMLVideoElement
- const landscape =
- mediaElement instanceof HTMLVideoElement
- ? mediaElement.videoWidth > mediaElement.videoHeight
- : mediaElement.naturalWidth > mediaElement.naturalHeight
- landscapeViewer.value = landscape
- phone.setCameraLandscape(landscape)
+async function toggleFavorite(): Promise {
+ if (!selected.value || favoriting.value) return
+ favoriting.value = true
+ const nextFavorite = !selected.value.favorite
+ if (isDevelopment && !developmentApiEnabled) {
+ selected.value.favorite = nextFavorite
+ const developmentItem = developmentMedia.find(
+ (entry) => entry.id === selected.value?.id,
+ )
+ if (developmentItem) developmentItem.favorite = nextFavorite
+ if (favoritesOnly.value && !nextFavorite) {
+ media.value = media.value.filter((entry) => entry.id !== selected.value?.id)
+ }
+ favoriting.value = false
+ await fetchCounts()
+ return
+ }
+ const response = await nuiCall('gallery:favorite', {
+ favorite: nextFavorite,
+ id: selected.value.id,
+ })
+ favoriting.value = false
+ if (!response.success || !response.data) {
+ if (isDevelopment && developmentGalleryState !== 'error') {
+ selected.value.favorite = nextFavorite
+ const developmentItem = developmentMedia.find(
+ (entry) => entry.id === selected.value?.id,
+ )
+ if (developmentItem) developmentItem.favorite = nextFavorite
+ if (favoritesOnly.value && !nextFavorite) {
+ media.value = media.value.filter((entry) => entry.id !== selected.value?.id)
+ }
+ await fetchCounts()
+ return
+ }
+ showToast(phone.t(`Apps.photos.errors.${mediaErrorKey(response.error)}`))
+ return
+ }
+ selected.value.favorite = response.data.favorite
+ const item = media.value.find((entry) => entry.id === response.data?.id)
+ if (item) item.favorite = response.data.favorite
+ if (favoritesOnly.value && !response.data.favorite) {
+ media.value = media.value.filter((entry) => entry.id !== response.data?.id)
+ }
+ await fetchCounts()
+}
+
+function shareSelection(): void {
+ if (!selectedMedia.value.length) return
+ const count = String(selectedMedia.value.length)
+ easyShare.open({
+ appId: 'photos',
+ copyText: phone.t('Apps.photos.selection.shareCopy', { count }),
+ imageUrl: selectedMedia.value[0].url,
+ kind: 'media',
+ meta: { mediaIds: selectedMedia.value.map((entry) => entry.id) },
+ subtitle: selectedCountText.value,
+ title: phone.t('Apps.photos.selection.shareTitle', { count }),
+ })
+}
+
+async function deleteSelection(): Promise {
+ if (!selectedMediaIds.value.length || deletingMany.value) return
+ deletingMany.value = true
+ deleteManyDialogOpened.value = false
+ pendingDeleteManyCorrelation = `${Date.now()}-${crypto.randomUUID()}`
+ const ids = [...selectedMediaIds.value]
+ if (isDevelopment) {
+ if (!developmentApiEnabled) {
+ for (const id of ids) {
+ const index = developmentMedia.findIndex((entry) => entry.id === id)
+ if (index >= 0) developmentMedia.splice(index, 1)
+ }
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ data: {
+ correlationId: pendingDeleteManyCorrelation,
+ deletedIds: ids,
+ success: true,
+ },
+ type: 'media:deleteManyResult',
+ },
+ }),
+ )
+ return
+ }
+ const response = await nuiCall('gallery:delete-many', {
+ correlationId: pendingDeleteManyCorrelation,
+ ids,
+ })
+ const fallbackDeletedIds =
+ !response.success && developmentGalleryState !== 'error'
+ ? ids.filter((id) => developmentMedia.some((entry) => entry.id === id))
+ : []
+ for (const id of fallbackDeletedIds) {
+ const index = developmentMedia.findIndex((entry) => entry.id === id)
+ if (index >= 0) developmentMedia.splice(index, 1)
+ }
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: {
+ data: response.data ?? {
+ correlationId: pendingDeleteManyCorrelation,
+ deletedIds: fallbackDeletedIds,
+ error: fallbackDeletedIds.length ? undefined : response.error,
+ success: fallbackDeletedIds.length > 0,
+ },
+ type: 'media:deleteManyResult',
+ },
+ }),
+ )
+ return
+ }
+ await nuiCall('gallery:delete-many', {
+ correlationId: pendingDeleteManyCorrelation,
+ ids,
+ })
}
function setZoom(value: number): void {
@@ -443,7 +891,6 @@ function moveImageWithKeyboard(event: KeyboardEvent): void {
}
async function initializeVideo(event: Event): Promise {
- orientToMedia(event)
videoPlaybackError.value = false
try {
await (event.currentTarget as HTMLVideoElement).play()
@@ -458,6 +905,10 @@ async function deleteSelected(): Promise {
deleteDialogOpened.value = false
pendingDeleteCorrelation = `${Date.now()}-${crypto.randomUUID()}`
if (isDevelopment) {
+ const developmentIndex = developmentMedia.findIndex(
+ (entry) => entry.id === selected.value?.id,
+ )
+ if (developmentIndex >= 0) developmentMedia.splice(developmentIndex, 1)
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
@@ -482,25 +933,55 @@ async function deleteSelected(): Promise {
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
- const message = event.data as { data?: DeleteResult; type?: string }
+ const message = event.data as {
+ data?: DeleteManyResult | DeleteResult
+ type?: string
+ }
if (message.type === 'gallery:changed') {
void loadGallery()
return
}
+ if (message.type === 'media:deleteManyResult') {
+ const result = message.data as DeleteManyResult | undefined
+ if (result?.correlationId !== pendingDeleteManyCorrelation) return
+ deletingMany.value = false
+ const deletedIds = result.deletedIds ?? []
+ if (deletedIds.length) {
+ const deleted = new Set(deletedIds)
+ media.value = media.value.filter((entry) => !deleted.has(entry.id))
+ void fetchCounts()
+ }
+ if (result.success) {
+ showToast(
+ phone.t('Apps.photos.selection.deleted', {
+ count: String(deletedIds.length),
+ }),
+ )
+ exitSelectionMode()
+ } else {
+ selectedMediaIds.value = selectedMediaIds.value.filter(
+ (id) => !deletedIds.includes(id),
+ )
+ showToast(phone.t(`Apps.photos.errors.${mediaErrorKey(result.error)}`))
+ }
+ return
+ }
if (
message.type !== 'media:deleteResult' ||
message.data?.correlationId !== pendingDeleteCorrelation
) {
return
}
+ const result = message.data as DeleteResult
deleting.value = false
- if (message.data.success && message.data.id) {
- media.value = media.value.filter((entry) => entry.id !== message.data?.id)
+ if (result.success && result.id) {
+ media.value = media.value.filter((entry) => entry.id !== result.id)
+ void fetchCounts()
closeMedia()
showToast(phone.t('Apps.photos.deleted'))
} else {
showToast(
- phone.t(`Apps.photos.errors.${mediaErrorKey(message.data.error)}`),
+ phone.t(`Apps.photos.errors.${mediaErrorKey(result.error)}`),
)
}
}
@@ -616,7 +1097,8 @@ onBeforeUnmount(() => {
{
-
-
-
- {{ phone.t('Apps.photos.import.action') }}
-
+
+
+
-
+
-
+
{{ phone.t('Apps.photos.loading') }}
@@ -669,14 +1201,36 @@ onBeforeUnmount(() => {
class="gallery-grid"
:class="{ 'gallery-grid--fill': media.length >= 13 }"
>
+
@@ -718,7 +1276,7 @@ onBeforeUnmount(() => {
{
+
+
+
+ {{ selectedCountText }}
+
+
+
+
+
+
+
+
+
+
-
-
+ >
+
+
-
{{ phone.t('Common.use') }}
-
-
-
-
-
-
-
+
-
+
@@ -807,7 +1381,6 @@ onBeforeUnmount(() => {
:style="imageStyle"
draggable="false"
tabindex="0"
- @load="orientToMedia"
@pointerdown="startDragging"
@pointermove="moveImage"
@pointerup="stopDragging"
@@ -819,6 +1392,7 @@ onBeforeUnmount(() => {
-
- {{ formatDate(selected.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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.deleteTitle') }}
+
+ {{ phone.t('Apps.photos.selection.deleteBody') }}
+
+
+ {{ phone.t('Common.cancel') }}
+
+
+ {{ phone.t('Common.delete') }}
+
+
+
+
{
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 = ``
+ 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,