diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml new file mode 100644 index 0000000000..27a3477da4 --- /dev/null +++ b/.github/workflows/nightly-update-source-languages.yaml @@ -0,0 +1,38 @@ +name: Nightly Update Source Languages +on: + schedule: + - cron: '0 2 * * *' # run at 2 AM UTC + workflow_dispatch: + +jobs: + extract-languages: + name: Nightly Update Source Languages + runs-on: ubuntu-latest + + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the + # added or changed files to the repository. + contents: write + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + with: + ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}} + - name: Install node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Yarn install + uses: Wandalen/wretry.action@master + with: + command: yarn --frozen-lockfile + attempt_limit: 3 + attempt_delay: 2000 + - name: Extract language strings + run: yarn intl:extract + - name: Create commit + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: Nightly source-language update + file_pattern: ./src/locale/locales/en/messages.po diff --git a/__tests__/lib/strings/handles.test.ts b/__tests__/lib/strings/handles.test.ts new file mode 100644 index 0000000000..4456fae946 --- /dev/null +++ b/__tests__/lib/strings/handles.test.ts @@ -0,0 +1,42 @@ +import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles' + +describe('handle validation', () => { + const valid = [ + ['ali', 'bsky.social'], + ['alice', 'bsky.social'], + ['a-lice', 'bsky.social'], + ['a-----lice', 'bsky.social'], + ['123', 'bsky.social'], + ['123456789012345678', 'bsky.social'], + ['alice', 'custom-pds.com'], + ['alice', 'my-custom-pds-with-long-name.social'], + ['123456789012345678', 'my-custom-pds-with-long-name.social'], + ] + it.each(valid)(`should be valid: %s.%s`, (handle, service) => { + const result = validateServiceHandle(handle, service) + expect(result.overall).toEqual(true) + }) + + const invalid = [ + ['al', 'bsky.social', 'frontLength'], + ['-alice', 'bsky.social', 'hyphenStartOrEnd'], + ['alice-', 'bsky.social', 'hyphenStartOrEnd'], + ['%%%', 'bsky.social', 'handleChars'], + ['1234567890123456789', 'bsky.social', 'frontLength'], + [ + '1234567890123456789', + 'my-custom-pds-with-long-name.social', + 'frontLength', + ], + ['al', 'my-custom-pds-with-long-name.social', 'frontLength'], + ['a'.repeat(300), 'toolong.com', 'totalLength'], + ] satisfies [string, string, keyof IsValidHandle][] + it.each(invalid)( + `should be invalid: %s.%s due to %s`, + (handle, service, expectedError) => { + const result = validateServiceHandle(handle, service) + expect(result.overall).toEqual(false) + expect(result[expectedError]).toEqual(false) + }, + ) +}) diff --git a/app.config.js b/app.config.js index 9916e3e65f..2303a2a338 100644 --- a/app.config.js +++ b/app.config.js @@ -86,6 +86,7 @@ module.exports = function (config) { 'gl', 'hi', 'hu', + 'ia', 'id', 'it', 'ja', diff --git a/assets/icons/chevronTopBottom_stroke2_corner0_rounded.svg b/assets/icons/chevronTopBottom_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..249846bc3d --- /dev/null +++ b/assets/icons/chevronTopBottom_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/coffee_stroke2_corner0_rounded.svg b/assets/icons/coffee_stroke2_corner0_rounded.svg deleted file mode 100644 index 90bd5b6ae1..0000000000 --- a/assets/icons/coffee_stroke2_corner0_rounded.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/piggyBank_stroke2_corner0_rounded.svg b/assets/icons/piggyBank_stroke2_corner0_rounded.svg deleted file mode 100644 index 0ec432635b..0000000000 --- a/assets/icons/piggyBank_stroke2_corner0_rounded.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/poop_stroke2_corner0_rounded.svg b/assets/icons/poop_stroke2_corner0_rounded.svg deleted file mode 100644 index d6342739ff..0000000000 --- a/assets/icons/poop_stroke2_corner0_rounded.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000000..b5ed92f284 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,20 @@ +{ + "project_id": "664276", + "base_path": ".", + "preserve_hierarchy": true, + "files": [ + { + "source": "/src/locale/locales/en/messages.po", + "translation": "/src/locale/locales/%two_letters_code%/messages.po", + "languages_mapping": { + "two_letters_code": { + "pt-BR": "pt-BR", + "en-GB": "en-GB", + "zh-CN": "zh-CN", + "zh-TW": "zh-TW", + "zh-HK": "zh-HK" + } + } + } + ] +} \ No newline at end of file diff --git a/docs/localization.md b/docs/localization.md index 0de2447952..25c4518e6e 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -3,9 +3,64 @@ We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app) ## Tools -We are using Lingui to manage translations. You can find the documentation [here](https://lingui.dev/). + +- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/). +- We use Crowdin to manage translations. + - Bluesky Crowdin: https://crowdin.com/project/bluesky-social + - Introduction to Crowdin: https://support.crowdin.com/for-translators/ + +## Translators + +Much of the app is translated by community contributions. (We <3 our translators!) If you want to participate in the translation of the app, read this section. + +### Using Crowdin + +[Crowdin](https://crowdin.com/project/bluesky-social) is our primary tool for managing translations. There are two roles: + +- **Proof-readers**. Can create new translations and approve submitted translations. +- **Translators**. Can create new translations. + +All translations must be approved by proof-readers before they are accepted into the app. + +### Using other platforms + +You may contribute PRs separately from Crowdin, however we strongly recommend using Crowdin to avoid conflicts. + +### Code of conduct on Crowdin + +Please treat everyone with respect. Proof-readers are given final say on translations. Translators who frequently come into conflict with other translators, or who contribute noticably incorrect translations, will have their membership to the Crowdin project revoked. + +### Adding a new language + +Create a new [Crowdin discussion](https://crowdin.com/project/bluesky-social/discussions) or [GitHub issue](https://github.com/bluesky-social/social-app/issues) requesting the new language be added to the project. + +Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language. + +## Maintainers + +Install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/). You will need to [configure your API token](https://crowdin.github.io/crowdin-cli/configuration) to access the project. + +### English source-file sync with Crowdin + +Every night, a GitHub action will run `yarn intl:extract` to update the english `messages.po` file. This will be automatically synced with Crowdin. Crowdin should notify all subscribed users of new translations. + +### Release process + +1. Pull main and create a branch. +1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. +1. Create a PR, ensure the translations all look correct, and merge. +1. If needed: + 1. Merge all approved translation PRs (contributions from outside crowdin). + 1. Run `yarn intl:push` to sync Crowdin with the state of the repo. + +### Testing the translations in Crowdin + +You can run `yarn intl:pull` to pull the currently-approved translations from Crowdin. + +## Developers ### Adding new strings + When adding a new string, do it as follows: ```jsx // Before @@ -61,6 +116,7 @@ So the workflow is as follows: 6. Enjoy translated app! ### Common pitfalls + These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown. ```jsx @@ -111,9 +167,3 @@ export function Welcome() { return
{welcome}
; } ``` - - -### Credits -Please check each individual `messages.po` file for the credits of the translators. We are very grateful for their help! - -If you would like to translate the Bluesky app into your language, please open a PR or issue on this repo. diff --git a/lingui.config.js b/lingui.config.js index fe93775f99..7bef435093 100644 --- a/lingui.config.js +++ b/lingui.config.js @@ -17,6 +17,7 @@ module.exports = { 'gl', 'hi', 'hu', + 'ia', 'id', 'it', 'ja', diff --git a/src/Navigation.tsx b/src/Navigation.tsx index a6332c5d88..0dcce98bf3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -70,6 +70,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' +import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostQuotesScreen} from '#/screens/Post/PostQuotes' import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' @@ -155,6 +156,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => ModerationBlockedAccounts} options={{title: title(msg`Blocked Accounts`), requireAuth: true}} /> + ModerationInteractionSettings} + options={{ + title: title(msg`Post Interaction Settings`), + requireAuth: true, + }} + /> SettingsScreen} diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index a7cf6cb3f4..c9db8accc3 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -50,6 +50,12 @@ export const atoms = { overflow_hidden: { overflow: 'hidden', }, + /** + * @platform web + */ + overflow_auto: web({ + overflow: 'auto', + }), /* * Width @@ -68,7 +74,6 @@ export const atoms = { * Used for the outermost components on screens, to ensure that they can fill * the screen and extend beyond. */ - // @ts-ignore - web only minHeight string util_screen_outer: [ web({ minHeight: '100vh', @@ -76,7 +81,7 @@ export const atoms = { native({ height: '100%', }), - ] as ViewStyle, + ] as StyleProp, /* * Theme-independent bg colors @@ -965,6 +970,34 @@ export const atoms = { transitionDelay: '50ms', }), + /* + * Animaations + */ + fade_in: web({ + animation: 'fadeIn ease-out 0.15s', + }), + fade_out: web({ + animation: 'fadeOut ease-out 0.15s', + }), + zoom_in: web({ + animation: 'zoomIn ease-out 0.1s', + }), + zoom_out: web({ + animation: 'zoomOut ease-out 0.1s', + }), + slide_in_left: web({ + // exponential easing function + animation: 'slideInLeft cubic-bezier(0.16, 1, 0.3, 1) 0.5s', + }), + slide_out_left: web({ + animation: 'slideOutLeft ease-in 0.15s', + animationFillMode: 'forwards', + }), + // special composite animation for dialogs + zoom_fade_in: web({ + animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s', + }), + /** * {@link Layout.SCROLLBAR_OFFSET} */ diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index e45133dc5a..9f4f8bb3fa 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -15,6 +15,7 @@ import {FocusScope} from '@radix-ui/react-focus-scope' import {RemoveScrollBar} from 'react-remove-scroll-bar' import {logger} from '#/logger' +import {useA11y} from '#/state/a11y' import {useDialogStateControlContext} from '#/state/dialogs' import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' @@ -152,6 +153,7 @@ export function Inner({ const t = useTheme() const {close} = React.useContext(Context) const {gtMobile} = useBreakpoints() + const {reduceMotionEnabled} = useA11y() useFocusGuards() return ( @@ -161,7 +163,7 @@ export function Inner({ aria-label={label} aria-labelledby={accessibilityLabelledBy} aria-describedby={accessibilityDescribedBy} - // @ts-ignore web only -prf + // @ts-expect-error web only -prf onClick={stopPropagation} onStartShouldSetResponder={_ => true} onTouchEnd={stopPropagation} @@ -177,10 +179,9 @@ export function Inner({ shadowColor: t.palette.black, shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowRadius: 30, - // @ts-ignore web only - animation: 'fadeIn ease-out 0.1s', }, - flatten(style), + !reduceMotionEnabled && a.zoom_fade_in, + style, ])}> + diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index d38cf9d94c..3af0215c52 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -1,5 +1,5 @@ import {createContext, useCallback, useContext} from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {GestureResponderEvent, Keyboard, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -140,6 +140,7 @@ export function MenuButton() { const {gtMobile} = useBreakpoints() const onPress = useCallback(() => { + Keyboard.dismiss() setDrawerOpen(true) }, [setDrawerOpen]) diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index dc91161682..eb91e014f7 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -4,6 +4,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {useA11y} from '#/state/a11y' import {atoms as a, flatten, useTheme, web} from '#/alf' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -177,10 +178,16 @@ export function Outer({ style?: StyleProp }>) { const t = useTheme() + const {reduceMotionEnabled} = useA11y() return ( - + {children} @@ -373,9 +382,8 @@ export function Divider() { style={flatten([ a.my_xs, t.atoms.bg_contrast_100, - { - height: 1, - }, + a.flex_shrink_0, + {height: 1}, ])} /> ) diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 288dcfe247..006f86574b 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -762,7 +762,7 @@ function SearchInput({ autoComplete="off" autoCapitalize="none" accessibilityLabel={_(msg`Search profiles`)} - accessibilityHint={_(msg`Search profiles`)} + accessibilityHint={_(msg`Searches for profiles`)} /> ) diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx index ab9c01fc06..caa052726a 100644 --- a/src/components/StarterPack/StarterPackCard.tsx +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -12,7 +12,7 @@ import {precacheResolvedUri} from '#/state/queries/resolve-uri' import {precacheStarterPack} from '#/state/queries/starter-packs' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' -import {StarterPack} from '#/components/icons/StarterPack' +import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' @@ -70,7 +70,7 @@ export function Card({ return ( - {!noIcon ? : null} + {!noIcon ? : null} + {hasIcon && topic.type === 'starter-pack' && ( + + )} + {/* - - {topic.type === 'tag' ? ( - - ) : topic.type === 'topic' ? ( - - ) : topic.type === 'feed' ? ( - - ) : ( - - )} - + + {topic.type === 'tag' ? ( + + ) : topic.type === 'topic' ? ( + + ) : topic.type === 'feed' ? ( + + ) : ( + + )} + */} - {settings[0].type === 'everybody' ? ( + {settings.length === 0 ? ( + + This post has an unknown type of threadgate on it. Your app may be + out of date. + + ) : settings[0].type === 'everybody' ? ( Everybody can reply to this post. ) : settings[0].type === 'nobody' ? ( Replies to this post are disabled. @@ -257,6 +262,19 @@ function Rule({ if (rule.type === 'mention') { return mentioned users } + if (rule.type === 'followers') { + return ( + + users following{' '} + + @{post.author.handle} + + + ) + } if (rule.type === 'following') { return ( diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index a698574a49..e4bc777023 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -40,6 +40,7 @@ import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' export type PostInteractionSettingsFormProps = { + canSave?: boolean onSave: () => void isSaving?: boolean @@ -58,20 +59,53 @@ export function PostInteractionSettingsControlledDialog({ }: PostInteractionSettingsFormProps & { control: Dialog.DialogControlProps }) { + const t = useTheme() const {_} = useLingui() + return ( - + +
+ + + + You can set default interaction settings in{' '} + + Settings → Moderation → Interaction settings. + + + + ) } +export function Header() { + return ( + + + Post interaction settings + + + Customize who can interact with this post. + + + + ) +} + export type PostInteractionSettingsDialogProps = { control: Dialog.DialogControlProps /** @@ -203,26 +237,31 @@ export function PostInteractionSettingsDialogControlledInner( - {isLoading ? ( - - - - ) : ( - - )} + +
+ + {isLoading ? ( + + + + ) : ( + + )} + ) } export function PostInteractionSettingsForm({ + canSave = true, onSave, isSaving, postgate, @@ -283,17 +322,7 @@ export function PostInteractionSettingsForm({ return ( - - Post interaction settings - - - - Customize who can interact with this post. - - - - Quote settings @@ -400,7 +429,7 @@ export function PostInteractionSettingsForm({ disabled={replySettingsDisabled} /> v.type === 'following', @@ -409,6 +438,16 @@ export function PostInteractionSettingsForm({ onPress={() => onPressAudience({type: 'following'})} disabled={replySettingsDisabled} /> + v.type === 'followers', + ) + } + onPress={() => onPressAudience({type: 'followers'})} + disabled={replySettingsDisabled} + /> {lists && lists.length > 0 ? lists.map(list => ( + )} + + + + Filter search by language + + onChange('')}> + + All languages + + + + + + {languages.map(lang => ( + onChange(lang.value)}> + {lang.label} + + + ))} + + + ) } @@ -534,12 +530,7 @@ let SearchScreenInner = ({ ( - + section.title)} {...props} /> )} @@ -597,13 +588,13 @@ SearchScreenInner = React.memo(SearchScreenInner) export function SearchScreen( props: NativeStackScreenProps, ) { - const t = useThemeNew() + const t = useTheme() const {gtMobile} = useBreakpoints() const navigation = useNavigation() const textInput = React.useRef(null) const {_} = useLingui() - const setDrawerOpen = useSetDrawerOpen() const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() // Query terms const queryParam = props.route?.params?.q ?? '' @@ -612,20 +603,72 @@ export function SearchScreen( useActorAutocompleteQuery(searchText, true) const [showAutocomplete, setShowAutocomplete] = React.useState(false) - const [searchHistory, setSearchHistory] = React.useState([]) - const [selectedProfiles, setSelectedProfiles] = React.useState< - AppBskyActorDefs.ProfileViewBasic[] - >([]) + + const [termHistory = [], setTermHistory] = useStorage(account, [ + currentAccount?.did ?? 'pwi', + 'searchTermHistory', + ] as const) + const [accountHistory = [], setAccountHistory] = useStorage(account, [ + currentAccount?.did ?? 'pwi', + 'searchAccountHistory', + ]) + + const {data: accountHistoryProfiles} = useProfilesQuery({ + handles: accountHistory, + maintainData: true, + }) + + const updateSearchHistory = useCallback( + async (item: string) => { + if (!item) return + const newSearchHistory = [ + item, + ...termHistory.filter(search => search !== item), + ].slice(0, 6) + setTermHistory(newSearchHistory) + }, + [termHistory, setTermHistory], + ) + + const updateProfileHistory = useCallback( + async (item: AppBskyActorDefs.ProfileViewBasic) => { + const newAccountHistory = [ + item.did, + ...accountHistory.filter(p => p !== item.did), + ].slice(0, 5) + setAccountHistory(newAccountHistory) + }, + [accountHistory, setAccountHistory], + ) + + const deleteSearchHistoryItem = useCallback( + async (item: string) => { + setTermHistory(termHistory.filter(search => search !== item)) + }, + [termHistory, setTermHistory], + ) + const deleteProfileHistoryItem = useCallback( + async (item: AppBskyActorDefs.ProfileViewBasic) => { + setAccountHistory(accountHistory.filter(p => p !== item.did)) + }, + [accountHistory, setAccountHistory], + ) const {params, query, queryWithParams} = useQueryManager({ initialQuery: queryParam, }) const showFilters = Boolean(queryWithParams && !showAutocomplete) - /* - * Arbitrary sizing, so guess and check, used for sticky header alignment and - * sizing. - */ - const headerHeight = 60 + (showFilters ? 40 : 0) + + // web only - measure header height for sticky positioning + const [headerHeight, setHeaderHeight] = React.useState(0) + const headerRef = React.useRef(null) + useLayoutEffect(() => { + if (isWeb) { + if (!headerRef.current) return + const measurement = (headerRef.current as Element).getBoundingClientRect() + setHeaderHeight(measurement.height) + } + }, []) useFocusEffect( useNonReactiveCallback(() => { @@ -635,30 +678,6 @@ export function SearchScreen( }), ) - React.useEffect(() => { - const loadSearchHistory = async () => { - try { - const history = await AsyncStorage.getItem('searchHistory') - if (history !== null) { - setSearchHistory(JSON.parse(history)) - } - const profiles = await AsyncStorage.getItem('selectedProfiles') - if (profiles !== null) { - setSelectedProfiles(JSON.parse(profiles)) - } - } catch (e: any) { - logger.error('Failed to load search history', {message: e}) - } - } - - loadSearchHistory() - }, []) - - const onPressMenu = React.useCallback(() => { - textInput.current?.blur() - setDrawerOpen(true) - }, [setDrawerOpen]) - const onPressClearQuery = React.useCallback(() => { scrollToTopWeb() setSearchText('') @@ -670,57 +689,6 @@ export function SearchScreen( setSearchText(text) }, []) - const updateSearchHistory = React.useCallback( - async (newQuery: string) => { - newQuery = newQuery.trim() - if (newQuery) { - let newHistory = [ - newQuery, - ...searchHistory.filter(q => q !== newQuery), - ] - - if (newHistory.length > 5) { - newHistory = newHistory.slice(0, 5) - } - - setSearchHistory(newHistory) - try { - await AsyncStorage.setItem( - 'searchHistory', - JSON.stringify(newHistory), - ) - } catch (e: any) { - logger.error('Failed to save search history', {message: e}) - } - } - }, - [searchHistory, setSearchHistory], - ) - - const updateSelectedProfiles = React.useCallback( - async (profile: AppBskyActorDefs.ProfileViewBasic) => { - let newProfiles = [ - profile, - ...selectedProfiles.filter(p => p.did !== profile.did), - ] - - if (newProfiles.length > 5) { - newProfiles = newProfiles.slice(0, 5) - } - - setSelectedProfiles(newProfiles) - try { - await AsyncStorage.setItem( - 'selectedProfiles', - JSON.stringify(newProfiles), - ) - } catch (e: any) { - logger.error('Failed to save selected profiles', {message: e}) - } - }, - [selectedProfiles, setSelectedProfiles], - ) - const navigateToItem = React.useCallback( (item: string) => { scrollToTopWeb() @@ -774,10 +742,10 @@ export function SearchScreen( (profile: AppBskyActorDefs.ProfileViewBasic) => { // Slight delay to avoid updating during push nav animation. setTimeout(() => { - updateSelectedProfiles(profile) + updateProfileHistory(profile) }, 400) }, - [updateSelectedProfiles], + [updateProfileHistory], ) const onSoftReset = React.useCallback(() => { @@ -798,36 +766,6 @@ export function SearchScreen( }, [onSoftReset, setMinimalShellMode]), ) - const handleRemoveHistoryItem = React.useCallback( - (itemToRemove: string) => { - const updatedHistory = searchHistory.filter(item => item !== itemToRemove) - setSearchHistory(updatedHistory) - AsyncStorage.setItem( - 'searchHistory', - JSON.stringify(updatedHistory), - ).catch(e => { - logger.error('Failed to update search history', {message: e}) - }) - }, - [searchHistory], - ) - - const handleRemoveProfile = React.useCallback( - (profileToRemove: AppBskyActorDefs.ProfileViewBasic) => { - const updatedProfiles = selectedProfiles.filter( - profile => profile.did !== profileToRemove.did, - ) - setSelectedProfiles(updatedProfiles) - AsyncStorage.setItem( - 'selectedProfiles', - JSON.stringify(updatedProfiles), - ).catch(e => { - logger.error('Failed to update selected profiles', {message: e}) - }) - }, - [selectedProfiles], - ) - const onSearchInputFocus = React.useCallback(() => { if (isWeb) { // Prevent a jump on iPad by ensuring that @@ -843,75 +781,89 @@ export function SearchScreen( return ( { + if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) + }} style={[ + a.relative, + a.z_10, web({ - height: headerHeight, position: 'sticky', top: 0, - zIndex: 1, }), ]}> - - - - {!gtMobile && !showAutocomplete && ( - - )} - - - - {showAutocomplete && ( - - )} + + {!gtMobile && ( + + )} + + + + + + + {showAutocomplete && ( + + )} + - {showFilters && ( - - + {showFilters && gtMobile && ( + - - )} + )} + @@ -932,12 +884,12 @@ export function SearchScreen( /> ) : ( )} @@ -1087,7 +1039,7 @@ function SearchHistory({ accessibilityRole="button" accessibilityLabel={_(msg`Remove profile`)} accessibilityHint={_( - msg`Remove profile from search history`, + msg`Removes profile from search history`, )} onPress={() => onRemoveProfileClick(profile)} hitSlop={createHitslop(6)} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 80a112705f..b76e883721 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -5,6 +5,7 @@ import {useFocusGuards} from '@radix-ui/react-focus-guards' import {FocusScope} from '@radix-ui/react-focus-scope' import {RemoveScrollBar} from 'react-remove-scroll-bar' +import {useA11y} from '#/state/a11y' import {useModals} from '#/state/modals' import {ComposerOpts, useComposerState} from '#/state/shell/composer' import { @@ -12,7 +13,7 @@ import { EmojiPickerPosition, EmojiPickerState, } from '#/view/com/composer/text-input/web/EmojiPicker.web' -import {useBreakpoints, useTheme} from '#/alf' +import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' const BOTTOM_BAR_HEIGHT = 61 @@ -41,6 +42,7 @@ function Inner({state}: {state: ComposerOpts}) { const {isModalActive} = useModals() const t = useTheme() const {gtMobile} = useBreakpoints() + const {reduceMotionEnabled} = useA11y() const [pickerState, setPickerState] = React.useState({ isOpen: false, pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, @@ -71,17 +73,15 @@ function Inner({state}: {state: ComposerOpts}) { evt.preventDefault()} onInteractOutside={evt => evt.preventDefault()} onDismiss={() => { @@ -96,6 +96,11 @@ function Inner({state}: {state: ComposerOpts}) { !gtMobile && styles.containerMobile, t.atoms.bg, t.atoms.border_contrast_medium, + !reduceMotionEnabled && [ + a.zoom_fade_in, + {animationDelay: 0.1}, + {animationFillMode: 'backwards'}, + ], ]}> + {accounts && accounts.length > 0 && ( <> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 8c30813ab5..e194a49de8 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react' +import {useEffect, useLayoutEffect, useState} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -33,6 +33,20 @@ function ShellInner() { const closeAllActiveElements = useCloseAllActiveElements() const {_} = useLingui() const showDrawer = !isDesktop && isDrawerOpen + const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer) + + useLayoutEffect(() => { + if (showDrawer !== showDrawerDelayedExit) { + if (showDrawer) { + setShowDrawerDelayedExit(true) + } else { + const timeout = setTimeout(() => { + setShowDrawerDelayedExit(false) + }, 160) + return () => clearTimeout(timeout) + } + } + }, [showDrawer, showDrawerDelayedExit]) useComposerKeyboardShortcut() useIntentHandler() @@ -56,7 +70,7 @@ function ShellInner() { - {showDrawer && ( + {showDrawerDelayedExit && ( <> + accessibilityLabel={_(msg`Close drawer menu`)} + accessibilityHint=""> - + @@ -109,7 +130,6 @@ const styles = StyleSheet.create({ backgroundColor: colors.black, // TODO }, drawerMask: { - // @ts-ignore web only position: 'fixed', width: '100%', height: '100%', @@ -118,10 +138,11 @@ const styles = StyleSheet.create({ }, drawerContainer: { display: 'flex', - // @ts-ignore web only position: 'fixed', top: 0, left: 0, height: '100%', + width: 330, + maxWidth: '80%', }, }) diff --git a/tsconfig.json b/tsconfig.json index 1c5e27eecd..001b5247e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "module": "esnext", - "types": ["node"], + "types": ["node", "jest"], "paths": { "#/*": ["./src/*"], "lib/*": ["./src/lib/*"], diff --git a/yarn.lock b/yarn.lock index 3ff7db7f2d..7d6d8af00c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -277,7 +277,7 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/lexicon@^0.4.7": +"@atproto/lexicon@^0.4.4", "@atproto/lexicon@^0.4.7": version "0.4.7" resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86" integrity sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g==