[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
parent
9b19a95e63
commit
08804f265e
1
app.json
1
app.json
|
@ -79,6 +79,7 @@
|
|||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"kotlinVersion": "1.8.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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__",
|
||||
|
|
|
@ -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])) {
|
||||
// we check for code3 here because that is what the `lande` library returns
|
||||
if (langsCode3.includes(mostLikelyLang)) {
|
||||
hasPreferredLang = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// no text? roll with it
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()}`)
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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 />
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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, {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue