Bluesky app fork with some witchin' additions 💫

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>

authored by Eric Bailey Kisaragi Hiu and committed by GitHub 58aaad70 c8582924

+1
assets/icons/checkThick_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/clipboard_stroke2_corner2_rounded.svg
··· 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>
+1
assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/mute_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/pageText_stroke2_corner0_rounded.svg
··· 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>
+5
bskyweb/templates/base.html
··· 205 205 [data-tooltip]:hover::before { 206 206 display:block; 207 207 } 208 + 209 + /* NativeDropdown component */ 210 + .nativeDropdown-item:focus { 211 + outline: none; 212 + } 208 213 </style> 209 214 {% include "scripts.html" %} 210 215 <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+1 -1
package.json
··· 43 43 "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" 44 44 }, 45 45 "dependencies": { 46 - "@atproto/api": "^0.9.5", 46 + "@atproto/api": "^0.10.0", 47 47 "@bam.tech/react-native-image-resizer": "^3.0.4", 48 48 "@braintree/sanitize-url": "^6.0.2", 49 49 "@emoji-mart/react": "^1.1.1",
+2 -1
src/Navigation.tsx
··· 497 497 }, 498 498 ]) 499 499 } else { 500 - return buildStateObject('Flat', name, params) 500 + const res = buildStateObject('Flat', name, params) 501 + return res 501 502 } 502 503 }, 503 504 }
+4
src/alf/atoms.ts
··· 1 + import {web, native} from '#/alf/util/platform' 1 2 import * as tokens from '#/alf/tokens' 2 3 3 4 export const atoms = { ··· 112 113 }, 113 114 flex_wrap: { 114 115 flexWrap: 'wrap', 116 + }, 117 + flex_0: { 118 + flex: web('0 0 auto') || (native(0) as number), 115 119 }, 116 120 flex_1: { 117 121 flex: 1,
+1 -1
src/components/Dialog/index.web.tsx
··· 188 188 <Button 189 189 size="small" 190 190 variant="ghost" 191 - color="primary" 191 + color="secondary" 192 192 shape="round" 193 193 onPress={close} 194 194 label={_(msg`Close active dialog`)}>
+102 -1
src/components/RichText.tsx
··· 1 1 import React from 'react' 2 2 import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' 3 + import {useLingui} from '@lingui/react' 4 + import {msg} from '@lingui/macro' 3 5 4 - import {atoms as a, TextStyleProp, flatten} from '#/alf' 6 + import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf' 5 7 import {InlineLink} from '#/components/Link' 6 8 import {Text, TextProps} from '#/components/Typography' 7 9 import {toShortUrl} from 'lib/strings/url-helpers' 8 10 import {getAgent} from '#/state/session' 11 + import {TagMenu, useTagMenuControl} from '#/components/TagMenu' 12 + import {isNative} from '#/platform/detection' 13 + import {useInteractionState} from '#/components/hooks/useInteractionState' 9 14 10 15 const WORD_WRAP = {wordWrap: 1} 11 16 ··· 17 22 disableLinks, 18 23 resolveFacets = false, 19 24 selectable, 25 + enableTags = false, 26 + authorHandle, 20 27 }: TextStyleProp & 21 28 Pick<TextProps, 'selectable'> & { 22 29 value: RichTextAPI | string ··· 24 31 numberOfLines?: number 25 32 disableLinks?: boolean 26 33 resolveFacets?: boolean 34 + enableTags?: boolean 35 + authorHandle?: string 27 36 }) { 28 37 const detected = React.useRef(false) 29 38 const [richText, setRichText] = React.useState<RichTextAPI>(() => ··· 85 94 for (const segment of richText.segments()) { 86 95 const link = segment.link 87 96 const mention = segment.mention 97 + const tag = segment.tag 88 98 if ( 89 99 mention && 90 100 AppBskyRichtextFacet.validateMention(mention).success && ··· 118 128 </InlineLink>, 119 129 ) 120 130 } 131 + } else if ( 132 + !disableLinks && 133 + enableTags && 134 + tag && 135 + AppBskyRichtextFacet.validateTag(tag).success 136 + ) { 137 + els.push( 138 + <RichTextTag 139 + key={key} 140 + text={segment.text} 141 + style={styles} 142 + selectable={selectable} 143 + authorHandle={authorHandle} 144 + />, 145 + ) 121 146 } else { 122 147 els.push(segment.text) 123 148 } ··· 136 161 </Text> 137 162 ) 138 163 } 164 + 165 + function RichTextTag({ 166 + text: tag, 167 + style, 168 + selectable, 169 + authorHandle, 170 + }: { 171 + text: string 172 + selectable?: boolean 173 + authorHandle?: string 174 + } & TextStyleProp) { 175 + const t = useTheme() 176 + const {_} = useLingui() 177 + const control = useTagMenuControl() 178 + const { 179 + state: hovered, 180 + onIn: onHoverIn, 181 + onOut: onHoverOut, 182 + } = useInteractionState() 183 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 184 + const { 185 + state: pressed, 186 + onIn: onPressIn, 187 + onOut: onPressOut, 188 + } = useInteractionState() 189 + 190 + const open = React.useCallback(() => { 191 + control.open() 192 + }, [control]) 193 + 194 + /* 195 + * N.B. On web, this is wrapped in another pressable comopnent with a11y 196 + * labels, etc. That's why only some of these props are applied here. 197 + */ 198 + 199 + return ( 200 + <React.Fragment> 201 + <TagMenu control={control} tag={tag} authorHandle={authorHandle}> 202 + <Text 203 + selectable={selectable} 204 + {...native({ 205 + accessibilityLabel: _(msg`Hashtag: ${tag}`), 206 + accessibilityHint: _(msg`Click here to open tag menu for ${tag}`), 207 + accessibilityRole: isNative ? 'button' : undefined, 208 + onPress: open, 209 + onPressIn: onPressIn, 210 + onPressOut: onPressOut, 211 + })} 212 + {...web({ 213 + onMouseEnter: onHoverIn, 214 + onMouseLeave: onHoverOut, 215 + })} 216 + // @ts-ignore 217 + onFocus={onFocus} 218 + onBlur={onBlur} 219 + style={[ 220 + style, 221 + { 222 + pointerEvents: 'auto', 223 + color: t.palette.primary_500, 224 + }, 225 + web({ 226 + cursor: 'pointer', 227 + }), 228 + (hovered || focused || pressed) && { 229 + ...web({outline: 0}), 230 + textDecorationLine: 'underline', 231 + textDecorationColor: t.palette.primary_500, 232 + }, 233 + ]}> 234 + {tag} 235 + </Text> 236 + </TagMenu> 237 + </React.Fragment> 238 + ) 239 + }
+279
src/components/TagMenu/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useNavigation} from '@react-navigation/native' 4 + import {useLingui} from '@lingui/react' 5 + import {msg, Trans} from '@lingui/macro' 6 + 7 + import {atoms as a, native, useTheme} from '#/alf' 8 + import * as Dialog from '#/components/Dialog' 9 + import {Text} from '#/components/Typography' 10 + import {Button, ButtonText} from '#/components/Button' 11 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 12 + import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 13 + import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 14 + import {Divider} from '#/components/Divider' 15 + import {Link} from '#/components/Link' 16 + import {makeSearchLink} from '#/lib/routes/links' 17 + import {NavigationProp} from '#/lib/routes/types' 18 + import { 19 + usePreferencesQuery, 20 + useUpsertMutedWordsMutation, 21 + useRemoveMutedWordMutation, 22 + } from '#/state/queries/preferences' 23 + import {Loader} from '#/components/Loader' 24 + import {isInvalidHandle} from '#/lib/strings/handles' 25 + 26 + export function useTagMenuControl() { 27 + return Dialog.useDialogControl() 28 + } 29 + 30 + export function TagMenu({ 31 + children, 32 + control, 33 + tag, 34 + authorHandle, 35 + }: React.PropsWithChildren<{ 36 + control: Dialog.DialogOuterProps['control'] 37 + tag: string 38 + authorHandle?: string 39 + }>) { 40 + const {_} = useLingui() 41 + const t = useTheme() 42 + const navigation = useNavigation<NavigationProp>() 43 + const {isLoading: isPreferencesLoading, data: preferences} = 44 + usePreferencesQuery() 45 + const { 46 + mutateAsync: upsertMutedWord, 47 + variables: optimisticUpsert, 48 + reset: resetUpsert, 49 + } = useUpsertMutedWordsMutation() 50 + const { 51 + mutateAsync: removeMutedWord, 52 + variables: optimisticRemove, 53 + reset: resetRemove, 54 + } = useRemoveMutedWordMutation() 55 + 56 + const sanitizedTag = tag.replace(/^#/, '') 57 + const isMuted = Boolean( 58 + (preferences?.mutedWords?.find( 59 + m => m.value === sanitizedTag && m.targets.includes('tag'), 60 + ) ?? 61 + optimisticUpsert?.find( 62 + m => m.value === sanitizedTag && m.targets.includes('tag'), 63 + )) && 64 + !(optimisticRemove?.value === sanitizedTag), 65 + ) 66 + 67 + return ( 68 + <> 69 + {children} 70 + 71 + <Dialog.Outer control={control}> 72 + <Dialog.Handle /> 73 + 74 + <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> 75 + {isPreferencesLoading ? ( 76 + <View style={[a.w_full, a.align_center]}> 77 + <Loader size="lg" /> 78 + </View> 79 + ) : ( 80 + <> 81 + <View 82 + style={[ 83 + a.rounded_md, 84 + a.border, 85 + a.mb_md, 86 + t.atoms.border_contrast_low, 87 + t.atoms.bg_contrast_25, 88 + ]}> 89 + <Link 90 + label={_(msg`Search for all posts with tag ${tag}`)} 91 + to={makeSearchLink({query: tag})} 92 + onPress={e => { 93 + e.preventDefault() 94 + 95 + control.close(() => { 96 + // @ts-ignore :ron_swanson: "I know more than you" 97 + navigation.navigate('SearchTab', { 98 + screen: 'Search', 99 + params: { 100 + q: tag, 101 + }, 102 + }) 103 + }) 104 + 105 + return false 106 + }}> 107 + <View 108 + style={[ 109 + a.w_full, 110 + a.flex_row, 111 + a.align_center, 112 + a.justify_start, 113 + a.gap_md, 114 + a.px_lg, 115 + a.py_md, 116 + ]}> 117 + <Search size="lg" style={[t.atoms.text_contrast_medium]} /> 118 + <Text 119 + numberOfLines={1} 120 + ellipsizeMode="middle" 121 + style={[ 122 + a.flex_1, 123 + a.text_md, 124 + a.font_bold, 125 + native({top: 2}), 126 + t.atoms.text_contrast_medium, 127 + ]}> 128 + <Trans> 129 + See{' '} 130 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 131 + {tag} 132 + </Text>{' '} 133 + posts 134 + </Trans> 135 + </Text> 136 + </View> 137 + </Link> 138 + 139 + {authorHandle && !isInvalidHandle(authorHandle) && ( 140 + <> 141 + <Divider /> 142 + 143 + <Link 144 + label={_( 145 + msg`Search for all posts by @${authorHandle} with tag ${tag}`, 146 + )} 147 + to={makeSearchLink({query: tag, from: authorHandle})} 148 + onPress={e => { 149 + e.preventDefault() 150 + 151 + control.close(() => { 152 + // @ts-ignore :ron_swanson: "I know more than you" 153 + navigation.navigate('SearchTab', { 154 + screen: 'Search', 155 + params: { 156 + q: 157 + tag + 158 + (authorHandle ? ` from:${authorHandle}` : ''), 159 + }, 160 + }) 161 + }) 162 + 163 + return false 164 + }}> 165 + <View 166 + style={[ 167 + a.w_full, 168 + a.flex_row, 169 + a.align_center, 170 + a.justify_start, 171 + a.gap_md, 172 + a.px_lg, 173 + a.py_md, 174 + ]}> 175 + <Person 176 + size="lg" 177 + style={[t.atoms.text_contrast_medium]} 178 + /> 179 + <Text 180 + numberOfLines={1} 181 + ellipsizeMode="middle" 182 + style={[ 183 + a.flex_1, 184 + a.text_md, 185 + a.font_bold, 186 + native({top: 2}), 187 + t.atoms.text_contrast_medium, 188 + ]}> 189 + <Trans> 190 + See{' '} 191 + <Text 192 + style={[a.text_md, a.font_bold, t.atoms.text]}> 193 + {tag} 194 + </Text>{' '} 195 + posts by this user 196 + </Trans> 197 + </Text> 198 + </View> 199 + </Link> 200 + </> 201 + )} 202 + 203 + {preferences ? ( 204 + <> 205 + <Divider /> 206 + 207 + <Button 208 + label={ 209 + isMuted 210 + ? _(msg`Unmute all ${tag} posts`) 211 + : _(msg`Mute all ${tag} posts`) 212 + } 213 + onPress={() => { 214 + control.close(() => { 215 + if (isMuted) { 216 + resetUpsert() 217 + removeMutedWord({ 218 + value: sanitizedTag, 219 + targets: ['tag'], 220 + }) 221 + } else { 222 + resetRemove() 223 + upsertMutedWord([ 224 + {value: sanitizedTag, targets: ['tag']}, 225 + ]) 226 + } 227 + }) 228 + }}> 229 + <View 230 + style={[ 231 + a.w_full, 232 + a.flex_row, 233 + a.align_center, 234 + a.justify_start, 235 + a.gap_md, 236 + a.px_lg, 237 + a.py_md, 238 + ]}> 239 + <Mute 240 + size="lg" 241 + style={[t.atoms.text_contrast_medium]} 242 + /> 243 + <Text 244 + numberOfLines={1} 245 + ellipsizeMode="middle" 246 + style={[ 247 + a.flex_1, 248 + a.text_md, 249 + a.font_bold, 250 + native({top: 2}), 251 + t.atoms.text_contrast_medium, 252 + ]}> 253 + {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} 254 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 255 + {tag} 256 + </Text>{' '} 257 + <Trans>posts</Trans> 258 + </Text> 259 + </View> 260 + </Button> 261 + </> 262 + ) : null} 263 + </View> 264 + 265 + <Button 266 + label={_(msg`Close this dialog`)} 267 + size="small" 268 + variant="ghost" 269 + color="secondary" 270 + onPress={() => control.close()}> 271 + <ButtonText>Cancel</ButtonText> 272 + </Button> 273 + </> 274 + )} 275 + </Dialog.Inner> 276 + </Dialog.Outer> 277 + </> 278 + ) 279 + }
+127
src/components/TagMenu/index.web.tsx
··· 1 + import React from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + import {useNavigation} from '@react-navigation/native' 5 + 6 + import {isInvalidHandle} from '#/lib/strings/handles' 7 + import {EventStopper} from '#/view/com/util/EventStopper' 8 + import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' 9 + import {NavigationProp} from '#/lib/routes/types' 10 + import { 11 + usePreferencesQuery, 12 + useUpsertMutedWordsMutation, 13 + useRemoveMutedWordMutation, 14 + } from '#/state/queries/preferences' 15 + 16 + export function useTagMenuControl() {} 17 + 18 + export function TagMenu({ 19 + children, 20 + tag, 21 + authorHandle, 22 + }: React.PropsWithChildren<{ 23 + tag: string 24 + authorHandle?: string 25 + }>) { 26 + const sanitizedTag = tag.replace(/^#/, '') 27 + const {_} = useLingui() 28 + const navigation = useNavigation<NavigationProp>() 29 + const {data: preferences} = usePreferencesQuery() 30 + const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = 31 + useUpsertMutedWordsMutation() 32 + const {mutateAsync: removeMutedWord, variables: optimisticRemove} = 33 + useRemoveMutedWordMutation() 34 + const isMuted = Boolean( 35 + (preferences?.mutedWords?.find( 36 + m => m.value === sanitizedTag && m.targets.includes('tag'), 37 + ) ?? 38 + optimisticUpsert?.find( 39 + m => m.value === sanitizedTag && m.targets.includes('tag'), 40 + )) && 41 + !(optimisticRemove?.value === sanitizedTag), 42 + ) 43 + 44 + const dropdownItems = React.useMemo(() => { 45 + return [ 46 + { 47 + label: _(msg`See ${tag} posts`), 48 + onPress() { 49 + navigation.navigate('Search', { 50 + q: tag, 51 + }) 52 + }, 53 + testID: 'tagMenuSearch', 54 + icon: { 55 + ios: { 56 + name: 'magnifyingglass', 57 + }, 58 + android: '', 59 + web: 'magnifying-glass', 60 + }, 61 + }, 62 + authorHandle && 63 + !isInvalidHandle(authorHandle) && { 64 + label: _(msg`See ${tag} posts by this user`), 65 + onPress() { 66 + navigation.navigate({ 67 + name: 'Search', 68 + params: { 69 + q: tag + (authorHandle ? ` from:${authorHandle}` : ''), 70 + }, 71 + }) 72 + }, 73 + testID: 'tagMenuSeachByUser', 74 + icon: { 75 + ios: { 76 + name: 'magnifyingglass', 77 + }, 78 + android: '', 79 + web: ['far', 'user'], 80 + }, 81 + }, 82 + preferences && { 83 + label: 'separator', 84 + }, 85 + preferences && { 86 + label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`), 87 + onPress() { 88 + if (isMuted) { 89 + removeMutedWord({value: sanitizedTag, targets: ['tag']}) 90 + } else { 91 + upsertMutedWord([{value: sanitizedTag, targets: ['tag']}]) 92 + } 93 + }, 94 + testID: 'tagMenuMute', 95 + icon: { 96 + ios: { 97 + name: 'speaker.slash', 98 + }, 99 + android: 'ic_menu_sort_alphabetically', 100 + web: isMuted ? 'eye' : ['far', 'eye-slash'], 101 + }, 102 + }, 103 + ].filter(Boolean) 104 + }, [ 105 + _, 106 + authorHandle, 107 + isMuted, 108 + navigation, 109 + preferences, 110 + tag, 111 + sanitizedTag, 112 + upsertMutedWord, 113 + removeMutedWord, 114 + ]) 115 + 116 + return ( 117 + <EventStopper> 118 + <NativeDropdown 119 + accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)} 120 + accessibilityHint="" 121 + // @ts-ignore 122 + items={dropdownItems}> 123 + {children} 124 + </NativeDropdown> 125 + </EventStopper> 126 + ) 127 + }
+29
src/components/dialogs/Context.tsx
··· 1 + import React from 'react' 2 + 3 + import * as Dialog from '#/components/Dialog' 4 + 5 + type Control = Dialog.DialogOuterProps['control'] 6 + 7 + type ControlsContext = { 8 + mutedWordsDialogControl: Control 9 + } 10 + 11 + const ControlsContext = React.createContext({ 12 + mutedWordsDialogControl: {} as Control, 13 + }) 14 + 15 + export function useGlobalDialogsControlContext() { 16 + return React.useContext(ControlsContext) 17 + } 18 + 19 + export function Provider({children}: React.PropsWithChildren<{}>) { 20 + const mutedWordsDialogControl = Dialog.useDialogControl() 21 + const ctx = React.useMemo( 22 + () => ({mutedWordsDialogControl}), 23 + [mutedWordsDialogControl], 24 + ) 25 + 26 + return ( 27 + <ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider> 28 + ) 29 + }
+328
src/components/dialogs/MutedWords.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {AppBskyActorDefs} from '@atproto/api' 6 + 7 + import { 8 + usePreferencesQuery, 9 + useUpsertMutedWordsMutation, 10 + useRemoveMutedWordMutation, 11 + } from '#/state/queries/preferences' 12 + import {isNative} from '#/platform/detection' 13 + import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf' 14 + import {Text} from '#/components/Typography' 15 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 16 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 17 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 18 + import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 19 + import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' 20 + import {Divider} from '#/components/Divider' 21 + import {Loader} from '#/components/Loader' 22 + import {logger} from '#/logger' 23 + import * as Dialog from '#/components/Dialog' 24 + import * as Toggle from '#/components/forms/Toggle' 25 + import * as Prompt from '#/components/Prompt' 26 + 27 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 28 + 29 + export function MutedWordsDialog() { 30 + const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() 31 + return ( 32 + <Dialog.Outer control={control}> 33 + <Dialog.Handle /> 34 + <MutedWordsInner control={control} /> 35 + </Dialog.Outer> 36 + ) 37 + } 38 + 39 + function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { 40 + const t = useTheme() 41 + const {_} = useLingui() 42 + const {gtMobile} = useBreakpoints() 43 + const { 44 + isLoading: isPreferencesLoading, 45 + data: preferences, 46 + error: preferencesError, 47 + } = usePreferencesQuery() 48 + const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() 49 + const [field, setField] = React.useState('') 50 + const [options, setOptions] = React.useState(['content']) 51 + const [_error, setError] = React.useState('') 52 + 53 + const submit = React.useCallback(async () => { 54 + const value = field.trim() 55 + const targets = ['tag', options.includes('content') && 'content'].filter( 56 + Boolean, 57 + ) as AppBskyActorDefs.MutedWord['targets'] 58 + 59 + if (!value || !targets.length) return 60 + 61 + try { 62 + await addMutedWord([{value, targets}]) 63 + setField('') 64 + } catch (e: any) { 65 + logger.error(`Failed to save muted word`, {message: e.message}) 66 + setError(e.message) 67 + } 68 + }, [field, options, addMutedWord, setField]) 69 + 70 + return ( 71 + <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> 72 + <Text 73 + style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> 74 + <Trans>Add muted words and tags</Trans> 75 + </Text> 76 + <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}> 77 + <Trans> 78 + Posts can be muted based on their text, their tags, or both. 79 + </Trans> 80 + </Text> 81 + 82 + <View style={[a.pb_lg]}> 83 + <Dialog.Input 84 + autoCorrect={false} 85 + autoCapitalize="none" 86 + autoComplete="off" 87 + label={_(msg`Enter a word or tag`)} 88 + placeholder={_(msg`Enter a word or tag`)} 89 + value={field} 90 + onChangeText={setField} 91 + onSubmitEditing={submit} 92 + /> 93 + 94 + <Toggle.Group 95 + label={_(msg`Toggle between muted word options.`)} 96 + type="radio" 97 + values={options} 98 + onChange={setOptions}> 99 + <View 100 + style={[ 101 + a.pt_sm, 102 + a.pb_md, 103 + a.flex_row, 104 + a.align_center, 105 + a.gap_sm, 106 + a.flex_wrap, 107 + ]}> 108 + <Toggle.Item 109 + label={_(msg`Mute this word in post text and tags`)} 110 + name="content" 111 + style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> 112 + <TargetToggle> 113 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 114 + <Toggle.Radio /> 115 + <Toggle.Label> 116 + <Trans>Mute in text & tags</Trans> 117 + </Toggle.Label> 118 + </View> 119 + <PageText size="sm" /> 120 + </TargetToggle> 121 + </Toggle.Item> 122 + 123 + <Toggle.Item 124 + label={_(msg`Mute this word in tags only`)} 125 + name="tag" 126 + style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> 127 + <TargetToggle> 128 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 129 + <Toggle.Radio /> 130 + <Toggle.Label> 131 + <Trans>Mute in tags only</Trans> 132 + </Toggle.Label> 133 + </View> 134 + <Hashtag size="sm" /> 135 + </TargetToggle> 136 + </Toggle.Item> 137 + 138 + <Button 139 + disabled={isPending || !field} 140 + label={_(msg`Add mute word for configured settings`)} 141 + size="small" 142 + color="primary" 143 + variant="solid" 144 + style={[!gtMobile && [a.w_full, a.flex_0]]} 145 + onPress={submit}> 146 + <ButtonText> 147 + <Trans>Add</Trans> 148 + </ButtonText> 149 + <ButtonIcon icon={isPending ? Loader : Plus} /> 150 + </Button> 151 + </View> 152 + </Toggle.Group> 153 + 154 + <Text 155 + style={[ 156 + a.text_sm, 157 + a.italic, 158 + a.leading_snug, 159 + t.atoms.text_contrast_medium, 160 + ]}> 161 + <Trans> 162 + We recommend avoiding common words that appear in many posts, since 163 + it can result in no posts being shown. 164 + </Trans> 165 + </Text> 166 + </View> 167 + 168 + <Divider /> 169 + 170 + <View style={[a.pt_2xl]}> 171 + <Text 172 + style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> 173 + <Trans>Your muted words</Trans> 174 + </Text> 175 + 176 + {isPreferencesLoading ? ( 177 + <Loader /> 178 + ) : preferencesError || !preferences ? ( 179 + <View 180 + style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> 181 + <Text style={[a.italic, t.atoms.text_contrast_high]}> 182 + <Trans> 183 + We're sorry, but we weren't able to load your muted words at 184 + this time. Please try again. 185 + </Trans> 186 + </Text> 187 + </View> 188 + ) : preferences.mutedWords.length ? ( 189 + [...preferences.mutedWords] 190 + .reverse() 191 + .map((word, i) => ( 192 + <MutedWordRow 193 + key={word.value + i} 194 + word={word} 195 + style={[i % 2 === 0 && t.atoms.bg_contrast_25]} 196 + /> 197 + )) 198 + ) : ( 199 + <View 200 + style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> 201 + <Text style={[a.italic, t.atoms.text_contrast_high]}> 202 + <Trans>You haven't muted any words or tags yet</Trans> 203 + </Text> 204 + </View> 205 + )} 206 + </View> 207 + 208 + {isNative && <View style={{height: 20}} />} 209 + 210 + <Dialog.Close /> 211 + </Dialog.ScrollableInner> 212 + ) 213 + } 214 + 215 + function MutedWordRow({ 216 + style, 217 + word, 218 + }: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { 219 + const t = useTheme() 220 + const {_} = useLingui() 221 + const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() 222 + const control = Prompt.usePromptControl() 223 + 224 + const remove = React.useCallback(async () => { 225 + control.close() 226 + removeMutedWord(word) 227 + }, [removeMutedWord, word, control]) 228 + 229 + return ( 230 + <> 231 + <Prompt.Outer control={control}> 232 + <Prompt.Title> 233 + <Trans>Are you sure?</Trans> 234 + </Prompt.Title> 235 + <Prompt.Description> 236 + <Trans> 237 + This will delete {word.value} from your muted words. You can always 238 + add it back later. 239 + </Trans> 240 + </Prompt.Description> 241 + <Prompt.Actions> 242 + <Prompt.Cancel> 243 + <ButtonText> 244 + <Trans>Nevermind</Trans> 245 + </ButtonText> 246 + </Prompt.Cancel> 247 + <Prompt.Action onPress={remove}> 248 + <ButtonText> 249 + <Trans>Remove</Trans> 250 + </ButtonText> 251 + </Prompt.Action> 252 + </Prompt.Actions> 253 + </Prompt.Outer> 254 + 255 + <View 256 + style={[ 257 + a.py_md, 258 + a.px_lg, 259 + a.flex_row, 260 + a.align_center, 261 + a.justify_between, 262 + a.rounded_md, 263 + style, 264 + ]}> 265 + <Text style={[a.font_bold, t.atoms.text_contrast_high]}> 266 + {word.value} 267 + </Text> 268 + 269 + <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}> 270 + {word.targets.map(target => ( 271 + <View 272 + key={target} 273 + style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}> 274 + <Text 275 + style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}> 276 + {target === 'content' ? _(msg`text`) : _(msg`tag`)} 277 + </Text> 278 + </View> 279 + ))} 280 + 281 + <Button 282 + label={_(msg`Remove mute word from your list`)} 283 + size="tiny" 284 + shape="round" 285 + variant="ghost" 286 + color="secondary" 287 + onPress={() => control.open()} 288 + style={[a.ml_sm]}> 289 + <ButtonIcon icon={isPending ? Loader : X} /> 290 + </Button> 291 + </View> 292 + </View> 293 + </> 294 + ) 295 + } 296 + 297 + function TargetToggle({children}: React.PropsWithChildren<{}>) { 298 + const t = useTheme() 299 + const ctx = Toggle.useItemContext() 300 + const {gtMobile} = useBreakpoints() 301 + return ( 302 + <View 303 + style={[ 304 + a.flex_row, 305 + a.align_center, 306 + a.justify_between, 307 + a.gap_xs, 308 + a.flex_1, 309 + a.py_sm, 310 + a.px_sm, 311 + gtMobile && a.px_md, 312 + a.rounded_sm, 313 + t.atoms.bg_contrast_50, 314 + (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100, 315 + ctx.selected && [ 316 + { 317 + backgroundColor: 318 + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975, 319 + }, 320 + ], 321 + ctx.disabled && { 322 + opacity: 0.8, 323 + }, 324 + ]}> 325 + {children} 326 + </View> 327 + ) 328 + }
+1 -1
src/components/forms/TextField.tsx
··· 72 72 return ( 73 73 <Context.Provider value={context}> 74 74 <View 75 - style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]} 75 + style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]} 76 76 {...web({ 77 77 onClick: () => inputRef.current?.focus(), 78 78 onMouseOver: onHoverIn,
+10 -24
src/components/forms/Toggle.tsx
··· 5 5 import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' 6 6 import {Text} from '#/components/Typography' 7 7 import {useInteractionState} from '#/components/hooks/useInteractionState' 8 + import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' 8 9 9 10 export type ItemState = { 10 11 name: string ··· 331 332 export function Checkbox() { 332 333 const t = useTheme() 333 334 const {selected, hovered, focused, disabled, isInvalid} = useItemContext() 334 - const {baseStyles, baseHoverStyles, indicatorStyles} = 335 - createSharedToggleStyles({ 336 - theme: t, 337 - hovered, 338 - focused, 339 - selected, 340 - disabled, 341 - isInvalid, 342 - }) 335 + const {baseStyles, baseHoverStyles} = createSharedToggleStyles({ 336 + theme: t, 337 + hovered, 338 + focused, 339 + selected, 340 + disabled, 341 + isInvalid, 342 + }) 343 343 return ( 344 344 <View 345 345 style={[ ··· 355 355 baseStyles, 356 356 hovered || focused ? baseHoverStyles : {}, 357 357 ]}> 358 - {selected ? ( 359 - <View 360 - style={[ 361 - a.absolute, 362 - a.rounded_2xs, 363 - {height: 12, width: 12}, 364 - selected 365 - ? { 366 - backgroundColor: t.palette.primary_500, 367 - } 368 - : {}, 369 - indicatorStyles, 370 - ]} 371 - /> 372 - ) : null} 358 + {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} 373 359 </View> 374 360 ) 375 361 }
+4
src/components/icons/Check.tsx
··· 3 3 export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 4 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', 5 5 }) 6 + 7 + export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
+5
src/components/icons/Clipboard.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+1 -1
src/components/icons/Group3.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 3 export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 - 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', 4 + 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 5 })
+5
src/components/icons/MagnifyingGlass2.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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 + })
+5
src/components/icons/Mute.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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 + })
+5
src/components/icons/PageText.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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 + })
+5
src/components/icons/Person.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+578
src/lib/__tests__/moderatePost_wrapped.test.ts
··· 1 + import {describe, it, expect} from '@jest/globals' 2 + import {RichText} from '@atproto/api' 3 + 4 + import {hasMutedWord} from '../moderatePost_wrapped' 5 + 6 + describe(`hasMutedWord`, () => { 7 + describe(`tags`, () => { 8 + it(`match: outline tag`, () => { 9 + const rt = new RichText({ 10 + text: `This is a post #inlineTag`, 11 + }) 12 + rt.detectFacetsWithoutResolution() 13 + 14 + const match = hasMutedWord( 15 + [{value: 'outlineTag', targets: ['tag']}], 16 + rt.text, 17 + rt.facets, 18 + ['outlineTag'], 19 + ) 20 + 21 + expect(match).toBe(true) 22 + }) 23 + 24 + it(`match: inline tag`, () => { 25 + const rt = new RichText({ 26 + text: `This is a post #inlineTag`, 27 + }) 28 + rt.detectFacetsWithoutResolution() 29 + 30 + const match = hasMutedWord( 31 + [{value: 'inlineTag', targets: ['tag']}], 32 + rt.text, 33 + rt.facets, 34 + ['outlineTag'], 35 + ) 36 + 37 + expect(match).toBe(true) 38 + }) 39 + 40 + it(`match: content target matches inline tag`, () => { 41 + const rt = new RichText({ 42 + text: `This is a post #inlineTag`, 43 + }) 44 + rt.detectFacetsWithoutResolution() 45 + 46 + const match = hasMutedWord( 47 + [{value: 'inlineTag', targets: ['content']}], 48 + rt.text, 49 + rt.facets, 50 + ['outlineTag'], 51 + ) 52 + 53 + expect(match).toBe(true) 54 + }) 55 + 56 + it(`no match: only tag targets`, () => { 57 + const rt = new RichText({ 58 + text: `This is a post`, 59 + }) 60 + rt.detectFacetsWithoutResolution() 61 + 62 + const match = hasMutedWord( 63 + [{value: 'inlineTag', targets: ['tag']}], 64 + rt.text, 65 + rt.facets, 66 + [], 67 + ) 68 + 69 + expect(match).toBe(false) 70 + }) 71 + }) 72 + 73 + describe(`early exits`, () => { 74 + it(`match: single character 希`, () => { 75 + /** 76 + * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c 77 + */ 78 + const rt = new RichText({ 79 + text: `改善希望です`, 80 + }) 81 + rt.detectFacetsWithoutResolution() 82 + 83 + const match = hasMutedWord( 84 + [{value: '希', targets: ['content']}], 85 + rt.text, 86 + rt.facets, 87 + [], 88 + ) 89 + 90 + expect(match).toBe(true) 91 + }) 92 + 93 + it(`no match: long muted word, short post`, () => { 94 + const rt = new RichText({ 95 + text: `hey`, 96 + }) 97 + rt.detectFacetsWithoutResolution() 98 + 99 + const match = hasMutedWord( 100 + [{value: 'politics', targets: ['content']}], 101 + rt.text, 102 + rt.facets, 103 + [], 104 + ) 105 + 106 + expect(match).toBe(false) 107 + }) 108 + 109 + it(`match: exact text`, () => { 110 + const rt = new RichText({ 111 + text: `javascript`, 112 + }) 113 + rt.detectFacetsWithoutResolution() 114 + 115 + const match = hasMutedWord( 116 + [{value: 'javascript', targets: ['content']}], 117 + rt.text, 118 + rt.facets, 119 + [], 120 + ) 121 + 122 + expect(match).toBe(true) 123 + }) 124 + }) 125 + 126 + describe(`general content`, () => { 127 + it(`match: word within post`, () => { 128 + const rt = new RichText({ 129 + text: `This is a post about javascript`, 130 + }) 131 + rt.detectFacetsWithoutResolution() 132 + 133 + const match = hasMutedWord( 134 + [{value: 'javascript', targets: ['content']}], 135 + rt.text, 136 + rt.facets, 137 + [], 138 + ) 139 + 140 + expect(match).toBe(true) 141 + }) 142 + 143 + it(`no match: partial word`, () => { 144 + const rt = new RichText({ 145 + text: `Use your brain, Eric`, 146 + }) 147 + rt.detectFacetsWithoutResolution() 148 + 149 + const match = hasMutedWord( 150 + [{value: 'ai', targets: ['content']}], 151 + rt.text, 152 + rt.facets, 153 + [], 154 + ) 155 + 156 + expect(match).toBe(false) 157 + }) 158 + 159 + it(`match: multiline`, () => { 160 + const rt = new RichText({ 161 + text: `Use your\n\tbrain, Eric`, 162 + }) 163 + rt.detectFacetsWithoutResolution() 164 + 165 + const match = hasMutedWord( 166 + [{value: 'brain', targets: ['content']}], 167 + rt.text, 168 + rt.facets, 169 + [], 170 + ) 171 + 172 + expect(match).toBe(true) 173 + }) 174 + 175 + it(`match: :)`, () => { 176 + const rt = new RichText({ 177 + text: `So happy :)`, 178 + }) 179 + rt.detectFacetsWithoutResolution() 180 + 181 + const match = hasMutedWord( 182 + [{value: `:)`, targets: ['content']}], 183 + rt.text, 184 + rt.facets, 185 + [], 186 + ) 187 + 188 + expect(match).toBe(true) 189 + }) 190 + }) 191 + 192 + describe(`punctuation semi-fuzzy`, () => { 193 + describe(`yay!`, () => { 194 + const rt = new RichText({ 195 + text: `We're federating, yay!`, 196 + }) 197 + rt.detectFacetsWithoutResolution() 198 + 199 + it(`match: yay!`, () => { 200 + const match = hasMutedWord( 201 + [{value: 'yay!', targets: ['content']}], 202 + rt.text, 203 + rt.facets, 204 + [], 205 + ) 206 + 207 + expect(match).toBe(true) 208 + }) 209 + 210 + it(`match: yay`, () => { 211 + const match = hasMutedWord( 212 + [{value: 'yay', targets: ['content']}], 213 + rt.text, 214 + rt.facets, 215 + [], 216 + ) 217 + 218 + expect(match).toBe(true) 219 + }) 220 + }) 221 + 222 + describe(`y!ppee!!`, () => { 223 + const rt = new RichText({ 224 + text: `We're federating, y!ppee!!`, 225 + }) 226 + rt.detectFacetsWithoutResolution() 227 + 228 + it(`match: y!ppee`, () => { 229 + const match = hasMutedWord( 230 + [{value: 'y!ppee', targets: ['content']}], 231 + rt.text, 232 + rt.facets, 233 + [], 234 + ) 235 + 236 + expect(match).toBe(true) 237 + }) 238 + 239 + // single exclamation point, source has double 240 + it(`no match: y!ppee!`, () => { 241 + const match = hasMutedWord( 242 + [{value: 'y!ppee!', targets: ['content']}], 243 + rt.text, 244 + rt.facets, 245 + [], 246 + ) 247 + 248 + expect(match).toBe(true) 249 + }) 250 + }) 251 + 252 + describe(`Why so S@assy?`, () => { 253 + const rt = new RichText({ 254 + text: `Why so S@assy?`, 255 + }) 256 + rt.detectFacetsWithoutResolution() 257 + 258 + it(`match: S@assy`, () => { 259 + const match = hasMutedWord( 260 + [{value: 'S@assy', targets: ['content']}], 261 + rt.text, 262 + rt.facets, 263 + [], 264 + ) 265 + 266 + expect(match).toBe(true) 267 + }) 268 + 269 + it(`match: s@assy`, () => { 270 + const match = hasMutedWord( 271 + [{value: 's@assy', targets: ['content']}], 272 + rt.text, 273 + rt.facets, 274 + [], 275 + ) 276 + 277 + expect(match).toBe(true) 278 + }) 279 + }) 280 + 281 + describe(`New York Times`, () => { 282 + const rt = new RichText({ 283 + text: `New York Times`, 284 + }) 285 + rt.detectFacetsWithoutResolution() 286 + 287 + // case insensitive 288 + it(`match: new york times`, () => { 289 + const match = hasMutedWord( 290 + [{value: 'new york times', targets: ['content']}], 291 + rt.text, 292 + rt.facets, 293 + [], 294 + ) 295 + 296 + expect(match).toBe(true) 297 + }) 298 + }) 299 + 300 + describe(`!command`, () => { 301 + const rt = new RichText({ 302 + text: `Idk maybe a bot !command`, 303 + }) 304 + rt.detectFacetsWithoutResolution() 305 + 306 + it(`match: !command`, () => { 307 + const match = hasMutedWord( 308 + [{value: `!command`, targets: ['content']}], 309 + rt.text, 310 + rt.facets, 311 + [], 312 + ) 313 + 314 + expect(match).toBe(true) 315 + }) 316 + 317 + it(`match: command`, () => { 318 + const match = hasMutedWord( 319 + [{value: `command`, targets: ['content']}], 320 + rt.text, 321 + rt.facets, 322 + [], 323 + ) 324 + 325 + expect(match).toBe(true) 326 + }) 327 + 328 + it(`no match: !command`, () => { 329 + const rt = new RichText({ 330 + text: `Idk maybe a bot command`, 331 + }) 332 + rt.detectFacetsWithoutResolution() 333 + 334 + const match = hasMutedWord( 335 + [{value: `!command`, targets: ['content']}], 336 + rt.text, 337 + rt.facets, 338 + [], 339 + ) 340 + 341 + expect(match).toBe(false) 342 + }) 343 + }) 344 + 345 + describe(`e/acc`, () => { 346 + const rt = new RichText({ 347 + text: `I'm e/acc pilled`, 348 + }) 349 + rt.detectFacetsWithoutResolution() 350 + 351 + it(`match: e/acc`, () => { 352 + const match = hasMutedWord( 353 + [{value: `e/acc`, targets: ['content']}], 354 + rt.text, 355 + rt.facets, 356 + [], 357 + ) 358 + 359 + expect(match).toBe(true) 360 + }) 361 + 362 + it(`match: acc`, () => { 363 + const match = hasMutedWord( 364 + [{value: `acc`, targets: ['content']}], 365 + rt.text, 366 + rt.facets, 367 + [], 368 + ) 369 + 370 + expect(match).toBe(true) 371 + }) 372 + }) 373 + 374 + describe(`super-bad`, () => { 375 + const rt = new RichText({ 376 + text: `I'm super-bad`, 377 + }) 378 + rt.detectFacetsWithoutResolution() 379 + 380 + it(`match: super-bad`, () => { 381 + const match = hasMutedWord( 382 + [{value: `super-bad`, targets: ['content']}], 383 + rt.text, 384 + rt.facets, 385 + [], 386 + ) 387 + 388 + expect(match).toBe(true) 389 + }) 390 + 391 + it(`match: super`, () => { 392 + const match = hasMutedWord( 393 + [{value: `super`, targets: ['content']}], 394 + rt.text, 395 + rt.facets, 396 + [], 397 + ) 398 + 399 + expect(match).toBe(true) 400 + }) 401 + 402 + it(`match: super bad`, () => { 403 + const match = hasMutedWord( 404 + [{value: `super bad`, targets: ['content']}], 405 + rt.text, 406 + rt.facets, 407 + [], 408 + ) 409 + 410 + expect(match).toBe(true) 411 + }) 412 + 413 + it(`match: superbad`, () => { 414 + const match = hasMutedWord( 415 + [{value: `superbad`, targets: ['content']}], 416 + rt.text, 417 + rt.facets, 418 + [], 419 + ) 420 + 421 + expect(match).toBe(false) 422 + }) 423 + }) 424 + 425 + describe(`idk_what_this_would_be`, () => { 426 + const rt = new RichText({ 427 + text: `Weird post with idk_what_this_would_be`, 428 + }) 429 + rt.detectFacetsWithoutResolution() 430 + 431 + it(`match: idk what this would be`, () => { 432 + const match = hasMutedWord( 433 + [{value: `idk what this would be`, targets: ['content']}], 434 + rt.text, 435 + rt.facets, 436 + [], 437 + ) 438 + 439 + expect(match).toBe(true) 440 + }) 441 + 442 + it(`no match: idk what this would be for`, () => { 443 + // extra word 444 + const match = hasMutedWord( 445 + [{value: `idk what this would be for`, targets: ['content']}], 446 + rt.text, 447 + rt.facets, 448 + [], 449 + ) 450 + 451 + expect(match).toBe(false) 452 + }) 453 + 454 + it(`match: idk`, () => { 455 + // extra word 456 + const match = hasMutedWord( 457 + [{value: `idk`, targets: ['content']}], 458 + rt.text, 459 + rt.facets, 460 + [], 461 + ) 462 + 463 + expect(match).toBe(true) 464 + }) 465 + 466 + it(`match: idkwhatthiswouldbe`, () => { 467 + const match = hasMutedWord( 468 + [{value: `idkwhatthiswouldbe`, targets: ['content']}], 469 + rt.text, 470 + rt.facets, 471 + [], 472 + ) 473 + 474 + expect(match).toBe(false) 475 + }) 476 + }) 477 + 478 + describe(`parentheses`, () => { 479 + const rt = new RichText({ 480 + text: `Post with context(iykyk)`, 481 + }) 482 + rt.detectFacetsWithoutResolution() 483 + 484 + it(`match: context(iykyk)`, () => { 485 + const match = hasMutedWord( 486 + [{value: `context(iykyk)`, targets: ['content']}], 487 + rt.text, 488 + rt.facets, 489 + [], 490 + ) 491 + 492 + expect(match).toBe(true) 493 + }) 494 + 495 + it(`match: context`, () => { 496 + const match = hasMutedWord( 497 + [{value: `context`, targets: ['content']}], 498 + rt.text, 499 + rt.facets, 500 + [], 501 + ) 502 + 503 + expect(match).toBe(true) 504 + }) 505 + 506 + it(`match: iykyk`, () => { 507 + const match = hasMutedWord( 508 + [{value: `iykyk`, targets: ['content']}], 509 + rt.text, 510 + rt.facets, 511 + [], 512 + ) 513 + 514 + expect(match).toBe(true) 515 + }) 516 + 517 + it(`match: (iykyk)`, () => { 518 + const match = hasMutedWord( 519 + [{value: `(iykyk)`, targets: ['content']}], 520 + rt.text, 521 + rt.facets, 522 + [], 523 + ) 524 + 525 + expect(match).toBe(true) 526 + }) 527 + }) 528 + 529 + describe(`🦋`, () => { 530 + const rt = new RichText({ 531 + text: `Post with 🦋`, 532 + }) 533 + rt.detectFacetsWithoutResolution() 534 + 535 + it(`match: 🦋`, () => { 536 + const match = hasMutedWord( 537 + [{value: `🦋`, targets: ['content']}], 538 + rt.text, 539 + rt.facets, 540 + [], 541 + ) 542 + 543 + expect(match).toBe(true) 544 + }) 545 + }) 546 + }) 547 + 548 + describe(`phrases`, () => { 549 + describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => { 550 + const rt = new RichText({ 551 + text: `I like turtles, or how I learned to stop worrying and love the internet.`, 552 + }) 553 + rt.detectFacetsWithoutResolution() 554 + 555 + it(`match: stop worrying`, () => { 556 + const match = hasMutedWord( 557 + [{value: 'stop worrying', targets: ['content']}], 558 + rt.text, 559 + rt.facets, 560 + [], 561 + ) 562 + 563 + expect(match).toBe(true) 564 + }) 565 + 566 + it(`match: turtles, or how`, () => { 567 + const match = hasMutedWord( 568 + [{value: 'turtles, or how', targets: ['content']}], 569 + rt.text, 570 + rt.facets, 571 + [], 572 + ) 573 + 574 + expect(match).toBe(true) 575 + }) 576 + }) 577 + }) 578 + })
+155 -1
src/lib/moderatePost_wrapped.ts
··· 2 2 AppBskyEmbedRecord, 3 3 AppBskyEmbedRecordWithMedia, 4 4 moderatePost, 5 + AppBskyActorDefs, 6 + AppBskyFeedPost, 7 + AppBskyRichtextFacet, 8 + AppBskyEmbedImages, 5 9 } from '@atproto/api' 6 10 7 11 type ModeratePost = typeof moderatePost 8 12 type Options = Parameters<ModeratePost>[1] & { 9 13 hiddenPosts?: string[] 14 + mutedWords?: AppBskyActorDefs.MutedWord[] 15 + } 16 + 17 + const REGEX = { 18 + LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, 19 + ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, 20 + SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g, 21 + WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, 22 + } 23 + 24 + export function hasMutedWord( 25 + mutedWords: AppBskyActorDefs.MutedWord[], 26 + text: string, 27 + facets?: AppBskyRichtextFacet.Main[], 28 + outlineTags?: string[], 29 + ) { 30 + const tags = ([] as string[]) 31 + .concat(outlineTags || []) 32 + .concat( 33 + facets 34 + ?.filter(facet => { 35 + return facet.features.find(feature => 36 + AppBskyRichtextFacet.isTag(feature), 37 + ) 38 + }) 39 + .map(t => t.features[0].tag as string) || [], 40 + ) 41 + .map(t => t.toLowerCase()) 42 + 43 + for (const mute of mutedWords) { 44 + const mutedWord = mute.value.toLowerCase() 45 + const postText = text.toLowerCase() 46 + 47 + // `content` applies to tags as well 48 + if (tags.includes(mutedWord)) return true 49 + // rest of the checks are for `content` only 50 + if (!mute.targets.includes('content')) continue 51 + // single character, has to use includes 52 + if (mutedWord.length === 1 && postText.includes(mutedWord)) return true 53 + // too long 54 + if (mutedWord.length > postText.length) continue 55 + // exact match 56 + if (mutedWord === postText) return true 57 + // any muted phrase with space or punctuation 58 + if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) 59 + return true 60 + 61 + // check individual character groups 62 + const words = postText.split(REGEX.WORD_BOUNDARY) 63 + for (const word of words) { 64 + if (word === mutedWord) return true 65 + 66 + // compare word without leading/trailing punctuation, but allow internal 67 + // punctuation (such as `s@ssy`) 68 + const wordTrimmedPunctuation = word.replace( 69 + REGEX.LEADING_TRAILING_PUNCTUATION, 70 + '', 71 + ) 72 + 73 + if (mutedWord === wordTrimmedPunctuation) return true 74 + if (mutedWord.length > wordTrimmedPunctuation.length) continue 75 + 76 + // handle hyphenated, slash separated words, etc 77 + if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) { 78 + // check against full normalized phrase 79 + const wordNormalizedSeparators = wordTrimmedPunctuation.replace( 80 + REGEX.SEPARATORS, 81 + ' ', 82 + ) 83 + const mutedWordNormalizedSeparators = mutedWord.replace( 84 + REGEX.SEPARATORS, 85 + ' ', 86 + ) 87 + // hyphenated (or other sep) to spaced words 88 + if (wordNormalizedSeparators === mutedWordNormalizedSeparators) 89 + return true 90 + 91 + /* Disabled for now e.g. `super-cool` to `supercool` 92 + const wordNormalizedCompressed = wordNormalizedSeparators.replace( 93 + REGEX.WORD_BOUNDARY, 94 + '', 95 + ) 96 + const mutedWordNormalizedCompressed = 97 + mutedWordNormalizedSeparators.replace(/\s+?/g, '') 98 + // hyphenated (or other sep) to non-hyphenated contiguous word 99 + if (mutedWordNormalizedCompressed === wordNormalizedCompressed) 100 + return true 101 + */ 102 + 103 + // then individual parts of separated phrases/words 104 + const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS) 105 + for (const wp of wordParts) { 106 + // still retain internal punctuation 107 + if (wp === mutedWord) return true 108 + } 109 + } 110 + } 111 + } 112 + 113 + return false 10 114 } 11 115 12 116 export function moderatePost_wrapped( 13 117 subject: Parameters<ModeratePost>[0], 14 118 opts: Options, 15 119 ) { 16 - const {hiddenPosts = [], ...options} = opts 120 + const {hiddenPosts = [], mutedWords = [], ...options} = opts 17 121 const moderations = moderatePost(subject, options) 18 122 19 123 if (hiddenPosts.includes(subject.uri)) { ··· 29 133 } 30 134 } 31 135 136 + if (AppBskyFeedPost.isRecord(subject.record)) { 137 + let muted = hasMutedWord( 138 + mutedWords, 139 + subject.record.text, 140 + subject.record.facets || [], 141 + subject.record.tags || [], 142 + ) 143 + 144 + if ( 145 + subject.record.embed && 146 + AppBskyEmbedImages.isMain(subject.record.embed) 147 + ) { 148 + for (const image of subject.record.embed.images) { 149 + muted = muted || hasMutedWord(mutedWords, image.alt, [], []) 150 + } 151 + } 152 + 153 + if (muted) { 154 + moderations.content.filter = true 155 + moderations.content.blur = true 156 + if (!moderations.content.cause) { 157 + moderations.content.cause = { 158 + // @ts-ignore Temporary extension to the moderation system -prf 159 + type: 'muted-word', 160 + source: {type: 'user'}, 161 + priority: 1, 162 + } 163 + } 164 + } 165 + } 166 + 32 167 if (subject.embed) { 33 168 let embedHidden = false 34 169 if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { 35 170 embedHidden = hiddenPosts.includes(subject.embed.record.uri) 171 + 172 + if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { 173 + embedHidden = 174 + embedHidden || 175 + hasMutedWord( 176 + mutedWords, 177 + subject.embed.record.value.text, 178 + subject.embed.record.value.facets, 179 + subject.embed.record.value.tags, 180 + ) 181 + 182 + if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { 183 + for (const image of subject.embed.record.value.embed.images) { 184 + embedHidden = 185 + embedHidden || hasMutedWord(mutedWords, image.alt, [], []) 186 + } 187 + } 188 + } 36 189 } 37 190 if ( 38 191 AppBskyEmbedRecordWithMedia.isView(subject.embed) && 39 192 AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) 40 193 ) { 194 + // TODO what 41 195 embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) 42 196 } 43 197 if (embedHidden) {
+7
src/lib/moderation.ts
··· 67 67 description: 'You have hidden this post', 68 68 } 69 69 } 70 + // @ts-ignore Temporary extension to the moderation system -prf 71 + if (cause.type === 'muted-word') { 72 + return { 73 + name: 'Post hidden by muted word', 74 + description: `You've chosen to hide a word or tag within this post.`, 75 + } 76 + } 70 77 return cause.labelDef.strings[context].en 71 78 } 72 79
+10
src/lib/routes/links.ts
··· 25 25 export function makeListLink(did: string, rkey: string, ...segments: string[]) { 26 26 return [`/profile`, did, 'lists', rkey, ...segments].join('/') 27 27 } 28 + 29 + export function makeTagLink(did: string) { 30 + return `/search?q=${encodeURIComponent(did)}` 31 + } 32 + 33 + export function makeSearchLink(props: {query: string; from?: 'me' | string}) { 34 + return `/search?q=${encodeURIComponent( 35 + props.query + (props.from ? ` from:${props.from}` : ''), 36 + )}` 37 + }
+1
src/lib/routes/types.ts
··· 33 33 PreferencesFollowingFeed: undefined 34 34 PreferencesThreads: undefined 35 35 PreferencesExternalEmbeds: undefined 36 + Search: {q?: string} 36 37 } 37 38 38 39 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+2 -1
src/state/dialogs/index.tsx
··· 1 1 import React from 'react' 2 2 import {DialogControlProps} from '#/components/Dialog' 3 + import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' 3 4 4 5 const DialogContext = React.createContext<{ 5 6 activeDialogs: React.MutableRefObject< ··· 37 38 return ( 38 39 <DialogContext.Provider value={context}> 39 40 <DialogControlContext.Provider value={controls}> 40 - {children} 41 + <GlobalDialogsProvider>{children}</GlobalDialogsProvider> 41 42 </DialogControlContext.Provider> 42 43 </DialogContext.Provider> 43 44 )
+2
src/state/queries/preferences/const.ts
··· 49 49 threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, 50 50 userAge: 13, // TODO(pwi) 51 51 interests: {tags: []}, 52 + mutedWords: [], 53 + hiddenPosts: [], 52 54 }
+48 -1
src/state/queries/preferences/index.ts
··· 1 1 import {useMemo} from 'react' 2 2 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 3 - import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' 3 + import { 4 + LabelPreference, 5 + BskyFeedViewPreference, 6 + AppBskyActorDefs, 7 + } from '@atproto/api' 4 8 5 9 import {track} from '#/lib/analytics/analytics' 6 10 import {getAge} from '#/lib/strings/time' ··· 108 112 return { 109 113 ...moderationOpts, 110 114 hiddenPosts, 115 + mutedWords: prefs.data.mutedWords || [], 111 116 } 112 117 }, [currentAccount?.did, prefs.data, hiddenPosts]) 113 118 return opts ··· 278 283 }, 279 284 }) 280 285 } 286 + 287 + export function useUpsertMutedWordsMutation() { 288 + const queryClient = useQueryClient() 289 + 290 + return useMutation({ 291 + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { 292 + await getAgent().upsertMutedWords(mutedWords) 293 + // triggers a refetch 294 + await queryClient.invalidateQueries({ 295 + queryKey: preferencesQueryKey, 296 + }) 297 + }, 298 + }) 299 + } 300 + 301 + export function useUpdateMutedWordMutation() { 302 + const queryClient = useQueryClient() 303 + 304 + return useMutation({ 305 + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { 306 + await getAgent().updateMutedWord(mutedWord) 307 + // triggers a refetch 308 + await queryClient.invalidateQueries({ 309 + queryKey: preferencesQueryKey, 310 + }) 311 + }, 312 + }) 313 + } 314 + 315 + export function useRemoveMutedWordMutation() { 316 + const queryClient = useQueryClient() 317 + 318 + return useMutation({ 319 + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { 320 + await getAgent().removeMutedWord(mutedWord) 321 + // triggers a refetch 322 + await queryClient.invalidateQueries({ 323 + queryKey: preferencesQueryKey, 324 + }) 325 + }, 326 + }) 327 + }
+1 -2
src/view/com/composer/text-input/TextInput.tsx
··· 190 190 let i = 0 191 191 192 192 return Array.from(richtext.segments()).map(segment => { 193 - const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) 194 193 return ( 195 194 <Text 196 195 key={i++} 197 196 style={[ 198 - segment.facet && !isTag ? pal.link : pal.text, 197 + segment.facet ? pal.link : pal.text, 199 198 styles.textInputFormatting, 200 199 ]}> 201 200 {segment.text}
+2
src/view/com/composer/text-input/TextInput.web.tsx
··· 23 23 import {Text} from '../../util/text/Text' 24 24 import {Trans} from '@lingui/macro' 25 25 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 26 + import {TagDecorator} from './web/TagDecorator' 26 27 27 28 export interface TextInputRef { 28 29 focus: () => void ··· 67 68 () => [ 68 69 Document, 69 70 LinkDecorator, 71 + TagDecorator, 70 72 Mention.configure({ 71 73 HTMLAttributes: { 72 74 class: 'mention',
+83
src/view/com/composer/text-input/web/TagDecorator.ts
··· 1 + /** 2 + * TipTap is a stateful rich-text editor, which is extremely useful 3 + * when you _want_ it to be stateful formatting such as bold and italics. 4 + * 5 + * However we also use "stateless" behaviors, specifically for URLs 6 + * where the text itself drives the formatting. 7 + * 8 + * This plugin uses a regex to detect URIs and then applies 9 + * link decorations (a <span> with the "autolink") class. That avoids 10 + * adding any stateful formatting to TipTap's document model. 11 + * 12 + * We then run the URI detection again when constructing the 13 + * RichText object from TipTap's output and merge their features into 14 + * the facet-set. 15 + */ 16 + 17 + import {Mark} from '@tiptap/core' 18 + import {Plugin, PluginKey} from '@tiptap/pm/state' 19 + import {Node as ProsemirrorNode} from '@tiptap/pm/model' 20 + import {Decoration, DecorationSet} from '@tiptap/pm/view' 21 + 22 + function getDecorations(doc: ProsemirrorNode) { 23 + const decorations: Decoration[] = [] 24 + 25 + doc.descendants((node, pos) => { 26 + if (node.isText && node.text) { 27 + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g 28 + const textContent = node.textContent 29 + 30 + let match 31 + while ((match = regex.exec(textContent))) { 32 + const [matchedString, tag] = match 33 + 34 + if (tag.length > 66) continue 35 + 36 + const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || [] 37 + 38 + const from = match.index + matchedString.indexOf(tag) 39 + const to = from + (tag.length - trailingPunc.length) 40 + 41 + decorations.push( 42 + Decoration.inline(pos + from, pos + to, { 43 + class: 'autolink', 44 + }), 45 + ) 46 + } 47 + } 48 + }) 49 + 50 + return DecorationSet.create(doc, decorations) 51 + } 52 + 53 + const tagDecoratorPlugin: Plugin = new Plugin({ 54 + key: new PluginKey('link-decorator'), 55 + 56 + state: { 57 + init: (_, {doc}) => getDecorations(doc), 58 + apply: (transaction, decorationSet) => { 59 + if (transaction.docChanged) { 60 + return getDecorations(transaction.doc) 61 + } 62 + return decorationSet.map(transaction.mapping, transaction.doc) 63 + }, 64 + }, 65 + 66 + props: { 67 + decorations(state) { 68 + return tagDecoratorPlugin.getState(state) 69 + }, 70 + }, 71 + }) 72 + 73 + export const TagDecorator = Mark.create({ 74 + name: 'tag-decorator', 75 + priority: 1000, 76 + keepOnSplit: false, 77 + inclusive() { 78 + return true 79 + }, 80 + addProseMirrorPlugins() { 81 + return [tagDecoratorPlugin] 82 + }, 83 + })
+5 -1
src/view/com/post-thread/PostThreadItem.tsx
··· 327 327 styles.postTextLargeContainer, 328 328 ]}> 329 329 <RichText 330 + enableTags 331 + selectable 330 332 value={richText} 331 333 style={[a.flex_1, a.text_xl]} 332 - selectable 334 + authorHandle={post.author.handle} 333 335 /> 334 336 </View> 335 337 ) : undefined} ··· 521 523 {richText?.text ? ( 522 524 <View style={styles.postTextContainer}> 523 525 <RichText 526 + enableTags 524 527 value={richText} 525 528 style={[a.flex_1, a.text_md]} 526 529 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 530 + authorHandle={post.author.handle} 527 531 /> 528 532 </View> 529 533 ) : undefined}
+2
src/view/com/post/Post.tsx
··· 184 184 {richText.text ? ( 185 185 <View style={styles.postTextContainer}> 186 186 <RichText 187 + enableTags 187 188 testID="postText" 188 189 value={richText} 189 190 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 190 191 style={[a.flex_1, a.text_md]} 192 + authorHandle={post.author.handle} 191 193 /> 192 194 </View> 193 195 ) : undefined}
+2
src/view/com/posts/FeedItem.tsx
··· 347 347 {richText.text ? ( 348 348 <View style={styles.postTextContainer}> 349 349 <RichText 350 + enableTags 350 351 testID="postText" 351 352 value={richText} 352 353 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 353 354 style={[a.flex_1, a.text_md]} 355 + authorHandle={postAuthor.handle} 354 356 /> 355 357 </View> 356 358 ) : undefined}
+5
src/view/com/util/forms/NativeDropdown.web.tsx
··· 21 21 22 22 return ( 23 23 <DropdownMenu.Item 24 + className="nativeDropdown-item" 24 25 {...props} 25 26 style={StyleSheet.flatten([ 26 27 styles.item, ··· 232 233 paddingLeft: 12, 233 234 paddingRight: 12, 234 235 borderRadius: 8, 236 + fontFamily: 237 + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', 238 + outline: 0, 239 + border: 0, 235 240 }, 236 241 itemTitle: { 237 242 fontSize: 16,
+16
src/view/com/util/forms/PostDropdownBtn.tsx
··· 34 34 import {useSession} from '#/state/session' 35 35 import {isWeb} from '#/platform/detection' 36 36 import {richTextToString} from '#/lib/strings/rich-text-helpers' 37 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 37 38 38 39 let PostDropdownBtn = ({ 39 40 testID, ··· 67 68 const {hidePost} = useHiddenPostsApi() 68 69 const openLink = useOpenLink() 69 70 const navigation = useNavigation() 71 + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 70 72 71 73 const rootUri = record.reply?.root?.uri || postUri 72 74 const isThreadMuted = mutedThreads.includes(rootUri) ··· 208 210 }, 209 211 android: 'ic_lock_silent_mode', 210 212 web: 'comment-slash', 213 + }, 214 + }, 215 + hasSession && { 216 + label: _(msg`Mute words & tags`), 217 + onPress() { 218 + mutedWordsDialogControl.open() 219 + }, 220 + testID: 'postDropdownMuteWordsBtn', 221 + icon: { 222 + ios: { 223 + name: 'speaker.slash', 224 + }, 225 + android: 'ic_lock_silent_mode', 226 + web: 'filter', 211 227 }, 212 228 }, 213 229 hasSession &&
+2
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 128 128 ) : null} 129 129 {richText ? ( 130 130 <RichText 131 + enableTags 131 132 value={richText} 132 133 style={[a.text_md]} 133 134 numberOfLines={20} 134 135 disableLinks 136 + authorHandle={quote.author.handle} 135 137 /> 136 138 ) : null} 137 139 {embed && <PostEmbeds embed={embed} moderation={{}} />}
+66
src/view/com/util/text/RichText.tsx
··· 7 7 import {toShortUrl} from 'lib/strings/url-helpers' 8 8 import {useTheme, TypographyVariant} from 'lib/ThemeContext' 9 9 import {usePalette} from 'lib/hooks/usePalette' 10 + import {makeTagLink} from 'lib/routes/links' 11 + import {TagMenu, useTagMenuControl} from '#/components/TagMenu' 12 + import {isNative} from '#/platform/detection' 10 13 11 14 const WORD_WRAP = {wordWrap: 1} 12 15 ··· 82 85 for (const segment of richText.segments()) { 83 86 const link = segment.link 84 87 const mention = segment.mention 88 + const tag = segment.tag 85 89 if ( 86 90 !noLinks && 87 91 mention && ··· 115 119 />, 116 120 ) 117 121 } 122 + } else if ( 123 + !noLinks && 124 + tag && 125 + AppBskyRichtextFacet.validateTag(tag).success 126 + ) { 127 + els.push( 128 + <RichTextTag 129 + key={key} 130 + text={segment.text} 131 + type={type} 132 + style={style} 133 + lineHeightStyle={lineHeightStyle} 134 + selectable={selectable} 135 + />, 136 + ) 118 137 } else { 119 138 els.push(segment.text) 120 139 } ··· 133 152 </Text> 134 153 ) 135 154 } 155 + 156 + function RichTextTag({ 157 + text: tag, 158 + type, 159 + style, 160 + lineHeightStyle, 161 + selectable, 162 + }: { 163 + text: string 164 + type?: TypographyVariant 165 + style?: StyleProp<TextStyle> 166 + lineHeightStyle?: TextStyle 167 + selectable?: boolean 168 + }) { 169 + const pal = usePalette('default') 170 + const control = useTagMenuControl() 171 + 172 + const open = React.useCallback(() => { 173 + control.open() 174 + }, [control]) 175 + 176 + return ( 177 + <React.Fragment> 178 + <TagMenu control={control} tag={tag}> 179 + {isNative ? ( 180 + <TextLink 181 + type={type} 182 + text={tag} 183 + // segment.text has the leading "#" while tag.tag does not 184 + href={makeTagLink(tag)} 185 + style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} 186 + dataSet={WORD_WRAP} 187 + selectable={selectable} 188 + onPress={open} 189 + /> 190 + ) : ( 191 + <Text 192 + selectable={selectable} 193 + type={type} 194 + style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}> 195 + {tag} 196 + </Text> 197 + )} 198 + </TagMenu> 199 + </React.Fragment> 200 + ) 201 + }
+2
src/view/icons/index.tsx
··· 103 103 import {faX} from '@fortawesome/free-solid-svg-icons/faX' 104 104 import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' 105 105 import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown' 106 + import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter' 106 107 107 108 library.add( 108 109 faAddressCard, ··· 208 209 faX, 209 210 faXmark, 210 211 faChevronDown, 212 + faFilter, 211 213 )
+21 -2
src/view/screens/Moderation.tsx
··· 31 31 useProfileUpdateMutation, 32 32 } from '#/state/queries/profile' 33 33 import {ScrollView} from '../com/util/Views' 34 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 34 35 35 36 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> 36 37 export function ModerationScreen({}: Props) { ··· 40 41 const {screen, track} = useAnalytics() 41 42 const {isTabletOrDesktop} = useWebMediaQueries() 42 43 const {openModal} = useModalControls() 44 + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 43 45 44 46 useFocusEffect( 45 47 React.useCallback(() => { ··· 69 71 style={[styles.linkCard, pal.view]} 70 72 onPress={onPressContentFiltering} 71 73 accessibilityRole="tab" 72 - accessibilityHint="Content filtering" 73 - accessibilityLabel=""> 74 + accessibilityHint="" 75 + accessibilityLabel={_(msg`Open content filtering settings`)}> 74 76 <View style={[styles.iconContainer, pal.btn]}> 75 77 <FontAwesomeIcon 76 78 icon="eye" ··· 79 81 </View> 80 82 <Text type="lg" style={pal.text}> 81 83 <Trans>Content filtering</Trans> 84 + </Text> 85 + </TouchableOpacity> 86 + <TouchableOpacity 87 + testID="mutedWordsBtn" 88 + style={[styles.linkCard, pal.view]} 89 + onPress={() => mutedWordsDialogControl.open()} 90 + accessibilityRole="tab" 91 + accessibilityHint="" 92 + accessibilityLabel={_(msg`Open muted words settings`)}> 93 + <View style={[styles.iconContainer, pal.btn]}> 94 + <FontAwesomeIcon 95 + icon="filter" 96 + style={pal.text as FontAwesomeIconStyle} 97 + /> 98 + </View> 99 + <Text type="lg" style={pal.text}> 100 + <Trans>Muted words & tags</Trans> 82 101 </Text> 83 102 </TouchableOpacity> 84 103 <Link
+26 -1
src/view/screens/Search/Search.tsx
··· 16 16 FontAwesomeIcon, 17 17 FontAwesomeIconStyle, 18 18 } from '@fortawesome/react-native-fontawesome' 19 - import {useFocusEffect} from '@react-navigation/native' 19 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 20 20 21 21 import {logger} from '#/logger' 22 22 import { ··· 53 53 import {s} from '#/lib/styles' 54 54 import AsyncStorage from '@react-native-async-storage/async-storage' 55 55 import {augmentSearchQuery} from '#/lib/strings/helpers' 56 + import {NavigationProp} from '#/lib/routes/types' 56 57 57 58 function Loader() { 58 59 const pal = usePalette('default') ··· 448 449 export function SearchScreen( 449 450 props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 450 451 ) { 452 + const navigation = useNavigation<NavigationProp>() 451 453 const theme = useTheme() 452 454 const textInput = React.useRef<TextInput>(null) 453 455 const {_} = useLingui() ··· 471 473 const [showAutocompleteResults, setShowAutocompleteResults] = 472 474 React.useState(false) 473 475 const [searchHistory, setSearchHistory] = React.useState<string[]>([]) 476 + 477 + /** 478 + * The Search screen's `q` param 479 + */ 480 + const queryParam = props.route?.params?.q 481 + 482 + /** 483 + * If `true`, this means we received new instructions from the router. This 484 + * is handled in a effect, and used to update the value of `query` locally 485 + * within this screen. 486 + */ 487 + const routeParamsMismatch = queryParam && queryParam !== query 488 + 489 + React.useEffect(() => { 490 + if (queryParam && routeParamsMismatch) { 491 + // reset immediately and let local state take over 492 + navigation.setParams({q: ''}) 493 + // update query for next search 494 + setQuery(queryParam) 495 + } 496 + }, [queryParam, routeParamsMismatch, navigation]) 474 497 475 498 React.useEffect(() => { 476 499 const loadSearchHistory = async () => { ··· 774 797 )} 775 798 </View> 776 799 </CenteredView> 800 + ) : routeParamsMismatch ? ( 801 + <ActivityIndicator /> 777 802 ) : ( 778 803 <SearchScreenInner query={query} /> 779 804 )}
+2
src/view/shell/index.tsx
··· 29 29 import {useCloseAnyActiveElement} from '#/state/util' 30 30 import * as notifications from 'lib/notifications/notifications' 31 31 import {Outlet as PortalOutlet} from '#/components/Portal' 32 + import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 32 33 33 34 function ShellInner() { 34 35 const isDrawerOpen = useIsDrawerOpen() ··· 94 95 </View> 95 96 <Composer winHeight={winDim.height} /> 96 97 <ModalsContainer /> 98 + <MutedWordsDialog /> 97 99 <PortalOutlet /> 98 100 <Lightbox /> 99 101 </>
+2
src/view/shell/index.web.tsx
··· 16 16 import {useCloseAllActiveElements} from '#/state/util' 17 17 import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' 18 18 import {Outlet as PortalOutlet} from '#/components/Portal' 19 + import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 19 20 20 21 function ShellInner() { 21 22 const isDrawerOpen = useIsDrawerOpen() ··· 40 41 </ErrorBoundary> 41 42 <Composer winHeight={0} /> 42 43 <ModalsContainer /> 44 + <MutedWordsDialog /> 43 45 <PortalOutlet /> 44 46 <Lightbox /> 45 47 {!isDesktop && isDrawerOpen && (
+5
web/index.html
··· 209 209 [data-tooltip]:hover::before { 210 210 display:block; 211 211 } 212 + 213 + /* NativeDropdown component */ 214 + .nativeDropdown-item:focus { 215 + outline: none; 216 + } 212 217 </style> 213 218 </head> 214 219
+14
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 + "@atproto/api@^0.10.0": 38 + version "0.10.0" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044" 40 + integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ== 41 + dependencies: 42 + "@atproto/common-web" "^0.2.3" 43 + "@atproto/lexicon" "^0.3.1" 44 + "@atproto/syntax" "^0.1.5" 45 + "@atproto/xrpc" "^0.4.1" 46 + multiformats "^9.9.0" 47 + tlds "^1.234.0" 48 + typed-emitter "^2.1.0" 49 + zod "^3.21.4" 50 + 37 51 "@atproto/api@^0.9.5": 38 52 version "0.9.5" 39 53 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"