diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index ad363394ef0f..cfd2a8a017cf 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -4,7 +4,7 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import Main from './main' import {KeyboardProvider} from 'react-native-keyboard-controller' -import Animated, {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated' +import {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated' import {AppRegistry, AppState, Appearance, Platform} from 'react-native' import {PortalProvider} from '@/common-adapters/portal.native' import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context' @@ -106,7 +106,6 @@ const useInit = () => { if (inited) return inited = true initDarkMode() - Animated.addWhitelistedNativeProps({text: true}) const {batch} = C.useWaitingState.getState().dispatch const eng = makeEngine(batch, c => { if (c) { diff --git a/shared/chat/audio/audio-recorder.native.tsx b/shared/chat/audio/audio-recorder.native.tsx index 60df934bf543..9690e0ffe54c 100644 --- a/shared/chat/audio/audio-recorder.native.tsx +++ b/shared/chat/audio/audio-recorder.native.tsx @@ -16,9 +16,10 @@ import * as Haptics from 'expo-haptics' import {File} from 'expo-file-system' import AudioSend from './audio-send.native' import {useConversationSendActions} from '@/chat/conversation/send-actions' +import {scheduleOnRN} from 'react-native-worklets' const {useSharedValue, Extrapolation, useAnimatedStyle} = Reanimated -const {interpolate, withSequence, withSpring, runOnJS} = Reanimated +const {interpolate, withSequence, withSpring} = Reanimated const {useAnimatedReaction, withDelay, withTiming, interpolateColor, default: Animated} = Reanimated const AnimatedBox2 = Animated.createAnimatedComponent(Kb.Box2) type SVN = Reanimated.SharedValue @@ -75,7 +76,7 @@ const useTooltip = () => { const flashTip = () => { 'worklet' - runOnJS(setShowTooltip)(true) + scheduleOnRN(setShowTooltip, true) } return {flashTip, tooltip} @@ -217,11 +218,11 @@ const useIconAndOverlay = (p: { if (f === 0) { if (fadeSyncedSV.value !== 0) { fadeSyncedSV.set(0) - runOnJS(setVisible)(Visible.HIDDEN) + scheduleOnRN(setVisible, Visible.HIDDEN) } } else if (fadeSyncedSV.value !== 1) { fadeSyncedSV.set(1) - runOnJS(setVisible)(Visible.SHOW) + scheduleOnRN(setVisible, Visible.SHOW) } } ) diff --git a/shared/chat/conversation/attachment-actions.tsx b/shared/chat/conversation/attachment-actions.tsx index a88ac0b88675..febd30410e86 100644 --- a/shared/chat/conversation/attachment-actions.tsx +++ b/shared/chat/conversation/attachment-actions.tsx @@ -246,9 +246,8 @@ export const messageAttachmentNativeSaveMessage = ( logger.info('Trying to save chat attachment to camera roll') await PlatformSpecific.saveAttachmentToCameraRoll(fileName, fileType) } catch (err) { - const errString = String(err) - logger.error('Failed to save attachment: ' + errString) - throw new Error('Failed to save attachment: ' + errString, {cause: err}) + // saveAttachmentToCameraRoll already fired a local notification for this failure + logger.error('Failed to save attachment: ' + String(err)) } } ignorePromise(f()) @@ -392,9 +391,9 @@ export const useConversationAttachmentActions = () => { await PlatformSpecific.saveAttachmentToCameraRoll(fileName, fileType) actions.setAttachmentMobileSaving(ordinal, false) } catch (err) { - const errString = String(err) + const errString = err instanceof Error ? err.message : String(err) logger.error('Failed to save attachment: ' + errString) - throw new Error('Failed to save attachment: ' + errString, {cause: err}) + actions.failAttachmentDownload(ordinal, 'Failed to save attachment: ' + errString) } } ignorePromise(f()) diff --git a/shared/common-adapters/reanimated.tsx b/shared/common-adapters/reanimated.tsx index 6b387ae93593..b59e1b47d7a1 100644 --- a/shared/common-adapters/reanimated.tsx +++ b/shared/common-adapters/reanimated.tsx @@ -9,6 +9,9 @@ let useAnimatedStyle: Record>(arg: T) => Retur let withTiming: typeof R.withTiming let withDelay: typeof R.withDelay let useAnimatedScrollHandler: typeof R.useAnimatedScrollHandler +// reanimated only deprecates manual wrapping of built-ins (Animated.FlatList etc); it's still the +// only API for animating custom components like Kb.Box2 +// eslint-disable-next-line @typescript-eslint/no-deprecated let createAnimatedComponent: typeof R.default.createAnimatedComponent let Animated: typeof R.default let interpolate: typeof R.interpolate @@ -19,6 +22,7 @@ let useReducedMotion: typeof R.useReducedMotion if (isMobile && !skipAnimations) { const rnr = ReanimatedRT as typeof R Animated = rnr.default + // eslint-disable-next-line @typescript-eslint/no-deprecated createAnimatedComponent = rnr.default.createAnimatedComponent useAnimatedStyle = rnr.useAnimatedStyle as typeof useAnimatedStyle useSharedValue = rnr.useSharedValue diff --git a/shared/common-adapters/zoomable-image.tsx b/shared/common-adapters/zoomable-image.tsx index 0440269e0106..501df9205ac2 100644 --- a/shared/common-adapters/zoomable-image.tsx +++ b/shared/common-adapters/zoomable-image.tsx @@ -20,7 +20,8 @@ type Props = { import Toast from './toast' import Text from './text' import {View} from 'react-native' -import {useSharedValue, runOnJS} from 'react-native-reanimated' +import {useSharedValue} from 'react-native-reanimated' +import {scheduleOnRN} from 'react-native-worklets' import {fitContainer, ResumableZoom, useImageResolution} from '@/util/zoom-toolkit' import ImageNative from './image' @@ -221,7 +222,7 @@ const NativeZoomableImage = (p: Props) => { x: left, y: top, } - runOnJS(onZoom)(z) + scheduleOnRN(onZoom, z) } } diff --git a/shared/desktop/app/main-window.desktop.tsx b/shared/desktop/app/main-window.desktop.tsx index 7b994189346c..0277f560d311 100644 --- a/shared/desktop/app/main-window.desktop.tsx +++ b/shared/desktop/app/main-window.desktop.tsx @@ -246,9 +246,13 @@ const fixWindowsScalingIssue = (win: Electron.BrowserWindow) => { const maybeShowWindowOrDock = (win: Electron.BrowserWindow) => { const openedAtLogin = Electron.app.getLoginItemSettings().wasOpenedAtLogin - // app.getLoginItemSettings().restoreState is Mac only, so consider it always on in Windows + // app.getLoginItemSettings().restoreState is Mac only, so consider it always on in Windows. + // restoreState/wasOpenedAsHidden are deprecated with no replacement for the pre-13 macOS login + // item mechanism we still use + // eslint-disable-next-line @typescript-eslint/no-deprecated const isRestore = !!env.KEYBASE_RESTORE_UI || Electron.app.getLoginItemSettings().restoreState || isWindows const hideWindowOnStart = env.KEYBASE_AUTOSTART === '1' + // eslint-disable-next-line @typescript-eslint/no-deprecated const openHidden = Electron.app.getLoginItemSettings().wasOpenedAsHidden logger.info('KEYBASE_AUTOSTART =', env.KEYBASE_AUTOSTART) logger.info('KEYBASE_START_UI =', env.KEYBASE_START_UI) diff --git a/shared/eslint.config.mjs b/shared/eslint.config.mjs index 1a7bafb3126f..da278a67355a 100644 --- a/shared/eslint.config.mjs +++ b/shared/eslint.config.mjs @@ -52,6 +52,7 @@ const rules = { '@typescript-eslint/no-dupe-class-members': 'error', '@typescript-eslint/no-dynamic-delete': 'error', '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-deprecated': 'error', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-extra-non-null-assertion': 'error', '@typescript-eslint/no-extraneous-class': 'error', @@ -69,7 +70,25 @@ const rules = { '@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/no-redundant-type-constituents': 'error', '@typescript-eslint/no-require-imports': 'off', - '@typescript-eslint/no-restricted-imports': 'error', + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + message: 'Deprecated legacy API; use the class-based API from expo-contacts', + name: 'expo-contacts/legacy', + }, + { + message: 'Deprecated legacy API; use the class-based API from expo-media-library', + name: 'expo-media-library/legacy', + }, + { + message: 'Deprecated legacy API; use File/Directory from expo-file-system', + name: 'expo-file-system/legacy', + }, + ], + }, + ], '@typescript-eslint/no-this-alias': 'error', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error', '@typescript-eslint/no-unnecessary-condition': ['error', {allowConstantLoopConditions: true}], @@ -254,4 +273,12 @@ export default [ strict: ['error', 'global'], }, }, + { + // vendored from react-native-zoom-toolkit; tracks upstream, don't hold it to our deprecation rule + files: ['util/zoom-toolkit/**'], + name: 'zoom-toolkit-vendored', + rules: { + '@typescript-eslint/no-deprecated': 'off', + }, + }, ] diff --git a/shared/stores/settings-contacts.tsx b/shared/stores/settings-contacts.tsx index aa5a36fe52c1..b1a871e2c09a 100644 --- a/shared/stores/settings-contacts.tsx +++ b/shared/stores/settings-contacts.tsx @@ -66,20 +66,18 @@ export const useSettingsContactsState = Z.createZustand('settings-contact const importContactsConfigKey = (username: string) => `ui.importContacts.${username}` const nativeContactsToContacts = ( - contacts: { - data: Array<{ - name?: string - phoneNumbers?: Array<{number?: string; countryCode?: string; label?: string}> - emails?: Array<{email?: string; label?: string}> - }> - }, + contacts: Array<{ + fullName?: string | null + phones?: Array<{number?: string; label?: string}> + emails?: Array<{address?: string; label?: string}> + }>, countryCode: string ) => { - return contacts.data.reduce>((ret, contact) => { - const {name, phoneNumbers = [], emails = []} = contact + return contacts.reduce>((ret, contact) => { + const {fullName, phones = [], emails = []} = contact - const components = phoneNumbers.reduce((res, pn) => { - const formatted = getE164(pn.number || '', pn.countryCode || countryCode) + const components = phones.reduce((res, pn) => { + const formatted = getE164(pn.number || '', countryCode) if (formatted) { res.push({ label: pn.label ?? '', @@ -88,9 +86,9 @@ export const useSettingsContactsState = Z.createZustand('settings-contact } return res }, []) - components.push(...emails.map(e => ({email: e.email ?? '', label: e.label ?? ''}))) + components.push(...emails.map(e => ({email: e.address ?? '', label: e.label ?? ''}))) if (components.length) { - ret.push({components, name: name ?? ''}) + ret.push({components, name: fullName ?? ''}) } return ret @@ -220,9 +218,11 @@ export const useSettingsContactsState = Z.createZustand('settings-contact let mapped: T.RPCChat.Keybase1.Contact[] let defaultCountryCode = '' try { - const _contacts = await Contacts.getContactsAsync({ - fields: [Contacts.Fields.Name, Contacts.Fields.PhoneNumbers, Contacts.Fields.Emails], - }) + const _contacts = await Contacts.Contact.getAllDetails([ + Contacts.ContactField.FULL_NAME, + Contacts.ContactField.PHONES, + Contacts.ContactField.EMAILS, + ] as const) defaultCountryCode = (getLocales()[0].regionCode ?? '').toLowerCase() if (__DEV__ && !defaultCountryCode) { diff --git a/shared/teams/common/use-contacts.native.tsx b/shared/teams/common/use-contacts.native.tsx index 3a4f05e08abc..ef23fa06fe98 100644 --- a/shared/teams/common/use-contacts.native.tsx +++ b/shared/teams/common/use-contacts.native.tsx @@ -26,15 +26,12 @@ const compareContacts = (a: Contact, b: Contact): number => { } const fetchContacts = async (regionFromState: string): Promise<[Array, string]> => { - const contacts = await Contacts.getContactsAsync({ - fields: [ - Contacts.Fields.Name, - Contacts.Fields.PhoneNumbers, - Contacts.Fields.Emails, - Contacts.Fields.ImageAvailable, - Contacts.Fields.Image, - ], - }) + const contacts = await Contacts.Contact.getAllDetails([ + Contacts.ContactField.FULL_NAME, + Contacts.ContactField.PHONES, + Contacts.ContactField.EMAILS, + Contacts.ContactField.IMAGE, + ] as const) let region = '' if (regionFromState) { @@ -52,24 +49,24 @@ const fetchContacts = async (regionFromState: string): Promise<[Array, } } - const mapped = contacts.data.reduce>((ret, contact) => { - const {name = '', phoneNumbers = [], emails = []} = contact + const mapped = contacts.reduce>((ret, contact) => { + const name = contact.fullName ?? '' let pictureUri: string | undefined - if (contact.imageAvailable && contact.image?.uri) { - pictureUri = contact.image.uri + if (contact.image) { + pictureUri = contact.image } - phoneNumbers.forEach(pn => { + contact.phones.forEach(pn => { if (pn.number && pn.id) { - const value = getE164(pn.number, pn.countryCode || region) + const value = getE164(pn.number, region) if (value) { const valueFormatted = e164ToDisplay(value) ret.push({id: pn.id, name, pictureUri, type: 'phone', value, valueFormatted}) } } }) - emails.forEach(em => { - if (em.email && em.id) { - ret.push({id: em.id, name, pictureUri, type: 'email', value: em.email}) + contact.emails.forEach(em => { + if (em.address && em.id) { + ret.push({id: em.id, name, pictureUri, type: 'email', value: em.address}) } }) return ret diff --git a/shared/util/platform-specific/index.tsx b/shared/util/platform-specific/index.tsx index a688437ad263..2d3fce47d71d 100644 --- a/shared/util/platform-specific/index.tsx +++ b/shared/util/platform-specific/index.tsx @@ -1,8 +1,8 @@ import * as T from '@/constants/types' import logger from '@/logger' -import * as MediaLibrary from 'expo-media-library/legacy' +import * as MediaLibrary from 'expo-media-library' import * as ExpoLocation from 'expo-location' -import * as FileSystem from 'expo-file-system/legacy' +import {File} from 'expo-file-system' import {addNotificationRequest, androidShare, androidShareText} from 'react-native-kb' import {ActionSheetIOS} from 'react-native' @@ -60,12 +60,22 @@ export async function saveAttachmentToCameraRoll(filePath: string, mimeType: str const saveType: 'video' | 'photo' = mimeType.startsWith('video') ? 'video' : 'photo' const logPrefix = '[saveAttachmentToCameraRoll] ' try { - try { - // see it we can keep going anyways, android perms are needed sometimes and sometimes not w/ 33 - await requestPermissionsToWrite() - } catch {} + if (isIOS) { + // Asset.create only checks permission and never prompts, so request add-only access first + const p = await MediaLibrary.requestPermissionsAsync(true) + if (!p.granted) { + throw new Error('Please allow Keybase to add to your Photos in the phone settings.') + } + } else { + try { + // see if we can keep going anyways, android perms are needed sometimes and sometimes not w/ 33 + await requestPermissionsToWrite() + } catch (e) { + logger.warn(logPrefix + 'write permission denied, attempting save anyway: ' + String(e)) + } + } logger.info(logPrefix + `Attempting to save as ${saveType}`) - await MediaLibrary.saveToLibraryAsync(fileURL) + await MediaLibrary.Asset.create(fileURL) logger.info(logPrefix + 'Success') } catch (e) { // This can fail if the user backgrounds too quickly, so throw up a local notification @@ -78,7 +88,7 @@ export async function saveAttachmentToCameraRoll(filePath: string, mimeType: str throw e } finally { try { - await FileSystem.deleteAsync(filePath, {idempotent: true}) + new File(fileURL).delete() } catch { logger.warn('failed to unlink') }