Add self-labeling controls (#1141)

* Add self-label modal

* Use the shield-exclamation icon consistently on post moderation

* Wire up self-labeling

* Bump @atproto/api@0.6.0

* Bump @atproto/dev-env@^0.2.3

* Add e2e test for self-labeling

* Fix types
zio/stable
Paul Frazee 2023-08-09 17:34:16 -07:00 committed by GitHub
parent 48813a96d6
commit 03d152675e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 443 additions and 124 deletions

View File

@ -0,0 +1,34 @@
/* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util'
describe('Self-labeling', () => {
let service: string
beforeAll(async () => {
service = await createServer('?users')
await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
})
})
it('Login', async () => {
await login(service, 'alice', 'hunter2')
await element(by.id('homeScreenFeedTabs-Following')).tap()
})
it('Post an image with the porn label', async () => {
await element(by.id('composeFAB')).tap()
await element(by.id('composerTextInput')).typeText('Post with an image')
await element(by.id('openGalleryBtn')).tap()
await sleep(1e3)
await element(by.id('labelsBtn')).tap()
await element(by.id('pornLabelBtn')).tap()
await element(by.id('confirmBtn')).tap()
await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible()
const posts = by.id('feedItem-by-alice.test')
await expect(
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
).toExist()
})
})

View File

@ -24,7 +24,7 @@
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
},
"dependencies": {
"@atproto/api": "^0.5.4",
"@atproto/api": "^0.6.0",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@expo/html-elements": "^0.4.2",
@ -146,7 +146,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.2.2",
"@atproto/dev-env": "^0.2.3",
"@atproto/pds": "^0.2.0-beta.2",
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",

View File

@ -4,6 +4,7 @@ import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyRichtextFacet,
ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob,
RichText,
} from '@atproto/api'
@ -77,6 +78,7 @@ interface PostOpts {
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
knownHandles?: Set<string>
onStateChange?: (state: string) => void
langs?: string[]
@ -234,6 +236,15 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
}
}
// set labels
let labels: ComAtprotoLabelDefs.SelfLabels | undefined
if (opts.labels?.length) {
labels = {
$type: 'com.atproto.label.defs#selfLabels',
values: opts.labels.map(val => ({val})),
}
}
// add top 3 languages from user preferences if langs is provided
let langs = opts.langs
if (opts.langs) {
@ -248,6 +259,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
reply,
embed,
langs,
labels,
})
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)

View File

@ -957,3 +957,41 @@ export function SatelliteDishIcon({
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function ShieldExclamation({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
let color = 'currentColor'
if (
style &&
typeof style === 'object' &&
'color' in style &&
typeof style.color === 'string'
) {
color = style.color
}
return (
<Svg
width={size}
height={size}
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1.5}
stroke={color}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
/>
</Svg>
)
}

View File

@ -29,12 +29,7 @@ export function describeModerationCause(
}
}
if (cause.type === 'muted') {
if (cause.source.type === 'user') {
return {
name: context === 'account' ? 'Muted User' : 'Post by muted user',
description: 'You have muted this user',
}
} else {
if (cause.source.type === 'list') {
return {
name:
context === 'account'
@ -42,6 +37,11 @@ export function describeModerationCause(
: `Post by muted user ("${cause.source.list.name}")`,
description: 'You have muted this user',
}
} else {
return {
name: context === 'account' ? 'Muted User' : 'Post by muted user',
description: 'You have muted this user',
}
}
}
return cause.labelDef.strings[context].en

View File

@ -418,13 +418,7 @@ export class PreferencesModel {
return {
userDid: this.rootStore.session.currentSession?.did || '',
adultContentEnabled: this.adultContentEnabled,
labelerSettings: [
{
labeler: {
did: '',
displayName: 'Bluesky Social',
},
settings: {
labels: {
// TEMP translate old settings until this UI can be migrated -prf
porn: tempfixLabelPref(this.contentLabels.nsfw),
sexual: tempfixLabelPref(this.contentLabels.suggestive),
@ -446,6 +440,13 @@ export class PreferencesModel {
impersonation: tempfixLabelPref(this.contentLabels.impersonation),
scam: 'warn',
},
labelers: [
{
labeler: {
did: '',
displayName: 'Bluesky Social',
},
labels: {},
},
],
}

View File

@ -100,6 +100,12 @@ export interface RepostModal {
isReposted: boolean
}
export interface SelfLabelModal {
name: 'self-label'
labels: string[]
onChange: (labels: string[]) => void
}
export interface ChangeHandleModal {
name: 'change-handle'
onChanged: () => void
@ -164,6 +170,7 @@ export type Modal =
| EditImageModal
| ServerInputModal
| RepostModal
| SelfLabelModal
// Bluesky access
| WaitlistModal

View File

@ -41,6 +41,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection'
import {GalleryModel} from 'state/models/media/gallery'
import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {LabelsBtn} from './labels/LabelsBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn'
type Props = ComposerOpts & {
@ -67,6 +68,7 @@ export const ComposePost = observer(function ComposePost({
initQuote,
)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [labels, setLabels] = useState<string[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(store), [store])
@ -145,8 +147,7 @@ export const ComposePost = observer(function ComposePost({
[gallery, track],
)
const onPressPublish = useCallback(
async (rt: RichText) => {
const onPressPublish = async (rt: RichText) => {
if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
@ -168,8 +169,9 @@ export const ComposePost = observer(function ComposePost({
rawText: rt.text,
replyTo: replyTo?.uri,
images: gallery.images,
quote: quote,
extLink: extLink,
quote,
extLink,
labels,
onStateChange: setProcessingState,
knownHandles: autocompleteView.knownHandles,
langs: store.preferences.postLanguages,
@ -197,23 +199,7 @@ export const ComposePost = observer(function ComposePost({
onPost?.()
onClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
},
[
isProcessing,
setError,
setIsProcessing,
replyTo,
autocompleteView.knownHandles,
extLink,
onClose,
onPost,
quote,
setExtLink,
store,
track,
gallery,
],
)
}
const canPost = useMemo(
() =>
@ -246,6 +232,7 @@ export const ComposePost = observer(function ComposePost({
<Text style={[pal.link, s.f18]}>Cancel</Text>
</TouchableOpacity>
<View style={s.flex1} />
<LabelsBtn labels={labels} onChange={setLabels} />
{isProcessing ? (
<View style={styles.postBtn}>
<ActivityIndicator />
@ -407,7 +394,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
paddingTop: isDesktopWeb ? 10 : undefined,
paddingBottom: 10,
paddingBottom: isDesktopWeb ? 10 : 4,
paddingHorizontal: 20,
height: 55,
},

View File

@ -0,0 +1,53 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Button} from 'view/com/util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {ShieldExclamation} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
export const LabelsBtn = observer(function LabelsBtn({
labels,
onChange,
}: {
labels: string[]
onChange: (v: string[]) => void
}) {
const pal = usePalette('default')
const store = useStores()
return (
<Button
type="default-light"
testID="labelsBtn"
style={styles.button}
accessibilityLabel="Content warnings"
accessibilityHint=""
onPress={() =>
store.shell.openModal({name: 'self-label', labels, onChange})
}>
<ShieldExclamation style={pal.link} size={26} />
{labels.length > 0 ? (
<FontAwesomeIcon
icon="check"
size={16}
style={pal.link as FontAwesomeIconStyle}
/>
) : null}
</Button>
)
})
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
marginRight: 4,
},
label: {
maxWidth: 100,
},
})

View File

@ -15,6 +15,7 @@ import * as ProfilePreviewModal from './ProfilePreview'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './report/ReportPost'
import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel'
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as AltImageModal from './AltImage'
@ -104,6 +105,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'repost') {
snapPoints = RepostModal.snapPoints
element = <RepostModal.Component {...activeModal} />
} else if (activeModal?.name === 'self-label') {
snapPoints = SelfLabelModal.snapPoints
element = <SelfLabelModal.Component {...activeModal} />
} else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints
element = <AltImageModal.Component {...activeModal} />

View File

@ -16,6 +16,7 @@ import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel'
import * as CropImageModal from './crop-image/CropImage.web'
import * as AltTextImageModal from './AltImage'
import * as EditImageModal from './EditImage'
@ -89,6 +90,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <DeleteAccountModal.Component />
} else if (modal.name === 'repost') {
element = <RepostModal.Component {...modal} />
} else if (modal.name === 'self-label') {
element = <SelfLabelModal.Component {...modal} />
} else if (modal.name === 'change-handle') {
element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'waitlist') {

View File

@ -35,10 +35,7 @@ export function Component({
name = 'Account Blocks You'
description = 'This user has blocked you. You cannot view their content.'
} else if (moderation.cause.type === 'muted') {
if (moderation.cause.source.type === 'user') {
name = 'Account Muted'
description = 'You have muted this user.'
} else {
if (moderation.cause.source.type === 'list') {
const list = moderation.cause.source.list
name = <>Account Muted by List</>
description = (
@ -53,6 +50,9 @@ export function Component({
list which you have muted.
</>
)
} else {
name = 'Account Muted'
description = 'You have muted this user.'
}
} else {
name = moderation.cause.labelDef.strings[context].en.name

View File

@ -0,0 +1,167 @@
import React, {useState} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Text} from '../util/text/Text'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
import {SelectableBtn} from '../util/forms/SelectableBtn'
import {ScrollView} from 'view/com/modals/util'
const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
export const snapPoints = ['50%']
export const Component = observer(function Component({
labels,
onChange,
}: {
labels: string[]
onChange: (labels: string[]) => void
}) {
const pal = usePalette('default')
const store = useStores()
const [selected, setSelected] = useState(labels)
const toggleAdultContent = (label: string) => {
const hadLabel = selected.includes(label)
const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l))
const final = !hadLabel ? stripped.concat([label]) : stripped
setSelected(final)
onChange(final)
}
return (
<View testID="selfLabelModal" style={[pal.view, styles.container]}>
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
Add a content warning
</Text>
</View>
<ScrollView>
<View style={[styles.section, pal.border, {borderBottomWidth: 1}]}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 8,
}}>
<Text type="title" style={pal.text}>
Adult Content
</Text>
<Text type="lg" style={pal.text}>
{selected.includes('sexual') ? (
<>😏</>
) : selected.includes('nudity') ? (
<>🫣</>
) : selected.includes('porn') ? (
<>🥵</>
) : (
<></>
)}
</Text>
</View>
<View style={s.flexRow}>
<SelectableBtn
testID="sexualLabelBtn"
selected={selected.includes('sexual')}
left
label="Suggestive"
onSelect={() => toggleAdultContent('sexual')}
accessibilityHint=""
style={s.flex1}
/>
<SelectableBtn
testID="nudityLabelBtn"
selected={selected.includes('nudity')}
label="Nudity"
onSelect={() => toggleAdultContent('nudity')}
accessibilityHint=""
style={s.flex1}
/>
<SelectableBtn
testID="pornLabelBtn"
selected={selected.includes('porn')}
label="Porn"
right
onSelect={() => toggleAdultContent('porn')}
accessibilityHint=""
style={s.flex1}
/>
</View>
<Text style={[pal.text, styles.adultExplainer]}>
{selected.includes('sexual') ? (
<>Pictures meant for adults.</>
) : selected.includes('nudity') ? (
<>Artistic or non-erotic nudity.</>
) : selected.includes('porn') ? (
<>Sexual activity or erotic nudity.</>
) : (
<>If none are selected, suitable for all ages.</>
)}
</Text>
</View>
</ScrollView>
<View style={[styles.btnContainer, pal.borderDark]}>
<TouchableOpacity
testID="confirmBtn"
onPress={() => {
store.shell.closeModal()
}}
style={styles.btn}
accessibilityRole="button"
accessibilityLabel="Confirm"
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>Done</Text>
</TouchableOpacity>
</View>
</View>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 40,
},
titleSection: {
paddingTop: isDesktopWeb ? 0 : 4,
paddingBottom: isDesktopWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
description: {
textAlign: 'center',
paddingHorizontal: 32,
},
section: {
borderTopWidth: 1,
paddingVertical: 20,
paddingHorizontal: isDesktopWeb ? 0 : 20,
},
adultExplainer: {
paddingLeft: 5,
paddingTop: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
paddingHorizontal: 20,
},
})

View File

@ -271,6 +271,7 @@ export const FeedItem = observer(function ({
</View>
)}
<ContentHider
testID="contentHider-post"
moderation={item.moderation.content}
ignoreMute
style={styles.contentHider}
@ -291,6 +292,7 @@ export const FeedItem = observer(function ({
) : undefined}
{item.post.embed ? (
<ContentHider
testID="contentHider-embed"
moderation={item.moderation.embed}
style={styles.embed}>
<PostEmbeds

View File

@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
interface SelectableBtnProps {
testID?: string
selected: boolean
label: string
left?: boolean
@ -15,6 +16,7 @@ interface SelectableBtnProps {
}
export function SelectableBtn({
testID,
selected,
label,
left,
@ -25,12 +27,15 @@ export function SelectableBtn({
}: SelectableBtnProps) {
const pal = usePalette('default')
const palPrimary = usePalette('inverted')
const needsWidthStyles = !style || !('width' in style || 'flex' in style)
return (
<Pressable
testID={testID}
style={[
styles.selectableBtn,
left && styles.selectableBtnLeft,
right && styles.selectableBtnRight,
styles.btn,
needsWidthStyles && styles.btnWidth,
left && styles.btnLeft,
right && styles.btnRight,
pal.border,
selected ? palPrimary.view : pal.view,
style,
@ -45,9 +50,7 @@ export function SelectableBtn({
}
const styles = StyleSheet.create({
selectableBtn: {
flex: isDesktopWeb ? undefined : 1,
width: isDesktopWeb ? 100 : undefined,
btn: {
flexDirection: 'row',
justifyContent: 'center',
borderWidth: 1,
@ -55,12 +58,16 @@ const styles = StyleSheet.create({
paddingHorizontal: 10,
paddingVertical: 10,
},
selectableBtnLeft: {
btnWidth: {
flex: isDesktopWeb ? undefined : 1,
width: isDesktopWeb ? 100 : undefined,
},
btnLeft: {
borderTopLeftRadius: 8,
borderBottomLeftRadius: 8,
borderLeftWidth: 1,
},
selectableBtnRight: {
btnRight: {
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
},

View File

@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {ModerationUI} from '@atproto/api'
import {Text} from '../text/Text'
import {InfoCircleIcon} from 'lib/icons'
import {ShieldExclamation} from 'lib/icons'
import {describeModerationCause} from 'lib/moderation'
import {useStores} from 'state/index'
import {isDesktopWeb} from 'platform/detection'
@ -58,7 +58,7 @@ export function ContentHider({
accessibilityRole="button"
accessibilityLabel="Learn more about this warning"
accessibilityHint="">
<InfoCircleIcon size={18} style={pal.text} />
<ShieldExclamation size={18} style={pal.text} />
</Pressable>
<Text type="lg" style={pal.text}>
{desc.name}

View File

@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {InfoCircleIcon} from 'lib/icons'
import {ShieldExclamation} from 'lib/icons'
import {describeModerationCause} from 'lib/moderation'
import {useStores} from 'state/index'
@ -41,7 +41,7 @@ export function PostAlerts({
accessibilityLabel="Learn more about this warning"
accessibilityHint=""
style={[styles.container, pal.viewLight, style]}>
<InfoCircleIcon style={pal.text} size={18} />
<ShieldExclamation style={pal.text} size={16} />
<Text type="lg" style={pal.text}>
{desc.name}
</Text>

View File

@ -6,7 +6,7 @@ import {Link} from '../Link'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {describeModerationCause} from 'lib/moderation'
import {InfoCircleIcon} from 'lib/icons'
import {ShieldExclamation} from 'lib/icons'
import {useStores} from 'state/index'
import {isDesktopWeb} from 'platform/detection'
@ -67,7 +67,7 @@ export function PostHider({
accessibilityRole="button"
accessibilityLabel="Learn more about this warning"
accessibilityHint="">
<InfoCircleIcon size={18} style={pal.text} />
<ShieldExclamation size={18} style={pal.text} />
</Pressable>
<Text type="lg" style={pal.text}>
{desc.name}

View File

@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {ProfileModeration} from '@atproto/api'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {InfoCircleIcon} from 'lib/icons'
import {ShieldExclamation} from 'lib/icons'
import {
describeModerationCause,
getProfileModerationCauses,
@ -44,7 +44,7 @@ export function ProfileHeaderAlerts({
accessibilityLabel="Learn more about this warning"
accessibilityHint=""
style={[styles.container, pal.viewLight, style]}>
<InfoCircleIcon style={pal.text} size={24} />
<ShieldExclamation style={pal.text} size={24} />
<Text type="lg" style={pal.text}>
{desc.name}
</Text>

View File

@ -72,6 +72,8 @@ import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSq
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
@ -162,6 +164,8 @@ export function setup() {
faShield,
faSignal,
faSliders,
faSquare,
faSquareCheck,
faSquarePlus,
faUser,
faUsers,

View File

@ -40,10 +40,10 @@
tlds "^1.234.0"
typed-emitter "^2.1.0"
"@atproto/api@^0.5.4":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.5.4.tgz#81a0dc36d3fcae085092434218740b68ee28f816"
integrity sha512-e2M5d+w6PMEzunVWbeX4yD9pMXaP6FakQYOfj4gUz9tL05k94Qv1dcX8IFUHdWEunHijLGU2fU2SNC4jgiuLBA==
"@atproto/api@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.0.tgz#c4eea08ee4d1be522928cd016d7de8061d86e573"
integrity sha512-GkWHoGZfNneHarAYkIPJD1GGgKiI7OwnCtKS+J4AmlVKYijGEzOYgg1fY6rluT6XPT5TlQZiHUWpMlpqAkQIkQ==
dependencies:
"@atproto/common-web" "*"
"@atproto/uri" "*"
@ -157,10 +157,10 @@
one-webcrypto "^1.0.3"
uint8arrays "3.0.0"
"@atproto/dev-env@^0.2.2":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.2.2.tgz#930cb63dc751b08fa0a1c69820e27587d7b38252"
integrity sha512-94KruFi2C52YHiSmvJKjPp3414KV9ev06Sla4BebJxm1z3/DKGhmPGDEHVke4+KPZOTR3vQ2x1WKuX87mtCp7w==
"@atproto/dev-env@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.2.3.tgz#17f7574a85f560dbd128dc3dd7620de14ff63483"
integrity sha512-7Glr0NVWftXF8kvmVouNWhXX9DzTWpKhytQZkfPaIWf6jVRATgRSfts1QOet1lTx33DczqKBJb9xc0qDt4MqWw==
dependencies:
"@atproto/api" "*"
"@atproto/bsky" "*"