diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/api/feed-manip.ts | 59 | ||||
-rw-r--r-- | src/lib/api/index.ts | 12 | ||||
-rw-r--r-- | src/lib/functions.ts | 5 | ||||
-rw-r--r-- | src/platform/detection.ts | 6 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 59 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 5 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 26 | ||||
-rw-r--r-- | src/view/com/composer/select-language/SelectLangBtn.tsx | 56 | ||||
-rw-r--r-- | src/view/com/modals/ContentLanguagesSettings.tsx | 143 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx | 52 | ||||
-rw-r--r-- | src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx | 100 | ||||
-rw-r--r-- | src/view/com/modals/lang-settings/LanguageToggle.tsx | 56 | ||||
-rw-r--r-- | src/view/com/modals/lang-settings/PostLanguagesSettings.tsx | 97 | ||||
-rw-r--r-- | src/view/com/util/forms/ToggleButton.tsx | 2 |
16 files changed, 516 insertions, 174 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index d1f516bc0..da89ca88f 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -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 } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 6235ca343..458ef7baa 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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()}`) diff --git a/src/lib/functions.ts b/src/lib/functions.ts index d6fbf5b92..b45c7fa6d 100644 --- a/src/lib/functions.ts +++ b/src/lib/functions.ts @@ -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] +} diff --git a/src/platform/detection.ts b/src/platform/detection.ts index da33fdca7..3069c9be2 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -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), +) diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 6c9dc756e..28c7c5666 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -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 + } else { + // default to the device languages + this.contentLanguages = deviceLocales } - if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { - Object.assign(this.contentLabels, v.contentLabels) + // 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.contentLanguages = deviceLocales.map(locale => locale.languageCode) + 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, diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index c7e72e695..d6ece48aa 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -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 diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f88cf4bf0..abac291a2 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 selectTextInputPlaceholder = replyTo ? 'Write your reply' : "What's up?" + const canPost = useMemo( + () => graphemeLength <= MAX_GRAPHEME_LENGTH, + [graphemeLength], + ) + 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> diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx new file mode 100644 index 000000000..8c55e1c91 --- /dev/null +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -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, + }, +}) diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx deleted file mode 100644 index 700f1cbcb..000000000 --- a/src/view/com/modals/ContentLanguagesSettings.tsx +++ /dev/null @@ -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, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 5989d9ff9..b276dabc0 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -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 /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3895d47ac..77842d3e1 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -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') { diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx new file mode 100644 index 000000000..e1ecce589 --- /dev/null +++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx @@ -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, + }, +}) diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx new file mode 100644 index 000000000..4f7bbc9c7 --- /dev/null +++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx @@ -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, + }, +}) diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx new file mode 100644 index 000000000..df1b405ca --- /dev/null +++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx @@ -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, + }, +}) diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx new file mode 100644 index 000000000..3dc35e9ed --- /dev/null +++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx @@ -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, + }, +}) diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 804d414b3..47620d0a6 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -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, { |