From 26aa7c0d9a9ea6092876c9ddbb2f503c8b950a55 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 15 Jan 2026 03:03:48 +0200 Subject: [PATCH] Add cashtag support for stock ticker discussions (#9689) * Add cashtag support for stock ticker discussions Display cashtags ($TICKER format) as clickable links with dedicated menu actions and search feed, alongside hashtags. Includes composer highlighting and proper formatting. Co-Authored-By: Claude Haiku 4.5 * Fix cashtag search query to use # prefix Cashtags need to be searched as "#$BTC" rather than just "$BTC" to work with the search API. Co-Authored-By: Claude Opus 4.5 * Update @atproto/api to 0.18.14 with cashtag support The updated package includes native CASHTAG_REGEX detection for stock ticker symbols like $AAPL and $BTC. Co-Authored-By: Claude Opus 4.5 * Fix todo, remove redundant validation --------- Co-authored-by: Claude Haiku 4.5 Co-authored-by: Eric Bailey --- package.json | 2 +- src/components/RichTextTag.tsx | 23 +- src/screens/Hashtag.tsx | 29 +- .../composer/text-input/web/TagDecorator.ts | 28 +- yarn.lock | 286 ++++++++++-------- 5 files changed, 228 insertions(+), 140 deletions(-) diff --git a/package.json b/package.json index 32b78e18fb..d94a49874f 100644 --- a/package.json +++ b/package.json @@ -226,7 +226,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.196", + "@atproto/dev-env": "^0.3.204", "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/runtime": "^7.26.0", diff --git a/src/components/RichTextTag.tsx b/src/components/RichTextTag.tsx index 4f1af6e1ea..219ead634e 100644 --- a/src/components/RichTextTag.tsx +++ b/src/components/RichTextTag.tsx @@ -48,10 +48,11 @@ export function RichTextTag({ reset: resetRemove, } = useRemoveMutedWordsMutation() const navigation = useNavigation() - const label = _(msg`Hashtag ${tag}`) + const isCashtag = tag.startsWith('$') + const label = isCashtag ? _(msg`Cashtag ${tag}`) : _(msg`Hashtag ${tag}`) const hint = isNative - ? _(msg`Long press to open tag menu for #${tag}`) - : _(msg`Click to open tag menu for ${tag}`) + ? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`) + : _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`) const isMuted = Boolean( (preferences?.moderationPrefs.mutedWords?.find( @@ -109,20 +110,24 @@ export function RichTextTag({ { navigation.push('Hashtag', { tag: encodeURIComponent(tag), }) }}> - See #{tag} posts + {isCashtag ? ( + See {tag} posts + ) : ( + See #{tag} posts + )} {authorHandle && !isInvalidHandle(authorHandle) && ( { navigation.push('Hashtag', { tag: encodeURIComponent(tag), @@ -130,7 +135,11 @@ export function RichTextTag({ }) }}> - See #{tag} posts by user + {isCashtag ? ( + See {tag} posts by user + ) : ( + See #{tag} posts by user + )} diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index b57b73edb6..226067ec6b 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -46,13 +46,22 @@ export default function HashtagScreen({ const {tag, author} = route.params const {_} = useLingui() - const fullTag = React.useMemo(() => { - return `#${decodeURIComponent(tag)}` + const decodedTag = React.useMemo(() => { + return decodeURIComponent(tag) }, [tag]) + const isCashtag = decodedTag.startsWith('$') + + const fullTag = React.useMemo(() => { + // Cashtags already include the $ prefix, hashtags need # added + return isCashtag ? decodedTag : `#${decodedTag}` + }, [decodedTag, isCashtag]) + const headerTitle = React.useMemo(() => { - return enforceLen(fullTag.toLowerCase(), 24, true, 'middle') - }, [fullTag]) + // Keep cashtags uppercase, lowercase hashtags + const displayTag = isCashtag ? fullTag.toUpperCase() : fullTag.toLowerCase() + return enforceLen(displayTag, 24, true, 'middle') + }, [fullTag, isCashtag]) const sanitizedAuthor = React.useMemo(() => { if (!author) return @@ -172,10 +181,14 @@ function HashtagScreenTab({ const {hasSession} = useSession() const trackPostView = usePostViewTracking('Hashtag') + const isCashtag = fullTag.startsWith('$') + const queryParam = React.useMemo(() => { - if (!author) return fullTag - return `${fullTag} from:${author}` - }, [fullTag, author]) + // Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author" + const searchTag = isCashtag ? `#${fullTag}` : fullTag + if (!author) return searchTag + return `${searchTag} from:${author}` + }, [fullTag, author, isCashtag]) const { data, @@ -255,7 +268,7 @@ function HashtagScreenTab({ isError={isError} onRetry={refetch} emptyType="results" - emptyMessage={_(msg`We couldn't find any results for that hashtag.`)} + emptyMessage={_(msg`We couldn't find any results for that tag.`)} /> ) : (