Add tags and mute words (#2968)
* Add bare minimum hashtags support (#2804) * Add bare minimum hashtags support As atproto/api already parses hashtags, this is as simple as hooking it up like link segments. This is "bare minimum" because: - Opening hashtag "#foo" is actually just a search for "foo" right now to work around #2491. - There is no integration in the composer. This hasn't stopped people from using hashtags already, and can be added later. - This change itself only had to hook things up - thank you for having already put the hashtag parsing in place. * Remove workaround for hash search not working now that it's fixed * Add RichTextTag and TagMenu * Sketch * Remove hackfix * Some cleanup * Sketch web * Mobile design * Mobile handling of tags search * Web only * Fix navigation woes * Use new callback * Hook it up * Integrate muted tags * Fix dropdown styles * Type error * Use close callback * Fix styles * Cleanup, install latest sdk * Quick muted words screen * Targets * Dir structure * Icons, list view * Move to dialog * Add removal confirmation * Swap copy * Improve checkboxees * Update matching, add tests * Moderate embeds * Create global dialogs concept again to prevent flashing * Add access from moderation screen * Highlight tags on native * Add web highlighting * Add close to web modal * Adjust close color * Rename toggles and adjust logic * Icon update * Load states * Improve regex * Improve regex * Improve regex * Revert link test * Hyphenated words * Improve matching * Enhance * Some tweaks * Muted words modal changes * Handle invalid handles, handle long tags * Remove main regex * Better test * Space/punct check drop to includes * Lowercase post text before comparison * Add better real world test case --------- Co-authored-by: Kisaragi Hiu <mail@kisaragi-hiu.com>
This commit is contained in:
		
							parent
							
								
									c8582924e2
								
							
						
					
					
						commit
						58aaad704a
					
				
					 49 changed files with 1983 additions and 39 deletions
				
			
		
							
								
								
									
										1
									
								
								assets/icons/checkThick_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/icons/checkThick_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z" clip-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 300 B  | 
							
								
								
									
										1
									
								
								assets/icons/clipboard_stroke2_corner2_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/icons/clipboard_stroke2_corner2_rounded.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z" clip-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 422 B  | 
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z" clip-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 269 B  | 
							
								
								
									
										1
									
								
								assets/icons/mute_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/icons/mute_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z" clip-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 465 B  | 
							
								
								
									
										1
									
								
								assets/icons/pageText_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/icons/pageText_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z" clip-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 356 B  | 
| 
						 | 
				
			
			@ -205,6 +205,11 @@
 | 
			
		|||
    [data-tooltip]:hover::before {
 | 
			
		||||
      display:block;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* NativeDropdown component */
 | 
			
		||||
    .nativeDropdown-item:focus {
 | 
			
		||||
      outline: none;
 | 
			
		||||
    }
 | 
			
		||||
  </style>
 | 
			
		||||
  {% include "scripts.html" %}
 | 
			
		||||
  <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,7 +43,7 @@
 | 
			
		|||
    "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@atproto/api": "^0.9.5",
 | 
			
		||||
    "@atproto/api": "^0.10.0",
 | 
			
		||||
    "@bam.tech/react-native-image-resizer": "^3.0.4",
 | 
			
		||||
    "@braintree/sanitize-url": "^6.0.2",
 | 
			
		||||
    "@emoji-mart/react": "^1.1.1",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -497,7 +497,8 @@ const LINKING = {
 | 
			
		|||
        },
 | 
			
		||||
      ])
 | 
			
		||||
    } else {
 | 
			
		||||
      return buildStateObject('Flat', name, params)
 | 
			
		||||
      const res = buildStateObject('Flat', name, params)
 | 
			
		||||
      return res
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import {web, native} from '#/alf/util/platform'
 | 
			
		||||
import * as tokens from '#/alf/tokens'
 | 
			
		||||
 | 
			
		||||
export const atoms = {
 | 
			
		||||
| 
						 | 
				
			
			@ -113,6 +114,9 @@ export const atoms = {
 | 
			
		|||
  flex_wrap: {
 | 
			
		||||
    flexWrap: 'wrap',
 | 
			
		||||
  },
 | 
			
		||||
  flex_0: {
 | 
			
		||||
    flex: web('0 0 auto') || (native(0) as number),
 | 
			
		||||
  },
 | 
			
		||||
  flex_1: {
 | 
			
		||||
    flex: 1,
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -188,7 +188,7 @@ export function Close() {
 | 
			
		|||
      <Button
 | 
			
		||||
        size="small"
 | 
			
		||||
        variant="ghost"
 | 
			
		||||
        color="primary"
 | 
			
		||||
        color="secondary"
 | 
			
		||||
        shape="round"
 | 
			
		||||
        onPress={close}
 | 
			
		||||
        label={_(msg`Close active dialog`)}>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,16 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a, TextStyleProp, flatten} from '#/alf'
 | 
			
		||||
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
 | 
			
		||||
import {InlineLink} from '#/components/Link'
 | 
			
		||||
import {Text, TextProps} from '#/components/Typography'
 | 
			
		||||
import {toShortUrl} from 'lib/strings/url-helpers'
 | 
			
		||||
import {getAgent} from '#/state/session'
 | 
			
		||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
 | 
			
		||||
import {isNative} from '#/platform/detection'
 | 
			
		||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
 | 
			
		||||
 | 
			
		||||
const WORD_WRAP = {wordWrap: 1}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -17,6 +22,8 @@ export function RichText({
 | 
			
		|||
  disableLinks,
 | 
			
		||||
  resolveFacets = false,
 | 
			
		||||
  selectable,
 | 
			
		||||
  enableTags = false,
 | 
			
		||||
  authorHandle,
 | 
			
		||||
}: TextStyleProp &
 | 
			
		||||
  Pick<TextProps, 'selectable'> & {
 | 
			
		||||
    value: RichTextAPI | string
 | 
			
		||||
| 
						 | 
				
			
			@ -24,6 +31,8 @@ export function RichText({
 | 
			
		|||
    numberOfLines?: number
 | 
			
		||||
    disableLinks?: boolean
 | 
			
		||||
    resolveFacets?: boolean
 | 
			
		||||
    enableTags?: boolean
 | 
			
		||||
    authorHandle?: string
 | 
			
		||||
  }) {
 | 
			
		||||
  const detected = React.useRef(false)
 | 
			
		||||
  const [richText, setRichText] = React.useState<RichTextAPI>(() =>
 | 
			
		||||
| 
						 | 
				
			
			@ -85,6 +94,7 @@ export function RichText({
 | 
			
		|||
  for (const segment of richText.segments()) {
 | 
			
		||||
    const link = segment.link
 | 
			
		||||
    const mention = segment.mention
 | 
			
		||||
    const tag = segment.tag
 | 
			
		||||
    if (
 | 
			
		||||
      mention &&
 | 
			
		||||
      AppBskyRichtextFacet.validateMention(mention).success &&
 | 
			
		||||
| 
						 | 
				
			
			@ -118,6 +128,21 @@ export function RichText({
 | 
			
		|||
          </InlineLink>,
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    } else if (
 | 
			
		||||
      !disableLinks &&
 | 
			
		||||
      enableTags &&
 | 
			
		||||
      tag &&
 | 
			
		||||
      AppBskyRichtextFacet.validateTag(tag).success
 | 
			
		||||
    ) {
 | 
			
		||||
      els.push(
 | 
			
		||||
        <RichTextTag
 | 
			
		||||
          key={key}
 | 
			
		||||
          text={segment.text}
 | 
			
		||||
          style={styles}
 | 
			
		||||
          selectable={selectable}
 | 
			
		||||
          authorHandle={authorHandle}
 | 
			
		||||
        />,
 | 
			
		||||
      )
 | 
			
		||||
    } else {
 | 
			
		||||
      els.push(segment.text)
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -136,3 +161,79 @@ export function RichText({
 | 
			
		|||
    </Text>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function RichTextTag({
 | 
			
		||||
  text: tag,
 | 
			
		||||
  style,
 | 
			
		||||
  selectable,
 | 
			
		||||
  authorHandle,
 | 
			
		||||
}: {
 | 
			
		||||
  text: string
 | 
			
		||||
  selectable?: boolean
 | 
			
		||||
  authorHandle?: string
 | 
			
		||||
} & TextStyleProp) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const control = useTagMenuControl()
 | 
			
		||||
  const {
 | 
			
		||||
    state: hovered,
 | 
			
		||||
    onIn: onHoverIn,
 | 
			
		||||
    onOut: onHoverOut,
 | 
			
		||||
  } = useInteractionState()
 | 
			
		||||
  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
 | 
			
		||||
  const {
 | 
			
		||||
    state: pressed,
 | 
			
		||||
    onIn: onPressIn,
 | 
			
		||||
    onOut: onPressOut,
 | 
			
		||||
  } = useInteractionState()
 | 
			
		||||
 | 
			
		||||
  const open = React.useCallback(() => {
 | 
			
		||||
    control.open()
 | 
			
		||||
  }, [control])
 | 
			
		||||
 | 
			
		||||
  /*
 | 
			
		||||
   * N.B. On web, this is wrapped in another pressable comopnent with a11y
 | 
			
		||||
   * labels, etc. That's why only some of these props are applied here.
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <React.Fragment>
 | 
			
		||||
      <TagMenu control={control} tag={tag} authorHandle={authorHandle}>
 | 
			
		||||
        <Text
 | 
			
		||||
          selectable={selectable}
 | 
			
		||||
          {...native({
 | 
			
		||||
            accessibilityLabel: _(msg`Hashtag: ${tag}`),
 | 
			
		||||
            accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
 | 
			
		||||
            accessibilityRole: isNative ? 'button' : undefined,
 | 
			
		||||
            onPress: open,
 | 
			
		||||
            onPressIn: onPressIn,
 | 
			
		||||
            onPressOut: onPressOut,
 | 
			
		||||
          })}
 | 
			
		||||
          {...web({
 | 
			
		||||
            onMouseEnter: onHoverIn,
 | 
			
		||||
            onMouseLeave: onHoverOut,
 | 
			
		||||
          })}
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          onFocus={onFocus}
 | 
			
		||||
          onBlur={onBlur}
 | 
			
		||||
          style={[
 | 
			
		||||
            style,
 | 
			
		||||
            {
 | 
			
		||||
              pointerEvents: 'auto',
 | 
			
		||||
              color: t.palette.primary_500,
 | 
			
		||||
            },
 | 
			
		||||
            web({
 | 
			
		||||
              cursor: 'pointer',
 | 
			
		||||
            }),
 | 
			
		||||
            (hovered || focused || pressed) && {
 | 
			
		||||
              ...web({outline: 0}),
 | 
			
		||||
              textDecorationLine: 'underline',
 | 
			
		||||
              textDecorationColor: t.palette.primary_500,
 | 
			
		||||
            },
 | 
			
		||||
          ]}>
 | 
			
		||||
          {tag}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </TagMenu>
 | 
			
		||||
    </React.Fragment>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										279
									
								
								src/components/TagMenu/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								src/components/TagMenu/index.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,279 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useNavigation} from '@react-navigation/native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a, native, useTheme} from '#/alf'
 | 
			
		||||
import * as Dialog from '#/components/Dialog'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {Button, ButtonText} from '#/components/Button'
 | 
			
		||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
 | 
			
		||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
 | 
			
		||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
 | 
			
		||||
import {Divider} from '#/components/Divider'
 | 
			
		||||
import {Link} from '#/components/Link'
 | 
			
		||||
import {makeSearchLink} from '#/lib/routes/links'
 | 
			
		||||
import {NavigationProp} from '#/lib/routes/types'
 | 
			
		||||
import {
 | 
			
		||||
  usePreferencesQuery,
 | 
			
		||||
  useUpsertMutedWordsMutation,
 | 
			
		||||
  useRemoveMutedWordMutation,
 | 
			
		||||
} from '#/state/queries/preferences'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import {isInvalidHandle} from '#/lib/strings/handles'
 | 
			
		||||
 | 
			
		||||
export function useTagMenuControl() {
 | 
			
		||||
  return Dialog.useDialogControl()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TagMenu({
 | 
			
		||||
  children,
 | 
			
		||||
  control,
 | 
			
		||||
  tag,
 | 
			
		||||
  authorHandle,
 | 
			
		||||
}: React.PropsWithChildren<{
 | 
			
		||||
  control: Dialog.DialogOuterProps['control']
 | 
			
		||||
  tag: string
 | 
			
		||||
  authorHandle?: string
 | 
			
		||||
}>) {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const navigation = useNavigation<NavigationProp>()
 | 
			
		||||
  const {isLoading: isPreferencesLoading, data: preferences} =
 | 
			
		||||
    usePreferencesQuery()
 | 
			
		||||
  const {
 | 
			
		||||
    mutateAsync: upsertMutedWord,
 | 
			
		||||
    variables: optimisticUpsert,
 | 
			
		||||
    reset: resetUpsert,
 | 
			
		||||
  } = useUpsertMutedWordsMutation()
 | 
			
		||||
  const {
 | 
			
		||||
    mutateAsync: removeMutedWord,
 | 
			
		||||
    variables: optimisticRemove,
 | 
			
		||||
    reset: resetRemove,
 | 
			
		||||
  } = useRemoveMutedWordMutation()
 | 
			
		||||
 | 
			
		||||
  const sanitizedTag = tag.replace(/^#/, '')
 | 
			
		||||
  const isMuted = Boolean(
 | 
			
		||||
    (preferences?.mutedWords?.find(
 | 
			
		||||
      m => m.value === sanitizedTag && m.targets.includes('tag'),
 | 
			
		||||
    ) ??
 | 
			
		||||
      optimisticUpsert?.find(
 | 
			
		||||
        m => m.value === sanitizedTag && m.targets.includes('tag'),
 | 
			
		||||
      )) &&
 | 
			
		||||
      !(optimisticRemove?.value === sanitizedTag),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {children}
 | 
			
		||||
 | 
			
		||||
      <Dialog.Outer control={control}>
 | 
			
		||||
        <Dialog.Handle />
 | 
			
		||||
 | 
			
		||||
        <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
 | 
			
		||||
          {isPreferencesLoading ? (
 | 
			
		||||
            <View style={[a.w_full, a.align_center]}>
 | 
			
		||||
              <Loader size="lg" />
 | 
			
		||||
            </View>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <>
 | 
			
		||||
              <View
 | 
			
		||||
                style={[
 | 
			
		||||
                  a.rounded_md,
 | 
			
		||||
                  a.border,
 | 
			
		||||
                  a.mb_md,
 | 
			
		||||
                  t.atoms.border_contrast_low,
 | 
			
		||||
                  t.atoms.bg_contrast_25,
 | 
			
		||||
                ]}>
 | 
			
		||||
                <Link
 | 
			
		||||
                  label={_(msg`Search for all posts with tag ${tag}`)}
 | 
			
		||||
                  to={makeSearchLink({query: tag})}
 | 
			
		||||
                  onPress={e => {
 | 
			
		||||
                    e.preventDefault()
 | 
			
		||||
 | 
			
		||||
                    control.close(() => {
 | 
			
		||||
                      // @ts-ignore :ron_swanson: "I know more than you"
 | 
			
		||||
                      navigation.navigate('SearchTab', {
 | 
			
		||||
                        screen: 'Search',
 | 
			
		||||
                        params: {
 | 
			
		||||
                          q: tag,
 | 
			
		||||
                        },
 | 
			
		||||
                      })
 | 
			
		||||
                    })
 | 
			
		||||
 | 
			
		||||
                    return false
 | 
			
		||||
                  }}>
 | 
			
		||||
                  <View
 | 
			
		||||
                    style={[
 | 
			
		||||
                      a.w_full,
 | 
			
		||||
                      a.flex_row,
 | 
			
		||||
                      a.align_center,
 | 
			
		||||
                      a.justify_start,
 | 
			
		||||
                      a.gap_md,
 | 
			
		||||
                      a.px_lg,
 | 
			
		||||
                      a.py_md,
 | 
			
		||||
                    ]}>
 | 
			
		||||
                    <Search size="lg" style={[t.atoms.text_contrast_medium]} />
 | 
			
		||||
                    <Text
 | 
			
		||||
                      numberOfLines={1}
 | 
			
		||||
                      ellipsizeMode="middle"
 | 
			
		||||
                      style={[
 | 
			
		||||
                        a.flex_1,
 | 
			
		||||
                        a.text_md,
 | 
			
		||||
                        a.font_bold,
 | 
			
		||||
                        native({top: 2}),
 | 
			
		||||
                        t.atoms.text_contrast_medium,
 | 
			
		||||
                      ]}>
 | 
			
		||||
                      <Trans>
 | 
			
		||||
                        See{' '}
 | 
			
		||||
                        <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
 | 
			
		||||
                          {tag}
 | 
			
		||||
                        </Text>{' '}
 | 
			
		||||
                        posts
 | 
			
		||||
                      </Trans>
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </View>
 | 
			
		||||
                </Link>
 | 
			
		||||
 | 
			
		||||
                {authorHandle && !isInvalidHandle(authorHandle) && (
 | 
			
		||||
                  <>
 | 
			
		||||
                    <Divider />
 | 
			
		||||
 | 
			
		||||
                    <Link
 | 
			
		||||
                      label={_(
 | 
			
		||||
                        msg`Search for all posts by @${authorHandle} with tag ${tag}`,
 | 
			
		||||
                      )}
 | 
			
		||||
                      to={makeSearchLink({query: tag, from: authorHandle})}
 | 
			
		||||
                      onPress={e => {
 | 
			
		||||
                        e.preventDefault()
 | 
			
		||||
 | 
			
		||||
                        control.close(() => {
 | 
			
		||||
                          // @ts-ignore :ron_swanson: "I know more than you"
 | 
			
		||||
                          navigation.navigate('SearchTab', {
 | 
			
		||||
                            screen: 'Search',
 | 
			
		||||
                            params: {
 | 
			
		||||
                              q:
 | 
			
		||||
                                tag +
 | 
			
		||||
                                (authorHandle ? ` from:${authorHandle}` : ''),
 | 
			
		||||
                            },
 | 
			
		||||
                          })
 | 
			
		||||
                        })
 | 
			
		||||
 | 
			
		||||
                        return false
 | 
			
		||||
                      }}>
 | 
			
		||||
                      <View
 | 
			
		||||
                        style={[
 | 
			
		||||
                          a.w_full,
 | 
			
		||||
                          a.flex_row,
 | 
			
		||||
                          a.align_center,
 | 
			
		||||
                          a.justify_start,
 | 
			
		||||
                          a.gap_md,
 | 
			
		||||
                          a.px_lg,
 | 
			
		||||
                          a.py_md,
 | 
			
		||||
                        ]}>
 | 
			
		||||
                        <Person
 | 
			
		||||
                          size="lg"
 | 
			
		||||
                          style={[t.atoms.text_contrast_medium]}
 | 
			
		||||
                        />
 | 
			
		||||
                        <Text
 | 
			
		||||
                          numberOfLines={1}
 | 
			
		||||
                          ellipsizeMode="middle"
 | 
			
		||||
                          style={[
 | 
			
		||||
                            a.flex_1,
 | 
			
		||||
                            a.text_md,
 | 
			
		||||
                            a.font_bold,
 | 
			
		||||
                            native({top: 2}),
 | 
			
		||||
                            t.atoms.text_contrast_medium,
 | 
			
		||||
                          ]}>
 | 
			
		||||
                          <Trans>
 | 
			
		||||
                            See{' '}
 | 
			
		||||
                            <Text
 | 
			
		||||
                              style={[a.text_md, a.font_bold, t.atoms.text]}>
 | 
			
		||||
                              {tag}
 | 
			
		||||
                            </Text>{' '}
 | 
			
		||||
                            posts by this user
 | 
			
		||||
                          </Trans>
 | 
			
		||||
                        </Text>
 | 
			
		||||
                      </View>
 | 
			
		||||
                    </Link>
 | 
			
		||||
                  </>
 | 
			
		||||
                )}
 | 
			
		||||
 | 
			
		||||
                {preferences ? (
 | 
			
		||||
                  <>
 | 
			
		||||
                    <Divider />
 | 
			
		||||
 | 
			
		||||
                    <Button
 | 
			
		||||
                      label={
 | 
			
		||||
                        isMuted
 | 
			
		||||
                          ? _(msg`Unmute all ${tag} posts`)
 | 
			
		||||
                          : _(msg`Mute all ${tag} posts`)
 | 
			
		||||
                      }
 | 
			
		||||
                      onPress={() => {
 | 
			
		||||
                        control.close(() => {
 | 
			
		||||
                          if (isMuted) {
 | 
			
		||||
                            resetUpsert()
 | 
			
		||||
                            removeMutedWord({
 | 
			
		||||
                              value: sanitizedTag,
 | 
			
		||||
                              targets: ['tag'],
 | 
			
		||||
                            })
 | 
			
		||||
                          } else {
 | 
			
		||||
                            resetRemove()
 | 
			
		||||
                            upsertMutedWord([
 | 
			
		||||
                              {value: sanitizedTag, targets: ['tag']},
 | 
			
		||||
                            ])
 | 
			
		||||
                          }
 | 
			
		||||
                        })
 | 
			
		||||
                      }}>
 | 
			
		||||
                      <View
 | 
			
		||||
                        style={[
 | 
			
		||||
                          a.w_full,
 | 
			
		||||
                          a.flex_row,
 | 
			
		||||
                          a.align_center,
 | 
			
		||||
                          a.justify_start,
 | 
			
		||||
                          a.gap_md,
 | 
			
		||||
                          a.px_lg,
 | 
			
		||||
                          a.py_md,
 | 
			
		||||
                        ]}>
 | 
			
		||||
                        <Mute
 | 
			
		||||
                          size="lg"
 | 
			
		||||
                          style={[t.atoms.text_contrast_medium]}
 | 
			
		||||
                        />
 | 
			
		||||
                        <Text
 | 
			
		||||
                          numberOfLines={1}
 | 
			
		||||
                          ellipsizeMode="middle"
 | 
			
		||||
                          style={[
 | 
			
		||||
                            a.flex_1,
 | 
			
		||||
                            a.text_md,
 | 
			
		||||
                            a.font_bold,
 | 
			
		||||
                            native({top: 2}),
 | 
			
		||||
                            t.atoms.text_contrast_medium,
 | 
			
		||||
                          ]}>
 | 
			
		||||
                          {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
 | 
			
		||||
                          <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
 | 
			
		||||
                            {tag}
 | 
			
		||||
                          </Text>{' '}
 | 
			
		||||
                          <Trans>posts</Trans>
 | 
			
		||||
                        </Text>
 | 
			
		||||
                      </View>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </>
 | 
			
		||||
                ) : null}
 | 
			
		||||
              </View>
 | 
			
		||||
 | 
			
		||||
              <Button
 | 
			
		||||
                label={_(msg`Close this dialog`)}
 | 
			
		||||
                size="small"
 | 
			
		||||
                variant="ghost"
 | 
			
		||||
                color="secondary"
 | 
			
		||||
                onPress={() => control.close()}>
 | 
			
		||||
                <ButtonText>Cancel</ButtonText>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </Dialog.Inner>
 | 
			
		||||
      </Dialog.Outer>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										127
									
								
								src/components/TagMenu/index.web.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/components/TagMenu/index.web.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,127 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {msg} from '@lingui/macro'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {useNavigation} from '@react-navigation/native'
 | 
			
		||||
 | 
			
		||||
import {isInvalidHandle} from '#/lib/strings/handles'
 | 
			
		||||
import {EventStopper} from '#/view/com/util/EventStopper'
 | 
			
		||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
 | 
			
		||||
import {NavigationProp} from '#/lib/routes/types'
 | 
			
		||||
import {
 | 
			
		||||
  usePreferencesQuery,
 | 
			
		||||
  useUpsertMutedWordsMutation,
 | 
			
		||||
  useRemoveMutedWordMutation,
 | 
			
		||||
} from '#/state/queries/preferences'
 | 
			
		||||
 | 
			
		||||
export function useTagMenuControl() {}
 | 
			
		||||
 | 
			
		||||
export function TagMenu({
 | 
			
		||||
  children,
 | 
			
		||||
  tag,
 | 
			
		||||
  authorHandle,
 | 
			
		||||
}: React.PropsWithChildren<{
 | 
			
		||||
  tag: string
 | 
			
		||||
  authorHandle?: string
 | 
			
		||||
}>) {
 | 
			
		||||
  const sanitizedTag = tag.replace(/^#/, '')
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const navigation = useNavigation<NavigationProp>()
 | 
			
		||||
  const {data: preferences} = usePreferencesQuery()
 | 
			
		||||
  const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
 | 
			
		||||
    useUpsertMutedWordsMutation()
 | 
			
		||||
  const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
 | 
			
		||||
    useRemoveMutedWordMutation()
 | 
			
		||||
  const isMuted = Boolean(
 | 
			
		||||
    (preferences?.mutedWords?.find(
 | 
			
		||||
      m => m.value === sanitizedTag && m.targets.includes('tag'),
 | 
			
		||||
    ) ??
 | 
			
		||||
      optimisticUpsert?.find(
 | 
			
		||||
        m => m.value === sanitizedTag && m.targets.includes('tag'),
 | 
			
		||||
      )) &&
 | 
			
		||||
      !(optimisticRemove?.value === sanitizedTag),
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const dropdownItems = React.useMemo(() => {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        label: _(msg`See ${tag} posts`),
 | 
			
		||||
        onPress() {
 | 
			
		||||
          navigation.navigate('Search', {
 | 
			
		||||
            q: tag,
 | 
			
		||||
          })
 | 
			
		||||
        },
 | 
			
		||||
        testID: 'tagMenuSearch',
 | 
			
		||||
        icon: {
 | 
			
		||||
          ios: {
 | 
			
		||||
            name: 'magnifyingglass',
 | 
			
		||||
          },
 | 
			
		||||
          android: '',
 | 
			
		||||
          web: 'magnifying-glass',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      authorHandle &&
 | 
			
		||||
        !isInvalidHandle(authorHandle) && {
 | 
			
		||||
          label: _(msg`See ${tag} posts by this user`),
 | 
			
		||||
          onPress() {
 | 
			
		||||
            navigation.navigate({
 | 
			
		||||
              name: 'Search',
 | 
			
		||||
              params: {
 | 
			
		||||
                q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
 | 
			
		||||
              },
 | 
			
		||||
            })
 | 
			
		||||
          },
 | 
			
		||||
          testID: 'tagMenuSeachByUser',
 | 
			
		||||
          icon: {
 | 
			
		||||
            ios: {
 | 
			
		||||
              name: 'magnifyingglass',
 | 
			
		||||
            },
 | 
			
		||||
            android: '',
 | 
			
		||||
            web: ['far', 'user'],
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      preferences && {
 | 
			
		||||
        label: 'separator',
 | 
			
		||||
      },
 | 
			
		||||
      preferences && {
 | 
			
		||||
        label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`),
 | 
			
		||||
        onPress() {
 | 
			
		||||
          if (isMuted) {
 | 
			
		||||
            removeMutedWord({value: sanitizedTag, targets: ['tag']})
 | 
			
		||||
          } else {
 | 
			
		||||
            upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        testID: 'tagMenuMute',
 | 
			
		||||
        icon: {
 | 
			
		||||
          ios: {
 | 
			
		||||
            name: 'speaker.slash',
 | 
			
		||||
          },
 | 
			
		||||
          android: 'ic_menu_sort_alphabetically',
 | 
			
		||||
          web: isMuted ? 'eye' : ['far', 'eye-slash'],
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ].filter(Boolean)
 | 
			
		||||
  }, [
 | 
			
		||||
    _,
 | 
			
		||||
    authorHandle,
 | 
			
		||||
    isMuted,
 | 
			
		||||
    navigation,
 | 
			
		||||
    preferences,
 | 
			
		||||
    tag,
 | 
			
		||||
    sanitizedTag,
 | 
			
		||||
    upsertMutedWord,
 | 
			
		||||
    removeMutedWord,
 | 
			
		||||
  ])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <EventStopper>
 | 
			
		||||
      <NativeDropdown
 | 
			
		||||
        accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
 | 
			
		||||
        accessibilityHint=""
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        items={dropdownItems}>
 | 
			
		||||
        {children}
 | 
			
		||||
      </NativeDropdown>
 | 
			
		||||
    </EventStopper>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										29
									
								
								src/components/dialogs/Context.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/components/dialogs/Context.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
 | 
			
		||||
import * as Dialog from '#/components/Dialog'
 | 
			
		||||
 | 
			
		||||
type Control = Dialog.DialogOuterProps['control']
 | 
			
		||||
 | 
			
		||||
type ControlsContext = {
 | 
			
		||||
  mutedWordsDialogControl: Control
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ControlsContext = React.createContext({
 | 
			
		||||
  mutedWordsDialogControl: {} as Control,
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export function useGlobalDialogsControlContext() {
 | 
			
		||||
  return React.useContext(ControlsContext)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Provider({children}: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const mutedWordsDialogControl = Dialog.useDialogControl()
 | 
			
		||||
  const ctx = React.useMemo(
 | 
			
		||||
    () => ({mutedWordsDialogControl}),
 | 
			
		||||
    [mutedWordsDialogControl],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										328
									
								
								src/components/dialogs/MutedWords.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								src/components/dialogs/MutedWords.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,328 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {AppBskyActorDefs} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  usePreferencesQuery,
 | 
			
		||||
  useUpsertMutedWordsMutation,
 | 
			
		||||
  useRemoveMutedWordMutation,
 | 
			
		||||
} from '#/state/queries/preferences'
 | 
			
		||||
import {isNative} from '#/platform/detection'
 | 
			
		||||
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 | 
			
		||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 | 
			
		||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
 | 
			
		||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
 | 
			
		||||
import {Divider} from '#/components/Divider'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
import * as Dialog from '#/components/Dialog'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import * as Prompt from '#/components/Prompt'
 | 
			
		||||
 | 
			
		||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 | 
			
		||||
 | 
			
		||||
export function MutedWordsDialog() {
 | 
			
		||||
  const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog.Outer control={control}>
 | 
			
		||||
      <Dialog.Handle />
 | 
			
		||||
      <MutedWordsInner control={control} />
 | 
			
		||||
    </Dialog.Outer>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {gtMobile} = useBreakpoints()
 | 
			
		||||
  const {
 | 
			
		||||
    isLoading: isPreferencesLoading,
 | 
			
		||||
    data: preferences,
 | 
			
		||||
    error: preferencesError,
 | 
			
		||||
  } = usePreferencesQuery()
 | 
			
		||||
  const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
 | 
			
		||||
  const [field, setField] = React.useState('')
 | 
			
		||||
  const [options, setOptions] = React.useState(['content'])
 | 
			
		||||
  const [_error, setError] = React.useState('')
 | 
			
		||||
 | 
			
		||||
  const submit = React.useCallback(async () => {
 | 
			
		||||
    const value = field.trim()
 | 
			
		||||
    const targets = ['tag', options.includes('content') && 'content'].filter(
 | 
			
		||||
      Boolean,
 | 
			
		||||
    ) as AppBskyActorDefs.MutedWord['targets']
 | 
			
		||||
 | 
			
		||||
    if (!value || !targets.length) return
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await addMutedWord([{value, targets}])
 | 
			
		||||
      setField('')
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      logger.error(`Failed to save muted word`, {message: e.message})
 | 
			
		||||
      setError(e.message)
 | 
			
		||||
    }
 | 
			
		||||
  }, [field, options, addMutedWord, setField])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
 | 
			
		||||
      <Text
 | 
			
		||||
        style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
 | 
			
		||||
        <Trans>Add muted words and tags</Trans>
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
 | 
			
		||||
        <Trans>
 | 
			
		||||
          Posts can be muted based on their text, their tags, or both.
 | 
			
		||||
        </Trans>
 | 
			
		||||
      </Text>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.pb_lg]}>
 | 
			
		||||
        <Dialog.Input
 | 
			
		||||
          autoCorrect={false}
 | 
			
		||||
          autoCapitalize="none"
 | 
			
		||||
          autoComplete="off"
 | 
			
		||||
          label={_(msg`Enter a word or tag`)}
 | 
			
		||||
          placeholder={_(msg`Enter a word or tag`)}
 | 
			
		||||
          value={field}
 | 
			
		||||
          onChangeText={setField}
 | 
			
		||||
          onSubmitEditing={submit}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <Toggle.Group
 | 
			
		||||
          label={_(msg`Toggle between muted word options.`)}
 | 
			
		||||
          type="radio"
 | 
			
		||||
          values={options}
 | 
			
		||||
          onChange={setOptions}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              a.pt_sm,
 | 
			
		||||
              a.pb_md,
 | 
			
		||||
              a.flex_row,
 | 
			
		||||
              a.align_center,
 | 
			
		||||
              a.gap_sm,
 | 
			
		||||
              a.flex_wrap,
 | 
			
		||||
            ]}>
 | 
			
		||||
            <Toggle.Item
 | 
			
		||||
              label={_(msg`Mute this word in post text and tags`)}
 | 
			
		||||
              name="content"
 | 
			
		||||
              style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
 | 
			
		||||
              <TargetToggle>
 | 
			
		||||
                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
 | 
			
		||||
                  <Toggle.Radio />
 | 
			
		||||
                  <Toggle.Label>
 | 
			
		||||
                    <Trans>Mute in text & tags</Trans>
 | 
			
		||||
                  </Toggle.Label>
 | 
			
		||||
                </View>
 | 
			
		||||
                <PageText size="sm" />
 | 
			
		||||
              </TargetToggle>
 | 
			
		||||
            </Toggle.Item>
 | 
			
		||||
 | 
			
		||||
            <Toggle.Item
 | 
			
		||||
              label={_(msg`Mute this word in tags only`)}
 | 
			
		||||
              name="tag"
 | 
			
		||||
              style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
 | 
			
		||||
              <TargetToggle>
 | 
			
		||||
                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
 | 
			
		||||
                  <Toggle.Radio />
 | 
			
		||||
                  <Toggle.Label>
 | 
			
		||||
                    <Trans>Mute in tags only</Trans>
 | 
			
		||||
                  </Toggle.Label>
 | 
			
		||||
                </View>
 | 
			
		||||
                <Hashtag size="sm" />
 | 
			
		||||
              </TargetToggle>
 | 
			
		||||
            </Toggle.Item>
 | 
			
		||||
 | 
			
		||||
            <Button
 | 
			
		||||
              disabled={isPending || !field}
 | 
			
		||||
              label={_(msg`Add mute word for configured settings`)}
 | 
			
		||||
              size="small"
 | 
			
		||||
              color="primary"
 | 
			
		||||
              variant="solid"
 | 
			
		||||
              style={[!gtMobile && [a.w_full, a.flex_0]]}
 | 
			
		||||
              onPress={submit}>
 | 
			
		||||
              <ButtonText>
 | 
			
		||||
                <Trans>Add</Trans>
 | 
			
		||||
              </ButtonText>
 | 
			
		||||
              <ButtonIcon icon={isPending ? Loader : Plus} />
 | 
			
		||||
            </Button>
 | 
			
		||||
          </View>
 | 
			
		||||
        </Toggle.Group>
 | 
			
		||||
 | 
			
		||||
        <Text
 | 
			
		||||
          style={[
 | 
			
		||||
            a.text_sm,
 | 
			
		||||
            a.italic,
 | 
			
		||||
            a.leading_snug,
 | 
			
		||||
            t.atoms.text_contrast_medium,
 | 
			
		||||
          ]}>
 | 
			
		||||
          <Trans>
 | 
			
		||||
            We recommend avoiding common words that appear in many posts, since
 | 
			
		||||
            it can result in no posts being shown.
 | 
			
		||||
          </Trans>
 | 
			
		||||
        </Text>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <Divider />
 | 
			
		||||
 | 
			
		||||
      <View style={[a.pt_2xl]}>
 | 
			
		||||
        <Text
 | 
			
		||||
          style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
 | 
			
		||||
          <Trans>Your muted words</Trans>
 | 
			
		||||
        </Text>
 | 
			
		||||
 | 
			
		||||
        {isPreferencesLoading ? (
 | 
			
		||||
          <Loader />
 | 
			
		||||
        ) : preferencesError || !preferences ? (
 | 
			
		||||
          <View
 | 
			
		||||
            style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
 | 
			
		||||
            <Text style={[a.italic, t.atoms.text_contrast_high]}>
 | 
			
		||||
              <Trans>
 | 
			
		||||
                We're sorry, but we weren't able to load your muted words at
 | 
			
		||||
                this time. Please try again.
 | 
			
		||||
              </Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        ) : preferences.mutedWords.length ? (
 | 
			
		||||
          [...preferences.mutedWords]
 | 
			
		||||
            .reverse()
 | 
			
		||||
            .map((word, i) => (
 | 
			
		||||
              <MutedWordRow
 | 
			
		||||
                key={word.value + i}
 | 
			
		||||
                word={word}
 | 
			
		||||
                style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
 | 
			
		||||
              />
 | 
			
		||||
            ))
 | 
			
		||||
        ) : (
 | 
			
		||||
          <View
 | 
			
		||||
            style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
 | 
			
		||||
            <Text style={[a.italic, t.atoms.text_contrast_high]}>
 | 
			
		||||
              <Trans>You haven't muted any words or tags yet</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        )}
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      {isNative && <View style={{height: 20}} />}
 | 
			
		||||
 | 
			
		||||
      <Dialog.Close />
 | 
			
		||||
    </Dialog.ScrollableInner>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function MutedWordRow({
 | 
			
		||||
  style,
 | 
			
		||||
  word,
 | 
			
		||||
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
 | 
			
		||||
  const control = Prompt.usePromptControl()
 | 
			
		||||
 | 
			
		||||
  const remove = React.useCallback(async () => {
 | 
			
		||||
    control.close()
 | 
			
		||||
    removeMutedWord(word)
 | 
			
		||||
  }, [removeMutedWord, word, control])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Prompt.Outer control={control}>
 | 
			
		||||
        <Prompt.Title>
 | 
			
		||||
          <Trans>Are you sure?</Trans>
 | 
			
		||||
        </Prompt.Title>
 | 
			
		||||
        <Prompt.Description>
 | 
			
		||||
          <Trans>
 | 
			
		||||
            This will delete {word.value} from your muted words. You can always
 | 
			
		||||
            add it back later.
 | 
			
		||||
          </Trans>
 | 
			
		||||
        </Prompt.Description>
 | 
			
		||||
        <Prompt.Actions>
 | 
			
		||||
          <Prompt.Cancel>
 | 
			
		||||
            <ButtonText>
 | 
			
		||||
              <Trans>Nevermind</Trans>
 | 
			
		||||
            </ButtonText>
 | 
			
		||||
          </Prompt.Cancel>
 | 
			
		||||
          <Prompt.Action onPress={remove}>
 | 
			
		||||
            <ButtonText>
 | 
			
		||||
              <Trans>Remove</Trans>
 | 
			
		||||
            </ButtonText>
 | 
			
		||||
          </Prompt.Action>
 | 
			
		||||
        </Prompt.Actions>
 | 
			
		||||
      </Prompt.Outer>
 | 
			
		||||
 | 
			
		||||
      <View
 | 
			
		||||
        style={[
 | 
			
		||||
          a.py_md,
 | 
			
		||||
          a.px_lg,
 | 
			
		||||
          a.flex_row,
 | 
			
		||||
          a.align_center,
 | 
			
		||||
          a.justify_between,
 | 
			
		||||
          a.rounded_md,
 | 
			
		||||
          style,
 | 
			
		||||
        ]}>
 | 
			
		||||
        <Text style={[a.font_bold, t.atoms.text_contrast_high]}>
 | 
			
		||||
          {word.value}
 | 
			
		||||
        </Text>
 | 
			
		||||
 | 
			
		||||
        <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
 | 
			
		||||
          {word.targets.map(target => (
 | 
			
		||||
            <View
 | 
			
		||||
              key={target}
 | 
			
		||||
              style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
 | 
			
		||||
              <Text
 | 
			
		||||
                style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
 | 
			
		||||
                {target === 'content' ? _(msg`text`) : _(msg`tag`)}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </View>
 | 
			
		||||
          ))}
 | 
			
		||||
 | 
			
		||||
          <Button
 | 
			
		||||
            label={_(msg`Remove mute word from your list`)}
 | 
			
		||||
            size="tiny"
 | 
			
		||||
            shape="round"
 | 
			
		||||
            variant="ghost"
 | 
			
		||||
            color="secondary"
 | 
			
		||||
            onPress={() => control.open()}
 | 
			
		||||
            style={[a.ml_sm]}>
 | 
			
		||||
            <ButtonIcon icon={isPending ? Loader : X} />
 | 
			
		||||
          </Button>
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function TargetToggle({children}: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const ctx = Toggle.useItemContext()
 | 
			
		||||
  const {gtMobile} = useBreakpoints()
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.flex_row,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
        a.justify_between,
 | 
			
		||||
        a.gap_xs,
 | 
			
		||||
        a.flex_1,
 | 
			
		||||
        a.py_sm,
 | 
			
		||||
        a.px_sm,
 | 
			
		||||
        gtMobile && a.px_md,
 | 
			
		||||
        a.rounded_sm,
 | 
			
		||||
        t.atoms.bg_contrast_50,
 | 
			
		||||
        (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
 | 
			
		||||
        ctx.selected && [
 | 
			
		||||
          {
 | 
			
		||||
            backgroundColor:
 | 
			
		||||
              t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        ctx.disabled && {
 | 
			
		||||
          opacity: 0.8,
 | 
			
		||||
        },
 | 
			
		||||
      ]}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
 | 
			
		|||
  return (
 | 
			
		||||
    <Context.Provider value={context}>
 | 
			
		||||
      <View
 | 
			
		||||
        style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
 | 
			
		||||
        style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
 | 
			
		||||
        {...web({
 | 
			
		||||
          onClick: () => inputRef.current?.focus(),
 | 
			
		||||
          onMouseOver: onHoverIn,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
 | 
			
		|||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
 | 
			
		||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
 | 
			
		||||
 | 
			
		||||
export type ItemState = {
 | 
			
		||||
  name: string
 | 
			
		||||
| 
						 | 
				
			
			@ -331,15 +332,14 @@ export function createSharedToggleStyles({
 | 
			
		|||
export function Checkbox() {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
 | 
			
		||||
  const {baseStyles, baseHoverStyles, indicatorStyles} =
 | 
			
		||||
    createSharedToggleStyles({
 | 
			
		||||
      theme: t,
 | 
			
		||||
      hovered,
 | 
			
		||||
      focused,
 | 
			
		||||
      selected,
 | 
			
		||||
      disabled,
 | 
			
		||||
      isInvalid,
 | 
			
		||||
    })
 | 
			
		||||
  const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
 | 
			
		||||
    theme: t,
 | 
			
		||||
    hovered,
 | 
			
		||||
    focused,
 | 
			
		||||
    selected,
 | 
			
		||||
    disabled,
 | 
			
		||||
    isInvalid,
 | 
			
		||||
  })
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
| 
						 | 
				
			
			@ -355,21 +355,7 @@ export function Checkbox() {
 | 
			
		|||
        baseStyles,
 | 
			
		||||
        hovered || focused ? baseHoverStyles : {},
 | 
			
		||||
      ]}>
 | 
			
		||||
      {selected ? (
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            a.absolute,
 | 
			
		||||
            a.rounded_2xs,
 | 
			
		||||
            {height: 12, width: 12},
 | 
			
		||||
            selected
 | 
			
		||||
              ? {
 | 
			
		||||
                  backgroundColor: t.palette.primary_500,
 | 
			
		||||
                }
 | 
			
		||||
              : {},
 | 
			
		||||
            indicatorStyles,
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
      ) : null}
 | 
			
		||||
      {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		|||
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										5
									
								
								src/components/icons/Clipboard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/icons/Clipboard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
 | 
			
		||||
  path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										5
									
								
								src/components/icons/MagnifyingGlass2.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/icons/MagnifyingGlass2.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										5
									
								
								src/components/icons/Mute.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/icons/Mute.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										5
									
								
								src/components/icons/PageText.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/icons/PageText.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										5
									
								
								src/components/icons/Person.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/icons/Person.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										578
									
								
								src/lib/__tests__/moderatePost_wrapped.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										578
									
								
								src/lib/__tests__/moderatePost_wrapped.test.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,578 @@
 | 
			
		|||
import {describe, it, expect} from '@jest/globals'
 | 
			
		||||
import {RichText} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
import {hasMutedWord} from '../moderatePost_wrapped'
 | 
			
		||||
 | 
			
		||||
describe(`hasMutedWord`, () => {
 | 
			
		||||
  describe(`tags`, () => {
 | 
			
		||||
    it(`match: outline tag`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `This is a post #inlineTag`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'outlineTag', targets: ['tag']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        ['outlineTag'],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`match: inline tag`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `This is a post #inlineTag`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'inlineTag', targets: ['tag']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        ['outlineTag'],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`match: content target matches inline tag`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `This is a post #inlineTag`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'inlineTag', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        ['outlineTag'],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`no match: only tag targets`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `This is a post`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'inlineTag', targets: ['tag']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(false)
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe(`early exits`, () => {
 | 
			
		||||
    it(`match: single character 希`, () => {
 | 
			
		||||
      /**
 | 
			
		||||
       * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
 | 
			
		||||
       */
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `改善希望です`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: '希', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`no match: long muted word, short post`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `hey`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'politics', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(false)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`match: exact text`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `javascript`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'javascript', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe(`general content`, () => {
 | 
			
		||||
    it(`match: word within post`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `This is a post about javascript`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'javascript', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`no match: partial word`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Use your brain, Eric`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'ai', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(false)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`match: multiline`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Use your\n\tbrain, Eric`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: 'brain', targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it(`match: :)`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `So happy :)`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      const match = hasMutedWord(
 | 
			
		||||
        [{value: `:)`, targets: ['content']}],
 | 
			
		||||
        rt.text,
 | 
			
		||||
        rt.facets,
 | 
			
		||||
        [],
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      expect(match).toBe(true)
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe(`punctuation semi-fuzzy`, () => {
 | 
			
		||||
    describe(`yay!`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `We're federating, yay!`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: yay!`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'yay!', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: yay`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'yay', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`y!ppee!!`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `We're federating, y!ppee!!`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: y!ppee`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'y!ppee', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      // single exclamation point, source has double
 | 
			
		||||
      it(`no match: y!ppee!`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'y!ppee!', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`Why so S@assy?`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Why so S@assy?`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: S@assy`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'S@assy', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: s@assy`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 's@assy', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`New York Times`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `New York Times`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      // case insensitive
 | 
			
		||||
      it(`match: new york times`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'new york times', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`!command`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Idk maybe a bot !command`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: !command`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `!command`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: command`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `command`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`no match: !command`, () => {
 | 
			
		||||
        const rt = new RichText({
 | 
			
		||||
          text: `Idk maybe a bot command`,
 | 
			
		||||
        })
 | 
			
		||||
        rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `!command`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(false)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`e/acc`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `I'm e/acc pilled`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: e/acc`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `e/acc`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: acc`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `acc`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`super-bad`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `I'm super-bad`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: super-bad`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `super-bad`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: super`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `super`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: super bad`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `super bad`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: superbad`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `superbad`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(false)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`idk_what_this_would_be`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Weird post with idk_what_this_would_be`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: idk what this would be`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `idk what this would be`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`no match: idk what this would be for`, () => {
 | 
			
		||||
        // extra word
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `idk what this would be for`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(false)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: idk`, () => {
 | 
			
		||||
        // extra word
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `idk`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: idkwhatthiswouldbe`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `idkwhatthiswouldbe`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(false)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`parentheses`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Post with context(iykyk)`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: context(iykyk)`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `context(iykyk)`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: context`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `context`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: iykyk`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `iykyk`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: (iykyk)`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `(iykyk)`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    describe(`🦋`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `Post with 🦋`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: 🦋`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: `🦋`, targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe(`phrases`, () => {
 | 
			
		||||
    describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
 | 
			
		||||
      const rt = new RichText({
 | 
			
		||||
        text: `I like turtles, or how I learned to stop worrying and love the internet.`,
 | 
			
		||||
      })
 | 
			
		||||
      rt.detectFacetsWithoutResolution()
 | 
			
		||||
 | 
			
		||||
      it(`match: stop worrying`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'stop worrying', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      it(`match: turtles, or how`, () => {
 | 
			
		||||
        const match = hasMutedWord(
 | 
			
		||||
          [{value: 'turtles, or how', targets: ['content']}],
 | 
			
		||||
          rt.text,
 | 
			
		||||
          rt.facets,
 | 
			
		||||
          [],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        expect(match).toBe(true)
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -2,18 +2,122 @@ import {
 | 
			
		|||
  AppBskyEmbedRecord,
 | 
			
		||||
  AppBskyEmbedRecordWithMedia,
 | 
			
		||||
  moderatePost,
 | 
			
		||||
  AppBskyActorDefs,
 | 
			
		||||
  AppBskyFeedPost,
 | 
			
		||||
  AppBskyRichtextFacet,
 | 
			
		||||
  AppBskyEmbedImages,
 | 
			
		||||
} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
type ModeratePost = typeof moderatePost
 | 
			
		||||
type Options = Parameters<ModeratePost>[1] & {
 | 
			
		||||
  hiddenPosts?: string[]
 | 
			
		||||
  mutedWords?: AppBskyActorDefs.MutedWord[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const REGEX = {
 | 
			
		||||
  LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
 | 
			
		||||
  ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
 | 
			
		||||
  SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
 | 
			
		||||
  WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function hasMutedWord(
 | 
			
		||||
  mutedWords: AppBskyActorDefs.MutedWord[],
 | 
			
		||||
  text: string,
 | 
			
		||||
  facets?: AppBskyRichtextFacet.Main[],
 | 
			
		||||
  outlineTags?: string[],
 | 
			
		||||
) {
 | 
			
		||||
  const tags = ([] as string[])
 | 
			
		||||
    .concat(outlineTags || [])
 | 
			
		||||
    .concat(
 | 
			
		||||
      facets
 | 
			
		||||
        ?.filter(facet => {
 | 
			
		||||
          return facet.features.find(feature =>
 | 
			
		||||
            AppBskyRichtextFacet.isTag(feature),
 | 
			
		||||
          )
 | 
			
		||||
        })
 | 
			
		||||
        .map(t => t.features[0].tag as string) || [],
 | 
			
		||||
    )
 | 
			
		||||
    .map(t => t.toLowerCase())
 | 
			
		||||
 | 
			
		||||
  for (const mute of mutedWords) {
 | 
			
		||||
    const mutedWord = mute.value.toLowerCase()
 | 
			
		||||
    const postText = text.toLowerCase()
 | 
			
		||||
 | 
			
		||||
    // `content` applies to tags as well
 | 
			
		||||
    if (tags.includes(mutedWord)) return true
 | 
			
		||||
    // rest of the checks are for `content` only
 | 
			
		||||
    if (!mute.targets.includes('content')) continue
 | 
			
		||||
    // single character, has to use includes
 | 
			
		||||
    if (mutedWord.length === 1 && postText.includes(mutedWord)) return true
 | 
			
		||||
    // too long
 | 
			
		||||
    if (mutedWord.length > postText.length) continue
 | 
			
		||||
    // exact match
 | 
			
		||||
    if (mutedWord === postText) return true
 | 
			
		||||
    // any muted phrase with space or punctuation
 | 
			
		||||
    if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
 | 
			
		||||
      return true
 | 
			
		||||
 | 
			
		||||
    // check individual character groups
 | 
			
		||||
    const words = postText.split(REGEX.WORD_BOUNDARY)
 | 
			
		||||
    for (const word of words) {
 | 
			
		||||
      if (word === mutedWord) return true
 | 
			
		||||
 | 
			
		||||
      // compare word without leading/trailing punctuation, but allow internal
 | 
			
		||||
      // punctuation (such as `s@ssy`)
 | 
			
		||||
      const wordTrimmedPunctuation = word.replace(
 | 
			
		||||
        REGEX.LEADING_TRAILING_PUNCTUATION,
 | 
			
		||||
        '',
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      if (mutedWord === wordTrimmedPunctuation) return true
 | 
			
		||||
      if (mutedWord.length > wordTrimmedPunctuation.length) continue
 | 
			
		||||
 | 
			
		||||
      // handle hyphenated, slash separated words, etc
 | 
			
		||||
      if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
 | 
			
		||||
        // check against full normalized phrase
 | 
			
		||||
        const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
 | 
			
		||||
          REGEX.SEPARATORS,
 | 
			
		||||
          ' ',
 | 
			
		||||
        )
 | 
			
		||||
        const mutedWordNormalizedSeparators = mutedWord.replace(
 | 
			
		||||
          REGEX.SEPARATORS,
 | 
			
		||||
          ' ',
 | 
			
		||||
        )
 | 
			
		||||
        // hyphenated (or other sep) to spaced words
 | 
			
		||||
        if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
 | 
			
		||||
          return true
 | 
			
		||||
 | 
			
		||||
        /* Disabled for now e.g. `super-cool` to `supercool`
 | 
			
		||||
        const wordNormalizedCompressed = wordNormalizedSeparators.replace(
 | 
			
		||||
          REGEX.WORD_BOUNDARY,
 | 
			
		||||
          '',
 | 
			
		||||
        )
 | 
			
		||||
        const mutedWordNormalizedCompressed =
 | 
			
		||||
          mutedWordNormalizedSeparators.replace(/\s+?/g, '')
 | 
			
		||||
        // hyphenated (or other sep) to non-hyphenated contiguous word
 | 
			
		||||
        if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
 | 
			
		||||
          return true
 | 
			
		||||
        */
 | 
			
		||||
 | 
			
		||||
        // then individual parts of separated phrases/words
 | 
			
		||||
        const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
 | 
			
		||||
        for (const wp of wordParts) {
 | 
			
		||||
          // still retain internal punctuation
 | 
			
		||||
          if (wp === mutedWord) return true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function moderatePost_wrapped(
 | 
			
		||||
  subject: Parameters<ModeratePost>[0],
 | 
			
		||||
  opts: Options,
 | 
			
		||||
) {
 | 
			
		||||
  const {hiddenPosts = [], ...options} = opts
 | 
			
		||||
  const {hiddenPosts = [], mutedWords = [], ...options} = opts
 | 
			
		||||
  const moderations = moderatePost(subject, options)
 | 
			
		||||
 | 
			
		||||
  if (hiddenPosts.includes(subject.uri)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -29,15 +133,65 @@ export function moderatePost_wrapped(
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (AppBskyFeedPost.isRecord(subject.record)) {
 | 
			
		||||
    let muted = hasMutedWord(
 | 
			
		||||
      mutedWords,
 | 
			
		||||
      subject.record.text,
 | 
			
		||||
      subject.record.facets || [],
 | 
			
		||||
      subject.record.tags || [],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      subject.record.embed &&
 | 
			
		||||
      AppBskyEmbedImages.isMain(subject.record.embed)
 | 
			
		||||
    ) {
 | 
			
		||||
      for (const image of subject.record.embed.images) {
 | 
			
		||||
        muted = muted || hasMutedWord(mutedWords, image.alt, [], [])
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (muted) {
 | 
			
		||||
      moderations.content.filter = true
 | 
			
		||||
      moderations.content.blur = true
 | 
			
		||||
      if (!moderations.content.cause) {
 | 
			
		||||
        moderations.content.cause = {
 | 
			
		||||
          // @ts-ignore Temporary extension to the moderation system -prf
 | 
			
		||||
          type: 'muted-word',
 | 
			
		||||
          source: {type: 'user'},
 | 
			
		||||
          priority: 1,
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (subject.embed) {
 | 
			
		||||
    let embedHidden = false
 | 
			
		||||
    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
 | 
			
		||||
      embedHidden = hiddenPosts.includes(subject.embed.record.uri)
 | 
			
		||||
 | 
			
		||||
      if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
 | 
			
		||||
        embedHidden =
 | 
			
		||||
          embedHidden ||
 | 
			
		||||
          hasMutedWord(
 | 
			
		||||
            mutedWords,
 | 
			
		||||
            subject.embed.record.value.text,
 | 
			
		||||
            subject.embed.record.value.facets,
 | 
			
		||||
            subject.embed.record.value.tags,
 | 
			
		||||
          )
 | 
			
		||||
 | 
			
		||||
        if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
 | 
			
		||||
          for (const image of subject.embed.record.value.embed.images) {
 | 
			
		||||
            embedHidden =
 | 
			
		||||
              embedHidden || hasMutedWord(mutedWords, image.alt, [], [])
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
 | 
			
		||||
      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
 | 
			
		||||
    ) {
 | 
			
		||||
      // TODO what
 | 
			
		||||
      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
 | 
			
		||||
    }
 | 
			
		||||
    if (embedHidden) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -67,6 +67,13 @@ export function describeModerationCause(
 | 
			
		|||
      description: 'You have hidden this post',
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  // @ts-ignore Temporary extension to the moderation system -prf
 | 
			
		||||
  if (cause.type === 'muted-word') {
 | 
			
		||||
    return {
 | 
			
		||||
      name: 'Post hidden by muted word',
 | 
			
		||||
      description: `You've chosen to hide a word or tag within this post.`,
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return cause.labelDef.strings[context].en
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,3 +25,13 @@ export function makeCustomFeedLink(
 | 
			
		|||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
 | 
			
		||||
  return [`/profile`, did, 'lists', rkey, ...segments].join('/')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function makeTagLink(did: string) {
 | 
			
		||||
  return `/search?q=${encodeURIComponent(did)}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
 | 
			
		||||
  return `/search?q=${encodeURIComponent(
 | 
			
		||||
    props.query + (props.from ? ` from:${props.from}` : ''),
 | 
			
		||||
  )}`
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
 | 
			
		|||
  PreferencesFollowingFeed: undefined
 | 
			
		||||
  PreferencesThreads: undefined
 | 
			
		||||
  PreferencesExternalEmbeds: undefined
 | 
			
		||||
  Search: {q?: string}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {DialogControlProps} from '#/components/Dialog'
 | 
			
		||||
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
 | 
			
		||||
 | 
			
		||||
const DialogContext = React.createContext<{
 | 
			
		||||
  activeDialogs: React.MutableRefObject<
 | 
			
		||||
| 
						 | 
				
			
			@ -37,7 +38,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 | 
			
		|||
  return (
 | 
			
		||||
    <DialogContext.Provider value={context}>
 | 
			
		||||
      <DialogControlContext.Provider value={controls}>
 | 
			
		||||
        {children}
 | 
			
		||||
        <GlobalDialogsProvider>{children}</GlobalDialogsProvider>
 | 
			
		||||
      </DialogControlContext.Provider>
 | 
			
		||||
    </DialogContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
 | 
			
		|||
  threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
 | 
			
		||||
  userAge: 13, // TODO(pwi)
 | 
			
		||||
  interests: {tags: []},
 | 
			
		||||
  mutedWords: [],
 | 
			
		||||
  hiddenPosts: [],
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,10 @@
 | 
			
		|||
import {useMemo} from 'react'
 | 
			
		||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
 | 
			
		||||
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
 | 
			
		||||
import {
 | 
			
		||||
  LabelPreference,
 | 
			
		||||
  BskyFeedViewPreference,
 | 
			
		||||
  AppBskyActorDefs,
 | 
			
		||||
} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
import {track} from '#/lib/analytics/analytics'
 | 
			
		||||
import {getAge} from '#/lib/strings/time'
 | 
			
		||||
| 
						 | 
				
			
			@ -108,6 +112,7 @@ export function useModerationOpts() {
 | 
			
		|||
    return {
 | 
			
		||||
      ...moderationOpts,
 | 
			
		||||
      hiddenPosts,
 | 
			
		||||
      mutedWords: prefs.data.mutedWords || [],
 | 
			
		||||
    }
 | 
			
		||||
  }, [currentAccount?.did, prefs.data, hiddenPosts])
 | 
			
		||||
  return opts
 | 
			
		||||
| 
						 | 
				
			
			@ -278,3 +283,45 @@ export function useUnpinFeedMutation() {
 | 
			
		|||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useUpsertMutedWordsMutation() {
 | 
			
		||||
  const queryClient = useQueryClient()
 | 
			
		||||
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
 | 
			
		||||
      await getAgent().upsertMutedWords(mutedWords)
 | 
			
		||||
      // triggers a refetch
 | 
			
		||||
      await queryClient.invalidateQueries({
 | 
			
		||||
        queryKey: preferencesQueryKey,
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useUpdateMutedWordMutation() {
 | 
			
		||||
  const queryClient = useQueryClient()
 | 
			
		||||
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
 | 
			
		||||
      await getAgent().updateMutedWord(mutedWord)
 | 
			
		||||
      // triggers a refetch
 | 
			
		||||
      await queryClient.invalidateQueries({
 | 
			
		||||
        queryKey: preferencesQueryKey,
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useRemoveMutedWordMutation() {
 | 
			
		||||
  const queryClient = useQueryClient()
 | 
			
		||||
 | 
			
		||||
  return useMutation({
 | 
			
		||||
    mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
 | 
			
		||||
      await getAgent().removeMutedWord(mutedWord)
 | 
			
		||||
      // triggers a refetch
 | 
			
		||||
      await queryClient.invalidateQueries({
 | 
			
		||||
        queryKey: preferencesQueryKey,
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
 | 
			
		|||
    let i = 0
 | 
			
		||||
 | 
			
		||||
    return Array.from(richtext.segments()).map(segment => {
 | 
			
		||||
      const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
 | 
			
		||||
      return (
 | 
			
		||||
        <Text
 | 
			
		||||
          key={i++}
 | 
			
		||||
          style={[
 | 
			
		||||
            segment.facet && !isTag ? pal.link : pal.text,
 | 
			
		||||
            segment.facet ? pal.link : pal.text,
 | 
			
		||||
            styles.textInputFormatting,
 | 
			
		||||
          ]}>
 | 
			
		||||
          {segment.text}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal'
 | 
			
		|||
import {Text} from '../../util/text/Text'
 | 
			
		||||
import {Trans} from '@lingui/macro'
 | 
			
		||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 | 
			
		||||
import {TagDecorator} from './web/TagDecorator'
 | 
			
		||||
 | 
			
		||||
export interface TextInputRef {
 | 
			
		||||
  focus: () => void
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
 | 
			
		|||
    () => [
 | 
			
		||||
      Document,
 | 
			
		||||
      LinkDecorator,
 | 
			
		||||
      TagDecorator,
 | 
			
		||||
      Mention.configure({
 | 
			
		||||
        HTMLAttributes: {
 | 
			
		||||
          class: 'mention',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										83
									
								
								src/view/com/composer/text-input/web/TagDecorator.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/view/com/composer/text-input/web/TagDecorator.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,83 @@
 | 
			
		|||
/**
 | 
			
		||||
 * TipTap is a stateful rich-text editor, which is extremely useful
 | 
			
		||||
 * when you _want_ it to be stateful formatting such as bold and italics.
 | 
			
		||||
 *
 | 
			
		||||
 * However we also use "stateless" behaviors, specifically for URLs
 | 
			
		||||
 * where the text itself drives the formatting.
 | 
			
		||||
 *
 | 
			
		||||
 * This plugin uses a regex to detect URIs and then applies
 | 
			
		||||
 * link decorations (a <span> with the "autolink") class. That avoids
 | 
			
		||||
 * adding any stateful formatting to TipTap's document model.
 | 
			
		||||
 *
 | 
			
		||||
 * We then run the URI detection again when constructing the
 | 
			
		||||
 * RichText object from TipTap's output and merge their features into
 | 
			
		||||
 * the facet-set.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import {Mark} from '@tiptap/core'
 | 
			
		||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
 | 
			
		||||
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
 | 
			
		||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
 | 
			
		||||
 | 
			
		||||
function getDecorations(doc: ProsemirrorNode) {
 | 
			
		||||
  const decorations: Decoration[] = []
 | 
			
		||||
 | 
			
		||||
  doc.descendants((node, pos) => {
 | 
			
		||||
    if (node.isText && node.text) {
 | 
			
		||||
      const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
 | 
			
		||||
      const textContent = node.textContent
 | 
			
		||||
 | 
			
		||||
      let match
 | 
			
		||||
      while ((match = regex.exec(textContent))) {
 | 
			
		||||
        const [matchedString, tag] = match
 | 
			
		||||
 | 
			
		||||
        if (tag.length > 66) continue
 | 
			
		||||
 | 
			
		||||
        const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || []
 | 
			
		||||
 | 
			
		||||
        const from = match.index + matchedString.indexOf(tag)
 | 
			
		||||
        const to = from + (tag.length - trailingPunc.length)
 | 
			
		||||
 | 
			
		||||
        decorations.push(
 | 
			
		||||
          Decoration.inline(pos + from, pos + to, {
 | 
			
		||||
            class: 'autolink',
 | 
			
		||||
          }),
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return DecorationSet.create(doc, decorations)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const tagDecoratorPlugin: Plugin = new Plugin({
 | 
			
		||||
  key: new PluginKey('link-decorator'),
 | 
			
		||||
 | 
			
		||||
  state: {
 | 
			
		||||
    init: (_, {doc}) => getDecorations(doc),
 | 
			
		||||
    apply: (transaction, decorationSet) => {
 | 
			
		||||
      if (transaction.docChanged) {
 | 
			
		||||
        return getDecorations(transaction.doc)
 | 
			
		||||
      }
 | 
			
		||||
      return decorationSet.map(transaction.mapping, transaction.doc)
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  props: {
 | 
			
		||||
    decorations(state) {
 | 
			
		||||
      return tagDecoratorPlugin.getState(state)
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export const TagDecorator = Mark.create({
 | 
			
		||||
  name: 'tag-decorator',
 | 
			
		||||
  priority: 1000,
 | 
			
		||||
  keepOnSplit: false,
 | 
			
		||||
  inclusive() {
 | 
			
		||||
    return true
 | 
			
		||||
  },
 | 
			
		||||
  addProseMirrorPlugins() {
 | 
			
		||||
    return [tagDecoratorPlugin]
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({
 | 
			
		|||
                    styles.postTextLargeContainer,
 | 
			
		||||
                  ]}>
 | 
			
		||||
                  <RichText
 | 
			
		||||
                    enableTags
 | 
			
		||||
                    selectable
 | 
			
		||||
                    value={richText}
 | 
			
		||||
                    style={[a.flex_1, a.text_xl]}
 | 
			
		||||
                    selectable
 | 
			
		||||
                    authorHandle={post.author.handle}
 | 
			
		||||
                  />
 | 
			
		||||
                </View>
 | 
			
		||||
              ) : undefined}
 | 
			
		||||
| 
						 | 
				
			
			@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({
 | 
			
		|||
                {richText?.text ? (
 | 
			
		||||
                  <View style={styles.postTextContainer}>
 | 
			
		||||
                    <RichText
 | 
			
		||||
                      enableTags
 | 
			
		||||
                      value={richText}
 | 
			
		||||
                      style={[a.flex_1, a.text_md]}
 | 
			
		||||
                      numberOfLines={limitLines ? MAX_POST_LINES : undefined}
 | 
			
		||||
                      authorHandle={post.author.handle}
 | 
			
		||||
                    />
 | 
			
		||||
                  </View>
 | 
			
		||||
                ) : undefined}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -184,10 +184,12 @@ function PostInner({
 | 
			
		|||
            {richText.text ? (
 | 
			
		||||
              <View style={styles.postTextContainer}>
 | 
			
		||||
                <RichText
 | 
			
		||||
                  enableTags
 | 
			
		||||
                  testID="postText"
 | 
			
		||||
                  value={richText}
 | 
			
		||||
                  numberOfLines={limitLines ? MAX_POST_LINES : undefined}
 | 
			
		||||
                  style={[a.flex_1, a.text_md]}
 | 
			
		||||
                  authorHandle={post.author.handle}
 | 
			
		||||
                />
 | 
			
		||||
              </View>
 | 
			
		||||
            ) : undefined}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -347,10 +347,12 @@ let PostContent = ({
 | 
			
		|||
      {richText.text ? (
 | 
			
		||||
        <View style={styles.postTextContainer}>
 | 
			
		||||
          <RichText
 | 
			
		||||
            enableTags
 | 
			
		||||
            testID="postText"
 | 
			
		||||
            value={richText}
 | 
			
		||||
            numberOfLines={limitLines ? MAX_POST_LINES : undefined}
 | 
			
		||||
            style={[a.flex_1, a.text_md]}
 | 
			
		||||
            authorHandle={postAuthor.handle}
 | 
			
		||||
          />
 | 
			
		||||
        </View>
 | 
			
		||||
      ) : undefined}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenu.Item
 | 
			
		||||
      className="nativeDropdown-item"
 | 
			
		||||
      {...props}
 | 
			
		||||
      style={StyleSheet.flatten([
 | 
			
		||||
        styles.item,
 | 
			
		||||
| 
						 | 
				
			
			@ -232,6 +233,10 @@ const styles = StyleSheet.create({
 | 
			
		|||
    paddingLeft: 12,
 | 
			
		||||
    paddingRight: 12,
 | 
			
		||||
    borderRadius: 8,
 | 
			
		||||
    fontFamily:
 | 
			
		||||
      '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
 | 
			
		||||
    outline: 0,
 | 
			
		||||
    border: 0,
 | 
			
		||||
  },
 | 
			
		||||
  itemTitle: {
 | 
			
		||||
    fontSize: 16,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
 | 
			
		|||
import {useSession} from '#/state/session'
 | 
			
		||||
import {isWeb} from '#/platform/detection'
 | 
			
		||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
 | 
			
		||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 | 
			
		||||
 | 
			
		||||
let PostDropdownBtn = ({
 | 
			
		||||
  testID,
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +68,7 @@ let PostDropdownBtn = ({
 | 
			
		|||
  const {hidePost} = useHiddenPostsApi()
 | 
			
		||||
  const openLink = useOpenLink()
 | 
			
		||||
  const navigation = useNavigation()
 | 
			
		||||
  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
 | 
			
		||||
 | 
			
		||||
  const rootUri = record.reply?.root?.uri || postUri
 | 
			
		||||
  const isThreadMuted = mutedThreads.includes(rootUri)
 | 
			
		||||
| 
						 | 
				
			
			@ -210,6 +212,20 @@ let PostDropdownBtn = ({
 | 
			
		|||
        web: 'comment-slash',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    hasSession && {
 | 
			
		||||
      label: _(msg`Mute words & tags`),
 | 
			
		||||
      onPress() {
 | 
			
		||||
        mutedWordsDialogControl.open()
 | 
			
		||||
      },
 | 
			
		||||
      testID: 'postDropdownMuteWordsBtn',
 | 
			
		||||
      icon: {
 | 
			
		||||
        ios: {
 | 
			
		||||
          name: 'speaker.slash',
 | 
			
		||||
        },
 | 
			
		||||
        android: 'ic_lock_silent_mode',
 | 
			
		||||
        web: 'filter',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    hasSession &&
 | 
			
		||||
      !isAuthor &&
 | 
			
		||||
      !isPostHidden && {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -128,10 +128,12 @@ export function QuoteEmbed({
 | 
			
		|||
      ) : null}
 | 
			
		||||
      {richText ? (
 | 
			
		||||
        <RichText
 | 
			
		||||
          enableTags
 | 
			
		||||
          value={richText}
 | 
			
		||||
          style={[a.text_md]}
 | 
			
		||||
          numberOfLines={20}
 | 
			
		||||
          disableLinks
 | 
			
		||||
          authorHandle={quote.author.handle}
 | 
			
		||||
        />
 | 
			
		||||
      ) : null}
 | 
			
		||||
      {embed && <PostEmbeds embed={embed} moderation={{}} />}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
 | 
			
		|||
import {toShortUrl} from 'lib/strings/url-helpers'
 | 
			
		||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
 | 
			
		||||
import {usePalette} from 'lib/hooks/usePalette'
 | 
			
		||||
import {makeTagLink} from 'lib/routes/links'
 | 
			
		||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
 | 
			
		||||
import {isNative} from '#/platform/detection'
 | 
			
		||||
 | 
			
		||||
const WORD_WRAP = {wordWrap: 1}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -82,6 +85,7 @@ export function RichText({
 | 
			
		|||
  for (const segment of richText.segments()) {
 | 
			
		||||
    const link = segment.link
 | 
			
		||||
    const mention = segment.mention
 | 
			
		||||
    const tag = segment.tag
 | 
			
		||||
    if (
 | 
			
		||||
      !noLinks &&
 | 
			
		||||
      mention &&
 | 
			
		||||
| 
						 | 
				
			
			@ -115,6 +119,21 @@ export function RichText({
 | 
			
		|||
          />,
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    } else if (
 | 
			
		||||
      !noLinks &&
 | 
			
		||||
      tag &&
 | 
			
		||||
      AppBskyRichtextFacet.validateTag(tag).success
 | 
			
		||||
    ) {
 | 
			
		||||
      els.push(
 | 
			
		||||
        <RichTextTag
 | 
			
		||||
          key={key}
 | 
			
		||||
          text={segment.text}
 | 
			
		||||
          type={type}
 | 
			
		||||
          style={style}
 | 
			
		||||
          lineHeightStyle={lineHeightStyle}
 | 
			
		||||
          selectable={selectable}
 | 
			
		||||
        />,
 | 
			
		||||
      )
 | 
			
		||||
    } else {
 | 
			
		||||
      els.push(segment.text)
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -133,3 +152,50 @@ export function RichText({
 | 
			
		|||
    </Text>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function RichTextTag({
 | 
			
		||||
  text: tag,
 | 
			
		||||
  type,
 | 
			
		||||
  style,
 | 
			
		||||
  lineHeightStyle,
 | 
			
		||||
  selectable,
 | 
			
		||||
}: {
 | 
			
		||||
  text: string
 | 
			
		||||
  type?: TypographyVariant
 | 
			
		||||
  style?: StyleProp<TextStyle>
 | 
			
		||||
  lineHeightStyle?: TextStyle
 | 
			
		||||
  selectable?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const pal = usePalette('default')
 | 
			
		||||
  const control = useTagMenuControl()
 | 
			
		||||
 | 
			
		||||
  const open = React.useCallback(() => {
 | 
			
		||||
    control.open()
 | 
			
		||||
  }, [control])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <React.Fragment>
 | 
			
		||||
      <TagMenu control={control} tag={tag}>
 | 
			
		||||
        {isNative ? (
 | 
			
		||||
          <TextLink
 | 
			
		||||
            type={type}
 | 
			
		||||
            text={tag}
 | 
			
		||||
            // segment.text has the leading "#" while tag.tag does not
 | 
			
		||||
            href={makeTagLink(tag)}
 | 
			
		||||
            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
 | 
			
		||||
            dataSet={WORD_WRAP}
 | 
			
		||||
            selectable={selectable}
 | 
			
		||||
            onPress={open}
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Text
 | 
			
		||||
            selectable={selectable}
 | 
			
		||||
            type={type}
 | 
			
		||||
            style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
 | 
			
		||||
            {tag}
 | 
			
		||||
          </Text>
 | 
			
		||||
        )}
 | 
			
		||||
      </TagMenu>
 | 
			
		||||
    </React.Fragment>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
 | 
			
		|||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
 | 
			
		||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
 | 
			
		||||
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
 | 
			
		||||
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
 | 
			
		||||
 | 
			
		||||
library.add(
 | 
			
		||||
  faAddressCard,
 | 
			
		||||
| 
						 | 
				
			
			@ -208,4 +209,5 @@ library.add(
 | 
			
		|||
  faX,
 | 
			
		||||
  faXmark,
 | 
			
		||||
  faChevronDown,
 | 
			
		||||
  faFilter,
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ import {
 | 
			
		|||
  useProfileUpdateMutation,
 | 
			
		||||
} from '#/state/queries/profile'
 | 
			
		||||
import {ScrollView} from '../com/util/Views'
 | 
			
		||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 | 
			
		||||
 | 
			
		||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
 | 
			
		||||
export function ModerationScreen({}: Props) {
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) {
 | 
			
		|||
  const {screen, track} = useAnalytics()
 | 
			
		||||
  const {isTabletOrDesktop} = useWebMediaQueries()
 | 
			
		||||
  const {openModal} = useModalControls()
 | 
			
		||||
  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
 | 
			
		||||
 | 
			
		||||
  useFocusEffect(
 | 
			
		||||
    React.useCallback(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) {
 | 
			
		|||
          style={[styles.linkCard, pal.view]}
 | 
			
		||||
          onPress={onPressContentFiltering}
 | 
			
		||||
          accessibilityRole="tab"
 | 
			
		||||
          accessibilityHint="Content filtering"
 | 
			
		||||
          accessibilityLabel="">
 | 
			
		||||
          accessibilityHint=""
 | 
			
		||||
          accessibilityLabel={_(msg`Open content filtering settings`)}>
 | 
			
		||||
          <View style={[styles.iconContainer, pal.btn]}>
 | 
			
		||||
            <FontAwesomeIcon
 | 
			
		||||
              icon="eye"
 | 
			
		||||
| 
						 | 
				
			
			@ -81,6 +83,23 @@ export function ModerationScreen({}: Props) {
 | 
			
		|||
            <Trans>Content filtering</Trans>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </TouchableOpacity>
 | 
			
		||||
        <TouchableOpacity
 | 
			
		||||
          testID="mutedWordsBtn"
 | 
			
		||||
          style={[styles.linkCard, pal.view]}
 | 
			
		||||
          onPress={() => mutedWordsDialogControl.open()}
 | 
			
		||||
          accessibilityRole="tab"
 | 
			
		||||
          accessibilityHint=""
 | 
			
		||||
          accessibilityLabel={_(msg`Open muted words settings`)}>
 | 
			
		||||
          <View style={[styles.iconContainer, pal.btn]}>
 | 
			
		||||
            <FontAwesomeIcon
 | 
			
		||||
              icon="filter"
 | 
			
		||||
              style={pal.text as FontAwesomeIconStyle}
 | 
			
		||||
            />
 | 
			
		||||
          </View>
 | 
			
		||||
          <Text type="lg" style={pal.text}>
 | 
			
		||||
            <Trans>Muted words & tags</Trans>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </TouchableOpacity>
 | 
			
		||||
        <Link
 | 
			
		||||
          testID="moderationlistsBtn"
 | 
			
		||||
          style={[styles.linkCard, pal.view]}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,7 @@ import {
 | 
			
		|||
  FontAwesomeIcon,
 | 
			
		||||
  FontAwesomeIconStyle,
 | 
			
		||||
} from '@fortawesome/react-native-fontawesome'
 | 
			
		||||
import {useFocusEffect} from '@react-navigation/native'
 | 
			
		||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
 | 
			
		||||
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
 | 
			
		|||
import {s} from '#/lib/styles'
 | 
			
		||||
import AsyncStorage from '@react-native-async-storage/async-storage'
 | 
			
		||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
 | 
			
		||||
import {NavigationProp} from '#/lib/routes/types'
 | 
			
		||||
 | 
			
		||||
function Loader() {
 | 
			
		||||
  const pal = usePalette('default')
 | 
			
		||||
| 
						 | 
				
			
			@ -448,6 +449,7 @@ export function SearchScreenInner({
 | 
			
		|||
export function SearchScreen(
 | 
			
		||||
  props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
 | 
			
		||||
) {
 | 
			
		||||
  const navigation = useNavigation<NavigationProp>()
 | 
			
		||||
  const theme = useTheme()
 | 
			
		||||
  const textInput = React.useRef<TextInput>(null)
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
| 
						 | 
				
			
			@ -472,6 +474,27 @@ export function SearchScreen(
 | 
			
		|||
    React.useState(false)
 | 
			
		||||
  const [searchHistory, setSearchHistory] = React.useState<string[]>([])
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * The Search screen's `q` param
 | 
			
		||||
   */
 | 
			
		||||
  const queryParam = props.route?.params?.q
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * If `true`, this means we received new instructions from the router. This
 | 
			
		||||
   * is handled in a effect, and used to update the value of `query` locally
 | 
			
		||||
   * within this screen.
 | 
			
		||||
   */
 | 
			
		||||
  const routeParamsMismatch = queryParam && queryParam !== query
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    if (queryParam && routeParamsMismatch) {
 | 
			
		||||
      // reset immediately and let local state take over
 | 
			
		||||
      navigation.setParams({q: ''})
 | 
			
		||||
      // update query for next search
 | 
			
		||||
      setQuery(queryParam)
 | 
			
		||||
    }
 | 
			
		||||
  }, [queryParam, routeParamsMismatch, navigation])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    const loadSearchHistory = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
| 
						 | 
				
			
			@ -774,6 +797,8 @@ export function SearchScreen(
 | 
			
		|||
            )}
 | 
			
		||||
          </View>
 | 
			
		||||
        </CenteredView>
 | 
			
		||||
      ) : routeParamsMismatch ? (
 | 
			
		||||
        <ActivityIndicator />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <SearchScreenInner query={query} />
 | 
			
		||||
      )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
 | 
			
		|||
import {useCloseAnyActiveElement} from '#/state/util'
 | 
			
		||||
import * as notifications from 'lib/notifications/notifications'
 | 
			
		||||
import {Outlet as PortalOutlet} from '#/components/Portal'
 | 
			
		||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 | 
			
		||||
 | 
			
		||||
function ShellInner() {
 | 
			
		||||
  const isDrawerOpen = useIsDrawerOpen()
 | 
			
		||||
| 
						 | 
				
			
			@ -94,6 +95,7 @@ function ShellInner() {
 | 
			
		|||
      </View>
 | 
			
		||||
      <Composer winHeight={winDim.height} />
 | 
			
		||||
      <ModalsContainer />
 | 
			
		||||
      <MutedWordsDialog />
 | 
			
		||||
      <PortalOutlet />
 | 
			
		||||
      <Lightbox />
 | 
			
		||||
    </>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 | 
			
		|||
import {useCloseAllActiveElements} from '#/state/util'
 | 
			
		||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 | 
			
		||||
import {Outlet as PortalOutlet} from '#/components/Portal'
 | 
			
		||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 | 
			
		||||
 | 
			
		||||
function ShellInner() {
 | 
			
		||||
  const isDrawerOpen = useIsDrawerOpen()
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +41,7 @@ function ShellInner() {
 | 
			
		|||
      </ErrorBoundary>
 | 
			
		||||
      <Composer winHeight={0} />
 | 
			
		||||
      <ModalsContainer />
 | 
			
		||||
      <MutedWordsDialog />
 | 
			
		||||
      <PortalOutlet />
 | 
			
		||||
      <Lightbox />
 | 
			
		||||
      {!isDesktop && isDrawerOpen && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -209,6 +209,11 @@
 | 
			
		|||
      [data-tooltip]:hover::before {
 | 
			
		||||
        display:block;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      /* NativeDropdown component */
 | 
			
		||||
      .nativeDropdown-item:focus {
 | 
			
		||||
        outline: none;
 | 
			
		||||
      }
 | 
			
		||||
    </style>
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								yarn.lock
									
										
									
									
									
								
							
							
						
						
									
										14
									
								
								yarn.lock
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -34,6 +34,20 @@
 | 
			
		|||
    jsonpointer "^5.0.0"
 | 
			
		||||
    leven "^3.1.0"
 | 
			
		||||
 | 
			
		||||
"@atproto/api@^0.10.0":
 | 
			
		||||
  version "0.10.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044"
 | 
			
		||||
  integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@atproto/common-web" "^0.2.3"
 | 
			
		||||
    "@atproto/lexicon" "^0.3.1"
 | 
			
		||||
    "@atproto/syntax" "^0.1.5"
 | 
			
		||||
    "@atproto/xrpc" "^0.4.1"
 | 
			
		||||
    multiformats "^9.9.0"
 | 
			
		||||
    tlds "^1.234.0"
 | 
			
		||||
    typed-emitter "^2.1.0"
 | 
			
		||||
    zod "^3.21.4"
 | 
			
		||||
 | 
			
		||||
"@atproto/api@^0.9.5":
 | 
			
		||||
  version "0.9.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue