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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-12 19:49:06 +02:00
parent c540dae4e7
commit a538cf8cda
3 changed files with 56 additions and 12 deletions
+16 -7
View File
@@ -48,10 +48,11 @@ export function RichTextTag({
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const navigation = useNavigation<NavigationProp>()
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({
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`See ${tag} posts`)}
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts</Trans>
{isCashtag ? (
<Trans>See {tag} posts</Trans>
) : (
<Trans>See #{tag} posts</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Search} />
</Menu.Item>
{authorHandle && !isInvalidHandle(authorHandle) && (
<Menu.Item
label={_(msg`See ${tag} posts by user`)}
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts by user`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
@@ -130,7 +135,11 @@ export function RichTextTag({
})
}}>
<Menu.ItemText>
<Trans>See #{tag} posts by user</Trans>
{isCashtag ? (
<Trans>See {tag} posts by user</Trans>
) : (
<Trans>See #{tag} posts by user</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
+14 -5
View File
@@ -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
@@ -255,7 +264,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.`)}
/>
) : (
<List
@@ -20,6 +20,10 @@ import {type Node as ProsemirrorNode} from '@tiptap/pm/model'
import {Plugin, PluginKey} from '@tiptap/pm/state'
import {Decoration, DecorationSet} from '@tiptap/pm/view'
// TODO: Import from @atproto/api once updated
const CASHTAG_REGEX =
/(^|\s|\()\$([A-Za-z][A-Za-z0-9]{0,4})(?=\s|$|[.,;:!?)"'\u2019])/gu
function getDecorations(doc: ProsemirrorNode) {
const decorations: Decoration[] = []
@@ -28,6 +32,7 @@ function getDecorations(doc: ProsemirrorNode) {
const regex = TAG_REGEX
const textContent = node.textContent
// Detect hashtags
let match
while ((match = regex.exec(textContent))) {
const [matchedString, __, tag] = match
@@ -52,6 +57,27 @@ function getDecorations(doc: ProsemirrorNode) {
}),
)
}
// Detect cashtags
const cashtagRegex = new RegExp(CASHTAG_REGEX.source, 'gu')
while ((match = cashtagRegex.exec(textContent))) {
const [_fullMatch, leading, ticker] = match
if (!ticker || ticker.length > 5) continue
// Calculate positions: leading char + $ + ticker
const matchedFrom = match.index + leading.length
const matchedTo = matchedFrom + 1 + ticker.length // +1 for $
const start = pos + matchedFrom
const end = pos + matchedTo
decorations.push(
Decoration.inline(start, end, {
class: 'autolink',
}),
)
}
}
})