Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[APP-735] Post language improvements (#982)

* Fix composer character-counter bouncing around UI elements

* Fix composer toolbar padding when keyboard is dismissed on iOS

* Use the full name of the language in the composer footer

* Add headings to the DropdownButton

* Update the composer language control to use a simpler dropdown

* Fix lint

* Add translate link to Post component used in notifications

* Fix lint

authored by

Paul Frazee and committed by
GitHub
e14c9783 f05c2f06

+189 -28
+35
src/lib/hooks/useIsKeyboardVisible.ts
··· 1 + import {useState, useEffect} from 'react' 2 + import {Keyboard} from 'react-native' 3 + import {isIOS} from 'platform/detection' 4 + 5 + export function useIsKeyboardVisible({ 6 + iosUseWillEvents, 7 + }: { 8 + iosUseWillEvents?: boolean 9 + } = {}) { 10 + const [isKeyboardVisible, setKeyboardVisible] = useState(false) 11 + 12 + // NOTE 13 + // only iOS suppose the "will" events 14 + // -prf 15 + const showEvent = 16 + isIOS && iosUseWillEvents ? 'keyboardWillShow' : 'keyboardDidShow' 17 + const hideEvent = 18 + isIOS && iosUseWillEvents ? 'keyboardWillHide' : 'keyboardDidHide' 19 + 20 + useEffect(() => { 21 + const keyboardShowListener = Keyboard.addListener(showEvent, () => 22 + setKeyboardVisible(true), 23 + ) 24 + const keyboardHideListener = Keyboard.addListener(hideEvent, () => 25 + setKeyboardVisible(false), 26 + ) 27 + 28 + return () => { 29 + keyboardHideListener.remove() 30 + keyboardShowListener.remove() 31 + } 32 + }, [showEvent, hideEvent]) 33 + 34 + return [isKeyboardVisible] 35 + }
+3
src/lib/styles.ts
··· 89 89 // text decoration 90 90 underline: {textDecorationLine: 'underline'}, 91 91 92 + // font variants 93 + tabularNum: {fontVariant: ['tabular-nums']}, 94 + 92 95 // font sizes 93 96 f9: {fontSize: 9}, 94 97 f10: {fontSize: 10},
+5
src/locale/helpers.ts
··· 18 18 return lang 19 19 } 20 20 21 + export function codeToLanguageName(lang: string): string { 22 + const lang2 = code3ToCode2(lang) 23 + return LANGUAGES_MAP_CODE2[lang2]?.name || lang 24 + } 25 + 21 26 export function getPostLanguage( 22 27 post: AppBskyFeedDefs.PostView, 23 28 ): string | undefined {
+1 -1
src/locale/languages.ts
··· 455 455 {code3: 'som', code2: 'so', name: 'Somali'}, 456 456 {code3: 'son', code2: ' ', name: 'Songhai languages'}, 457 457 {code3: 'sot', code2: 'st', name: 'Sotho, Southern'}, 458 - {code3: 'spa', code2: 'es', name: 'Spanish; Castilian'}, 458 + {code3: 'spa', code2: 'es', name: 'Spanish'}, 459 459 {code3: 'sqi', code2: 'sq', name: 'Albanian'}, 460 460 {code3: 'srd', code2: 'sc', name: 'Sardinian'}, 461 461 {code3: 'srn', code2: ' ', name: 'Sranan Tongo'},
+4
src/state/models/ui/preferences.ts
··· 311 311 } 312 312 } 313 313 314 + setPostLanguage(code2: string) { 315 + this.postLanguages = [code2] 316 + } 317 + 314 318 getReadablePostLanguages() { 315 319 const all = this.postLanguages.map(code2 => { 316 320 const lang = LANGUAGES.find(l => l.code2 === code2)
+6 -3
src/view/com/composer/Composer.tsx
··· 16 16 import {RichText} from '@atproto/api' 17 17 import {useAnalytics} from 'lib/analytics/analytics' 18 18 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' 19 + import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' 19 20 import {ExternalEmbed} from './ExternalEmbed' 20 21 import {Text} from '../util/text/Text' 21 22 import * as Toast from '../util/Toast' ··· 35 36 import {usePalette} from 'lib/hooks/usePalette' 36 37 import QuoteEmbed from '../util/post-embeds/QuoteEmbed' 37 38 import {useExternalLinkFetch} from './useExternalLinkFetch' 38 - import {isDesktopWeb, isAndroid} from 'platform/detection' 39 + import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection' 39 40 import {GalleryModel} from 'state/models/media/gallery' 40 41 import {Gallery} from './photos/Gallery' 41 42 import {MAX_GRAPHEME_LENGTH} from 'lib/constants' ··· 55 56 const pal = usePalette('default') 56 57 const store = useStores() 57 58 const textInput = useRef<TextInputRef>(null) 59 + const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) 58 60 const [isProcessing, setIsProcessing] = useState(false) 59 61 const [processingState, setProcessingState] = useState('') 60 62 const [error, setError] = useState('') ··· 75 77 const insets = useSafeAreaInsets() 76 78 const viewStyles = useMemo( 77 79 () => ({ 78 - paddingBottom: isAndroid ? insets.bottom : 0, 80 + paddingBottom: 81 + isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0, 79 82 paddingTop: isAndroid ? insets.top : isDesktopWeb ? 0 : 15, 80 83 }), 81 - [insets], 84 + [insets, isKeyboardVisible], 82 85 ) 83 86 84 87 // HACK
+1 -1
src/view/com/composer/char-progress/CharProgress.tsx
··· 17 17 const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link 18 18 return ( 19 19 <> 20 - <Text style={[s.mr10, {color: textColor}]}> 20 + <Text style={[s.mr10, s.tabularNum, {color: textColor}]}> 21 21 {MAX_GRAPHEME_LENGTH - count} 22 22 </Text> 23 23 <View>
+65 -13
src/view/com/composer/select-language/SelectLangBtn.tsx
··· 1 - import React, {useCallback} from 'react' 2 - import {TouchableOpacity, StyleSheet, Keyboard} from 'react-native' 1 + import React, {useCallback, useMemo} from 'react' 2 + import {StyleSheet, Keyboard} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import { 5 5 FontAwesomeIcon, 6 6 FontAwesomeIconStyle, 7 7 } from '@fortawesome/react-native-fontawesome' 8 8 import {Text} from 'view/com/util/text/Text' 9 + import { 10 + DropdownButton, 11 + DropdownItem, 12 + DropdownItemButton, 13 + } from 'view/com/util/forms/DropdownButton' 9 14 import {usePalette} from 'lib/hooks/usePalette' 10 15 import {useStores} from 'state/index' 11 16 import {isNative} from 'platform/detection' 12 - 13 - const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} 17 + import {codeToLanguageName} from '../../../../locale/helpers' 18 + import {deviceLocales} from 'platform/detection' 14 19 15 20 export const SelectLangBtn = observer(function SelectLangBtn() { 16 21 const pal = usePalette('default') 17 22 const store = useStores() 18 23 19 - const onPress = useCallback(async () => { 24 + const onPressMore = useCallback(async () => { 20 25 if (isNative) { 21 26 if (Keyboard.isVisible()) { 22 27 Keyboard.dismiss() ··· 25 30 store.shell.openModal({name: 'post-languages-settings'}) 26 31 }, [store]) 27 32 33 + const postLanguagesPref = store.preferences.postLanguages 34 + const items: DropdownItem[] = useMemo(() => { 35 + let arr: DropdownItemButton[] = [] 36 + 37 + const add = (langCode: string) => { 38 + const langName = codeToLanguageName(langCode) 39 + if (arr.find((item: DropdownItemButton) => item.label === langName)) { 40 + return 41 + } 42 + arr.push({ 43 + icon: store.preferences.hasPostLanguage(langCode) 44 + ? ['fas', 'circle-check'] 45 + : ['far', 'circle'], 46 + label: langName, 47 + onPress() { 48 + store.preferences.setPostLanguage(langCode) 49 + }, 50 + }) 51 + } 52 + 53 + for (const lang of postLanguagesPref) { 54 + add(lang) 55 + } 56 + for (const lang of deviceLocales) { 57 + add(lang) 58 + } 59 + add('en') // english 60 + add('ja') // japanese 61 + add('pt') // portugese 62 + add('de') // german 63 + 64 + return [ 65 + {heading: true, label: 'Post language'}, 66 + ...arr.slice(0, 6), 67 + {sep: true}, 68 + { 69 + label: 'Other...', 70 + onPress: onPressMore, 71 + }, 72 + ] 73 + }, [store.preferences, postLanguagesPref, onPressMore]) 74 + 28 75 return ( 29 - <TouchableOpacity 76 + <DropdownButton 77 + type="bare" 30 78 testID="selectLangBtn" 31 - onPress={onPress} 79 + items={items} 80 + openUpwards 32 81 style={styles.button} 33 - hitSlop={HITSLOP} 34 - accessibilityRole="button" 35 82 accessibilityLabel="Language selection" 36 - accessibilityHint="Opens screen or modal to select language of post"> 83 + accessibilityHint=""> 37 84 {store.preferences.postLanguages.length > 0 ? ( 38 - <Text type="lg-bold" style={pal.link}> 39 - {store.preferences.postLanguages.join(', ')} 85 + <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}> 86 + {store.preferences.postLanguages 87 + .map(lang => codeToLanguageName(lang)) 88 + .join(', ')} 40 89 </Text> 41 90 ) : ( 42 91 <FontAwesomeIcon ··· 45 94 size={26} 46 95 /> 47 96 )} 48 - </TouchableOpacity> 97 + </DropdownButton> 49 98 ) 50 99 }) 51 100 52 101 const styles = StyleSheet.create({ 53 102 button: { 54 103 paddingHorizontal: 15, 104 + }, 105 + label: { 106 + maxWidth: 100, 55 107 }, 56 108 })
+24 -5
src/view/com/post/Post.tsx
··· 1 - import React, {useEffect, useState} from 'react' 1 + import React, {useEffect, useState, useMemo} from 'react' 2 2 import { 3 3 ActivityIndicator, 4 4 Linking, ··· 29 29 import {useStores} from 'state/index' 30 30 import {s, colors} from 'lib/styles' 31 31 import {usePalette} from 'lib/hooks/usePalette' 32 - import {getTranslatorLink} from '../../../locale/helpers' 32 + import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 33 33 34 34 export const Post = observer(function Post({ 35 35 uri, ··· 134 134 const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) 135 135 replyAuthorDid = urip.hostname 136 136 } 137 + 138 + const primaryLanguage = store.preferences.contentLanguages[0] || 'en' 139 + const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') 140 + const needsTranslation = useMemo( 141 + () => 142 + store.preferences.contentLanguages.length > 0 && 143 + !isPostInLanguage(item.post, store.preferences.contentLanguages), 144 + [item.post, store.preferences.contentLanguages], 145 + ) 146 + 137 147 const onPressReply = React.useCallback(() => { 138 148 store.shell.openComposer({ 139 149 replyTo: { ··· 166 176 Toast.show('Copied to clipboard') 167 177 }, [record]) 168 178 169 - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' 170 - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') 171 - 172 179 const onOpenTranslate = React.useCallback(() => { 173 180 Linking.openURL(translatorUrl) 174 181 }, [translatorUrl]) ··· 263 270 <ImageHider moderation={item.moderation.list} style={s.mb10}> 264 271 <PostEmbeds embed={item.post.embed} style={s.mb10} /> 265 272 </ImageHider> 273 + {needsTranslation && ( 274 + <View style={[pal.borderDark, styles.translateLink]}> 275 + <Link href={translatorUrl} title="Translate"> 276 + <Text type="sm" style={pal.link}> 277 + Translate this post 278 + </Text> 279 + </Link> 280 + </View> 281 + )} 266 282 </ContentHider> 267 283 <PostCtrls 268 284 itemUri={itemUri} ··· 319 335 alignItems: 'center', 320 336 flexWrap: 'wrap', 321 337 paddingBottom: 8, 338 + }, 339 + translateLink: { 340 + marginBottom: 12, 322 341 }, 323 342 replyLine: { 324 343 position: 'absolute',
+40 -4
src/view/com/util/forms/DropdownButton.tsx
··· 24 24 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} 25 25 const ESTIMATED_BTN_HEIGHT = 50 26 26 const ESTIMATED_SEP_HEIGHT = 16 27 + const ESTIMATED_HEADING_HEIGHT = 60 27 28 28 29 export interface DropdownItemButton { 29 30 testID?: string ··· 34 35 export interface DropdownItemSeparator { 35 36 sep: true 36 37 } 37 - export type DropdownItem = DropdownItemButton | DropdownItemSeparator 38 + export interface DropdownItemHeading { 39 + heading: true 40 + label: string 41 + } 42 + export type DropdownItem = 43 + | DropdownItemButton 44 + | DropdownItemSeparator 45 + | DropdownItemHeading 38 46 type MaybeDropdownItem = DropdownItem | false | undefined 39 47 40 48 export type DropdownButtonType = ButtonType | 'bare' ··· 48 56 menuWidth?: number 49 57 children?: React.ReactNode 50 58 openToRight?: boolean 59 + openUpwards?: boolean 51 60 rightOffset?: number 52 61 bottomOffset?: number 53 62 accessibilityLabel?: string ··· 63 72 menuWidth, 64 73 children, 65 74 openToRight = false, 75 + openUpwards = false, 66 76 rightOffset = 0, 67 77 bottomOffset = 0, 68 78 accessibilityLabel, ··· 91 101 estimatedMenuHeight += ESTIMATED_SEP_HEIGHT 92 102 } else if (item && isBtn(item)) { 93 103 estimatedMenuHeight += ESTIMATED_BTN_HEIGHT 104 + } else if (item && isHeading(item)) { 105 + estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT 94 106 } 95 107 } 96 108 const newX = openToRight 97 109 ? pageX + width + rightOffset 98 110 : pageX + width - menuWidth 99 111 let newY = pageY + height + bottomOffset 100 - if (newY + estimatedMenuHeight > winHeight) { 112 + if (openUpwards || newY + estimatedMenuHeight > winHeight) { 101 113 newY -= estimatedMenuHeight 102 114 } 103 115 createDropdownMenu( ··· 357 369 return ( 358 370 <View key={index} style={[styles.separator, separatorColor]} /> 359 371 ) 372 + } else if (isHeading(item)) { 373 + return ( 374 + <View style={[styles.heading, pal.border]} key={index}> 375 + <Text style={[pal.text, styles.headingLabel]}> 376 + {item.label} 377 + </Text> 378 + </View> 379 + ) 360 380 } 361 381 return null 362 382 })} ··· 368 388 function isSep(item: DropdownItem): item is DropdownItemSeparator { 369 389 return 'sep' in item && item.sep 370 390 } 391 + function isHeading(item: DropdownItem): item is DropdownItemHeading { 392 + return 'heading' in item && item.heading 393 + } 371 394 function isBtn(item: DropdownItem): item is DropdownItemButton { 372 - return !isSep(item) 395 + return !isSep(item) && !isHeading(item) 373 396 } 374 397 375 398 const styles = StyleSheet.create({ ··· 403 426 paddingTop: 12, 404 427 }, 405 428 icon: { 406 - marginLeft: 6, 429 + marginLeft: 2, 407 430 marginRight: 8, 408 431 }, 409 432 label: { ··· 412 435 separator: { 413 436 borderTopWidth: 1, 414 437 marginVertical: 8, 438 + }, 439 + heading: { 440 + flexDirection: 'row', 441 + justifyContent: 'center', 442 + paddingVertical: 10, 443 + paddingLeft: 15, 444 + paddingRight: 20, 445 + borderBottomWidth: 1, 446 + marginBottom: 6, 447 + }, 448 + headingLabel: { 449 + fontSize: 18, 450 + fontWeight: '500', 415 451 }, 416 452 })
+5 -1
src/view/index.ts
··· 24 24 import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar' 25 25 import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera' 26 26 import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck' 27 - import {faCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' 27 + import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle' 28 + import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' 29 + import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' 28 30 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation' 29 31 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser' 30 32 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone' ··· 112 114 farCalendar, 113 115 faCamera, 114 116 faCheck, 117 + faCircle, 115 118 faCircleCheck, 119 + farCircleCheck, 116 120 faCircleExclamation, 117 121 faCircleUser, 118 122 faClone,