diff --git a/__tests__/lib/pds-label.test.ts b/__tests__/lib/pds-label.test.ts new file mode 100644 index 000000000..89639c4b5 --- /dev/null +++ b/__tests__/lib/pds-label.test.ts @@ -0,0 +1,26 @@ +import { + getPdsFallbackFaviconUrl, + isBridgedPdsUrl, + isBskyPdsUrl, +} from '#/state/queries/pds-label.util' + +describe('pds-label helpers', () => { + it('builds a favicon.ico fallback URL from the PDS origin', () => { + expect(getPdsFallbackFaviconUrl('https://pds.example')).toBe( + 'https://pds.example/favicon.ico', + ) + expect(getPdsFallbackFaviconUrl('https://pds.example/xrpc')).toBe( + 'https://pds.example/favicon.ico', + ) + }) + + it('returns undefined for an invalid PDS URL', () => { + expect(getPdsFallbackFaviconUrl('not a url')).toBeUndefined() + }) + + it('detects special-case PDS hosts', () => { + expect(isBskyPdsUrl('https://bsky.social')).toBe(true) + expect(isBskyPdsUrl('https://foo.host.bsky.network')).toBe(true) + expect(isBridgedPdsUrl('https://atproto.brid.gy')).toBe(true) + }) +}) diff --git a/metro.config.js b/metro.config.js index dcdead3f5..85c6e01c8 100644 --- a/metro.config.js +++ b/metro.config.js @@ -35,6 +35,13 @@ cfg.resolver.resolveRequest = (context, moduleName, platform) => { if (moduleName === '@ipld/dag-cbor') { return context.resolveRequest(context, '@ipld/dag-cbor/src', platform) } + if (moduleName === '@easrng/tr58') { + return context.resolveRequest( + context, + '@easrng/tr58/dist/index.js', + platform, + ) + } if (process.env.BSKY_PROFILE) { if (moduleName.endsWith('ReactNativeRenderer-prod')) { return context.resolveRequest( diff --git a/src/components/PdsDialog.tsx b/src/components/PdsDialog.tsx index 473ad3235..4d6ee7c85 100644 --- a/src/components/PdsDialog.tsx +++ b/src/components/PdsDialog.tsx @@ -1,5 +1,6 @@ import {useState} from 'react' import {Image, View} from 'react-native' +import Svg, {G, Path, Rect} from 'react-native-svg' import { FontAwesomeIcon, type FontAwesomeIconStyle, @@ -8,17 +9,19 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label' - -const failedFaviconUrls = new Set() -import Svg, {G, Path, Rect} from 'react-native-svg' - +import { + getPdsFallbackFaviconUrl, + isBridgedPdsUrl, + isBskyPdsUrl, +} from '#/state/queries/pds-label.util' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +const failedFaviconUrls = new Set() + function formatBskyPdsDisplayName(hostname: string): string { const match = hostname.match(/^([^.]+)\.([^.]+)\.host\.bsky\.network$/) if (match) { @@ -72,6 +75,7 @@ export function PdsDialog({ Fediverse : displayName} } - {isBsky && + {isBsky && ( Bluesky Social - } + )} @@ -244,17 +248,26 @@ function FaviconBadgeIcon({ size, borderRadius, faviconUrl, + fallbackFaviconUrl, }: { size: number borderRadius: number faviconUrl: string + fallbackFaviconUrl?: string }) { - const t = useTheme() - const [imgError, setImgError] = useState(() => - failedFaviconUrls.has(faviconUrl), + const getInitialUrl = () => { + if (!failedFaviconUrls.has(faviconUrl)) return faviconUrl + if (fallbackFaviconUrl && !failedFaviconUrls.has(fallbackFaviconUrl)) { + return fallbackFaviconUrl + } + return undefined + } + const [currentUrl, setCurrentUrl] = useState( + getInitialUrl, ) + const [imageLoaded, setImageLoaded] = useState(false) - if (imgError) { + if (!currentUrl) { return } @@ -262,22 +275,42 @@ function FaviconBadgeIcon({ + { + setImageLoaded(true) + }} onError={() => { - failedFaviconUrls.add(faviconUrl) - setImgError(true) + failedFaviconUrls.add(currentUrl) + setImageLoaded(false) + + if ( + fallbackFaviconUrl && + currentUrl !== fallbackFaviconUrl && + !failedFaviconUrls.has(fallbackFaviconUrl) + ) { + setCurrentUrl(fallbackFaviconUrl) + return + } + + setCurrentUrl(undefined) }} /> @@ -286,12 +319,14 @@ function FaviconBadgeIcon({ export function PdsBadgeIcon({ faviconUrl, + pdsUrl, isBsky, isBridged, size, borderRadius, }: { faviconUrl?: string + pdsUrl?: string isBsky: boolean isBridged: boolean size: number @@ -300,9 +335,27 @@ export function PdsBadgeIcon({ const r = borderRadius ?? size / 5 if (isBsky) return if (isBridged) return + const fallbackFaviconUrl = pdsUrl + ? getPdsFallbackFaviconUrl(pdsUrl) + : undefined if (faviconUrl) return ( - + + ) + if (fallbackFaviconUrl) + return ( + ) return } diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx index d3ae87b5d..e38368cd7 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -98,7 +98,9 @@ let ShareMenuItems = ({ } else { await ExpoClipboard.setStringAsync(url) } - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') + Toast.show(_(msg`Copied to clipboard`), { + type: 'success', + }) onShareProp() } diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index c757e8c26..c190381ca 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -20,6 +20,7 @@ import {useDisableQuotesMetrics} from '#/state/preferences/disable-quotes-metric import {useDisableReplyMetrics} from '#/state/preferences/disable-reply-metrics' import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics' import { + useGetPost, usePostLikeMutationQueue, usePostRepostMutationQueue, } from '#/state/queries/post' @@ -28,6 +29,7 @@ import { ProgressGuideAction, useProgressGuideControls, } from '#/state/shell/progress-guide' +import * as userActionHistory from '#/state/userActionHistory' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Reply as Bubble} from '#/components/icons/Reply' import {useFormatPostStatCount} from '#/components/PostControls/util' @@ -83,6 +85,7 @@ let PostControls = ({ const {t: l} = useLingui() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() + const getPost = useGetPost() const [queueLike, queueUnlike] = usePostLikeMutationQueue( post, viaRepost, @@ -118,6 +121,21 @@ let PostControls = ({ const autoLikeOnRepost = useAutoLikeOnRepost() + const shouldAutoLikeOnRepost = async () => { + if (post.viewer?.like) return false + + if (userActionHistory.getActionHistory().likes.includes(post.uri)) { + return false + } + + try { + const latestPost = await getPost({uri: post.uri}) + return !latestPost.viewer?.like + } catch { + return false + } + } + const onPressToggleLike = async () => { if (isBlocked) { Toast.show(l`Cannot interact with a blocked user`, { @@ -167,7 +185,7 @@ let PostControls = ({ }) await queueRepost() setHasLikeIconBeenToggled(true) - if (!post.viewer?.like && autoLikeOnRepost) { + if (autoLikeOnRepost && (await shouldAutoLikeOnRepost())) { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionLike', diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx index 95ac5406d..e2eef8a96 100644 --- a/src/components/ProfileBadges.tsx +++ b/src/components/ProfileBadges.tsx @@ -173,6 +173,7 @@ function PdsInlineIcon({ const icon = ( [RQKEY_ROOT, did] @@ -78,7 +49,7 @@ export function usePdsFaviconQuery(pdsUrl: string | undefined) { return useQuery({ queryKey, queryFn: () => - isEnabled ? getFaviconUrl(pdsUrl!, faviconService!) : undefined, + isEnabled ? getFaviconServiceUrl(pdsUrl!, faviconService!) : undefined, enabled: isEnabled, staleTime: 1000 * 60 * 60, // 1 hour }) diff --git a/src/state/queries/pds-label.util.ts b/src/state/queries/pds-label.util.ts new file mode 100644 index 000000000..800f84b23 --- /dev/null +++ b/src/state/queries/pds-label.util.ts @@ -0,0 +1,43 @@ +const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] +const BSKY_PDS_SUFFIX = '.bsky.network' +const BRIDGY_FED_HOSTNAME = 'atproto.brid.gy' + +export function isBskyPdsUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname + return ( + BSKY_PDS_HOSTNAMES.includes(hostname) || + hostname.endsWith(BSKY_PDS_SUFFIX) + ) + } catch { + return false + } +} + +export function isBridgedPdsUrl(url: string): boolean { + try { + return new URL(url).hostname === BRIDGY_FED_HOSTNAME + } catch { + return false + } +} + +export function getFaviconServiceUrl( + pdsUrl: string, + faviconService: string, +): string | undefined { + try { + const hostname = new URL(pdsUrl).hostname + return faviconService.replace('(pds)', hostname) + } catch { + return undefined + } +} + +export function getPdsFallbackFaviconUrl(pdsUrl: string): string | undefined { + try { + return new URL('/favicon.ico', pdsUrl).toString() + } catch { + return undefined + } +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 06cb382d3..fffcba60b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -8,7 +8,7 @@ import { useState, useSyncExternalStore, } from 'react' -import {type AtpAgent, type AtpSessionEvent} from '@atproto/api' +import {type Agent, type AtpAgent, type AtpSessionEvent} from '@atproto/api' import * as persisted from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' @@ -482,7 +482,7 @@ export function useAgent(): AtpAgent { return agent } -export function useBlankPrefAuthedAgent(): BskyAgent { +export function useBlankPrefAuthedAgent(): Agent { const agent = useContext(AgentContext) if (!agent) { throw Error('useAgent() must be below .') diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 5ebad1a47..6fb8ad374 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -504,7 +504,7 @@ let NotificationFeedItem = ({ ) : ( {firstAuthorLink} liked your repost ) - icon = + icon = } else if (item.type === 'repost-via-repost') { a11yLabel = hasMultipleAuthors ? _(