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
-`
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 = `
-
-
About Press Copyright Contact us Creators Advertise Developers Terms Privacy Policy & Safety How YouTube works Test 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">
+
+
+
+
+
+
+
+ Save
+
+
+
+
+ )
+}
+
+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
+ />
+
+
+ MAX_ALT_TEXT || altText === image.alt}
+ size="large"
+ color="primary"
+ variant="solid"
+ onPress={onPressSubmit}>
+
+ Save
+
+
+
+
+ )
+}
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 (
(null)
const textInputSelection = useRef({start: 0, end: 0})
const theme = useTheme()
@@ -193,10 +190,12 @@ export const TextInput = forwardRef(function TextInputImpl(
},
)
- /*
- * `PasteInput` appears to prefer no `lineHeight`
+ /**
+ * PasteInput doesn't like `lineHeight`, results in jumpiness
*/
- style.lineHeight = undefined
+ if (isNative) {
+ style.lineHeight = undefined
+ }
/*
* Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
@@ -215,18 +214,23 @@ export const TextInput = forwardRef(function TextInputImpl(
return Array.from(richtext.segments()).map(segment => {
return (
-
+ style={[
+ inputTextStyle,
+ {
+ color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
+ marginTop: -1,
+ },
+ ]}>
{segment.text}
-
+
)
})
- }, [richtext, pal.link, pal.text, inputTextStyle])
+ }, [t, richtext, inputTextStyle])
return (
-
+
{textDecorated}
@@ -252,17 +256,3 @@ export const TextInput = forwardRef(function TextInputImpl(
)
})
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- textInput: {
- flex: 1,
- width: '100%',
- padding: 5,
- paddingBottom: 20,
- marginLeft: 8,
- alignSelf: 'flex-start',
- },
-})
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 3175144372..60afadefea 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -2,22 +2,18 @@ import {useEffect, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {logger} from '#/logger'
-import {useFetchDid} from '#/state/queries/handle'
-import {useGetPost} from '#/state/queries/post'
-import {useAgent} from '#/state/session'
-import * as apilib from 'lib/api/index'
-import {POST_IMG_MAX} from 'lib/constants'
+import * as apilib from '#/lib/api/index'
+import {POST_IMG_MAX} from '#/lib/constants'
import {
EmbeddingDisabledError,
getFeedAsEmbed,
getListAsEmbed,
getPostAsQuote,
getStarterPackAsEmbed,
-} from 'lib/link-meta/bsky'
-import {getLinkMeta} from 'lib/link-meta/link-meta'
-import {resolveShortLink} from 'lib/link-meta/resolve-short-link'
-import {downloadAndResize} from 'lib/media/manip'
+} from '#/lib/link-meta/bsky'
+import {getLinkMeta} from '#/lib/link-meta/link-meta'
+import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
+import {downloadAndResize} from '#/lib/media/manip'
import {
isBskyCustomFeedUrl,
isBskyListUrl,
@@ -25,9 +21,13 @@ import {
isBskyStarterPackUrl,
isBskyStartUrl,
isShortLink,
-} from 'lib/strings/url-helpers'
-import {ImageModel} from 'state/models/media/image'
-import {ComposerOpts} from 'state/shell/composer'
+} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {createComposerImage} from '#/state/gallery'
+import {useFetchDid} from '#/state/queries/handle'
+import {useGetPost} from '#/state/queries/post'
+import {useAgent} from '#/state/session'
+import {ComposerOpts} from '#/state/shell/composer'
export function useExternalLinkFetch({
setQuote,
@@ -161,14 +161,15 @@ export function useExternalLinkFetch({
timeout: 15e3,
})
.catch(() => undefined)
- .then(localThumb => {
+ .then(thumb => (thumb ? createComposerImage(thumb) : undefined))
+ .then(thumb => {
if (aborted) {
return
}
setExtLink({
...extLink,
isLoading: false, // done
- localThumb: localThumb ? new ImageModel(localThumb) : undefined,
+ localThumb: thumb,
})
})
return cleanup
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 6f98cc49a4..693a8e361a 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -15,9 +15,9 @@ import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
+import {EmptyState} from '#/view/com/util/EmptyState'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {EmptyState} from 'view/com/util/EmptyState'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, ios, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
@@ -191,6 +191,7 @@ export const ProfileFeedgens = React.forwardRef<
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
+ progressViewOffset={ios(0)}
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index f633774c7a..117164413f 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {useAnalytics} from '#/lib/analytics/analytics'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
-import {useAnalytics} from 'lib/analytics/analytics'
+import {EmptyState} from '#/view/com/util/EmptyState'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {EmptyState} from 'view/com/util/EmptyState'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, ios, useTheme} from '#/alf'
import * as ListCard from '#/components/ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
@@ -192,6 +192,7 @@ export const ProfileLists = React.forwardRef(
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
+ progressViewOffset={ios(0)}
contentContainerStyle={
isNative && {paddingBottom: headerOffset + 100}
}
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
deleted file mode 100644
index ba489cde7b..0000000000
--- a/src/view/com/modals/AltImage.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import React, {useCallback, useMemo, useState} from 'react'
-import {
- ImageStyle,
- ScrollView as RNScrollView,
- StyleSheet,
- TextInput as RNTextInput,
- TouchableOpacity,
- useWindowDimensions,
- View,
-} from 'react-native'
-import {Image} from 'expo-image'
-import {LinearGradient} from 'expo-linear-gradient'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {useModalControls} from '#/state/modals'
-import {MAX_ALT_TEXT} from 'lib/constants'
-import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
-import {usePalette} from 'lib/hooks/usePalette'
-import {enforceLen} from 'lib/strings/helpers'
-import {gradients, s} from 'lib/styles'
-import {useTheme} from 'lib/ThemeContext'
-import {isAndroid, isWeb} from 'platform/detection'
-import {ImageModel} from 'state/models/media/image'
-import {Text} from '../util/text/Text'
-import {ScrollView, TextInput} from './util'
-
-export const snapPoints = ['100%']
-
-interface Props {
- image: ImageModel
-}
-
-export function Component({image}: Props) {
- const pal = usePalette('default')
- const theme = useTheme()
- const {_} = useLingui()
- const [altText, setAltText] = useState(image.altText)
- const windim = useWindowDimensions()
- const {closeModal} = useModalControls()
- const inputRef = React.useRef(null)
- const scrollViewRef = React.useRef(null)
- const keyboardShown = useIsKeyboardVisible()
-
- // Autofocus hack when we open the modal. We have to wait for the animation to complete first
- React.useEffect(() => {
- if (isAndroid) return
- setTimeout(() => {
- inputRef.current?.focus()
- }, 500)
- }, [])
-
- // We'd rather be at the bottom here so that we can easily dismiss the modal instead of having to scroll
- // (especially on android, it acts weird)
- React.useEffect(() => {
- if (keyboardShown[0]) {
- scrollViewRef.current?.scrollToEnd()
- }
- }, [keyboardShown])
-
- const imageStyles = useMemo(() => {
- const maxWidth = isWeb ? 450 : windim.width
- if (image.height > image.width) {
- return {
- resizeMode: 'contain',
- width: '100%',
- aspectRatio: 1,
- borderRadius: 8,
- }
- }
- return {
- width: '100%',
- height: (maxWidth / image.width) * image.height,
- borderRadius: 8,
- }
- }, [image, windim])
-
- const onUpdate = useCallback(
- (v: string) => {
- v = enforceLen(v, MAX_ALT_TEXT)
- setAltText(v)
- image.setAltText(v)
- },
- [setAltText, image],
- )
-
- const onPressSave = useCallback(() => {
- image.setAltText(altText)
- closeModal()
- }, [closeModal, image, altText])
-
- return (
-
-
-
-
-
-
-
-
-
-
- Done
-
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- scrollContainer: {
- flex: 1,
- height: '100%',
- paddingHorizontal: isWeb ? 0 : 12,
- paddingVertical: isWeb ? 0 : 24,
- },
- scrollInner: {
- gap: 12,
- paddingTop: isWeb ? 0 : 12,
- },
- imageContainer: {
- borderRadius: 8,
- },
- textArea: {
- borderWidth: 1,
- borderRadius: 6,
- paddingTop: 10,
- paddingHorizontal: 12,
- fontSize: 16,
- height: 100,
- textAlignVertical: 'top',
- },
- button: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- borderRadius: 32,
- padding: 10,
- },
- buttonControls: {
- gap: 8,
- paddingBottom: isWeb ? 0 : 50,
- },
-})
diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx
new file mode 100644
index 0000000000..41ca306573
--- /dev/null
+++ b/src/view/com/modals/CropImage.web.tsx
@@ -0,0 +1,145 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
+import {LinearGradient} from 'expo-linear-gradient'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import ReactCrop, {PercentCrop} from 'react-image-crop'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {getDataUriSize} from '#/lib/media/util'
+import {gradients, s} from '#/lib/styles'
+import {useModalControls} from '#/state/modals'
+import {Text} from '#/view/com/util/text/Text'
+
+export const snapPoints = ['0%']
+
+export function Component({
+ uri,
+ aspect,
+ circular,
+ onSelect,
+}: {
+ uri: string
+ aspect?: number
+ circular?: boolean
+ onSelect: (img?: RNImage) => void
+}) {
+ const pal = usePalette('default')
+ const {_} = useLingui()
+
+ const {closeModal} = useModalControls()
+ const {isMobile} = useWebMediaQueries()
+
+ const imageRef = React.useRef(null)
+ const [crop, setCrop] = React.useState()
+
+ const isEmpty = !crop || (crop.width || crop.height) === 0
+
+ const onPressCancel = () => {
+ onSelect(undefined)
+ closeModal()
+ }
+ const onPressDone = async () => {
+ const img = imageRef.current!
+
+ const result = await manipulateAsync(
+ uri,
+ isEmpty
+ ? []
+ : [
+ {
+ crop: {
+ originX: (crop.x * img.naturalWidth) / 100,
+ originY: (crop.y * img.naturalHeight) / 100,
+ width: (crop.width * img.naturalWidth) / 100,
+ height: (crop.height * img.naturalHeight) / 100,
+ },
+ },
+ ],
+ {
+ base64: true,
+ format: SaveFormat.JPEG,
+ },
+ )
+
+ onSelect({
+ path: result.uri,
+ mime: 'image/jpeg',
+ size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
+ width: result.width,
+ height: result.height,
+ })
+
+ closeModal()
+ }
+
+ return (
+
+
+ setCrop(percentCrop)}
+ circularCrop={circular}>
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+ Done
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ cropper: {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ borderWidth: 1,
+ borderRadius: 4,
+ overflow: 'hidden',
+ alignItems: 'center',
+ },
+ ctrls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 10,
+ },
+ btns: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 10,
+ },
+ btn: {
+ borderRadius: 4,
+ paddingVertical: 8,
+ paddingHorizontal: 24,
+ },
+})
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
deleted file mode 100644
index c921984d42..0000000000
--- a/src/view/com/modals/EditImage.tsx
+++ /dev/null
@@ -1,380 +0,0 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
-import {Pressable, StyleSheet, View} from 'react-native'
-import {useWindowDimensions} from 'react-native'
-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 {observer} from 'mobx-react-lite'
-import ImageEditor, {Position} from 'react-avatar-editor'
-
-import {MAX_ALT_TEXT} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {enforceLen} from '#/lib/strings/helpers'
-import {gradients, s} from '#/lib/styles'
-import {useTheme} from '#/lib/ThemeContext'
-import {getKeys} from '#/lib/type-assertions'
-import {useModalControls} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
-import {ImageModel} from '#/state/models/media/image'
-import {atoms as a} from '#/alf'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {
- AspectRatio11_Stroke2_Corner0_Rounded as A11,
- AspectRatio34_Stroke2_Corner0_Rounded as A34,
- AspectRatio43_Stroke2_Corner0_Rounded as A43,
-} from '#/components/icons/AspectRatio'
-import {CircleBanSign_Stroke2_Corner0_Rounded as Ban} from '#/components/icons/CircleBanSign'
-import {
- FlipHorizontal_Stroke2_Corner0_Rounded as FlipHorizontal,
- FlipVertical_Stroke2_Corner0_Rounded as FlipVertical,
-} from '#/components/icons/FlipImage'
-import {Text} from '../util/text/Text'
-import {TextInput} from './util'
-
-export const snapPoints = ['80%']
-
-const RATIOS = {
- '4:3': {
- icon: A43,
- },
- '1:1': {
- icon: A11,
- },
- '3:4': {
- icon: A34,
- },
- None: {
- icon: Ban,
- },
-} as const
-
-type AspectRatio = keyof typeof RATIOS
-
-interface Props {
- image: ImageModel
- gallery: GalleryModel
-}
-
-export const Component = observer(function EditImageImpl({
- image,
- gallery,
-}: Props) {
- const pal = usePalette('default')
- const theme = useTheme()
- const {_} = useLingui()
- const windowDimensions = useWindowDimensions()
- const {isMobile} = useWebMediaQueries()
- const {closeModal} = useModalControls()
-
- const {
- aspectRatio,
- // rotate = 0
- } = image.attributes
-
- const editorRef = useRef(null)
- const [scale, setScale] = useState(image.attributes.scale ?? 1)
- const [position, setPosition] = useState(
- image.attributes.position,
- )
- const [altText, setAltText] = useState(image?.altText ?? '')
-
- const onFlipHorizontal = useCallback(() => {
- image.flipHorizontal()
- }, [image])
-
- const onFlipVertical = useCallback(() => {
- image.flipVertical()
- }, [image])
-
- // const onSetRotate = useCallback(
- // (direction: 'left' | 'right') => {
- // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
- // image.setRotate(rotation)
- // },
- // [rotate, image],
- // )
-
- const onSetRatio = useCallback(
- (ratio: AspectRatio) => {
- image.setRatio(ratio)
- },
- [image],
- )
-
- const adjustments = useMemo(
- () => [
- // {
- // name: 'rotate-left' as const,
- // label: 'Rotate left',
- // onPress: () => {
- // onSetRotate('left')
- // },
- // },
- // {
- // name: 'rotate-right' as const,
- // label: 'Rotate right',
- // onPress: () => {
- // onSetRotate('right')
- // },
- // },
- {
- icon: FlipHorizontal,
- label: _(msg`Flip horizontal`),
- onPress: onFlipHorizontal,
- },
- {
- icon: FlipVertical,
- label: _(msg`Flip vertically`),
- onPress: onFlipVertical,
- },
- ],
- [onFlipHorizontal, onFlipVertical, _],
- )
-
- useEffect(() => {
- image.prev = image.cropped
- image.prevAttributes = image.attributes
- image.resetCropped()
- }, [image])
-
- const onCloseModal = useCallback(() => {
- closeModal()
- }, [closeModal])
-
- const onPressCancel = useCallback(async () => {
- await gallery.previous(image)
- onCloseModal()
- }, [onCloseModal, gallery, image])
-
- const onPressSave = useCallback(async () => {
- image.setAltText(altText)
-
- const crop = editorRef.current?.getCroppingRect()
-
- await image.manipulate({
- ...(crop !== undefined
- ? {
- crop: {
- originX: crop.x,
- originY: crop.y,
- width: crop.width,
- height: crop.height,
- },
- ...(scale !== 1 ? {scale} : {}),
- ...(position !== undefined ? {position} : {}),
- }
- : {}),
- })
-
- image.prev = image.cropped
- image.prevAttributes = image.attributes
- onCloseModal()
- }, [altText, image, position, scale, onCloseModal])
-
- if (image.cropped === undefined) {
- return null
- }
-
- const computedWidth =
- windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
- const sideLength = isMobile ? computedWidth : 300
-
- const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
- const imgContainerStyles = {width: sideLength, height: sideLength}
-
- const imgControlStyles = {
- alignItems: 'center' as const,
- flexDirection: isMobile ? ('column' as const) : ('row' as const),
- gap: isMobile ? 0 : 5,
- }
-
- return (
-
-
- Edit image
-
-
-
-
-
-
-
- setScale(Array.isArray(v) ? v[0] : v)
- }
- minimumValue={1}
- maximumValue={3}
- />
-
-
- {!isMobile ? (
-
- Ratios
-
- ) : null}
-
- {getKeys(RATIOS).map(ratio => {
- const {icon} = RATIOS[ratio]
- const isSelected = aspectRatio === ratio
-
- return (
- {
- onSetRatio(ratio)
- }}>
-
-
- {ratio}
-
-
- )
- })}
-
- {!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 && (
+ setShowFilters(!showFilters)}
+ hitSlop={HITSLOP_10}
+ label={_(msg`Show advanced filters`)}
+ size="large"
+ variant="solid"
+ color="secondary"
+ shape="square">
+
+
+ )}
+ {showAutocomplete && (
+
-
+
Cancel
-
-
+
+
+ )}
+
+
+ {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"