diff --git a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..0bfcc48a0e
--- /dev/null
+++ b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/filter_stroke2_corner0_rounded.svg b/assets/icons/filter_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..1fbcfc5711
--- /dev/null
+++ b/assets/icons/filter_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..81357a12e3
--- /dev/null
+++ b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/trash_stroke2_corner0_rounded.svg b/assets/icons/trash_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..d4b32f81fe
--- /dev/null
+++ b/assets/icons/trash_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/warning_stroke2_corner0_rounded.svg b/assets/icons/warning_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..d5b6f13d5f
--- /dev/null
+++ b/assets/icons/warning_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 55447552ea..c7c5ec0f0b 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -43,6 +43,9 @@
height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable both-edges;
}
+ html, body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ }
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea {
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index b535b7a9aa..2da5a0d626 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -80,6 +80,7 @@ import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag'
import {logEvent} from './lib/statsig/statsig'
+import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
const navigationRef = createNavigationContainerRef()
@@ -199,6 +200,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
+ ProfileLabelerLikedByScreen}
+ options={{title: title(msg`Liked by`)}}
+ />
Storybook}
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 8a79a90294..6d9b835894 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -49,6 +49,9 @@ export const atoms = {
h_full: {
height: '100%',
},
+ h_full_vh: web({
+ height: '100vh',
+ }),
/*
* Border radius
@@ -524,6 +527,10 @@ export const atoms = {
/*
* Margin
*/
+ mx_auto: {
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ },
m_2xs: {
margin: tokens.space._2xs,
},
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 3dff417793..a8805c9c24 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -27,7 +27,7 @@ export type ButtonColor =
| 'gradient_sunset'
| 'gradient_nordic'
| 'gradient_bonfire'
-export type ButtonSize = 'tiny' | 'small' | 'large'
+export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
export type ButtonShape = 'round' | 'square' | 'default'
export type VariantProps = {
/**
@@ -274,6 +274,8 @@ export function Button({
if (shape === 'default') {
if (size === 'large') {
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
+ } else if (size === 'medium') {
+ baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') {
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index 9b571e8e9c..859f8edd77 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -21,8 +21,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
open: () => {},
close: () => {},
})
- const {activeDialogs, openDialogs} = useDialogStateContext()
- const isOpen = openDialogs.includes(id)
+ const {activeDialogs} = useDialogStateContext()
React.useEffect(() => {
activeDialogs.current.set(id, control)
@@ -36,7 +35,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
() => ({
id,
ref: control,
- isOpen,
open: () => {
control.current.open()
},
@@ -44,6 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
control.current.close(cb)
},
}),
- [id, control, isOpen],
+ [id, control],
)
}
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index fa9398fe05..4fc60ec394 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -22,7 +22,7 @@ export type DialogControlRefProps = {
export type DialogControlProps = DialogControlRefProps & {
id: string
ref: React.RefObject
- isOpen: boolean
+ isOpen?: boolean
}
export type DialogContextProps = {
diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx
new file mode 100644
index 0000000000..e416198b3f
--- /dev/null
+++ b/src/components/LikedByList.tsx
@@ -0,0 +1,137 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {List} from '#/view/com/util/List'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {cleanError} from '#/lib/strings/errors'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from './icons/ArrowRotateCounterClockwise'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+
+export function LikedByList({uri}: {uri: string}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const [isPTRing, setIsPTRing] = React.useState(false)
+ const {
+ data: resolvedUri,
+ error: resolveError,
+ isFetching: isFetchingResolvedUri,
+ } = useResolveUriQuery(uri)
+ const {
+ data,
+ isFetching,
+ isFetched,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ isError,
+ error,
+ refetch,
+ } = useLikedByQuery(resolvedUri?.uri)
+ const likes = React.useMemo(() => {
+ if (data?.pages) {
+ return data.pages.flatMap(page => page.likes)
+ }
+ return []
+ }, [data])
+
+ const onRefresh = React.useCallback(async () => {
+ setIsPTRing(true)
+ try {
+ await refetch()
+ } catch (err) {
+ logger.error('Failed to refresh likes', {message: err})
+ }
+ setIsPTRing(false)
+ }, [refetch, setIsPTRing])
+
+ const onEndReached = React.useCallback(async () => {
+ if (isFetching || !hasNextPage || isError) return
+ try {
+ await fetchNextPage()
+ } catch (err) {
+ logger.error('Failed to load more likes', {message: err})
+ }
+ }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+ const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
+ return (
+
+ )
+ }, [])
+
+ if (isFetchingResolvedUri || !isFetched) {
+ return (
+
+
+
+ )
+ }
+
+ if (resolveError || isError) {
+ return (
+
+
+
+ {cleanError(resolveError || error)}
+
+
+
+
+
+
+
+ )
+ }
+
+ return likes.length ? (
+ item.actor.did}
+ refreshing={isPTRing}
+ onRefresh={onRefresh}
+ onEndReached={onEndReached}
+ renderItem={renderItem}
+ initialNumToRender={15}
+ contentContainerStyle={{borderWidth: 0}}
+ // FIXME(dan)
+ // eslint-disable-next-line react/no-unstable-nested-components
+ ListFooterComponent={() => (
+
+ {(isFetching || isFetchingNextPage) && }
+
+ )}
+ // @ts-ignore our .web version only -prf
+ desktopFixedHeight
+ />
+ ) : (
+
+
+
+
+ Nobody has liked this yet. Maybe you should be the first!
+
+
+
+
+ )
+}
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index ca2e40566d..054e51b01e 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -92,10 +92,8 @@ export function Trigger({children, label, style}: TriggerProps) {
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
- style={flatten([style, web({outline: 0})])}
- onPointerDown={() => {
- control.open()
- }}
+ style={flatten([style, focused && web({outline: 0})])}
+ onPointerDown={() => control.open()}
{...web({
onMouseEnter,
onMouseLeave,
@@ -131,6 +129,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
{children}
+ {/* Disabled until we can fix positioning
) {
.backgroundColor
}
/>
+ */}
)
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 8e55bd8347..3b245c440f 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -3,7 +3,7 @@ import {View, PressableProps} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useTheme, atoms as a} from '#/alf'
+import {useTheme, atoms as a, useBreakpoints} from '#/alf'
import {Text} from '#/components/Typography'
import {Button} from '#/components/Button'
@@ -25,6 +25,7 @@ export function Outer({
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
}>) {
+ const {gtMobile} = useBreakpoints()
const titleId = React.useId()
const descriptionId = React.useId()
@@ -38,12 +39,12 @@ export function Outer({
-
+ style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
{children}
-
+
)
@@ -71,8 +72,16 @@ export function Description({children}: React.PropsWithChildren<{}>) {
}
export function Actions({children}: React.PropsWithChildren<{}>) {
+ const {gtMobile} = useBreakpoints()
+
return (
-
+
{children}
)
@@ -82,12 +91,13 @@ export function Cancel({
children,
}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
const {_} = useLingui()
+ const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
return (
@@ -399,7 +391,7 @@ export function Switch() {
width: 30,
},
baseStyles,
- hovered || focused ? baseHoverStyles : {},
+ hovered ? baseHoverStyles : {},
]}>
{selected ? (
) {
t.atoms.border_contrast_low,
baseStyles,
activeStyles,
- (state.hovered || state.focused || state.pressed) && hoverStyles,
+ (state.hovered || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
) {
+ constructor(description: Record) {
for (const [screen, pattern] of Object.entries(description)) {
- this.routes.push([screen, createRoute(pattern)])
+ if (typeof pattern === 'string') {
+ this.routes.push([screen, createRoute(pattern)])
+ } else {
+ pattern.forEach(subPattern => {
+ this.routes.push([screen, createRoute(subPattern)])
+ })
+ }
}
}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 59495401d5..95af2f237d 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,6 +21,7 @@ export type CommonNavigatorParams = {
PostRepostedBy: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
+ ProfileLabelerLikedBy: {name: string}
Debug: undefined
DebugMod: undefined
Log: undefined
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 43822dda85..6d9ebeb099 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -48,6 +48,18 @@ export function Provider({children}: {children: React.ReactNode}) {
() => toStatsigUser(currentAccount?.did),
[currentAccount?.did],
)
+
+ React.useEffect(() => {
+ function refresh() {
+ // Intentionally refetching the config using the JS SDK rather than React SDK
+ // so that the new config is stored in cache but isn't used during this session.
+ // It will kick in for the next reload.
+ Statsig.updateUser(currentStatsigUser)
+ }
+ const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
+ return () => clearInterval(id)
+ }, [currentStatsigUser])
+
return (
toStatsigUser(currentAccount?.did),
[currentAccount?.did],
)
+
+ React.useEffect(() => {
+ function refresh() {
+ // Intentionally refetching the config using the JS SDK rather than React SDK
+ // so that the new config is stored in cache but isn't used during this session.
+ // It will kick in for the next reload.
+ Statsig.updateUser(currentStatsigUser)
+ }
+ const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
+ return () => clearInterval(id)
+ }, [currentStatsigUser])
+
return (
{
let i = str.lastIndexOf(tld)
@@ -67,8 +69,21 @@ export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/')
}
+export function isRelativeUrl(url: string): boolean {
+ return /^\/[^/]/.test(url)
+}
+
+export function isBskyRSSUrl(url: string): boolean {
+ return (
+ (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
+ /\/rss\/?$/.test(url)
+ )
+}
+
export function isExternalUrl(url: string): boolean {
- return !isBskyAppUrl(url) && url.startsWith('http')
+ const external = !isBskyAppUrl(url) && url.startsWith('http')
+ const rss = isBskyRSSUrl(url)
+ return external || rss
}
export function isBskyPostUrl(url: string): boolean {
@@ -149,7 +164,7 @@ export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
// If the uri started with a / we know it is internal.
- if (uri.startsWith('/')) {
+ if (isRelativeUrl(uri)) {
return false
}
@@ -222,3 +237,8 @@ export function splitApexDomain(hostname: string): [string, string] {
hostnamep.domain,
]
}
+
+export function createBskyAppAbsoluteUrl(path: string): string {
+ const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
+ return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
+}
diff --git a/src/routes.ts b/src/routes.ts
index 23ec880de2..f6f3729475 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -12,7 +12,7 @@ export const router = new Router({
ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts',
- Profile: '/profile/:name',
+ Profile: ['/profile/:name', '/profile/:name/rss'],
ProfileFollowers: '/profile/:name/followers',
ProfileFollows: '/profile/:name/follows',
ProfileList: '/profile/:name/lists/:rkey',
@@ -21,6 +21,7 @@ export const router = new Router({
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
+ ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
Debug: '/sys/debug',
DebugMod: '/sys/debug-mod',
Log: '/sys/log',
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index b6e1de28fd..c7ca56e598 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -35,10 +35,9 @@ import {
Heart2_Stroke2_Corner0_Rounded as Heart,
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
} from '#/components/icons/Heart2'
-import {LikesDialog} from '#/components/LikesDialog'
-import * as Dialog from '#/components/Dialog'
import {DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
+import {Link} from '#/components/Link'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
@@ -64,7 +63,6 @@ let ProfileHeaderLabeler = ({
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
const {track} = useAnalytics()
- const likesControl = Dialog.useDialogControl()
const cantSubscribePrompt = Prompt.usePromptControl()
const moderation = useMemo(
@@ -86,9 +84,7 @@ let ProfileHeaderLabeler = ({
const [likeUri, setLikeUri] = React.useState(
labeler.viewer?.like || '',
)
- const isLiked = !!likeUri
- const likeCount =
- isLiked && likeUri ? (labeler.likeCount || 0) + 1 : labeler.likeCount || 0
+ const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
const onToggleLiked = React.useCallback(async () => {
if (!labeler) {
@@ -97,13 +93,15 @@ let ProfileHeaderLabeler = ({
try {
Haptics.default()
- if (isLiked && likeUri) {
+ if (likeUri) {
await unlikeMod({uri: likeUri})
track('CustomFeed:Unlike')
+ setLikeCount(c => c - 1)
setLikeUri('')
} else {
const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
track('CustomFeed:Like')
+ setLikeCount(c => c + 1)
setLikeUri(res.uri)
}
} catch (e: any) {
@@ -114,7 +112,7 @@ let ProfileHeaderLabeler = ({
)
logger.error(`Failed to toggle labeler like`, {message: e.message})
}
- }, [labeler, likeUri, isLiked, likeMod, unlikeMod, track, _])
+ }, [labeler, likeUri, likeMod, unlikeMod, track, _])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
@@ -229,17 +227,22 @@ let ProfileHeaderLabeler = ({
label={_(msg`Like this feed`)}
disabled={!hasSession || isLikePending || isUnlikePending}
onPress={onToggleLiked}>
- {isLiked ? (
+ {likeUri ? (
) : (
)}
- {typeof labeler.likeCount === 'number' && (
- likesControl.open()}
label={_(
msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`,
)}>
@@ -257,13 +260,12 @@ let ProfileHeaderLabeler = ({
)}
-
+
)}
>
)}
-
)
diff --git a/src/screens/Profile/ProfileLabelerLikedBy.tsx b/src/screens/Profile/ProfileLabelerLikedBy.tsx
new file mode 100644
index 0000000000..0b13139f9a
--- /dev/null
+++ b/src/screens/Profile/ProfileLabelerLikedBy.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {LikedByList} from '#/components/LikedByList'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
+
+import {atoms as a, useTheme, useBreakpoints} from '#/alf'
+
+export function ProfileLabelerLikedByScreen({
+ route,
+}: NativeStackScreenProps) {
+ const setMinimalShellMode = useSetMinimalShellMode()
+ const {name: handleOrDid} = route.params
+ const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
+ const {_} = useLingui()
+ const t = useTheme()
+ const {gtMobile} = useBreakpoints()
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setMinimalShellMode(false)
+ }, [setMinimalShellMode]),
+ )
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
index 90aaca4f8b..951105a509 100644
--- a/src/state/dialogs/index.tsx
+++ b/src/state/dialogs/index.tsx
@@ -1,8 +1,9 @@
import React from 'react'
+import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
-const DialogContext = React.createContext<{
+interface IDialogContext {
/**
* The currently active `useDialogControl` hooks.
*/
@@ -13,13 +14,18 @@ const DialogContext = React.createContext<{
* The currently open dialogs, referenced by their IDs, generated from
* `useId`.
*/
- openDialogs: string[]
-}>({
- activeDialogs: {
- current: new Map(),
- },
- openDialogs: [],
-})
+ openDialogs: React.MutableRefObject>
+ /**
+ * The counterpart to `accessibilityViewIsModal` for Android. This property
+ * applies to the parent of all non-modal views, and prevents TalkBack from
+ * navigating within content beneath an open dialog.
+ *
+ * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
+ */
+ importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
+}
+
+const DialogContext = React.createContext({} as IDialogContext)
const DialogControlContext = React.createContext<{
closeAllDialogs(): boolean
@@ -41,26 +47,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef<
Map>
>(new Map())
- const [openDialogs, setOpenDialogs] = React.useState([])
+ const openDialogs = React.useRef>(new Set())
+ const importantForAccessibility = useSharedValue<
+ 'auto' | 'no-hide-descendants'
+ >('auto')
const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close())
- return openDialogs.length > 0
- }, [openDialogs])
+ return openDialogs.current.size > 0
+ }, [])
const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => {
- setOpenDialogs(prev => {
- const filtered = prev.filter(dialogId => dialogId !== id) as string[]
- return isOpen ? [...filtered, id] : filtered
- })
+ if (isOpen) {
+ openDialogs.current.add(id)
+ importantForAccessibility.value = 'no-hide-descendants'
+ } else {
+ openDialogs.current.delete(id)
+ if (openDialogs.current.size < 1) {
+ importantForAccessibility.value = 'auto'
+ }
+ }
},
- [setOpenDialogs],
+ [importantForAccessibility],
)
- const context = React.useMemo(
- () => ({activeDialogs, openDialogs}),
- [openDialogs],
+ const context = React.useMemo(
+ () => ({
+ activeDialogs: {
+ current: new Map(),
+ },
+ openDialogs: {
+ current: new Set(),
+ },
+ importantForAccessibility,
+ }),
+ [importantForAccessibility],
)
const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}),
diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx
index 4f033db659..2398f1f812 100644
--- a/src/state/preferences/in-app-browser.tsx
+++ b/src/state/preferences/in-app-browser.tsx
@@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection'
import {useModalControls} from '../modals'
import {usePalette} from 'lib/hooks/usePalette'
+import {
+ isBskyRSSUrl,
+ isRelativeUrl,
+ createBskyAppAbsoluteUrl,
+} from 'lib/strings/url-helpers'
type StateContext = persisted.Schema['useInAppBrowser']
type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
@@ -57,6 +62,10 @@ export function useOpenLink() {
const openLink = React.useCallback(
(url: string, override?: boolean) => {
+ if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
+ url = createBskyAppAbsoluteUrl(url)
+ }
+
if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) {
openModal({
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index c1bec535f5..1f6852f8cc 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -157,7 +157,7 @@ export function Step1({
)}
- {uiState.inviteCode ? (
+ {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
<>
) {
+ onKeyDown = true,
+}: React.PropsWithChildren<{
+ style?: ViewStyle | ViewStyle[]
+ /**
+ * Default `true`. Set to `false` to allow onKeyDown to propagate
+ */
+ onKeyDown?: boolean
+}>) {
const stop = (e: any) => {
e.stopPropagation()
}
@@ -18,7 +25,7 @@ export function EventStopper({
onTouchEnd={stop}
// @ts-ignore web only -prf
onClick={stop}
- onKeyDown={stop}
+ onKeyDown={onKeyDown ? stop : undefined}
style={style}>
{children}
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 00dfe6a7ac..d0daa99ce9 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,5 +1,11 @@
import React, {memo} from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import {
+ StyleProp,
+ ViewStyle,
+ Pressable,
+ View,
+ PressableProps,
+} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
@@ -12,10 +18,6 @@ import {
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
-import {
- NativeDropdown,
- DropdownItem as NativeDropdownItem,
-} from './NativeDropdown'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useModalControls} from '#/state/modals'
@@ -35,9 +37,20 @@ import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
-import {NEW_REPORT_DIALOG_ENABLED} from '#/lib/build-flags'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {atoms as a, useTheme as useAlf, web} from '#/alf'
+import * as Menu from '#/components/Menu'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
+import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+
let PostDropdownBtn = ({
testID,
postAuthor,
@@ -46,6 +59,7 @@ let PostDropdownBtn = ({
record,
richText,
style,
+ hitSlop,
}: {
testID: string
postAuthor: AppBskyActorDefs.ProfileViewBasic
@@ -54,9 +68,11 @@ let PostDropdownBtn = ({
record: AppBskyFeedPost.Record
richText: RichTextAPI
style?: StyleProp
+ hitSlop?: PressableProps['hitSlop']
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const theme = useTheme()
+ const alf = useAlf()
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
@@ -152,149 +168,6 @@ let PostDropdownBtn = ({
hidePost({uri: postUri})
}, [postUri, hidePost])
- const dropdownItems: NativeDropdownItem[] = [
- {
- label: _(msg`Translate`),
- onPress() {
- onOpenTranslate()
- },
- testID: 'postDropdownTranslateBtn',
- icon: {
- ios: {
- name: 'character.book.closed',
- },
- android: 'ic_menu_sort_alphabetically',
- web: 'language',
- },
- },
- {
- label: _(msg`Copy post text`),
- onPress() {
- onCopyPostText()
- },
- testID: 'postDropdownCopyTextBtn',
- icon: {
- ios: {
- name: 'doc.on.doc',
- },
- android: 'ic_menu_edit',
- web: ['far', 'paste'],
- },
- },
- {
- label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`),
- onPress() {
- const url = toShareUrl(href)
- shareUrl(url)
- },
- testID: 'postDropdownShareBtn',
- icon: {
- ios: {
- name: 'square.and.arrow.up',
- },
- android: 'ic_menu_share',
- web: 'share',
- },
- },
- hasSession && {
- label: 'separator',
- },
- hasSession && {
- label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`),
- onPress() {
- onToggleThreadMute()
- },
- testID: 'postDropdownMuteThreadBtn',
- icon: {
- ios: {
- name: 'speaker.slash',
- },
- android: 'ic_lock_silent_mode',
- web: 'comment-slash',
- },
- },
- hasSession && {
- label: _(msg`Mute words & tags`),
- onPress() {
- mutedWordsDialogControl.open()
- },
- testID: 'postDropdownMuteWordsBtn',
- icon: {
- ios: {
- name: 'speaker.slash',
- },
- android: 'ic_lock_silent_mode',
- web: 'filter',
- },
- },
- hasSession &&
- !isAuthor &&
- !isPostHidden && {
- label: _(msg`Hide post`),
- onPress() {
- openModal({
- name: 'confirm',
- title: _(msg`Hide this post?`),
- message: _(msg`This will hide this post from your feeds.`),
- onPressConfirm: onHidePost,
- })
- },
- testID: 'postDropdownHideBtn',
- icon: {
- ios: {
- name: 'eye.slash',
- },
- android: 'ic_menu_delete',
- web: ['far', 'eye-slash'],
- },
- },
- {
- label: 'separator',
- },
- !isAuthor &&
- hasSession && {
- label: _(msg`Report post`),
- onPress() {
- if (NEW_REPORT_DIALOG_ENABLED) {
- control.open()
- } else {
- openModal({
- name: 'report',
- uri: postUri,
- cid: postCid,
- })
- }
- },
- testID: 'postDropdownReportBtn',
- icon: {
- ios: {
- name: 'exclamationmark.triangle',
- },
- android: 'ic_menu_report_image',
- web: 'circle-exclamation',
- },
- },
- isAuthor && {
- label: _(msg`Delete post`),
- onPress() {
- openModal({
- name: 'confirm',
- title: _(msg`Delete this post?`),
- message: _(msg`Are you sure? This cannot be undone.`),
- onPressConfirm: onDeletePost,
- })
- },
- testID: 'postDropdownDeleteBtn',
- icon: {
- ios: {
- name: 'trash',
- },
- android: 'ic_menu_delete',
- web: ['far', 'trash-can'],
- },
- },
- ].filter(Boolean) as NativeDropdownItem[]
-
return (
<>
-
-
-
-
-
-
+
+
+
+ {({props, state}) => {
+ const styles = [
+ style,
+ a.rounded_full,
+ (state.hovered || state.focused || state.pressed) && [
+ web({outline: 0}),
+ alf.atoms.bg_contrast_25,
+ ],
+ ]
+ return isWeb ? (
+
+
+
+ ) : (
+
+
+
+ )
+ }}
+
+
+
+
+
+ {_(msg`Translate`)}
+
+
+
+
+ {_(msg`Copy post text`)}
+
+
+
+ {
+ const url = toShareUrl(href)
+ shareUrl(url)
+ }}>
+
+ {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
+
+
+
+
+
+ {hasSession && (
+ <>
+
+
+
+
+
+ {isThreadMuted
+ ? _(msg`Unmute thread`)
+ : _(msg`Mute thread`)}
+
+
+
+
+ mutedWordsDialogControl.open()}>
+ {_(msg`Mute words & tags`)}
+
+
+
+ {!isAuthor && !isPostHidden && (
+ {
+ openModal({
+ name: 'confirm',
+ title: _(msg`Hide this post?`),
+ message: _(
+ msg`This will hide this post from your feeds.`,
+ ),
+ onPressConfirm: onHidePost,
+ })
+ }}>
+ {_(msg`Hide post`)}
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+ {!isAuthor && (
+ {
+ openModal({
+ name: 'report',
+ uri: postUri,
+ cid: postCid,
+ })
+ }}>
+ {_(msg`Report post`)}
+
+
+ )}
+
+ {isAuthor && (
+ {
+ openModal({
+ name: 'confirm',
+ title: _(msg`Delete this post?`),
+ message: _(msg`Are you sure? This cannot be undone.`),
+ onPressConfirm: onDeletePost,
+ })
+ }}>
+ {_(msg`Delete post`)}
+
+
+ )}
+
+
+
>
)
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 422e23d660..ca4628e5ee 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -210,9 +210,7 @@ let PostCtrls = ({
style={[styles.btn]}
onPress={onShare}
accessibilityRole="button"
- accessibilityLabel={`${
- post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
- } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
+ accessibilityLabel={`${_(msg`Share`)}`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
@@ -228,6 +226,7 @@ let PostCtrls = ({
record={record}
richText={richText}
style={styles.btnPad}
+ hitSlop={big ? HITSLOP_20 : HITSLOP_10}
/>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 55a21e53f8..702ddf1542 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -30,7 +30,8 @@ import {useCloseAnyActiveElement} from '#/state/util'
import * as notifications from 'lib/notifications/notifications'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
-import {useDialogStateContext} from '#/state/dialogs'
+import {useDialogStateContext} from 'state/dialogs'
+import Animated from 'react-native-reanimated'
function ShellInner() {
const isDrawerOpen = useIsDrawerOpen()
@@ -54,9 +55,9 @@ function ShellInner() {
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
const {hasSession, currentAccount} = useSession()
const closeAnyActiveElement = useCloseAnyActiveElement()
+ const {importantForAccessibility} = useDialogStateContext()
// start undefined
const currentAccountDid = React.useRef(undefined)
- const {openDialogs} = useDialogStateContext()
React.useEffect(() => {
let listener = {remove() {}}
@@ -80,19 +81,9 @@ function ShellInner() {
}
}, [currentAccount])
- /**
- * The counterpart to `accessibilityViewIsModal` for Android. This property
- * applies to the parent of all non-modal views, and prevents TalkBack from
- * navigating within content beneath an open dialog.
- *
- * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
- */
- const importantForAccessibility =
- openDialogs.length > 0 ? 'no-hide-descendants' : undefined
-
return (
<>
-
@@ -106,7 +97,7 @@ function ShellInner() {
-
+
diff --git a/web/index.html b/web/index.html
index b6e01ba4c2..de0abfc91b 100644
--- a/web/index.html
+++ b/web/index.html
@@ -47,6 +47,9 @@
height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable both-edges;
}
+ html, body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ }
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea {