Merge branch 'main' into inherit_system_theme
This commit is contained in:
commit
09ade363fd
136 changed files with 5771 additions and 2428 deletions
|
@ -143,9 +143,6 @@ export class FeedTuner {
|
|||
}
|
||||
}
|
||||
|
||||
// sort by slice roots' timestamps
|
||||
slices.sort((a, b) => b.ts.localeCompare(a.ts))
|
||||
|
||||
for (const slice of slices) {
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface ExternalEmbedDraft {
|
|||
uri: string
|
||||
isLoading: boolean
|
||||
meta?: LinkMeta
|
||||
embed?: AppBskyEmbedRecord.Main
|
||||
localThumb?: ImageModel
|
||||
}
|
||||
|
||||
|
@ -109,6 +110,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const res = await uploadBlob(store, path, 'image/jpeg')
|
||||
images.push({
|
||||
|
@ -135,40 +137,54 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
}
|
||||
|
||||
if (opts.extLink && !opts.images?.length) {
|
||||
let thumb
|
||||
if (opts.extLink.localThumb) {
|
||||
opts.onStateChange?.('Uploading link thumbnail...')
|
||||
let encoding
|
||||
if (opts.extLink.localThumb.mime) {
|
||||
encoding = opts.extLink.localThumb.mime
|
||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
||||
encoding = 'image/png'
|
||||
} else if (
|
||||
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
||||
opts.extLink.localThumb.path.endsWith('.jpg')
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
store.log.warn(
|
||||
'Unexpected image format for thumbnail, skipping',
|
||||
opts.extLink.localThumb.path,
|
||||
)
|
||||
if (opts.extLink.embed) {
|
||||
embed = opts.extLink.embed
|
||||
} else {
|
||||
let thumb
|
||||
if (opts.extLink.localThumb) {
|
||||
opts.onStateChange?.('Uploading link thumbnail...')
|
||||
let encoding
|
||||
if (opts.extLink.localThumb.mime) {
|
||||
encoding = opts.extLink.localThumb.mime
|
||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
||||
encoding = 'image/png'
|
||||
} else if (
|
||||
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
||||
opts.extLink.localThumb.path.endsWith('.jpg')
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
store.log.warn(
|
||||
'Unexpected image format for thumbnail, skipping',
|
||||
opts.extLink.localThumb.path,
|
||||
)
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
store,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = thumbUploadRes.data.blob
|
||||
}
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
store,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = thumbUploadRes.data.blob
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
title: opts.extLink.meta?.title || '',
|
||||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
|
@ -176,18 +192,8 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
|||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
uri: opts.extLink.uri,
|
||||
title: opts.extLink.meta?.title || '',
|
||||
description: opts.extLink.meta?.description || '',
|
||||
thumb,
|
||||
},
|
||||
} as AppBskyEmbedExternal.Main
|
||||
} as AppBskyEmbedExternal.Main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
import VersionNumber from 'react-native-version-number'
|
||||
|
||||
export const appVersion = VersionNumber.appVersion
|
||||
export const buildVersion = VersionNumber.buildVersion
|
||||
export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
// TODO
|
||||
export const appVersion = 'TODO'
|
||||
export const buildVersion = 'TODO'
|
||||
import {version} from '../../package.json'
|
||||
export const appVersion = version
|
||||
|
|
|
@ -4,6 +4,22 @@ import set from 'lodash.set'
|
|||
|
||||
const ongoingActions = new Set<any>()
|
||||
|
||||
/**
|
||||
* This is a TypeScript function that optimistically updates data on the client-side before sending a
|
||||
* request to the server and rolling back changes if the request fails.
|
||||
* @param {T} model - The object or record that needs to be updated optimistically.
|
||||
* @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
|
||||
* can be used to perform any necessary actions or updates on the model or UI before the server update
|
||||
* is initiated.
|
||||
* @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
|
||||
* update operation. This function is called after the previous state of the model has been recorded
|
||||
* and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
|
||||
* function is called with the result
|
||||
* @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
|
||||
* server update is successful. It takes in the response from the server update as its parameter. If
|
||||
* this parameter is not provided, nothing will happen after the server update.
|
||||
* @returns A Promise that resolves to `void`.
|
||||
*/
|
||||
export const updateDataOptimistically = async <
|
||||
T extends Record<string, any>,
|
||||
U,
|
||||
|
|
|
@ -4,6 +4,8 @@ export const FEEDBACK_FORM_URL =
|
|||
export const MAX_DISPLAY_NAME = 64
|
||||
export const MAX_DESCRIPTION = 256
|
||||
|
||||
export const MAX_GRAPHEME_LENGTH = 300
|
||||
|
||||
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 1000
|
||||
|
@ -94,8 +96,66 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export const STAGING_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
|
||||
export const PROD_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
|
||||
export async function DEFAULT_FEEDS(
|
||||
serviceUrl: string,
|
||||
resolveHandle: (name: string) => Promise<string>,
|
||||
) {
|
||||
if (serviceUrl.includes('localhost')) {
|
||||
// local dev
|
||||
const aliceDid = await resolveHandle('alice.test')
|
||||
return {
|
||||
pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
|
||||
saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
|
||||
}
|
||||
} else if (serviceUrl.includes('staging')) {
|
||||
// staging
|
||||
return {
|
||||
pinned: [STAGING_DEFAULT_FEED('whats-hot')],
|
||||
saved: [
|
||||
STAGING_DEFAULT_FEED('bsky-team'),
|
||||
STAGING_DEFAULT_FEED('with-friends'),
|
||||
STAGING_DEFAULT_FEED('whats-hot'),
|
||||
STAGING_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
// production
|
||||
return {
|
||||
pinned: [
|
||||
PROD_DEFAULT_FEED('whats-hot'),
|
||||
PROD_DEFAULT_FEED('with-friends'),
|
||||
],
|
||||
saved: [
|
||||
PROD_DEFAULT_FEED('bsky-team'),
|
||||
PROD_DEFAULT_FEED('with-friends'),
|
||||
PROD_DEFAULT_FEED('whats-hot'),
|
||||
PROD_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const POST_IMG_MAX = {
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
size: 1000000,
|
||||
}
|
||||
|
||||
export const STAGING_LINK_META_PROXY =
|
||||
'https://cardyb.staging.bsky.dev/v1/extract?url='
|
||||
|
||||
export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
|
||||
|
||||
export function LINK_META_PROXY(serviceUrl: string) {
|
||||
if (serviceUrl.includes('localhost')) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else if (serviceUrl.includes('staging')) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else {
|
||||
return PROD_LINK_META_PROXY
|
||||
}
|
||||
}
|
||||
|
|
40
src/lib/haptics.ts
Normal file
40
src/lib/haptics.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import ReactNativeHapticFeedback, {
|
||||
HapticFeedbackTypes,
|
||||
} from 'react-native-haptic-feedback'
|
||||
|
||||
const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s
|
||||
|
||||
export class Haptics {
|
||||
static default() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
}
|
||||
static impact(type: HapticFeedbackTypes = hapticImpact) {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger(type)
|
||||
}
|
||||
static selection() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger('selection')
|
||||
}
|
||||
static notification = (type: 'success' | 'warning' | 'error') => {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return ReactNativeHapticFeedback.trigger('notificationSuccess')
|
||||
case 'warning':
|
||||
return ReactNativeHapticFeedback.trigger('notificationWarning')
|
||||
case 'error':
|
||||
return ReactNativeHapticFeedback.trigger('notificationError')
|
||||
}
|
||||
}
|
||||
}
|
27
src/lib/hooks/useCustomFeed.ts
Normal file
27
src/lib/hooks/useCustomFeed.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {useEffect, useState} from 'react'
|
||||
import {useStores} from 'state/index'
|
||||
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
|
||||
|
||||
export function useCustomFeed(uri: string): CustomFeedModel | undefined {
|
||||
const store = useStores()
|
||||
const [item, setItem] = useState<CustomFeedModel | undefined>()
|
||||
useEffect(() => {
|
||||
async function fetchView() {
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: uri,
|
||||
})
|
||||
const view = res.data.view
|
||||
return view
|
||||
}
|
||||
async function buildFeedItem() {
|
||||
const view = await fetchView()
|
||||
if (view) {
|
||||
const temp = new CustomFeedModel(store, view)
|
||||
setItem(temp)
|
||||
}
|
||||
}
|
||||
buildFeedItem()
|
||||
}, [store, uri])
|
||||
|
||||
return item
|
||||
}
|
84
src/lib/hooks/useDraggableScrollView.ts
Normal file
84
src/lib/hooks/useDraggableScrollView.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import {useEffect, useRef, useMemo, ForwardedRef} from 'react'
|
||||
import {Platform, findNodeHandle} from 'react-native'
|
||||
import type {ScrollView} from 'react-native'
|
||||
import {mergeRefs} from 'lib/merge-refs'
|
||||
|
||||
type Props<Scrollable extends ScrollView = ScrollView> = {
|
||||
cursor?: string
|
||||
outerRef?: ForwardedRef<Scrollable>
|
||||
}
|
||||
|
||||
export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
|
||||
outerRef,
|
||||
cursor = 'grab',
|
||||
}: Props<Scrollable> = {}) {
|
||||
const ref = useRef<Scrollable>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'web' || !ref.current) {
|
||||
return
|
||||
}
|
||||
const slider = findNodeHandle(ref.current) as unknown as HTMLDivElement
|
||||
if (!slider) {
|
||||
return
|
||||
}
|
||||
let isDragging = false
|
||||
let isMouseDown = false
|
||||
let startX = 0
|
||||
let scrollLeft = 0
|
||||
|
||||
const mouseDown = (e: MouseEvent) => {
|
||||
isMouseDown = true
|
||||
startX = e.pageX - slider.offsetLeft
|
||||
scrollLeft = slider.scrollLeft
|
||||
|
||||
slider.style.cursor = cursor
|
||||
}
|
||||
|
||||
const mouseUp = () => {
|
||||
if (isDragging) {
|
||||
slider.addEventListener('click', e => e.stopPropagation(), {once: true})
|
||||
}
|
||||
|
||||
isMouseDown = false
|
||||
isDragging = false
|
||||
slider.style.cursor = 'default'
|
||||
}
|
||||
|
||||
const mouseMove = (e: MouseEvent) => {
|
||||
if (!isMouseDown) {
|
||||
return
|
||||
}
|
||||
|
||||
// Require n pixels momement before start of drag (3 in this case )
|
||||
const x = e.pageX - slider.offsetLeft
|
||||
if (Math.abs(x - startX) < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
isDragging = true
|
||||
e.preventDefault()
|
||||
const walk = x - startX
|
||||
slider.scrollLeft = scrollLeft - walk
|
||||
}
|
||||
|
||||
slider.addEventListener('mousedown', mouseDown)
|
||||
window.addEventListener('mouseup', mouseUp)
|
||||
window.addEventListener('mousemove', mouseMove)
|
||||
|
||||
return () => {
|
||||
slider.removeEventListener('mousedown', mouseDown)
|
||||
window.removeEventListener('mouseup', mouseUp)
|
||||
window.removeEventListener('mousemove', mouseMove)
|
||||
}
|
||||
}, [cursor])
|
||||
|
||||
const refs = useMemo(
|
||||
() => mergeRefs(outerRef ? [ref, outerRef] : [ref]),
|
||||
[ref, outerRef],
|
||||
)
|
||||
|
||||
return {
|
||||
refs,
|
||||
}
|
||||
}
|
|
@ -6,14 +6,16 @@ export function useNavigationTabState() {
|
|||
const res = {
|
||||
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
||||
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
||||
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
}
|
||||
if (
|
||||
!res.isAtHome &&
|
||||
!res.isAtNotifications &&
|
||||
!res.isAtSearch &&
|
||||
!res.isAtFeeds &&
|
||||
!res.isAtNotifications &&
|
||||
!res.isAtMyProfile
|
||||
) {
|
||||
// HACK for some reason useNavigationState will give us pre-hydration results
|
||||
|
|
|
@ -1,25 +1,56 @@
|
|||
import {useState} from 'react'
|
||||
import {useState, useCallback, useRef} from 'react'
|
||||
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
const DY_LIMIT = isDesktopWeb ? 30 : 10
|
||||
|
||||
export type OnScrollCb = (
|
||||
event: NativeSyntheticEvent<NativeScrollEvent>,
|
||||
) => void
|
||||
export type ResetCb = () => void
|
||||
|
||||
export function useOnMainScroll(store: RootStoreModel) {
|
||||
let [lastY, setLastY] = useState(0)
|
||||
let isMinimal = store.shell.minimalShellMode
|
||||
return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY || 0)
|
||||
setLastY(y)
|
||||
export function useOnMainScroll(
|
||||
store: RootStoreModel,
|
||||
): [OnScrollCb, boolean, ResetCb] {
|
||||
let lastY = useRef(0)
|
||||
let [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
return [
|
||||
useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = event.nativeEvent.contentOffset.y
|
||||
const dy = y - (lastY.current || 0)
|
||||
lastY.current = y
|
||||
|
||||
if (!isMinimal && y > 10 && dy > 10) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
isMinimal = true
|
||||
} else if (isMinimal && (y <= 10 || dy < -10)) {
|
||||
if (!store.shell.minimalShellMode && y > 10 && dy > DY_LIMIT) {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
} else if (
|
||||
store.shell.minimalShellMode &&
|
||||
(y <= 10 || dy < DY_LIMIT * -1)
|
||||
) {
|
||||
store.shell.setMinimalShellMode(false)
|
||||
}
|
||||
|
||||
if (
|
||||
!isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y > s.window.height
|
||||
) {
|
||||
setIsScrolledDown(true)
|
||||
} else if (
|
||||
isScrolledDown &&
|
||||
event.nativeEvent.contentOffset.y < s.window.height
|
||||
) {
|
||||
setIsScrolledDown(false)
|
||||
}
|
||||
},
|
||||
[store, isScrolledDown],
|
||||
),
|
||||
isScrolledDown,
|
||||
useCallback(() => {
|
||||
setIsScrolledDown(false)
|
||||
store.shell.setMinimalShellMode(false)
|
||||
isMinimal = false
|
||||
}
|
||||
}
|
||||
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
|
||||
}, [store, setIsScrolledDown]),
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
|
||||
import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
|
@ -88,7 +88,7 @@ export function HomeIconSolid({
|
|||
<Path
|
||||
fill="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
|
||||
d="m 23.951,2 c -0.32,0.011 -0.628,0.124 -0.879,0.322 L 8.859,13.52 C 7.055,14.941 6,17.114 6,19.41 V 38.5 C 6,39.864 7.136,41 8.5,41 h 8 c 1.364,0 2.5,-1.136 2.5,-2.5 v -12 C 19,26.205 19.205,26 19.5,26 h 9 c 0.295,0 0.5,0.205 0.5,0.5 v 12 c 0,1.364 1.136,2.5 2.5,2.5 h 8 C 40.864,41 42,39.864 42,38.5 V 19.41 c 0,-2.296 -1.055,-4.469 -2.859,-5.89 L 24.928,2.322 C 24.65,2.103 24.304,1.989 23.951,2 Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
|
@ -472,7 +472,7 @@ export function HeartIcon({
|
|||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth: number
|
||||
}) {
|
||||
|
@ -493,7 +493,7 @@ export function HeartIconSolid({
|
|||
style,
|
||||
size = 24,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
}) {
|
||||
return (
|
||||
|
@ -883,3 +883,77 @@ export function HandIcon({
|
|||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SatelliteDishIconSolid({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
viewBox="0 0 22 22"
|
||||
style={style}
|
||||
fill="none"
|
||||
stroke="none">
|
||||
<Path
|
||||
d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Path
|
||||
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Circle cx="10" cy="12" r="2" fill="currentColor" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SatelliteDishIcon({
|
||||
style,
|
||||
size,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
style?: StyleProp<TextStyle>
|
||||
size?: string | number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 22 22"
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" />
|
||||
<Path
|
||||
d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
title: 'Violent / Bloody',
|
||||
subtitle: 'Gore, self-harm, torture',
|
||||
warning: 'Violence',
|
||||
values: ['gore', 'self-harm', 'torture', 'nsfl'],
|
||||
values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
|
||||
isAdultImagery: true,
|
||||
},
|
||||
hate: {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import * as apilib from 'lib/api/index'
|
||||
import {LikelyType, LinkMeta} from './link-meta'
|
||||
// import {match as matchRoute} from 'view/routes'
|
||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||
|
@ -128,3 +129,29 @@ export async function getPostAsQuote(
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeedAsEmbed(
|
||||
store: RootStoreModel,
|
||||
url: string,
|
||||
): Promise<apilib.ExternalEmbedDraft> {
|
||||
url = convertBskyAppUrlIfNeeded(url)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
|
||||
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
|
||||
return {
|
||||
isLoading: false,
|
||||
uri: feed,
|
||||
meta: {
|
||||
url: feed,
|
||||
likelyType: LikelyType.AtpData,
|
||||
title: res.data.view.displayName,
|
||||
},
|
||||
embed: {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
uri: res.data.view.uri,
|
||||
cid: res.data.view.cid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import he from 'he'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
import {extractHtmlMeta} from './html'
|
||||
import {LINK_META_PROXY} from 'lib/constants'
|
||||
|
||||
export enum LikelyType {
|
||||
HTML,
|
||||
|
@ -54,26 +53,29 @@ export async function getLinkMeta(
|
|||
try {
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(() => controller.abort(), timeout || 5e3)
|
||||
const httpRes = await fetch(url, {
|
||||
headers: {accept: 'text/html'},
|
||||
signal: controller.signal,
|
||||
})
|
||||
const httpResBody = await httpRes.text()
|
||||
|
||||
const response = await fetch(
|
||||
`${LINK_META_PROXY(
|
||||
store.session.currentSession?.service || '',
|
||||
)}${encodeURIComponent(url)}`,
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
clearTimeout(to)
|
||||
const httpResMeta = extractHtmlMeta({
|
||||
html: httpResBody,
|
||||
hostname: urlp?.hostname,
|
||||
pathname: urlp?.pathname,
|
||||
})
|
||||
meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined
|
||||
meta.description = httpResMeta.description
|
||||
? he.decode(httpResMeta.description)
|
||||
: undefined
|
||||
meta.image = httpResMeta.image
|
||||
|
||||
const {description, error, image, title} = body
|
||||
|
||||
if (error !== '') {
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
meta.description = description
|
||||
meta.image = image
|
||||
meta.title = title
|
||||
} catch (e) {
|
||||
// failed
|
||||
console.error(e)
|
||||
meta.error = 'Failed to fetch link'
|
||||
meta.error = e instanceof Error ? e.toString() : 'Failed to fetch link'
|
||||
}
|
||||
|
||||
return meta
|
||||
|
|
|
@ -6,52 +6,8 @@ import * as RNFS from 'react-native-fs'
|
|||
import uuid from 'react-native-uuid'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import {Dimensions} from './types'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {isAndroid, isIOS} from 'platform/detection'
|
||||
|
||||
export async function compressAndResizeImageForPost(
|
||||
image: Image,
|
||||
): Promise<Image> {
|
||||
const uri = `file://${image.path}`
|
||||
let resized: Omit<Image, 'mime'>
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const quality = 100 - i * 10
|
||||
|
||||
try {
|
||||
resized = await ImageResizer.createResizedImage(
|
||||
uri,
|
||||
POST_IMG_MAX.width,
|
||||
POST_IMG_MAX.height,
|
||||
'JPEG',
|
||||
quality,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{mode: 'cover'},
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to resize: ${err}`)
|
||||
}
|
||||
|
||||
if (resized.size < POST_IMG_MAX.size) {
|
||||
const path = await moveToPermanentPath(resized.path)
|
||||
|
||||
return {
|
||||
path,
|
||||
mime: 'image/jpeg',
|
||||
size: resized.size,
|
||||
height: resized.height,
|
||||
width: resized.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: Image,
|
||||
maxSize: number = 1000000,
|
||||
|
|
|
@ -1,25 +1,6 @@
|
|||
import {Dimensions} from './types'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {getDataUriSize, blobToDataUri} from './util'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
|
||||
export async function compressAndResizeImageForPost({
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
}): Promise<RNImage> {
|
||||
// Compression is handled in `doResize` via `quality`
|
||||
return await doResize(path, {
|
||||
width,
|
||||
height,
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
mode: 'stretch',
|
||||
})
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: RNImage,
|
||||
|
|
|
@ -2,7 +2,7 @@ import {RootStoreModel} from 'state/index'
|
|||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {CropperOptions} from './types'
|
||||
import {compressAndResizeImageForPost} from './manip'
|
||||
import {compressIfNeeded} from './manip'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
|
@ -13,7 +13,7 @@ async function getFile() {
|
|||
.join('/'),
|
||||
)
|
||||
const file = files[_imageCounter++ % files.length]
|
||||
return await compressAndResizeImageForPost({
|
||||
return await compressIfNeeded({
|
||||
path: file.path,
|
||||
mime: 'image/jpeg',
|
||||
size: file.size,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import {Dimensions} from './types'
|
||||
|
||||
export function extractDataUriMime(uri: string): string {
|
||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||
}
|
||||
|
@ -10,21 +8,6 @@ export function getDataUriSize(uri: string): number {
|
|||
return Math.round((uri.length * 3) / 4)
|
||||
}
|
||||
|
||||
export function scaleDownDimensions(
|
||||
dim: Dimensions,
|
||||
max: Dimensions,
|
||||
): Dimensions {
|
||||
if (dim.width < max.width && dim.height < max.height) {
|
||||
return dim
|
||||
}
|
||||
const wScale = dim.width > max.width ? max.width / dim.width : 1
|
||||
const hScale = dim.height > max.height ? max.height / dim.height : 1
|
||||
if (wScale < hScale) {
|
||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
||||
}
|
||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
}
|
||||
|
||||
export function isUriImage(uri: string) {
|
||||
return /\.(jpg|jpeg|png).*$/.test(uri)
|
||||
}
|
||||
|
|
27
src/lib/merge-refs.ts
Normal file
27
src/lib/merge-refs.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* This TypeScript function merges multiple React refs into a single ref callback.
|
||||
* When developing low level UI components, it is common to have to use a local ref
|
||||
* but also support an external one using React.forwardRef.
|
||||
* Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility.
|
||||
* Today a ref can be a function or an object, tomorrow it could be another thing, who knows.
|
||||
* This utility handles compatibility for you.
|
||||
* This function is inspired by https://github.com/gregberge/react-merge-refs
|
||||
* @param refs - An array of React refs, which can be either `React.MutableRefObject<T>` or
|
||||
* `React.LegacyRef<T>`. These refs are used to store references to DOM elements or React components.
|
||||
* The `mergeRefs` function takes in an array of these refs and returns a callback function that
|
||||
* @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and
|
||||
* returns a ref callback function that can be used to merge multiple refs into a single ref.
|
||||
*/
|
||||
export function mergeRefs<T = any>(
|
||||
refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>,
|
||||
): React.RefCallback<T> {
|
||||
return value => {
|
||||
refs.forEach(ref => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value)
|
||||
} else if (ref != null) {
|
||||
;(ref as React.MutableRefObject<T | null>).current = value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -41,26 +41,26 @@ export function displayNotification(
|
|||
}
|
||||
|
||||
export function displayNotificationFromModel(
|
||||
notif: NotificationsFeedItemModel,
|
||||
notification: NotificationsFeedItemModel,
|
||||
) {
|
||||
let author = sanitizeDisplayName(
|
||||
notif.author.displayName || notif.author.handle,
|
||||
notification.author.displayName || notification.author.handle,
|
||||
)
|
||||
let title: string
|
||||
let body: string = ''
|
||||
if (notif.isLike) {
|
||||
if (notification.isLike) {
|
||||
title = `${author} liked your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isRepost) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isRepost) {
|
||||
title = `${author} reposted your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isMention) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isMention) {
|
||||
title = `${author} mentioned you`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isReply) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isReply) {
|
||||
title = `${author} replied to your post`
|
||||
body = notif.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notif.isFollow) {
|
||||
body = notification.additionalPost?.thread?.postRecord?.text || ''
|
||||
} else if (notification.isFollow) {
|
||||
title = 'New follower!'
|
||||
body = `${author} has followed you`
|
||||
} else {
|
||||
|
@ -68,10 +68,12 @@ export function displayNotificationFromModel(
|
|||
}
|
||||
let image
|
||||
if (
|
||||
AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) &&
|
||||
notif.additionalPost?.thread?.post.embed.images[0]?.thumb
|
||||
AppBskyEmbedImages.isView(
|
||||
notification.additionalPost?.thread?.post.embed,
|
||||
) &&
|
||||
notification.additionalPost?.thread?.post.embed.images[0]?.thumb
|
||||
) {
|
||||
image = notif.additionalPost.thread.post.embed.images[0].thumb
|
||||
image = notification.additionalPost.thread.post.embed.images[0].thumb
|
||||
}
|
||||
return displayNotification(title, body, image)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ export function getCurrentRoute(state: State) {
|
|||
export function isStateAtTabRoot(state: State | undefined) {
|
||||
if (!state) {
|
||||
// NOTE
|
||||
// if state is not defined it's because init is occuring
|
||||
// if state is not defined it's because init is occurring
|
||||
// and therefore we can safely assume we're at root
|
||||
// -prf
|
||||
return true
|
||||
|
|
|
@ -9,6 +9,7 @@ export type CommonNavigatorParams = {
|
|||
ModerationMuteLists: undefined
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
DiscoverFeeds: undefined
|
||||
Settings: undefined
|
||||
Profile: {name: string; hideBackButton?: boolean}
|
||||
ProfileFollowers: {name: string}
|
||||
|
@ -17,6 +18,8 @@ export type CommonNavigatorParams = {
|
|||
PostThread: {name: string; rkey: string}
|
||||
PostLikedBy: {name: string; rkey: string}
|
||||
PostRepostedBy: {name: string; rkey: string}
|
||||
CustomFeed: {name: string; rkey: string}
|
||||
CustomFeedLikedBy: {name: string; rkey: string}
|
||||
Debug: undefined
|
||||
Log: undefined
|
||||
Support: undefined
|
||||
|
@ -25,11 +28,13 @@ export type CommonNavigatorParams = {
|
|||
CommunityGuidelines: undefined
|
||||
CopyrightPolicy: undefined
|
||||
AppPasswords: undefined
|
||||
SavedFeeds: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
HomeTab: undefined
|
||||
SearchTab: undefined
|
||||
FeedsTab: undefined
|
||||
NotificationsTab: undefined
|
||||
MyProfileTab: undefined
|
||||
}
|
||||
|
@ -42,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
|||
Search: {q?: string}
|
||||
}
|
||||
|
||||
export type FeedsTabNavigatorParams = CommonNavigatorParams & {
|
||||
Feeds: undefined
|
||||
}
|
||||
|
||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||
Notifications: undefined
|
||||
}
|
||||
|
@ -53,6 +62,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
|||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Home: undefined
|
||||
Search: {q?: string}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
}
|
||||
|
||||
|
@ -61,6 +71,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
|||
Home: undefined
|
||||
SearchTab: undefined
|
||||
Search: {q?: string}
|
||||
FeedsTab: undefined
|
||||
Feeds: undefined
|
||||
NotificationsTab: undefined
|
||||
Notifications: undefined
|
||||
MyProfileTab: undefined
|
||||
|
|
|
@ -27,7 +27,7 @@ export function detectLinkables(text: string): DetectedLinkable[] {
|
|||
matchValue = matchValue.slice(1)
|
||||
}
|
||||
|
||||
// strip ending puncuation
|
||||
// strip ending punctuation
|
||||
if (/[.,;!?]$/.test(matchValue)) {
|
||||
matchValue = matchValue.slice(0, -1)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
const MINUTE = 60
|
||||
const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH = DAY * 28
|
||||
const YEAR = DAY * 365
|
||||
const WEEK = DAY * 7
|
||||
|
||||
export function ago(date: number | string | Date): string {
|
||||
let ts: number
|
||||
if (typeof date === 'string') {
|
||||
|
@ -19,12 +19,14 @@ export function ago(date: number | string | Date): string {
|
|||
return `${Math.floor(diffSeconds / MINUTE)}m`
|
||||
} else if (diffSeconds < DAY) {
|
||||
return `${Math.floor(diffSeconds / HOUR)}h`
|
||||
} else if (diffSeconds < MONTH) {
|
||||
} else if (diffSeconds < WEEK) {
|
||||
return `${Math.floor(diffSeconds / DAY)}d`
|
||||
} else if (diffSeconds < YEAR) {
|
||||
return `${Math.floor(diffSeconds / MONTH)}mo`
|
||||
} else {
|
||||
return new Date(ts).toLocaleDateString()
|
||||
return new Date(ts).toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -82,6 +82,18 @@ export function isBskyPostUrl(url: string): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
export function isBskyCustomFeedUrl(url: string): boolean {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test(
|
||||
urlp.pathname,
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {Theme, TypographyVariant} from './ThemeContext'
|
||||
import {isMobileWeb} from 'platform/detection'
|
||||
|
||||
|
@ -52,6 +52,7 @@ export const colors = {
|
|||
green5: '#082b03',
|
||||
|
||||
unreadNotifBg: '#ebf6ff',
|
||||
brandBlue: '#0066FF',
|
||||
}
|
||||
|
||||
export const gradients = {
|
||||
|
@ -169,6 +170,10 @@ export const s = StyleSheet.create({
|
|||
w100pct: {width: '100%'},
|
||||
h100pct: {height: '100%'},
|
||||
hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'},
|
||||
window: {
|
||||
width: Dimensions.get('window').width,
|
||||
height: Dimensions.get('window').height,
|
||||
},
|
||||
|
||||
// text align
|
||||
textLeft: {textAlign: 'left'},
|
||||
|
@ -214,6 +219,8 @@ export const s = StyleSheet.create({
|
|||
green3: {color: colors.green3},
|
||||
green4: {color: colors.green4},
|
||||
green5: {color: colors.green5},
|
||||
|
||||
brandBlue: {color: colors.brandBlue},
|
||||
})
|
||||
|
||||
export function lh(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue