Merge branch 'main' into app-2288
Resolve conflict in src/components/dms/MessageOverlays.tsx: app-2288's liveReactionsMessage/displayReactionsMessage already covers main's reactionsMessage live-read for optimistic reaction updates, and adds a last-known snapshot so the dialog can finish its close animation. Keep app-2288's memoized reportSubject (with extracted convoId) over main's unmemoized inline version.
This commit is contained in:
+7
-1
@@ -9,7 +9,12 @@ import {
|
|||||||
setFontScale as persistFontScale,
|
setFontScale as persistFontScale,
|
||||||
} from '#/alf/fonts'
|
} from '#/alf/fonts'
|
||||||
import {themes} from '#/alf/themes'
|
import {themes} from '#/alf/themes'
|
||||||
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration'
|
import {
|
||||||
|
contrastRatio,
|
||||||
|
darken,
|
||||||
|
lighten,
|
||||||
|
rgbToHex,
|
||||||
|
} from '#/alf/util/colorGeneration'
|
||||||
import {type Device} from '#/storage'
|
import {type Device} from '#/storage'
|
||||||
|
|
||||||
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
|
||||||
@@ -26,6 +31,7 @@ export const utils = {
|
|||||||
rgbToHex,
|
rgbToHex,
|
||||||
lighten,
|
lighten,
|
||||||
darken,
|
darken,
|
||||||
|
contrastRatio,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Alf = {
|
export type Alf = {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration'
|
import {
|
||||||
|
contrastRatio,
|
||||||
|
darken,
|
||||||
|
hexToRgb,
|
||||||
|
lighten,
|
||||||
|
rgbToHex,
|
||||||
|
} from './colorGeneration'
|
||||||
|
|
||||||
describe('hexToRgb', () => {
|
describe('hexToRgb', () => {
|
||||||
it('parses 6-digit hex', () => {
|
it('parses 6-digit hex', () => {
|
||||||
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
|
|||||||
expect(darken('#zzz', 10)).toBe('#zzz')
|
expect(darken('#zzz', 10)).toBe('#zzz')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('contrastRatio', () => {
|
||||||
|
it('returns 21 for black on white', () => {
|
||||||
|
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 1 for identical colors', () => {
|
||||||
|
expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is symmetric regardless of argument order', () => {
|
||||||
|
expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
|
||||||
|
contrastRatio('#fedcba', '#123456')!,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
|
||||||
|
expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
|
||||||
|
expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for invalid hex input', () => {
|
||||||
|
expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
|
||||||
|
expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
|
|||||||
.slice(1)}`
|
.slice(1)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the WCAG contrast ratio between two colors, ranging from 1 (no
|
||||||
|
* contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
|
||||||
|
* either argument is not a valid hex color.
|
||||||
|
*
|
||||||
|
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
|
||||||
|
*/
|
||||||
|
export function contrastRatio(hexA: string, hexB: string): number | null {
|
||||||
|
const rgbA = hexToRgb(hexA)
|
||||||
|
const rgbB = hexToRgb(hexB)
|
||||||
|
if (!rgbA || !rgbB) return null
|
||||||
|
const luminanceA = relativeLuminance(rgbA)
|
||||||
|
const luminanceB = relativeLuminance(rgbB)
|
||||||
|
const lighter = Math.max(luminanceA, luminanceB)
|
||||||
|
const darker = Math.min(luminanceA, luminanceB)
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
|
||||||
|
* to 1 (white).
|
||||||
|
*
|
||||||
|
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||||
|
*/
|
||||||
|
function relativeLuminance({
|
||||||
|
r,
|
||||||
|
g,
|
||||||
|
b,
|
||||||
|
}: {
|
||||||
|
r: number
|
||||||
|
g: number
|
||||||
|
b: number
|
||||||
|
}): number {
|
||||||
|
const toLinear = (channel: number) => {
|
||||||
|
const normalized = channel / 255
|
||||||
|
return normalized <= 0.03928
|
||||||
|
? normalized / 12.92
|
||||||
|
: ((normalized + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
|
||||||
|
}
|
||||||
|
|
||||||
function rgbToHsl(
|
function rgbToHsl(
|
||||||
r: number,
|
r: number,
|
||||||
g: number,
|
g: number,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ChatInviteEmbed({
|
|||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ChatInvite.Root code={code}>
|
<ChatInvite.Root code={code} hasFixedHeight>
|
||||||
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
|
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
|
||||||
</ChatInvite.Root>
|
</ChatInvite.Root>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {Linking, View} from 'react-native'
|
||||||
|
import {plural} from '@lingui/core/macro'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_NATIVE} from '#/env'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OTA-able fallback that ships to native builds which don't yet know how to
|
||||||
|
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
|
||||||
|
* treatment pending design from Darrin/Danielle/Alex.
|
||||||
|
*
|
||||||
|
* Native-only per APP-2308 - web builds receive the new gallery support in
|
||||||
|
* the same release that adds it.
|
||||||
|
*/
|
||||||
|
export function GalleryFallbackEmbed({count}: {count?: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const bodyStyle = [
|
||||||
|
a.text_sm,
|
||||||
|
a.text_center,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mt_sm,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
a.p_lg,
|
||||||
|
a.pb_2xl,
|
||||||
|
a.gap_sm,
|
||||||
|
a.align_center,
|
||||||
|
{
|
||||||
|
borderColor: t.palette.primary_200,
|
||||||
|
backgroundColor: t.palette.primary_25,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Sparkle size="lg" fill={t.palette.primary_500} />
|
||||||
|
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
|
||||||
|
<Trans>Something new is here</Trans>
|
||||||
|
</Text>
|
||||||
|
{count ? (
|
||||||
|
<View>
|
||||||
|
<Text style={bodyStyle}>
|
||||||
|
{plural(count, {
|
||||||
|
one: 'This post has # photo.',
|
||||||
|
other: 'This post has # photos.',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{IS_NATIVE ? (
|
||||||
|
<Text style={bodyStyle}>
|
||||||
|
{plural(count, {
|
||||||
|
one: 'Update your app to see it.',
|
||||||
|
other: 'Update your app to see them all.',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={bodyStyle}>
|
||||||
|
{plural(count, {
|
||||||
|
one: 'Refresh the page to see it.',
|
||||||
|
other: 'Refresh the page to see them all.',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : IS_NATIVE ? (
|
||||||
|
<Text style={bodyStyle}>
|
||||||
|
<Trans>Update your app to see it.</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={bodyStyle}>
|
||||||
|
<Trans>Refresh the page to see it.</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{IS_NATIVE && (
|
||||||
|
<Button
|
||||||
|
label={l`Update your app`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onPress={() => {
|
||||||
|
void Linking.openURL(BSKY_DOWNLOAD_URL)
|
||||||
|
}}
|
||||||
|
style={[a.mt_xs]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Update app</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,7 +31,10 @@ export function JoinRequestEmbed({
|
|||||||
if (!resolvedCode) return null
|
if (!resolvedCode) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatInvite.Root code={resolvedCode} initialPreview={preview}>
|
<ChatInvite.Root
|
||||||
|
code={resolvedCode}
|
||||||
|
initialPreview={preview}
|
||||||
|
hasFixedHeight>
|
||||||
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
|
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
|
||||||
</ChatInvite.Root>
|
</ChatInvite.Root>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {useCallOnce} from '#/lib/once'
|
|
||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
import {niceDate} from '#/lib/strings/time'
|
import {niceDate} from '#/lib/strings/time'
|
||||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
@@ -104,12 +103,6 @@ export const StandardSiteEmbed = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useCallOnce(() => {
|
|
||||||
if (!preview) {
|
|
||||||
ax.metric('embed:standardSite:view', {url: view.uri})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (isStandardPublication) {
|
if (isStandardPublication) {
|
||||||
return (
|
return (
|
||||||
<PublicationCard
|
<PublicationCard
|
||||||
@@ -427,6 +420,26 @@ export function SubscribeButton({
|
|||||||
? l`Subscribe on ${highlightedPublisher.name}`
|
? l`Subscribe on ${highlightedPublisher.name}`
|
||||||
: l`View publication`
|
: l`View publication`
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The custom site theme paints the button background with `accent` and the
|
||||||
|
* text with `accentForeground`. Only honor it when that pairing clears WCAG
|
||||||
|
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
|
||||||
|
* Otherwise we fall through to the default `secondary_inverted` styling,
|
||||||
|
* which is guaranteed to be legible.
|
||||||
|
*/
|
||||||
|
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
|
||||||
|
let useCustomTheme = false
|
||||||
|
if (accentRGB && accentForegroundRGB) {
|
||||||
|
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
|
||||||
|
const accentForeground = utils.rgbToHex(
|
||||||
|
accentForegroundRGB.r,
|
||||||
|
accentForegroundRGB.g,
|
||||||
|
accentForegroundRGB.b,
|
||||||
|
)
|
||||||
|
const ratio = utils.contrastRatio(accent, accentForeground)
|
||||||
|
useCustomTheme = ratio !== null && ratio >= 4.5
|
||||||
|
}
|
||||||
|
|
||||||
if (!view.source) return null
|
if (!view.source) return null
|
||||||
|
|
||||||
const publicationTitle = view.source.title
|
const publicationTitle = view.source.title
|
||||||
@@ -468,8 +481,7 @@ export function SubscribeButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const button = (
|
||||||
<StandardSiteThemeProvider view={view}>
|
|
||||||
<Link
|
<Link
|
||||||
shouldProxy
|
shouldProxy
|
||||||
to={view.source.uri}
|
to={view.source.uri}
|
||||||
@@ -497,7 +509,14 @@ export function SubscribeButton({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</StandardSiteThemeProvider>
|
)
|
||||||
|
|
||||||
|
if (!useCustomTheme) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ export function ProfileBadges({
|
|||||||
interactive = false,
|
interactive = false,
|
||||||
size,
|
size,
|
||||||
style,
|
style,
|
||||||
|
allowFontScaling = true,
|
||||||
}: ViewStyleProp & {
|
}: ViewStyleProp & {
|
||||||
profile: bsky.profile.AnyProfileView
|
profile: bsky.profile.AnyProfileView
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
size: Size
|
size: Size
|
||||||
|
allowFontScaling?: boolean
|
||||||
}) {
|
}) {
|
||||||
const shadowed = useProfileShadow(profile)
|
const shadowed = useProfileShadow(profile)
|
||||||
const verification = useSimpleVerificationState({profile})
|
const verification = useSimpleVerificationState({profile})
|
||||||
@@ -48,10 +50,12 @@ export function ProfileBadges({
|
|||||||
|
|
||||||
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
||||||
|
|
||||||
const verificationIconWidth =
|
const scaleMultiplier = allowFontScaling
|
||||||
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
|
? nativeScaleMultiplier * alfScaleMultiplier
|
||||||
const botIconWidth =
|
: 1
|
||||||
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
|
|
||||||
|
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
|
||||||
|
const botIconWidth = botIconSizes[size] * scaleMultiplier
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function Text({
|
|||||||
title,
|
title,
|
||||||
dataSet,
|
dataSet,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
|
allowFontScaling = true,
|
||||||
...rest
|
...rest
|
||||||
}: TextProps) {
|
}: TextProps) {
|
||||||
const {fonts, flags} = useAlf()
|
const {fonts, flags} = useAlf()
|
||||||
@@ -36,7 +37,7 @@ export function Text({
|
|||||||
style,
|
style,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
fontScale: fonts.scaleMultiplier,
|
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
|
||||||
fontFamily: fonts.family,
|
fontFamily: fonts.family,
|
||||||
flags,
|
flags,
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,7 @@ export function Text({
|
|||||||
numberOfLines,
|
numberOfLines,
|
||||||
style: s,
|
style: s,
|
||||||
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
||||||
|
allowFontScaling,
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {useChatInvite} from './Context'
|
|||||||
*/
|
*/
|
||||||
export function Card({size}: {size: 'large' | 'small'}) {
|
export function Card({size}: {size: 'large' | 'small'}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {preview} = useChatInvite()
|
const {preview, hasFixedHeight} = useChatInvite()
|
||||||
|
|
||||||
if (!preview) return null
|
if (!preview) return null
|
||||||
|
|
||||||
@@ -31,14 +31,15 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
|||||||
<Text
|
<Text
|
||||||
emoji
|
emoji
|
||||||
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
|
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}
|
||||||
|
allowFontScaling={!hasFixedHeight}>
|
||||||
{preview.name}
|
{preview.name}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
|
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
|
||||||
allowFontScaling
|
numberOfLines={1}
|
||||||
numberOfLines={1}>
|
allowFontScaling={!hasFixedHeight}>
|
||||||
<Trans>Group chat</Trans>
|
<Trans>Group chat</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -48,8 +49,8 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
|||||||
a.font_medium,
|
a.font_medium,
|
||||||
t.atoms.text_contrast_high,
|
t.atoms.text_contrast_high,
|
||||||
]}
|
]}
|
||||||
allowFontScaling
|
numberOfLines={1}
|
||||||
numberOfLines={1}>
|
allowFontScaling={!hasFixedHeight}>
|
||||||
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
|
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
|
||||||
{preview.memberCount}/{preview.memberLimit}{' '}
|
{preview.memberCount}/{preview.memberLimit}{' '}
|
||||||
<Plural
|
<Plural
|
||||||
@@ -70,17 +71,24 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
|||||||
<Text
|
<Text
|
||||||
emoji
|
emoji
|
||||||
style={[a.flex_shrink, a.text_sm, a.font_medium]}
|
style={[a.flex_shrink, a.text_sm, a.font_medium]}
|
||||||
allowFontScaling
|
numberOfLines={1}
|
||||||
numberOfLines={1}>
|
allowFontScaling={!hasFixedHeight}>
|
||||||
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
|
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
|
||||||
By <Text style={[a.font_medium]}>{ownerDisplayName}</Text>
|
By{' '}
|
||||||
|
<Text style={[a.font_medium]} allowFontScaling={!hasFixedHeight}>
|
||||||
|
{ownerDisplayName}
|
||||||
|
</Text>
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<ProfileBadges profile={preview.owner} size="sm" />
|
<ProfileBadges
|
||||||
|
profile={preview.owner}
|
||||||
|
size="sm"
|
||||||
|
allowFontScaling={!hasFixedHeight}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
|
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
|
||||||
allowFontScaling
|
numberOfLines={1}
|
||||||
numberOfLines={1}>
|
allowFontScaling={!hasFixedHeight}>
|
||||||
{ownerHandle}
|
{ownerHandle}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export type ChatInviteContextValue = {
|
|||||||
* preview to act on.
|
* preview to act on.
|
||||||
*/
|
*/
|
||||||
action: ChatInviteAction | undefined
|
action: ChatInviteAction | undefined
|
||||||
|
/** Whether the invite is rendered inside a fixed-height container; when true, text inside disables font scaling so the card doesn't overflow. */
|
||||||
|
hasFixedHeight: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatInviteContext = createContext<ChatInviteContextValue | null>(null)
|
const ChatInviteContext = createContext<ChatInviteContextValue | null>(null)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function JoinButton({
|
|||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const {action} = useChatInvite()
|
const {action, hasFixedHeight} = useChatInvite()
|
||||||
|
|
||||||
if (!action) return null
|
if (!action) return null
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export function JoinButton({
|
|||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
style={[a.w_full, style]}>
|
style={[a.w_full, style]}>
|
||||||
{action.side === 'left' && <ButtonIcon icon={action.icon} />}
|
{action.side === 'left' && <ButtonIcon icon={action.icon} />}
|
||||||
<ButtonText>{action.label}</ButtonText>
|
<ButtonText allowFontScaling={!hasFixedHeight}>{action.label}</ButtonText>
|
||||||
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
|
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function Root({
|
|||||||
code,
|
code,
|
||||||
initialPreview,
|
initialPreview,
|
||||||
currentConvoId,
|
currentConvoId,
|
||||||
|
hasFixedHeight,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
code: string
|
code: string
|
||||||
@@ -40,6 +41,7 @@ export function Root({
|
|||||||
* open/join (you're already here).
|
* open/join (you're already here).
|
||||||
*/
|
*/
|
||||||
currentConvoId?: string
|
currentConvoId?: string
|
||||||
|
hasFixedHeight: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
@@ -137,7 +139,7 @@ export function Root({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatInviteProvider
|
<ChatInviteProvider
|
||||||
value={{code, loading, error: !!error, preview, action}}>
|
value={{code, loading, error: !!error, preview, action, hasFixedHeight}}>
|
||||||
{children}
|
{children}
|
||||||
</ChatInviteProvider>
|
</ChatInviteProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ let MessageItemInviteEmbed = ({
|
|||||||
<ChatInvite.Root
|
<ChatInvite.Root
|
||||||
code={embed.joinLinkPreview.code}
|
code={embed.joinLinkPreview.code}
|
||||||
initialPreview={embed.joinLinkPreview}
|
initialPreview={embed.joinLinkPreview}
|
||||||
currentConvoId={convo.convo.view.id}>
|
currentConvoId={convo.convo.view.id}
|
||||||
|
hasFixedHeight={false}>
|
||||||
<ChatInvite.Card size="small" />
|
<ChatInvite.Card size="small" />
|
||||||
<ChatInvite.JoinButton />
|
<ChatInvite.JoinButton />
|
||||||
</ChatInvite.Root>
|
</ChatInvite.Root>
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective `allowGroupInvites` value for a chat declaration.
|
||||||
|
* When unset, group invites follow the general DM preference
|
||||||
|
* (`allowIncoming`), which itself defaults to `following`. This mirrors the
|
||||||
|
* `undefined` fallthrough in canBeAddedToGroup, and is the single source of
|
||||||
|
* truth for both displaying and persisting the setting.
|
||||||
|
*/
|
||||||
|
export function resolveAllowGroupInvites(
|
||||||
|
chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined,
|
||||||
|
): 'all' | 'none' | 'following' {
|
||||||
|
return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as
|
||||||
|
| 'all'
|
||||||
|
| 'none'
|
||||||
|
| 'following'
|
||||||
|
}
|
||||||
|
|
||||||
export function localDateString(date: Date) {
|
export function localDateString(date: Date) {
|
||||||
// can't use toISOString because it should be in local time
|
// can't use toISOString because it should be in local time
|
||||||
const mm = date.getMonth()
|
const mm = date.getMonth()
|
||||||
|
|||||||
+475
-363
File diff suppressed because one or more lines are too long
@@ -451,6 +451,12 @@ export function Header({
|
|||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const requireEmailVerification = useRequireEmailVerification()
|
const requireEmailVerification = useRequireEmailVerification()
|
||||||
const leftConvos = useLeftConvos()
|
const leftConvos = useLeftConvos()
|
||||||
|
const {isWithinSplitView} = useIsWithinSplitView()
|
||||||
|
|
||||||
|
// In split view, the left column (and this header) stays mounted while the
|
||||||
|
// right column shows the selected route. Pushing would stack duplicate routes
|
||||||
|
// on repeated clicks, so navigate instead to dedupe by route + params.
|
||||||
|
const action = isWithinSplitView ? 'navigate' : 'push'
|
||||||
|
|
||||||
const {data: unreadInboxData, hasNextPage: hasMoreRequests} =
|
const {data: unreadInboxData, hasNextPage: hasMoreRequests} =
|
||||||
useListConvosQuery({
|
useListConvosQuery({
|
||||||
@@ -494,9 +500,11 @@ export function Header({
|
|||||||
count={inboxAllConvos.length}
|
count={inboxAllConvos.length}
|
||||||
more={hasMoreRequests}
|
more={hasMoreRequests}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
|
action={action}
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
to="/messages/settings"
|
to="/messages/settings"
|
||||||
|
action={action}
|
||||||
label={l`Chat settings`}
|
label={l`Chat settings`}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
|
|||||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||||
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
|
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
|
||||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||||
import {FAB} from '#/view/com/util/fab/FAB'
|
|
||||||
import {List} from '#/view/com/util/List'
|
import {List} from '#/view/com/util/List'
|
||||||
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -62,8 +61,6 @@ export function MessagesInboxScreen(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MessagesInboxScreenInner({}: Props) {
|
export function MessagesInboxScreenInner({}: Props) {
|
||||||
const {gtTablet} = useBreakpoints()
|
|
||||||
|
|
||||||
const listConvosQuery = useListConvosQuery({status: 'request'})
|
const listConvosQuery = useListConvosQuery({status: 'request'})
|
||||||
const {data} = listConvosQuery
|
const {data} = listConvosQuery
|
||||||
|
|
||||||
@@ -94,21 +91,16 @@ export function MessagesInboxScreenInner({}: Props) {
|
|||||||
<Layout.Screen testID="messagesInboxScreen">
|
<Layout.Screen testID="messagesInboxScreen">
|
||||||
<Layout.Header.Outer>
|
<Layout.Header.Outer>
|
||||||
<Layout.Header.BackButton />
|
<Layout.Header.BackButton />
|
||||||
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
|
<Layout.Header.Content align="left">
|
||||||
<Layout.Header.TitleText>
|
<Layout.Header.TitleText>
|
||||||
<Trans>Chat requests</Trans>
|
<Trans>Chat requests</Trans>
|
||||||
</Layout.Header.TitleText>
|
</Layout.Header.TitleText>
|
||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
{hasUnreadConvos && gtTablet ? (
|
{hasUnreadConvos ? <MarkAsReadHeaderButton /> : <Layout.Header.Slot />}
|
||||||
<MarkAsReadHeaderButton />
|
|
||||||
) : (
|
|
||||||
<Layout.Header.Slot />
|
|
||||||
)}
|
|
||||||
</Layout.Header.Outer>
|
</Layout.Header.Outer>
|
||||||
<RequestList
|
<RequestList
|
||||||
listConvosQuery={listConvosQuery}
|
listConvosQuery={listConvosQuery}
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
hasUnreadConvos={hasUnreadConvos}
|
|
||||||
/>
|
/>
|
||||||
</Layout.Screen>
|
</Layout.Screen>
|
||||||
)
|
)
|
||||||
@@ -117,14 +109,12 @@ export function MessagesInboxScreenInner({}: Props) {
|
|||||||
function RequestList({
|
function RequestList({
|
||||||
listConvosQuery,
|
listConvosQuery,
|
||||||
conversations,
|
conversations,
|
||||||
hasUnreadConvos,
|
|
||||||
}: {
|
}: {
|
||||||
listConvosQuery: UseInfiniteQueryResult<
|
listConvosQuery: UseInfiniteQueryResult<
|
||||||
InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
|
InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
|
||||||
Error
|
Error
|
||||||
>
|
>
|
||||||
conversations: ChatBskyConvoDefs.ConvoView[]
|
conversations: ChatBskyConvoDefs.ConvoView[]
|
||||||
hasUnreadConvos: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -285,7 +275,6 @@ function RequestList({
|
|||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
sideBorders={false}
|
sideBorders={false}
|
||||||
/>
|
/>
|
||||||
{hasUnreadConvos && <MarkAllReadFAB />}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,34 +287,6 @@ function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
|
|||||||
return <RequestListItem convo={item} />
|
return <RequestListItem convo={item} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkAllReadFAB() {
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
const {mutate: markAllRead} = useUpdateAllRead('request', {
|
|
||||||
onMutate: () => {
|
|
||||||
Toast.show(l`Marked all as read`, {
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
Toast.show(l`Failed to mark all requests as read`, {
|
|
||||||
type: 'error',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FAB
|
|
||||||
testID="markAllAsReadFAB"
|
|
||||||
onPress={() => markAllRead()}
|
|
||||||
icon={<CheckIcon size="lg" fill={t.palette.white} />}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={l`Mark all as read`}
|
|
||||||
accessibilityHint=""
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkAsReadHeaderButton() {
|
function MarkAsReadHeaderButton() {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {mutate: markAllRead} = useUpdateAllRead('request', {
|
const {mutate: markAllRead} = useUpdateAllRead('request', {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen
|
|||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {resolveAllowGroupInvites} from '#/components/dms/util'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
|
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
|
||||||
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
|
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
|
||||||
@@ -199,10 +200,7 @@ export function MessagesSettingsScreenInner({}: Props) {
|
|||||||
<Toggle.Group
|
<Toggle.Group
|
||||||
label={l`Allow group chat invites from`}
|
label={l`Allow group chat invites from`}
|
||||||
type="radio"
|
type="radio"
|
||||||
values={[
|
values={[resolveAllowGroupInvites(profile?.associated?.chat)]}
|
||||||
(profile?.associated?.chat
|
|
||||||
?.allowGroupInvites as AllowIncoming) ?? 'following',
|
|
||||||
]}
|
|
||||||
onChange={onSelectGroupInvitesFrom}>
|
onChange={onSelectGroupInvitesFrom}>
|
||||||
<View>
|
<View>
|
||||||
{allowGroupInvitesFromOptions.map(option => (
|
{allowGroupInvitesFromOptions.map(option => (
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function DirectChatItem({
|
|||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const profile = useProfileShadow(convo.primaryMember)
|
const profile = useProfileShadow(convo.primaryMember)
|
||||||
const {isWithinSplitView} = useIsWithinSplitView()
|
const {isWithinLeftPanel} = useIsWithinSplitView()
|
||||||
|
|
||||||
const moderation = useMemo(
|
const moderation = useMemo(
|
||||||
() => moderateProfile(profile, moderationOpts),
|
() => moderateProfile(profile, moderationOpts),
|
||||||
@@ -140,7 +140,7 @@ function DirectChatItem({
|
|||||||
avatar={
|
avatar={
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
profile={profile}
|
profile={profile}
|
||||||
size={isWithinSplitView ? 48 : 52}
|
size={isWithinLeftPanel ? 48 : 52}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ function DirectChatItem({
|
|||||||
isBlockedAccount={moderation.blocked}
|
isBlockedAccount={moderation.blocked}
|
||||||
showProfileBadges
|
showProfileBadges
|
||||||
postAlerts={
|
postAlerts={
|
||||||
isWithinSplitView ? null : (
|
isWithinLeftPanel ? null : (
|
||||||
<PostAlerts
|
<PostAlerts
|
||||||
modui={moderation.ui('contentList')}
|
modui={moderation.ui('contentList')}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -189,7 +189,7 @@ function GroupChatItem({
|
|||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const groupOwner = useMaybeProfileShadow(convo.primaryMember)
|
const groupOwner = useMaybeProfileShadow(convo.primaryMember)
|
||||||
const {isWithinSplitView} = useIsWithinSplitView()
|
const {isWithinLeftPanel} = useIsWithinSplitView()
|
||||||
|
|
||||||
const moderation = useMemo(
|
const moderation = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -205,7 +205,7 @@ function GroupChatItem({
|
|||||||
avatar={
|
avatar={
|
||||||
<AvatarBubbles
|
<AvatarBubbles
|
||||||
profiles={convo.members}
|
profiles={convo.members}
|
||||||
size={isWithinSplitView ? 48 : 52}
|
size={isWithinLeftPanel ? 48 : 52}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -278,7 +278,7 @@ function BaseChatItem({
|
|||||||
const leaveConvoControl = useDialogControl()
|
const leaveConvoControl = useDialogControl()
|
||||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {isWithinSplitView} = useIsWithinSplitView()
|
const {isWithinLeftPanel} = useIsWithinSplitView()
|
||||||
|
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -458,7 +458,7 @@ function BaseChatItem({
|
|||||||
leftFirst: deleteAction,
|
leftFirst: deleteAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarSize = isWithinSplitView ? 48 : 52
|
const avatarSize = isWithinLeftPanel ? 48 : 52
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatListItemPortal.Provider>
|
<ChatListItemPortal.Provider>
|
||||||
@@ -469,7 +469,7 @@ function BaseChatItem({
|
|||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onMouseLeave}
|
onBlur={onMouseLeave}
|
||||||
style={[a.relative, t.atoms.bg, isWithinSplitView && a.mx_sm]}>
|
style={[a.relative, t.atoms.bg, isWithinLeftPanel && a.mx_sm]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.z_10,
|
a.z_10,
|
||||||
@@ -481,6 +481,9 @@ function BaseChatItem({
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
to={`/messages/${convo.view.id}`}
|
to={`/messages/${convo.view.id}`}
|
||||||
|
// In split view, this list stays mounted alongside the open convo,
|
||||||
|
// so push would stack duplicate routes on repeated clicks.
|
||||||
|
action={isWithinLeftPanel ? 'navigate' : 'push'}
|
||||||
label={title}
|
label={title}
|
||||||
accessibilityHint={accessibilityHint}
|
accessibilityHint={accessibilityHint}
|
||||||
accessibilityActions={
|
accessibilityActions={
|
||||||
@@ -512,7 +515,7 @@ function BaseChatItem({
|
|||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
isWithinSplitView && a.rounded_sm,
|
isWithinLeftPanel && a.rounded_sm,
|
||||||
{
|
{
|
||||||
backgroundColor: hasUnread
|
backgroundColor: hasUnread
|
||||||
? t.palette.primary_25
|
? t.palette.primary_25
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ export function InboxRequests({
|
|||||||
count,
|
count,
|
||||||
more,
|
more,
|
||||||
variant,
|
variant,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
count: number
|
count: number
|
||||||
more: boolean
|
more: boolean
|
||||||
variant?: 'ghost' | 'solid'
|
variant?: 'ghost' | 'solid'
|
||||||
|
action?: 'push' | 'navigate'
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ export function InboxRequests({
|
|||||||
<Link
|
<Link
|
||||||
label={label}
|
label={label}
|
||||||
to="/messages/inbox"
|
to="/messages/inbox"
|
||||||
|
action={action}
|
||||||
size="small"
|
size="small"
|
||||||
variant={unread ? 'solid' : 'ghost'}
|
variant={unread ? 'solid' : 'ghost'}
|
||||||
color={unread ? 'primary_subtle' : 'secondary'}
|
color={unread ? 'primary_subtle' : 'secondary'}
|
||||||
@@ -66,6 +69,7 @@ export function InboxRequests({
|
|||||||
<Link
|
<Link
|
||||||
label={label}
|
label={label}
|
||||||
to="/messages/inbox"
|
to="/messages/inbox"
|
||||||
|
action={action}
|
||||||
color={unread ? 'primary_subtle' : 'secondary'}
|
color={unread ? 'primary_subtle' : 'secondary'}
|
||||||
size="small">
|
size="small">
|
||||||
<ButtonIcon icon={InboxIcon} />
|
<ButtonIcon icon={InboxIcon} />
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ function MessageInputInviteEmbed({
|
|||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatInvite.Root code={code}>
|
<ChatInvite.Root code={code} hasFixedHeight={false}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {resolveAllowGroupInvites} from '#/components/dms/util'
|
||||||
import {RQKEY as PROFILE_RKEY} from '../profile'
|
import {RQKEY as PROFILE_RKEY} from '../profile'
|
||||||
|
|
||||||
export function useUpdateActorDeclaration({
|
export function useUpdateActorDeclaration({
|
||||||
@@ -34,8 +35,12 @@ export function useUpdateActorDeclaration({
|
|||||||
update.allowIncoming ??
|
update.allowIncoming ??
|
||||||
current?.associated?.chat?.allowIncoming ??
|
current?.associated?.chat?.allowIncoming ??
|
||||||
'following'
|
'following'
|
||||||
const allowGroupInvites =
|
const allowGroupInvites = resolveAllowGroupInvites({
|
||||||
update.allowGroupInvites ?? current?.associated?.chat?.allowGroupInvites
|
allowIncoming,
|
||||||
|
allowGroupInvites:
|
||||||
|
update.allowGroupInvites ??
|
||||||
|
current?.associated?.chat?.allowGroupInvites,
|
||||||
|
})
|
||||||
const result = await agent.com.atproto.repo.putRecord({
|
const result = await agent.com.atproto.repo.putRecord({
|
||||||
repo: currentAccount.did,
|
repo: currentAccount.did,
|
||||||
collection: 'chat.bsky.actor.declaration',
|
collection: 'chat.bsky.actor.declaration',
|
||||||
@@ -43,7 +48,7 @@ export function useUpdateActorDeclaration({
|
|||||||
record: {
|
record: {
|
||||||
$type: 'chat.bsky.actor.declaration',
|
$type: 'chat.bsky.actor.declaration',
|
||||||
allowIncoming,
|
allowIncoming,
|
||||||
...(allowGroupInvites && {allowGroupInvites}),
|
allowGroupInvites,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
@@ -54,14 +59,26 @@ export function useUpdateActorDeclaration({
|
|||||||
PROFILE_RKEY(currentAccount?.did),
|
PROFILE_RKEY(currentAccount?.did),
|
||||||
(old?: AppBskyActorDefs.ProfileViewDetailed) => {
|
(old?: AppBskyActorDefs.ProfileViewDetailed) => {
|
||||||
if (!old) return old
|
if (!old) return old
|
||||||
|
const allowIncoming =
|
||||||
|
update.allowIncoming ??
|
||||||
|
old.associated?.chat?.allowIncoming ??
|
||||||
|
'following'
|
||||||
|
// resolve the same concrete value the server will receive, so
|
||||||
|
// optimistic cache and persisted record stay aligned
|
||||||
|
const allowGroupInvites = resolveAllowGroupInvites({
|
||||||
|
allowIncoming,
|
||||||
|
allowGroupInvites:
|
||||||
|
update.allowGroupInvites ??
|
||||||
|
old.associated?.chat?.allowGroupInvites,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
...old,
|
...old,
|
||||||
associated: {
|
associated: {
|
||||||
...old.associated,
|
...old.associated,
|
||||||
chat: {
|
chat: {
|
||||||
allowIncoming: 'following',
|
|
||||||
...old.associated?.chat,
|
...old.associated?.chat,
|
||||||
...update,
|
allowIncoming,
|
||||||
|
allowGroupInvites,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies AppBskyActorDefs.ProfileViewDetailed
|
} satisfies AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
|||||||
@@ -418,67 +418,34 @@ export function ListConvosProviderInner({
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
} else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
|
} else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
|
||||||
queryClient.setQueriesData(
|
mutateConvoView(log.convoId, convo =>
|
||||||
{queryKey: [RQKEY_ROOT]},
|
ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||||
(old?: ConvoListQueryData) =>
|
? {
|
||||||
optimisticUpdate(log.convoId, old, convo => {
|
|
||||||
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
|
|
||||||
return {
|
|
||||||
...convo,
|
...convo,
|
||||||
kind: {
|
kind: {...convo.kind, lockStatus: 'locked'},
|
||||||
...convo.kind,
|
|
||||||
lockStatus: 'locked',
|
|
||||||
},
|
|
||||||
rev: log.rev,
|
rev: log.rev,
|
||||||
}
|
}
|
||||||
}
|
: {...convo, rev: log.rev},
|
||||||
return {
|
|
||||||
...convo,
|
|
||||||
rev: log.rev,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
} else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
|
} else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
|
||||||
queryClient.setQueriesData(
|
mutateConvoView(log.convoId, convo =>
|
||||||
{queryKey: [RQKEY_ROOT]},
|
ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||||
(old?: ConvoListQueryData) =>
|
? {
|
||||||
optimisticUpdate(log.convoId, old, convo => {
|
|
||||||
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
|
|
||||||
return {
|
|
||||||
...convo,
|
...convo,
|
||||||
kind: {
|
kind: {...convo.kind, lockStatus: 'unlocked'},
|
||||||
...convo.kind,
|
|
||||||
lockStatus: 'unlocked',
|
|
||||||
},
|
|
||||||
rev: log.rev,
|
rev: log.rev,
|
||||||
}
|
}
|
||||||
}
|
: {...convo, rev: log.rev},
|
||||||
return {
|
|
||||||
...convo,
|
|
||||||
rev: log.rev,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
} else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
|
} else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
|
||||||
queryClient.setQueriesData(
|
mutateConvoView(log.convoId, convo =>
|
||||||
{queryKey: [RQKEY_ROOT]},
|
ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||||
(old?: ConvoListQueryData) =>
|
? {
|
||||||
optimisticUpdate(log.convoId, old, convo => {
|
|
||||||
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
|
|
||||||
return {
|
|
||||||
...convo,
|
...convo,
|
||||||
kind: {
|
kind: {...convo.kind, lockStatus: 'locked-permanently'},
|
||||||
...convo.kind,
|
|
||||||
lockStatus: 'locked-permanently',
|
|
||||||
},
|
|
||||||
rev: log.rev,
|
rev: log.rev,
|
||||||
}
|
}
|
||||||
}
|
: {...convo, rev: log.rev},
|
||||||
return {
|
|
||||||
...convo,
|
|
||||||
rev: log.rev,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
|
ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
type AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
|
AppBskyEmbedExternal,
|
||||||
AppBskyEmbedVideo,
|
AppBskyEmbedVideo,
|
||||||
type AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
} from '#/components/feeds/PostFeedVideoGridRow'
|
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||||
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||||
|
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
|
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
|
||||||
@@ -905,6 +907,7 @@ let PostFeed = ({
|
|||||||
|
|
||||||
const seenActorWithStatusRef = useRef<Set<string>>(new Set())
|
const seenActorWithStatusRef = useRef<Set<string>>(new Set())
|
||||||
const seenPostUrisRef = useRef<Set<string>>(new Set())
|
const seenPostUrisRef = useRef<Set<string>>(new Set())
|
||||||
|
const seenStandardSiteUrisRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
// Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
|
// Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
|
||||||
const getPostPosition = useNonReactiveCallback(
|
const getPostPosition = useNonReactiveCallback(
|
||||||
@@ -974,6 +977,16 @@ let PostFeed = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard site embed view tracking
|
||||||
|
if (
|
||||||
|
AppBskyEmbedExternal.isView(post.embed) &&
|
||||||
|
isStandardSiteEmbed(post.embed.external) &&
|
||||||
|
!seenStandardSiteUrisRef.current.has(post.embed.external.uri)
|
||||||
|
) {
|
||||||
|
seenStandardSiteUrisRef.current.add(post.embed.external.uri)
|
||||||
|
ax.metric('embed:standardSite:view', {url: post.embed.external.uri})
|
||||||
|
}
|
||||||
} else if (item.type === 'videoGridRow') {
|
} else if (item.type === 'videoGridRow') {
|
||||||
// Track each video in the grid row
|
// Track each video in the grid row
|
||||||
for (let i = 0; i < item.items.length; i++) {
|
for (let i = 0; i < item.items.length; i++) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {GalleryFallbackEmbed} from '#/components/Post/Embed/GalleryFallbackEmbed'
|
||||||
|
import {H1, H3} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function GalleryFallback() {
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<H1>Gallery fallback (APP-2308)</H1>
|
||||||
|
|
||||||
|
<H3>No count</H3>
|
||||||
|
<GalleryFallbackEmbed />
|
||||||
|
|
||||||
|
<H3>1 photo</H3>
|
||||||
|
<GalleryFallbackEmbed count={1} />
|
||||||
|
|
||||||
|
<H3>5 photos</H3>
|
||||||
|
<GalleryFallbackEmbed count={5} />
|
||||||
|
|
||||||
|
<H3>10 photos</H3>
|
||||||
|
<GalleryFallbackEmbed count={10} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {Breakpoints} from './Breakpoints'
|
|||||||
import {Buttons} from './Buttons'
|
import {Buttons} from './Buttons'
|
||||||
import {Dialogs} from './Dialogs'
|
import {Dialogs} from './Dialogs'
|
||||||
import {Forms} from './Forms'
|
import {Forms} from './Forms'
|
||||||
|
import {GalleryFallback} from './GalleryFallback'
|
||||||
import {Icons} from './Icons'
|
import {Icons} from './Icons'
|
||||||
import {Links} from './Links'
|
import {Links} from './Links'
|
||||||
import {Menus} from './Menus'
|
import {Menus} from './Menus'
|
||||||
@@ -120,6 +121,7 @@ export default function Storybook() {
|
|||||||
<Breakpoints />
|
<Breakpoints />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
<Admonitions />
|
<Admonitions />
|
||||||
|
<GalleryFallback />
|
||||||
<Settings />
|
<Settings />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
import {useEffect, useRef} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {Modal, View} from 'react-native'
|
import {Modal, View} from 'react-native'
|
||||||
|
import {SystemBars} from 'react-native-edge-to-edge'
|
||||||
|
|
||||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
|
||||||
import {useComposerState} from '#/state/shell/composer'
|
import {useComposerState} from '#/state/shell/composer'
|
||||||
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
|
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip'
|
import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip'
|
||||||
|
import {IS_LIQUID_GLASS} from '#/env'
|
||||||
|
|
||||||
export function Composer({}: {winHeight: number}) {
|
export function Composer() {
|
||||||
const {setFullyExpandedCount} = useDialogStateControlContext()
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const state = useComposerState()
|
const state = useComposerState()
|
||||||
const ref = useComposerCancelRef()
|
const ref = useComposerCancelRef()
|
||||||
|
|
||||||
const open = !!state
|
const open = !!state
|
||||||
const prevOpen = useRef(open)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !prevOpen.current) {
|
if (open && !IS_LIQUID_GLASS) {
|
||||||
setFullyExpandedCount(c => c + 1)
|
const entry = SystemBars.pushStackEntry({
|
||||||
} else if (!open && prevOpen.current) {
|
style: {
|
||||||
setFullyExpandedCount(c => c - 1)
|
statusBar: 'light',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return () => SystemBars.popStackEntry(entry)
|
||||||
}
|
}
|
||||||
prevOpen.current = open
|
}, [open])
|
||||||
}, [open, setFullyExpandedCount])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
+21
-29
@@ -1,49 +1,41 @@
|
|||||||
import {useEffect} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {Animated, Easing} from 'react-native'
|
import {SystemBars} from 'react-native-edge-to-edge'
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
SlideInDown,
|
||||||
|
SlideOutDown,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
|
|
||||||
import {useComposerState} from '#/state/shell/composer'
|
import {useComposerState} from '#/state/shell/composer'
|
||||||
|
import {ComposePost} from '#/view/com/composer/Composer'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {ComposePost} from '../com/composer/Composer'
|
|
||||||
|
|
||||||
export function Composer({winHeight}: {winHeight: number}) {
|
export function Composer() {
|
||||||
const state = useComposerState()
|
const state = useComposerState()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const initInterp = useAnimatedValue(0)
|
|
||||||
|
const open = !!state
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state) {
|
if (open) {
|
||||||
Animated.timing(initInterp, {
|
const entry = SystemBars.pushStackEntry({
|
||||||
toValue: 1,
|
style: {
|
||||||
duration: 300,
|
statusBar: t.name !== 'light' ? 'light' : 'dark',
|
||||||
easing: Easing.out(Easing.exp),
|
|
||||||
useNativeDriver: true,
|
|
||||||
}).start()
|
|
||||||
} else {
|
|
||||||
initInterp.setValue(0)
|
|
||||||
}
|
|
||||||
}, [initInterp, state])
|
|
||||||
const wrapperAnimStyle = {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: initInterp.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [winHeight, 0],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
],
|
})
|
||||||
|
return () => SystemBars.popStackEntry(entry)
|
||||||
}
|
}
|
||||||
|
}, [open, t.name])
|
||||||
|
|
||||||
// rendering
|
if (!open) {
|
||||||
// =
|
|
||||||
|
|
||||||
if (!state) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[a.absolute, a.inset_0, t.atoms.bg, wrapperAnimStyle]}
|
style={[a.absolute, a.inset_0, t.atoms.bg]}
|
||||||
|
entering={SlideInDown.duration(300).easing(Easing.out(Easing.exp))}
|
||||||
|
exiting={SlideOutDown.duration(200).easing(Easing.in(Easing.quad))}
|
||||||
aria-modal
|
aria-modal
|
||||||
accessibilityViewIsModal>
|
accessibilityViewIsModal>
|
||||||
<ComposePost
|
<ComposePost
|
||||||
|
|||||||
@@ -5,18 +5,15 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
|||||||
import {useA11y} from '#/state/a11y'
|
import {useA11y} from '#/state/a11y'
|
||||||
import {useModals} from '#/state/modals'
|
import {useModals} from '#/state/modals'
|
||||||
import {type ComposerOpts, useComposerState} from '#/state/shell/composer'
|
import {type ComposerOpts, useComposerState} from '#/state/shell/composer'
|
||||||
|
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
|
||||||
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
|
|
||||||
|
|
||||||
const BOTTOM_BAR_HEIGHT = 61
|
const BOTTOM_BAR_HEIGHT = 61
|
||||||
|
|
||||||
export function Composer({}: {winHeight: number}) {
|
export function Composer() {
|
||||||
const state = useComposerState()
|
const state = useComposerState()
|
||||||
const isActive = !!state
|
const isActive = !!state
|
||||||
|
|
||||||
// rendering
|
|
||||||
// =
|
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import {Composer} from './Composer'
|
|||||||
import {DrawerContent} from './Drawer'
|
import {DrawerContent} from './Drawer'
|
||||||
|
|
||||||
function ShellInner() {
|
function ShellInner() {
|
||||||
const winDim = useWindowDimensions()
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const {state: policyUpdateState} = usePolicyUpdateContext()
|
const {state: policyUpdateState} = usePolicyUpdateContext()
|
||||||
|
|
||||||
@@ -108,8 +107,7 @@ function ShellInner() {
|
|||||||
<TabsNavigator layout={drawerLayout} />
|
<TabsNavigator layout={drawerLayout} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</View>
|
</View>
|
||||||
|
<Composer />
|
||||||
<Composer winHeight={winDim.height} />
|
|
||||||
<ModalsContainer />
|
<ModalsContainer />
|
||||||
<MutedWordsDialog />
|
<MutedWordsDialog />
|
||||||
<SigninDialog />
|
<SigninDialog />
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function ShellInner() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<FlatNavigator layout={drawerLayout} />
|
<FlatNavigator layout={drawerLayout} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<Composer winHeight={0} />
|
<Composer />
|
||||||
<ModalsContainer />
|
<ModalsContainer />
|
||||||
<MutedWordsDialog />
|
<MutedWordsDialog />
|
||||||
<SigninDialog />
|
<SigninDialog />
|
||||||
|
|||||||
Reference in New Issue
Block a user