diff --git a/assets/icons/bot_filled.svg b/assets/icons/bot_filled.svg new file mode 100644 index 0000000000..207d13295b --- /dev/null +++ b/assets/icons/bot_filled.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bot_stroke.svg b/assets/icons/bot_stroke.svg new file mode 100644 index 0000000000..707d695595 --- /dev/null +++ b/assets/icons/bot_stroke.svg @@ -0,0 +1 @@ + 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/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 1f9c0ed649..9c10827a8b 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -292,6 +292,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/accessibility", server.WebGeneric) e.GET("/settings/appearance", server.WebGeneric) e.GET("/settings/account", server.WebGeneric) + e.GET("/settings/automation-label", server.WebGeneric) e.GET("/settings/privacy-and-security", server.WebGeneric) e.GET("/settings/privacy-and-security/activity", server.WebGeneric) e.GET("/settings/content-and-media", server.WebGeneric) 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..4a43bf283c 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -16,9 +16,8 @@ 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' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {useActorStatus} from '#/features/liveNow' export function AccountList({ @@ -116,7 +115,6 @@ function AccountItem({ }) { const t = useTheme() const {_} = useLingui() - const verification = useSimpleVerificationState({profile}) const {isActive: live} = useActorStatus(profile) const onPress = useCallback(() => { @@ -164,13 +162,12 @@ function AccountItem({ profile?.displayName || profile?.handle || account.handle, )} - {verification.showBadge && ( - - - + {profile && ( + )} + + + + + + + {description} + + + + + {isSelf ? ( + + ) : null} + + + + ) +} diff --git a/src/components/BotBadge.tsx b/src/components/BotBadge.tsx new file mode 100644 index 0000000000..83da49bc4e --- /dev/null +++ b/src/components/BotBadge.tsx @@ -0,0 +1,92 @@ +import {View} from 'react-native' +import {type ComAtprotoLabelDefs} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {BotAccountAlert} from '#/components/BotAccountAlert' +import {Button} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {Bot_Filled as RobotIcon} from '#/components/icons/Bot' +import {useAnalytics} from '#/analytics' +import type * as bsky from '#/types/bsky' + +export function isBotAccount(profile: { + did: string + labels?: ComAtprotoLabelDefs.Label[] +}): boolean { + return ( + profile.labels?.some(l => l.val === 'bot' && l.src === profile.did) ?? false + ) +} + +export function BotBadge({ + profile, + alwaysShow = false, + width, +}: { + profile: bsky.profile.AnyProfileView + alwaysShow?: boolean + width: number +}) { + const t = useTheme() + + if (!isBotAccount(profile) && !alwaysShow) { + return null + } + + return ( + + + + ) +} + +export function BotBadgeButton({ + profile, + width, +}: { + profile: bsky.profile.AnyProfileView + width: number +}) { + const t = useTheme() + const ax = useAnalytics() + const {t: l} = useLingui() + const control = useDialogControl() + + if (!isBotAccount(profile)) { + return null + } + + return ( + <> + + + + ) +} diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index 3ba2682239..178f8c0baa 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -17,9 +17,8 @@ import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, tokens, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useDialogContext} from '#/components/Dialog' +import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' @@ -111,7 +110,6 @@ function RecentChatItem({ profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), ) - const verification = useSimpleVerificationState({profile}) if (isBlockedOrBlocking(profile) || isMuted(profile)) { return null @@ -141,14 +139,7 @@ function RecentChatItem({ numberOfLines={1}> {name} - {verification.showBadge && ( - - - - )} + ) diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx new file mode 100644 index 0000000000..dd0cedfe8f --- /dev/null +++ b/src/components/ProfileBadges.tsx @@ -0,0 +1,76 @@ +import {View} from 'react-native' + +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {atoms as a, type ViewStyleProp} from '#/alf' +import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' +import type * as bsky from '#/types/bsky' + +export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +const verificationIconSizes: Record = { + xs: 10, + sm: 12, + md: 14, + lg: 18, + xl: 22, +} as const + +const botIconSizes: Record = { + xs: 11, + sm: 13, + md: 15, + lg: 19, + xl: 23, +} as const + +export function ProfileBadges({ + profile, + interactive = false, + size, + style, +}: ViewStyleProp & { + profile: bsky.profile.AnyProfileView + interactive?: boolean + size: Size +}) { + const shadowed = useProfileShadow(profile) + const verification = useSimpleVerificationState({profile}) + + // if nothing to show, don't render the container at all + if (!verification.showBadge && !isBotAccount(shadowed)) return null + + const isOnTheSmallSide = size === 'xs' || size === 'sm' + + return ( + + {interactive ? ( + <> + + + + ) : ( + <> + {verification.showBadge && ( + + )} + + + )} + + ) +} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 0c8f26f200..ee9e657028 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -41,10 +41,9 @@ import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {Link as InternalLink, type LinkProps} from '#/components/Link' import * as Pills from '#/components/Pills' +import {ProfileBadges} from '#/components/ProfileBadges' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {type Metrics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' import type * as bsky from '#/types/bsky' @@ -242,7 +241,6 @@ function InlineNameAndHandle({ moderationOpts: ModerationOpts }) { const t = useTheme() - const verification = useSimpleVerificationState({profile}) const moderation = moderateProfile(profile, moderationOpts) const name = sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), @@ -262,19 +260,15 @@ function InlineNameAndHandle({ numberOfLines={1}> {forceLTR(name)} - {verification.showBadge && ( - - - - )} + {name} - {verification.showBadge && ( - - - - )} + ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 8586607e52..6ccdf89ae4 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -36,10 +36,9 @@ import {InlineLinkText, Link} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Pills from '#/components/Pills' import {Portal} from '#/components/Portal' +import {ProfileBadges} from '#/components/ProfileBadges' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {IS_WEB_TOUCH_DEVICE} from '#/env' import {useActorStatus} from '#/features/liveNow' import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog' @@ -459,7 +458,6 @@ function Inner({ [currentAccount, profile], ) const isLabeler = profile.associated?.labeler - const verification = useSimpleVerificationState({profile}) return ( @@ -527,20 +525,16 @@ function Inner({ moderation.ui('displayName'), )} - {verification.showBadge && ( - - - - )} + diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index afdeee545e..3f7694a342 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -20,9 +20,8 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/ import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {PostAlerts} from '#/components/moderation/PostAlerts' +import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {IS_WEB} from '#/env' const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE @@ -112,9 +111,6 @@ function HeaderReady({ const {_} = useLingui() const t = useTheme() const convoState = useConvo() - const verification = useSimpleVerificationState({ - profile, - }) const isDeletedAccount = profile?.handle === 'missing.invalid' const displayName = isDeletedAccount @@ -161,14 +157,7 @@ function HeaderReady({ numberOfLines={1}> {displayName} - {verification.showBadge && ( - - - - )} + {!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/components/verification/VerificationCheckButton.tsx b/src/components/verification/VerificationCheckButton.tsx index 40b12c2064..ceb9d24f2b 100644 --- a/src/components/verification/VerificationCheckButton.tsx +++ b/src/components/verification/VerificationCheckButton.tsx @@ -3,7 +3,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {type Shadow} from '#/state/cache/types' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {useFullVerificationState} from '#/components/verification' @@ -51,43 +51,36 @@ export function shouldShowVerificationCheckButton( export function VerificationCheckButton({ profile, - size, + width, }: { profile: Shadow - size: 'lg' | 'md' | 'sm' + width: number }) { const state = useFullVerificationState({ profile, }) if (shouldShowVerificationCheckButton(state)) { - return + return } return null } -export function Badge({ +function Badge({ profile, verificationState: state, - size, + width, }: { profile: Shadow verificationState: FullVerificationState - size: 'lg' | 'md' | 'sm' + width: number }) { const t = useTheme() const ax = useAnalytics() const {_} = useLingui() const verificationsDialogControl = useDialogControl() const verifierDialogControl = useDialogControl() - const {gtPhone} = useBreakpoints() - let dimensions = 12 - if (size === 'lg') { - dimensions = gtPhone ? 20 : 18 - } else if (size === 'md') { - dimensions = 14 - } const verifiedByHidden = !state.profile.showBadge && state.profile.isViewer @@ -116,8 +109,8 @@ export function Badge({ a.align_end, a.transition_transform, { - width: dimensions, - height: dimensions, + width: width, + height: width, transform: [ { scale: hovered ? 1.1 : 1, @@ -126,7 +119,7 @@ export function Badge({ }, ]}> ({ 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..82c5b76af6 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -41,9 +41,8 @@ import {Link} from '#/components/Link' import {useMenuControl} from '#/components/Menu' import {PostAlerts} from '#/components/moderation/PostAlerts' import {createPortalGroup} from '#/components/Portal' +import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' @@ -112,9 +111,6 @@ function ChatListItemReady({ const playHaptic = useHaptics() const queryClient = useQueryClient() const isUnread = convo.unreadCount > 0 - const verification = useSimpleVerificationState({ - profile, - }) const blockInfo = useMemo(() => { const modui = moderation.ui('profileView') @@ -414,14 +410,11 @@ function ChatListItemReady({ {displayName} - {verification.showBadge && ( - - - - )} + {lastMessageSentAt && ( diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 9133a313cd..f17a832461 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -47,12 +47,12 @@ import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {TranslatedPost} from '#/components/Post/Translated' import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {useFormatPostStatCount} from '#/components/PostControls/util' +import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import * as Skele from '#/components/Skeleton' import {Text} from '#/components/Typography' -import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {WhoCanReply} from '#/components/WhoCanReply' import {useAnalytics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' @@ -362,7 +362,11 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ - + - + diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx index 5b463ae75d..f49fadf0d5 100644 --- a/src/screens/Search/components/SearchHistory.tsx +++ b/src/screens/Search/components/SearchHistory.tsx @@ -14,9 +14,8 @@ import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' +import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' @@ -139,7 +138,6 @@ function RecentProfileItem({ profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), ) - const verification = useSimpleVerificationState({profile}) return ( @@ -165,14 +163,7 @@ function RecentProfileItem({ {name} - {verification.showBadge && ( - - - - )} +