Merge branch 'bluesky-social:main' into patch-3
This commit is contained in:
commit
3767e76390
63 changed files with 4654 additions and 1462 deletions
|
@ -45,6 +45,7 @@ import {Splash} from '#/Splash'
|
|||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
|
@ -53,6 +54,7 @@ function InnerApp() {
|
|||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
|
|
@ -32,11 +32,13 @@ import {
|
|||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
|
|
@ -460,7 +460,8 @@ const FlatNavigator = () => {
|
|||
*/
|
||||
|
||||
const LINKING = {
|
||||
prefixes: ['bsky://', 'https://bsky.app'],
|
||||
// TODO figure out what we are going to use
|
||||
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
|
||||
|
||||
getPathFromState(state: State) {
|
||||
// find the current node in the navigation tree
|
||||
|
@ -478,6 +479,11 @@ const LINKING = {
|
|||
},
|
||||
|
||||
getStateFromPath(path: string) {
|
||||
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
|
||||
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
|
||||
// intent
|
||||
if (path.includes('intent/')) return
|
||||
|
||||
const [name, params] = router.matchPath(path)
|
||||
if (isNative) {
|
||||
if (name === 'Search') {
|
||||
|
|
|
@ -7,7 +7,6 @@ import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
|||
import {InlineLink} from '#/components/Link'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
@ -20,7 +19,6 @@ export function RichText({
|
|||
style,
|
||||
numberOfLines,
|
||||
disableLinks,
|
||||
resolveFacets = false,
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
|
@ -30,31 +28,16 @@ export function RichText({
|
|||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
resolveFacets?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
}) {
|
||||
const detected = React.useRef(false)
|
||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
const styles = [a.leading_snug, flatten(style)]
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolveFacets) return
|
||||
|
||||
async function detectFacets() {
|
||||
const rt = new RichTextAPI({text: richText.text})
|
||||
await rt.detectFacets(getAgent())
|
||||
setRichText(rt)
|
||||
}
|
||||
|
||||
if (!detected.current) {
|
||||
detected.current = true
|
||||
detectFacets()
|
||||
}
|
||||
}, [richText, setRichText, resolveFacets])
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
if (!facets?.length) {
|
||||
|
|
|
@ -12,6 +12,8 @@ import {
|
|||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {web} from '#/alf'
|
||||
|
||||
export function useTagMenuControl() {}
|
||||
|
||||
|
@ -40,11 +42,12 @@ export function TagMenu({
|
|||
)) &&
|
||||
!(optimisticRemove?.value === sanitizedTag),
|
||||
)
|
||||
const truncatedTag = enforceLen(tag, 15, true, 'middle')
|
||||
|
||||
const dropdownItems = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: _(msg`See ${tag} posts`),
|
||||
label: _(msg`See ${truncatedTag} posts`),
|
||||
onPress() {
|
||||
navigation.navigate('Search', {
|
||||
q: tag,
|
||||
|
@ -61,7 +64,7 @@ export function TagMenu({
|
|||
},
|
||||
authorHandle &&
|
||||
!isInvalidHandle(authorHandle) && {
|
||||
label: _(msg`See ${tag} posts by this user`),
|
||||
label: _(msg`See ${truncatedTag} posts by user`),
|
||||
onPress() {
|
||||
navigation.navigate({
|
||||
name: 'Search',
|
||||
|
@ -83,7 +86,9 @@ export function TagMenu({
|
|||
label: 'separator',
|
||||
},
|
||||
preferences && {
|
||||
label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`),
|
||||
label: isMuted
|
||||
? _(msg`Unmute ${truncatedTag}`)
|
||||
: _(msg`Mute ${truncatedTag}`),
|
||||
onPress() {
|
||||
if (isMuted) {
|
||||
removeMutedWord({value: sanitizedTag, targets: ['tag']})
|
||||
|
@ -108,6 +113,7 @@ export function TagMenu({
|
|||
navigation,
|
||||
preferences,
|
||||
tag,
|
||||
truncatedTag,
|
||||
sanitizedTag,
|
||||
upsertMutedWord,
|
||||
removeMutedWord,
|
||||
|
@ -119,7 +125,10 @@ export function TagMenu({
|
|||
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||
accessibilityHint=""
|
||||
// @ts-ignore
|
||||
items={dropdownItems}>
|
||||
items={dropdownItems}
|
||||
triggerStyle={web({
|
||||
textAlign: 'left',
|
||||
})}>
|
||||
{children}
|
||||
</NativeDropdown>
|
||||
</EventStopper>
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf'
|
||||
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
|
@ -260,9 +260,21 @@ function MutedWordRow({
|
|||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_high]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.w_full,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.value}
|
||||
</Text>
|
||||
|
||||
|
|
|
@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
|
|||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:z72i7hdynmk6r22z27h6tvur',
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
|
87
src/lib/hooks/useIntentHandler.ts
Normal file
87
src/lib/hooks/useIntentHandler.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import React from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useComposerControls} from 'state/shell'
|
||||
import {useSession} from 'state/session'
|
||||
|
||||
type IntentType = 'compose'
|
||||
|
||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||
|
||||
export function useIntentHandler() {
|
||||
const incomingUrl = Linking.useURL()
|
||||
const composeIntent = useComposeIntent()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleIncomingURL = (url: string) => {
|
||||
const urlp = new URL(url)
|
||||
const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/')
|
||||
|
||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||
const intentType = isNative ? intentTypeNative : intentTypeWeb
|
||||
const isIntent = isNative
|
||||
? urlp.hostname === 'intent'
|
||||
: intentTypeNative === 'intent'
|
||||
const params = urlp.searchParams
|
||||
|
||||
if (!isIntent) return
|
||||
|
||||
switch (intentType as IntentType) {
|
||||
case 'compose': {
|
||||
composeIntent({
|
||||
text: params.get('text'),
|
||||
imageUrisStr: params.get('imageUris'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||
}, [incomingUrl, composeIntent])
|
||||
}
|
||||
|
||||
function useComposeIntent() {
|
||||
const {openComposer} = useComposerControls()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
return React.useCallback(
|
||||
({
|
||||
text,
|
||||
imageUrisStr,
|
||||
}: {
|
||||
text: string | null
|
||||
imageUrisStr: string | null // unused for right now, will be used later with intents
|
||||
}) => {
|
||||
if (!hasSession) return
|
||||
|
||||
const imageUris = imageUrisStr
|
||||
?.split(',')
|
||||
.filter(part => {
|
||||
// For some security, we're going to filter out any image uri that is external. We don't want someone to
|
||||
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
|
||||
// and we load that image
|
||||
if (part.includes('https://') || part.includes('http://')) {
|
||||
return false
|
||||
}
|
||||
// We also should just filter out cases that don't have all the info we need
|
||||
if (!VALID_IMAGE_REGEX.test(part)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(part => {
|
||||
const [uri, width, height] = part.split('|')
|
||||
return {uri, width: Number(width), height: Number(height)}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
imageUris: isNative ? imageUris : undefined,
|
||||
})
|
||||
}, 500)
|
||||
},
|
||||
[openComposer, hasSession],
|
||||
)
|
||||
}
|
|
@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
|
|||
return base + 's'
|
||||
}
|
||||
|
||||
export function enforceLen(str: string, len: number, ellipsis = false): string {
|
||||
export function enforceLen(
|
||||
str: string,
|
||||
len: number,
|
||||
ellipsis = false,
|
||||
mode: 'end' | 'middle' = 'end',
|
||||
): string {
|
||||
str = str || ''
|
||||
if (str.length > len) {
|
||||
return str.slice(0, len) + (ellipsis ? '...' : '')
|
||||
if (ellipsis) {
|
||||
if (mode === 'end') {
|
||||
return str.slice(0, len) + '…'
|
||||
} else if (mode === 'middle') {
|
||||
const half = Math.floor(len / 2)
|
||||
return str.slice(0, half) + '…' + str.slice(-half)
|
||||
} else {
|
||||
// fallback
|
||||
return str.slice(0, len)
|
||||
}
|
||||
} else {
|
||||
return str.slice(0, len)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
|||
import {openPicker} from 'lib/media/picker'
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
|
||||
interface InitialImageUri {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export class GalleryModel {
|
||||
images: ImageModel[] = []
|
||||
|
||||
constructor() {
|
||||
constructor(uris?: {uri: string; width: number; height: number}[]) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
if (uris) {
|
||||
this.addFromUris(uris)
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
|
@ -23,7 +33,7 @@ export class GalleryModel {
|
|||
return this.images.some(image => image.altText.trim() === '')
|
||||
}
|
||||
|
||||
async add(image_: Omit<RNImage, 'size'>) {
|
||||
*add(image_: Omit<RNImage, 'size'>) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
@ -86,4 +96,15 @@ export class GalleryModel {
|
|||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async addFromUris(uris: InitialImageUri[]) {
|
||||
for (const uriObj of uris) {
|
||||
this.add({
|
||||
mime: 'image/jpeg',
|
||||
height: uriObj.height,
|
||||
width: uriObj.width,
|
||||
path: uriObj.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
|
@ -29,6 +34,7 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
|||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
import {useModerationOpts} from './preferences'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||
|
||||
type ActorDid = string
|
||||
type AuthorFilter =
|
||||
|
@ -137,24 +143,41 @@ export function usePostFeedQuery(
|
|||
cursor: undefined,
|
||||
}
|
||||
|
||||
const res = await api.fetch({cursor, limit: PAGE_SIZE})
|
||||
precacheFeedPostProfiles(queryClient, res.feed)
|
||||
try {
|
||||
const res = await api.fetch({cursor, limit: PAGE_SIZE})
|
||||
precacheFeedPostProfiles(queryClient, res.feed)
|
||||
|
||||
/*
|
||||
* If this is a public view, we need to check if posts fail moderation.
|
||||
* If all fail, we throw an error. If only some fail, we continue and let
|
||||
* moderations happen later, which results in some posts being shown and
|
||||
* some not.
|
||||
*/
|
||||
if (!getAgent().session) {
|
||||
assertSomePostsPassModeration(res.feed)
|
||||
}
|
||||
/*
|
||||
* If this is a public view, we need to check if posts fail moderation.
|
||||
* If all fail, we throw an error. If only some fail, we continue and let
|
||||
* moderations happen later, which results in some posts being shown and
|
||||
* some not.
|
||||
*/
|
||||
if (!getAgent().session) {
|
||||
assertSomePostsPassModeration(res.feed)
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
cursor: res.cursor,
|
||||
feed: res.feed,
|
||||
fetchedAt: Date.now(),
|
||||
return {
|
||||
api,
|
||||
cursor: res.cursor,
|
||||
feed: res.feed,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
} catch (e) {
|
||||
const feedDescParts = feedDesc.split('|')
|
||||
const feedOwnerDid = new AtUri(feedDescParts[1]).hostname
|
||||
|
||||
if (
|
||||
feedDescParts[0] === 'feedgen' &&
|
||||
BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid)
|
||||
) {
|
||||
logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, {
|
||||
feedDesc,
|
||||
jsError: e,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
|
@ -253,7 +276,7 @@ export function usePostFeedQuery(
|
|||
.success
|
||||
) {
|
||||
return {
|
||||
_reactKey: `${slice._reactKey}-${i}`,
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
|
|
|
@ -38,6 +38,8 @@ export interface ComposerOpts {
|
|||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
text?: string
|
||||
imageUris?: {uri: string; width: number; height: number}[]
|
||||
}
|
||||
|
||||
type StateContext = ComposerOpts | undefined
|
||||
|
|
|
@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
|
|||
quote: initQuote,
|
||||
mention: initMention,
|
||||
openPicker,
|
||||
text: initText,
|
||||
imageUris: initImageUris,
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
|
@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
|
|||
const [error, setError] = useState('')
|
||||
const [richtext, setRichText] = useState(
|
||||
new RichText({
|
||||
text: initMention
|
||||
text: initText
|
||||
? initText
|
||||
: initMention
|
||||
? insertMentionAt(
|
||||
`@${initMention}`,
|
||||
initMention.length + 1,
|
||||
|
@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
|
|||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||
const gallery = useMemo(() => new GalleryModel(), [])
|
||||
const gallery = useMemo(
|
||||
() => new GalleryModel(initImageUris),
|
||||
[initImageUris],
|
||||
)
|
||||
const onClose = useCallback(() => {
|
||||
closeComposer()
|
||||
}, [closeComposer])
|
||||
|
|
|
@ -1,30 +1,24 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {MainScrollProvider} from '../util/MainScrollProvider'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {s} from 'lib/styles'
|
||||
import {View, useWindowDimensions} from 'react-native'
|
||||
import {ListMethods} from '../util/List'
|
||||
import {Feed} from '../posts/Feed'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {FAB} from '../util/fab/FAB'
|
||||
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {listenSoftReset, emitSoftReset} from '#/state/events'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
@ -47,10 +41,8 @@ export function FeedPage({
|
|||
renderEndOfFeed?: () => JSX.Element
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useComposerControls()
|
||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||
|
@ -99,63 +91,6 @@ export function FeedPage({
|
|||
setHasNew(false)
|
||||
}, [scrollToTop, feed, queryClient, setHasNew])
|
||||
|
||||
const ListHeaderComponent = React.useCallback(() => {
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
]}>
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/"
|
||||
style={[pal.text, {fontWeight: 'bold'}]}
|
||||
text={
|
||||
<>
|
||||
Bluesky{' '}
|
||||
{hasNew && (
|
||||
<View
|
||||
style={{
|
||||
top: -8,
|
||||
backgroundColor: colors.blue3,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onPress={emitSoftReset}
|
||||
/>
|
||||
{hasSession && (
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/settings/following-feed"
|
||||
style={{fontWeight: 'bold'}}
|
||||
accessibilityLabel={_(msg`Feed Preferences`)}
|
||||
accessibilityHint=""
|
||||
text={
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={s.h100pct}>
|
||||
<MainScrollProvider>
|
||||
|
@ -171,7 +106,6 @@ export function FeedPage({
|
|||
onHasNew={setHasNew}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
headerOffset={headerOffset}
|
||||
/>
|
||||
</MainScrollProvider>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react'
|
||||
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {HomeHeaderLayout} from './HomeHeaderLayout'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
@ -11,16 +10,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
|
|||
|
||||
export function HomeHeader(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
if (isDesktop) {
|
||||
return null
|
||||
}
|
||||
return <HomeHeaderInner {...props} />
|
||||
}
|
||||
|
||||
export function HomeHeaderInner(
|
||||
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
|
||||
) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {CogIcon} from '#/lib/icons'
|
||||
|
||||
export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
|||
const pal = usePalette('default')
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
|
||||
|
@ -28,12 +38,44 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
|||
onLayout={e => {
|
||||
headerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
<View style={[pal.view, styles.topBar]}>
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/settings/following-feed"
|
||||
accessibilityLabel={_(msg`Following Feed Preferences`)}
|
||||
accessibilityHint=""
|
||||
text={
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Logo width={28} />
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
||||
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
||||
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||
</Link>
|
||||
</View>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 8,
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
},
|
||||
tabBar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
|
@ -42,7 +84,7 @@ const styles = StyleSheet.create({
|
|||
left: 'calc(50% - 300px)',
|
||||
width: 600,
|
||||
top: 0,
|
||||
flexDirection: 'row',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
|
|
|
@ -103,7 +103,6 @@ const styles = StyleSheet.create({
|
|||
right: 0,
|
||||
top: 0,
|
||||
flexDirection: 'column',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
@ -5,6 +5,7 @@ import {PressableWithHover} from '../util/PressableWithHover'
|
|||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export interface TabBarProps {
|
||||
testID?: string
|
||||
|
@ -15,6 +16,10 @@ export interface TabBarProps {
|
|||
onPressSelected?: (index: number) => void
|
||||
}
|
||||
|
||||
// How much of the previous/next item we're showing
|
||||
// to give the user a hint there's more to scroll.
|
||||
const OFFSCREEN_ITEM_WIDTH = 20
|
||||
|
||||
export function TabBar({
|
||||
testID,
|
||||
selectedPage,
|
||||
|
@ -25,6 +30,7 @@ export function TabBar({
|
|||
}: TabBarProps) {
|
||||
const pal = usePalette('default')
|
||||
const scrollElRef = useRef<ScrollView>(null)
|
||||
const itemRefs = useRef<Array<Element>>([])
|
||||
const [itemXs, setItemXs] = useState<number[]>([])
|
||||
const indicatorStyle = useMemo(
|
||||
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
|
||||
|
@ -33,12 +39,58 @@ export function TabBar({
|
|||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
||||
|
||||
// scrolls to the selected item when the page changes
|
||||
useEffect(() => {
|
||||
scrollElRef.current?.scrollTo({
|
||||
x:
|
||||
(itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal,
|
||||
})
|
||||
if (isNative) {
|
||||
// On native, the primary interaction is swiping.
|
||||
// We adjust the scroll little by little on every tab change.
|
||||
// Scroll into view but keep the end of the previous item visible.
|
||||
let x = itemXs[selectedPage] || 0
|
||||
x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
|
||||
scrollElRef.current?.scrollTo({x})
|
||||
} else {
|
||||
// On the web, the primary interaction is tapping.
|
||||
// Scrolling under tap feels disorienting so only adjust the scroll offset
|
||||
// when tapping on an item out of view--and we adjust by almost an entire page.
|
||||
const parent = scrollElRef?.current?.getScrollableNode?.()
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
if (!parentRect) {
|
||||
return
|
||||
}
|
||||
const {
|
||||
left: parentLeft,
|
||||
right: parentRight,
|
||||
width: parentWidth,
|
||||
} = parentRect
|
||||
const child = itemRefs.current[selectedPage]
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
const childRect = child.getBoundingClientRect?.()
|
||||
if (!childRect) {
|
||||
return
|
||||
}
|
||||
const {left: childLeft, right: childRight, width: childWidth} = childRect
|
||||
let dx = 0
|
||||
if (childRight >= parentRight) {
|
||||
dx += childRight - parentRight
|
||||
dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
|
||||
} else if (childLeft <= parentLeft) {
|
||||
dx -= parentLeft - childLeft
|
||||
dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
|
||||
}
|
||||
let x = parent.scrollLeft + dx
|
||||
x = Math.max(0, x)
|
||||
x = Math.min(x, parent.scrollWidth - parentWidth)
|
||||
if (dx !== 0) {
|
||||
parent.scroll({
|
||||
left: x,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [scrollElRef, itemXs, selectedPage, styles])
|
||||
|
||||
const onPressItem = useCallback(
|
||||
|
@ -78,6 +130,7 @@ export function TabBar({
|
|||
<PressableWithHover
|
||||
testID={`${testID}-selector-${i}`}
|
||||
key={`${item}-${i}`}
|
||||
ref={node => (itemRefs.current[i] = node)}
|
||||
onLayout={e => onItemLayout(e, i)}
|
||||
style={styles.item}
|
||||
hoverStyle={pal.viewLight}
|
||||
|
@ -94,6 +147,7 @@ export function TabBar({
|
|||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
<View style={[pal.border, styles.outerBottomBorder]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -117,6 +171,13 @@ const desktopStyles = StyleSheet.create({
|
|||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
outerBottomBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const mobileStyles = StyleSheet.create({
|
||||
|
@ -137,4 +198,11 @@ const mobileStyles = StyleSheet.create({
|
|||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
outerBottomBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -94,6 +94,8 @@ export function PostThreadItem({
|
|||
if (richText && moderation) {
|
||||
return (
|
||||
<PostThreadItemLoaded
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadowed.uri}
|
||||
post={postShadowed}
|
||||
prevPost={prevPost}
|
||||
nextPost={nextPost}
|
||||
|
|
|
@ -70,6 +70,8 @@ export function FeedItem({
|
|||
if (richText && moderation) {
|
||||
return (
|
||||
<FeedItemInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadowed.uri}
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
reason={reason}
|
||||
|
|
|
@ -20,12 +20,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||
const setMode = useSetMinimalShellMode()
|
||||
const startDragOffset = useSharedValue<number | null>(null)
|
||||
const startMode = useSharedValue<number | null>(null)
|
||||
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
return listenToForcedWindowScroll(() => {
|
||||
startDragOffset.value = null
|
||||
startMode.value = null
|
||||
didJustRestoreScroll.value = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -86,6 +88,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||
mode.value = newValue
|
||||
}
|
||||
} else {
|
||||
if (didJustRestoreScroll.value) {
|
||||
didJustRestoreScroll.value = false
|
||||
// Don't hide/show navbar based on scroll restoratoin.
|
||||
return
|
||||
}
|
||||
// On the web, we don't try to follow the drag because we don't know when it ends.
|
||||
// Instead, show/hide immediately based on whether we're scrolling up or down.
|
||||
const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
|
||||
|
@ -98,7 +105,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||
}
|
||||
}
|
||||
},
|
||||
[headerHeight, mode, setMode, startDragOffset, startMode],
|
||||
[
|
||||
headerHeight,
|
||||
mode,
|
||||
setMode,
|
||||
startDragOffset,
|
||||
startMode,
|
||||
didJustRestoreScroll,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu'
|
||||
import {Pressable, StyleSheet, Platform, View} from 'react-native'
|
||||
import {Pressable, StyleSheet, Platform, View, ViewStyle} from 'react-native'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
@ -151,6 +151,7 @@ type Props = {
|
|||
testID?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
triggerStyle?: ViewStyle
|
||||
}
|
||||
|
||||
/* The `NativeDropdown` function uses native iOS and Android dropdown menus.
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {Pressable, StyleSheet, View, Text} from 'react-native'
|
||||
import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
@ -53,6 +53,7 @@ type Props = {
|
|||
testID?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
triggerStyle?: ViewStyle
|
||||
}
|
||||
|
||||
export function NativeDropdown({
|
||||
|
@ -61,6 +62,7 @@ export function NativeDropdown({
|
|||
testID,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
triggerStyle,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
|
@ -120,7 +122,8 @@ export function NativeDropdown({
|
|||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}
|
||||
onPress={() => setOpen(o => !o)}
|
||||
hitSlop={HITSLOP_10}>
|
||||
hitSlop={HITSLOP_10}
|
||||
style={triggerStyle}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</DropdownMenu.Trigger>
|
||||
|
|
|
@ -22,12 +22,14 @@ export function Typography() {
|
|||
<Text style={[a.text_2xs]}>atoms.text_2xs</Text>
|
||||
|
||||
<RichText
|
||||
resolveFacets
|
||||
// TODO: This only supports already resolved facets.
|
||||
// Resolving them on read is bad anyway.
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
/>
|
||||
<RichText
|
||||
selectable
|
||||
resolveFacets
|
||||
// TODO: This only supports already resolved facets.
|
||||
// Resolving them on read is bad anyway.
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
style={[a.text_xl]}
|
||||
/>
|
||||
|
|
|
@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
|
|||
onPost={state.onPost}
|
||||
quote={state.quote}
|
||||
mention={state.mention}
|
||||
text={state.text}
|
||||
imageUris={state.imageUris}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
|||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerState,
|
||||
} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
|
||||
} from 'view/com/composer/text-input/web/EmojiPicker.web'
|
||||
|
||||
const BOTTOM_BAR_HEIGHT = 61
|
||||
|
||||
|
@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
|
|||
onPost={state.onPost}
|
||||
mention={state.mention}
|
||||
openPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
/>
|
||||
</Animated.View>
|
||||
<EmojiPicker state={pickerState} close={onClosePicker} />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue