diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx
index be3350485d..fb32250464 100644
--- a/bskyembed/src/components/post.tsx
+++ b/bskyembed/src/components/post.tsx
@@ -10,6 +10,7 @@ import logo from '../../assets/logo_full_name.svg'
import {Like as LikeIcon} from '../icons/Like'
import {Reply as ReplyIcon} from '../icons/Reply'
import {Repost as RepostIcon} from '../icons/Repost'
+import {Robot as RobotIcon} from '../icons/Robot'
import {CONTENT_LABELS} from '../labels'
import * as bsky from '../types/bsky'
import {niceDate} from '../util/nice-date'
@@ -43,6 +44,9 @@ export function Post({thread}: Props) {
}
const verification = getVerificationState({profile: post.author})
+ const isBot = post.author.labels?.some(
+ l => l.val === 'bot' && l.src === post.author.did,
+ )
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
@@ -76,6 +80,12 @@ export function Post({thread}: Props) {
size={15}
/>
)}
+ {isBot && (
+
+ )}
(
+
+)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 03ff43d739..7ef150e9e5 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -103,6 +103,7 @@ import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacyS
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
+import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
@@ -403,6 +404,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
+ AutomationLabelSettingsScreen}
+ options={{
+ title: title(msg`Automation Label`),
+ requireAuth: true,
+ }}
+ />
PrivacyAndSecuritySettingsScreen}
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 28c3150c26..d3d5d88f92 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -732,6 +732,9 @@ export type Events = {
'verification:settings:hideBadges': {}
'verification:settings:unHideBadges': {}
+ 'bot:label:toggle': {state: 'add' | 'remove'}
+ 'bot:badge:click': {}
+
'live:create': {duration: number}
'live:edit': {}
'live:remove': {}
diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx
index ad0f44809d..2e5e39aaa4 100644
--- a/src/components/AccountList.tsx
+++ b/src/components/AccountList.tsx
@@ -12,6 +12,7 @@ import {useProfilesQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {Button} from '#/components/Button'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
@@ -172,6 +173,11 @@ function AccountItem({
/>
)}
+ {profile && (
+
+
+
+ )}
+
+
+
+
+
+ {description}
+
+
+
+ {isOwn ? (
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/components/BotBadge.tsx b/src/components/BotBadge.tsx
new file mode 100644
index 0000000000..8d8783e5ec
--- /dev/null
+++ b/src/components/BotBadge.tsx
@@ -0,0 +1,94 @@
+import {View} from 'react-native'
+import {type ComAtprotoLabelDefs} from '@atproto/api'
+import {msg} from '@lingui/core/macro'
+import {useLingui} from '@lingui/react'
+
+import {isBotAccount} from '#/lib/bots'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {BotAccountAlert} from '#/components/BotAccountAlert'
+import {Button} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {Robot_Filled_Corner2_Rounded as RobotIcon} from '#/components/icons/Robot'
+import {useAnalytics} from '#/analytics'
+
+export function BotBadge({
+ profile,
+ alwaysShow = false,
+ size = 14,
+}: {
+ profile: {did: string; labels?: ComAtprotoLabelDefs.Label[]}
+ alwaysShow?: boolean
+ size?: number
+}) {
+ const t = useTheme()
+
+ if (!isBotAccount(profile) && !alwaysShow) {
+ return null
+ }
+
+ return (
+
+
+
+ )
+}
+
+export function BotBadgeButton({
+ profile,
+ size,
+ isMe,
+}: {
+ profile: {did: string; labels?: ComAtprotoLabelDefs.Label[]}
+ size: 'lg' | 'md' | 'sm'
+ isMe?: boolean
+}) {
+ const t = useTheme()
+ const ax = useAnalytics()
+ const {_} = useLingui()
+ const {gtPhone} = useBreakpoints()
+ const control = useDialogControl()
+
+ if (!isBotAccount(profile)) {
+ return null
+ }
+
+ let dimensions = 12
+ if (size === 'lg') {
+ dimensions = gtPhone ? 20 : 18
+ } else if (size === 'md') {
+ dimensions = 14
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx
index 3ba2682239..07943d073c 100644
--- a/src/components/PostControls/ShareMenu/RecentChats.tsx
+++ b/src/components/PostControls/ShareMenu/RecentChats.tsx
@@ -15,6 +15,7 @@ import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, tokens, useTheme} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {Button} from '#/components/Button'
import {useDialogContext} from '#/components/Dialog'
import {Text} from '#/components/Typography'
@@ -149,6 +150,7 @@ function RecentChatItem({
/>
)}
+
)
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 1cbfc2b50d..0e489c5aac 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -30,6 +30,7 @@ import {
useTheme,
type ViewStyleProp,
} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {
Button,
ButtonIcon,
@@ -272,6 +273,14 @@ function InlineNameAndHandle({
/>
)}
+
+
+
)}
+
+
+
)
}
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index 8586607e52..938bdf330c 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -23,6 +23,7 @@ import {formatCount} from '#/view/com/util/numeric/format'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
import {atoms as a, useTheme} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
import {useRichText} from '#/components/hooks/useRichText'
@@ -541,6 +542,7 @@ function Inner({
/>
)}
+
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index afdeee545e..1f87c77371 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -15,6 +15,7 @@ import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import * as Layout from '#/components/Layout'
@@ -169,6 +170,7 @@ function HeaderReady({
/>
)}
+
{!isDeletedAccount && (
!l.val.startsWith('!'))
+ labels = labels.filter(
+ l =>
+ !l.val.startsWith('!') &&
+ !(l.val === 'bot' && l.src === currentAccount.did),
+ )
if (!labels.length) {
return null
}
diff --git a/src/lib/bots.ts b/src/lib/bots.ts
new file mode 100644
index 0000000000..0f9a24baae
--- /dev/null
+++ b/src/lib/bots.ts
@@ -0,0 +1,10 @@
+import {type ComAtprotoLabelDefs} from '@atproto/api'
+
+export function isBotAccount(profile: {
+ did: string
+ labels?: ComAtprotoLabelDefs.Label[]
+}): boolean {
+ return (
+ profile.labels?.some(l => l.val === 'bot' && l.src === profile.did) ?? false
+ )
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0a3a0d5458..5bb7265709 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -50,6 +50,7 @@ export type CommonNavigatorParams = {
AccessibilitySettings: undefined
AppearanceSettings: undefined
AccountSettings: undefined
+ AutomationLabelSettings: undefined
PrivacyAndSecuritySettings: undefined
ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined
diff --git a/src/routes.ts b/src/routes.ts
index f325539c71..75eda461c8 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -50,6 +50,7 @@ export const router = new Router({
AppearanceSettings: '/settings/appearance',
SavedFeeds: '/settings/saved-feeds',
AccountSettings: '/settings/account',
+ AutomationLabelSettings: '/settings/automation-label',
PrivacyAndSecuritySettings: '/settings/privacy-and-security',
ActivityPrivacySettings: '/settings/privacy-and-security/activity',
ContentAndMediaSettings: '/settings/content-and-media',
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index f09cf23007..30985b21f4 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -31,6 +31,7 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import * as tokens from '#/alf/tokens'
+import {BotBadge} from '#/components/BotBadge'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
@@ -422,6 +423,7 @@ function ChatListItemReady({
/>
)}
+
{lastMessageSentAt && (
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index 1531784aee..80ebec2b8c 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -34,6 +34,7 @@ import {
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
@@ -364,6 +365,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
+
+
+
+
diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx
index 5b463ae75d..6cae572c93 100644
--- a/src/screens/Search/components/SearchHistory.tsx
+++ b/src/screens/Search/components/SearchHistory.tsx
@@ -10,6 +10,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout'
@@ -173,6 +174,7 @@ function RecentProfileItem({
/>
)}
+
+
+
+
+ Automation label
+
+ {profile && (
+
+ {isBotAccount(profile) ? _(msg`On`) : _(msg`Off`)}
+
+ )}
+
+export function AutomationLabelSettingsScreen({}: Props) {
+ const t = useTheme()
+ const ax = useAnalytics()
+ const {_} = useLingui()
+ const queryClient = useQueryClient()
+ const {currentAccount} = useSession()
+ const {data: profile} = useProfileQuery({did: currentAccount?.did})
+ const updateProfile = useProfileUpdateMutation()
+ const verification = useSimpleVerificationState({profile})
+
+ const isBotLabeled =
+ profile?.labels?.some(l => l.val === 'bot' && l.src === profile.did) ??
+ false
+ const canToggle = profile && !updateProfile.isPending
+
+ const onToggle = () => {
+ if (!profile) {
+ return
+ }
+ let wasAdded = false
+ ax.metric('bot:label:toggle', {state: isBotLabeled ? 'remove' : 'add'})
+ updateProfile.mutate(
+ {
+ profile,
+ updates: existing => {
+ const labels: $Typed = bsky.validate(
+ existing.labels,
+ ComAtprotoLabelDefs.validateSelfLabels,
+ )
+ ? existing.labels
+ : {
+ $type: 'com.atproto.label.defs#selfLabels',
+ values: [],
+ }
+
+ const hasLabel = labels.values.some(l => l.val === 'bot')
+ if (hasLabel) {
+ wasAdded = false
+ labels.values = labels.values.filter(l => l.val !== 'bot')
+ } else {
+ wasAdded = true
+ labels.values.push({val: 'bot'})
+ }
+
+ if (labels.values.length === 0) {
+ delete existing.labels
+ } else {
+ existing.labels = labels
+ }
+
+ return existing
+ },
+ checkCommitted: res => {
+ const exists = !!res.data.labels?.some(l => l.val === 'bot')
+ return exists === wasAdded
+ },
+ },
+ {
+ onSuccess() {
+ queryClient.invalidateQueries({queryKey: [POST_FEED_RQKEY_ROOT]})
+ queryClient.invalidateQueries({queryKey: [postThreadQueryKeyRoot]})
+ },
+ },
+ )
+ }
+
+ return (
+
+
+
+
+
+ Automation Label
+
+
+
+
+
+
+ {profile && (
+
+
+
+
+
+
+ {profile.displayName || profile.handle}
+
+ {verification.isVerified && (
+
+ )}
+
+
+
+
+ @{profile.handle}
+
+
+
+ )}
+
+
+ Add automation label to account
+
+
+
+ This label lets the world know that this account is automated.
+ If turned on, this label appears next to the account's name on
+ their profile and posts. It can be turned on or off at any time.
+
+
+
+
+
+
+
+
+ Show automation label
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index 575bd4cce1..1e9c07fad4 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -34,6 +34,7 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
import {AvatarStackWithFetch} from '#/components/AvatarStack'
+import {BotBadgeButton} from '#/components/BotBadge'
import {Button, ButtonText} from '#/components/Button'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
import {useDialogControl} from '#/components/Dialog'
@@ -374,6 +375,14 @@ function ProfilePreview({
)}
+
+
+
{sanitizeHandle(profile.handle, '@')}
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 3757ed7c92..b30522eb52 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -15,6 +15,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {type ComposerOptsPostRef} from '#/state/shell/composer'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {QuoteEmbed} from '#/components/Post/Embed'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
@@ -117,6 +118,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
/>
)}
+
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index 1272e98909..6b0f71064d 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -9,6 +9,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useTheme} from '#/alf'
+import {BotBadge} from '#/components/BotBadge'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -128,6 +129,7 @@ function AutocompleteProfileCard({
/>
)}
+
)}
+
)
@@ -1063,6 +1065,7 @@ function ExpandedAuthorCard({author}: {author: Author}) {
/>
)}
+
{
/>
)}
+
+
+
)}
+ {profile && }