Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae356a2d2 | |||
| faa9473032 | |||
| 743f2ea497 | |||
| e1a0fb80f9 | |||
| 3ba894a851 |
@@ -119,7 +119,6 @@ module.exports = function (_config) {
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@expo/metro-runtime'
|
||||
import '#/platform/markBundleStartTime'
|
||||
import '#/platform/polyfills'
|
||||
|
||||
|
||||
+2
-1
@@ -73,7 +73,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.20",
|
||||
"@atproto/api": "^0.18.18",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
@@ -235,6 +235,7 @@
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@expo/config-plugins": "~54.0.1",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useMemo} from 'react'
|
||||
import React from 'react'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
@@ -27,16 +27,6 @@ export type RichTextProps = TextStyleProp &
|
||||
interactiveStyle?: StyleProp<TextStyle>
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
/**
|
||||
* DANGEROUS: Disable facet lexicon validation
|
||||
*
|
||||
* `detectFacetsWithoutResolution()` generates technically invalid facets,
|
||||
* with a handle in place of the DID. This means that RichText that uses it
|
||||
* won't be able to render links.
|
||||
*
|
||||
* Use with care - only use if you're rendering facets you're generating yourself.
|
||||
*/
|
||||
disableMentionFacetValidation?: true
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -54,17 +44,12 @@ export function RichText({
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
shouldProxyLinks,
|
||||
disableMentionFacetValidation,
|
||||
}: RichTextProps) {
|
||||
const richText = useMemo(() => {
|
||||
if (value instanceof RichTextAPI) {
|
||||
return value
|
||||
} else {
|
||||
const rt = new RichTextAPI({text: value})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}
|
||||
}, [value])
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
|
||||
const plainStyles = [a.leading_snug, style]
|
||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||
@@ -113,11 +98,9 @@ export function RichText({
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
|
||||
if (
|
||||
mention &&
|
||||
(disableMentionFacetValidation ||
|
||||
AppBskyRichtextFacet.validateMention(mention).success) &&
|
||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||
!disableLinks
|
||||
) {
|
||||
els.push(
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as Device from 'expo-device'
|
||||
|
||||
import * as env from '#/env'
|
||||
|
||||
export const FALLBACK_ANDROID = 'Android'
|
||||
export const FALLBACK_IOS = 'iOS'
|
||||
export const FALLBACK_WEB = 'Web'
|
||||
|
||||
export function getDeviceName(): string {
|
||||
const deviceName = Device.deviceName
|
||||
if (env.IS_ANDROID) {
|
||||
return deviceName || FALLBACK_ANDROID
|
||||
} else if (env.IS_IOS) {
|
||||
return deviceName || FALLBACK_IOS
|
||||
} else {
|
||||
return FALLBACK_WEB // could append browser info here
|
||||
}
|
||||
}
|
||||
@@ -303,7 +303,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
@@ -318,7 +318,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
|
||||
@@ -343,7 +343,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
@@ -358,7 +358,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
|
||||
+16
-17
@@ -270,29 +270,27 @@ async function moveIfNecessary(from: string) {
|
||||
* On web, converts blob URLs to data URIs immediately to prevent revocation issues.
|
||||
*/
|
||||
async function copyToCache(from: string): Promise<string> {
|
||||
// Handle web blob URLs - convert to data URI immediately before they can be revoked
|
||||
if (IS_WEB && from.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(from)
|
||||
const blob = await response.blob()
|
||||
return await blobToDataUri(blob)
|
||||
} catch (e) {
|
||||
// If fetch fails, the blob URL was likely already revoked
|
||||
// Return as-is and let downstream code handle the error
|
||||
return from
|
||||
}
|
||||
}
|
||||
|
||||
// Data URIs don't need any conversion
|
||||
if (from.startsWith('data:')) {
|
||||
return from
|
||||
}
|
||||
|
||||
if (IS_WEB) {
|
||||
// Web: convert blob URLs to data URIs before they can be revoked
|
||||
if (from.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(from)
|
||||
const blob = await response.blob()
|
||||
return await blobToDataUri(blob)
|
||||
} catch (e) {
|
||||
// Blob URL was likely revoked, return as-is for downstream error handling
|
||||
return from
|
||||
}
|
||||
}
|
||||
// Other URLs on web don't need conversion
|
||||
return from
|
||||
}
|
||||
const cacheDir = IS_WEB && getImageCacheDirectory()
|
||||
|
||||
// Native: copy to cache directory to survive OS temp file cleanup
|
||||
const cacheDir = getImageCacheDirectory()
|
||||
// On web (non-blob URLs) or if already in cache dir, no need to copy
|
||||
if (!cacheDir || from.startsWith(cacheDir)) {
|
||||
return from
|
||||
}
|
||||
@@ -300,6 +298,7 @@ async function copyToCache(from: string): Promise<string> {
|
||||
const to = joinPath(cacheDir, nanoid(36))
|
||||
await makeDirectoryAsync(cacheDir, {intermediates: true})
|
||||
|
||||
// Normalize the source path for expo-file-system
|
||||
let normalizedFrom = from
|
||||
if (!from.startsWith('file://') && from.startsWith('/')) {
|
||||
normalizedFrom = `file://${from}`
|
||||
|
||||
@@ -138,7 +138,7 @@ import {
|
||||
type RestoredVideo,
|
||||
} from './drafts/state/api'
|
||||
import {
|
||||
loadDraftMedia,
|
||||
loadDraft,
|
||||
useCleanupPublishedDraftMutation,
|
||||
useSaveDraftMutation,
|
||||
} from './drafts/state/queries'
|
||||
@@ -482,7 +482,7 @@ export const ComposePost = ({
|
||||
})
|
||||
|
||||
// Load local media files for the draft
|
||||
const {loadedMedia} = await loadDraftMedia(draftSummary.draft)
|
||||
const {loadedMedia} = await loadDraft(draftSummary.draft)
|
||||
|
||||
// Extract original localRefs for orphan detection on save
|
||||
const originalLocalRefs = extractLocalRefs(draftSummary.draft)
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import * as VideoThumbnails from 'expo-video-thumbnails'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as device from '#/lib/deviceName'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||
import {logger} from '#/view/com/composer/drafts/state/logger'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {type DraftPostDisplay, type DraftSummary} from './state/schema'
|
||||
@@ -34,34 +32,6 @@ export function DraftItem({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
const post = draft.posts[0]
|
||||
|
||||
const mediaExistsOnOtherDevice =
|
||||
!draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
|
||||
const mediaIsMissing =
|
||||
draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
|
||||
const hasMetadata =
|
||||
draft.meta.replyCount > 0 ||
|
||||
mediaExistsOnOtherDevice ||
|
||||
draft.meta.hasQuotes
|
||||
|
||||
const deviceName = useMemo(() => {
|
||||
const raw = draft.draft.deviceName
|
||||
let name = raw
|
||||
switch (raw) {
|
||||
case device.FALLBACK_IOS:
|
||||
case device.FALLBACK_ANDROID:
|
||||
case device.FALLBACK_WEB:
|
||||
name = _(
|
||||
msg({
|
||||
message: `another device`,
|
||||
comment: `Prefixed with "This media is stored on...". Example: "This media is stored on another device"`,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
return name
|
||||
}, [_, draft])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(draft)
|
||||
@@ -69,169 +39,48 @@ export function DraftItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.relative]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Open draft`)}
|
||||
accessibilityHint={_(msg`Opens this draft in the composer`)}
|
||||
onPress={() => onSelect(draft)}
|
||||
style={({pressed, hovered}) => [
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.shadow_sm,
|
||||
pressed || hovered
|
||||
? t.atoms.border_contrast_medium
|
||||
: t.atoms.border_contrast_low,
|
||||
{
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.atoms.bg.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_25.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_25.backgroundColor,
|
||||
}),
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.p_lg,
|
||||
a.pb_md,
|
||||
a.gap_sm,
|
||||
{
|
||||
paddingTop: 20 + a.pt_md.paddingTop,
|
||||
},
|
||||
]}>
|
||||
<RichText
|
||||
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
|
||||
value={post.text}
|
||||
enableTags
|
||||
disableMentionFacetValidation
|
||||
/>
|
||||
|
||||
{!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
|
||||
|
||||
{hasMetadata && (
|
||||
<View style={[a.gap_xs]}>
|
||||
{mediaExistsOnOtherDevice && (
|
||||
<DraftMetadataTag
|
||||
icon={WarningIcon}
|
||||
text={_(
|
||||
msg({
|
||||
message: `Media stored on ${deviceName}`,
|
||||
comment: `This media is stored on... Example: "This media is stored on John's iPhone"`,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{mediaIsMissing && (
|
||||
<DraftMetadataTag
|
||||
display="warning"
|
||||
icon={WarningIcon}
|
||||
text={_(msg`Missing media`)}
|
||||
/>
|
||||
)}
|
||||
{draft.meta.hasQuotes && (
|
||||
<DraftMetadataTag
|
||||
icon={CloseQuoteIcon}
|
||||
text={_(msg`Quote post`)}
|
||||
/>
|
||||
)}
|
||||
{draft.meta.replyCount > 0 && (
|
||||
<DraftMetadataTag
|
||||
icon={CirclePlusIcon}
|
||||
text={plural(draft.meta.replyCount, {
|
||||
one: '1 more post',
|
||||
other: '# more posts',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Timestamp */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.pointer_events_none,
|
||||
{
|
||||
top: a.pt_md.paddingTop,
|
||||
left: a.pl_lg.paddingLeft,
|
||||
},
|
||||
]}>
|
||||
<TimeElapsed timestamp={draft.updatedAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_tight,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{timeElapsed}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Open draft`)}
|
||||
accessibilityHint={_(msg`Opens this draft in the composer`)}
|
||||
onPress={() => onSelect(draft)}
|
||||
style={({pressed, hovered}) => [
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.shadow_sm,
|
||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.p_md, a.gap_sm]}>
|
||||
{draft.hasMissingMedia && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_sm,
|
||||
a.py_xs,
|
||||
a.mb_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Some media unavailable (saved on another device)</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Menu button */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: a.pt_md.paddingTop,
|
||||
right: a.pr_md.paddingRight,
|
||||
},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`More options`)}
|
||||
hitSlop={8}
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
discardPromptControl.open()
|
||||
}}
|
||||
style={[
|
||||
a.pointer,
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
]}>
|
||||
{({pressed, hovered}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
top: -4,
|
||||
bottom: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
backgroundColor:
|
||||
pressed || hovered
|
||||
? select(t.name, {
|
||||
light: t.atoms.bg_contrast_50.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||
})
|
||||
: 'transparent',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DotsIcon
|
||||
width={16}
|
||||
fill={t.atoms.text_contrast_low.color}
|
||||
style={[a.z_20]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{draft.posts.map((post, index) => (
|
||||
<DraftPostRow
|
||||
key={post.id}
|
||||
post={post}
|
||||
isFirst={index === 0}
|
||||
isLast={index === draft.posts.length - 1}
|
||||
timestamp={draft.updatedAt}
|
||||
discardPromptControl={discardPromptControl}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
@@ -245,28 +94,125 @@ export function DraftItem({
|
||||
)
|
||||
}
|
||||
|
||||
function DraftMetadataTag({
|
||||
display = 'info',
|
||||
icon: Icon,
|
||||
text,
|
||||
function DraftPostRow({
|
||||
post,
|
||||
isFirst,
|
||||
isLast,
|
||||
timestamp,
|
||||
discardPromptControl,
|
||||
}: {
|
||||
display?: 'info' | 'warning'
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
text: string
|
||||
post: DraftPostDisplay
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
timestamp: string
|
||||
discardPromptControl: Prompt.PromptControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const color = {
|
||||
info: t.atoms.text_contrast_medium.color,
|
||||
warning: select(t.name, {
|
||||
light: '#C99A00',
|
||||
dark: '#FFC404',
|
||||
dim: '#FFC404',
|
||||
}),
|
||||
}[display]
|
||||
const profile = useCurrentAccountProfile()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Icon size="sm" fill={color} />
|
||||
<Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.align_center]}>
|
||||
<UserAvatar type="user" size={42} avatar={profile?.avatar} />
|
||||
{!isLast && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_xs,
|
||||
{
|
||||
width: 2,
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
minHeight: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1, a.gap_xs]}>
|
||||
{profile && (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{createSanitizedDisplayName(profile)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
·
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<TimeElapsed timestamp={timestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
|
||||
{isFirst && (
|
||||
<Button
|
||||
label={_(msg`More options`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
size="tiny"
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
discardPromptControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={DotsIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{post.text ? (
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text]}>
|
||||
{post.text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.italic,
|
||||
]}>
|
||||
<Trans>(No text)</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<DraftMediaPreview post={post} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -325,7 +271,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaPreview.Outer>
|
||||
<MediaPreview.Outer style={[a.pt_xs]}>
|
||||
{loadedImages.map((image, i) => (
|
||||
<MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
|
||||
))}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {atoms as a, select, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
|
||||
@@ -26,7 +26,6 @@ export function DraftsListDialog({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const ax = useAnalytics()
|
||||
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
|
||||
useDraftsQuery()
|
||||
@@ -92,7 +91,7 @@ export function DraftsListDialog({
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: DraftSummary}) => {
|
||||
return (
|
||||
<View style={[gtPhone ? [a.px_md, a.pt_md] : [a.px_sm, a.pt_sm]]}>
|
||||
<View style={[a.px_lg, a.mt_lg]}>
|
||||
<DraftItem
|
||||
draft={item}
|
||||
onSelect={handleSelectDraft}
|
||||
@@ -101,7 +100,7 @@ export function DraftsListDialog({
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[handleSelectDraft, handleDeleteDraft, gtPhone],
|
||||
[handleSelectDraft, handleDeleteDraft],
|
||||
)
|
||||
|
||||
const header = useMemo(
|
||||
@@ -163,17 +162,7 @@ export function DraftsListDialog({
|
||||
ListFooterComponent={footerComponent}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
style={[
|
||||
a.px_0,
|
||||
web({minHeight: 500}),
|
||||
{
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_50,
|
||||
dark: t.palette.contrast_0,
|
||||
dim: '#000000',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
style={[t.atoms.bg_contrast_50, a.px_0, web({minHeight: 500})]}
|
||||
webInnerContentContainerStyle={[a.py_0]}
|
||||
contentContainerStyle={[a.pb_xl]}
|
||||
/>
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
/**
|
||||
* Type converters for Draft API - convert between ComposerState and server Draft types.
|
||||
*/
|
||||
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {type AppBskyDraftDefs, RichText} from '@atproto/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {resolveLink} from '#/lib/api/resolve'
|
||||
import {getDeviceName} from '#/lib/deviceName'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
import {mimeToExt} from '#/lib/media/video/util'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import {type Gif} from '#/state/queries/tenor'
|
||||
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||
import {createPublicAgent} from '#/state/session/agent'
|
||||
import {
|
||||
type ComposerState,
|
||||
type EmbedDraft,
|
||||
type PostDraft,
|
||||
} from '#/view/com/composer/state/composer'
|
||||
import {type VideoState} from '#/view/com/composer/state/video'
|
||||
import {type AnalyticsContextType} from '#/analytics'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {logger} from './logger'
|
||||
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
||||
import * as storage from './storage'
|
||||
|
||||
const TENOR_HOSTNAME = 'media.tenor.com'
|
||||
|
||||
@@ -68,14 +61,33 @@ export async function composerStateToDraft(state: ComposerState): Promise<{
|
||||
}),
|
||||
)
|
||||
|
||||
// Convert threadgate settings to server format
|
||||
const threadgateAllow: AppBskyDraftDefs.Draft['threadgateAllow'] = []
|
||||
for (const setting of state.thread.threadgate) {
|
||||
if (setting.type === 'mention') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#mentionRule' as const,
|
||||
})
|
||||
} else if (setting.type === 'following') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#followingRule' as const,
|
||||
})
|
||||
} else if (setting.type === 'followers') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#followerRule' as const,
|
||||
})
|
||||
} else if (setting.type === 'list') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#listRule' as const,
|
||||
list: setting.list,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draft: AppBskyDraftDefs.Draft = {
|
||||
$type: 'app.bsky.draft.defs#draft',
|
||||
deviceId: getDeviceId(),
|
||||
deviceName: getDeviceName().slice(0, 100), // max length of 100 in lex
|
||||
posts,
|
||||
threadgateAllow: threadgateAllowUISettingToAllowRecordValue(
|
||||
state.thread.threadgate,
|
||||
),
|
||||
threadgateAllow: threadgateAllow.length > 0 ? threadgateAllow : undefined,
|
||||
postgateEmbeddingRules:
|
||||
state.thread.postgate.embeddingRules &&
|
||||
state.thread.postgate.embeddingRules.length > 0
|
||||
@@ -128,21 +140,15 @@ async function postDraftToServerPost(
|
||||
|
||||
// Add quote record embed
|
||||
if (post.embed.quote) {
|
||||
const resolved = await resolveLink(
|
||||
createPublicAgent(),
|
||||
post.embed.quote.uri,
|
||||
)
|
||||
if (resolved && resolved.type === 'record') {
|
||||
draftPost.embedRecords = [
|
||||
{
|
||||
$type: 'app.bsky.draft.defs#draftEmbedRecord',
|
||||
record: {
|
||||
uri: resolved.record.uri,
|
||||
cid: resolved.record.cid,
|
||||
},
|
||||
draftPost.embedRecords = [
|
||||
{
|
||||
$type: 'app.bsky.draft.defs#draftEmbedRecord',
|
||||
record: {
|
||||
uri: post.embed.quote.uri,
|
||||
cid: '', // We don't have the CID at draft time
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Add external link embed (only if no media, otherwise it's ignored)
|
||||
@@ -271,24 +277,16 @@ function serializeGif(gifMedia: {
|
||||
* Convert server DraftView to DraftSummary for list display.
|
||||
* Also checks which media files exist locally.
|
||||
*/
|
||||
export function draftViewToSummary({
|
||||
view,
|
||||
analytics,
|
||||
}: {
|
||||
view: AppBskyDraftDefs.DraftView
|
||||
analytics: AnalyticsContextType
|
||||
}): DraftSummary {
|
||||
const meta = {
|
||||
isOriginatingDevice: view.draft.deviceId === getDeviceId(),
|
||||
postCount: view.draft.posts.length,
|
||||
// minus anchor post
|
||||
replyCount: view.draft.posts.length - 1,
|
||||
hasMedia: false,
|
||||
hasMissingMedia: false,
|
||||
mediaCount: 0,
|
||||
hasQuotes: false,
|
||||
quoteCount: 0,
|
||||
}
|
||||
export function draftViewToSummary(
|
||||
view: AppBskyDraftDefs.DraftView,
|
||||
localMediaExists: (path: string) => boolean,
|
||||
): DraftSummary {
|
||||
const firstPost = view.draft.posts[0]
|
||||
const previewText = firstPost?.text?.slice(0, 100) || ''
|
||||
|
||||
let mediaCount = 0
|
||||
let hasMedia = false
|
||||
let hasMissingMedia = false
|
||||
|
||||
const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
|
||||
const images: DraftPostDisplay['images'] = []
|
||||
@@ -298,11 +296,11 @@ export function draftViewToSummary({
|
||||
// Process images
|
||||
if (post.embedImages) {
|
||||
for (const img of post.embedImages) {
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(img.localRef.path)
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
const exists = localMediaExists(img.localRef.path)
|
||||
if (!exists) {
|
||||
meta.hasMissingMedia = true
|
||||
hasMissingMedia = true
|
||||
}
|
||||
images.push({
|
||||
localPath: img.localRef.path,
|
||||
@@ -315,11 +313,11 @@ export function draftViewToSummary({
|
||||
// Process videos
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(vid.localRef.path)
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
const exists = localMediaExists(vid.localRef.path)
|
||||
if (!exists) {
|
||||
meta.hasMissingMedia = true
|
||||
hasMissingMedia = true
|
||||
}
|
||||
videos.push({
|
||||
localPath: vid.localRef.path,
|
||||
@@ -334,18 +332,13 @@ export function draftViewToSummary({
|
||||
for (const ext of post.embedExternals) {
|
||||
const gifData = parseGifFromUrl(ext.uri)
|
||||
if (gifData) {
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
gif = gifData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (post.embedRecords && post.embedRecords.length > 0) {
|
||||
meta.quoteCount += post.embedRecords.length
|
||||
meta.hasQuotes = true
|
||||
}
|
||||
|
||||
return {
|
||||
id: `post-${index}`,
|
||||
text: post.text || '',
|
||||
@@ -355,17 +348,17 @@ export function draftViewToSummary({
|
||||
}
|
||||
})
|
||||
|
||||
if (meta.isOriginatingDevice && meta.hasMissingMedia) {
|
||||
analytics.logger.warn(`Draft is missing media on originating device`, {})
|
||||
}
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
draft: view.draft,
|
||||
previewText,
|
||||
hasMedia,
|
||||
hasMissingMedia,
|
||||
mediaCount,
|
||||
postCount: view.draft.posts.length,
|
||||
createdAt: view.createdAt,
|
||||
updatedAt: view.updatedAt,
|
||||
draft: view.draft,
|
||||
posts,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,9 +536,7 @@ export async function draftToComposerPosts(
|
||||
// Restore quote embed
|
||||
if (post.embedRecords && post.embedRecords.length > 0) {
|
||||
const record = post.embedRecords[0]
|
||||
const urip = new AtUri(record.record.uri)
|
||||
const url = `https://bsky.app/profile/${urip.host}/post/${urip.rkey}`
|
||||
embed.quote = {type: 'link', uri: url}
|
||||
embed.quote = {type: 'link', uri: record.record.uri}
|
||||
}
|
||||
|
||||
// Restore link embed (only if not a GIF)
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type ComposerState} from '#/view/com/composer/state/composer'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {composerStateToDraft, draftViewToSummary} from './api'
|
||||
import {logger} from './logger'
|
||||
import * as storage from './storage'
|
||||
@@ -21,7 +19,6 @@ const DRAFTS_QUERY_KEY = ['drafts']
|
||||
*/
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
const ax = useAnalytics()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: DRAFTS_QUERY_KEY,
|
||||
@@ -32,10 +29,7 @@ export function useDraftsQuery() {
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
drafts: res.data.drafts.map(view =>
|
||||
draftViewToSummary({
|
||||
view,
|
||||
analytics: ax,
|
||||
}),
|
||||
draftViewToSummary(view, path => storage.mediaExists(path)),
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -48,17 +42,11 @@ export function useDraftsQuery() {
|
||||
* Load a draft's local media for editing.
|
||||
* Takes the full Draft object (from DraftSummary) to avoid re-fetching.
|
||||
*/
|
||||
export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
loadedMedia: Map<string, string>
|
||||
}> {
|
||||
// Load local media files
|
||||
const loadedMedia = new Map<string, string>()
|
||||
|
||||
// can't load media from another device
|
||||
if (draft.deviceId && draft.deviceId !== getDeviceId()) {
|
||||
return {loadedMedia}
|
||||
}
|
||||
|
||||
for (const post of draft.posts) {
|
||||
// Load images
|
||||
if (post.embedImages) {
|
||||
@@ -66,10 +54,10 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
try {
|
||||
const url = await storage.loadMediaFromLocal(img.localRef.path)
|
||||
loadedMedia.set(img.localRef.path, url)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to load draft image', {
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load draft image', {
|
||||
path: img.localRef.path,
|
||||
safeMessage: e.message,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,10 +68,10 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
try {
|
||||
const url = await storage.loadMediaFromLocal(vid.localRef.path)
|
||||
loadedMedia.set(vid.localRef.path, url)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to load draft video', {
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load draft video', {
|
||||
path: vid.localRef.path,
|
||||
safeMessage: e.message,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,31 +50,22 @@ export type DraftPostDisplay = {
|
||||
*/
|
||||
export type DraftSummary = {
|
||||
id: string
|
||||
/** The full draft data from the server */
|
||||
draft: AppBskyDraftDefs.Draft
|
||||
/** First ~100 chars of first post */
|
||||
previewText: string
|
||||
/** Whether the draft has media */
|
||||
hasMedia: boolean
|
||||
/** Whether some media is missing (saved on another device) */
|
||||
hasMissingMedia?: boolean
|
||||
/** Number of media items */
|
||||
mediaCount: number
|
||||
/** Number of posts in thread */
|
||||
postCount: number
|
||||
/** ISO timestamp of creation */
|
||||
createdAt: string
|
||||
/** ISO timestamp of last update */
|
||||
updatedAt: string
|
||||
/** The full draft data from the server */
|
||||
draft: AppBskyDraftDefs.Draft
|
||||
/** All posts in the draft for full display */
|
||||
posts: DraftPostDisplay[]
|
||||
/** Metadata about the draft for display purposes */
|
||||
meta: {
|
||||
/** Whether this device is the originating device for the draft */
|
||||
isOriginatingDevice: boolean
|
||||
/** Number of posts in thread */
|
||||
postCount: number
|
||||
/** Number of replies to anchor post */
|
||||
replyCount: number
|
||||
/** Whether the draft has media */
|
||||
hasMedia: boolean
|
||||
/** Whether some media is missing (saved on another device) */
|
||||
hasMissingMedia?: boolean
|
||||
/** Number of media items */
|
||||
mediaCount: number
|
||||
/** Whether any posts in the draft has quotes */
|
||||
hasQuotes: boolean
|
||||
/** Number of quotes in the draft */
|
||||
quoteCount: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,9 +670,6 @@ export function createComposerState({
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (initMention) {
|
||||
// highlight the mention
|
||||
initRichText.detectFacetsWithoutResolution()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -241,7 +241,7 @@ export function TextInput({
|
||||
}
|
||||
},
|
||||
},
|
||||
content: generateJSON(richTextToHTML(richtext), extensions, {
|
||||
content: generateJSON(richtext.text.toString(), extensions, {
|
||||
preserveWhitespace: 'full',
|
||||
}),
|
||||
autofocus: 'end',
|
||||
@@ -382,36 +382,6 @@ export function TextInput({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to initialise the editor with RichText, which expects HTML
|
||||
*
|
||||
* All the extensions are able to initialise themselves from plain text, *except*
|
||||
* for the Mention extension - we need to manually convert it into a `<span>` element
|
||||
*
|
||||
* It also escapes HTML characters
|
||||
*/
|
||||
function richTextToHTML(richtext: RichText): string {
|
||||
let html = ''
|
||||
|
||||
for (const segment of richtext.segments()) {
|
||||
if (segment.mention) {
|
||||
html += `<span data-type="mention" data-id="${escapeHTML(segment.mention.did)}"></span>`
|
||||
} else {
|
||||
html += escapeHTML(segment.text)
|
||||
}
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function escapeHTML(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function editorJsonToText(
|
||||
json: JSONContent,
|
||||
isLastDocumentChild: boolean = false,
|
||||
|
||||
@@ -199,7 +199,7 @@ function PostInner({
|
||||
style={[a.pb_xs]}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<View>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
|
||||
@@ -466,7 +466,7 @@ let PostContent = ({
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
@@ -479,7 +479,7 @@ let PostContent = ({
|
||||
{limitLines && (
|
||||
<ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : undefined}
|
||||
{postEmbed ? (
|
||||
<View style={[a.pb_xs]}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
@@ -56,7 +57,7 @@ type TabOptions = 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile'
|
||||
|
||||
export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const {footerHeight} = useShellLayout()
|
||||
@@ -144,10 +145,8 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
pal.view,
|
||||
hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
@@ -162,12 +161,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtHome ? (
|
||||
<HomeFilled
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
) : (
|
||||
<Home
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -181,13 +180,13 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtSearch ? (
|
||||
<MagnifyingGlassFilled
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
testID="bottomBarSearchBtn"
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -202,12 +201,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtMessages ? (
|
||||
<MessageFilled
|
||||
width={iconWidth - 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
/>
|
||||
) : (
|
||||
<Message
|
||||
width={iconWidth - 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -234,12 +233,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtNotifications ? (
|
||||
<BellFilled
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
) : (
|
||||
<Bell
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -263,28 +262,49 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
testID="bottomBarProfileBtn"
|
||||
icon={
|
||||
<View style={styles.ctrlIconSizingWrapper}>
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
styles.profileIcon,
|
||||
isAtMyProfile && [
|
||||
{isAtMyProfile ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
styles.onProfile,
|
||||
{
|
||||
borderColor: t.atoms.text.color,
|
||||
borderColor: pal.text.color,
|
||||
borderWidth: live ? 0 : 1,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - (isAtMyProfile ? 3 : 2)}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - 2}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
{
|
||||
borderWidth: live ? 0 : 1,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - 2}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
onPress={onPressProfile}
|
||||
@@ -312,7 +332,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
style={{flexDirection: 'row', alignItems: 'center', gap: 8}}>
|
||||
<Logo width={28} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={t.atoms.text.color} />
|
||||
<Logotype width={80} fill={pal.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -12,19 +12,15 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {
|
||||
Bell_Filled_Corner0_Rounded as BellFilled,
|
||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||
@@ -41,6 +37,10 @@ import {
|
||||
Message_Stroke2_Corner0_Rounded as Message,
|
||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||
} from '#/components/icons/Message'
|
||||
import {
|
||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||
} from '#/components/icons/UserCircle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
@@ -53,8 +53,6 @@ export function BottomBarWeb() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const hideBorder = useHideBottomBarBorder()
|
||||
const accountSwitchControl = useDialogControl()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const iconWidth = 26
|
||||
|
||||
const unreadMessageCount = useUnreadMessageCount()
|
||||
@@ -71,176 +69,158 @@ export function BottomBarWeb() {
|
||||
// setShowLoggedOut(true)
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
const onLongPressProfile = React.useCallback(() => {
|
||||
accountSwitchControl.open()
|
||||
}, [accountSwitchControl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SwitchAccountDialog control={accountSwitchControl} />
|
||||
<Animated.View
|
||||
role="navigation"
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<NavItem routeName="Home" href="/">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? HomeFilled : Home
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem routeName="Search" href="/search">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
|
||||
<Animated.View
|
||||
role="navigation"
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<NavItem routeName="Home" href="/">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? HomeFilled : Home
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem routeName="Search" href="/search">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
|
||||
{hasSession && (
|
||||
<>
|
||||
<NavItem
|
||||
routeName="Messages"
|
||||
href="/messages"
|
||||
notificationCount={unreadMessageCount.numUnread}
|
||||
hasNew={unreadMessageCount.hasNew}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MessageFilled : Message
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth - 1}
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
t.atoms.text,
|
||||
styles.messagesIcon,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Notifications"
|
||||
href="/notifications"
|
||||
notificationCount={notificationCountStr}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? BellFilled : Bell
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Profile"
|
||||
href={
|
||||
currentAccount
|
||||
? makeProfileLink({
|
||||
did: currentAccount.did,
|
||||
handle: currentAccount.handle,
|
||||
})
|
||||
: '/'
|
||||
}
|
||||
onLongPress={onLongPressProfile}>
|
||||
{({isActive}) => (
|
||||
<View style={styles.ctrlIconSizingWrapper}>
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
styles.profileIcon,
|
||||
isActive && [
|
||||
styles.onProfile,
|
||||
{borderColor: t.atoms.text.color},
|
||||
],
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={iconWidth - 3}
|
||||
type={
|
||||
profile?.associated?.labeler ? 'labeler' : 'user'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
{
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<Logo width={32} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
|
||||
<Button
|
||||
onPress={showCreateAccount}
|
||||
label={_(msg`Create account`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
onPress={showSignIn}
|
||||
label={_(msg`Sign in`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{hasSession && (
|
||||
<>
|
||||
<NavItem
|
||||
routeName="Messages"
|
||||
href="/messages"
|
||||
notificationCount={unreadMessageCount.numUnread}
|
||||
hasNew={unreadMessageCount.hasNew}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MessageFilled : Message
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth - 1}
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
t.atoms.text,
|
||||
styles.messagesIcon,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Notifications"
|
||||
href="/notifications"
|
||||
notificationCount={notificationCountStr}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? BellFilled : Bell
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Profile"
|
||||
href={
|
||||
currentAccount
|
||||
? makeProfileLink({
|
||||
did: currentAccount.did,
|
||||
handle: currentAccount.handle,
|
||||
})
|
||||
: '/'
|
||||
}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? UserCircleFilled : UserCircle
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth}
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
t.atoms.text,
|
||||
styles.profileIcon,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
gap: 8,
|
||||
}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
|
||||
<Logo width={32} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
|
||||
<Button
|
||||
onPress={showCreateAccount}
|
||||
label={_(msg`Create account`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
onPress={showSignIn}
|
||||
label={_(msg`Sign in`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,8 +230,7 @@ const NavItem: React.FC<{
|
||||
routeName: string
|
||||
hasNew?: boolean
|
||||
notificationCount?: string
|
||||
onLongPress?: () => void
|
||||
}> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => {
|
||||
}> = ({children, href, routeName, hasNew, notificationCount}) => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -285,8 +264,7 @@ const NavItem: React.FC<{
|
||||
navigationAction={isOnDifferentProfile ? 'push' : 'navigate'}
|
||||
aria-role="link"
|
||||
aria-label={routeName}
|
||||
accessible={true}
|
||||
onLongPress={onLongPress}>
|
||||
accessible={true}>
|
||||
{children({isActive})}
|
||||
{notificationCount ? (
|
||||
<View
|
||||
|
||||
@@ -96,20 +96,6 @@
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/api@^0.18.20":
|
||||
version "0.18.20"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.20.tgz#3fdbb7b7fae90bd59101970c2b56cc31e8cf417d"
|
||||
integrity sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.15"
|
||||
"@atproto/lexicon" "^0.6.1"
|
||||
"@atproto/syntax" "^0.4.3"
|
||||
"@atproto/xrpc" "^0.7.7"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/aws@^0.2.31":
|
||||
version "0.2.31"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8"
|
||||
@@ -204,16 +190,6 @@
|
||||
"@atproto/syntax" "0.4.3"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common-web@^0.4.15":
|
||||
version "0.4.15"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.15.tgz#1fffedf62d69b8c96f7b360e11c4180446a2cc82"
|
||||
integrity sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.10"
|
||||
"@atproto/lex-json" "^0.0.10"
|
||||
"@atproto/syntax" "^0.4.3"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common@0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
||||
@@ -351,16 +327,6 @@
|
||||
uint8arrays "3.0.0"
|
||||
unicode-segmenter "^0.14.0"
|
||||
|
||||
"@atproto/lex-data@^0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.10.tgz#091c012af817a869951ce0e61eb757bd6c29797a"
|
||||
integrity sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==
|
||||
dependencies:
|
||||
multiformats "^9.9.0"
|
||||
tslib "^2.8.1"
|
||||
uint8arrays "3.0.0"
|
||||
unicode-segmenter "^0.14.0"
|
||||
|
||||
"@atproto/lex-document@0.0.11":
|
||||
version "0.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.11.tgz#8cfdd6ab5b5befac4d1409c76e2d5a310845c1dc"
|
||||
@@ -378,14 +344,6 @@
|
||||
"@atproto/lex-data" "0.0.9"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-json@^0.0.10":
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.10.tgz#18de1c8cc3ba564e5412bce1f8e22c198c956e64"
|
||||
integrity sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.10"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-resolver@0.0.12":
|
||||
version "0.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.12.tgz#fb6cd78c78c0acfc9a92d9e42abe7ff18b4c3a41"
|
||||
@@ -4166,6 +4124,16 @@
|
||||
postcss "~8.4.32"
|
||||
resolve-from "^5.0.0"
|
||||
|
||||
"@expo/metro-runtime@~6.1.2":
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz#5a4ff117df6115f9c9d4dcc561065e16d69c078b"
|
||||
integrity sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==
|
||||
dependencies:
|
||||
anser "^1.4.9"
|
||||
pretty-format "^29.7.0"
|
||||
stacktrace-parser "^0.1.10"
|
||||
whatwg-fetch "^3.0.0"
|
||||
|
||||
"@expo/metro@~54.1.0":
|
||||
version "54.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@expo/metro/-/metro-54.1.0.tgz#27765ef2c342c39086a2f5c9f932a375dc2ccad3"
|
||||
|
||||
Reference in New Issue
Block a user