diff --git a/__tests__/lib/__mocks__/exampleComHtml.ts b/__tests__/lib/__mocks__/exampleComHtml.ts deleted file mode 100644 index 6633e40ca5..0000000000 --- a/__tests__/lib/__mocks__/exampleComHtml.ts +++ /dev/null @@ -1,47 +0,0 @@ -export const exampleComHtml = ` - - - Example Domain - - - - - - - - - -
-

Example Domain

-

This domain is for use in illustrative examples in documents. You may use this - domain in literature without prior coordination or asking for permission.

-

More information...

-
- -` diff --git a/__tests__/lib/__mocks__/tiktokHtml.ts b/__tests__/lib/__mocks__/tiktokHtml.ts deleted file mode 100644 index fa3d112836..0000000000 --- a/__tests__/lib/__mocks__/tiktokHtml.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const tiktokHtml = ` -Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok
Upload

For You

Log in to follow creators, like videos, and view comments.

Suggested accounts

© 2023 TikTok
Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp
00:00/00:00
Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp
_powervision_
Power Vision Tests · 2019-10-19

Related videos

Get TikTok App
-` diff --git a/__tests__/lib/__mocks__/youtubeChannelHtml.ts b/__tests__/lib/__mocks__/youtubeChannelHtml.ts deleted file mode 100644 index cc71995c45..0000000000 --- a/__tests__/lib/__mocks__/youtubeChannelHtml.ts +++ /dev/null @@ -1,63 +0,0 @@ -export const youtubeChannelHtml = ` - -
AboutPressCopyrightContact usCreatorsAdvertiseDevelopersTermsPrivacyPolicy & SafetyHow YouTube worksTest new features
penguinz0 - YouTube
- -` diff --git a/__tests__/lib/__mocks__/youtubeHtml.ts b/__tests__/lib/__mocks__/youtubeHtml.ts deleted file mode 100644 index 7fd9f819da..0000000000 --- a/__tests__/lib/__mocks__/youtubeHtml.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const youtubeHTML = ` - -YouTube
- -` diff --git a/__tests__/lib/extractHtmlMeta.test.ts b/__tests__/lib/extractHtmlMeta.test.ts deleted file mode 100644 index cdd2a33848..0000000000 --- a/__tests__/lib/extractHtmlMeta.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import {extractHtmlMeta} from '../../src/lib/link-meta/html' -import {exampleComHtml} from './__mocks__/exampleComHtml' -import {youtubeHTML} from './__mocks__/youtubeHtml' -import {tiktokHtml} from './__mocks__/tiktokHtml' -import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml' - -describe('extractHtmlMeta', () => { - const cases = [ - ['', {}], - ['nothing', {}], - ['title', {title: 'title'}], - [' aSd!@#AC ', {title: 'aSd!@#AC'}], - ['\n title\n ', {title: 'title'}], - ['', {title: 'meta title'}], - [ - '', - {description: 'meta description'}, - ], - ['', {title: 'og title'}], - [ - '', - {description: 'og description'}, - ], - [ - '', - {image: 'https://ogimage.com/foo.png'}, - ], - [ - '', - {title: 'twitter title'}, - ], - [ - '', - {description: 'twitter description'}, - ], - [ - '', - {image: 'https://twitterimage.com/foo.png'}, - ], - ['', {title: 'meta title'}], - ] - - it.each(cases)( - 'given the html tag %p, returns %p', - // @ts-ignore not worth fixing -prf - (input, expectedResult) => { - const output = extractHtmlMeta({html: input as string, hostname: ''}) - expect(output).toEqual(expectedResult) - }, - ) - - it('extracts title and description from a generic HTML page', () => { - const input = exampleComHtml - const expectedOutput = { - title: 'Example Domain', - description: 'An example website', - } - const output = extractHtmlMeta({html: input, hostname: 'example.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts title and description from a Tiktok HTML page', () => { - const input = tiktokHtml - const expectedOutput = { - title: - 'Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok', - description: - '5.5M Likes, 20.8K Comments. TikTok video from Power Vision Tests (@_powervision_): "Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp". оригинальный звук - Power Vision Tests.', - } - const output = extractHtmlMeta({html: input, hostname: 'tiktok.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts title and description from a generic youtube page', () => { - const input = youtubeHTML - const expectedOutput = { - title: 'HD Video (1080p) with Relaxing Music of Native American Shamans', - description: - 'Stunning HD Video ( 1080p ) of Patagonian Nature with Relaxing Native American Shamanic Music. HD footage used from ', - image: 'https://i.ytimg.com/vi/x6UITRjhijI/sddefault.jpg', - } - const output = extractHtmlMeta({html: input, hostname: 'youtube.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts avatar from a youtube channel', () => { - const input = youtubeChannelHtml - const expectedOutput = { - title: 'penguinz0', - description: - 'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don't have any other public accounts.', - image: - 'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj', - } - const output = extractHtmlMeta({html: input, hostname: 'youtube.com'}) - expect(output).toEqual(expectedOutput) - }) - - it('extracts username from the url a twitter profile page', () => { - const expectedOutput = { - title: '@bluesky on Twitter', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/bluesky', - }) - expect(output).toEqual(expectedOutput) - }) - - it('extracts username from the url a tweet', () => { - const expectedOutput = { - title: 'Tweet by @bluesky', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/bluesky/status/1582437529969917953', - }) - expect(output).toEqual(expectedOutput) - }) - - it("does not extract username from the url when it's not a tweet or profile page", () => { - const expectedOutput = { - title: 'Twitter', - } - const output = extractHtmlMeta({ - html: '', - hostname: 'twitter.com', - pathname: '/i/articles/follows/-1675653703?time_window=24', - }) - expect(output).toEqual(expectedOutput) - }) -}) 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,11 +86,11 @@ describe('downloadAndResize', () => { expect(result).toBeUndefined() }) - it('should return undefined for unsupported file type', async () => { + it('should return undefined for non-200 response', async () => { const mockedFetch = RNFetchBlob.fetch as jest.Mock mockedFetch.mockResolvedValueOnce({ path: jest.fn().mockReturnValue('file://downloaded-image'), - info: jest.fn().mockReturnValue({status: 200}), + info: jest.fn().mockReturnValue({status: 400}), flush: jest.fn(), }) @@ -100,47 +104,47 @@ describe('downloadAndResize', () => { } 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'}, - ) + expect(errorSpy).not.toHaveBeenCalled() + expect(result).toBeUndefined() }) - it('should return undefined for non-200 response', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image'), - info: jest.fn().mockReturnValue({status: 400}), - flush: jest.fn(), - }) + it('should not downsize whenever dimensions are below the max dimensions', () => { + const initialDimensionsOne = { + width: 1200, + height: 1000, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) - const opts: DownloadAndResizeOpts = { - uri: 'https://example.com/image', - width: 100, - height: 100, - maxSize: 500000, - mode: 'cover', - timeout: 10000, + const initialDimensionsTwo = { + width: 1000, + height: 1200, } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) - const result = await downloadAndResize(opts) - expect(errorSpy).not.toHaveBeenCalled() - expect(result).toBeUndefined() + 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/accessibility_stroke2_corner2_rounded.svg b/assets/icons/accessibility_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..62184bd8d9 --- /dev/null +++ b/assets/icons/accessibility_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + 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/arrowBoxLeft_stroke2_corner2_rounded.svg b/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..ea9afbc60f --- /dev/null +++ b/assets/icons/arrowBoxLeft_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + 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/at_stroke2_corner2_rounded.svg b/assets/icons/at_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..37ccbda238 --- /dev/null +++ b/assets/icons/at_stroke2_corner2_rounded.svg @@ -0,0 +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/birthdayCake_stroke2_corner2_rounded.svg b/assets/icons/birthdayCake_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..542e1552f4 --- /dev/null +++ b/assets/icons/birthdayCake_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg b/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..2cc08924f8 --- /dev/null +++ b/assets/icons/bubbleInfo_stroke2_corner2_rounded.svg @@ -0,0 +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/circleQuestion_stroke2_corner2_rounded.svg b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..a534f98716 --- /dev/null +++ b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg @@ -0,0 +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/envelope_stroke2_corner2_rounded.svg b/assets/icons/envelope_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..39331f8a12 --- /dev/null +++ b/assets/icons/envelope_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + 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/eye_stroke2_corner2_rounded.svg b/assets/icons/eye_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..81e31ba032 --- /dev/null +++ b/assets/icons/eye_stroke2_corner2_rounded.svg @@ -0,0 +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/lock_stroke2_corner2_rounded.svg b/assets/icons/lock_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..8e34c3b05c --- /dev/null +++ b/assets/icons/lock_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + 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/paintRoller_stroke2_corner2_rounded.svg b/assets/icons/paintRoller_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..3ebb36aa82 --- /dev/null +++ b/assets/icons/paintRoller_stroke2_corner2_rounded.svg @@ -0,0 +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/person_stroke2_corner2_rounded.svg b/assets/icons/person_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..7088c2880c --- /dev/null +++ b/assets/icons/person_stroke2_corner2_rounded.svg @@ -0,0 +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/trash_stroke2_corner2_rounded.svg b/assets/icons/trash_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..e97dfe90c4 --- /dev/null +++ b/assets/icons/trash_stroke2_corner2_rounded.svg @@ -0,0 +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/verified_stroke2_corner2_rounded.svg b/assets/icons/verified_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..048b2816e3 --- /dev/null +++ b/assets/icons/verified_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/window_stroke2_corner2_rounded.svg b/assets/icons/window_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..859c00c4a5 --- /dev/null +++ b/assets/icons/window_stroke2_corner2_rounded.svg @@ -0,0 +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/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/package.json b/package.json index 245c095f19..5e4d896cce 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", @@ -120,6 +120,7 @@ "eventemitter3": "^5.0.1", "expo": "^51.0.8", "expo-application": "^5.9.1", + "expo-blur": "^13.0.2", "expo-build-properties": "^0.12.1", "expo-camera": "~15.0.9", "expo-clipboard": "^6.0.3", @@ -160,24 +161,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 +235,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 +268,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 +340,9 @@ ], "*{.js,.jsx,.ts,.tsx,.css}": [ "prettier --cache --write --ignore-unknown" + ], + "assets/icons/*.svg": [ + "svgo" ] } } diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch index 789ba84ace..aee3da1ecc 100644 --- a/patches/react-native+0.74.1.patch +++ b/patches/react-native+0.74.1.patch @@ -1,5 +1,18 @@ +diff --git a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm +index caa5540..c5d4e67 100644 +--- a/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm ++++ b/node_modules/react-native/Libraries/Blob/RCTFileReaderModule.mm +@@ -73,7 +73,7 @@ @implementation RCTFileReaderModule + } else { + NSString *type = [RCTConvert NSString:blob[@"type"]]; + NSString *text = [NSString stringWithFormat:@"data:%@;base64,%@", +- type != nil && [type length] > 0 ? type : @"application/octet-stream", ++ ![type isEqual:[NSNull null]] && [type length] > 0 ? type : @"application/octet-stream", + [data base64EncodedStringWithOptions:0]]; + + resolve(text); diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm -index b0d71dc..9974932 100644 +index b0d71dc..41b9a0e 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm +++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm @@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing @@ -36,7 +49,7 @@ index e9b330f..1ecdf0a 100644 + @end diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m -index b09e653..4c32b31 100644 +index b09e653..f93cb46 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m @@ -198,9 +198,53 @@ - (void)refreshControlValueChanged diff --git a/src/App.web.tsx b/src/App.web.tsx index 7d98737a3b..1664812d08 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,5 +1,5 @@ -import 'lib/sentry' // must be near top -import 'view/icons' +import '#/lib/sentry' // must be near top +import '#/view/icons' import './style.css' import React, {useEffect, useState} from 'react' diff --git a/src/alf/themes.ts b/src/alf/themes.ts index 9f7ec5c673..0cfe09aadc 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -305,6 +305,7 @@ export function createThemes({ } as const const light: Theme = { + scheme: 'light', name: 'light', palette: lightPalette, atoms: { @@ -390,6 +391,7 @@ export function createThemes({ } const dark: Theme = { + scheme: 'dark', name: 'dark', palette: darkPalette, atoms: { @@ -479,6 +481,7 @@ export function createThemes({ const dim: Theme = { ...dark, + scheme: 'dark', name: 'dim', palette: dimPalette, atoms: { diff --git a/src/alf/types.ts b/src/alf/types.ts index 41822b8dd5..08ec593927 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -156,6 +156,7 @@ export type ThemedAtoms = { } } export type Theme = { + scheme: 'light' | 'dark' // for library support name: ThemeName palette: Palette atoms: ThemedAtoms diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 6c25faffb8..c80b9f3707 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -9,6 +9,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url' import {StackActions, useLinkProps} from '@react-navigation/native' import {BSKY_DOWNLOAD_URL} from '#/lib/constants' +import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' import {AllNavigatorParams} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import { @@ -17,11 +18,10 @@ import { isExternalUrl, linkRequiresWarning, } from '#/lib/strings/url-helpers' -import {isNative} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {shouldClickOpenNewTab} from '#/platform/urls' import {useModalControls} from '#/state/modals' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf' import {Button, ButtonProps} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -244,7 +244,10 @@ export function Link({ export type InlineLinkProps = React.PropsWithChildren< BaseLinkProps & TextStyleProp & Pick > & - Pick + Pick & { + disableUnderline?: boolean + title?: TextProps['title'] + } export function InlineLinkText({ children, @@ -257,6 +260,7 @@ export function InlineLinkText({ selectable, label, shareOnLongPress, + disableUnderline, ...rest }: InlineLinkProps) { const t = useTheme() @@ -290,11 +294,12 @@ export function InlineLinkText({ {...rest} style={[ {color: t.palette.primary_500}, - (hovered || focused || pressed) && { - ...web({outline: 0}), - textDecorationLine: 'underline', - textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, - }, + (hovered || focused || pressed) && + !disableUnderline && { + ...web({outline: 0}), + textDecorationLine: 'underline', + textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, + }, flattenedStyle, ]} role="link" @@ -365,3 +370,18 @@ export function BaseLink({ ) } + +export function WebOnlyInlineLinkText({ + children, + to, + onPress, + ...props +}: InlineLinkProps) { + return isWeb ? ( + + {children} + + ) : ( + {children} + ) +} diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 7fb0545a21..00afbdcfe9 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' +import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' +import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {NavigationProp} from '#/lib/routes/types' +import {parseStarterPackUri} from '#/lib/strings/starter-pack' import {logger} from '#/logger' -import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' -import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {parseStarterPackUri} from 'lib/strings/starter-pack' -import {List, ListRef} from 'view/com/util/List' -import {Text} from 'view/com/util/text/Text' -import {atoms as a, useTheme} from '#/alf' +import {List, ListRef} from '#/view/com/util/List' +import {Text} from '#/view/com/util/text/Text' +import {atoms as a, ios, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {LinearGradientBackground} from '#/components/LinearGradientBackground' @@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef< keyExtractor={keyExtractor} refreshing={isPTRing} headerOffset={headerOffset} + progressViewOffset={ios(0)} contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} indicatorStyle={t.name === 'light' ? 'black' : 'white'} removeClippedSubviews={true} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 94ee261e38..21928d3df3 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) { placeholder, value, onChangeText, + onFocus, + onBlur, isInvalid, inputRef, style, @@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) { ref={refs} value={value} onChangeText={onChangeText} - onFocus={ctx.onFocus} - onBlur={ctx.onBlur} + onFocus={e => { + ctx.onFocus() + onFocus?.(e) + }} + onBlur={e => { + ctx.onBlur() + onBlur?.(e) + }} placeholder={placeholder || label} placeholderTextColor={t.palette.contrast_500} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} @@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) { a.px_xs, { // paddingVertical doesn't work w/multiline - esb - paddingTop: 14, - paddingBottom: 14, + paddingTop: 12, + paddingBottom: 13, lineHeight: a.text_md.fontSize * 1.1875, textAlignVertical: rest.multiline ? 'top' : undefined, minHeight: rest.multiline ? 80 : undefined, @@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) { }, // fix for autofill styles covering border web({ - paddingTop: 12, - paddingBottom: 12, + paddingTop: 10, + paddingBottom: 11, marginTop: 2, marginBottom: 2, }), android({ - paddingBottom: 16, + paddingTop: 8, + paddingBottom: 8, }), style, ]} diff --git a/src/components/icons/Accessibility.tsx b/src/components/icons/Accessibility.tsx new file mode 100644 index 0000000000..1e5ec0c090 --- /dev/null +++ b/src/components/icons/Accessibility.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Accessibility_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm-2.86.26.014.002c.944.125 1.893.238 2.846.238.95 0 1.904-.113 2.846-.238l.014-.002h.003a1 1 0 0 1 .273 1.98l-.006.002-.017.002c-.67.089-1.341.162-2.014.21.195 1.32.65 2.33 1.626 3.357a1 1 0 0 1-1.45 1.378 8.3 8.3 0 0 1-1.234-1.647 8.2 8.2 0 0 1-1.342 1.673 1 1 0 0 1-1.398-1.43c.673-.658 1.088-1.274 1.342-1.922.163-.42.269-.878.32-1.404a33 33 0 0 1-2.075-.215l-.017-.002-.006-.001a1 1 0 0 1 .271-1.982l.004.001Z', +}) diff --git a/src/components/icons/ArrowBoxLeft.tsx b/src/components/icons/ArrowBoxLeft.tsx index 011bf6afa3..82e0d6e7f6 100644 --- a/src/components/icons/ArrowBoxLeft.tsx +++ b/src/components/icons/ArrowBoxLeft.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', }) + +export const ArrowBoxLeft_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h5.25a1 1 0 1 1 0 2H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h5.25a1 1 0 1 1 0 2H6Zm8.793 1.793a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/At.tsx b/src/components/icons/At.tsx index 2487250545..ef0d1003f1 100644 --- a/src/components/icons/At.tsx +++ b/src/components/icons/At.tsx @@ -1,5 +1,9 @@ import {createSinglePathSVG} from './TEMPLATE' export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z', + path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.543-.819 2.784-2.529-.784-3.03-1.98-3.198Z', +}) + +export const At_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.544-.819 2.784-2.529-.784-3.03-1.98-3.198Z', }) diff --git a/src/components/icons/BirthdayCake.tsx b/src/components/icons/BirthdayCake.tsx new file mode 100644 index 0000000000..8e41cbac11 --- /dev/null +++ b/src/components/icons/BirthdayCake.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const BirthdayCake_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'm12 .757 2.122 2.122A3 3 0 0 1 13 7.829V9h4.5a3 3 0 0 1 3 3v1.646c0 .603-.18 1.177-.5 1.658V19a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-3.696a3 3 0 0 1-.5-1.658V12a3 3 0 0 1 3-3H11V7.829a3 3 0 0 1-1.121-4.95L12 .757ZM6.5 11a1 1 0 0 0-1 1v1.646a1 1 0 0 0 .629.928l.5.2a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l.5-.2a1 1 0 0 0 .629-.928V12a1 1 0 0 0-1-1h-11ZM6 16.674V19a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2.326a3 3 0 0 1-2.114-.043l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405a3 3 0 0 1-2.228 0l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405A3 3 0 0 1 6 16.674ZM12.002 6a1 1 0 0 0 .706-1.707L12 3.586l-.707.707A1 1 0 0 0 12.002 6Z', +}) diff --git a/src/components/icons/BubbleInfo.tsx b/src/components/icons/BubbleInfo.tsx new file mode 100644 index 0000000000..2865713743 --- /dev/null +++ b/src/components/icons/BubbleInfo.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const BubbleInfo_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M6.002 5h12a1 1 0 0 1 1 1v10.036a1 1 0 0 1-1 1h-2.626a2 2 0 0 0-1.276.46l-2.098 1.738-2.065-1.731a2 2 0 0 0-1.285-.467h-2.65a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm12-2h-12a3 3 0 0 0-3 3v10.036a3 3 0 0 0 3 3h2.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h2.626a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3ZM13 11.75a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2ZM12 10a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', +}) diff --git a/src/components/icons/CircleQuestion.tsx b/src/components/icons/CircleQuestion.tsx new file mode 100644 index 0000000000..4eb369379b --- /dev/null +++ b/src/components/icons/CircleQuestion.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleQuestion_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/> { + 'worklet' + if (onPressIn) { + runOnJS(onPressIn)(e) + } + cancelAnimation(scale) + scale.value = withTiming(targetScale, {duration: 100}) + }} + onPressOut={e => { + 'worklet' + if (onPressOut) { + runOnJS(onPressOut)(e) + } + cancelAnimation(scale) + scale.value = withTiming(1, {duration: 100}) + }} + {...rest}> + + {children as React.ReactNode} + + + ) +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts index f588808fc3..234be777d3 100644 --- a/src/lib/haptics.ts +++ b/src/lib/haptics.ts @@ -1,8 +1,10 @@ import React from 'react' +import * as Device from 'expo-device' import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics' import {isIOS, isWeb} from '#/platform/detection' import {useHapticsDisabled} from '#/state/preferences/disable-haptics' +import * as Toast from '#/view/com/util/Toast' export function useHaptics() { const isHapticsDisabled = useHapticsDisabled() @@ -18,6 +20,11 @@ export function useHaptics() { ? ImpactFeedbackStyle[strength] : ImpactFeedbackStyle.Light impactAsync(style) + + // DEV ONLY - show a toast when a haptic is meant to fire on simulator + if (__DEV__ && !Device.isDevice) { + Toast.show(`Buzzz!`) + } }, [isHapticsDisabled], ) diff --git a/src/lib/link-meta/html.ts b/src/lib/link-meta/html.ts deleted file mode 100644 index 220f8431d5..0000000000 --- a/src/lib/link-meta/html.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {extractTwitterMeta} from './twitter' -import {extractYoutubeMeta} from './youtube' - -interface ExtractHtmlMetaInput { - html: string - hostname?: string - pathname?: string -} - -export const extractHtmlMeta = ({ - html, - hostname, - pathname, -}: ExtractHtmlMetaInput): Record => { - const htmlTitleRegex = /([^<]+)<\/title>/i - - let res: Record = {} - - const match = htmlTitleRegex.exec(html) - - if (match) { - res.title = match[1].trim() - } - - let metaMatch - let propMatch - const metaRe = /]+)>/gis - while ((metaMatch = metaRe.exec(html))) { - let propName - let propValue - const propRe = /(name|property|content)="([^"]+)"/gis - while ((propMatch = propRe.exec(metaMatch[1]))) { - if (propMatch[1] === 'content') { - propValue = propMatch[2] - } else { - propName = propMatch[2] - } - } - if (!propName || !propValue) { - continue - } - switch (propName?.trim()) { - case 'title': - case 'og:title': - case 'twitter:title': - res.title = propValue?.trim() - break - case 'description': - case 'og:description': - case 'twitter:description': - res.description = propValue?.trim() - break - case 'og:image': - case 'twitter:image': - res.image = propValue?.trim() - break - } - } - - const isYoutubeUrl = - hostname?.includes('youtube.') || hostname?.includes('youtu.be') - const isTwitterUrl = hostname?.includes('twitter.') - // Workaround for some websites not having a title or description in the meta tags in the initial serve - if (isYoutubeUrl) { - res = {...res, ...extractYoutubeMeta(html)} - } else if (isTwitterUrl && pathname) { - res = {...extractTwitterMeta({pathname})} - } - - return res -} diff --git a/src/lib/link-meta/twitter.ts b/src/lib/link-meta/twitter.ts deleted file mode 100644 index d785903c00..0000000000 --- a/src/lib/link-meta/twitter.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const extractTwitterMeta = ({ - pathname, -}: { - pathname: string -}): Record => { - const res = {title: 'Twitter'} - const parsedPathname = pathname.split('/') - if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) { - // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703 - return res - } - const username = parsedPathname?.[1] - const isUserProfile = parsedPathname?.length === 2 - - res.title = isUserProfile - ? `@${username} on Twitter` - : `Tweet by @${username}` - - return res -} diff --git a/src/lib/link-meta/youtube.ts b/src/lib/link-meta/youtube.ts deleted file mode 100644 index 42eed51e8f..0000000000 --- a/src/lib/link-meta/youtube.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const extractYoutubeMeta = (html: string): Record => { - const res: Record = {} - const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i - const youtubeDescriptionRegex = - /"videoDetails":.*"shortDescription":"([^"]*)"/i - const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i - const youtubeAvatarRegex = - /"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i - const youtubeTitleMatch = youtubeTitleRegex.exec(html) - const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html) - const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html) - const youtubeAvatarMatch = youtubeAvatarRegex.exec(html) - - if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) { - res.title = decodeURI(youtubeTitleMatch[1]) - } - if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) { - res.description = decodeURI(youtubeDescriptionMatch[1]).replace( - /\\n/g, - '\n', - ) - } - if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) { - res.image = youtubeThumbnailMatch[1] + 'default.jpg' - } - if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) { - res.image = youtubeAvatarMatch[1] - } - - return res -} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 3f01e98c5e..e75f13755f 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -6,18 +6,20 @@ import { copyAsync, deleteAsync, EncodingType, + getInfoAsync, makeDirectoryAsync, StorageAccessFramework, writeAsStringAsync, } from 'expo-file-system' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' import * as Sharing from 'expo-sharing' -import ImageResizer from '@bam.tech/react-native-image-resizer' import {Buffer} from 'buffer' import RNFetchBlob from 'rn-fetch-blob' +import {POST_IMG_MAX} from '#/lib/constants' import {logger} from '#/logger' -import {isAndroid, isIOS} from 'platform/detection' +import {isAndroid, isIOS} from '#/platform/detection' import {Dimensions} from './types' export async function compressIfNeeded( @@ -165,29 +167,47 @@ interface DoResizeOpts { } async function doResize(localUri: string, opts: DoResizeOpts): Promise { + // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter + // a "max size", and it would do the "best possible size" calculation for us. + // Now instead, we have to supply the final dimensions to the manipulation function instead. + // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() + // does not work for local files... + const imageRes = await manipulateAsync(localUri, [], {}) + const newDimensions = getResizedDimensions({ + width: imageRes.width, + height: imageRes.height, + }) + for (let i = 0; i < 9; i++) { - const quality = 100 - i * 10 - const resizeRes = await ImageResizer.createResizedImage( + // nearest 10th + const quality = Math.round((1 - 0.1 * i) * 10) / 10 + const resizeRes = await manipulateAsync( localUri, - opts.width, - opts.height, - 'JPEG', - quality, - undefined, - undefined, - undefined, - {mode: opts.mode}, + [{resize: newDimensions}], + { + format: SaveFormat.JPEG, + compress: quality, + }, ) - if (resizeRes.size < opts.maxSize) { + + const fileInfo = await getInfoAsync(resizeRes.uri) + if (!fileInfo.exists) { + throw new Error( + 'The image manipulation library failed to create a new image.', + ) + } + + if (fileInfo.size < opts.maxSize) { + safeDeleteAsync(imageRes.uri) return { - path: normalizePath(resizeRes.path), + path: normalizePath(resizeRes.uri), mime: 'image/jpeg', - size: resizeRes.size, + size: fileInfo.size, width: resizeRes.width, height: resizeRes.height, } } else { - safeDeleteAsync(resizeRes.path) + safeDeleteAsync(resizeRes.uri) } } throw new Error( @@ -311,3 +331,25 @@ async function withTempFile( safeDeleteAsync(tmpDirUri) } } + +export function getResizedDimensions(originalDims: { + width: number + height: number +}) { + if ( + originalDims.width <= POST_IMG_MAX.width && + originalDims.height <= POST_IMG_MAX.height + ) { + return originalDims + } + + const ratio = Math.min( + POST_IMG_MAX.width / originalDims.width, + POST_IMG_MAX.height / originalDims.height, + ) + + return { + width: Math.round(originalDims.width * ratio), + height: Math.round(originalDims.height * ratio), + } +} diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index e6b46ba774..fc6fcde45e 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,25 +1,37 @@ -import RNFS from 'react-native-fs' import { Image as RNImage, openCropper as openCropperFn, } from 'react-native-image-crop-picker' +import { + documentDirectory, + getInfoAsync, + readDirectoryAsync, +} from 'expo-file-system' import {compressIfNeeded} from './manip' import {CropperOptions} from './types' async function getFile() { - let files = await RNFS.readDir( - RNFS.LibraryDirectoryPath.split('/') - .slice(0, -5) - .concat(['Media', 'DCIM', '100APPLE']) - .join('/'), - ) - files = files.filter(file => file.path.endsWith('.JPG')) - const file = files[0] + const imagesDir = documentDirectory! + .split('/') + .slice(0, -6) + .concat(['Media', 'DCIM', '100APPLE']) + .join('/') + + let files = await readDirectoryAsync(imagesDir) + files = files.filter(file => file.endsWith('.JPG')) + const file = `${imagesDir}/${files[0]}` + + const fileInfo = await getInfoAsync(file) + + if (!fileInfo.exists) { + throw new Error('Failed to get file info') + } + return await compressIfNeeded({ - path: file.path, + path: file, mime: 'image/jpeg', - size: file.size, + size: fileInfo.size, width: 4288, height: 2848, }) diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 9146cd7787..b959ce8be9 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -28,7 +28,7 @@ export async function openPicker(opts?: ImagePickerOptions) { return false }) .map(image => ({ - mime: 'image/jpeg', + mime: image.mimeType || 'image/jpeg', height: image.height, width: image.width, path: image.uri, diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 8782e14570..a53ffc9614 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise { name: 'crop-image', uri: opts.path, dimensions: - opts.height && opts.width + opts.width && opts.height ? {width: opts.width, height: opts.height} : undefined, + aspect: opts.webAspectRatio, + circular: opts.webCircularCrop, onSelect: (img?: RNImage) => { if (img) { resolve(img) diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts index e6f442759f..ec94256ea1 100644 --- a/src/lib/media/types.ts +++ b/src/lib/media/types.ts @@ -18,4 +18,7 @@ export interface CameraOpts { cropperCircleOverlay?: boolean } -export type CropperOptions = Parameters[0] +export type CropperOptions = Parameters[0] & { + webAspectRatio?: number + webCircularCrop?: boolean +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 7966767d1b..866d87aef0 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,3 +1,5 @@ export type Gate = // Keep this alphabetic please. - 'debug_show_feedcontext' | 'suggested_feeds_interstitial' + | 'debug_show_feedcontext' + | 'post_feed_lang_window' + | 'suggested_feeds_interstitial' diff --git a/src/screens/Messages/Conversation/MessageInputEmbed.tsx b/src/screens/Messages/Conversation/MessageInputEmbed.tsx index bf28ed4fe9..2d1551019e 100644 --- a/src/screens/Messages/Conversation/MessageInputEmbed.tsx +++ b/src/screens/Messages/Conversation/MessageInputEmbed.tsx @@ -174,7 +174,6 @@ export function MessageInputEmbed({ showAvatar author={post.author} moderation={moderation} - authorHasWarning={!!post.author.labels?.length} timestamp={post.indexedAt} postHref={itemHref} style={a.flex_0} diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index e30162c3af..bcc56d7f66 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -5,7 +5,7 @@ import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {Shadow} from '#/state/cache/types' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Text} from '#/components/Typography' export function ProfileHeaderDisplayName({ @@ -16,12 +16,19 @@ export function ProfileHeaderDisplayName({ moderation: ModerationDecision }) { const t = useTheme() + const {gtMobile} = useBreakpoints() + return ( + style={[ + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.self_start, + {fontWeight: '600'}, + ]}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), diff --git a/src/screens/Profile/Header/GrowableAvatar.tsx b/src/screens/Profile/Header/GrowableAvatar.tsx new file mode 100644 index 0000000000..20ac14892d --- /dev/null +++ b/src/screens/Profile/Header/GrowableAvatar.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + SharedValue, + useAnimatedStyle, +} from 'react-native-reanimated' + +import {isIOS} from '#/platform/detection' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' + +export function GrowableAvatar({ + children, + style, +}: { + children: React.ReactNode + style?: StyleProp +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return {children} + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableAvatarInner({ + scrollY, + children, + style, +}: { + scrollY: SharedValue + children: React.ReactNode + style?: StyleProp +}) { + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [1.2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + + {children} + + ) +} diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx new file mode 100644 index 0000000000..e1bb8e00ef --- /dev/null +++ b/src/screens/Profile/Header/GrowableBanner.tsx @@ -0,0 +1,212 @@ +import React, {useEffect, useState} from 'react' +import {View} from 'react-native' +import {ActivityIndicator} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + runOnJS, + SharedValue, + useAnimatedProps, + useAnimatedReaction, + useAnimatedStyle, +} from 'react-native-reanimated' +import {BlurView} from 'expo-blur' +import {useIsFetching} from '@tanstack/react-query' + +import {isIOS} from '#/platform/detection' +import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs' +import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed' +import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens' +import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists' +import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext' +import {atoms as a} from '#/alf' + +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) + +export function GrowableBanner({ + backButton, + children, +}: { + backButton?: React.ReactNode + children: React.ReactNode +}) { + const pagerContext = usePagerHeaderContext() + + // pagerContext should only be present on iOS, but better safe than sorry + if (!pagerContext || !isIOS) { + return ( + + {backButton} + {children} + + ) + } + + const {scrollY} = pagerContext + + return ( + + {children} + + ) +} + +function GrowableBannerInner({ + scrollY, + backButton, + children, +}: { + scrollY: SharedValue + backButton?: React.ReactNode + children: React.ReactNode +}) { + const isFetching = useIsProfileFetching() + const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scale: interpolate(scrollY.value, [-150, 0], [2, 1], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + const animatedBlurViewProps = useAnimatedProps(() => { + return { + intensity: interpolate( + scrollY.value, + [-300, -65, -15], + [50, 40, 0], + Extrapolation.CLAMP, + ), + } + }) + + const animatedSpinnerStyle = useAnimatedStyle(() => { + return { + display: scrollY.value < 0 ? 'flex' : 'none', + opacity: interpolate( + scrollY.value, + [-60, -15], + [1, 0], + Extrapolation.CLAMP, + ), + transform: [ + {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])}, + {rotate: '90deg'}, + ], + } + }) + + const animatedBackButtonStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], { + extrapolateRight: Extrapolation.CLAMP, + }), + }, + ], + })) + + return ( + <> + + {children} + + + + + + + + + {backButton} + + + ) +} + +function useIsProfileFetching() { + // are any of the profile-related queries fetching? + return [ + useIsFetching({queryKey: [FEED_RQKEY_ROOT]}), + useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}), + useIsFetching({queryKey: [LIST_RQKEY_ROOT]}), + useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}), + ].some(isFetching => isFetching) +} + +function useShouldAnimateSpinner({ + isFetching, + scrollY, +}: { + isFetching: boolean + scrollY: SharedValue +}) { + const [isOverscrolled, setIsOverscrolled] = useState(false) + // HACK: it reports a scroll pos of 0 for a tick when fetching finishes + // so paper over that by keeping it true for a bit -sfn + const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) + + useAnimatedReaction( + () => scrollY.value < -5, + (value, prevValue) => { + if (value !== prevValue) { + runOnJS(setIsOverscrolled)(value) + } + }, + [scrollY], + ) + + const [isAnimating, setIsAnimating] = useState(isFetching) + + if (isFetching && !isAnimating) { + setIsAnimating(true) + } + + if (!isFetching && isAnimating && !stickyIsOverscrolled) { + setIsAnimating(false) + } + + return isAnimating +} + +// stayed true for at least `delay` ms before returning to false +function useStickyToggle(value: boolean, delay: number) { + const [prevValue, setPrevValue] = useState(value) + const [isSticking, setIsSticking] = useState(false) + + useEffect(() => { + if (isSticking) { + const timeout = setTimeout(() => setIsSticking(false), delay) + return () => clearTimeout(timeout) + } + }, [isSticking, delay]) + + if (value !== prevValue) { + setIsSticking(prevValue) // Going true -> false should stick. + setPrevValue(value) + return prevValue ? true : value + } + + return isSticking ? true : value +} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 90c2830907..f7011fd359 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -6,19 +6,21 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' +import {BACK_HITSLOP} from '#/lib/constants' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {NavigationProp} from '#/lib/routes/types' +import {isIOS} from '#/platform/detection' import {Shadow} from '#/state/cache/types' import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox' import {useSession} from '#/state/session' -import {BACK_HITSLOP} from 'lib/constants' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {isIOS} from 'platform/detection' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {UserBanner} from 'view/com/util/UserBanner' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {UserBanner} from '#/view/com/util/UserBanner' import {atoms as a, useTheme} from '#/alf' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' +import {GrowableAvatar} from './GrowableAvatar' +import {GrowableBanner} from './GrowableBanner' interface Props { profile: Shadow @@ -63,20 +65,45 @@ let ProfileHeaderShell = ({ return ( - - {isPlaceholderProfile ? ( - - ) : ( - - )} + + + {!isDesktop && !hideBackButton && ( + + + + + + )} + + }> + {isPlaceholderProfile ? ( + + ) : ( + + )} + {children} @@ -93,40 +120,29 @@ let ProfileHeaderShell = ({ )} - {!isDesktop && !hideBackButton && ( + - - + + - )} - - - - - + ) } @@ -144,6 +160,9 @@ const styles = StyleSheet.create({ borderRadius: 15, // @ts-ignore web only cursor: 'pointer', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + alignItems: 'center', + justifyContent: 'center', }, backBtn: { width: 30, @@ -152,10 +171,12 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - avi: { + aviPosition: { position: 'absolute', top: 110, left: 10, + }, + avi: { width: 94, height: 94, borderRadius: 47, diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx index c7ef34b701..cdb0667d06 100644 --- a/src/screens/Profile/Header/index.tsx +++ b/src/screens/Profile/Header/index.tsx @@ -7,18 +7,22 @@ import { RichText as RichTextAPI, } from '@atproto/api' -import {usePalette} from 'lib/hooks/usePalette' -import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {useTheme} from '#/alf' import {ProfileHeaderLabeler} from './ProfileHeaderLabeler' import {ProfileHeaderStandard} from './ProfileHeaderStandard' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { - const pal = usePalette('default') + const t = useTheme() return ( - + + style={[ + t.atoms.bg, + {borderColor: t.atoms.bg.backgroundColor}, + styles.avi, + ]}> diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index fc4eff02c8..22ac5df9a7 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -4,17 +4,18 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {usePalette} from '#/lib/hooks/usePalette' import {isNative} from '#/platform/detection' import {FeedDescriptor} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' -import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' -import {usePalette} from 'lib/hooks/usePalette' +import {Feed} from '#/view/com/posts/Feed' +import {EmptyState} from '#/view/com/util/EmptyState' +import {ListRef} from '#/view/com/util/List' +import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {Text} from '#/view/com/util/text/Text' -import {Feed} from 'view/com/posts/Feed' -import {EmptyState} from 'view/com/util/EmptyState' -import {ListRef} from 'view/com/util/List' -import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {ios} from '#/alf' import {SectionRef} from './types' interface FeedSectionProps { @@ -82,6 +83,7 @@ export const ProfileFeedSection = React.forwardRef< onScrolledDownChange={setIsScrolledDown} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} + progressViewOffset={ios(0)} renderEndOfFeed={ProfileEndOfFeed} ignoreFilterFor={ignoreFilterFor} initialNumToRender={ diff --git a/src/screens/Search/__tests__/utils.test.ts b/src/screens/Search/__tests__/utils.test.ts new file mode 100644 index 0000000000..81610cc59a --- /dev/null +++ b/src/screens/Search/__tests__/utils.test.ts @@ -0,0 +1,43 @@ +import {describe, expect, it} from '@jest/globals' + +import {parseSearchQuery} from '#/screens/Search/utils' + +describe(`parseSearchQuery`, () => { + const tests = [ + { + input: `bluesky`, + output: {query: `bluesky`, params: {}}, + }, + { + input: `bluesky from:esb.lol`, + output: {query: `bluesky`, params: {from: `esb.lol`}}, + }, + { + input: `bluesky "from:esb.lol"`, + output: {query: `bluesky "from:esb.lol"`, params: {}}, + }, + { + input: `bluesky mentions:@esb.lol`, + output: {query: `bluesky`, params: {mentions: `@esb.lol`}}, + }, + { + input: `bluesky since:2021-01-01:00:00:00`, + output: {query: `bluesky`, params: {since: `2021-01-01:00:00:00`}}, + }, + { + input: `bluesky lang:"en"`, + output: {query: `bluesky`, params: {lang: `en`}}, + }, + { + input: `bluesky "literal" lang:en "from:invalid"`, + output: {query: `bluesky "literal" "from:invalid"`, params: {lang: `en`}}, + }, + ] + + it.each(tests)( + `$input -> $output.query $output.params`, + ({input, output}) => { + expect(parseSearchQuery(input)).toEqual(output) + }, + ) +}) diff --git a/src/screens/Search/utils.ts b/src/screens/Search/utils.ts new file mode 100644 index 0000000000..dcf92c0926 --- /dev/null +++ b/src/screens/Search/utils.ts @@ -0,0 +1,43 @@ +export type Params = Record + +export function parseSearchQuery(rawQuery: string) { + let base = rawQuery + const rawLiterals = rawQuery.match(/[^:\w\d]".+?"/gi) || [] + + // remove literals from base + for (const literal of rawLiterals) { + base = base.replace(literal.trim(), '') + } + + // find remaining params in base + const rawParams = base.match(/[a-z]+:[a-z-\.@\d:"]+/gi) || [] + + for (const param of rawParams) { + base = base.replace(param, '') + } + + base = base.trim() + + const params = rawParams.reduce((params, param) => { + const [name, ...value] = param.split(/:/) + params[name] = value.join(':').replace(/"/g, '') // dates can contain additional colons + return params + }, {} as Params) + const literals = rawLiterals.map(l => String(l).trim()) + + return { + query: [base, literals.join(' ')].filter(Boolean).join(' '), + params, + } +} + +export function makeSearchQuery(query: string, params: Params) { + return [ + query, + Object.entries(params) + .map(([name, value]) => `${name}:${value}`) + .join(' '), + ] + .filter(Boolean) + .join(' ') +} diff --git a/src/state/gallery.ts b/src/state/gallery.ts new file mode 100644 index 0000000000..f4c8b712ef --- /dev/null +++ b/src/state/gallery.ts @@ -0,0 +1,299 @@ +import { + cacheDirectory, + deleteAsync, + makeDirectoryAsync, + moveAsync, +} from 'expo-file-system' +import { + Action, + ActionCrop, + manipulateAsync, + SaveFormat, +} from 'expo-image-manipulator' +import {nanoid} from 'nanoid/non-secure' + +import {POST_IMG_MAX} from '#/lib/constants' +import {getImageDim} from '#/lib/media/manip' +import {openCropper} from '#/lib/media/picker' +import {getDataUriSize} from '#/lib/media/util' +import {isIOS, isNative} from '#/platform/detection' + +export type ImageTransformation = { + crop?: ActionCrop['crop'] +} + +export type ImageMeta = { + path: string + width: number + height: number + mime: string +} + +export type ImageSource = ImageMeta & { + id: string +} + +type ComposerImageBase = { + alt: string + source: ImageSource +} +type ComposerImageWithoutTransformation = ComposerImageBase & { + transformed?: undefined + manips?: undefined +} +type ComposerImageWithTransformation = ComposerImageBase & { + transformed: ImageMeta + manips?: ImageTransformation +} + +export type ComposerImage = + | ComposerImageWithoutTransformation + | ComposerImageWithTransformation + +let _imageCacheDirectory: string + +function getImageCacheDirectory(): string | null { + if (isNative) { + return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) + } + + return null +} + +export async function createComposerImage( + raw: ImageMeta, +): Promise { + return { + alt: '', + source: { + id: nanoid(), + path: await moveIfNecessary(raw.path), + width: raw.width, + height: raw.height, + mime: raw.mime, + }, + } +} + +export type InitialImage = { + uri: string + width: number + height: number + altText?: string +} + +export function createInitialImages( + uris: InitialImage[] = [], +): ComposerImageWithoutTransformation[] { + return uris.map(({uri, width, height, altText = ''}) => { + return { + alt: altText, + source: { + id: nanoid(), + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } + }) +} + +export async function pasteImage( + uri: string, +): Promise { + const {width, height} = await getImageDim(uri) + const match = /^data:(.+?);/.exec(uri) + + return { + alt: '', + source: { + id: nanoid(), + path: uri, + width: width, + height: height, + mime: match ? match[1] : 'image/jpeg', + }, + } +} + +export async function cropImage(img: ComposerImage): Promise { + if (!isNative) { + return img + } + + // NOTE + // on ios, react-native-image-crop-picker gives really bad quality + // without specifying width and height. on android, however, the + // crop stretches incorrectly if you do specify it. these are + // both separate bugs in the library. we deal with that by + // providing width & height for ios only + // -prf + + const source = img.source + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + + // @todo: we're always passing the original image here, does image-cropper + // allows for setting initial crop dimensions? -mary + try { + const cropped = await openCropper({ + mediaType: 'photo', + path: source.path, + freeStyleCropEnabled: true, + ...(isIOS ? {width: w, height: h} : {}), + }) + + return { + alt: img.alt, + source: source, + transformed: { + path: await moveIfNecessary(cropped.path), + width: cropped.width, + height: cropped.height, + mime: cropped.mime, + }, + } + } catch (e) { + if (e instanceof Error && e.message.includes('User cancelled')) { + return img + } + + throw e + } +} + +export async function manipulateImage( + img: ComposerImage, + trans: ImageTransformation, +): Promise { + const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] + + const actions = rawActions.filter((a): a is Action => a !== undefined) + + if (actions.length === 0) { + if (img.transformed === undefined) { + return img + } + + return {alt: img.alt, source: img.source} + } + + const source = img.source + const result = await manipulateAsync(source.path, actions, { + format: SaveFormat.PNG, + }) + + return { + alt: img.alt, + source: img.source, + transformed: { + path: await moveIfNecessary(result.uri), + width: result.width, + height: result.height, + mime: 'image/png', + }, + manips: trans, + } +} + +export function resetImageManipulation( + img: ComposerImage, +): ComposerImageWithoutTransformation { + if (img.transformed !== undefined) { + return {alt: img.alt, source: img.source} + } + + return img +} + +export async function compressImage(img: ComposerImage): Promise { + const source = img.transformed || img.source + + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + const cacheDir = isNative && getImageCacheDirectory() + + for (let i = 10; i > 0; i--) { + // Float precision + const factor = i / 10 + + const res = await manipulateAsync( + source.path, + [{resize: {width: w, height: h}}], + { + compress: factor, + format: SaveFormat.JPEG, + base64: true, + }, + ) + + const base64 = res.base64 + + if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) { + return { + path: await moveIfNecessary(res.uri), + width: res.width, + height: res.height, + mime: 'image/jpeg', + } + } + + if (cacheDir) { + await deleteAsync(res.uri) + } + } + + throw new Error(`Unable to compress image`) +} + +async function moveIfNecessary(from: string) { + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir && from.startsWith(cacheDir)) { + const to = joinPath(cacheDir, nanoid(36)) + + await makeDirectoryAsync(cacheDir, {intermediates: true}) + await moveAsync({from, to}) + + return to + } + + return from +} + +/** Purge files that were created to accomodate image manipulation */ +export async function purgeTemporaryImageFiles() { + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir) { + await deleteAsync(cacheDir, {idempotent: true}) + await makeDirectoryAsync(cacheDir) + } +} + +function joinPath(a: string, b: string) { + if (a.endsWith('/')) { + if (b.startsWith('/')) { + return a.slice(0, -1) + b + } + return a + b + } else if (b.startsWith('/')) { + return a + b + } + return a + '/' + b +} + +function containImageRes( + w: number, + h: number, + {width: maxW, height: maxH}: {width: number; height: number}, +): [width: number, height: number] { + let scale = 1 + + if (w > maxW || h > maxH) { + scale = w > h ? maxW / w : maxH / h + w = Math.floor(w * scale) + h = Math.floor(h * scale) + } + + return [w, h] +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 529dc55907..5be21dfd39 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,8 +3,6 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {GalleryModel} from '#/state/models/media/gallery' -import {ImageModel} from '#/state/models/media/image' export interface EditProfileModal { name: 'edit-profile' @@ -37,24 +35,15 @@ export interface ListAddRemoveUsersModal { ) => void } -export interface EditImageModal { - name: 'edit-image' - image: ImageModel - gallery: GalleryModel -} - export interface CropImageModal { name: 'crop-image' uri: string dimensions?: {width: number; height: number} + aspect?: number + circular?: boolean onSelect: (img?: RNImage) => void } -export interface AltTextImageModal { - name: 'alt-text-image' - image: ImageModel -} - export interface DeleteAccountModal { name: 'delete-account' } @@ -137,9 +126,7 @@ export type Modal = | ListAddRemoveUsersModal // Posts - | AltTextImageModal | CropImageModal - | EditImageModal | SelfLabelModal // Bluesky access diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts deleted file mode 100644 index 828905002e..0000000000 --- a/src/state/models/media/gallery.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' - -import {getImageDim} from 'lib/media/manip' -import {openPicker} from 'lib/media/picker' -import {ImageInitOptions, ImageModel} from './image' - -interface InitialImageUri { - uri: string - width: number - height: number - altText?: string -} - -export class GalleryModel { - images: ImageModel[] = [] - - constructor(uris?: InitialImageUri[]) { - makeAutoObservable(this) - - if (uris) { - this.addFromUris(uris) - } - } - - get isEmpty() { - return this.size === 0 - } - - get size() { - return this.images.length - } - - get needsAltText() { - return this.images.some(image => image.altText.trim() === '') - } - - *add(image_: ImageInitOptions) { - if (this.size >= 4) { - return - } - - // Temporarily enforce uniqueness but can eventually also use index - if (!this.images.some(i => i.path === image_.path)) { - const image = new ImageModel(image_) - - // Initial resize - image.manipulate({}) - this.images.push(image) - } - } - - async paste(uri: string) { - if (this.size >= 4) { - return - } - - const {width, height} = await getImageDim(uri) - - const image = { - path: uri, - height, - width, - } - - runInAction(() => { - this.add(image) - }) - } - - setAltText(image: ImageModel, altText: string) { - image.setAltText(altText) - } - - crop(image: ImageModel) { - image.crop() - } - - remove(image: ImageModel) { - const index = this.images.findIndex(image_ => image_.path === image.path) - this.images.splice(index, 1) - } - - async previous(image: ImageModel) { - image.previous() - } - - async pick() { - const images = await openPicker({ - selectionLimit: 4 - this.size, - allowsMultipleSelection: true, - }) - - return await Promise.all( - images.map(image => { - this.add(image) - }), - ) - } - - async addFromUris(uris: InitialImageUri[]) { - for (const uriObj of uris) { - this.add({ - height: uriObj.height, - width: uriObj.width, - path: uriObj.uri, - altText: uriObj.altText, - }) - } - } -} diff --git a/src/state/models/media/image.e2e.ts b/src/state/models/media/image.e2e.ts deleted file mode 100644 index ccabd50475..0000000000 --- a/src/state/models/media/image.e2e.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {Image as RNImage} from 'react-native-image-crop-picker' -import {makeAutoObservable} from 'mobx' -import {POST_IMG_MAX} from 'lib/constants' -import {ActionCrop} from 'expo-image-manipulator' -import {Position} from 'react-avatar-editor' -import {Dimensions} from 'lib/media/types' - -export interface ImageManipulationAttributes { - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' - rotate?: number - scale?: number - position?: Position - flipHorizontal?: boolean - flipVertical?: boolean -} - -export class ImageModel implements Omit { - path: string - mime = 'image/jpeg' - width: number - height: number - altText = '' - cropped?: RNImage = undefined - compressed?: RNImage = undefined - - // Web manipulation - prev?: RNImage - attributes: ImageManipulationAttributes = { - aspectRatio: 'None', - scale: 1, - flipHorizontal: false, - flipVertical: false, - rotate: 0, - } - prevAttributes: ImageManipulationAttributes = {} - - constructor(image: Omit) { - makeAutoObservable(this) - - this.path = image.path - this.width = image.width - this.height = image.height - } - - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { - this.attributes.aspectRatio = aspectRatio - } - - setRotate(degrees: number) { - this.attributes.rotate = degrees - this.manipulate({}) - } - - flipVertical() { - this.attributes.flipVertical = !this.attributes.flipVertical - this.manipulate({}) - } - - flipHorizontal() { - this.attributes.flipHorizontal = !this.attributes.flipHorizontal - this.manipulate({}) - } - - get ratioMultipliers() { - return { - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - None: this.width / this.height, - } - } - - getUploadDimensions( - dimensions: Dimensions, - maxDimensions: Dimensions = POST_IMG_MAX, - as: ImageManipulationAttributes['aspectRatio'] = 'None', - ) { - const {width, height} = dimensions - const {width: maxWidth, height: maxHeight} = maxDimensions - - return width < maxWidth && height < maxHeight - ? { - width, - height, - } - : this.getResizedDimensions(as, POST_IMG_MAX.width) - } - - getResizedDimensions( - as: ImageManipulationAttributes['aspectRatio'] = 'None', - maxSide: number, - ) { - const ratioMultiplier = this.ratioMultipliers[as] - - if (ratioMultiplier === 1) { - return { - height: maxSide, - width: maxSide, - } - } - - if (ratioMultiplier < 1) { - return { - width: maxSide * ratioMultiplier, - height: maxSide, - } - } - - return { - width: maxSide, - height: maxSide / ratioMultiplier, - } - } - - setAltText(altText: string) { - this.altText = altText.trim() - } - - // Only compress prior to upload - async compress() { - // do nothing - } - - // Mobile - async crop() { - // do nothing - } - - // Web manipulation - async manipulate( - _attributes: { - crop?: ActionCrop['crop'] - } & ImageManipulationAttributes, - ) { - // do nothing - } - - resetCropped() { - this.manipulate({}) - } - - previous() { - this.cropped = this.prev - this.attributes = this.prevAttributes - } -} diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts deleted file mode 100644 index 55f6364911..0000000000 --- a/src/state/models/media/image.ts +++ /dev/null @@ -1,310 +0,0 @@ -import {Image as RNImage} from 'react-native-image-crop-picker' -import * as ImageManipulator from 'expo-image-manipulator' -import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' -import {makeAutoObservable, runInAction} from 'mobx' -import {Position} from 'react-avatar-editor' - -import {logger} from '#/logger' -import {POST_IMG_MAX} from 'lib/constants' -import {openCropper} from 'lib/media/picker' -import {Dimensions} from 'lib/media/types' -import {getDataUriSize} from 'lib/media/util' -import {isIOS} from 'platform/detection' - -export interface ImageManipulationAttributes { - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' - rotate?: number - scale?: number - position?: Position - flipHorizontal?: boolean - flipVertical?: boolean -} - -export interface ImageInitOptions { - path: string - width: number - height: number - altText?: string -} - -const MAX_IMAGE_SIZE_IN_BYTES = 976560 - -export class ImageModel implements Omit { - path: string - mime = 'image/jpeg' - width: number - height: number - altText = '' - cropped?: RNImage = undefined - compressed?: RNImage = undefined - - // Web manipulation - prev?: RNImage - attributes: ImageManipulationAttributes = { - aspectRatio: 'None', - scale: 1, - flipHorizontal: false, - flipVertical: false, - rotate: 0, - } - prevAttributes: ImageManipulationAttributes = {} - - constructor(image: ImageInitOptions) { - makeAutoObservable(this) - - this.path = image.path - this.width = image.width - this.height = image.height - if (image.altText !== undefined) { - this.setAltText(image.altText) - } - } - - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { - this.attributes.aspectRatio = aspectRatio - } - - setRotate(degrees: number) { - this.attributes.rotate = degrees - this.manipulate({}) - } - - flipVertical() { - this.attributes.flipVertical = !this.attributes.flipVertical - this.manipulate({}) - } - - flipHorizontal() { - this.attributes.flipHorizontal = !this.attributes.flipHorizontal - this.manipulate({}) - } - - get ratioMultipliers() { - return { - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - None: this.width / this.height, - } - } - - getUploadDimensions( - dimensions: Dimensions, - maxDimensions: Dimensions = POST_IMG_MAX, - as: ImageManipulationAttributes['aspectRatio'] = 'None', - ) { - const {width, height} = dimensions - const {width: maxWidth, height: maxHeight} = maxDimensions - - return width < maxWidth && height < maxHeight - ? { - width, - height, - } - : this.getResizedDimensions(as, POST_IMG_MAX.width) - } - - getResizedDimensions( - as: ImageManipulationAttributes['aspectRatio'] = 'None', - maxSide: number, - ) { - const ratioMultiplier = this.ratioMultipliers[as] - - if (ratioMultiplier === 1) { - return { - height: maxSide, - width: maxSide, - } - } - - if (ratioMultiplier < 1) { - return { - width: maxSide * ratioMultiplier, - height: maxSide, - } - } - - return { - width: maxSide, - height: maxSide / ratioMultiplier, - } - } - - setAltText(altText: string) { - this.altText = altText.trim() - } - - // Only compress prior to upload - async compress() { - for (let i = 10; i > 0; i--) { - // Float precision - const factor = Math.round(i) / 10 - const compressed = await ImageManipulator.manipulateAsync( - this.cropped?.path ?? this.path, - undefined, - { - compress: factor, - base64: true, - format: SaveFormat.JPEG, - }, - ) - - if (compressed.base64 !== undefined) { - const size = getDataUriSize(compressed.base64) - - if (size < MAX_IMAGE_SIZE_IN_BYTES) { - runInAction(() => { - this.compressed = { - mime: 'image/jpeg', - path: compressed.uri, - size, - ...compressed, - } - }) - return - } - } - } - - // Compression fails when removing redundant information is not possible. - // This can be tested with images that have high variance in noise. - throw new Error('Failed to compress image') - } - - // Mobile - async crop() { - try { - // NOTE - // on ios, react-native-image-crop-picker gives really bad quality - // without specifying width and height. on android, however, the - // crop stretches incorrectly if you do specify it. these are - // both separate bugs in the library. we deal with that by - // providing width & height for ios only - // -prf - const {width, height} = this.getUploadDimensions({ - width: this.width, - height: this.height, - }) - - const cropped = await openCropper({ - mediaType: 'photo', - path: this.path, - freeStyleCropEnabled: true, - ...(isIOS ? {width, height} : {}), - }) - - runInAction(() => { - this.cropped = cropped - }) - } catch (err) { - logger.error('Failed to crop photo', {message: err}) - } - } - - // Web manipulation - async manipulate( - attributes: { - crop?: ActionCrop['crop'] - } & ImageManipulationAttributes, - ) { - let uploadWidth: number | undefined - let uploadHeight: number | undefined - - const {aspectRatio, crop, position, scale} = attributes - const modifiers = [] - - if (this.attributes.flipHorizontal) { - modifiers.push({flip: FlipType.Horizontal}) - } - - if (this.attributes.flipVertical) { - modifiers.push({flip: FlipType.Vertical}) - } - - if (this.attributes.rotate !== undefined) { - modifiers.push({rotate: this.attributes.rotate}) - } - - if (crop !== undefined) { - const croppedHeight = crop.height * this.height - const croppedWidth = crop.width * this.width - modifiers.push({ - crop: { - originX: crop.originX * this.width, - originY: crop.originY * this.height, - height: croppedHeight, - width: croppedWidth, - }, - }) - - const uploadDimensions = this.getUploadDimensions( - {width: croppedWidth, height: croppedHeight}, - POST_IMG_MAX, - aspectRatio, - ) - - uploadWidth = uploadDimensions.width - uploadHeight = uploadDimensions.height - } else { - const uploadDimensions = this.getUploadDimensions( - {width: this.width, height: this.height}, - POST_IMG_MAX, - aspectRatio, - ) - - uploadWidth = uploadDimensions.width - uploadHeight = uploadDimensions.height - } - - if (scale !== undefined) { - this.attributes.scale = scale - } - - if (position !== undefined) { - this.attributes.position = position - } - - if (aspectRatio !== undefined) { - this.attributes.aspectRatio = aspectRatio - } - - const ratioMultiplier = - this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - - const result = await ImageManipulator.manipulateAsync( - this.path, - [ - ...modifiers, - { - resize: - ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight}, - }, - ], - { - base64: true, - format: SaveFormat.JPEG, - }, - ) - - runInAction(() => { - this.cropped = { - mime: 'image/jpeg', - path: result.uri, - size: - result.base64 !== undefined - ? getDataUriSize(result.base64) - : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails - ...result, - } - }) - } - - resetCropped() { - this.manipulate({}) - } - - previous() { - this.cropped = this.prev - this.attributes = this.prevAttributes - } -} diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index 9de80b07de..487bcdfd99 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -6,9 +6,9 @@ import { useInfiniteQuery, } from '@tanstack/react-query' -import {useAgent} from 'state/session' +import {useAgent} from '#/state/session' -const RQKEY_ROOT = 'actor-starter-packs' +export const RQKEY_ROOT = 'actor-starter-packs' export const RQKEY = (did?: string) => [RQKEY_ROOT, did] export function useActorStarterPacksQuery({did}: {did?: string}) { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 7daf441adb..07c5da81b7 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -15,24 +15,25 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {AuthorFeedAPI} from '#/lib/api/feed/author' +import {CustomFeedAPI} from '#/lib/api/feed/custom' +import {FollowingFeedAPI} from '#/lib/api/feed/following' import {HomeFeedAPI} from '#/lib/api/feed/home' +import {LikesFeedAPI} from '#/lib/api/feed/likes' +import {ListFeedAPI} from '#/lib/api/feed/list' +import {MergeFeedAPI} from '#/lib/api/feed/merge' +import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types' import {aggregateUserInterests} from '#/lib/api/feed/utils' +import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip' import {DISCOVER_FEED_URI} from '#/lib/constants' +import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {useAgent} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' -import {AuthorFeedAPI} from 'lib/api/feed/author' -import {CustomFeedAPI} from 'lib/api/feed/custom' -import {FollowingFeedAPI} from 'lib/api/feed/following' -import {LikesFeedAPI} from 'lib/api/feed/likes' -import {ListFeedAPI} from 'lib/api/feed/list' -import {MergeFeedAPI} from 'lib/api/feed/merge' -import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' -import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip' -import {BSKY_FEED_OWNER_DIDS} from 'lib/constants' import {KnownError} from '#/view/com/posts/FeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' import {useModerationOpts} from '../preferences/moderation-opts' @@ -65,7 +66,7 @@ export interface FeedParams { type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined -const RQKEY_ROOT = 'post-feed' +export const RQKEY_ROOT = 'post-feed' export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { return [RQKEY_ROOT, feedDesc, params || {}] } @@ -109,13 +110,19 @@ export interface FeedPage { fetchedAt: number } -const PAGE_SIZE = 30 +/** + * The minimum number of posts we want in a single "page" of results. Since we + * filter out unwanted content, we may fetch more than this number to ensure + * that we get _at least_ this number. + */ +const MIN_POSTS = 30 export function usePostFeedQuery( feedDesc: FeedDescriptor, params?: FeedParams, opts?: {enabled?: boolean; ignoreFilterFor?: string}, ) { + const gate = useGate() const feedTuners = useFeedTuners(feedDesc) const moderationOpts = useModerationOpts() const {data: preferences} = usePreferencesQuery() @@ -135,6 +142,13 @@ export function usePostFeedQuery( } | null>(null) const isDiscover = feedDesc.includes(DISCOVER_FEED_URI) + /** + * The number of posts to fetch in a single request. Because we filter + * unwanted content, we may over-fetch here to try and fill pages by + * `MIN_POSTS`. + */ + const fetchLimit = gate('post_feed_lang_window') ? 100 : MIN_POSTS + // Make sure this doesn't invalidate unless really needed. const selectArgs = React.useMemo( () => ({ @@ -175,7 +189,7 @@ export function usePostFeedQuery( } try { - const res = await api.fetch({cursor, limit: PAGE_SIZE}) + const res = await api.fetch({cursor, limit: fetchLimit}) /* * If this is a public view, we need to check if posts fail moderation. @@ -373,13 +387,13 @@ export function usePostFeedQuery( // Now track how many items we really want, and fetch more if needed. if (isLoading || isRefetching) { // During the initial fetch, we want to get an entire page's worth of items. - wantedItemCount.current = PAGE_SIZE + wantedItemCount.current = MIN_POSTS } else if (isFetchingNextPage) { if (itemCount > wantedItemCount.current) { // We have more items than wantedItemCount, so wantedItemCount must be out of date. // Some other code must have called fetchNextPage(), for example, from onEndReached. // Adjust the wantedItemCount to reflect that we want one more full page of items. - wantedItemCount.current = itemCount + PAGE_SIZE + wantedItemCount.current = itemCount + MIN_POSTS } } else if (hasNextPage) { // At this point we're not fetching anymore, so it's time to make a decision. diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts index b50a2a2890..79d9735c98 100644 --- a/src/state/queries/profile-feedgens.ts +++ b/src/state/queries/profile-feedgens.ts @@ -8,7 +8,7 @@ const PAGE_SIZE = 50 type RQPageParam = string | undefined // TODO refactor invalidate on mutate? -const RQKEY_ROOT = 'profile-feedgens' +export const RQKEY_ROOT = 'profile-feedgens' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileFeedgensQuery( diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 03c983ff80..5c9f9f0d6f 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts' const PAGE_SIZE = 30 type RQPageParam = string | undefined -const RQKEY_ROOT = 'profile-lists' +export const RQKEY_ROOT = 'profile-lists' export const RQKEY = (did: string) => [RQKEY_ROOT, did] export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index 6755ec9a66..8e12386bd3 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -9,6 +9,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {purgeTemporaryImageFiles} from '#/state/gallery' import * as Toast from '#/view/com/util/Toast' export interface ComposerOptsPostRef { @@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const closeComposer = useNonReactiveCallback(() => { let wasOpen = !!state - setState(undefined) + if (wasOpen) { + setState(undefined) + purgeTemporaryImageFiles() + } + return wasOpen }) diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx index 0d64650ddb..fb69e1d9c7 100644 --- a/src/view/com/auth/server-input/index.tsx +++ b/src/view/com/auth/server-input/index.tsx @@ -3,14 +3,15 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {BSKY_SERVICE} from '#/lib/constants' import * as persisted from '#/state/persisted' -import {BSKY_SERVICE} from 'lib/constants' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import * as ToggleButton from '#/components/forms/ToggleButton' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {InlineLinkText} from '#/components/Link' import {P, Text} from '#/components/Typography' export function ServerInputDialog({ @@ -153,9 +154,13 @@ export function ServerInputDialog({ ]}> Bluesky is an open network where you can choose your hosting - provider. Custom hosting is now available in beta for - developers. - + provider. If you're a developer, you can host your own server. + {' '} + + Learn more. +

diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index dfdfb3ebdf..3b7cf13851 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -44,7 +44,6 @@ import {RichText} from '@atproto/api' 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 {useAnalytics} from '#/lib/analytics/analytics' import * as apilib from '#/lib/api/index' @@ -68,9 +67,9 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' +import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useModals} from '#/state/modals' -import {GalleryModel} from '#/state/models/media/gallery' import {useRequireAltTextEnabled} from '#/state/preferences' import { toPostLanguages, @@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' +const MAX_IMAGES = 4 + type CancelRef = { onPressCancel: () => void } type Props = ComposerOpts -export const ComposePost = observer(function ComposePost({ +export const ComposePost = ({ replyTo, onPost, quote: initQuote, @@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({ cancelRef, }: Props & { cancelRef?: React.RefObject -}) { +}) => { const {currentAccount} = useSession() const agent = useAgent() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) @@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({ ) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) - const gallery = useMemo( - () => new GalleryModel(initImageUris), - [initImageUris], + const [images, setImages] = useState(() => + createInitialImages(initImageUris), ) const onClose = useCallback(() => { closeComposer() @@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if ( graphemeLength > 0 || - !gallery.isEmpty || + images.length !== 0 || extGif || videoUploadState.status !== 'idle' ) { @@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({ }, [ extGif, graphemeLength, - gallery.isEmpty, + images.length, closeAllDialogs, discardPromptControl, onClose, @@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({ [extLink, setExtLink], ) + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length))) + }, + [setImages], + ) + const onPhotoPasted = useCallback( async (uri: string) => { track('Composer:PastedPhotos') if (uri.startsWith('data:video/')) { selectVideo({uri, type: 'video', height: 0, width: 0}) } else { - await gallery.paste(uri) + const res = await pasteImage(uri) + onImageAdd([res]) } }, - [gallery, track, selectVideo], + [track, selectVideo, onImageAdd], ) const isAltTextRequiredAndMissing = useMemo(() => { if (!requireAltTextEnabled) return false - if (gallery.needsAltText) return true + if (images.some(img => img.alt === '')) return true + if (extGif) { if (!extLink?.meta?.description) return true @@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({ if (!parsedAlt.isPreferred) return true } return false - }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) + }, [images, extLink, extGif, requireAltTextEnabled]) const onPressPublish = React.useCallback( async (finishedUploading?: boolean) => { @@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({ if ( richtext.text.trim().length === 0 && - gallery.isEmpty && + images.length === 0 && !extLink && !quote && videoUploadState.status === 'idle' @@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({ await apilib.post(agent, { rawText: richtext.text, replyTo: replyTo?.uri, - images: gallery.images, + images, quote, extLink, labels, @@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({ } catch (e: any) { logger.error(e, { message: `Composer: create post failed`, - hasImages: gallery.size > 0, + hasImages: images.length > 0, }) if (extLink) { @@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({ } finally { if (postUri) { logEvent('post:create', { - imageCount: gallery.size, + imageCount: images.length, isReply: replyTo != null, hasLink: extLink != null, hasQuote: quote != null, @@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({ }) } track('Create Post', { - imageCount: gallery.size, + imageCount: images.length, }) if (replyTo && replyTo.uri) track('Post:Reply') } @@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({ agent, captions, extLink, - gallery.images, - gallery.isEmpty, - gallery.size, + images, graphemeLength, isAltTextRequiredAndMissing, isProcessing, @@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({ : _(msg`What's up?`) const canSelectImages = - gallery.size < 4 && + images.length < MAX_IMAGES && !extLink && videoUploadState.status === 'idle' && !videoUploadState.video const hasMedia = - gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) + images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) @@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({ />
- - {gallery.isEmpty && extLink && ( + + {images.length === 0 && extLink && ( ) : ( - + - + ) -}) +} export function useComposerCancelRef() { return useRef(null) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 4801ca0abf..f61d410dfc 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -26,7 +26,7 @@ export const ExternalEmbed = ({ title: link.meta?.title ?? link.uri, uri: link.uri, description: link.meta?.description ?? '', - thumb: link.localThumb?.path, + thumb: link.localThumb?.source.path, }, [link], ) diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index a37452604f..a05607c76c 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -43,7 +43,7 @@ export function GifAltText({ title: linkProp.meta?.title ?? linkProp.uri, uri: linkProp.uri, description: linkProp.meta?.description ?? '', - thumb: linkProp.localThumb?.path, + thumb: linkProp.localThumb?.source.path, }, params: parseEmbedPlayerFromUrl(linkProp.uri), } diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx new file mode 100644 index 0000000000..4263587fd4 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +import {ComposerImage} from '#/state/gallery' +import * as Dialog from '#/components/Dialog' + +export type EditImageDialogProps = { + control: Dialog.DialogOuterProps['control'] + image: ComposerImage + onChange: (next: ComposerImage) => void +} + +export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => { + return null +} diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx new file mode 100644 index 0000000000..0afb83ed96 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -0,0 +1,105 @@ +import 'react-image-crop/dist/ReactCrop.css' + +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import ReactCrop, {PercentCrop} from 'react-image-crop' + +import { + ImageSource, + ImageTransformation, + manipulateImage, +} from '#/state/gallery' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {EditImageDialogProps} from './EditImageDialog' + +export const EditImageDialog = (props: EditImageDialogProps) => { + return ( + + + + ) +} + +const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { + const {_} = useLingui() + + const source = image.source + + const initialCrop = getInitialCrop(source, image.manips) + const [crop, setCrop] = React.useState(initialCrop) + + const isEmpty = !crop || (crop.width || crop.height) === 0 + const isNew = initialCrop ? true : !isEmpty + + const onPressSubmit = React.useCallback(async () => { + const result = await manipulateImage(image, { + crop: + crop && (crop.width || crop.height) !== 0 + ? { + originX: (crop.x * source.width) / 100, + originY: (crop.y * source.height) / 100, + width: (crop.width * source.width) / 100, + height: (crop.height * source.height) / 100, + } + : undefined, + }) + + onChange(result) + control.close() + }, [crop, image, source, control, onChange]) + + return ( + + + + + Edit image + + + + setCrop(percentCrop)} + className="ReactCrop--no-animate"> + + + + + + + + + ) +} + +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 imageStyle = { - height: side, - width: side, - } + const isOverflow = isMobile && images.length > 2 - 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/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 5fbaaa1550..3c1f51249f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -625,7 +625,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, layoutIcon: { - width: 70, + width: 60, alignItems: 'flex-end', paddingTop: 2, }, diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx new file mode 100644 index 0000000000..fd4cc74632 --- /dev/null +++ b/src/view/com/pager/PagerHeaderContext.tsx @@ -0,0 +1,41 @@ +import React, {useContext} from 'react' +import {SharedValue} from 'react-native-reanimated' + +import {isIOS} from '#/platform/detection' + +export const PagerHeaderContext = + React.createContext | null>(null) + +/** + * Passes the scrollY value to the pager header's banner, so it can grow on + * overscroll on iOS. Not necessary to use this context provider on other platforms. + * + * @platform ios + */ +export function PagerHeaderProvider({ + scrollY, + children, +}: { + scrollY: SharedValue + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export function usePagerHeaderContext() { + const scrollY = useContext(PagerHeaderContext) + if (isIOS) { + if (!scrollY) { + throw new Error( + 'usePagerHeaderContext must be used within a HeaderProvider', + ) + } + return {scrollY} + } else { + return null + } +} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 7b1d8b78f4..528f7fdf2e 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -19,9 +19,10 @@ import Animated, { import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {ScrollProvider} from '#/lib/ScrollContext' -import {isIOS} from 'platform/detection' -import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' +import {isIOS} from '#/platform/detection' +import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager' import {ListMethods} from '../util/List' +import {PagerHeaderProvider} from './PagerHeaderContext' import {TabBar} from './TabBar' export interface PagerWithHeaderChildParams { @@ -41,6 +42,7 @@ export interface PagerWithHeaderProps { initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void + allowHeaderOverScroll?: boolean } export const PagerWithHeader = React.forwardRef( function PageWithHeaderImpl( @@ -53,6 +55,7 @@ export const PagerWithHeader = React.forwardRef( initialPage, onPageSelected, onCurrentPageSelected, + allowHeaderOverScroll, }: PagerWithHeaderProps, ref, ) { @@ -80,19 +83,22 @@ export const PagerWithHeader = React.forwardRef( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - + + + ) }, [ @@ -106,6 +112,7 @@ export const PagerWithHeader = React.forwardRef( onHeaderOnlyLayout, scrollY, testID, + allowHeaderOverScroll, ], ) @@ -216,6 +223,7 @@ let PagerTabBar = ({ onTabBarLayout, onCurrentPageSelected, onSelect, + allowHeaderOverScroll, }: { currentPage: number headerOnlyHeight: number @@ -228,14 +236,20 @@ let PagerTabBar = ({ onTabBarLayout: (e: LayoutChangeEvent) => void onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void + allowHeaderOverScroll?: boolean }): React.ReactNode => { - const headerTransform = useAnimatedStyle(() => ({ - transform: [ - { - translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0), - }, - ], - })) + const headerTransform = useAnimatedStyle(() => { + const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1 + return { + transform: [ + { + translateY: allowHeaderOverScroll + ? translateY + : Math.min(translateY, 0), + }, + ], + } + }) const headerRef = React.useRef(null) return ( { + playHaptics('Light') + setTimeout( + () => { + onPressCompose() + }, + isHapticsDisabled ? 0 : 75, + ) + } + return ( - onPressCompose()} + - - + - Write your reply - - + + + Write your reply + + + ) } - -const styles = StyleSheet.create({ - prompt: { - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: 10, - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: StyleSheet.hairlineWidth, - }, - labelMobile: { - paddingLeft: 12, - }, - labelDesktopWeb: { - paddingLeft: 12, - }, -}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 3fb2309b96..ead9df1161 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -558,18 +558,14 @@ let PostThreadItemLoaded = ({ diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 9033fb96f7..ec730a5e16 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -163,7 +163,7 @@ function PostInner({ diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index d712605d6e..498ab48ee6 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -14,8 +14,11 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useAnalytics} from '#/lib/analytics/analytics' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {logEvent, useGate} from '#/lib/statsig/statsig' +import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' @@ -30,9 +33,6 @@ import { usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' -import {useAnalytics} from 'lib/analytics/analytics' -import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' -import {useTheme} from 'lib/ThemeContext' import { ProgressGuide, SuggestedFeeds, @@ -167,6 +167,7 @@ let Feed = ({ renderEndOfFeed, testID, headerOffset = 0, + progressViewOffset, desktopFixedHeightOffset, ListHeaderComponent, extraData, @@ -187,6 +188,7 @@ let Feed = ({ renderEndOfFeed?: () => JSX.Element testID?: string headerOffset?: number + progressViewOffset?: number desktopFixedHeightOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any @@ -548,6 +550,7 @@ let Feed = ({ refreshing={isPTRing} onRefresh={onRefresh} headerOffset={headerOffset} + progressViewOffset={progressViewOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index b1509b2719..fb9cdb065e 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -245,7 +245,7 @@ let FeedItemInner = ({ onBeforePress={onBeforePress} dataSet={{feedContext}}> - + {isThreadChild && ( ( onItemSeen, headerOffset, style, + progressViewOffset, ...props }: ListProps, ref: React.Ref, ) { const isScrolledDown = useSharedValue(false) - const pal = usePalette('default') + const t = useTheme() const dedupe = useDedupe(400) function handleScrolledDownChange(didScrollDown: boolean) { @@ -120,9 +121,9 @@ function ListImpl( ) } diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 6e75e88ca6..6620eb8e28 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -7,9 +7,9 @@ import { ViewStyle, } from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' +import {usePalette} from '#/lib/hooks/usePalette' +import {s} from '#/lib/styles' +import {useTheme} from '#/lib/ThemeContext' import {atoms as a, useTheme as useTheme_NEW} from '#/alf' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' import { @@ -53,8 +53,8 @@ export function PostLoadingPlaceholder({ return ( onOpenAuthor?: () => void style?: StyleProp } let PostMeta = (opts: PostMetaOpts): React.ReactNode => { - const {i18n} = useLingui() + const t = useTheme() + const {i18n, _} = useLingui() - const pal = usePalette('default') const displayName = opts.author.displayName || opts.author.handle const handle = opts.author.handle const profileLink = makeProfileLink(opts.author) @@ -53,9 +49,18 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { }, [queryClient, opts.author]) return ( - + {opts.showAvatar && ( - + { )} - - + - {forceLTR( - sanitizeDisplayName( - displayName, - opts.moderation?.ui('displayName'), - ), - )} - - } - href={profileLink} - onBeforePress={onBeforePressAuthor} - /> - + + {forceLTR( + sanitizeDisplayName( + displayName, + opts.moderation?.ui('displayName'), + ), + )} + + + - {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')} - - } - href={profileLink} - onBeforePress={onBeforePressAuthor} - anchorNoUnderline - /> + disableUnderline + onPress={onBeforePressAuthor} + style={[a.text_md, t.atoms.text_contrast_medium, a.leading_tight]}> + + {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')} + + - {!isAndroid && ( - - · - - )} + + + · + + {({timeElapsed}) => ( - + disableMismatchWarning + disableUnderline + onPress={onBeforePressPost} + style={[ + a.text_md, + t.atoms.text_contrast_medium, + a.leading_tight, + web({ + whiteSpace: 'nowrap', + }), + ]}> + {timeElapsed} + )} @@ -129,21 +138,3 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { } PostMeta = memo(PostMeta) export {PostMeta} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'flex-end', - paddingBottom: 2, - gap: 4, - zIndex: 1, - flex: 1, - }, - avatar: { - alignSelf: 'center', - }, - maxWidth: { - flex: isAndroid ? 1 : undefined, - flexShrink: isAndroid ? undefined : 1, - }, -}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index a4468e170d..2b4376b698 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..0e07a57454 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) { @@ -201,7 +202,7 @@ const styles = StyleSheet.create({ }, bannerImage: { width: '100%', - height: 150, + height: '100%', }, defaultBanner: { backgroundColor: '#0070ff', diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 79e3264046..3b8152c8b8 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -24,15 +24,15 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' +import {usePalette} from '#/lib/hooks/usePalette' +import {InfoCircleIcon} from '#/lib/icons' import {moderatePost_wrapped} from '#/lib/moderatePost_wrapped' +import {makeProfileLink} from '#/lib/routes/links' import {s} from '#/lib/styles' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {usePalette} from 'lib/hooks/usePalette' -import {InfoCircleIcon} from 'lib/icons' -import {makeProfileLink} from 'lib/routes/links' -import {precacheProfile} from 'state/queries/profile' -import {ComposerOptsQuote} from 'state/shell/composer' +import {ComposerOptsQuote} from '#/state/shell/composer' import {atoms as a, useTheme} from '#/alf' import {RichText} from '#/components/RichText' import {ContentHider} from '../../../../components/moderation/ContentHider' @@ -238,7 +238,6 @@ export function QuoteEmbed({ author={quote.author} moderation={moderation} showAvatar - authorHasWarning={false} postHref={itemHref} timestamp={quote.indexedAt} /> diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 82b2503eb1..fa2b7e3d3f 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,8 +1,9 @@ import React, {useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' import {AppBskyEmbedVideo} from '@atproto/api' -import Hls, {Events, FragChangedData, Fragment} from 'hls.js' +import type * as HlsTypes from 'hls.js' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Controls} from './web-controls/VideoControls' @@ -22,6 +23,7 @@ export function VideoEmbedInnerWeb({ const videoRef = useRef(null) const [focused, setFocused] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + const [hlsLoading, setHlsLoading] = React.useState(false) const figId = useId() // send error up to error boundary @@ -30,114 +32,14 @@ export function VideoEmbedInnerWeb({ throw error } - const hlsRef = useRef(undefined) - const [lowQualityFragments, setLowQualityFragments] = useState([]) - - useEffect(() => { - if (!videoRef.current) return - if (!Hls.isSupported()) throw new HLSUnsupportedError() - - const hls = new Hls({ - 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 - // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead - }) - hlsRef.current = hls - - 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 ( - data.details === 'manifestLoadError' && - data.response?.code === 404 - ) { - setError(new VideoNotFoundError()) - } else { - setError(data.error) - } - } else { - console.error(data.error) - } - }) - - return () => { - 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]) + const hlsRef = useHLS({ + focused, + playlist: embed.playlist, + setHasSubtitleTrack, + setError, + videoRef, + setHlsLoading, + }) return ( @@ -177,6 +79,7 @@ export function VideoEmbedInnerWeb({ setActive={setActive} focused={focused} setFocused={setFocused} + hlsLoading={hlsLoading} onScreen={onScreen} fullscreenRef={containerRef} hasSubtitleTrack={hasSubtitleTrack} @@ -198,3 +101,154 @@ export class VideoNotFoundError extends Error { super('Video not found') } } + +type CachedPromise = Promise & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise +promiseForHls.value = undefined +promiseForHls.then(Hls => { + promiseForHls.value = Hls +}) + +function useHLS({ + focused, + playlist, + setHasSubtitleTrack, + setError, + videoRef, + setHlsLoading, +}: { + focused: boolean + playlist: string + setHasSubtitleTrack: (v: boolean) => void + setError: (v: Error | null) => void + videoRef: React.RefObject + setHlsLoading: (v: boolean) => void +}) { + const [Hls, setHls] = useState( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef(undefined) + const [lowQualityFragments, setLowQualityFragments] = useState< + HlsTypes.Fragment[] + >([]) + + // purge low quality segments from buffer on next frag change + const handleFragChange = useNonReactiveCallback( + ( + _event: HlsTypes.Events.FRAG_CHANGED, + {frag}: HlsTypes.FragChangedData, + ) => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + + if (focused && hls.nextAutoLevel > 0) { + // if the current quality level goes above 0, flush the low quality segments + const flushed: HlsTypes.Fragment[] = [] + + for (const lowQualFrag of lowQualityFragments) { + // avoid if close to the current fragment + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { + continue + } + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + + flushed.push(lowQualFrag) + } + + setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) + } + }, + ) + + useEffect(() => { + if (!videoRef.current) return + if (!Hls) return + if (!Hls.isSupported()) { + throw new HLSUnsupportedError() + } + + const hls = new Hls({ + maxMaxBufferLength: 10, // only load 10s ahead + // note: the amount buffered is affected by both maxBufferLength and maxBufferSize + // it will buffer until it is greater than *both* of those values + // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead + }) + hlsRef.current = hls + + hls.attachMedia(videoRef.current) + hls.loadSource(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 + const videoNode = videoRef.current + videoNode.addEventListener( + 'ended', + function () { + videoNode.currentTime = 0 + videoNode.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 ( + data.details === 'manifestLoadError' && + data.response?.code === 404 + ) { + setError(new VideoNotFoundError()) + } else { + setError(data.error) + } + } else { + console.error(data.error) + } + }) + + hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) + + return () => { + hlsRef.current = undefined + hls.detachMedia() + hls.destroy() + abortController.abort() + } + }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) + + return hlsRef +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx index 2d1427347d..dd0dafc335 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx @@ -43,6 +43,7 @@ export function Controls({ setFocused, onScreen, fullscreenRef, + hlsLoading, hasSubtitleTrack, }: { videoRef: React.RefObject @@ -53,6 +54,7 @@ export function Controls({ setFocused: (focused: boolean) => void onScreen: boolean fullscreenRef: React.RefObject + hlsLoading: boolean hasSubtitleTrack: boolean }) { const { @@ -80,6 +82,7 @@ export function Controls({ const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const showSpinner = hlsLoading || buffering const { state: volumeHovered, onIn: onVolumeHover, @@ -409,11 +412,11 @@ export function Controls({ )} - {(buffering || error) && ( + {(showSpinner || error) && ( - {buffering && } + {showSpinner && } {error && ( An error occurred diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 879632e9ef..b37445fad6 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -37,11 +37,11 @@ import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' import {ProfileLists} from '#/view/com/lists/ProfileLists' +import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {FAB} from '#/view/com/util/fab/FAB' import {ListRef} from '#/view/com/util/List' import {CenteredView} from '#/view/com/util/Views' -import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' @@ -363,7 +363,8 @@ function ProfileScreenLoaded({ items={sectionTitles} onPageSelected={onPageSelected} onCurrentPageSelected={onCurrentPageSelected} - renderHeader={renderHeader}> + renderHeader={renderHeader} + allowHeaderOverScroll> {showFiltersTab ? ({headerHeight, isFocused, scrollElRef}) => ( { +function SearchLanguageDropdown({ + value, + onChange, +}: { + value: string + onChange(value: string): void +}) { + const t = useThemeNew() + const {contentLanguages} = useLanguagePrefs() + + const items = React.useMemo(() => { + return LANGUAGES.filter(l => Boolean(l.code2)) + .map(l => ({ + label: l.name, + inputLabel: l.name, + value: l.code2, + key: l.code2 + l.code3, + })) + .sort(a => (contentLanguages.includes(a.value) ? -1 : 1)) + }, [contentLanguages]) + + const style = { + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, + color: t.atoms.text.color, + fontSize: a.text_xs.fontSize, + fontFamily: 'inherit', + fontWeight: a.font_bold.fontWeight, + paddingHorizontal: 14, + paddingRight: 32, + paddingVertical: 8, + borderRadius: a.rounded_full.borderRadius, + borderWidth: a.border.borderWidth, + borderColor: t.atoms.border_contrast_low.borderColor, + } + + return ( + ( + + )} + useNativeAndroidPickerStyle={false} + style={{ + iconContainer: { + pointerEvents: 'none', + right: a.px_sm.paddingRight, + top: 0, + bottom: 0, + display: 'flex', + justifyContent: 'center', + }, + inputAndroid: { + ...style, + paddingVertical: 2, + }, + inputIOS: { + ...style, + }, + inputWeb: web({ + ...style, + cursor: 'pointer', + // @ts-ignore web only + '-moz-appearance': 'none', + '-webkit-appearance': 'none', + appearance: 'none', + outline: 0, + borderWidth: 0, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }), + }} + /> + ) +} + +function useQueryManager({initialQuery}: {initialQuery: string}) { + const {contentLanguages} = useLanguagePrefs() + const {query, params: initialParams} = React.useMemo(() => { + return parseSearchQuery(initialQuery || '') + }, [initialQuery]) + const prevInitialQuery = React.useRef(initialQuery) + const [lang, setLang] = React.useState( + initialParams.lang || contentLanguages[0], + ) + + if (initialQuery !== prevInitialQuery.current) { + // handle new queryParam change (from manual search entry) + prevInitialQuery.current = initialQuery + setLang(initialParams.lang || contentLanguages[0]) + } + + const params = React.useMemo( + () => ({ + // default stuff + ...initialParams, + // managed stuff + lang, + }), + [lang, initialParams], + ) + const handlers = React.useMemo( + () => ({ + setLang, + }), + [setLang], + ) + + return React.useMemo(() => { + return { + query, + queryWithParams: makeSearchQuery(query, params), + params: { + ...params, + ...handlers, + }, + } + }, [query, params, handlers]) +} + +let SearchScreenInner = ({ + query, + queryWithParams, + headerHeight, +}: { + query: string + queryWithParams: string + headerHeight: number +}): React.ReactNode => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -349,7 +488,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { title: _(msg`Top`), component: ( @@ -359,7 +498,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { title: _(msg`Latest`), component: ( @@ -378,7 +517,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { ), }, ] - }, [_, query, activeTab]) + }, [_, query, queryWithParams, activeTab]) return query ? ( { renderTabBar={props => ( + style={[ + pal.border, + pal.view, + web({ + position: isWeb ? 'sticky' : '', + zIndex: 1, + }), + {top: isWeb ? headerHeight : undefined}, + ]}> section.title)} {...props} /> )} @@ -448,14 +595,14 @@ SearchScreenInner = React.memo(SearchScreenInner) export function SearchScreen( props: NativeStackScreenProps, ) { + const t = useThemeNew() + const {gtMobile} = useBreakpoints() const navigation = useNavigation() const textInput = React.useRef(null) const {_} = useLingui() - const pal = usePalette('default') const {track} = useAnalytics() const setDrawerOpen = useSetDrawerOpen() const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() // Query terms const queryParam = props.route?.params?.q ?? '' @@ -469,6 +616,17 @@ export function SearchScreen( AppBskyActorDefs.ProfileViewBasic[] >([]) + const {params, query, queryWithParams} = useQueryManager({ + initialQuery: queryParam, + }) + const showFiltersButton = Boolean(query && !showAutocomplete) + const [showFilters, setShowFilters] = React.useState(false) + /* + * Arbitrary sizing, so guess and check, used for sticky header alignment and + * sizing. + */ + const headerHeight = 56 + (showFilters ? 40 : 0) + useFocusEffect( useNonReactiveCallback(() => { if (isWeb) { @@ -507,13 +665,6 @@ export function SearchScreen( textInput.current?.focus() }, []) - const onPressCancelSearch = React.useCallback(() => { - scrollToTopWeb() - textInput.current?.blur() - setShowAutocomplete(false) - setSearchText(queryParam) - }, [queryParam]) - const onChangeText = React.useCallback(async (text: string) => { scrollToTopWeb() setSearchText(text) @@ -586,6 +737,13 @@ export function SearchScreen( [updateSearchHistory, navigation], ) + const onPressCancelSearch = React.useCallback(() => { + scrollToTopWeb() + textInput.current?.blur() + setShowAutocomplete(false) + setSearchText(queryParam) + }, [setShowAutocomplete, setSearchText, queryParam]) + const onSubmit = React.useCallback(() => { navigateToItem(searchText) }, [navigateToItem, searchText]) @@ -624,6 +782,7 @@ export function SearchScreen( setSearchText('') navigation.setParams({q: ''}) } + setShowFilters(false) }, [navigation]) useFocusEffect( @@ -663,50 +822,107 @@ export function SearchScreen( [selectedProfiles], ) + const onSearchInputFocus = React.useCallback(() => { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + } + setShowFilters(false) + }, [setShowAutocomplete]) + return ( - {isTabletOrMobile && ( - - - - )} - - {showAutocomplete && ( - - + + {!gtMobile && ( + + )} + + {showFiltersButton && ( + + )} + {showAutocomplete && ( + + )} + + + {showFilters && ( + + + + )} + - + ) @@ -747,7 +967,7 @@ let SearchInputBox = ({ textInput, searchText, showAutocomplete, - setShowAutocomplete, + onFocus, onChangeText, onSubmit, onPressClearQuery, @@ -755,83 +975,62 @@ let SearchInputBox = ({ textInput: React.RefObject searchText: string showAutocomplete: boolean - setShowAutocomplete: (show: boolean) => void + onFocus: () => void onChangeText: (text: string) => void onSubmit: () => void onPressClearQuery: () => void }): React.ReactNode => { - const pal = usePalette('default') const {_} = useLingui() - const theme = useTheme() + const t = useThemeNew() + return ( - { - textInput.current?.focus() - }}> - - { - if (isWeb) { - // Prevent a jump on iPad by ensuring that - // the initial focused render has no result list. - requestAnimationFrame(() => { - setShowAutocomplete(true) - }) - } else { - setShowAutocomplete(true) - } - }} - onChangeText={onChangeText} - onSubmitEditing={onSubmit} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - /> + + + + + + {showAutocomplete && searchText.length > 0 && ( - - - + + + )} - + ) } SearchInputBox = React.memo(SearchInputBox) @@ -1029,21 +1228,7 @@ function scrollToTopWeb() { } } -const HEADER_HEIGHT = 46 - const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingLeft: 13, - paddingVertical: 4, - height: HEADER_HEIGHT, - // @ts-ignore web only - position: isWeb ? 'sticky' : '', - top: 0, - zIndex: 1, - }, headerMenuBtn: { width: 30, height: 30, @@ -1075,12 +1260,6 @@ const styles = StyleSheet.create({ zIndex: -1, elevation: -1, // For Android }, - tabBarContainer: { - // @ts-ignore web only - position: isWeb ? 'sticky' : '', - top: isWeb ? HEADER_HEIGHT : 0, - zIndex: 1, - }, searchHistoryContainer: { width: '100%', paddingHorizontal: 12, diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 737ca2d28a..73bfaa83ef 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -94,10 +94,14 @@ function SettingsAccountCard({ /> - + {profile?.displayName || account.handle} - + {account.handle} diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index fc414d31f3..8ec118ae3e 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -32,7 +32,7 @@ export function Forms() { label="Text field" /> - + ) -}) +} function Providers({ children, diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 1c97df9c39..049f35d35d 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -1,17 +1,12 @@ import React, {useEffect} from 'react' import {Animated, Easing, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {usePalette} from 'lib/hooks/usePalette' -import {useComposerState} from 'state/shell/composer' +import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' +import {usePalette} from '#/lib/hooks/usePalette' +import {useComposerState} from '#/state/shell/composer' import {ComposePost} from '../com/composer/Composer' -export const Composer = observer(function ComposerImpl({ - winHeight, -}: { - winHeight: number -}) { +export function Composer({winHeight}: {winHeight: number}) { const state = useComposerState() const pal = usePalette('default') const initInterp = useAnimatedValue(0) @@ -62,7 +57,7 @@ export const Composer = observer(function ComposerImpl({ /> ) -}) +} const styles = StyleSheet.create({ wrapper: { diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index f6d16ae8e5..02d9427330 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -1,5 +1,5 @@ import React, {ComponentProps} from 'react' -import {GestureResponderEvent, TouchableOpacity, View} from 'react-native' +import {GestureResponderEvent, View} from 'react-native' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg, Trans} from '@lingui/macro' @@ -8,6 +8,7 @@ import {BottomTabBarProps} from '@react-navigation/bottom-tabs' import {StackActions} from '@react-navigation/native' import {useAnalytics} from '#/lib/analytics/analytics' +import {PressableScale} from '#/lib/custom-animations/PressableScale' import {useHaptics} from '#/lib/haptics' import {useDedupe} from '#/lib/hooks/useDedupe' import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' @@ -29,6 +30,7 @@ import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' +import {atoms as a} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import { @@ -326,7 +328,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { interface BtnProps extends Pick< - ComponentProps, + ComponentProps, | 'accessible' | 'accessibilityRole' | 'accessibilityHint' @@ -350,7 +352,7 @@ function Btn({ accessibilityLabel, }: BtnProps) { return ( - + accessibilityHint={accessibilityHint} + targetScale={0.8} + contentContainerStyle={[a.flex_1]}> {icon} {notificationCount ? ( - + {notificationCount} ) : undefined} - + ) } diff --git a/svgo.config.mjs b/svgo.config.mjs new file mode 100644 index 0000000000..d7c98cd54a --- /dev/null +++ b/svgo.config.mjs @@ -0,0 +1,64 @@ +const preset = [ + "removeDoctype", + "removeXMLProcInst", + "removeComments", + "removeMetadata", + "removeEditorsNSData", + "cleanupAttrs", + "mergeStyles", + "inlineStyles", + "minifyStyles", + "cleanupIds", + "removeUselessDefs", + "cleanupNumericValues", + "convertColors", + "removeUnknownsAndDefaults", + "removeNonInheritableGroupAttrs", + "removeUselessStrokeAndFill", + "removeDimensions", + "cleanupEnableBackground", + "removeHiddenElems", + "removeEmptyText", + "convertShapeToPath", + "convertEllipseToCircle", + "moveElemsAttrsToGroup", + "moveGroupAttrsToElems", + "collapseGroups", + "convertPathData", + "convertTransform", + "removeEmptyAttrs", + "removeEmptyContainers", + "removeUnusedNS", + "mergePaths", + "sortAttrs", + "sortDefsChildren", + "removeTitle", + "removeDesc", +] + +export default { + plugins: [...preset.map(name => ({ + name, + params: { + floatPrecision: 3, + transformPrecision: 5, + // minimise diff in ouput from svgomg + // maybe remove in future? will produce smaller output + convertToZ: false, + removeUseless: false, + } + })), + { + name: 'addTrailingWhitespace', + fn() { + return { + root: { + exit (root) { + root.children.push({ type: 'text', value: '\n' }) + return root + } + } + } + } + }] +}; diff --git a/yarn.lock b/yarn.lock index 7077aeda3b..db8a707a40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2570,7 +2570,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4": +"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.10.tgz#89eda6daf1d3af6f36fb368766553054c8d7cd46" integrity sha512-RchI7HePu1eu0CYNKHHHQdfenZcM4nz8rew5B1VWqeRKdcwW5aQ5HeG9eTUbWiAS1UrmHVLmoxTWHt3iLD/NhA== @@ -2983,11 +2983,6 @@ "@babel/helper-validator-identifier" "^7.24.6" to-fast-properties "^2.0.0" -"@bam.tech/react-native-image-resizer@^3.0.4": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@bam.tech/react-native-image-resizer/-/react-native-image-resizer-3.0.5.tgz#6661ba020de156268f73bdc92fbb93ef86f88a13" - integrity sha512-u5QGUQGGVZiVCJ786k9/kd7pPRZ6eYfJCYO18myVCH8FbVI7J8b5GT2Svjj2x808DlWeqfaZOOzxPqo27XYvrQ== - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -8262,13 +8257,6 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-avatar-editor@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393" - integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ== - dependencies: - "@types/react" "*" - "@types/react-dom@^18.2.18": version "18.2.18" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd" @@ -9547,7 +9535,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base-64@0.1.0, base-64@^0.1.0: +base-64@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== @@ -10711,6 +10699,22 @@ css-tree@^1.1.2, css-tree@^1.1.3: mdn-data "2.0.14" source-map "^0.6.1" +css-tree@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" + integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== + dependencies: + mdn-data "2.0.30" + source-map-js "^1.0.1" + +css-tree@~2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032" + integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA== + dependencies: + mdn-data "2.0.28" + source-map-js "^1.0.1" + css-what@^3.2.1: version "3.4.2" resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" @@ -10787,6 +10791,13 @@ csso@^4.0.2, csso@^4.2.0: dependencies: css-tree "^1.1.2" +csso@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/csso/-/csso-5.0.5.tgz#f9b7fe6cc6ac0b7d90781bb16d5e9874303e2ca6" + integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== + dependencies: + css-tree "~2.2.0" + cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -12154,6 +12165,11 @@ expo-asset@~10.0.6: invariant "^2.2.4" md5-file "^3.2.3" +expo-blur@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958" + integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w== + expo-build-properties@^0.12.1: version "0.12.1" resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad" @@ -16261,6 +16277,16 @@ mdn-data@2.0.14: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== +mdn-data@2.0.28: + version "2.0.28" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" + integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== + +mdn-data@2.0.30: + version "2.0.30" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" + integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" @@ -16727,21 +16753,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mobx-react-lite@^3.4.0: - version "3.4.3" - resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7" - integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg== - -mobx-utils@^6.0.6: - version "6.0.8" - resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024" - integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g== - -mobx@^6.6.1: - version "6.10.0" - resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7" - integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww== - moo@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" @@ -18917,15 +18928,6 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" whatwg-fetch "^3.6.2" -react-avatar-editor@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2" - integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA== - dependencies: - "@babel/plugin-transform-runtime" "^7.12.1" - "@babel/runtime" "^7.12.5" - prop-types "^15.7.2" - "react-compiler-runtime@file:./lib/react-compiler-runtime": version "0.0.1" @@ -18985,6 +18987,11 @@ react-freeze@^1.0.0: resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d" integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g== +react-image-crop@^11.0.7: + version "11.0.7" + resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.7.tgz#25f3d37ccbb65a05d19d23b4740a5912835c741e" + integrity sha512-ZciKWHDYzmm366JDL18CbrVyjnjH0ojufGDmScfS4ZUqLHg4nm6ATY+K62C75W4ZRNt4Ii+tX0bSjNk9LQ2xzQ== + "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" @@ -19031,14 +19038,6 @@ react-native-drawer-layout@^4.0.0-alpha.3: dependencies: use-latest-callback "^0.1.9" -react-native-fs@^2.20.0: - version "2.20.0" - resolved "https://registry.yarnpkg.com/react-native-fs/-/react-native-fs-2.20.0.tgz#05a9362b473bfc0910772c0acbb73a78dbc810f6" - integrity sha512-VkTBzs7fIDUiy/XajOSNk0XazFE9l+QlMAce7lGuebZcag5CnjszB+u4BdqzwaQOdcYb5wsJIsqq4kxInIRpJQ== - dependencies: - base-64 "^0.1.0" - utf8 "^3.0.0" - react-native-gesture-handler@~2.16.2: version "2.16.2" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.16.2.tgz#032bd2a07334292d7f6cff1dc9d1ec928f72e26d" @@ -20650,7 +20649,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -20759,7 +20767,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -20773,6 +20781,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -20967,6 +20982,19 @@ svgo@^2.7.0: picocolors "^1.0.0" stable "^0.1.8" +svgo@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.2.tgz#ad58002652dffbb5986fc9716afe52d869ecbda8" + integrity sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^5.1.0" + css-tree "^2.3.1" + css-what "^6.1.0" + csso "^5.0.5" + picocolors "^1.0.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -21794,11 +21822,6 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -utf8@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/utf8/-/utf8-3.0.0.tgz#f052eed1364d696e769ef058b183df88c87f69d1" - integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== - util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -22500,7 +22523,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -22518,6 +22541,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"