Merge branch 'main' into hailey/79fab

This commit is contained in:
Hailey
2025-05-11 08:30:09 -07:00
102 changed files with 63327 additions and 40926 deletions
+7 -21
View File
@@ -98,20 +98,13 @@ appId: xyz.blueskyweb.app
- tapOn: "People"
- tapOn: "Start adding people"
- assertVisible:
id: "listAddUserModal"
- tapOn:
id: "searchInput"
- tapOn: "Search"
- inputText: "b"
- pressKey: Enter
- tapOn:
id: "user-bob.test-addBtn"
- tapOn:
id: "doneBtn"
- assertNotVisible:
id: "listAddUserModal"
- assertVisible:
id: "user-bob.test"
- tapOn: "Add user to list"
- swipe:
direction: DOWN
- assertVisible: "View Bob's profile"
- tapOn: "Posts"
- assertVisible:
@@ -142,23 +135,16 @@ appId: xyz.blueskyweb.app
id: "e2eGotoLists"
- tapOn: "Good Ppl"
- tapOn: "People"
- assertVisible:
label: "Removes users on curatelists from the list"
id: "user-bob.test"
# - tapOn: "People"
- assertVisible: "View Bob's profile"
- tapOn:
point: "90%,43%"
- assertVisible:
id: "userAddRemoveListsModal"
- tapOn:
id: "user-bob.test-addBtn"
- tapOn:
id: "doneBtn"
- assertNotVisible:
id: "userAddRemoveListsModal"
- tapOn:
label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users, or feeds"
- inputText: "bob"
+1 -1
View File
@@ -15,7 +15,7 @@ appId: xyz.blueskyweb.app
- tapOn:
point: "50%,22%"
- waitForAnimationToEnd
- tapOn: "Choose"
- tapOn: "Done"
- waitForAnimationToEnd
- tapOn: "Continue to next step"
- assertVisible: "What are your interests?"
+2 -2
View File
@@ -78,13 +78,13 @@ appId: xyz.blueskyweb.app
id: "changeBannerBtn"
- tapOn: "Upload from Library"
- waitForAnimationToEnd
- tapOn: "Choose"
- tapOn: "Done"
- waitForAnimationToEnd
- tapOn:
id: "changeAvatarBtn"
- tapOn: "Upload from Library"
- waitForAnimationToEnd
- tapOn: "Choose"
- tapOn: "Done"
- waitForAnimationToEnd
- tapOn:
id: "editProfileSaveBtn"
+1
View File
@@ -79,6 +79,7 @@ module.exports = function (_config) {
'eu',
'fi',
'fr',
'fy',
'ga',
'gd',
'gl',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2 12a9.97 9.97 0 0 1 2.929-7.07l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12a7.97 7.97 0 0 0 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12a9.97 9.97 0 0 1-2.929 7.07 1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A4 4 0 0 0 8 12.001a3.98 3.98 0 0 0 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a6 6 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A4 4 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z"/></svg>

After

Width:  |  Height:  |  Size: 879 B

+1
View File
@@ -15,6 +15,7 @@ module.exports = {
'eu',
'fi',
'fr',
'fy',
'ga',
'gd',
'gl',
+2 -2
View File
@@ -69,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.15.5",
"@atproto/api": "^0.15.6",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -218,7 +218,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.128",
"@atproto/dev-env": "^0.3.129",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
+3
View File
@@ -27,6 +27,9 @@ export const atoms = {
relative: {
position: 'relative',
},
static: {
position: 'static',
},
sticky: web({
position: 'sticky',
}),
+4
View File
@@ -4,6 +4,7 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfilesQuery} from '#/state/queries/profile'
@@ -110,6 +111,7 @@ function AccountItem({
const t = useTheme()
const {_} = useLingui()
const verification = useSimpleVerificationState({profile})
const {isActive: live} = useActorStatus(profile)
const onPress = useCallback(() => {
onSelect(account)
@@ -141,6 +143,8 @@ function AccountItem({
avatar={profile?.avatar}
size={36}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
live={live}
hideLiveBadge
/>
<View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
+86
View File
@@ -26,6 +26,7 @@ export type ButtonColor =
| 'secondary'
| 'secondary_inverted'
| 'negative'
| 'negative_secondary'
| 'gradient_primary'
| 'gradient_sky'
| 'gradient_midnight'
@@ -336,6 +337,57 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: t.palette.negative_50,
})
} else {
baseStyles.push(a.border, {
borderColor: t.palette.negative_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.negative_100,
})
}
}
} else if (color === 'negative_secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_50,
dim: t.palette.negative_100,
dark: t.palette.negative_100,
}),
})
hoverStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_100,
dim: t.palette.negative_200,
dark: t.palette.negative_200,
}),
})
} else {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_100,
dim: t.palette.negative_50,
dark: t.palette.negative_50,
}),
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_500,
@@ -425,6 +477,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
secondary: tokens.gradients.sky,
secondary_inverted: tokens.gradients.sky,
negative: tokens.gradients.sky,
negative_secondary: tokens.gradients.sky,
gradient_primary: tokens.gradients.primary,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
@@ -645,6 +698,39 @@ export function useSharedButtonTextStyles() {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
}
} else if (color === 'negative_secondary') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: select(t.name, {
light: t.palette.negative_500,
dim: t.palette.negative_950,
dark: t.palette.negative_900,
}),
})
} else {
baseStyles.push({
color: select(t.name, {
light: t.palette.negative_500,
dim: t.palette.negative_700,
dark: t.palette.negative_700,
}),
opacity: 0.5,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
}
} else {
if (!disabled) {
baseStyles.push({color: t.palette.white})
+13 -3
View File
@@ -307,7 +307,7 @@ export const InnerFlatList = React.forwardRef<
)
})
export function Handle() {
export function Handle({difference = false}: {difference?: boolean}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -328,9 +328,19 @@ export function Handle() {
width: 35,
height: 5,
alignSelf: 'center',
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
difference
? {
// TODO: mixBlendMode is only available on the new architecture -sfn
// backgroundColor: t.palette.white,
// mixBlendMode: 'difference',
backgroundColor: t.palette.white,
opacity: 0.75,
}
: {
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
/>
</Pressable>
+1 -7
View File
@@ -195,13 +195,7 @@ export function Inner({
onDismiss={close}
style={{display: 'flex', flexDirection: 'column'}}>
{header}
<View
style={[
gtMobile ? a.p_2xl : a.p_xl,
a.overflow_hidden,
a.rounded_md,
contentContainerStyle,
]}>
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
{children}
</View>
</DismissableLayer.DismissableLayer>
+7
View File
@@ -8,6 +8,7 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -132,20 +133,25 @@ export function Avatar({
moderationOpts,
onPress,
disabledPreview,
liveOverride,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onPress?: () => void
disabledPreview?: boolean
liveOverride?: boolean
}) {
const moderation = moderateProfile(profile, moderationOpts)
const {isActive: live} = useActorStatus(profile)
return disabledPreview ? (
<UserAvatar
size={40}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
live={liveOverride ?? live}
/>
) : (
<PreviewableUserAvatar
@@ -153,6 +159,7 @@ export function Avatar({
profile={profile}
moderation={moderation.ui('avatar')}
onBeforePress={onPress}
live={liveOverride ?? live}
/>
)
}
+41 -9
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
@@ -8,10 +8,13 @@ import {
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {isTouchDevice} from '#/lib/browser'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -32,6 +35,7 @@ import {
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {LiveStatus} from '#/components/live/LiveStatusDialog'
import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
@@ -105,6 +109,8 @@ const HIDE_DELAY = 150
const HIDE_DURATION = 200
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const navigation = useNavigation<NavigationProp>()
const {refs, floatingStyles} = useFloating({
middleware: floatingMiddlewares,
})
@@ -330,7 +336,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
onPointerEnter={onPointerEnterCard}
onPointerLeave={onPointerLeaveCard}>
<div style={{willChange: 'transform', ...animationStyle}}>
<Card did={props.did} hide={onPress} />
<Card did={props.did} hide={onPress} navigation={navigation} />
</div>
</div>
</Portal>
@@ -339,7 +345,15 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
)
}
let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
let Card = ({
did,
hide,
navigation,
}: {
did: string
hide: () => void
navigation: NavigationProp
}): React.ReactNode => {
const t = useTheme()
const profile = useProfileQuery({did})
@@ -347,24 +361,42 @@ let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
const data = profile.data
const status = useActorStatus(data)
const onPressOpenProfile = useCallback(() => {
if (!status.isActive || !data) return
hide()
navigation.push('Profile', {
name: data.handle,
})
}, [hide, navigation, status, data])
return (
<View
style={[
a.p_lg,
!status.isActive && a.p_lg,
a.border,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.shadow_lg,
{
width: 300,
},
a.w_full,
{maxWidth: status.isActive ? 500 : 300},
]}>
{data && moderationOpts ? (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
status.isActive ? (
<LiveStatus
profile={data}
embed={status.embed}
padding="lg"
onPressOpenProfile={onPressOpenProfile}
/>
) : (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
)
) : (
<View style={[a.justify_center]}>
<View style={[a.justify_center, a.align_center, {minHeight: 200}]}>
<Loader size="xl" />
</View>
)}
+1 -1
View File
@@ -1,4 +1,4 @@
import React from 'react'
import type React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
+8 -3
View File
@@ -3,7 +3,13 @@ import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {
atoms as a,
useBreakpoints,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {Button, type ButtonColor, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
@@ -32,7 +38,6 @@ export function Outer({
testID?: string
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
}>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId()
const descriptionId = React.useId()
@@ -52,7 +57,7 @@ export function Outer({
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={[gtMobile ? {width: 400} : a.w_full]}>
style={web({maxWidth: 400})}>
{children}
</Dialog.ScrollableInner>
</Context.Provider>
+2 -1
View File
@@ -2,6 +2,7 @@ import {useCallback, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {type StatefulControl} from '#/components/dialogs/Context'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
@@ -38,7 +39,7 @@ export function EmailDialog() {
<Dialog.ScrollableInner
label={_(msg`Make adjustments to email settings for your account`)}
style={[{maxWidth: 400}]}>
style={web({maxWidth: 400})}>
<Inner control={emailDialogControl} />
<Dialog.Close />
</Dialog.ScrollableInner>
@@ -45,6 +45,10 @@ export function VerificationReminder({
a.overflow_hidden,
a.pt_md,
t.atoms.bg_contrast_100,
{
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<GradientFill gradient={tokens.gradients.primary} />
<ShieldIcon width={64} fill="white" style={[a.z_10]} />
+4 -1
View File
@@ -16,6 +16,7 @@ import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/Initial
* NUXs
*/
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {isDaysOld} from '#/components/dialogs/nuxs/utils'
type Context = {
activeNux: Nux | undefined
@@ -33,7 +34,9 @@ const queuedNuxs: {
}[] = [
{
id: Nux.InitialVerificationAnnouncement,
enabled: () => true,
enabled: ({currentProfile}) => {
return isDaysOld(2, currentProfile.createdAt)
},
},
]
+18
View File
@@ -0,0 +1,18 @@
const ONE_DAY = 1000 * 60 * 60 * 24
export function isDaysOld(days: number, createdAt?: string) {
/*
* Should never happen because we gate NUXs to only accounts with a valid
* profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
* account is either old enough to be pre-onboarding, or some failure happened
* during account creation. Fail closed. - esb
*/
if (!createdAt) return false
const now = Date.now()
const then = new Date(createdAt).getTime()
const isOldEnough = then + ONE_DAY * days < now
if (isOldEnough) return true
return false
}
@@ -1,9 +1,10 @@
import {useCallback, useImperativeHandle, useState} from 'react'
import {Keyboard} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {useLingui} from '@lingui/react'
import {useTheme} from '#/alf'
import {DateFieldProps} from '#/components/forms/DateField/types'
import {type DateFieldProps} from '#/components/forms/DateField/types'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import * as TextField from '#/components/forms/TextField'
import {DateFieldButton} from './index.shared'
@@ -21,6 +22,7 @@ export function DateField({
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const {i18n} = useLingui()
const t = useTheme()
const [open, setOpen] = useState(false)
@@ -80,6 +82,8 @@ export function DateField({
onConfirm={onChangeInternal}
onCancel={onCancel}
mode="date"
locale={i18n.locale}
is24hourSource="locale"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
+4 -3
View File
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DateFieldProps} from '#/components/forms/DateField/types'
import {type DateFieldProps} from '#/components/forms/DateField/types'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import * as TextField from '#/components/forms/TextField'
import {DateFieldButton} from './index.shared'
@@ -33,7 +33,7 @@ export function DateField({
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const {_} = useLingui()
const {_, i18n} = useLingui()
const t = useTheme()
const control = Dialog.useDialogControl()
@@ -83,10 +83,11 @@ export function DateField({
<View style={[a.relative, a.w_full, a.align_center]}>
<DatePicker
timeZoneOffsetInMinutes={0}
theme={t.name === 'light' ? 'light' : 'dark'}
theme={t.scheme}
date={new Date(toSimpleDateString(value))}
onDateChange={onChangeInternal}
mode="date"
locale={i18n.locale}
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
+4 -2
View File
@@ -8,7 +8,7 @@
*/
import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {formatDistance, type Locale} from 'date-fns'
import {
ca,
cy,
@@ -21,6 +21,7 @@ import {
eu,
fi,
fr,
fy,
gd,
gl,
hi,
@@ -45,7 +46,7 @@ import {
zhTW,
} from 'date-fns/locale'
import {AppLanguage} from '#/locale/languages'
import {type AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
/**
@@ -66,6 +67,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
eu,
fi,
fr,
fy,
ga: undefined,
gd,
gl,
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Live_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2 12A9.97 9.97 0 0 1 4.929 4.93l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12c0 2.072.786 3.958 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12c0 2.761-1.12 5.262-2.929 7.07a1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A3.99 3.99 0 0 0 8 12.001c0 1.035.393 1.978 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a5.99 5.99 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A3.99 3.99 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z',
})
+348
View File
@@ -0,0 +1,348 @@
import {useMemo, useState} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
AppBskyActorStatus,
type AppBskyEmbedExternal,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {differenceInMinutes} from 'date-fns'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
import {cleanError} from '#/lib/strings/errors'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useAgent} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {
useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation,
} from './queries'
import {displayDuration, useDebouncedValue} from './utils'
export function EditLiveDialog({
control,
status,
embed,
}: {
control: Dialog.DialogControlProps
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner status={status} embed={embed} />
</Dialog.Outer>
)
}
function DialogInner({
status,
embed,
}: {
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
}) {
const control = Dialog.useDialogContext()
const {_, i18n} = useLingui()
const t = useTheme()
const agent = useAgent()
const [liveLink, setLiveLink] = useState(embed.external.uri)
const [liveLinkError, setLiveLinkError] = useState('')
const [imageLoadError, setImageLoadError] = useState(false)
const tick = useTickEveryMinute()
const liveLinkUrl = definitelyUrl(liveLink)
const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
const isDirty = liveLinkUrl !== embed.external.uri
const {
data: linkMeta,
isSuccess: hasValidLinkMeta,
isLoading: linkMetaLoading,
error: linkMetaError,
} = useQuery({
enabled: !!debouncedUrl,
queryKey: ['link-meta', debouncedUrl],
queryFn: async () => {
if (!debouncedUrl) return null
return getLinkMeta(agent, debouncedUrl)
},
})
const record = useMemo(() => {
if (!AppBskyActorStatus.isRecord(status.record)) return null
const validation = AppBskyActorStatus.validateRecord(status.record)
if (validation.success) {
return validation.value
}
return null
}, [status])
const {
mutate: goLive,
isPending: isGoingLive,
error: goLiveError,
} = useUpsertLiveStatusMutation(
record?.durationMinutes ?? 0,
linkMeta,
record?.createdAt,
)
const {
mutate: removeLiveStatus,
isPending: isRemovingLiveStatus,
error: removeLiveStatusError,
} = useRemoveLiveStatusMutation()
const {minutesUntilExpiry, expiryDateTime} = useMemo(() => {
tick!
const expiry = new Date(status.expiresAt ?? new Date())
return {
expiryDateTime: expiry,
minutesUntilExpiry: differenceInMinutes(expiry, new Date()),
}
}, [tick, status.expiresAt])
const submitDisabled =
isGoingLive ||
!hasValidLinkMeta ||
debouncedUrl !== liveLinkUrl ||
isRemovingLiveStatus
return (
<Dialog.ScrollableInner
label={_(msg`You are Live`)}
style={web({maxWidth: 420})}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>You are Live</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<ClockIcon style={[t.atoms.text_contrast_high]} size="sm" />
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{typeof record?.durationMinutes === 'number' ? (
<Trans>
Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '}
{i18n.date(expiryDateTime, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}
</Trans>
) : (
<Trans>No expiry set</Trans>
)}
</Text>
</View>
</View>
<View style={[a.gap_sm]}>
<View>
<TextField.LabelText>
<Trans>Live link</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
<TextField.Input
label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)}
value={liveLink}
onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')}
onBlur={() => {
if (!definitelyUrl(liveLink)) {
setLiveLinkError('Invalid URL')
}
}}
returnKeyType="done"
autoCapitalize="none"
autoComplete="url"
autoCorrect={false}
onSubmitEditing={() => {
if (isDirty && !submitDisabled) {
goLive()
}
}}
/>
</TextField.Root>
</View>
{(liveLinkError || linkMetaError) && (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<WarningIcon
style={[{color: t.palette.negative_500}]}
size="sm"
/>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_1,
a.font_bold,
{color: t.palette.negative_500},
]}>
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Text>
</View>
)}
{(linkMeta || linkMetaLoading) && (
<View
style={[
a.w_full,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.flex_row,
a.rounded_sm,
a.overflow_hidden,
a.align_stretch,
]}>
{(!linkMeta || linkMeta.image) && (
<View
style={[
t.atoms.bg_contrast_25,
{minHeight: 64, width: 114},
a.justify_center,
a.align_center,
]}>
{linkMeta?.image && (
<Image
source={linkMeta.image}
accessibilityIgnoresInvertColors
transition={200}
style={[a.absolute, a.inset_0]}
contentFit="cover"
onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)}
/>
)}
{linkMeta && imageLoadError && (
<CircleXIcon
style={[t.atoms.text_contrast_low]}
size="xl"
/>
)}
</View>
)}
<View
style={[
a.flex_1,
a.justify_center,
a.py_sm,
a.gap_xs,
a.px_md,
]}>
{linkMeta ? (
<>
<Text
numberOfLines={2}
style={[a.leading_snug, a.font_bold, a.text_md]}>
{linkMeta.title || linkMeta.url}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<GlobeIcon
size="xs"
style={[t.atoms.text_contrast_low]}
/>
<Text
numberOfLines={1}
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{toNiceDomain(linkMeta.url)}
</Text>
</View>
</>
) : (
<>
<LoadingPlaceholder height={16} width={128} />
<LoadingPlaceholder height={12} width={72} />
</>
)}
</View>
</View>
)}
</View>
{goLiveError && (
<Admonition type="error">{cleanError(goLiveError)}</Admonition>
)}
{removeLiveStatusError && (
<Admonition type="error">
{cleanError(removeLiveStatusError)}
</Admonition>
)}
<View
style={platform({
native: [a.gap_md, a.pt_lg],
web: [a.flex_row_reverse, a.gap_md, a.align_center],
})}>
{isDirty ? (
<Button
label={_(msg`Save`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => goLive()}
disabled={submitDisabled}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
{isGoingLive && <ButtonIcon icon={Loader} />}
</Button>
) : (
<Button
label={_(msg`Close`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
<Button
label={_(msg`Remove live status`)}
onPress={() => removeLiveStatus()}
size={platform({native: 'large', web: 'small'})}
color="negative_secondary"
variant="solid"
disabled={isRemovingLiveStatus || isGoingLive}>
<ButtonText>
<Trans>Remove live status</Trans>
</ButtonText>
{isRemovingLiveStatus && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+352
View File
@@ -0,0 +1,352 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
import {cleanError} from '#/lib/strings/errors'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {useUpsertLiveStatusMutation} from './queries'
import {displayDuration, useDebouncedValue} from './utils'
export function GoLiveDialog({
control,
profile,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner profile={profile} />
</Dialog.Outer>
)
}
// Possible durations: max 4 hours, 5 minute intervals
const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5)
function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
const control = Dialog.useDialogContext()
const {_, i18n} = useLingui()
const t = useTheme()
const agent = useAgent()
const [liveLink, setLiveLink] = useState('')
const [liveLinkError, setLiveLinkError] = useState('')
const [imageLoadError, setImageLoadError] = useState(false)
const [duration, setDuration] = useState(60)
const moderationOpts = useModerationOpts()
const tick = useTickEveryMinute()
const time = useCallback(
(offset: number) => {
tick!
const date = new Date()
date.setMinutes(date.getMinutes() + offset)
return i18n
.date(date, {hour: 'numeric', minute: '2-digit', hour12: true})
.toLocaleUpperCase()
.replace(' ', '')
},
[tick, i18n],
)
const onChangeDuration = useCallback((newDuration: string) => {
setDuration(Number(newDuration))
}, [])
const liveLinkUrl = definitelyUrl(liveLink)
const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
const hasLink = !!debouncedUrl
const {
data: linkMeta,
isSuccess: hasValidLinkMeta,
isLoading: linkMetaLoading,
error: linkMetaError,
} = useQuery({
enabled: !!debouncedUrl,
queryKey: ['link-meta', debouncedUrl],
queryFn: async () => {
if (!debouncedUrl) return null
return getLinkMeta(agent, debouncedUrl)
},
})
const {
mutate: goLive,
isPending: isGoingLive,
error: goLiveError,
} = useUpsertLiveStatusMutation(duration, linkMeta)
return (
<Dialog.ScrollableInner
label={_(msg`Go Live`)}
style={web({maxWidth: 420})}>
<View style={[a.gap_xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Go Live</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
Add a temporary live status to your profile. When someone clicks
on your avatar, theyll see information about your live event.
</Trans>
</Text>
</View>
{moderationOpts && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
liveOverride
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
)}
<View style={[a.gap_sm]}>
<View>
<TextField.LabelText>
<Trans>Live link</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
<TextField.Input
label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)}
value={liveLink}
onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')}
onBlur={() => {
if (!definitelyUrl(liveLink)) {
setLiveLinkError('Invalid URL')
}
}}
returnKeyType="done"
autoCapitalize="none"
autoComplete="url"
autoCorrect={false}
/>
</TextField.Root>
</View>
{(liveLinkError || linkMetaError) && (
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
<WarningIcon
style={[{color: t.palette.negative_500}]}
size="sm"
/>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_1,
a.font_bold,
{color: t.palette.negative_500},
]}>
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Text>
</View>
)}
{(linkMeta || linkMetaLoading) && (
<View
style={[
a.w_full,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.flex_row,
a.rounded_sm,
a.overflow_hidden,
a.align_stretch,
]}>
{(!linkMeta || linkMeta.image) && (
<View
style={[
t.atoms.bg_contrast_25,
{minHeight: 64, width: 114},
a.justify_center,
a.align_center,
]}>
{linkMeta?.image && (
<Image
source={linkMeta.image}
accessibilityIgnoresInvertColors
transition={200}
style={[a.absolute, a.inset_0]}
contentFit="cover"
onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)}
/>
)}
{linkMeta && imageLoadError && (
<CircleXIcon
style={[t.atoms.text_contrast_low]}
size="xl"
/>
)}
</View>
)}
<View
style={[
a.flex_1,
a.justify_center,
a.py_sm,
a.gap_xs,
a.px_md,
]}>
{linkMeta ? (
<>
<Text
numberOfLines={2}
style={[a.leading_snug, a.font_bold, a.text_md]}>
{linkMeta.title || linkMeta.url}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<GlobeIcon
size="xs"
style={[t.atoms.text_contrast_low]}
/>
<Text
numberOfLines={1}
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{toNiceDomain(linkMeta.url)}
</Text>
</View>
</>
) : (
<>
<LoadingPlaceholder height={16} width={128} />
<LoadingPlaceholder height={12} width={72} />
</>
)}
</View>
</View>
)}
</View>
{hasLink && (
<View>
<TextField.LabelText>
<Trans>Go live for</Trans>
</TextField.LabelText>
<Select.Root
value={String(duration)}
onValueChange={onChangeDuration}>
<Select.Trigger label={_(msg`Select duration`)}>
<Text style={[ios(a.py_xs)]}>
{displayDuration(i18n, duration)}
{' '}
<Text style={[t.atoms.text_contrast_low]}>
{time(duration)}
</Text>
</Text>
<Select.Icon />
</Select.Trigger>
<Select.Content
renderItem={(item, _i, selectedValue) => {
const label = displayDuration(i18n, item)
return (
<Select.Item value={String(item)} label={label}>
<Select.ItemIndicator />
<Select.ItemText>
{label}
{' '}
<Text
style={[
native(a.text_md),
web(a.ml_xs),
selectedValue === String(item)
? t.atoms.text_contrast_medium
: t.atoms.text_contrast_low,
a.font_normal,
]}>
{time(item)}
</Text>
</Select.ItemText>
</Select.Item>
)
}}
items={DURATIONS}
valueExtractor={d => String(d)}
/>
</Select.Root>
</View>
)}
{goLiveError && (
<Admonition type="error">{cleanError(goLiveError)}</Admonition>
)}
<View
style={platform({
native: [a.gap_md, a.pt_lg],
web: [a.flex_row_reverse, a.gap_md, a.align_center],
})}>
{hasLink && (
<Button
label={_(msg`Go Live`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => goLive()}
disabled={
isGoingLive || !hasValidLinkMeta || debouncedUrl !== liveLinkUrl
}>
<ButtonText>
<Trans>Go Live</Trans>
</ButtonText>
{isGoingLive && <ButtonIcon icon={Loader} />}
</Button>
)}
<Button
label={_(msg`Cancel`)}
onPress={() => control.close()}
size={platform({native: 'large', web: 'small'})}
color="secondary"
variant={platform({native: 'solid', web: 'ghost'})}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+53
View File
@@ -0,0 +1,53 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function LiveIndicator({
size = 'small',
style,
}: {
size?: 'tiny' | 'small' | 'large'
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const fontSize = {
tiny: {fontSize: 7, letterSpacing: tokens.TRACKING},
small: a.text_2xs,
large: a.text_xs,
}[size]
return (
<View
style={[
a.absolute,
a.w_full,
a.align_center,
a.pointer_events_none,
{bottom: size === 'large' ? -8 : -5},
style,
]}>
<View
style={{
backgroundColor: t.palette.negative_500,
paddingVertical: size === 'large' ? 2 : 1,
paddingHorizontal: size === 'large' ? 4 : 3,
borderRadius: size === 'large' ? 5 : tokens.borderRadius.xs,
}}>
<Text
style={[
a.text_center,
a.font_bold,
fontSize,
{color: t.palette.white},
]}>
<Trans comment="Live status indicator on avatar. Should be extremely short, not much space for more than 4 characters">
LIVE
</Trans>
</Text>
</View>
</View>
)
}
+212
View File
@@ -0,0 +1,212 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
import {LiveIndicator} from './LiveIndicator'
export function LiveStatusDialog({
control,
profile,
embed,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
}) {
const navigation = useNavigation<NavigationProp>()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle difference={!!embed.external.thumb} />
<DialogInner profile={profile} embed={embed} navigation={navigation} />
</Dialog.Outer>
)
}
function DialogInner({
profile,
embed,
navigation,
}: {
profile: bsky.profile.AnyProfileView
embed: AppBskyEmbedExternal.View
navigation: NavigationProp
}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
const onPressOpenProfile = useCallback(() => {
control.close(() => {
navigation.push('Profile', {
name: profile.handle,
})
})
}, [navigation, profile.handle, control])
return (
<Dialog.ScrollableInner
label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
contentContainerStyle={[a.pt_0, a.px_0]}
style={[web({maxWidth: 420}), a.overflow_hidden]}>
<LiveStatus
profile={profile}
embed={embed}
onPressOpenProfile={onPressOpenProfile}
/>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
export function LiveStatus({
profile,
embed,
padding = 'xl',
onPressOpenProfile,
}: {
profile: bsky.profile.AnyProfileView
embed: AppBskyEmbedExternal.View
padding?: 'lg' | 'xl'
onPressOpenProfile: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const queryClient = useQueryClient()
const openLink = useOpenLink()
const moderationOpts = useModerationOpts()
return (
<>
{embed.external.thumb && (
<View
style={[
t.atoms.bg_contrast_25,
a.w_full,
{aspectRatio: 1.91},
android([
a.overflow_hidden,
{
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]),
]}>
<Image
source={embed.external.thumb}
contentFit="cover"
style={[a.absolute, a.inset_0]}
accessibilityIgnoresInvertColors
/>
<LiveIndicator
size="large"
style={[
a.absolute,
{top: tokens.space.lg, left: tokens.space.lg},
a.align_start,
]}
/>
</View>
)}
<View
style={[
a.gap_lg,
padding === 'xl'
? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg]
: a.p_lg,
]}>
<View style={[a.flex_1, a.justify_center, a.gap_2xs]}>
<Text
numberOfLines={3}
style={[a.leading_snug, a.font_bold, a.text_xl]}>
{embed.external.title || embed.external.uri}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Globe_Stroke2_Corner0_Rounded
size="xs"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{toNiceDomain(embed.external.uri)}
</Text>
</View>
</View>
<Button
label={_(msg`Watch now`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => {
logger.metric('live:card:watch', {subject: profile.did})
openLink(embed.external.uri, false)
}}>
<ButtonText>
<Trans>Watch now</Trans>
</ButtonText>
<ButtonIcon icon={SquareArrowTopRightIcon} />
</Button>
<View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} />
{moderationOpts && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
{/* Ensure wide enough on web hover */}
<View style={[a.flex_1, web({minWidth: 100})]}>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</View>
<Button
label={_(msg`Open profile`)}
size="small"
color="secondary"
variant="solid"
onPress={() => {
logger.metric('live:card:openProfile', {subject: profile.did})
unstableCacheProfileView(queryClient, profile)
onPressOpenProfile()
}}>
<ButtonText>
<Trans>Open profile</Trans>
</ButtonText>
</Button>
</ProfileCard.Header>
)}
<Text
style={[
a.w_full,
a.text_center,
t.atoms.text_contrast_low,
a.text_sm,
]}>
<Trans>Live feature is in beta testing</Trans>
</Text>
</View>
</>
)
}
+187
View File
@@ -0,0 +1,187 @@
import {
type $Typed,
type AppBskyActorStatus,
type AppBskyEmbedExternal,
ComAtprotoRepoPutRecord,
} from '@atproto/api'
import {retry} from '@atproto/common-web'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve'
import {type LinkMeta} from '#/lib/link-meta/link-meta'
import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog'
export function useUpsertLiveStatusMutation(
duration: number,
linkMeta: LinkMeta | null | undefined,
createdAt?: string,
) {
const {currentAccount} = useSession()
const agent = useAgent()
const queryClient = useQueryClient()
const control = useDialogContext()
const {_} = useLingui()
return useMutation({
mutationFn: async () => {
if (!currentAccount) throw new Error('Not logged in')
let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
if (linkMeta) {
let thumb
if (linkMeta.image) {
try {
const img = await imageToThumb(linkMeta.image)
if (img) {
const blob = await uploadBlob(
agent,
img.source.path,
img.source.mime,
)
thumb = blob.data.blob
}
} catch (e: any) {
logger.error(`Failed to upload thumbnail for live status`, {
url: linkMeta.url,
image: linkMeta.image,
safeMessage: e,
})
}
}
embed = {
$type: 'app.bsky.embed.external',
external: {
$type: 'app.bsky.embed.external#external',
title: linkMeta.title ?? '',
description: linkMeta.description ?? '',
uri: linkMeta.url,
thumb,
},
}
}
const record = {
$type: 'app.bsky.actor.status',
createdAt: createdAt ?? new Date().toISOString(),
status: 'app.bsky.actor.status#live',
durationMinutes: duration,
embed,
} satisfies AppBskyActorStatus.Record
const upsert = async () => {
const repo = currentAccount.did
const collection = 'app.bsky.actor.status'
const existing = await agent.com.atproto.repo
.getRecord({repo, collection, rkey: 'self'})
.catch(_e => undefined)
await agent.com.atproto.repo.putRecord({
repo,
collection,
rkey: 'self',
record,
swapRecord: existing?.data.cid || null,
})
}
await retry(upsert, {
maxRetries: 5,
retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
})
return {
record,
image: linkMeta?.image,
}
},
onError: (e: any) => {
logger.error(`Failed to upsert live status`, {
url: linkMeta?.url,
image: linkMeta?.image,
safeMessage: e,
})
},
onSuccess: ({record, image}) => {
if (createdAt) {
logger.metric('live:edit', {duration: record.durationMinutes})
} else {
logger.metric('live:create', {duration: record.durationMinutes})
}
Toast.show(_(msg`You are now live!`))
control.close(() => {
if (!currentAccount) return
const expiresAt = new Date(record.createdAt)
expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes)
updateProfileShadow(queryClient, currentAccount.did, {
status: {
$type: 'app.bsky.actor.defs#statusView',
status: 'app.bsky.actor.status#live',
isActive: true,
expiresAt: expiresAt.toISOString(),
embed:
record.embed && image
? {
$type: 'app.bsky.embed.external#view',
external: {
...record.embed.external,
$type: 'app.bsky.embed.external#viewExternal',
thumb: image,
},
}
: undefined,
record,
},
})
})
},
})
}
export function useRemoveLiveStatusMutation() {
const {currentAccount} = useSession()
const agent = useAgent()
const queryClient = useQueryClient()
const control = useDialogContext()
const {_} = useLingui()
return useMutation({
mutationFn: async () => {
if (!currentAccount) throw new Error('Not logged in')
await agent.app.bsky.actor.status.delete({
repo: currentAccount.did,
rkey: 'self',
})
},
onError: (e: any) => {
logger.error(`Failed to remove live status`, {
safeMessage: e,
})
},
onSuccess: () => {
logger.metric('live:remove', {})
Toast.show(_(msg`You are no longer live`))
control.close(() => {
if (!currentAccount) return
updateProfileShadow(queryClient, currentAccount.did, {
status: undefined,
})
})
},
})
}
+41
View File
@@ -0,0 +1,41 @@
import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import type * as bsky from '#/types/bsky'
export const LIVE_DIDS: Record<string, true> = {
'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com
'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social
}
export const LIVE_SOURCES: Record<string, true> = {
'nba.com': true,
'twitch.tv': true,
}
// TEMP: dumb gating
export function temp__canBeLive(profile: bsky.profile.AnyProfileView) {
if (__DEV__)
return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did]
return !!LIVE_DIDS[profile.did]
}
export function temp__canGoLive(profile: bsky.profile.AnyProfileView) {
if (__DEV__) return true
return !!LIVE_DIDS[profile.did]
}
// status must have a embed, and the embed must be an approved host for the status to be valid
export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) {
if (status.status !== 'app.bsky.actor.status#live') return false
try {
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
return !!LIVE_SOURCES[url.hostname]
} else {
return false
}
} catch {
return false
}
}
+37
View File
@@ -0,0 +1,37 @@
import {useEffect, useState} from 'react'
import {type I18n} from '@lingui/core'
import {plural} from '@lingui/macro'
export function displayDuration(i18n: I18n, durationInMinutes: number) {
const roundedDurationInMinutes = Math.round(durationInMinutes)
const hours = Math.floor(roundedDurationInMinutes / 60)
const minutes = roundedDurationInMinutes % 60
const minutesString = i18n._(
plural(minutes, {one: '# minute', other: '# minutes'}),
)
return hours > 0
? i18n._(
minutes > 0
? plural(hours, {
one: `# hour ${minutesString}`,
other: `# hours ${minutesString}`,
})
: plural(hours, {
one: '# hour',
other: '# hours',
}),
)
: minutesString
}
// Trailing debounce
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
+51
View File
@@ -0,0 +1,51 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyEmbedExternal,
} from '@atproto/api'
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useTickEveryMinute} from '#/state/shell'
import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
import type * as bsky from '#/types/bsky'
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
return useMemo(() => {
tick! // revalidate every minute
if (
shadowed &&
temp__canBeLive(shadowed) &&
'status' in shadowed &&
shadowed.status &&
temp__isStatusValid(shadowed.status) &&
isStatusStillActive(shadowed.status.expiresAt)
) {
return {
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
+7 -7
View File
@@ -1,10 +1,10 @@
import {
AppBskyFeedDefs,
AppBskyGraphDefs,
ComAtprotoRepoStrongRef,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {type BskyAgent} from '@atproto/api'
import {POST_IMG_MAX} from '#/lib/constants'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
@@ -22,9 +22,9 @@ import {
isBskyStartUrl,
isShortLink,
} from '#/lib/strings/url-helpers'
import {ComposerImage} from '#/state/gallery'
import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery'
import {Gif} from '#/state/queries/tenor'
import {type Gif} from '#/state/queries/tenor'
import {createGIFDescription} from '../gif-alt-text'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
@@ -213,7 +213,7 @@ async function resolveExternal(
}
}
async function imageToThumb(
export async function imageToThumb(
imageUri: string,
): Promise<ComposerImage | undefined> {
try {
+1
View File
@@ -31,6 +31,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social
}
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
+30
View File
@@ -372,3 +372,33 @@ export function getServiceAuthAudFromUrl(url: string | URL): string | null {
}
return `did:web:${hostname}`
}
// passes URL.parse, and has a TLD etc
export function definitelyUrl(maybeUrl: string) {
try {
if (maybeUrl.endsWith('.')) return null
// Prepend 'https://' if the input doesn't start with a protocol
if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) {
maybeUrl = 'https://' + maybeUrl
}
const url = new URL(maybeUrl)
// Extract the hostname and split it into labels
const hostname = url.hostname
const labels = hostname.split('.')
// Ensure there are at least two labels (e.g., 'example' and 'com')
if (labels.length < 2) return null
const tld = labels[labels.length - 1]
// Check that the TLD is at least two characters long and contains only letters
if (!/^[a-z]{2,}$/i.test(tld)) return null
return url.toString()
} catch {
return null
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import * as bcp47Match from 'bcp-47-match'
import lande from 'lande'
@@ -181,6 +181,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.fi
case 'fr':
return AppLanguage.fr
case 'fy':
return AppLanguage.fy
case 'ga':
return AppLanguage.ga
case 'gd':
+9
View File
@@ -25,6 +25,7 @@ import {messages as messagesEs} from '#/locale/locales/es/messages'
import {messages as messagesEu} from '#/locale/locales/eu/messages'
import {messages as messagesFi} from '#/locale/locales/fi/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages'
import {messages as messagesFy} from '#/locale/locales/fy/messages'
import {messages as messagesGa} from '#/locale/locales/ga/messages'
import {messages as messagesGd} from '#/locale/locales/gd/messages'
import {messages as messagesGl} from '#/locale/locales/gl/messages'
@@ -161,6 +162,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.fy: {
i18n.loadAndActivate({locale, messages: messagesFy})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/fy'),
import('@formatjs/intl-numberformat/locale-data/fy'),
])
break
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await Promise.all([
+4
View File
@@ -64,6 +64,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/fr/messages`)
break
}
case AppLanguage.fy: {
mod = await import(`./locales/fy/messages`)
break
}
case AppLanguage.ga: {
mod = await import(`./locales/ga/messages`)
break
+2
View File
@@ -19,6 +19,7 @@ export enum AppLanguage {
eu = 'eu',
fi = 'fi',
fr = 'fr',
fy = 'fy',
ga = 'ga',
gd = 'gd',
gl = 'gl',
@@ -66,6 +67,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.eu, name: 'Euskera Basque'},
{code2: AppLanguage.fi, name: 'Suomi Finnish'},
{code2: AppLanguage.fr, name: 'Français French'},
{code2: AppLanguage.fy, name: 'Frysk Frisian'},
{code2: AppLanguage.ga, name: 'Gaeilge Irish'},
{code2: AppLanguage.gd, name: 'Gàidhlig Scottish Gaelic'},
{code2: AppLanguage.gl, name: 'Galego Galician'},
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
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
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
File diff suppressed because it is too large Load Diff
+9
View File
@@ -383,4 +383,13 @@ export type MetricEvents = {
}
'verification:settings:hideBadges': {}
'verification:settings:unHideBadges': {}
'live:create': {duration: number}
'live:edit': {}
'live:remove': {}
'live:card:open': {subject: string; from: 'post' | 'profile'}
'live:card:watch': {subject: string}
'live:card:openProfile': {subject: string}
'live:view:profile': {subject: string}
'live:view:post': {subject: string; feed?: string}
}
@@ -9,6 +9,7 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
@@ -138,6 +139,8 @@ let ProfileHeaderStandard = ({
[currentAccount, profile],
)
const {isActive: live} = useActorStatus(profile)
return (
<ProfileHeaderShell
profile={profile}
@@ -228,7 +231,8 @@ let ProfileHeaderStandard = ({
) : null}
<ProfileMenu profile={profile} />
</View>
<View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}>
<View
style={[a.flex_col, a.gap_2xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<Text
emoji
+69 -17
View File
@@ -1,4 +1,4 @@
import React, {memo} from 'react'
import React, {memo, useEffect} from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import Animated, {
measure,
@@ -13,8 +13,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {type Shadow} from '#/state/cache/types'
import {useLightboxControls} from '#/state/lightbox'
@@ -23,7 +26,11 @@ import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {UserBanner} from '#/view/com/util/UserBanner'
import {atoms as a, platform, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {LiveIndicator} from '#/components/live/LiveIndicator'
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {GrowableAvatar} from './GrowableAvatar'
@@ -50,6 +57,8 @@ let ProfileHeaderShell = ({
const {openLightbox} = useLightboxControls()
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
const playHaptic = useHaptics()
const liveStatusControl = useDialogControl()
const aviRef = useAnimatedRef()
@@ -84,23 +93,45 @@ let ProfileHeaderShell = ({
[openLightbox],
)
const onPressAvi = React.useCallback(() => {
const modui = moderation.ui('avatar')
const avatar = profile.avatar
if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
}, [profile, moderation, _openLightbox, aviRef])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const live = useActorStatus(profile)
useEffect(() => {
if (live.isActive) {
logger.metric('live:view:profile', {subject: profile.did})
}
}, [live.isActive, profile.did])
const onPressAvi = React.useCallback(() => {
if (live.isActive) {
playHaptic('Light')
logger.metric('live:card:open', {subject: profile.did, from: 'profile'})
liveStatusControl.open()
} else {
const modui = moderation.ui('avatar')
const avatar = profile.avatar
if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
}
}, [
profile,
moderation,
_openLightbox,
aviRef,
liveStatusControl,
live,
playHaptic,
])
return (
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View
@@ -174,21 +205,44 @@ let ProfileHeaderShell = ({
<View
style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
a.rounded_full,
{
borderWidth: live.isActive ? 3 : 2,
borderColor: live.isActive
? t.palette.negative_500
: t.atoms.bg.backgroundColor,
},
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<Animated.View ref={aviRef} collapsable={false}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
size={live.isActive ? 88 : 90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
{live.isActive && <LiveIndicator size="large" />}
</Animated.View>
</View>
</TouchableWithoutFeedback>
</GrowableAvatar>
{live.isActive &&
(isMe ? (
<EditLiveDialog
control={liveStatusControl}
status={live}
embed={live.embed}
/>
) : (
<LiveStatusDialog
control={liveStatusControl}
status={live}
embed={live.embed}
profile={profile}
/>
))}
</View>
)
}
@@ -223,8 +277,6 @@ const styles = StyleSheet.create({
avi: {
width: 94,
height: 94,
borderRadius: 47,
borderWidth: 2,
},
aviLabeler: {
borderRadius: 10,
+6
View File
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useActorStatus} from '#/lib/actor-status'
import {IS_INTERNAL} from '#/lib/app-info'
import {HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
@@ -287,6 +288,7 @@ function ProfilePreview({
const verificationState = useFullVerificationState({
profile: shadow,
})
const {isActive: live} = useActorStatus(profile)
if (!moderationOpts) return null
@@ -303,6 +305,7 @@ function ProfilePreview({
avatar={shadow.avatar}
moderation={moderation.ui('avatar')}
type={shadow.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
<View
@@ -468,6 +471,7 @@ function AccountRow({
const moderationOpts = useModerationOpts()
const removePromptControl = Prompt.usePromptControl()
const {removeAccount} = useSessionApi()
const {isActive: live} = useActorStatus(profile)
const onSwitchAccount = () => {
if (pendingDid) return
@@ -485,6 +489,8 @@ function AccountRow({
avatar={profile.avatar}
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
type={profile.associated?.labeler ? 'labeler' : 'user'}
live={live}
hideLiveBadge
/>
) : (
<View style={[{width: 28}]} />
+7
View File
@@ -31,6 +31,7 @@ export interface ProfileShadow {
muted: boolean | undefined
blockingUri: string | undefined
verification: AppBskyActorDefs.VerificationState
status: AppBskyActorDefs.StatusView | undefined
}
const shadows: WeakMap<
@@ -138,6 +139,12 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
},
verification:
'verification' in shadow ? shadow.verification : profile.verification,
status:
'status' in shadow
? shadow.status
: 'status' in profile
? profile.status
: undefined,
})
}
@@ -16,6 +16,7 @@ import {
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
@@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({
setLimitLines(false)
}, [setLimitLines])
const {isActive: live} = useActorStatus(post.author)
if (!record) {
return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
}
@@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
@@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
{showChildReplyLine && (
+6 -9
View File
@@ -27,7 +27,6 @@ import {
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
import {atoms as a} from '#/alf'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichText} from '#/components/RichText'
@@ -174,14 +173,12 @@ function PostInner({
{showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<AviFollowButton author={post.author} moderation={moderation}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
</AviFollowButton>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<View style={styles.layoutContent}>
<PostMeta
-143
View File
@@ -1,143 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {
DropdownItem,
NativeDropdown,
} from '#/view/com/util/forms/NativeDropdown'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, select, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
export function AviFollowButton({
author,
moderation,
children,
}: {
author: AppBskyActorDefs.ProfileViewBasic
moderation: ModerationDecision
children: React.ReactNode
}) {
const {_} = useLingui()
const t = useTheme()
const profile = useProfileShadow(author)
const {follow} = useFollowMethods({
profile: profile,
logContext: 'AvatarButton',
})
const {currentAccount, hasSession} = useSession()
const navigation = useNavigation<NavigationProp>()
const name = sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const isFollowing =
profile.viewer?.following || profile.did === currentAccount?.did
function onPress() {
follow()
Toast.show(_(msg`Following ${name}`))
}
const items: DropdownItem[] = [
{
label: _(msg`View profile`),
onPress: () => {
navigation.navigate('Profile', {name: profile.did})
},
icon: {
ios: {
name: 'arrow.up.right.square',
},
android: '',
web: ['far', 'arrow-up-right-from-square'],
},
},
{
label: _(msg`Follow ${name}`),
onPress: onPress,
icon: {
ios: {
name: 'person.badge.plus',
},
android: '',
web: ['far', 'user-plus'],
},
},
]
return hasSession ? (
<View style={a.relative}>
{children}
{!isFollowing && (
<Button
label={_(msg`Open ${name} profile shortcut menu`)}
style={[
a.rounded_full,
a.absolute,
{
bottom: -7,
right: -7,
},
]}>
<NativeDropdown items={items}>
<View
style={[
{
// An asymmetric hit slop
// to prioritize bottom right taps.
paddingTop: 2,
paddingLeft: 2,
paddingBottom: 6,
paddingRight: 6,
},
a.align_center,
a.justify_center,
a.rounded_full,
]}>
<View
style={[
a.rounded_full,
a.align_center,
select(t.name, {
light: t.atoms.bg_contrast_100,
dim: t.atoms.bg_contrast_100,
dark: t.atoms.bg_contrast_200,
}),
{
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
},
]}>
<Plus
size="sm"
fill={
select(t.name, {
light: t.atoms.bg_contrast_600,
dim: t.atoms.bg_contrast_500,
dark: t.atoms.bg_contrast_600,
}).backgroundColor
}
/>
</View>
</View>
</NativeDropdown>
</Button>
)}
</View>
) : (
children
)
}
@@ -1,5 +0,0 @@
import React from 'react'
export function AviFollowButton({children}: {children: React.ReactNode}) {
return children
}
+29 -3
View File
@@ -1,4 +1,4 @@
import React, {memo, useCallback} from 'react'
import React, {memo, useCallback, useRef} from 'react'
import {
ActivityIndicator,
AppState,
@@ -19,6 +19,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {isStatusStillActive} from '#/lib/actor-status'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {logEvent} from '#/lib/statsig/statsig'
@@ -52,6 +53,7 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg'
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
@@ -775,6 +777,31 @@ let PostFeed = ({
)
}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
const seenActorWithStatusRef = useRef<Set<string>>(new Set())
const onItemSeen = useCallback(
(item: FeedRow) => {
feedFeedback.onItemSeen(item)
if (item.type === 'sliceItem') {
const actor = item.slice.items[item.indexInSlice].post.author
if (
actor.status &&
temp__canBeLive(actor) &&
temp__isStatusValid(actor.status) &&
isStatusStillActive(actor.status.expiresAt)
) {
if (!seenActorWithStatusRef.current.has(actor.did)) {
seenActorWithStatusRef.current.add(actor.did)
logger.metric('live:view:post', {
subject: actor.did,
feed,
})
}
}
}
},
[feedFeedback, feed],
)
return (
<View testID={testID} style={style}>
<List
@@ -797,7 +824,6 @@ let PostFeed = ({
onEndReachedThreshold={2} // number of posts left to trigger load more
removeClippedSubviews={true}
extraData={extraData}
// @ts-ignore our .web version only -prf
desktopFixedHeight={
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
}
@@ -805,7 +831,7 @@ let PostFeed = ({
windowSize={9}
maxToRenderPerBatch={isIOS ? 5 : 1}
updateCellsBatchingPeriod={40}
onItemSeen={feedFeedback.onItemSeen}
onItemSeen={onItemSeen}
/>
</View>
)
+12 -11
View File
@@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
@@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import * as bsky from '#/types/bsky'
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
import {AviFollowButton} from './AviFollowButton'
interface FeedItemProps {
record: AppBskyFeedPost.Record
@@ -251,6 +251,8 @@ let FeedItemInner = ({
? rootPost.threadgate.record
: undefined
const {isActive: live} = useActorStatus(post.author)
return (
<Link
testID={`feedItem-by-${post.author.handle}`}
@@ -381,15 +383,14 @@ let FeedItemInner = ({
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<AviFollowButton author={post.author} moderation={moderation}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
/>
</AviFollowButton>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
live={live}
/>
{isThreadParent && (
<View
style={[
@@ -397,7 +398,7 @@ let FeedItemInner = ({
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginTop: 4,
marginTop: live ? 8 : 4,
},
]}
/>
+38
View File
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {HITSLOP_20} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
@@ -23,12 +24,14 @@ import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
@@ -38,6 +41,9 @@ import {
} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {GoLiveDialog} from '#/components/live/GoLiveDialog'
import {temp__canGoLive} from '#/components/live/temp'
import * as Menu from '#/components/Menu'
import {
ReportDialog,
@@ -77,6 +83,7 @@ let ProfileMenu = ({
const blockPromptControl = Prompt.usePromptControl()
const loggedOutWarningPromptControl = Prompt.usePromptControl()
const goLiveDialogControl = useDialogControl()
const showLoggedOutWarning = React.useMemo(() => {
return (
@@ -201,6 +208,8 @@ let ProfileMenu = ({
return v.issuer === currentAccount?.did
}) ?? []
const status = useActorStatus(profile)
return (
<EventStopper onKeyDown={false}>
<Menu.Root>
@@ -290,6 +299,25 @@ let ProfileMenu = ({
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
{isSelf && temp__canGoLive(profile) && (
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={
status.isActive
? _(msg`Edit live status`)
: _(msg`Go live`)
}
onPress={goLiveDialogControl.open}>
<Menu.ItemText>
{status.isActive ? (
<Trans>Edit live status</Trans>
) : (
<Trans>Go live</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={LiveIcon} />
</Menu.Item>
)}
{verification.viewer.role === 'verifier' &&
!verification.profile.isViewer &&
(verification.viewer.hasIssuedVerification ? (
@@ -456,6 +484,16 @@ let ProfileMenu = ({
profile={profile}
verifications={currentAccountVerifications}
/>
{status.isActive ? (
<EditLiveDialog
control={goLiveDialogControl}
status={status}
embed={status.embed}
/>
) : (
<GoLiveDialog control={goLiveDialogControl} profile={profile} />
)}
</EventStopper>
)
}
+4
View File
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import type React from 'react'
import {useActorStatus} from '#/lib/actor-status'
import {makeProfileLink} from '#/lib/routes/links'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
@@ -55,6 +56,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const timestampLabel = niceDate(i18n, opts.timestamp)
const verification = useSimpleVerificationState({profile: author})
const {isActive: live} = useActorStatus(author)
return (
<View
@@ -74,6 +76,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
profile={author}
moderation={opts.moderation?.ui('avatar')}
type={author.associated?.labeler ? 'labeler' : 'user'}
live={live}
hideLiveBadge
/>
</View>
)}
+75 -13
View File
@@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {isTouchDevice} from '#/lib/browser'
import {useHaptics} from '#/lib/haptics'
import {
useCameraPermission,
usePhotoLibraryPermission,
@@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip'
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {
@@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
import {HighPriorityImage} from '#/view/com/util/images/Image'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {
@@ -42,6 +48,8 @@ import {
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Link} from '#/components/Link'
import {LiveIndicator} from '#/components/live/LiveIndicator'
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Menu from '#/components/Menu'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -54,6 +62,8 @@ interface BaseUserAvatarProps {
shape?: 'circle' | 'square'
size: number
avatar?: string | null
live?: boolean
hideLiveBadge?: boolean
}
interface UserAvatarProps extends BaseUserAvatarProps {
@@ -196,27 +206,38 @@ let UserAvatar = ({
usePlainRNImage = false,
onLoad,
style,
live,
hideLiveBadge,
}: UserAvatarProps): React.ReactNode => {
const t = useTheme()
const backgroundColor = t.palette.contrast_25
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
const aviStyle = useMemo(() => {
let borderRadius
if (finalShape === 'square') {
return {
width: size,
height: size,
borderRadius: size > 32 ? 8 : 3,
backgroundColor,
}
borderRadius = size > 32 ? 8 : 3
} else {
borderRadius = Math.floor(size / 2)
}
return {
width: size,
height: size,
borderRadius: Math.floor(size / 2),
backgroundColor,
borderRadius,
backgroundColor: t.palette.contrast_25,
}
}, [finalShape, size, backgroundColor])
}, [finalShape, size, t])
const borderStyle = useMemo(() => {
return [
{borderRadius: aviStyle.borderRadius},
live && {
borderColor: t.palette.negative_500,
borderWidth: size > 16 ? 2 : 1,
opacity: 1,
},
]
}, [aviStyle.borderRadius, live, t, size])
const alert = useMemo(() => {
if (!moderation?.alert) {
@@ -277,12 +298,19 @@ let UserAvatar = ({
onLoad={onLoad}
/>
)}
<MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} />
<MediaInsetBorder style={borderStyle} />
{live && size > 16 && !hideLiveBadge && (
<LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
)}
{alert}
</View>
) : (
<View style={containerStyle}>
<DefaultAvatar type={type} shape={finalShape} size={size} />
<MediaInsetBorder style={borderStyle} />
{live && size > 16 && !hideLiveBadge && (
<LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
)}
{alert}
</View>
)
@@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({
disableHoverCard,
disableNavigation,
onBeforePress,
live,
...rest
}: PreviewableUserAvatarProps): React.ReactNode => {
const {_} = useLingui()
const queryClient = useQueryClient()
const status = useActorStatus(profile)
const liveControl = useDialogControl()
const playHaptic = useHaptics()
const onPress = React.useCallback(() => {
const onPress = useCallback(() => {
onBeforePress?.()
unstableCacheProfileView(queryClient, profile)
}, [profile, queryClient, onBeforePress])
const onOpenLiveStatus = useCallback(() => {
playHaptic('Light')
logger.metric('live:card:open', {subject: profile.did, from: 'post'})
liveControl.open()
}, [liveControl, playHaptic, profile.did])
const avatarEl = (
<UserAvatar
avatar={profile.avatar}
moderation={moderation}
type={profile.associated?.labeler ? 'labeler' : 'user'}
live={status.isActive || live}
{...rest}
/>
)
@@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
{disableNavigation ? (
avatarEl
) : status.isActive && (isNative || isTouchDevice) ? (
<>
<Button
label={_(
msg`${sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}'s avatar`,
)}
accessibilityHint={_(msg`Opens live status dialog`)}
onPress={onOpenLiveStatus}>
{avatarEl}
</Button>
<LiveStatusDialog
control={liveControl}
profile={profile}
status={status}
embed={status.embed}
/>
</>
) : (
<Link
label={_(msg`${profile.displayName || profile.handle}'s avatar`)}
label={_(
msg`${sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}'s avatar`,
)}
accessibilityHint={_(msg`Opens this profile`)}
to={makeProfileLink({
did: profile.did,
@@ -558,23 +558,24 @@ let PostDropdownMenuItems = ({
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSad} position="right" />
</Menu.Item>
{IS_INTERNAL &&
DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && (
<Menu.Item
testID="postDropdownReportMisclassificationBtn"
label={_(msg`Report topic misclassification`)}
onPress={onReportMisclassification}>
<Menu.ItemText>
{_(msg`Report topic misclassification`)}
</Menu.ItemText>
<Menu.ItemIcon icon={AtomIcon} position="right" />
</Menu.Item>
)}
</Menu.Group>
</>
)}
{hasSession &&
IS_INTERNAL &&
DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && (
<Menu.Item
testID="postDropdownReportMisclassificationBtn"
label={_(msg`Assign topic - help train Discover!`)}
onPress={onReportMisclassification}>
<Menu.ItemText>
{_(msg`Assign topic - help train Discover!`)}
</Menu.ItemText>
<Menu.ItemIcon icon={AtomIcon} position="right" />
</Menu.Item>
)}
{hasSession && (
<>
<Menu.Divider />
+31 -27
View File
@@ -4,10 +4,10 @@ import {View} from 'react-native'
import {atoms as a} from '#/alf'
import {
Button,
ButtonColor,
type ButtonColor,
ButtonIcon,
ButtonText,
ButtonVariant,
type ButtonVariant,
} from '#/components/Button'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
@@ -19,31 +19,35 @@ export function Buttons() {
<H1>Buttons</H1>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
{['primary', 'secondary', 'secondary_inverted', 'negative'].map(
color => (
<View key={color} style={[a.gap_md, a.align_start]}>
{['solid', 'outline', 'ghost'].map(variant => (
<React.Fragment key={variant}>
<Button
variant={variant as ButtonVariant}
color={color as ButtonColor}
size="large"
label="Click here">
<ButtonText>Button</ButtonText>
</Button>
<Button
disabled
variant={variant as ButtonVariant}
color={color as ButtonColor}
size="large"
label="Click here">
<ButtonText>Button</ButtonText>
</Button>
</React.Fragment>
))}
</View>
),
)}
{[
'primary',
'secondary',
'secondary_inverted',
'negative',
'negative_secondary',
].map(color => (
<View key={color} style={[a.gap_md, a.align_start]}>
{['solid', 'outline', 'ghost'].map(variant => (
<React.Fragment key={variant}>
<Button
variant={variant as ButtonVariant}
color={color as ButtonColor}
size="large"
label="Click here">
<ButtonText>Button</ButtonText>
</Button>
<Button
disabled
variant={variant as ButtonVariant}
color={color as ButtonColor}
size="large"
label="Click here">
<ButtonText>Button</ButtonText>
</Button>
</React.Fragment>
))}
</View>
))}
<View style={[a.flex_row, a.gap_md, a.align_start]}>
<View style={[a.gap_md, a.align_start]}>
+1 -2
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {useSetThemePrefs} from '#/state/shell'
import {ListContained} from '#/view/screens/Storybook/ListContained'
import {atoms as a, ThemeProvider} from '#/alf'
@@ -115,7 +115,6 @@ function StorybookInner() {
<Typography />
<Spacing />
<Shadows />
<Buttons />
<Icons />
<Links />
<Dialogs />
+3
View File
@@ -5,6 +5,7 @@ import {msg, Plural, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
import {type PressableScale} from '#/lib/custom-animations/PressableScale'
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
@@ -67,6 +68,7 @@ let DrawerProfileCard = ({
const t = useTheme()
const {data: profile} = useProfileQuery({did: account.did})
const verification = useSimpleVerificationState({profile})
const {isActive: live} = useActorStatus(profile)
return (
<TouchableOpacity
@@ -81,6 +83,7 @@ let DrawerProfileCard = ({
// See https://github.com/bluesky-social/social-app/pull/1801:
usePlainRNImage={true}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
<View style={[a.gap_2xs]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
+20 -4
View File
@@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react'
import {type BottomTabBarProps} from '@react-navigation/bottom-tabs'
import {StackActions} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {BOTTOM_BAR_AVI} from '#/lib/demo'
import {useHaptics} from '#/lib/haptics'
@@ -127,6 +128,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
}, [accountSwitchControl, playHaptic])
const [demoMode] = useDemoMode()
const {isActive: live} = useActorStatus(profile)
return (
<>
@@ -260,25 +262,39 @@ export function BottomBar({navigation}: BottomTabBarProps) {
pal.text,
styles.profileIcon,
styles.onProfile,
{borderColor: pal.text.color},
{
borderColor: pal.text.color,
borderWidth: live ? 0 : 1,
},
]}>
<UserAvatar
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
size={iconWidth - 3}
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]}>
style={[
styles.ctrlIcon,
pal.text,
styles.profileIcon,
{
borderWidth: live ? 0 : 1,
},
]}>
<UserAvatar
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
size={iconWidth - 3}
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>
)}
@@ -30,6 +30,11 @@ export const styles = StyleSheet.create({
borderRadius: 6,
zIndex: 1,
},
notificationCountWeb: {
paddingTop: 3,
paddingBottom: 3,
borderRadius: 12,
},
notificationCountLight: {
borderColor: colors.white,
},
+2 -2
View File
@@ -8,7 +8,7 @@ import {useNavigationState} from '@react-navigation/native'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {useHomeBadge} from '#/state/home-badge'
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
@@ -265,7 +265,7 @@ const NavItem: React.FC<{
{children({isActive})}
{notificationCount ? (
<View
style={styles.notificationCount}
style={[styles.notificationCount, styles.notificationCountWeb]}
aria-label={_(
msg`${plural(notificationCount, {
one: '# unread item',

Some files were not shown because too many files have changed in this diff Show More