[APP-690] better handling of post languages language filtering (#893)

* add SelectLangBtn

* memoized objects that are created to reduce re-creation on re-render

* add langs when uploading post

* only send the top 3 languages otherwise backend will throw error

* mv ContentLanguagesSettings to folder

* add post languages settings modal and state

* fix typos

* modify feed manip to also check langs label on post

* Fix tests

* Remove log

* Update feed-manip.ts

* Fix syntax errors

* UI tuneups

* Show the currently selected languages in the composer

* fix linting

* Use a bcp-47 matching function

* Fix a duplicate language issue

* Fix web

* Dont include lang in prompt

* Make select language btn an observer

* Keep device languages on top of language selection UIs

* Fix android build settings

* Enforce a max of 3 languages in posts

* Fix tests

* Fix types

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Ansh 2023-06-23 10:48:52 -07:00 committed by GitHub
parent 9b19a95e63
commit 08804f265e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 525 additions and 176 deletions

View File

@ -79,6 +79,7 @@
"expo-build-properties",
{
"android": {
"compileSdkVersion": 34,
"kotlinVersion": "1.8.0"
}
}

View File

@ -66,6 +66,7 @@
"@zxing/text-encoding": "^0.9.0",
"await-lock": "^2.2.2",
"base64-js": "^1.5.1",
"bcp-47-match": "^2.0.3",
"email-validator": "^2.0.4",
"eslint-plugin-react-native-a11y": "^3.3.0",
"expo": "~48.0.18",
@ -207,7 +208,7 @@
"node"
],
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo)"
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",

View File

@ -4,6 +4,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import * as bcp47Match from 'bcp-47-match'
import lande from 'lande'
import {hasProp} from 'lib/type-guards'
import {LANGUAGES_MAP_CODE2} from '../../locale/languages'
@ -236,44 +237,84 @@ export class FeedTuner {
}
}
static preferredLangOnly(langsCode2: string[]) {
const langsCode3 = langsCode2.map(l => LANGUAGES_MAP_CODE2[l]?.code3 || l)
/**
* This function filters a list of FeedViewPostsSlice items based on whether they contain text in a
* preferred language.
* @param {string[]} preferredLangsCode2 - An array of prefered language codes in ISO 639-1 or ISO 639-2 format.
* @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and
* returns an array of `FeedViewPostsSlice` objects.
*/
static preferredLangOnly(preferredLangsCode2: string[]) {
const langsCode3 = preferredLangsCode2.map(
l => LANGUAGES_MAP_CODE2[l]?.code3 || l,
)
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => {
if (!langsCode2.length) {
// 1. Early return if no languages have been specified
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
return slices
}
for (let i = slices.length - 1; i >= 0; i--) {
// 2. Set a flag to indicate whether the item has text in a preferred language
let hasPreferredLang = false
for (const item of slices[i].items) {
// 3. check if the post has a `langs` property and if it is in the list of preferred languages
// if it is, set the flag to true
// if language is declared, regardless of a match, break out of the loop
if (
hasProp(item.post.record, 'langs') &&
Array.isArray(item.post.record.langs)
) {
if (
bcp47Match.basicFilter(
item.post.record.langs,
preferredLangsCode2,
).length > 0
) {
hasPreferredLang = true
}
break
}
// 4. FALLBACK if no language declared :
// Get the most likely language of the text in the post from the `lande` library and
// check if it is in the list of preferred languages
// if it is, set the flag to true and break out of the loop
else if (
hasProp(item.post.record, 'text') &&
typeof item.post.record.text === 'string'
) {
// Treat empty text the same as no text.
// Treat empty text the same as no text
if (item.post.record.text.length === 0) {
hasPreferredLang = true
break
}
const langsProbabilityMap = lande(item.post.record.text)
const mostLikelyLang = langsProbabilityMap[0][0]
// const secondMostLikelyLang = langsProbabilityMap[1][0]
// const thirdMostLikelyLang = langsProbabilityMap[2][0]
const res = lande(item.post.record.text)
if (langsCode3.includes(res[0][0])) {
hasPreferredLang = true
break
}
} else {
// no text? roll with it
// we check for code3 here because that is what the `lande` library returns
if (langsCode3.includes(mostLikelyLang)) {
hasPreferredLang = true
break
}
}
// 5. no text? roll with it (eg: image-only posts, reposts, etc.)
else {
hasPreferredLang = true
break
}
}
// 6. if item does not fit preferred language, remove it
if (!hasPreferredLang) {
slices.splice(i, 1)
}
}
// 7. return the filtered list of items
return slices
}
}

View File

@ -65,6 +65,7 @@ interface PostOpts {
images?: ImageModel[]
knownHandles?: Set<string>
onStateChange?: (state: string) => void
langs?: string[]
}
export async function post(store: RootStoreModel, opts: PostOpts) {
@ -96,6 +97,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
return true
})
// add quote embed if present
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.record',
@ -106,6 +108,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
} as AppBskyEmbedRecord.Main
}
// add image embed if present
if (opts.images?.length) {
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
@ -136,6 +139,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
}
}
// add external embed if present
if (opts.extLink && !opts.images?.length) {
if (opts.extLink.embed) {
embed = opts.extLink.embed
@ -197,6 +201,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
}
}
// add replyTo if post is a reply to another post
if (opts.replyTo) {
const replyToUrip = new AtUri(opts.replyTo)
const parentPost = await store.agent.getPost({
@ -215,6 +220,12 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
}
}
// add top 3 languages from user preferences if langs is provided
let langs = opts.langs
if (opts.langs) {
langs = opts.langs.slice(0, 3)
}
try {
opts.onStateChange?.('Posting...')
return await store.agent.post({
@ -222,6 +233,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
facets: rt.facets,
reply,
embed,
langs,
})
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)

View File

@ -4,3 +4,8 @@ export function choose<U, T extends Record<string, U>>(
): U {
return choices[value]
}
export function dedupArray<T>(arr: T[]): T[] {
const s = new Set(arr)
return [...s]
}

View File

@ -1,4 +1,6 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {dedupArray} from 'lib/functions'
export const isIOS = Platform.OS === 'ios'
export const isAndroid = Platform.OS === 'android'
@ -10,3 +12,7 @@ export const isMobileWeb =
// @ts-ignore we know window exists -prf
global.window.matchMedia(isMobileWebMediaQuery)?.matches
export const isDesktopWeb = isWeb && !isMobileWeb
export const deviceLocales = dedupArray(
getLocales?.().map?.(locale => locale.languageCode),
)

View File

@ -1,5 +1,4 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {getLocales} from 'expo-localization'
import AwaitLock from 'await-lock'
import isEqual from 'lodash.isequal'
import {isObj, hasProp} from 'lib/type-guards'
@ -14,9 +13,8 @@ import {
ALWAYS_WARN_LABEL_GROUP,
} from 'lib/labeling/const'
import {DEFAULT_FEEDS} from 'lib/constants'
import {isIOS} from 'platform/detection'
const deviceLocales = getLocales()
import {isIOS, deviceLocales} from 'platform/detection'
import {LANGUAGES} from '../../../locale/languages'
export type LabelPreference = 'show' | 'warn' | 'hide'
const LABEL_GROUPS = [
@ -46,8 +44,8 @@ export class LabelPreferencesModel {
export class PreferencesModel {
adultContentEnabled = !isIOS
contentLanguages: string[] =
deviceLocales?.map?.(locale => locale.languageCode) || []
contentLanguages: string[] = deviceLocales || []
postLanguages: string[] = deviceLocales || []
contentLabels = new LabelPreferencesModel()
savedFeeds: string[] = []
pinnedFeeds: string[] = []
@ -66,6 +64,7 @@ export class PreferencesModel {
serialize() {
return {
contentLanguages: this.contentLanguages,
postLanguages: this.postLanguages,
contentLabels: this.contentLabels,
savedFeeds: this.savedFeeds,
pinnedFeeds: this.pinnedFeeds,
@ -83,19 +82,33 @@ export class PreferencesModel {
*/
hydrate(v: unknown) {
if (isObj(v)) {
// check if content languages in preferences exist, otherwise default to device languages
if (
hasProp(v, 'contentLanguages') &&
Array.isArray(v.contentLanguages) &&
typeof v.contentLanguages.every(item => typeof item === 'string')
) {
this.contentLanguages = v.contentLanguages
}
if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
Object.assign(this.contentLabels, v.contentLabels)
} else {
// default to the device languages
this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
this.contentLanguages = deviceLocales
}
// check if post languages in preferences exist, otherwise default to device languages
if (
hasProp(v, 'postLanguages') &&
Array.isArray(v.postLanguages) &&
typeof v.postLanguages.every(item => typeof item === 'string')
) {
this.postLanguages = v.postLanguages
} else {
// default to the device languages
this.postLanguages = deviceLocales
}
// check if content labels in preferences exist, then hydrate
if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
Object.assign(this.contentLabels, v.contentLabels)
}
// check if saved feeds in preferences, then hydrate
if (
hasProp(v, 'savedFeeds') &&
Array.isArray(v.savedFeeds) &&
@ -103,6 +116,7 @@ export class PreferencesModel {
) {
this.savedFeeds = v.savedFeeds
}
// check if pinned feeds in preferences exist, then hydrate
if (
hasProp(v, 'pinnedFeeds') &&
Array.isArray(v.pinnedFeeds) &&
@ -110,24 +124,28 @@ export class PreferencesModel {
) {
this.pinnedFeeds = v.pinnedFeeds
}
// check if home feed replies are enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedRepliesEnabled') &&
typeof v.homeFeedRepliesEnabled === 'boolean'
) {
this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled
}
// check if home feed replies threshold is enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedRepliesThreshold') &&
typeof v.homeFeedRepliesThreshold === 'number'
) {
this.homeFeedRepliesThreshold = v.homeFeedRepliesThreshold
}
// check if home feed reposts are enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedRepostsEnabled') &&
typeof v.homeFeedRepostsEnabled === 'boolean'
) {
this.homeFeedRepostsEnabled = v.homeFeedRepostsEnabled
}
// check if home feed quote posts are enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedQuotePostsEnabled') &&
typeof v.homeFeedQuotePostsEnabled === 'boolean'
@ -245,7 +263,8 @@ export class PreferencesModel {
try {
runInAction(() => {
this.contentLabels = new LabelPreferencesModel()
this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
this.contentLanguages = deviceLocales
this.postLanguages = deviceLocales
this.savedFeeds = []
this.pinnedFeeds = []
})
@ -271,6 +290,26 @@ export class PreferencesModel {
}
}
hasPostLanguage(code2: string) {
return this.postLanguages.includes(code2)
}
togglePostLanguage(code2: string) {
if (this.hasPostLanguage(code2)) {
this.postLanguages = this.postLanguages.filter(lang => lang !== code2)
} else {
this.postLanguages = this.postLanguages.concat([code2])
}
}
getReadablePostLanguages() {
const all = this.postLanguages.map(code2 => {
const lang = LANGUAGES.find(l => l.code2 === code2)
return lang ? lang.name : code2
})
return all.join(', ')
}
async setContentLabelPref(
key: keyof LabelPreferencesModel,
value: LabelPreference,

View File

@ -111,6 +111,10 @@ export interface ContentLanguagesSettingsModal {
name: 'content-languages-settings'
}
export interface PostLanguagesSettingsModal {
name: 'post-languages-settings'
}
export interface PreferencesHomeFeed {
name: 'preferences-home-feed'
}
@ -125,6 +129,7 @@ export type Modal =
// Curation
| ContentFilteringSettingsModal
| ContentLanguagesSettingsModal
| PostLanguagesSettingsModal
| PreferencesHomeFeed
// Moderation

View File

@ -38,6 +38,7 @@ import {isDesktopWeb, isAndroid} from 'platform/detection'
import {GalleryModel} from 'state/models/media/gallery'
import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {SelectLangBtn} from './select-language/SelectLangBtn'
type Props = ComposerOpts & {
onClose: () => void
@ -71,6 +72,13 @@ export const ComposePost = observer(function ComposePost({
)
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
() => ({
paddingBottom: isAndroid ? insets.bottom : 0,
paddingTop: isAndroid ? insets.top : isDesktopWeb ? 0 : 15,
}),
[insets],
)
// HACK
// there's a bug with @mattermost/react-native-paste-input where if the input
@ -87,6 +95,7 @@ export const ComposePost = observer(function ComposePost({
autocompleteView.setup()
}, [autocompleteView])
// listen to escape key on desktop web
const onEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
@ -109,7 +118,6 @@ export const ComposePost = observer(function ComposePost({
},
[store, onClose],
)
useEffect(() => {
if (isDesktopWeb) {
window.addEventListener('keydown', onEscape)
@ -157,6 +165,7 @@ export const ComposePost = observer(function ComposePost({
extLink: extLink,
onStateChange: setProcessingState,
knownHandles: autocompleteView.knownHandles,
langs: store.preferences.postLanguages,
})
track('Create Post', {
imageCount: gallery.size,
@ -197,15 +206,13 @@ export const ComposePost = observer(function ComposePost({
],
)
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH,
[graphemeLength],
)
const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
const selectTextInputPlaceholder = replyTo ? 'Write your reply' : "What's up?"
const canSelectImages = gallery.size < 4
const viewStyles = {
paddingBottom: isAndroid ? insets.bottom : 0,
paddingTop: isAndroid ? insets.top : isDesktopWeb ? 0 : 15,
}
const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
return (
<KeyboardAvoidingView
@ -352,6 +359,7 @@ export const ComposePost = observer(function ComposePost({
</>
) : null}
<View style={s.flex1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
</View>

View File

@ -0,0 +1,56 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet, Keyboard} from 'react-native'
import {observer} from 'mobx-react-lite'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {isNative} from 'platform/detection'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export const SelectLangBtn = observer(function SelectLangBtn() {
const pal = usePalette('default')
const store = useStores()
const onPress = useCallback(async () => {
if (isNative) {
if (Keyboard.isVisible()) {
Keyboard.dismiss()
}
}
store.shell.openModal({name: 'post-languages-settings'})
}, [store])
return (
<TouchableOpacity
testID="selectLangBtn"
onPress={onPress}
style={styles.button}
hitSlop={HITSLOP}
accessibilityRole="button"
accessibilityLabel="Language selection"
accessibilityHint="Opens screen or modal to select language of post">
{store.preferences.postLanguages.length > 0 ? (
<Text type="lg-bold" style={pal.link}>
{store.preferences.postLanguages.join(', ')}
</Text>
) : (
<FontAwesomeIcon
icon="language"
style={pal.link as FontAwesomeIconStyle}
size={26}
/>
)}
</TouchableOpacity>
)
})
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})

View File

@ -1,143 +0,0 @@
import React from 'react'
import {StyleSheet, Pressable, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {observer} from 'mobx-react-lite'
import {ScrollView} from './util'
import {useStores} from 'state/index'
import {ToggleButton} from '../util/forms/ToggleButton'
import {s, colors, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../locale/languages'
export const snapPoints = ['100%']
export function Component({}: {}) {
const store = useStores()
const pal = usePalette('default')
const onPressDone = React.useCallback(() => {
store.shell.closeModal()
}, [store])
const languages = React.useMemo(() => {
const langs = LANGUAGES.filter(
lang =>
!!lang.code2.trim() &&
LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
)
// sort so that selected languages are on top, then alphabetically
langs.sort((a, b) => {
const hasA = store.preferences.hasContentLanguage(a.code2)
const hasB = store.preferences.hasContentLanguage(b.code2)
if (hasA === hasB) return a.name.localeCompare(b.name)
if (hasA) return -1
return 1
})
return langs
}, [store])
return (
<View testID="contentLanguagesModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Languages</Text>
<Text style={[pal.text, styles.description]}>
Which languages would you like to see in the your feed? (Leave them all
unchecked to see any language.)
</Text>
<ScrollView style={styles.scrollContainer}>
{languages.map(lang => (
<LanguageToggle
key={lang.code2}
code2={lang.code2}
name={lang.name}
/>
))}
<View style={styles.bottomSpacer} />
</ScrollView>
<View style={[styles.btnContainer, pal.borderDark]}>
<Pressable
testID="sendReportBtn"
onPress={onPressDone}
accessibilityRole="button"
accessibilityLabel="Confirm content language settings"
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>Done</Text>
</LinearGradient>
</Pressable>
</View>
</View>
)
}
const LanguageToggle = observer(
({code2, name}: {code2: string; name: string}) => {
const store = useStores()
const pal = usePalette('default')
const onPress = React.useCallback(() => {
store.preferences.toggleContentLanguage(code2)
}, [store, code2])
return (
<ToggleButton
label={name}
isSelected={store.preferences.contentLanguages.includes(code2)}
onPress={onPress}
style={[pal.border, styles.languageToggle]}
/>
)
},
)
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 16,
marginBottom: 10,
},
scrollContainer: {
flex: 1,
paddingHorizontal: 10,
},
bottomSpacer: {
height: isDesktopWeb ? 0 : 60,
},
btnContainer: {
paddingTop: 10,
paddingHorizontal: 10,
paddingBottom: isDesktopWeb ? 0 : 40,
borderTopWidth: isDesktopWeb ? 0 : 1,
},
languageToggle: {
borderTopWidth: 1,
borderRadius: 0,
paddingHorizontal: 0,
paddingVertical: 12,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})

View File

@ -23,7 +23,8 @@ import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
const DEFAULT_SNAPPOINTS = ['90%']
@ -106,6 +107,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'content-languages-settings') {
snapPoints = ContentLanguagesSettingsModal.snapPoints
element = <ContentLanguagesSettingsModal.Component />
} else if (activeModal?.name === 'post-languages-settings') {
snapPoints = PostLanguagesSettingsModal.snapPoints
element = <PostLanguagesSettingsModal.Component />
} else if (activeModal?.name === 'preferences-home-feed') {
snapPoints = PreferencesHomeFeed.snapPoints
element = <PreferencesHomeFeed.Component />

View File

@ -23,7 +23,9 @@ import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
export const ModalsContainer = observer(function ModalsContainer() {
@ -94,6 +96,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ContentFilteringSettingsModal.Component />
} else if (modal.name === 'content-languages-settings') {
element = <ContentLanguagesSettingsModal.Component />
} else if (modal.name === 'post-languages-settings') {
element = <PostLanguagesSettingsModal.Component />
} else if (modal.name === 'alt-text-image') {
element = <AltTextImageModal.Component {...modal} />
} else if (modal.name === 'edit-image') {

View File

@ -0,0 +1,52 @@
import React from 'react'
import {StyleSheet, Text, View, Pressable} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {s, colors, gradients} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
export const ConfirmLanguagesButton = ({
onPress,
extraText,
}: {
onPress: () => void
extraText?: string
}) => {
const pal = usePalette('default')
return (
<View style={[styles.btnContainer, pal.borderDark]}>
<Pressable
testID="confirmContentLanguagesBtn"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Confirm content language settings"
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>Done{extraText}</Text>
</LinearGradient>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
btnContainer: {
paddingTop: 10,
paddingHorizontal: 10,
paddingBottom: isDesktopWeb ? 0 : 40,
borderTopWidth: isDesktopWeb ? 0 : 1,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})

View File

@ -0,0 +1,100 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ScrollView} from '../util'
import {useStores} from 'state/index'
import {Text} from '../../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb, deviceLocales} from 'platform/detection'
import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
import {LanguageToggle} from './LanguageToggle'
import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
export const snapPoints = ['100%']
export function Component({}: {}) {
const store = useStores()
const pal = usePalette('default')
const onPressDone = React.useCallback(() => {
store.shell.closeModal()
}, [store])
const languages = React.useMemo(() => {
const langs = LANGUAGES.filter(
lang =>
!!lang.code2.trim() &&
LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
)
// sort so that device & selected languages are on top, then alphabetically
langs.sort((a, b) => {
const hasA =
store.preferences.hasContentLanguage(a.code2) ||
deviceLocales.includes(a.code2)
const hasB =
store.preferences.hasContentLanguage(b.code2) ||
deviceLocales.includes(b.code2)
if (hasA === hasB) return a.name.localeCompare(b.name)
if (hasA) return -1
return 1
})
return langs
}, [store])
const onPress = React.useCallback(
(code2: string) => {
store.preferences.toggleContentLanguage(code2)
},
[store],
)
return (
<View testID="contentLanguagesModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Languages</Text>
<Text style={[pal.text, styles.description]}>
Which languages would you like to see in your algorithmic feeds?
</Text>
<Text style={[pal.textLight, styles.description]}>
Leave them all unchecked to see any language.
</Text>
<ScrollView style={styles.scrollContainer}>
{languages.map(lang => (
<LanguageToggle
key={lang.code2}
code2={lang.code2}
langType="contentLanguages"
name={lang.name}
onPress={() => {
onPress(lang.code2)
}}
/>
))}
<View style={styles.bottomSpacer} />
</ScrollView>
<ConfirmLanguagesButton onPress={onPressDone} />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 16,
marginBottom: 10,
},
scrollContainer: {
flex: 1,
paddingHorizontal: 10,
},
bottomSpacer: {
height: isDesktopWeb ? 0 : 60,
},
})

View File

@ -0,0 +1,56 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {observer} from 'mobx-react-lite'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {useStores} from 'state/index'
export const LanguageToggle = observer(
({
code2,
name,
onPress,
langType,
}: {
code2: string
name: string
onPress: () => void
langType: 'contentLanguages' | 'postLanguages'
}) => {
const pal = usePalette('default')
const store = useStores()
const isSelected = store.preferences[langType].includes(code2)
// enforce a max of 3 selections for post languages
let isDisabled = false
if (
langType === 'postLanguages' &&
store.preferences[langType].length >= 3 &&
!isSelected
) {
isDisabled = true
}
return (
<ToggleButton
label={name}
isSelected={isSelected}
onPress={isDisabled ? undefined : onPress}
style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]}
/>
)
},
)
const styles = StyleSheet.create({
languageToggle: {
borderTopWidth: 1,
borderRadius: 0,
paddingHorizontal: 6,
paddingVertical: 12,
},
dimmed: {
opacity: 0.5,
},
})

View File

@ -0,0 +1,97 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ScrollView} from '../util'
import {useStores} from 'state/index'
import {Text} from '../../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb, deviceLocales} from 'platform/detection'
import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages'
import {LanguageToggle} from './LanguageToggle'
import {ConfirmLanguagesButton} from './ConfirmLanguagesButton'
export const snapPoints = ['100%']
export function Component({}: {}) {
const store = useStores()
const pal = usePalette('default')
const onPressDone = React.useCallback(() => {
store.shell.closeModal()
}, [store])
const languages = React.useMemo(() => {
const langs = LANGUAGES.filter(
lang =>
!!lang.code2.trim() &&
LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3,
)
// sort so that device & selected languages are on top, then alphabetically
langs.sort((a, b) => {
const hasA =
store.preferences.hasPostLanguage(a.code2) ||
deviceLocales.includes(a.code2)
const hasB =
store.preferences.hasPostLanguage(b.code2) ||
deviceLocales.includes(b.code2)
if (hasA === hasB) return a.name.localeCompare(b.name)
if (hasA) return -1
return 1
})
return langs
}, [store])
const onPress = React.useCallback(
(code2: string) => {
store.preferences.togglePostLanguage(code2)
},
[store],
)
return (
<View testID="postLanguagesModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Post Languages</Text>
<Text style={[pal.text, styles.description]}>
Which languages are used in this post?
</Text>
<ScrollView style={styles.scrollContainer}>
{languages.map(lang => (
<LanguageToggle
key={lang.code2}
code2={lang.code2}
langType="postLanguages"
name={lang.name}
onPress={() => {
onPress(lang.code2)
}}
/>
))}
<View style={styles.bottomSpacer} />
</ScrollView>
<ConfirmLanguagesButton onPress={onPressDone} />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 16,
marginBottom: 10,
},
scrollContainer: {
flex: 1,
paddingHorizontal: 10,
},
bottomSpacer: {
height: isDesktopWeb ? 0 : 60,
},
})

View File

@ -17,7 +17,7 @@ export function ToggleButton({
label: string
isSelected: boolean
style?: StyleProp<ViewStyle>
onPress: () => void
onPress?: () => void
}) {
const theme = useTheme()
const circleStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {

View File

@ -6197,6 +6197,11 @@ batch@0.6.1:
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
bcp-47-match@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0"
integrity sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==
better-opn@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817"