diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index 595f566c47..a5acad25f6 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,26 +1,30 @@ -import ImageResizer from '@bam.tech/react-native-image-resizer' +import {deleteAsync} from 'expo-file-system' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import RNFetchBlob from 'rn-fetch-blob' import { downloadAndResize, DownloadAndResizeOpts, + getResizedDimensions, } from '../../src/lib/media/manip' +const mockResizedImage = { + path: 'file://resized-image.jpg', + size: 100, + width: 100, + height: 100, + mime: 'image/jpeg', +} + describe('downloadAndResize', () => { const errorSpy = jest.spyOn(global.console, 'error') - const mockResizedImage = { - path: jest.fn().mockReturnValue('file://resized-image.jpg'), - size: 100, - width: 50, - height: 50, - mime: 'image/jpeg', - } - beforeEach(() => { - const mockedCreateResizedImage = - ImageResizer.createResizedImage as jest.Mock - mockedCreateResizedImage.mockResolvedValue(mockResizedImage) + const mockedCreateResizedImage = manipulateAsync as jest.Mock + mockedCreateResizedImage.mockResolvedValue({ + uri: 'file://resized-image.jpg', + ...mockResizedImage, + }) }) afterEach(() => { @@ -54,17 +58,17 @@ describe('downloadAndResize', () => { 'GET', 'https://example.com/image.jpg', ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image.jpg', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, + + // First time it gets called is to get dimensions + expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) + expect(manipulateAsync).toHaveBeenCalledWith( + expect.any(String), + [{resize: {height: opts.height, width: opts.width}}], + {format: SaveFormat.JPEG, compress: 1.0}, ) + expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { + idempotent: true, + }) }) it('should return undefined for invalid URI', async () => { @@ -82,46 +86,6 @@ describe('downloadAndResize', () => { expect(result).toBeUndefined() }) - it('should return undefined for unsupported file type', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image'), - info: jest.fn().mockReturnValue({status: 200}), - flush: jest.fn(), - }) - - const opts: DownloadAndResizeOpts = { - uri: 'https://example.com/image', - width: 100, - height: 100, - maxSize: 500000, - mode: 'cover', - timeout: 10000, - } - - const result = await downloadAndResize(opts) - expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ - fileCache: true, - appendExt: 'jpeg', - }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( - 'GET', - 'https://example.com/image', - ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, - ) - }) - it('should return undefined for non-200 response', async () => { const mockedFetch = RNFetchBlob.fetch as jest.Mock mockedFetch.mockResolvedValueOnce({ @@ -143,4 +107,44 @@ describe('downloadAndResize', () => { expect(errorSpy).not.toHaveBeenCalled() expect(result).toBeUndefined() }) + + it('should not downsize whenever dimensions are below the max dimensions', () => { + const initialDimensionsOne = { + width: 1200, + height: 1000, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 1000, + height: 1200, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual(initialDimensionsOne) + expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) + }) + + it('should resize dimensions and maintain aspect ratio if they are above the max dimensons', () => { + const initialDimensionsOne = { + width: 3000, + height: 1500, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 2000, + height: 4000, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual({ + width: 2000, + height: 1000, + }) + expect(resizedDimensionsTwo).toEqual({ + width: 1000, + height: 2000, + }) + }) }) diff --git a/assets/icons/alien_stroke2_corner0_rounded.svg b/assets/icons/alien_stroke2_corner0_rounded.svg index 595308c97b..c4dcc350a5 100644 --- a/assets/icons/alien_stroke2_corner0_rounded.svg +++ b/assets/icons/alien_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/apple_stroke2_corner0_rounded.svg b/assets/icons/apple_stroke2_corner0_rounded.svg index 3c7f051a3c..300b1396af 100644 --- a/assets/icons/apple_stroke2_corner0_rounded.svg +++ b/assets/icons/apple_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg index f40546f7cd..e8bd913893 100644 --- a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg +++ b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg index a9532cd9c6..84e0d1e53c 100644 --- a/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg index 9b92e533eb..16b7f7db11 100644 --- a/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg +++ b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg index 9987b34406..ef8268de1f 100644 --- a/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg index 36d8e1d67c..56a6a6165a 100644 --- a/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg +++ b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/at_stroke2_corner0_rounded.svg b/assets/icons/at_stroke2_corner0_rounded.svg index 8d30d7c8c5..e43b7f77c3 100644 --- a/assets/icons/at_stroke2_corner0_rounded.svg +++ b/assets/icons/at_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/atom_stroke2_corner0_rounded.svg b/assets/icons/atom_stroke2_corner0_rounded.svg index 723fdeab6d..a2965fd3be 100644 --- a/assets/icons/atom_stroke2_corner0_rounded.svg +++ b/assets/icons/atom_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/bell2_filled_corner0_rounded.svg b/assets/icons/bell2_filled_corner0_rounded.svg index 9c66129b11..556854d35a 100644 --- a/assets/icons/bell2_filled_corner0_rounded.svg +++ b/assets/icons/bell2_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bell2_stroke2_corner0_rounded.svg b/assets/icons/bell2_stroke2_corner0_rounded.svg index 577bc5eaa1..5290906ab8 100644 --- a/assets/icons/bell2_stroke2_corner0_rounded.svg +++ b/assets/icons/bell2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bellOff_filled_corner0_rounded.svg b/assets/icons/bellOff_filled_corner0_rounded.svg index 4c6997a709..80b48c4c93 100644 --- a/assets/icons/bellOff_filled_corner0_rounded.svg +++ b/assets/icons/bellOff_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bellOff_stroke2_corner0_rounded.svg b/assets/icons/bellOff_stroke2_corner0_rounded.svg index 0ed4910d4b..07b30755e9 100644 --- a/assets/icons/bellOff_stroke2_corner0_rounded.svg +++ b/assets/icons/bellOff_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bell_filled_corner0_rounded.svg b/assets/icons/bell_filled_corner0_rounded.svg index 3f21b7e9bd..249e8bc19b 100644 --- a/assets/icons/bell_filled_corner0_rounded.svg +++ b/assets/icons/bell_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bell_stroke2_corner0_rounded.svg b/assets/icons/bell_stroke2_corner0_rounded.svg index a31f1bd152..8b50bb2eef 100644 --- a/assets/icons/bell_stroke2_corner0_rounded.svg +++ b/assets/icons/bell_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg index 0bfcc48a0e..fa37c14332 100644 --- a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg +++ b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/calendar_stroke2_corner0_rounded.svg b/assets/icons/calendar_stroke2_corner0_rounded.svg index 703f389dba..a8180a22fb 100644 --- a/assets/icons/calendar_stroke2_corner0_rounded.svg +++ b/assets/icons/calendar_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/camera_filled_stroke2_corner0_rounded.svg b/assets/icons/camera_filled_stroke2_corner0_rounded.svg index fa0101cf0d..a6baed32c6 100644 --- a/assets/icons/camera_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/camera_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/camera_stroke2_corner0_rounded.svg b/assets/icons/camera_stroke2_corner0_rounded.svg index ce0c29ae50..789bc14a84 100644 --- a/assets/icons/camera_stroke2_corner0_rounded.svg +++ b/assets/icons/camera_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/cc_filled_stroke2_corner0_rounded.svg b/assets/icons/cc_filled_stroke2_corner0_rounded.svg index 58823ca80d..ae8466e67e 100644 --- a/assets/icons/cc_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/cc_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/cc_stroke2_corner0_rounded.svg b/assets/icons/cc_stroke2_corner0_rounded.svg index fcda1570f9..4d711d85be 100644 --- a/assets/icons/cc_stroke2_corner0_rounded.svg +++ b/assets/icons/cc_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/celebrate_stroke2_corner0_rounded.svg b/assets/icons/celebrate_stroke2_corner0_rounded.svg index 3ea7bc8d2a..f2ea3c44d8 100644 --- a/assets/icons/celebrate_stroke2_corner0_rounded.svg +++ b/assets/icons/celebrate_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg index 705c1c5139..085a28bb8c 100644 --- a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg +++ b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/chevronTop_stroke2_corner0_rounded.svg b/assets/icons/chevronTop_stroke2_corner0_rounded.svg index da94ba911f..2012da128f 100644 --- a/assets/icons/chevronTop_stroke2_corner0_rounded.svg +++ b/assets/icons/chevronTop_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg index 73251477fa..349e9c32de 100644 --- a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg +++ b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/clipboard_stroke2_corner2_rounded.svg b/assets/icons/clipboard_stroke2_corner2_rounded.svg index f403cfb929..bcda20dd69 100644 --- a/assets/icons/clipboard_stroke2_corner2_rounded.svg +++ b/assets/icons/clipboard_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg b/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg index 41e75887c0..3eddcbfacc 100644 --- a/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/closeQuote_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/closeQuote_stroke2_corner0_rounded.svg b/assets/icons/closeQuote_stroke2_corner0_rounded.svg index 3c76c73920..304d3c59db 100644 --- a/assets/icons/closeQuote_stroke2_corner0_rounded.svg +++ b/assets/icons/closeQuote_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/closeQuote_stroke2_corner1_rounded.svg b/assets/icons/closeQuote_stroke2_corner1_rounded.svg index b27eb94f23..357ede850e 100644 --- a/assets/icons/closeQuote_stroke2_corner1_rounded.svg +++ b/assets/icons/closeQuote_stroke2_corner1_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/coffee_stroke2_corner0_rounded.svg b/assets/icons/coffee_stroke2_corner0_rounded.svg index b734ef606a..90bd5b6ae1 100644 --- a/assets/icons/coffee_stroke2_corner0_rounded.svg +++ b/assets/icons/coffee_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/colorPalette_stroke2_corner0_rounded.svg b/assets/icons/colorPalette_stroke2_corner0_rounded.svg index b1056e1a96..db94cb6208 100644 --- a/assets/icons/colorPalette_stroke2_corner0_rounded.svg +++ b/assets/icons/colorPalette_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/earth_stroke2_corner0_rounded.svg b/assets/icons/earth_stroke2_corner0_rounded.svg index 02d2fe0b0e..e130a2e819 100644 --- a/assets/icons/earth_stroke2_corner0_rounded.svg +++ b/assets/icons/earth_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/emojiArc_stroke2_corner0_rounded.svg b/assets/icons/emojiArc_stroke2_corner0_rounded.svg index 3d09228e71..33a1e208f2 100644 --- a/assets/icons/emojiArc_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiArc_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg b/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg index 4aecb86c54..9cebeb17c5 100644 --- a/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiHeartEyes_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/emojiSad_stroke2_corner0_rounded.svg b/assets/icons/emojiSad_stroke2_corner0_rounded.svg index 0a5a43cd0b..fbbfd366d9 100644 --- a/assets/icons/emojiSad_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiSad_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/emojiSmile_stroke2_corner0_rounded.svg b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg index fd329b5098..fac12d4c79 100644 --- a/assets/icons/emojiSmile_stroke2_corner0_rounded.svg +++ b/assets/icons/emojiSmile_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/envelope_filled_stroke2_corner0_rounded.svg b/assets/icons/envelope_filled_stroke2_corner0_rounded.svg index 3810bf334f..6c3ea83020 100644 --- a/assets/icons/envelope_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/envelope_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/envelope_stroke2_corner0_rounded.svg b/assets/icons/envelope_stroke2_corner0_rounded.svg index c3ab45980b..4de98cca03 100644 --- a/assets/icons/envelope_stroke2_corner0_rounded.svg +++ b/assets/icons/envelope_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/explosion_stroke2_corner0_rounded.svg b/assets/icons/explosion_stroke2_corner0_rounded.svg index 11544bf79c..e661ec3511 100644 --- a/assets/icons/explosion_stroke2_corner0_rounded.svg +++ b/assets/icons/explosion_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/eyeSlash_stroke2_corner0_rounded.svg b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg index f11bdd937f..6de34c5f3d 100644 --- a/assets/icons/eyeSlash_stroke2_corner0_rounded.svg +++ b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/eye_stroke2_corner0_rounded.svg b/assets/icons/eye_stroke2_corner0_rounded.svg index 035daa6e1c..604b3ff79e 100644 --- a/assets/icons/eye_stroke2_corner0_rounded.svg +++ b/assets/icons/eye_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg index 9f9cc5cdd1..d1de1d0075 100644 --- a/assets/icons/flag_stroke2_corner0_rounded.svg +++ b/assets/icons/flag_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/gameController_stroke2_corner0_rounded.svg b/assets/icons/gameController_stroke2_corner0_rounded.svg index e530c802b0..a3584d158c 100644 --- a/assets/icons/gameController_stroke2_corner0_rounded.svg +++ b/assets/icons/gameController_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/gifSquare_stroke2_corner0_rounded.svg b/assets/icons/gifSquare_stroke2_corner0_rounded.svg index 47b9df9846..7d44106dd5 100644 --- a/assets/icons/gifSquare_stroke2_corner0_rounded.svg +++ b/assets/icons/gifSquare_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/gif_stroke2_corner0_rounded.svg b/assets/icons/gif_stroke2_corner0_rounded.svg index 519acfd4d2..019900b593 100644 --- a/assets/icons/gif_stroke2_corner0_rounded.svg +++ b/assets/icons/gif_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/globe_stroke2_corner0_rounded.svg b/assets/icons/globe_stroke2_corner0_rounded.svg index 83cb88d136..fe0142abcb 100644 --- a/assets/icons/globe_stroke2_corner0_rounded.svg +++ b/assets/icons/globe_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/group3_stroke2_corner0_rounded.svg b/assets/icons/group3_stroke2_corner0_rounded.svg index 2a8f43a8a4..4a7cc0c2e4 100644 --- a/assets/icons/group3_stroke2_corner0_rounded.svg +++ b/assets/icons/group3_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/growth_stroke2_corner0_rounded.svg b/assets/icons/growth_stroke2_corner0_rounded.svg index ec9083fb1e..f5d931cf9c 100644 --- a/assets/icons/growth_stroke2_corner0_rounded.svg +++ b/assets/icons/growth_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/lab_stroke2_corner0_rounded.svg b/assets/icons/lab_stroke2_corner0_rounded.svg index 466809194e..cb1e017b9c 100644 --- a/assets/icons/lab_stroke2_corner0_rounded.svg +++ b/assets/icons/lab_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/leaf_stroke2_corner0_rounded.svg b/assets/icons/leaf_stroke2_corner0_rounded.svg index 16b379f98c..f5d931cf9c 100644 --- a/assets/icons/leaf_stroke2_corner0_rounded.svg +++ b/assets/icons/leaf_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/lock_stroke2_corner0_rounded.svg b/assets/icons/lock_stroke2_corner0_rounded.svg index 8b094ba5eb..6a2717fae9 100644 --- a/assets/icons/lock_stroke2_corner0_rounded.svg +++ b/assets/icons/lock_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/message_stroke2_corner0_rounded.svg b/assets/icons/message_stroke2_corner0_rounded.svg index 2cbaa3e628..1cfdb51250 100644 --- a/assets/icons/message_stroke2_corner0_rounded.svg +++ b/assets/icons/message_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/message_stroke2_corner0_rounded_filled.svg b/assets/icons/message_stroke2_corner0_rounded_filled.svg index 0de0246727..8e012013a2 100644 --- a/assets/icons/message_stroke2_corner0_rounded_filled.svg +++ b/assets/icons/message_stroke2_corner0_rounded_filled.svg @@ -1 +1 @@ - + diff --git a/assets/icons/moon_stroke2_corner2_rounded.svg b/assets/icons/moon_stroke2_corner2_rounded.svg index 8f5c03699b..06758ee3ff 100644 --- a/assets/icons/moon_stroke2_corner2_rounded.svg +++ b/assets/icons/moon_stroke2_corner2_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/musicNote_stroke2_corner0_rounded.svg b/assets/icons/musicNote_stroke2_corner0_rounded.svg index 2dcc2e3b29..031ba810ef 100644 --- a/assets/icons/musicNote_stroke2_corner0_rounded.svg +++ b/assets/icons/musicNote_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/mute_stroke2_corner0_rounded.svg b/assets/icons/mute_stroke2_corner0_rounded.svg index 8ebecb3920..ab73567563 100644 --- a/assets/icons/mute_stroke2_corner0_rounded.svg +++ b/assets/icons/mute_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/news2_stroke2_corner0_rounded.svg b/assets/icons/news2_stroke2_corner0_rounded.svg index 66e4c373a0..a298ad69a9 100644 --- a/assets/icons/news2_stroke2_corner0_rounded.svg +++ b/assets/icons/news2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/newskie.svg b/assets/icons/newskie.svg index e3a9d83c80..308049f586 100644 --- a/assets/icons/newskie.svg +++ b/assets/icons/newskie.svg @@ -1 +1 @@ - + diff --git a/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg b/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg index e8141a1128..97db191f3b 100644 --- a/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg +++ b/assets/icons/openQuote_filled_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/openQuote_stroke2_corner0_rounded.svg b/assets/icons/openQuote_stroke2_corner0_rounded.svg index eee6344cee..10cd33d20e 100644 --- a/assets/icons/openQuote_stroke2_corner0_rounded.svg +++ b/assets/icons/openQuote_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/pause_filled_corner0_rounded.svg b/assets/icons/pause_filled_corner0_rounded.svg index 0037701f90..b901dc7c51 100644 --- a/assets/icons/pause_filled_corner0_rounded.svg +++ b/assets/icons/pause_filled_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/pause_filled_corner2_rounded.svg b/assets/icons/pause_filled_corner2_rounded.svg index 98726d873e..3eebad4453 100644 --- a/assets/icons/pause_filled_corner2_rounded.svg +++ b/assets/icons/pause_filled_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/pause_stroke2_corner0_rounded.svg b/assets/icons/pause_stroke2_corner0_rounded.svg index d2735ed2bd..ee1c978f8e 100644 --- a/assets/icons/pause_stroke2_corner0_rounded.svg +++ b/assets/icons/pause_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/pause_stroke2_corner2_rounded.svg b/assets/icons/pause_stroke2_corner2_rounded.svg index 3a8c0b4379..6ce60ecf9a 100644 --- a/assets/icons/pause_stroke2_corner2_rounded.svg +++ b/assets/icons/pause_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/pencil_stroke2_corner0_rounded.svg b/assets/icons/pencil_stroke2_corner0_rounded.svg index 7341989894..098b180763 100644 --- a/assets/icons/pencil_stroke2_corner0_rounded.svg +++ b/assets/icons/pencil_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg index daec6f5579..b8d7437caa 100644 --- a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg +++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/personCheck_stroke2_corner0_rounded.svg b/assets/icons/personCheck_stroke2_corner0_rounded.svg index b3231c2780..212b166b15 100644 --- a/assets/icons/personCheck_stroke2_corner0_rounded.svg +++ b/assets/icons/personCheck_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/personPlus_stroke2_corner0_rounded.svg b/assets/icons/personPlus_stroke2_corner0_rounded.svg index 118268bf97..2de70426cb 100644 --- a/assets/icons/personPlus_stroke2_corner0_rounded.svg +++ b/assets/icons/personPlus_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/personX_stroke2_corner0_rounded.svg b/assets/icons/personX_stroke2_corner0_rounded.svg index 073015bc54..248e6ca76a 100644 --- a/assets/icons/personX_stroke2_corner0_rounded.svg +++ b/assets/icons/personX_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/person_stroke2_corner0_rounded.svg b/assets/icons/person_stroke2_corner0_rounded.svg index a23ad76071..01037371d3 100644 --- a/assets/icons/person_stroke2_corner0_rounded.svg +++ b/assets/icons/person_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/phone_stroke2_corner2_rounded.svg b/assets/icons/phone_stroke2_corner2_rounded.svg index 4f44f08e52..7e555c37a4 100644 --- a/assets/icons/phone_stroke2_corner2_rounded.svg +++ b/assets/icons/phone_stroke2_corner2_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/piggyBank_stroke2_corner0_rounded.svg b/assets/icons/piggyBank_stroke2_corner0_rounded.svg index 36d3060102..0ec432635b 100644 --- a/assets/icons/piggyBank_stroke2_corner0_rounded.svg +++ b/assets/icons/piggyBank_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/pizza_stroke2_corner0_rounded.svg b/assets/icons/pizza_stroke2_corner0_rounded.svg index e63351b897..5f809453b3 100644 --- a/assets/icons/pizza_stroke2_corner0_rounded.svg +++ b/assets/icons/pizza_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/play_filled_corner0_rounded.svg b/assets/icons/play_filled_corner0_rounded.svg index 7bee1ae9a3..2b030208fb 100644 --- a/assets/icons/play_filled_corner0_rounded.svg +++ b/assets/icons/play_filled_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/play_stroke2_corner0_rounded.svg b/assets/icons/play_stroke2_corner0_rounded.svg index d7321b9b7b..0aecbfabb7 100644 --- a/assets/icons/play_stroke2_corner0_rounded.svg +++ b/assets/icons/play_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/poop_stroke2_corner0_rounded.svg b/assets/icons/poop_stroke2_corner0_rounded.svg index daa6c11126..d6342739ff 100644 --- a/assets/icons/poop_stroke2_corner0_rounded.svg +++ b/assets/icons/poop_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/qrCode_stroke2_corner0_rounded.svg b/assets/icons/qrCode_stroke2_corner0_rounded.svg index b17db39533..680354f802 100644 --- a/assets/icons/qrCode_stroke2_corner0_rounded.svg +++ b/assets/icons/qrCode_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg index aed3d9e7ec..e032738579 100644 --- a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg +++ b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/rose_stroke2_corner0_rounded.svg b/assets/icons/rose_stroke2_corner0_rounded.svg index 2d269855bd..2b5ed9ab79 100644 --- a/assets/icons/rose_stroke2_corner0_rounded.svg +++ b/assets/icons/rose_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/settingsGear2_filled_corner0_rounded.svg b/assets/icons/settingsGear2_filled_corner0_rounded.svg index dfc89ff507..b01874bde2 100644 --- a/assets/icons/settingsGear2_filled_corner0_rounded.svg +++ b/assets/icons/settingsGear2_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/shaka_stroke2_corner0_rounded.svg b/assets/icons/shaka_stroke2_corner0_rounded.svg index 32af469e4a..63f6c222aa 100644 --- a/assets/icons/shaka_stroke2_corner0_rounded.svg +++ b/assets/icons/shaka_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/shield_stroke2_corner0_rounded.svg b/assets/icons/shield_stroke2_corner0_rounded.svg index c4ef98e5ad..36057176ff 100644 --- a/assets/icons/shield_stroke2_corner0_rounded.svg +++ b/assets/icons/shield_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg index 81357a12e3..9e0c2cfda3 100644 --- a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg +++ b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/starterPack.svg b/assets/icons/starterPack.svg index 7f0df55952..cefbab50cb 100644 --- a/assets/icons/starterPack.svg +++ b/assets/icons/starterPack.svg @@ -1 +1 @@ - + diff --git a/assets/icons/starter_pack_icon.svg b/assets/icons/starter_pack_icon.svg index 47a2f49b64..3d604b76dd 100644 --- a/assets/icons/starter_pack_icon.svg +++ b/assets/icons/starter_pack_icon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/textSize_stroke2_corner0_rounded.svg b/assets/icons/textSize_stroke2_corner0_rounded.svg index 6c7537d100..27665627d4 100644 --- a/assets/icons/textSize_stroke2_corner0_rounded.svg +++ b/assets/icons/textSize_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/ticket_stroke2_corner0_rounded.svg b/assets/icons/ticket_stroke2_corner0_rounded.svg index a45a90ae5f..0edb01eedf 100644 --- a/assets/icons/ticket_stroke2_corner0_rounded.svg +++ b/assets/icons/ticket_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/timesLarge_stroke2_corner0_rounded.svg b/assets/icons/timesLarge_stroke2_corner0_rounded.svg index 68403f5984..8b0f526bfb 100644 --- a/assets/icons/timesLarge_stroke2_corner0_rounded.svg +++ b/assets/icons/timesLarge_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/trash_stroke2_corner0_rounded.svg b/assets/icons/trash_stroke2_corner0_rounded.svg index d4b32f81fe..e7cbf50be8 100644 --- a/assets/icons/trash_stroke2_corner0_rounded.svg +++ b/assets/icons/trash_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/trending2_stroke2_corner2_rounded.svg b/assets/icons/trending2_stroke2_corner2_rounded.svg index cc806b0eb6..e5c2db79a9 100644 --- a/assets/icons/trending2_stroke2_corner2_rounded.svg +++ b/assets/icons/trending2_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg index aa56404457..ac71eab37a 100644 --- a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg +++ b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/ufo_stroke2_corner0_rounded.svg b/assets/icons/ufo_stroke2_corner0_rounded.svg index 115c589e0a..ae86aa7bef 100644 --- a/assets/icons/ufo_stroke2_corner0_rounded.svg +++ b/assets/icons/ufo_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/userCircle_filled_corner0_rounded.svg b/assets/icons/userCircle_filled_corner0_rounded.svg index 67bb6eac77..984205b020 100644 --- a/assets/icons/userCircle_filled_corner0_rounded.svg +++ b/assets/icons/userCircle_filled_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/userCircle_stroke2_corner0_rounded.svg b/assets/icons/userCircle_stroke2_corner0_rounded.svg index ffad04f2b7..7a3747e28c 100644 --- a/assets/icons/userCircle_stroke2_corner0_rounded.svg +++ b/assets/icons/userCircle_stroke2_corner0_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/zap_stroke2_corner0_rounded.svg b/assets/icons/zap_stroke2_corner0_rounded.svg index 06a88ad8d2..979649fddb 100644 --- a/assets/icons/zap_stroke2_corner0_rounded.svg +++ b/assets/icons/zap_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index eaa31aa4a3..03686ef5c4 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -32,6 +32,53 @@ --> + + {% include "scripts.html" %} @@ -48,7 +95,7 @@ {%- block body_all %}
-
+
diff --git a/jest/jestSetup.js b/jest/jestSetup.js index a68c1dc4bf..50a33589ea 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -42,8 +42,16 @@ jest.mock('rn-fetch-blob', () => ({ fetch: jest.fn(), })) -jest.mock('@bam.tech/react-native-image-resizer', () => ({ - createResizedImage: jest.fn(), +jest.mock('expo-file-system', () => ({ + getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), + deleteAsync: jest.fn(), +})) + +jest.mock('expo-image-manipulator', () => ({ + manipulateAsync: jest.fn().mockResolvedValue({ + uri: 'file://resized-image', + }), + SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat, })) jest.mock('@segment/analytics-react-native', () => ({ diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift index f863eaf223..481402890f 100644 --- a/modules/BlueskyNSE/NotificationService.swift +++ b/modules/BlueskyNSE/NotificationService.swift @@ -2,46 +2,80 @@ import UserNotifications import UIKit let APP_GROUP = "group.app.bsky" +typealias ContentHandler = (UNNotificationContent) -> Void + +// This extension allows us to do some processing of the received notification +// data before displaying the notification to the user. In our use case, there +// are a few particular things that we want to do: +// +// - Determine whether we should play a sound for the notification +// - Download and display any images for the notification +// - Update the badge count accordingly +// +// The extension may or may not create a new process to handle a notification. +// It is also possible that multiple notifications will be processed by the +// same instance of `NotificationService`, though these will happen in +// parallel. +// +// Because multiple instances of `NotificationService` may exist, we should +// be careful in accessing preferences that will be mutated _by the +// extension itself_. For example, we should not worry about `playChatSound` +// changing, since we never mutate that value within the extension itself. +// However, since we mutate `badgeCount` frequently, we should ensure that +// these updates always run sync with each other and that the have access +// to the most recent values. class NotificationService: UNNotificationServiceExtension { - var prefs = UserDefaults(suiteName: APP_GROUP) + private var contentHandler: ContentHandler? + private var bestAttempt: UNMutableNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - guard let bestAttempt = createCopy(request.content), + self.contentHandler = contentHandler + + guard let bestAttempt = NSEUtil.createCopy(request.content), let reason = request.content.userInfo["reason"] as? String else { contentHandler(request.content) return } + self.bestAttempt = bestAttempt if reason == "chat-message" { mutateWithChatMessage(bestAttempt) } else { mutateWithBadge(bestAttempt) } + // Any image downloading (or other network tasks) should be handled at the end + // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire + // gets called, we might not have all the needed mutations completed in time. + contentHandler(bestAttempt) } override func serviceExtensionTimeWillExpire() { - // If for some reason the alloted time expires, we don't actually want to display a notification + guard let contentHandler = self.contentHandler, + let bestAttempt = self.bestAttempt else { + return + } + contentHandler(bestAttempt) } - func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? { - return content.mutableCopy() as? UNMutableNotificationContent - } + // MARK: Mutations func mutateWithBadge(_ content: UNMutableNotificationContent) { - var count = prefs?.integer(forKey: "badgeCount") ?? 0 - count += 1 + NSEUtil.shared.prefsQueue.sync { + var count = NSEUtil.shared.prefs?.integer(forKey: "badgeCount") ?? 0 + count += 1 - // Set the new badge number for the notification, then store that value for using later - content.badge = NSNumber(value: count) - prefs?.setValue(count, forKey: "badgeCount") + // Set the new badge number for the notification, then store that value for using later + content.badge = NSNumber(value: count) + NSEUtil.shared.prefs?.setValue(count, forKey: "badgeCount") + } } func mutateWithChatMessage(_ content: UNMutableNotificationContent) { - if self.prefs?.bool(forKey: "playSoundChat") == true { + if NSEUtil.shared.prefs?.bool(forKey: "playSoundChat") == true { mutateWithDmSound(content) } } @@ -54,3 +88,18 @@ class NotificationService: UNNotificationServiceExtension { content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff")) } } + +// NSEUtil's purpose is to create a shared instance of `UserDefaults` across +// `NotificationService` instances. It also includes a queue so that we can process +// updates to `UserDefaults` in parallel. + +private class NSEUtil { + static let shared = NSEUtil() + + var prefs = UserDefaults(suiteName: APP_GROUP) + var prefsQueue = DispatchQueue(label: "NSEPrefsQueue") + + static func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? { + return content.mutableCopy() as? UNMutableNotificationContent + } +} diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist index 421abb3c41..43f46a5e56 100644 --- a/modules/Share-with-Bluesky/Info.plist +++ b/modules/Share-with-Bluesky/Info.plist @@ -16,6 +16,8 @@ 1 NSExtensionActivationSupportsImageWithMaxCount 10 + NSExtensionActivationSupportsMovieWithMaxCount + 1 NSExtensionPointIdentifier @@ -38,4 +40,4 @@ CFBundleShortVersionString $(MARKETING_VERSION) - \ No newline at end of file + diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift index c045d578fe..63143277a5 100644 --- a/modules/Share-with-Bluesky/ShareViewController.swift +++ b/modules/Share-with-Bluesky/ShareViewController.swift @@ -5,7 +5,6 @@ class ShareViewController: UIViewController { // scheme. let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky" - // override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -24,6 +23,8 @@ class ShareViewController: UIViewController { await self.handleUrl(item: firstAttachment) } else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") { await self.handleImages(items: attachments) + } else if firstAttachment.hasItemConformingToTypeIdentifier("public.video") { + await self.handleVideos(items: attachments) } else { self.completeRequest() } @@ -31,31 +32,23 @@ class ShareViewController: UIViewController { } private func handleText(item: NSItemProvider) async { - do { - if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String { - if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), - let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { - _ = self.openURL(url) - } + if let data = try? await item.loadItem(forTypeIdentifier: "public.text") as? String { + if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { + _ = self.openURL(url) } - self.completeRequest() - } catch { - self.completeRequest() } + self.completeRequest() } private func handleUrl(item: NSItemProvider) async { - do { - if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL { - if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), - let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { - _ = self.openURL(url) - } + if let data = try? await item.loadItem(forTypeIdentifier: "public.url") as? URL { + if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") { + _ = self.openURL(url) } - self.completeRequest() - } catch { - self.completeRequest() } + self.completeRequest() } private func handleImages(items: [NSItemProvider]) async { @@ -105,6 +98,25 @@ class ShareViewController: UIViewController { self.completeRequest() } + private func handleVideos(items: [NSItemProvider]) async { + let firstItem = items.first + + if let dataUri = try? await firstItem?.loadItem(forTypeIdentifier: "public.video") as? URL { + let ext = String(dataUri.lastPathComponent.split(separator: ".").last ?? "mp4") + if let tempUrl = getTempUrl(ext: ext) { + let data = try? Data(contentsOf: dataUri) + try? data?.write(to: tempUrl) + + if let encoded = dataUri.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(encoded)") { + _ = self.openURL(url) + } + } + } + + self.completeRequest() + } + private func saveImageWithInfo(_ image: UIImage?) -> String? { guard let image = image else { return nil @@ -114,27 +126,26 @@ class ShareViewController: UIViewController { // Saving this file to the bundle group's directory lets us access it from // inside of the app. Otherwise, we wouldn't have access even though the // extension does. - if let dir = FileManager() - .containerURL( - forSecurityApplicationGroupIdentifier: "group.app.bsky") { - let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg" - - if let newUri = URL(string: filePath), - let jpegData = image.jpegData(compressionQuality: 1) { - try jpegData.write(to: newUri) - return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)" - } + if let tempUrl = getTempUrl(ext: "jpeg"), + let jpegData = image.jpegData(compressionQuality: 1) { + try jpegData.write(to: tempUrl) + return "\(tempUrl.absoluteString)|\(image.size.width)|\(image.size.height)" } - return nil - } catch { - return nil - } + } catch {} + return nil } private func completeRequest() { self.extensionContext?.completeRequest(returningItems: nil) } + private func getTempUrl(ext: String) -> URL? { + if let dir = FileManager().containerURL(forSecurityApplicationGroupIdentifier: "group.app.bsky") { + return URL(string: "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).\(ext)")! + } + return nil + } + @objc func openURL(_ url: URL) -> Bool { var responder: UIResponder? = self while responder != nil { diff --git a/package.json b/package.json index 245c095f19..4b3486545e 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,11 @@ "export": "npx expo export", "make-deploy-bundle": "bash scripts/bundleUpdate.sh", "generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web", - "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" + "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web", + "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { "@atproto/api": "^0.13.7", - "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", @@ -160,24 +160,20 @@ "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", - "mobx": "^6.6.1", - "mobx-react-lite": "^3.4.0", - "mobx-utils": "^6.0.6", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", - "react-avatar-editor": "^13.0.0", "react-compiler-runtime": "file:./lib/react-compiler-runtime", "react-dom": "^18.2.0", + "react-image-crop": "^11.0.7", "react-keyed-flatten-children": "^3.0.0", "react-native": "0.74.1", "react-native-compressor": "^1.8.24", "react-native-date-picker": "^4.4.2", "react-native-drawer-layout": "^4.0.0-alpha.3", - "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.16.2", "react-native-get-random-values": "~1.11.0", "react-native-image-crop-picker": "0.41.2", @@ -238,7 +234,6 @@ "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", "@types/psl": "^1.1.1", - "@types/react-avatar-editor": "^13.0.0", "@types/react-dom": "^18.2.18", "@types/react-responsive": "^8.0.5", "@types/react-test-renderer": "^17.0.1", @@ -272,6 +267,7 @@ "react-refresh": "^0.14.0", "react-scripts": "^5.0.1", "react-test-renderer": "18.2.0", + "svgo": "^3.3.2", "ts-node": "^10.9.1", "typescript": "^5.5.4", "url-loader": "^4.1.1", @@ -343,6 +339,9 @@ ], "*{.js,.jsx,.ts,.tsx,.css}": [ "prettier --cache --write --ignore-unknown" + ], + "assets/icons/*.svg": [ + "svgo" ] } } diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx index 73ecf6616b..ca75b01390 100644 --- a/src/components/dialogs/Embed.tsx +++ b/src/components/dialogs/Embed.tsx @@ -106,16 +106,18 @@ function EmbedDialogInner({ - - - - + + + + + + + + + ) +} + +const getInitialCrop = ( + source: ImageSource, + manips: ImageTransformation | undefined, +): PercentCrop | undefined => { + const initialArea = manips?.crop + + if (initialArea) { + return { + unit: '%', + x: (initialArea.originX / source.width) * 100, + y: (initialArea.originY / source.height) * 100, + width: (initialArea.width / source.width) * 100, + height: (initialArea.height / source.height) * 100, + } + } +} diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 422a4dd937..369f08d745 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,29 +1,38 @@ -import React, {useState} from 'react' -import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import React from 'react' +import { + ImageStyle, + Keyboard, + LayoutChangeEvent, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' import {Image} from 'expo-image' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {observer} from 'mobx-react-lite' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {Dimensions} from '#/lib/media/types' import {colors, s} from '#/lib/styles' import {isNative} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {EditImageDialog} from './EditImageDialog' +import {ImageAltTextDialog} from './ImageAltTextDialog' const IMAGE_GAP = 8 interface GalleryProps { - gallery: GalleryModel + images: ComposerImage[] + onChange: (next: ComposerImage[]) => void } -export const Gallery = (props: GalleryProps) => { - const [containerInfo, setContainerInfo] = useState() +export let Gallery = (props: GalleryProps): React.ReactNode => { + const [containerInfo, setContainerInfo] = React.useState() const onLayout = (evt: LayoutChangeEvent) => { const {width, height} = evt.nativeEvent.layout @@ -41,177 +50,200 @@ export const Gallery = (props: GalleryProps) => { ) } +Gallery = React.memo(Gallery) interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = observer(function GalleryImpl({ - gallery, - containerInfo, -}: GalleryInnerProps) { - const {_} = useLingui() +const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - const t = useTheme() - let side: number + const {altTextControlStyle, imageControlsStyle, imageStyle} = + React.useMemo(() => { + const side = + images.length === 1 + ? 250 + : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / + images.length - if (gallery.size === 1) { - side = 250 - } else { - side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size - } + const isOverflow = isMobile && images.length > 2 - const imageStyle = { - height: side, - width: side, - } - - const isOverflow = isMobile && gallery.size > 2 - - const altTextControlStyle = isOverflow - ? { - left: 4, - bottom: 4, - } - : !isMobile && gallery.size < 3 - ? { - left: 8, - top: 8, - } - : { - left: 4, - top: 4, + return { + altTextControlStyle: isOverflow + ? {left: 4, bottom: 4} + : !isMobile && images.length < 3 + ? {left: 8, top: 8} + : {left: 4, top: 4}, + imageControlsStyle: { + display: 'flex' as const, + flexDirection: 'row' as const, + position: 'absolute' as const, + ...(isOverflow + ? {top: 4, right: 4, gap: 4} + : !isMobile && images.length < 3 + ? {top: 8, right: 8, gap: 8} + : {top: 4, right: 4, gap: 4}), + zIndex: 1, + }, + imageStyle: { + height: side, + width: side, + }, } + }, [images.length, containerInfo, isMobile]) - const imageControlsStyle = { - display: 'flex' as const, - flexDirection: 'row' as const, - position: 'absolute' as const, - ...(isOverflow - ? { - top: 4, - right: 4, - gap: 4, - } - : !isMobile && gallery.size < 3 - ? { - top: 8, - right: 8, - gap: 8, - } - : { - top: 4, - right: 4, - gap: 4, - }), - zIndex: 1, - } - - return !gallery.isEmpty ? ( + return images.length !== 0 ? ( <> - {gallery.images.map(image => ( - - { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) + {images.map((image, index) => { + return ( + { + onChange( + images.map(i => (i.source === image.source ? next : i)), + ) }} - style={[styles.altTextControl, altTextControlStyle]}> - {image.altText.length > 0 ? ( - - ) : ( - - )} - - ALT - - - - { - if (isNative) { - gallery.crop(image) - } else { - openModal({ - name: 'edit-image', - image, - gallery, - }) - } - }} - style={styles.imageControl}> - - - gallery.remove(image)} - style={styles.imageControl}> - - - - { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) - }} - style={styles.altTextHiddenRegion} - /> + onRemove={() => { + const next = images.slice() + next.splice(index, 1) - - - ))} + ) + })} ) : null -}) +} + +type GalleryItemProps = { + image: ComposerImage + altTextControlStyle?: ViewStyle + imageControlsStyle?: ViewStyle + imageStyle?: ViewStyle + onChange: (next: ComposerImage) => void + onRemove: () => void +} + +const GalleryItem = ({ + image, + altTextControlStyle, + imageControlsStyle, + imageStyle, + onChange, + onRemove, +}: GalleryItemProps): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + + const altTextControl = Dialog.useDialogControl() + const editControl = Dialog.useDialogControl() + + const onImageEdit = () => { + if (isNative) { + cropImage(image).then(next => { + onChange(next) + }) + } else { + editControl.open() + } + } + + const onAltTextEdit = () => { + Keyboard.dismiss() + altTextControl.open() + } + + return ( + + + {image.alt.length !== 0 ? ( + + ) : ( + + )} + + ALT + + + + + + + + + + + + + + + + + + + ) +} export function AltTextReminder() { const t = useTheme() diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx new file mode 100644 index 0000000000..123e1066a5 --- /dev/null +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import {ImageStyle, useWindowDimensions, View} from 'react-native' +import {Image} from 'expo-image' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {MAX_ALT_TEXT} from '#/lib/constants' +import {isWeb} from '#/platform/detection' +import {ComposerImage} from '#/state/gallery' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Text} from '#/components/Typography' + +type Props = { + control: Dialog.DialogOuterProps['control'] + image: ComposerImage + onChange: (next: ComposerImage) => void +} + +export const ImageAltTextDialog = (props: Props): React.ReactNode => { + return ( + + + + + + ) +} + +const ImageAltTextInner = ({ + control, + image, + onChange, +}: Props): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + + const windim = useWindowDimensions() + + const [altText, setAltText] = React.useState(image.alt) + + const onPressSubmit = React.useCallback(() => { + control.close() + onChange({...image, alt: altText.trim()}) + }, [control, image, altText, onChange]) + + const imageStyle = React.useMemo(() => { + const maxWidth = isWeb ? 450 : windim.width + const source = image.transformed ?? image.source + + if (source.height > source.width) { + return { + resizeMode: 'contain', + width: '100%', + aspectRatio: 1, + borderRadius: 8, + } + } + return { + width: '100%', + height: (maxWidth / source.width) * source.height, + borderRadius: 8, + } + }, [image, windim]) + + return ( + + + + + + Add alt text + + + + + + + + + + + Descriptive alt text + + + setAltText(text)} + value={altText} + multiline + numberOfLines={3} + autoFocus + /> + + + + + + ) +} diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index f1f984103e..2183ca7902 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' import {isMobileWeb, isNative} from '#/platform/detection' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' type Props = { - gallery: GalleryModel disabled?: boolean + onAdd: (next: ComposerImage[]) => void } -export function OpenCameraBtn({gallery, disabled}: Props) { +export function OpenCameraBtn({disabled, onAdd}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() @@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) { if (mediaPermissionRes) { await MediaLibrary.createAssetAsync(img.path) } - gallery.add(img) + + const res = await createComposerImage(img) + + onAdd([res]) } catch (err: any) { // ignore logger.warn('Error using camera', {error: err}) } }, [ - gallery, + onAdd, track, requestCameraAccessIfNeeded, mediaPermissionRes, diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 747653fc8d..95d2df022c 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' +import {openPicker} from '#/lib/media/picker' import {isNative} from '#/platform/detection' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' type Props = { - gallery: GalleryModel + size: number disabled?: boolean + onAdd: (next: ComposerImage[]) => void } -export function SelectPhotoBtn({gallery, disabled}: Props) { +export function SelectPhotoBtn({size, disabled, onAdd}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) { return } - gallery.pick() - }, [track, requestPhotoAccessIfNeeded, gallery]) + const images = await openPicker({ + selectionLimit: 4 - size, + allowsMultipleSelection: true, + }) + + const results = await Promise.all( + images.map(img => createComposerImage(img)), + ) + + onAdd(results) + }, [track, requestPhotoAccessIfNeeded, size, onAdd]) return ( - ) - })} - - {!isMobile ? ( - - Transformations - - ) : null} - - {adjustments.map(({label, icon, onPress}) => ( - - ))} - - - - - - Accessibility - - setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel={_(msg`Alt text`)} - accessibilityHint="" - accessibilityLabelledBy="alt-text" - /> - - - - - Cancel - - - - - - Done - - - - - - ) -}) - -const styles = StyleSheet.create({ - container: { - gap: 18, - height: '100%', - width: '100%', - }, - subsection: {marginTop: 12}, - gap18: {gap: 18}, - title: { - fontWeight: '600', - fontSize: 24, - }, - btns: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - btn: { - borderRadius: 4, - paddingVertical: 8, - paddingHorizontal: 24, - }, - imgEditor: { - maxWidth: '100%', - }, - imgContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderStyle: 'solid', - marginBottom: 4, - }, - flipVertical: { - transform: [{rotate: '90deg'}], - }, - flipBtn: { - paddingHorizontal: 4, - paddingVertical: 8, - }, - textArea: { - borderWidth: 1, - borderRadius: 6, - paddingTop: 10, - paddingHorizontal: 12, - fontSize: 16, - height: 100, - textAlignVertical: 'top', - }, - bottomSection: { - borderTopWidth: 1, - paddingTop: 18, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 3455e1cdf8..90e93821c5 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -3,13 +3,11 @@ import {StyleSheet} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import BottomSheet from '@discord/bottom-sheet/src' +import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' -import {usePalette} from 'lib/hooks/usePalette' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import * as AddAppPassword from './AddAppPasswords' -import * as AltImageModal from './AltImage' -import * as EditImageModal from './AltImage' import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' @@ -75,12 +73,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'self-label') { snapPoints = SelfLabelModal.snapPoints element = - } else if (activeModal?.name === 'alt-text-image') { - snapPoints = AltImageModal.snapPoints - element = - } else if (activeModal?.name === 'edit-image') { - snapPoints = AltImageModal.snapPoints - element = } else if (activeModal?.name === 'change-handle') { snapPoints = ChangeHandleModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index c4bab6fb18..a2acc23bb9 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -2,20 +2,18 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {usePalette} from '#/lib/hooks/usePalette' import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import type {Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import * as AddAppPassword from './AddAppPasswords' -import * as AltTextImageModal from './AltImage' import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' -import * as CropImageModal from './crop-image/CropImage.web' +import * as CropImageModal from './CropImage.web' import * as DeleteAccountModal from './DeleteAccount' -import * as EditImageModal from './EditImage' import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' @@ -54,11 +52,7 @@ function Modal({modal}: {modal: ModalIface}) { } const onPressMask = () => { - if ( - modal.name === 'crop-image' || - modal.name === 'edit-image' || - modal.name === 'alt-text-image' - ) { + if (modal.name === 'crop-image') { return // dont close on mask presses during crop } closeModal() @@ -93,10 +87,6 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'post-languages-settings') { element = - } else if (modal.name === 'alt-text-image') { - element = - } else if (modal.name === 'edit-image') { - element = } else if (modal.name === 'verify-email') { element = } else if (modal.name === 'change-email') { diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx deleted file mode 100644 index 10cae2f174..0000000000 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import ImageEditor from 'react-avatar-editor' - -import {useModalControls} from '#/state/modals' -import {usePalette} from 'lib/hooks/usePalette' -import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons' -import {Dimensions} from 'lib/media/types' -import {getDataUriSize} from 'lib/media/util' -import {gradients, s} from 'lib/styles' -import {Text} from 'view/com/util/text/Text' -import {calculateDimensions} from './cropImageUtil' - -enum AspectRatio { - Square = 'square', - Wide = 'wide', - Tall = 'tall', - Custom = 'custom', -} - -const DIMS: Record = { - [AspectRatio.Square]: {width: 1000, height: 1000}, - [AspectRatio.Wide]: {width: 1000, height: 750}, - [AspectRatio.Tall]: {width: 750, height: 1000}, -} - -export const snapPoints = ['0%'] - -export function Component({ - uri, - dimensions, - onSelect, -}: { - uri: string - dimensions?: Dimensions - onSelect: (img?: RNImage) => void -}) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {_} = useLingui() - const defaultAspectStyle = dimensions - ? AspectRatio.Custom - : AspectRatio.Square - const [as, setAs] = React.useState(defaultAspectStyle) - const [scale, setScale] = React.useState(1) - const editorRef = React.useRef(null) - const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width - const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height - - const doSetAs = (v: AspectRatio) => () => setAs(v) - - const onPressCancel = () => { - onSelect(undefined) - closeModal() - } - const onPressDone = () => { - const canvas = editorRef.current?.getImageScaledToCanvas() - if (canvas) { - const dataUri = canvas.toDataURL('image/jpeg') - onSelect({ - path: dataUri, - mime: 'image/jpeg', - size: getDataUriSize(dataUri), - width: imageEditorWidth, - height: imageEditorHeight, - }) - } else { - onSelect(undefined) - } - closeModal() - } - - let cropperStyle - if (as === AspectRatio.Square) { - cropperStyle = styles.cropperSquare - } else if (as === AspectRatio.Wide) { - cropperStyle = styles.cropperWide - } else if (as === AspectRatio.Tall) { - cropperStyle = styles.cropperTall - } else if (as === AspectRatio.Custom) { - const cropperDimensions = calculateDimensions( - 550, - imageEditorHeight, - imageEditorWidth, - ) - cropperStyle = { - width: cropperDimensions.width, - height: cropperDimensions.height, - } - } - - return ( - - - - - - - setScale(Array.isArray(v) ? v[0] : v) - } - minimumValue={1} - maximumValue={3} - containerStyle={styles.slider} - /> - {as === AspectRatio.Custom ? null : ( - <> - - - - - - - - - - - )} - - - - - Cancel - - - - - - - Done - - - - - - ) -} - -const styles = StyleSheet.create({ - cropper: { - marginLeft: 'auto', - marginRight: 'auto', - borderWidth: 1, - borderRadius: 4, - overflow: 'hidden', - }, - cropperSquare: { - width: 400, - height: 400, - }, - cropperWide: { - width: 400, - height: 300, - }, - cropperTall: { - width: 300, - height: 400, - }, - imageEditor: { - maxWidth: '100%', - }, - ctrls: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 10, - }, - slider: { - flex: 1, - marginRight: 10, - }, - btns: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 10, - }, - btn: { - borderRadius: 4, - paddingVertical: 8, - paddingHorizontal: 24, - }, -}) diff --git a/src/view/com/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts deleted file mode 100644 index 303d15ba5b..0000000000 --- a/src/view/com/modals/crop-image/cropImageUtil.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const calculateDimensions = ( - maxWidth: number, - originalHeight: number, - originalWidth: number, -) => { - const aspectRatio = originalWidth / originalHeight - const newHeight = maxWidth / aspectRatio - const newWidth = maxWidth - return { - width: newWidth, - height: newHeight, - } -} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index b2f56c1385..76d9d1503e 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -8,17 +8,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {logger} from '#/logger' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, -} from 'lib/hooks/usePermissions' -import {makeProfileLink} from 'lib/routes/links' -import {colors} from 'lib/styles' -import {isAndroid, isNative, isWeb} from 'platform/detection' -import {precacheProfile} from 'state/queries/profile' -import {HighPriorityImage} from 'view/com/util/images/Image' +} from '#/lib/hooks/usePermissions' +import {makeProfileLink} from '#/lib/routes/links' +import {colors} from '#/lib/styles' +import {logger} from '#/logger' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {precacheProfile} from '#/state/queries/profile' +import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, @@ -321,6 +321,8 @@ let EditableUserAvatar = ({ height: 1000, width: 1000, path: item.path, + webAspectRatio: 1, + webCircularCrop: true, }) onSelectNewAvatar(croppedImage) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 93ea32750d..13f4081fce 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -6,16 +6,16 @@ import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logger} from '#/logger' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, -} from 'lib/hooks/usePermissions' -import {colors} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {isAndroid, isNative} from 'platform/detection' -import {EventStopper} from 'view/com/util/EventStopper' +} from '#/lib/hooks/usePermissions' +import {colors} from '#/lib/styles' +import {useTheme} from '#/lib/ThemeContext' +import {logger} from '#/logger' +import {isAndroid, isNative} from '#/platform/detection' +import {EventStopper} from '#/view/com/util/EventStopper' import {tokens, useTheme as useAlfTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, @@ -72,6 +72,7 @@ export function UserBanner({ path: items[0].path, width: 3000, height: 1000, + webAspectRatio: 3, }), ) } catch (e: any) { diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index ee8e1f47a2..5d8aac81af 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -4,11 +4,13 @@ import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {LinearGradient} from 'expo-linear-gradient' +import {useHaptics} from '#/lib/haptics' import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {clamp} from '#/lib/numbers' import {gradients} from '#/lib/styles' import {isWeb} from '#/platform/detection' +import {useHapticsDisabled} from '#/state/preferences' import {useInteractionState} from '#/components/hooks/useInteractionState' export interface FABProps @@ -17,9 +19,11 @@ export interface FABProps icon: JSX.Element } -export function FABInner({testID, icon, ...props}: FABProps) { +export function FABInner({testID, icon, onPress, ...props}: FABProps) { const insets = useSafeAreaInsets() const {isMobile, isTablet} = useWebMediaQueries() + const playHaptic = useHaptics() + const isHapticsDisabled = useHapticsDisabled() const fabMinimalShellTransform = useMinimalShellFabTransform() const { state: pressed, @@ -42,6 +46,15 @@ export function FABInner({testID, icon, ...props}: FABProps) { testID={testID} onPressIn={onPressIn} onPressOut={onPressOut} + onPress={e => { + playHaptic('Light') + setTimeout( + () => { + onPress?.(e) + }, + isHapticsDisabled ? 0 : 75, + ) + }} {...props}> (null) - const ref = useRef(null) + const videoRef = useRef(null) const [focused, setFocused] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const figId = useId() @@ -31,13 +31,13 @@ export function VideoEmbedInnerWeb({ } const hlsRef = useRef(undefined) + const [lowQualityFragments, setLowQualityFragments] = useState([]) useEffect(() => { - if (!ref.current) return + if (!videoRef.current) return if (!Hls.isSupported()) throw new HLSUnsupportedError() const hls = new Hls({ - capLevelToPlayerSize: true, maxMaxBufferLength: 10, // only load 10s ahead // note: the amount buffered is affected by both maxBufferLength and maxBufferSize // it will buffer until it it's greater than *both* of those values @@ -45,18 +45,36 @@ export function VideoEmbedInnerWeb({ }) hlsRef.current = hls - hls.attachMedia(ref.current) + hls.attachMedia(videoRef.current) hls.loadSource(embed.playlist) // initial value, later on it's managed by Controls hls.autoLevelCapping = 0 + // manually loop, so if we've flushed the first buffer it doesn't get confused + const abortController = new AbortController() + const {signal} = abortController + videoRef.current.addEventListener( + 'ended', + function () { + this.currentTime = 0 + this.play() + }, + {signal}, + ) + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { if (data.subtitleTracks.length > 0) { setHasSubtitleTrack(true) } }) + hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { + if (frag.level === 0) { + setLowQualityFragments(prev => [...prev, frag]) + } + }) + hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { if ( @@ -67,6 +85,8 @@ export function VideoEmbedInnerWeb({ } else { setError(data.error) } + } else { + console.error(data.error) } }) @@ -74,20 +94,61 @@ export function VideoEmbedInnerWeb({ hlsRef.current = undefined hls.detachMedia() hls.destroy() + abortController.abort() } }, [embed.playlist]) + // purge low quality segments from buffer on next frag change + useEffect(() => { + if (!hlsRef.current) return + + const current = hlsRef.current + + if (focused) { + function fragChanged( + _event: Events.FRAG_CHANGED, + {frag}: FragChangedData, + ) { + // if the current quality level goes above 0, flush the low quality segments + if (current.nextAutoLevel > 0) { + const flushed: Fragment[] = [] + + for (const lowQualFrag of lowQualityFragments) { + // avoid if close to the current fragment + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { + return + } + + current.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + + flushed.push(lowQualFrag) + } + + setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) + } + } + current.on(Hls.Events.FRAG_CHANGED, fragChanged) + + return () => { + current.off(Hls.Events.FRAG_CHANGED, fragChanged) + } + } + }, [focused, lowQualityFragments]) + return (
void @@ -107,7 +108,7 @@ export function ProfileScreen({route}: Props) { // Most pushes will happen here, since we will have only placeholder data if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) { return ( - + ) diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 8c888f04d7..fc414d31f3 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -32,6 +32,27 @@ export function Forms() { label="Text field" /> + + + + + + + + + + - + - @@ -93,16 +92,17 @@ function StorybookInner() { + - +