my fork of the bluesky client

Update muted words dialog with `expiresAt` and `actorTarget` (#4801)

* WIP not working dropdown

* Update MutedWords dialog

* Add i18n formatDistance

* Comments

* Handle text wrapping

* Update label copy

Co-authored-by: Hailey <me@haileyok.com>

* Fix alignment

* Improve translation output

* Revert toggle changes

* Better types for useFormatDistance

* Tweaks

* Integrate new sdk version into TagMenu

* Use ampersand

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Bump SDK

---------

Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

authored by

Eric Bailey
Hailey
surfdude29
and committed by
GitHub
b0e130a4 d2e88cc6

+432 -127
+1 -1
package.json
··· 52 52 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 53 53 }, 54 54 "dependencies": { 55 - "@atproto/api": "0.12.25", 55 + "@atproto/api": "^0.12.26", 56 56 "@bam.tech/react-native-image-resizer": "^3.0.4", 57 57 "@braintree/sanitize-url": "^6.0.2", 58 58 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+35 -21
src/components/TagMenu/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {useNavigation} from '@react-navigation/native' 3 + import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 - import {msg, Trans} from '@lingui/macro' 5 + import {useNavigation} from '@react-navigation/native' 6 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 7 import {makeSearchLink} from '#/lib/routes/links' 17 8 import {NavigationProp} from '#/lib/routes/types' 9 + import {isInvalidHandle} from '#/lib/strings/handles' 18 10 import { 19 11 usePreferencesQuery, 12 + useRemoveMutedWordsMutation, 20 13 useUpsertMutedWordsMutation, 21 - useRemoveMutedWordMutation, 22 14 } from '#/state/queries/preferences' 15 + import {atoms as a, native, useTheme} from '#/alf' 16 + import {Button, ButtonText} from '#/components/Button' 17 + import * as Dialog from '#/components/Dialog' 18 + import {Divider} from '#/components/Divider' 19 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 20 + import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 21 + import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 22 + import {Link} from '#/components/Link' 23 23 import {Loader} from '#/components/Loader' 24 - import {isInvalidHandle} from '#/lib/strings/handles' 24 + import {Text} from '#/components/Typography' 25 25 26 26 export function useTagMenuControl() { 27 27 return Dialog.useDialogControl() ··· 52 52 reset: resetUpsert, 53 53 } = useUpsertMutedWordsMutation() 54 54 const { 55 - mutateAsync: removeMutedWord, 55 + mutateAsync: removeMutedWords, 56 56 variables: optimisticRemove, 57 57 reset: resetRemove, 58 - } = useRemoveMutedWordMutation() 58 + } = useRemoveMutedWordsMutation() 59 59 const displayTag = '#' + tag 60 60 61 61 const isMuted = Boolean( ··· 65 65 optimisticUpsert?.find( 66 66 m => m.value === tag && m.targets.includes('tag'), 67 67 )) && 68 - !(optimisticRemove?.value === tag), 68 + !optimisticRemove?.find(m => m?.value === tag), 69 69 ) 70 + 71 + /* 72 + * Mute word records that exactly match the tag in question. 73 + */ 74 + const removeableMuteWords = React.useMemo(() => { 75 + return ( 76 + preferences?.moderationPrefs.mutedWords?.filter(word => { 77 + return word.value === tag 78 + }) || [] 79 + ) 80 + }, [tag, preferences?.moderationPrefs?.mutedWords]) 70 81 71 82 return ( 72 83 <> ··· 212 223 control.close(() => { 213 224 if (isMuted) { 214 225 resetUpsert() 215 - removeMutedWord({ 216 - value: tag, 217 - targets: ['tag'], 218 - }) 226 + removeMutedWords(removeableMuteWords) 219 227 } else { 220 228 resetRemove() 221 - upsertMutedWord([{value: tag, targets: ['tag']}]) 229 + upsertMutedWord([ 230 + { 231 + value: tag, 232 + targets: ['tag'], 233 + actorTarget: 'all', 234 + }, 235 + ]) 222 236 } 223 237 }) 224 238 }}>
+25 -11
src/components/TagMenu/index.web.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 import {useNavigation} from '@react-navigation/native' 5 5 6 + import {NavigationProp} from '#/lib/routes/types' 6 7 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' 8 + import {enforceLen} from '#/lib/strings/helpers' 10 9 import { 11 10 usePreferencesQuery, 11 + useRemoveMutedWordsMutation, 12 12 useUpsertMutedWordsMutation, 13 - useRemoveMutedWordMutation, 14 13 } from '#/state/queries/preferences' 15 - import {enforceLen} from '#/lib/strings/helpers' 14 + import {EventStopper} from '#/view/com/util/EventStopper' 15 + import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' 16 16 import {web} from '#/alf' 17 17 import * as Dialog from '#/components/Dialog' 18 18 ··· 47 47 const {data: preferences} = usePreferencesQuery() 48 48 const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = 49 49 useUpsertMutedWordsMutation() 50 - const {mutateAsync: removeMutedWord, variables: optimisticRemove} = 51 - useRemoveMutedWordMutation() 50 + const {mutateAsync: removeMutedWords, variables: optimisticRemove} = 51 + useRemoveMutedWordsMutation() 52 52 const isMuted = Boolean( 53 53 (preferences?.moderationPrefs.mutedWords?.find( 54 54 m => m.value === tag && m.targets.includes('tag'), ··· 56 56 optimisticUpsert?.find( 57 57 m => m.value === tag && m.targets.includes('tag'), 58 58 )) && 59 - !(optimisticRemove?.value === tag), 59 + !optimisticRemove?.find(m => m?.value === tag), 60 60 ) 61 61 const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') 62 62 63 + /* 64 + * Mute word records that exactly match the tag in question. 65 + */ 66 + const removeableMuteWords = React.useMemo(() => { 67 + return ( 68 + preferences?.moderationPrefs.mutedWords?.filter(word => { 69 + return word.value === tag 70 + }) || [] 71 + ) 72 + }, [tag, preferences?.moderationPrefs?.mutedWords]) 73 + 63 74 const dropdownItems = React.useMemo(() => { 64 75 return [ 65 76 { ··· 105 116 : _(msg`Mute ${truncatedTag}`), 106 117 onPress() { 107 118 if (isMuted) { 108 - removeMutedWord({value: tag, targets: ['tag']}) 119 + removeMutedWords(removeableMuteWords) 109 120 } else { 110 - upsertMutedWord([{value: tag, targets: ['tag']}]) 121 + upsertMutedWord([ 122 + {value: tag, targets: ['tag'], actorTarget: 'all'}, 123 + ]) 111 124 } 112 125 }, 113 126 testID: 'tagMenuMute', ··· 129 142 tag, 130 143 truncatedTag, 131 144 upsertMutedWord, 132 - removeMutedWord, 145 + removeMutedWords, 146 + removeableMuteWords, 133 147 ]) 134 148 135 149 return (
+283 -90
src/components/dialogs/MutedWords.tsx
··· 1 1 import React from 'react' 2 - import {Keyboard, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' ··· 24 24 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 25 25 import {Divider} from '#/components/Divider' 26 26 import * as Toggle from '#/components/forms/Toggle' 27 + import {useFormatDistance} from '#/components/hooks/dates' 27 28 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 28 29 import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' 29 30 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' ··· 32 33 import * as Prompt from '#/components/Prompt' 33 34 import {Text} from '#/components/Typography' 34 35 36 + const ONE_DAY = 24 * 60 * 60 * 1000 37 + 35 38 export function MutedWordsDialog() { 36 39 const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() 37 40 return ( ··· 53 56 } = usePreferencesQuery() 54 57 const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() 55 58 const [field, setField] = React.useState('') 56 - const [options, setOptions] = React.useState(['content']) 59 + const [targets, setTargets] = React.useState(['content']) 57 60 const [error, setError] = React.useState('') 61 + const [durations, setDurations] = React.useState(['forever']) 62 + const [excludeFollowing, setExcludeFollowing] = React.useState(false) 58 63 59 64 const submit = React.useCallback(async () => { 60 65 const sanitizedValue = sanitizeMutedWordValue(field) 61 - const targets = ['tag', options.includes('content') && 'content'].filter( 66 + const surfaces = ['tag', targets.includes('content') && 'content'].filter( 62 67 Boolean, 63 68 ) as AppBskyActorDefs.MutedWord['targets'] 69 + const actorTarget = excludeFollowing ? 'exclude-following' : 'all' 64 70 65 - if (!sanitizedValue || !targets.length) { 71 + const now = Date.now() 72 + const rawDuration = durations.at(0) 73 + // undefined evaluates to 'forever' 74 + let duration: string | undefined 75 + 76 + if (rawDuration === '24_hours') { 77 + duration = new Date(now + ONE_DAY).toISOString() 78 + } else if (rawDuration === '7_days') { 79 + duration = new Date(now + 7 * ONE_DAY).toISOString() 80 + } else if (rawDuration === '30_days') { 81 + duration = new Date(now + 30 * ONE_DAY).toISOString() 82 + } 83 + 84 + if (!sanitizedValue || !surfaces.length) { 66 85 setField('') 67 86 setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) 68 87 return ··· 70 89 71 90 try { 72 91 // send raw value and rely on SDK as sanitization source of truth 73 - await addMutedWord([{value: field, targets}]) 92 + await addMutedWord([ 93 + { 94 + value: field, 95 + targets: surfaces, 96 + actorTarget, 97 + expiresAt: duration, 98 + }, 99 + ]) 74 100 setField('') 75 101 } catch (e: any) { 76 102 logger.error(`Failed to save muted word`, {message: e.message}) 77 103 setError(e.message) 78 104 } 79 - }, [_, field, options, addMutedWord, setField]) 105 + }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) 80 106 81 107 return ( 82 108 <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> 83 - <View onTouchStart={Keyboard.dismiss}> 109 + <View> 84 110 <Text 85 111 style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> 86 112 <Trans>Add muted words and tags</Trans> 87 113 </Text> 88 114 <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}> 89 115 <Trans> 90 - Posts can be muted based on their text, their tags, or both. 116 + Posts can be muted based on their text, their tags, or both. We 117 + recommend avoiding common words that appear in many posts, since it 118 + can result in no posts being shown. 91 119 </Trans> 92 120 </Text> 93 121 94 - <View style={[a.pb_lg]}> 122 + <View style={[a.pb_sm]}> 95 123 <Dialog.Input 96 124 autoCorrect={false} 97 125 autoCapitalize="none" ··· 107 135 }} 108 136 onSubmitEditing={submit} 109 137 /> 138 + </View> 110 139 140 + <View style={[a.pb_xl, a.gap_sm]}> 111 141 <Toggle.Group 112 - label={_(msg`Toggle between muted word options.`)} 142 + label={_(msg`Select how long to mute this word for.`)} 113 143 type="radio" 114 - values={options} 115 - onChange={setOptions}> 144 + values={durations} 145 + onChange={setDurations}> 146 + <Text 147 + style={[ 148 + a.pb_xs, 149 + a.text_sm, 150 + a.font_bold, 151 + t.atoms.text_contrast_medium, 152 + ]}> 153 + <Trans>Duration:</Trans> 154 + </Text> 155 + 116 156 <View 117 157 style={[ 118 - a.pt_sm, 119 - a.py_sm, 120 - a.flex_row, 121 - a.align_center, 158 + gtMobile && [a.flex_row, a.align_center, a.justify_start], 122 159 a.gap_sm, 123 - a.flex_wrap, 160 + ]}> 161 + <View 162 + style={[ 163 + a.flex_1, 164 + a.flex_row, 165 + a.justify_start, 166 + a.align_center, 167 + a.gap_sm, 168 + ]}> 169 + <Toggle.Item 170 + label={_(msg`Mute this word until you unmute it`)} 171 + name="forever" 172 + style={[a.flex_1]}> 173 + <TargetToggle> 174 + <View 175 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 176 + <Toggle.Radio /> 177 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 178 + <Trans>Forever</Trans> 179 + </Toggle.LabelText> 180 + </View> 181 + </TargetToggle> 182 + </Toggle.Item> 183 + 184 + <Toggle.Item 185 + label={_(msg`Mute this word for 24 hours`)} 186 + name="24_hours" 187 + style={[a.flex_1]}> 188 + <TargetToggle> 189 + <View 190 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 191 + <Toggle.Radio /> 192 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 193 + <Trans>24 hours</Trans> 194 + </Toggle.LabelText> 195 + </View> 196 + </TargetToggle> 197 + </Toggle.Item> 198 + </View> 199 + 200 + <View 201 + style={[ 202 + a.flex_1, 203 + a.flex_row, 204 + a.justify_start, 205 + a.align_center, 206 + a.gap_sm, 207 + ]}> 208 + <Toggle.Item 209 + label={_(msg`Mute this word for 7 days`)} 210 + name="7_days" 211 + style={[a.flex_1]}> 212 + <TargetToggle> 213 + <View 214 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 215 + <Toggle.Radio /> 216 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 217 + <Trans>7 days</Trans> 218 + </Toggle.LabelText> 219 + </View> 220 + </TargetToggle> 221 + </Toggle.Item> 222 + 223 + <Toggle.Item 224 + label={_(msg`Mute this word for 30 days`)} 225 + name="30_days" 226 + style={[a.flex_1]}> 227 + <TargetToggle> 228 + <View 229 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 230 + <Toggle.Radio /> 231 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 232 + <Trans>30 days</Trans> 233 + </Toggle.LabelText> 234 + </View> 235 + </TargetToggle> 236 + </Toggle.Item> 237 + </View> 238 + </View> 239 + </Toggle.Group> 240 + 241 + <Toggle.Group 242 + label={_(msg`Select what content this mute word should apply to.`)} 243 + type="radio" 244 + values={targets} 245 + onChange={setTargets}> 246 + <Text 247 + style={[ 248 + a.pb_xs, 249 + a.text_sm, 250 + a.font_bold, 251 + t.atoms.text_contrast_medium, 124 252 ]}> 253 + <Trans>Mute in:</Trans> 254 + </Text> 255 + 256 + <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}> 125 257 <Toggle.Item 126 258 label={_(msg`Mute this word in post text and tags`)} 127 259 name="content" 128 - style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> 260 + style={[a.flex_1]}> 129 261 <TargetToggle> 130 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 262 + <View 263 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 131 264 <Toggle.Radio /> 132 - <Toggle.LabelText> 133 - <Trans>Mute in text & tags</Trans> 265 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 266 + <Trans>Text & tags</Trans> 134 267 </Toggle.LabelText> 135 268 </View> 136 269 <PageText size="sm" /> ··· 140 273 <Toggle.Item 141 274 label={_(msg`Mute this word in tags only`)} 142 275 name="tag" 143 - style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}> 276 + style={[a.flex_1]}> 144 277 <TargetToggle> 145 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 278 + <View 279 + style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 146 280 <Toggle.Radio /> 147 - <Toggle.LabelText> 148 - <Trans>Mute in tags only</Trans> 281 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 282 + <Trans>Tags only</Trans> 149 283 </Toggle.LabelText> 150 284 </View> 151 285 <Hashtag size="sm" /> 152 286 </TargetToggle> 153 287 </Toggle.Item> 154 - 155 - <Button 156 - disabled={isPending || !field} 157 - label={_(msg`Add mute word for configured settings`)} 158 - size="small" 159 - color="primary" 160 - variant="solid" 161 - style={[!gtMobile && [a.w_full, a.flex_0]]} 162 - onPress={submit}> 163 - <ButtonText> 164 - <Trans>Add</Trans> 165 - </ButtonText> 166 - <ButtonIcon icon={isPending ? Loader : Plus} /> 167 - </Button> 168 288 </View> 169 289 </Toggle.Group> 170 290 291 + <View> 292 + <Text 293 + style={[ 294 + a.pb_xs, 295 + a.text_sm, 296 + a.font_bold, 297 + t.atoms.text_contrast_medium, 298 + ]}> 299 + <Trans>Options:</Trans> 300 + </Text> 301 + <Toggle.Item 302 + label={_(msg`Do not apply this mute word to users you follow`)} 303 + name="exclude_following" 304 + style={[a.flex_row, a.justify_between]} 305 + value={excludeFollowing} 306 + onChange={setExcludeFollowing}> 307 + <TargetToggle> 308 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 309 + <Toggle.Checkbox /> 310 + <Toggle.LabelText style={[a.flex_1, a.leading_tight]}> 311 + <Trans>Exclude users you follow</Trans> 312 + </Toggle.LabelText> 313 + </View> 314 + </TargetToggle> 315 + </Toggle.Item> 316 + </View> 317 + 318 + <View style={[a.pt_xs]}> 319 + <Button 320 + disabled={isPending || !field} 321 + label={_(msg`Add mute word for configured settings`)} 322 + size="medium" 323 + color="primary" 324 + variant="solid" 325 + style={[]} 326 + onPress={submit}> 327 + <ButtonText> 328 + <Trans>Add</Trans> 329 + </ButtonText> 330 + <ButtonIcon icon={isPending ? Loader : Plus} position="right" /> 331 + </Button> 332 + </View> 333 + 171 334 {error && ( 172 335 <View 173 336 style={[ ··· 191 354 </Text> 192 355 </View> 193 356 )} 194 - 195 - <Text 196 - style={[ 197 - a.pt_xs, 198 - a.text_sm, 199 - a.italic, 200 - a.leading_snug, 201 - t.atoms.text_contrast_medium, 202 - ]}> 203 - <Trans> 204 - We recommend avoiding common words that appear in many posts, 205 - since it can result in no posts being shown. 206 - </Trans> 207 - </Text> 208 357 </View> 209 358 210 359 <Divider /> ··· 268 417 const {_} = useLingui() 269 418 const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() 270 419 const control = Prompt.usePromptControl() 420 + const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined 421 + const isExpired = expiryDate && expiryDate < new Date() 422 + const formatDistance = useFormatDistance() 271 423 272 424 const remove = React.useCallback(async () => { 273 425 control.close() ··· 280 432 control={control} 281 433 title={_(msg`Are you sure?`)} 282 434 description={_( 283 - msg`This will delete ${word.value} from your muted words. You can always add it back later.`, 435 + msg`This will delete "${word.value}" from your muted words. You can always add it back later.`, 284 436 )} 285 437 onConfirm={remove} 286 438 confirmButtonCta={_(msg`Remove`)} ··· 289 441 290 442 <View 291 443 style={[ 292 - a.py_md, 293 - a.px_lg, 294 444 a.flex_row, 295 - a.align_center, 296 445 a.justify_between, 446 + a.py_md, 447 + a.px_lg, 297 448 a.rounded_md, 298 449 a.gap_md, 299 450 style, 300 451 ]}> 301 - <Text 302 - style={[ 303 - a.flex_1, 304 - a.leading_snug, 305 - a.w_full, 306 - a.font_bold, 307 - t.atoms.text_contrast_high, 308 - web({ 309 - overflowWrap: 'break-word', 310 - wordBreak: 'break-word', 311 - }), 312 - ]}> 313 - {word.value} 314 - </Text> 452 + <View style={[a.flex_1, a.gap_xs]}> 453 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 454 + <Text 455 + style={[ 456 + a.flex_1, 457 + a.leading_snug, 458 + a.font_bold, 459 + web({ 460 + overflowWrap: 'break-word', 461 + wordBreak: 'break-word', 462 + }), 463 + ]}> 464 + {word.targets.find(t => t === 'content') ? ( 465 + <Trans comment="Pattern: {wordValue} in text, tags"> 466 + {word.value}{' '} 467 + <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 468 + in{' '} 469 + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> 470 + text & tags 471 + </Text> 472 + </Text> 473 + </Trans> 474 + ) : ( 475 + <Trans comment="Pattern: {wordValue} in tags"> 476 + {word.value}{' '} 477 + <Text style={[a.font_normal, t.atoms.text_contrast_medium]}> 478 + in{' '} 479 + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> 480 + tags 481 + </Text> 482 + </Text> 483 + </Trans> 484 + )} 485 + </Text> 486 + </View> 315 487 316 - <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}> 317 - {word.targets.map(target => ( 318 - <View 319 - key={target} 320 - style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}> 488 + {(expiryDate || word.actorTarget === 'exclude-following') && ( 489 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 321 490 <Text 322 - style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}> 323 - {target === 'content' ? _(msg`text`) : _(msg`tag`)} 491 + style={[ 492 + a.flex_1, 493 + a.text_xs, 494 + a.leading_snug, 495 + t.atoms.text_contrast_medium, 496 + ]}> 497 + {expiryDate && ( 498 + <> 499 + {isExpired ? ( 500 + <Trans>Expired</Trans> 501 + ) : ( 502 + <Trans> 503 + Expires{' '} 504 + {formatDistance(expiryDate, new Date(), { 505 + addSuffix: true, 506 + })} 507 + </Trans> 508 + )} 509 + </> 510 + )} 511 + {word.actorTarget === 'exclude-following' && ( 512 + <> 513 + {' • '} 514 + <Trans>Excludes users you follow</Trans> 515 + </> 516 + )} 324 517 </Text> 325 518 </View> 326 - ))} 519 + )} 520 + </View> 327 521 328 - <Button 329 - label={_(msg`Remove mute word from your list`)} 330 - size="tiny" 331 - shape="round" 332 - variant="ghost" 333 - color="secondary" 334 - onPress={() => control.open()} 335 - style={[a.ml_sm]}> 336 - <ButtonIcon icon={isPending ? Loader : X} /> 337 - </Button> 338 - </View> 522 + <Button 523 + label={_(msg`Remove mute word from your list`)} 524 + size="tiny" 525 + shape="round" 526 + variant="outline" 527 + color="secondary" 528 + onPress={() => control.open()} 529 + style={[a.ml_sm]}> 530 + <ButtonIcon icon={isPending ? Loader : X} /> 531 + </Button> 339 532 </View> 340 533 </> 341 534 )
+69
src/components/hooks/dates.ts
··· 1 + /** 2 + * Hooks for date-fns localized formatters. 3 + * 4 + * Our app supports some languages that are not included in date-fns by 5 + * default, in which case it will fall back to English. 6 + * 7 + * {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md} 8 + */ 9 + 10 + import React from 'react' 11 + import {formatDistance, Locale} from 'date-fns' 12 + import { 13 + ca, 14 + de, 15 + es, 16 + fi, 17 + fr, 18 + hi, 19 + id, 20 + it, 21 + ja, 22 + ko, 23 + ptBR, 24 + tr, 25 + uk, 26 + zhCN, 27 + zhTW, 28 + } from 'date-fns/locale' 29 + 30 + import {AppLanguage} from '#/locale/languages' 31 + import {useLanguagePrefs} from '#/state/preferences' 32 + 33 + /** 34 + * {@link AppLanguage} 35 + */ 36 + const locales: Record<AppLanguage, Locale | undefined> = { 37 + en: undefined, 38 + ca, 39 + de, 40 + es, 41 + fi, 42 + fr, 43 + ga: undefined, 44 + hi, 45 + id, 46 + it, 47 + ja, 48 + ko, 49 + ['pt-BR']: ptBR, 50 + tr, 51 + uk, 52 + ['zh-CN']: zhCN, 53 + ['zh-TW']: zhTW, 54 + } 55 + 56 + /** 57 + * Returns a localized `formatDistance` function. 58 + * {@link formatDistance} 59 + */ 60 + export function useFormatDistance() { 61 + const {appLanguage} = useLanguagePrefs() 62 + return React.useCallback<typeof formatDistance>( 63 + (date, baseDate, options) => { 64 + const locale = locales[appLanguage as AppLanguage] 65 + return formatDistance(date, baseDate, {...options, locale: locale}) 66 + }, 67 + [appLanguage], 68 + ) 69 + }
+15
src/state/queries/preferences/index.ts
··· 343 343 }) 344 344 } 345 345 346 + export function useRemoveMutedWordsMutation() { 347 + const queryClient = useQueryClient() 348 + const agent = useAgent() 349 + 350 + return useMutation({ 351 + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { 352 + await agent.removeMutedWords(mutedWords) 353 + // triggers a refetch 354 + await queryClient.invalidateQueries({ 355 + queryKey: preferencesQueryKey, 356 + }) 357 + }, 358 + }) 359 + } 360 + 346 361 export function useQueueNudgesMutation() { 347 362 const queryClient = useQueryClient() 348 363 const agent = useAgent()
+4 -4
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@0.12.25": 38 - version "0.12.25" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b" 40 - integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A== 37 + "@atproto/api@^0.12.26": 38 + version "0.12.26" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca" 40 + integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA== 41 41 dependencies: 42 42 "@atproto/common-web" "^0.3.0" 43 43 "@atproto/lexicon" "^0.4.0"