From 1b8f4204434deb7bb586ae9d44ccafbc1b707a92 Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Fri, 17 Jul 2026 16:26:06 -0700 Subject: [PATCH] show tags on posts and in composer --- src/components/Post/Embed/index.tsx | 2 + .../PostControls/PostMenu/PostMenuItems.tsx | 1 + src/components/PostTags.tsx | 58 ++++ src/lib/api/index.ts | 4 +- src/lib/constants.ts | 8 + .../components/ThreadItemAnchor.tsx | 2 + .../PostThread/components/ThreadItemPost.tsx | 2 + .../components/ThreadItemReaderSegment.tsx | 2 + .../components/ThreadItemTreePost.tsx | 2 + src/state/shell/composer/index.tsx | 4 + src/view/com/composer/Composer.tsx | 21 +- src/view/com/composer/drafts/DraftItem.tsx | 12 + src/view/com/composer/drafts/state/api.ts | 27 ++ src/view/com/composer/drafts/state/schema.ts | 2 + src/view/com/composer/state/composer.ts | 16 + src/view/com/composer/tags/TagsBtn.tsx | 281 ++++++++++++++++++ src/view/com/post/Post.tsx | 8 +- src/view/com/posts/PostFeedItem.tsx | 2 + src/view/shell/Composer.ios.tsx | 1 + src/view/shell/Composer.tsx | 1 + src/view/shell/Composer.web.tsx | 1 + 21 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 src/components/PostTags.tsx create mode 100644 src/view/com/composer/tags/TagsBtn.tsx diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index d9bd26111..c61ff6eae 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -30,6 +30,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts' import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext' import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' +import {PostTags} from '#/components/PostTags' import {RichText} from '#/components/RichText' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {SubtleHover} from '#/components/SubtleHover' @@ -473,6 +474,7 @@ export function QuoteEmbed({ disableLinks /> ) : null} + {quote.embed && ( { diff --git a/src/components/PostTags.tsx b/src/components/PostTags.tsx new file mode 100644 index 000000000..df8ae7e59 --- /dev/null +++ b/src/components/PostTags.tsx @@ -0,0 +1,58 @@ +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {AppBskyFeedPost, type AppBskyFeedDefs} from '@atproto/api' + +import {atoms as a, tokens, useTheme} from '#/alf' +import {RichTextTag} from '#/components/RichTextTag' + +/** + * Renders outline tags from `app.bsky.feed.post` `tags` (separate from + * in-text hashtag facets). + */ +export function PostTags({ + post, + style, +}: { + post: AppBskyFeedDefs.PostView + style?: StyleProp +}) { + const t = useTheme() + const tags = getOutlineTags(post) + + if (!tags.length) { + return null + } + + return ( + + {tags.map(tag => ( + + ))} + + ) +} + +function getOutlineTags(post: AppBskyFeedDefs.PostView): string[] { + if (!AppBskyFeedPost.isRecord(post.record)) { + return [] + } + const tags = post.record.tags + return Array.isArray(tags) + ? tags.filter((tag): tag is string => typeof tag === 'string') + : [] +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 192ba3df4..cb37ba049 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -22,7 +22,7 @@ import {sha256} from 'js-sha256' import {CID} from 'multiformats/cid' import * as Hasher from 'multiformats/hashes/hasher' -import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' +import {IMAGE_SIZE_CONFIG_POSTS, MAX_TAGS} from '#/lib/constants' import {isNetworkError} from '#/lib/strings/errors' import { parseMarkdownLinks, @@ -129,6 +129,7 @@ export async function post( values: draft.labels.map(val => ({val})), } } + const tags = draft.tags.length ? draft.tags.slice(0, MAX_TAGS) : undefined // The sorting behavior for multiple posts sharing the same createdAt time is // undefined, so what we'll do here is increment the time by 1 for every post @@ -152,6 +153,7 @@ export async function post( embed, langs, labels, + tags, } if (!opts.omitViaField) { record.via = via diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c934f1051..49b4d48fd 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -59,6 +59,14 @@ export const MAX_DESCRIPTION = 256 export const MAX_GRAPHEME_LENGTH = 300 +/** + * Outline tags on `app.bsky.feed.post` (`tags` array). + * @see https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json + */ +export const MAX_TAGS = 8 +export const MAX_GRAPHEME_LENGTH_PER_TAG = 64 +export const MAX_BYTES_PER_TAG = 640 + export const MAX_DRAFT_GRAPHEME_LENGTH = 1000 export const MAX_DM_GRAPHEME_LENGTH = 1000 diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index bf22e6eb7..213b268f4 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -77,6 +77,7 @@ import {type AppModerationCause} from '#/components/Pills' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {TranslatedPost} from '#/components/Post/Translated' import {PostControls, PostControlsSkeleton} from '#/components/PostControls' +import {PostTags} from '#/components/PostTags' import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import * as Prompt from '#/components/Prompt' @@ -532,6 +533,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ ) : ( )} + )} + {post.embed && ( ) : undefined} + )} + {post.embed && ( diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index c556a585c..60b9edfad 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -56,6 +56,10 @@ export interface ComposerOpts { quote?: AppBskyFeedDefs.PostView mention?: string // handle of user to mention text?: string + /** + * Outline tags from `app.bsky.feed.post` `tags` (e.g. when redrafting). + */ + tags?: string[] imageUris?: { uri: string width: number diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 249ee95d3..4f586c76b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -137,6 +137,7 @@ import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLa // TODO: Prevent naming components that coincide with RN primitives // due to linting false positives import {TextInput} from '#/view/com/composer/text-input/TextInput' +import {TagsBtn} from '#/view/com/composer/tags/TagsBtn' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft' @@ -283,6 +284,7 @@ export const ComposePost = ({ quote: initQuote, mention: initMention, text: initText, + tags: initTags, imageUris: initImageUris, videoUri: initVideoUri, openGallery, @@ -456,6 +458,7 @@ export const ComposePost = ({ initMention, initInteractionSettings: preferences?.postInteractionSettings, initVideoUri, + initTags, }), ) @@ -2413,11 +2416,6 @@ function ComposerPills({ media?.type === 'video' const hasLink = !!post.embed.link - // Don't render anything if no pills are going to be displayed - if (isReply && !hasMedia && !hasLink) { - return null - } - return ( @@ -2443,6 +2441,19 @@ function ComposerPills({ style={bottomBarAnimatedStyle} /> )} + { + dispatch({ + type: 'update_post', + postId: post.id, + postAction: { + type: 'update_tags', + tags: nextTags, + }, + }) + }} + /> {hasMedia || hasLink ? ( )} + {post.tags && post.tags.length > 0 ? ( + + {post.tags.map(tag => `#${tag}`).join(' ')} + + ) : null} + {!mediaExistsOnOtherDevice && } {hasMetadata && ( diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 12ed4acf5..2fc7a5cd1 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -5,6 +5,7 @@ import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {resolveLink} from '#/lib/api/resolve' +import {MAX_TAGS} from '#/lib/constants' import {getDeviceName} from '#/lib/deviceName' import {getImageDim} from '#/lib/media/manip' import {mimeToExt} from '#/lib/media/video/util' @@ -29,6 +30,22 @@ import * as storage from './storage' const TENOR_HOSTNAME = 'media.tenor.com' const KLIPY_HOSTNAME = 'static.klipy.com' +/** + * Draft lexicon does not include outline tags; Witchsky stores them as an + * extra field on draft posts (valid AT Protocol open object). + */ +type DraftPostWithTags = AppBskyDraftDefs.DraftPost & {tags?: string[]} + +function getDraftPostTags(post: AppBskyDraftDefs.DraftPost): string[] { + const tags = (post as DraftPostWithTags).tags + if (!Array.isArray(tags)) { + return [] + } + return tags + .filter((tag): tag is string => typeof tag === 'string' && tag.length > 0) + .slice(0, MAX_TAGS) +} + /** * Video data from a draft that needs to be restored by re-processing. * Contains the local file URI, alt text, mime type, and captions to restore. @@ -109,6 +126,11 @@ async function postDraftToServerPost( } } + // Outline tags are not in the draft lexicon; store as an extra field. + if (post.tags.length > 0) { + ;(draftPost as DraftPostWithTags).tags = post.tags + } + // Add embeds if (post.embed.media) { // We always write the `embedGallery` shape for images now, including the @@ -356,6 +378,7 @@ export function draftViewToSummary({ const images: DraftPostDisplay['images'] = [] const videos: DraftPostDisplay['video'][] = [] let gif: DraftPostDisplay['gif'] + const tags = getDraftPostTags(post) // Process images if (post.embedImages) { @@ -429,6 +452,7 @@ export function draftViewToSummary({ return { id: `post-${index}`, text: post.text || '', + tags: tags.length > 0 ? tags : undefined, images: images.length > 0 ? images : undefined, video: videos[0], // Only one video per post gif, @@ -626,11 +650,14 @@ export async function draftToComposerPosts( } } + const tags = getDraftPostTags(post) + return { id: `draft-post-${index}`, richtext, shortenedGraphemeLength: shortenLinks(richtext).graphemeLength, labels, + tags, embed, } as PostDraft }), diff --git a/src/view/com/composer/drafts/state/schema.ts b/src/view/com/composer/drafts/state/schema.ts index 9f88aa07d..786c106b3 100644 --- a/src/view/com/composer/drafts/state/schema.ts +++ b/src/view/com/composer/drafts/state/schema.ts @@ -37,6 +37,8 @@ export type DraftPostDisplay = { id: string /** Full text content */ text: string + /** Outline tags from `app.bsky.feed.post` `tags` */ + tags?: string[] /** Image references for display */ images?: LocalMediaDisplay[] /** Video reference */ diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index db1adee0b..7bfaa5c26 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -9,6 +9,7 @@ import { import {nanoid} from 'nanoid/non-secure' import {type VideoTelemetry} from '#/lib/media/video/telemetry' +import {MAX_TAGS} from '#/lib/constants' import {type SelfLabel} from '#/lib/moderation' import {insertMentionAt} from '#/lib/strings/mention-manip' import {parseMarkdownLinks, shortenLinks} from '#/lib/strings/rich-text-manip' @@ -76,6 +77,10 @@ export type PostDraft = { id: string richtext: RichText labels: SelfLabel[] + /** + * Outline tags written to `app.bsky.feed.post` `tags` (not in-text hashtags). + */ + tags: string[] embed: EmbedDraft shortenedGraphemeLength: number } @@ -83,6 +88,7 @@ export type PostDraft = { export type PostAction = | {type: 'update_richtext'; richtext: RichText} | {type: 'update_labels'; labels: SelfLabel[]} + | {type: 'update_tags'; tags: string[]} | {type: 'embed_add_images'; images: ComposerImage[]} | {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_remove_image'; image: ComposerImage} @@ -243,6 +249,7 @@ export function composerReducer( richtext: new RichText({text: ''}), shortenedGraphemeLength: 0, labels: [], + tags: [], embed: { quote: undefined, media: undefined, @@ -364,6 +371,12 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { labels: action.labels, } } + case 'update_tags': { + return { + ...state, + tags: action.tags, + } + } case 'embed_add_images': { if (action.images.length === 0) { return state @@ -625,6 +638,7 @@ export function createComposerState({ initQuoteUri, initInteractionSettings, initVideoUri, + initTags, }: { initText: string | undefined initMention: string | undefined @@ -634,6 +648,7 @@ export function createComposerState({ | AppBskyActorDefs.PostInteractionSettingsPref | undefined initVideoUri?: ComposerOpts['videoUri'] + initTags?: string[] }): ComposerState { let media: ImagesMedia | GalleryMedia | VideoMedia | undefined if (initImageUris?.length) { @@ -748,6 +763,7 @@ export function createComposerState({ richtext: initRichText, shortenedGraphemeLength: getShortenedLength(initRichText), labels: [], + tags: initTags?.length ? initTags.slice(0, MAX_TAGS) : [], embed: { quote, media, diff --git a/src/view/com/composer/tags/TagsBtn.tsx b/src/view/com/composer/tags/TagsBtn.tsx new file mode 100644 index 000000000..d925cec9b --- /dev/null +++ b/src/view/com/composer/tags/TagsBtn.tsx @@ -0,0 +1,281 @@ +import {useState} from 'react' +import {Keyboard, View} from 'react-native' +import {UnicodeString} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' + +import { + HITSLOP_10, + MAX_BYTES_PER_TAG, + MAX_GRAPHEME_LENGTH_PER_TAG, + MAX_TAGS, +} from '#/lib/constants' +import {atoms as a, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' +import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_WEB} from '#/env' + +/** + * Normalize a raw tag input for `app.bsky.feed.post` `tags`. + * Strips a leading `#`, trims, and enforces lexicon grapheme/byte limits. + */ +export function normalizeOutlineTag(raw: string): string | undefined { + let tag = raw.trim() + if (tag.startsWith('#')) { + tag = tag.slice(1).trim() + } + if (!tag) { + return undefined + } + const unicode = new UnicodeString(tag) + if ( + unicode.graphemeLength > MAX_GRAPHEME_LENGTH_PER_TAG || + unicode.length > MAX_BYTES_PER_TAG + ) { + return undefined + } + return tag +} + +export function TagsBtn({ + tags, + onChange, +}: { + tags: string[] + onChange: (v: string[]) => void +}) { + const control = Dialog.useDialogControl() + const {t: l} = useLingui() + const hasTags = tags.length > 0 + + return ( + <> + + + + + + + + ) +} + +function DialogInner({ + tags, + onChange, +}: { + tags: string[] + onChange: (v: string[]) => void +}) { + const {t: l} = useLingui() + const control = Dialog.useDialogContext() + const t = useTheme() + const [field, setField] = useState('') + const [error, setError] = useState() + + const atLimit = tags.length >= MAX_TAGS + const canAdd = !atLimit && !!field.trim() + + const tryAddTag = ( + raw: string, + currentTags: string[], + ): {tags: string[]; error?: string} => { + const tag = normalizeOutlineTag(raw) + if (!tag) { + return {tags: currentTags, error: l`Enter a valid tag`} + } + if (currentTags.length >= MAX_TAGS) { + return { + tags: currentTags, + error: l`You can add up to ${MAX_TAGS} tags`, + } + } + if (currentTags.some(existing => existing.toLowerCase() === tag.toLowerCase())) { + return {tags: currentTags, error: l`This tag is already added`} + } + return {tags: [...currentTags, tag]} + } + + const addTag = (raw: string = field) => { + const result = tryAddTag(raw, tags) + if (result.error) { + setError(result.error) + return + } + onChange(result.tags) + setField('') + setError(undefined) + } + + const onChangeText = (value: string) => { + if (error) { + setError(undefined) + } + if (!value.includes(',')) { + setField(value) + return + } + + const parts = value.split(',') + const remainder = parts.pop() ?? '' + let nextTags = tags + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (!part.trim()) { + continue + } + const result = tryAddTag(part, nextTags) + if (result.error) { + const leftover = [part, ...parts.slice(i + 1), remainder] + .filter(segment => segment.length > 0) + .join(',') + setField(leftover) + setError(result.error) + if (nextTags !== tags) { + onChange(nextTags) + } + return + } + nextTags = result.tags + } + if (nextTags !== tags) { + onChange(nextTags) + } + setField(remainder) + } + + const removeTag = (tag: string) => { + onChange(tags.filter(existing => existing !== tag)) + setError(undefined) + } + + return ( + + + + + Add tags + + + + Tags help people find your post. They are stored separately from + the post text, so they do not count toward the character limit. + + + + + + + addTag()} + style={[{paddingRight: 40}]} + /> + + + + + {error ? ( + + {error} + + ) : ( + + + {tags.length} of {MAX_TAGS} tags + + + )} + + + {tags.length > 0 ? ( + + {tags.map(tag => ( + + ))} + + ) : null} + + + + + + + ) +} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 2753f80f6..d14c45093 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -38,6 +38,7 @@ import {PostRepliedTo} from '#/components/Post/PostRepliedTo' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' import {TranslatedPost} from '#/components/Post/Translated' import {PostControls} from '#/components/PostControls' +import {PostTags} from '#/components/PostTags' import {RichText} from '#/components/RichText' import {SubtleHover} from '#/components/SubtleHover' import * as bsky from '#/types/bsky' @@ -255,9 +256,10 @@ function PostInner({ /> )} - ) : undefined} - - {post.embed ? ( + ) : undefined} + + + {post.embed ? ( ) : undefined} + {record && } {postEmbed ? ( diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index b61bb18a2..8ab05ac3f 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -46,6 +46,7 @@ export function Composer() { quote={state.quote} mention={state.mention} text={state.text} + tags={state.tags} imageUris={state.imageUris} videoUri={state.videoUri} openGallery={state.openGallery} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 89c4ff12e..6afa15218 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -71,6 +71,7 @@ function Inner({state}: {state: ComposerOpts}) { onPostSuccess={state.onPostSuccess} mention={state.mention} text={state.text} + tags={state.tags} imageUris={state.imageUris} videoUri={state.videoUri} openGallery={state.openGallery} -- 2.51.2