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:
@@ -48,10 +48,11 @@ export function RichTextTag({
|
|||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordsMutation()
|
} = useRemoveMutedWordsMutation()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
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
|
const hint = isNative
|
||||||
? _(msg`Long press to open tag menu for #${tag}`)
|
? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
|
||||||
: _(msg`Click to open tag menu for ${tag}`)
|
: _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.moderationPrefs.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
@@ -109,20 +110,24 @@ export function RichTextTag({
|
|||||||
<Menu.Outer>
|
<Menu.Outer>
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
label={_(msg`See ${tag} posts`)}
|
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts`)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.push('Hashtag', {
|
navigation.push('Hashtag', {
|
||||||
tag: encodeURIComponent(tag),
|
tag: encodeURIComponent(tag),
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>See #{tag} posts</Trans>
|
{isCashtag ? (
|
||||||
|
<Trans>See {tag} posts</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>See #{tag} posts</Trans>
|
||||||
|
)}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Search} />
|
<Menu.ItemIcon icon={Search} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
label={_(msg`See ${tag} posts by user`)}
|
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts by user`)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.push('Hashtag', {
|
navigation.push('Hashtag', {
|
||||||
tag: encodeURIComponent(tag),
|
tag: encodeURIComponent(tag),
|
||||||
@@ -130,7 +135,11 @@ export function RichTextTag({
|
|||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
<Menu.ItemText>
|
<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.ItemText>
|
||||||
<Menu.ItemIcon icon={Person} />
|
<Menu.ItemIcon icon={Person} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
+14
-5
@@ -46,13 +46,22 @@ export default function HashtagScreen({
|
|||||||
const {tag, author} = route.params
|
const {tag, author} = route.params
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const fullTag = React.useMemo(() => {
|
const decodedTag = React.useMemo(() => {
|
||||||
return `#${decodeURIComponent(tag)}`
|
return decodeURIComponent(tag)
|
||||||
}, [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(() => {
|
const headerTitle = React.useMemo(() => {
|
||||||
return enforceLen(fullTag.toLowerCase(), 24, true, 'middle')
|
// Keep cashtags uppercase, lowercase hashtags
|
||||||
}, [fullTag])
|
const displayTag = isCashtag ? fullTag.toUpperCase() : fullTag.toLowerCase()
|
||||||
|
return enforceLen(displayTag, 24, true, 'middle')
|
||||||
|
}, [fullTag, isCashtag])
|
||||||
|
|
||||||
const sanitizedAuthor = React.useMemo(() => {
|
const sanitizedAuthor = React.useMemo(() => {
|
||||||
if (!author) return
|
if (!author) return
|
||||||
@@ -255,7 +264,7 @@ function HashtagScreenTab({
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
emptyType="results"
|
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
|
<List
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {type Node as ProsemirrorNode} from '@tiptap/pm/model'
|
|||||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
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) {
|
function getDecorations(doc: ProsemirrorNode) {
|
||||||
const decorations: Decoration[] = []
|
const decorations: Decoration[] = []
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ function getDecorations(doc: ProsemirrorNode) {
|
|||||||
const regex = TAG_REGEX
|
const regex = TAG_REGEX
|
||||||
const textContent = node.textContent
|
const textContent = node.textContent
|
||||||
|
|
||||||
|
// Detect hashtags
|
||||||
let match
|
let match
|
||||||
while ((match = regex.exec(textContent))) {
|
while ((match = regex.exec(textContent))) {
|
||||||
const [matchedString, __, tag] = match
|
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',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user