From 3bd17d38c70de02fc11bb048c42a673f150491e7 Mon Sep 17 00:00:00 2001 From: Julian Tigler Date: Thu, 26 Sep 2024 11:11:52 -0700 Subject: [PATCH] Fix unknown ballot categories crashed the vote tab - It's likely that new ballot categories will be added in the future, so it'd be bad if apps that hadn't updated yet started crashing - This change updates the BallotPreviewsScreen to explicitly handle new/unknown ballot categories by displaying the BallotPreviewRow with a warning icon, title explaining that an update is needed to view the new vote, and subtitle explaining that tapping the row will start the update process (by opening the Organize app store page). - Remove BallotTypeInfo type in favor of the more general BallotType type - BallotTypeInfo's navigation info was only used in BallotTypeScreen, so that navigation logic was explicitly moved to BallotTypeScreen - Remove ballotTypeMap in favor of getBallotTypeInfo which explicitly handles the new/unknown category scenario - Update BallotPreviewRow to handle the unknown BallotCategory - Add appStoreURI to Routes and re-export from model/index.ts so that components don't need to import it directly from networking --- .../controls/lists/rows/BallotPreviewRow.tsx | 16 ++++++-- app/model/BallotTypes.ts | 25 ++++++------ app/model/index.ts | 6 ++- app/model/types.ts | 11 ------ app/networking/BallotAPI.ts | 4 ++ app/networking/Routes.ts | 1 + app/networking/index.ts | 2 +- app/networking/types.ts | 2 +- app/screens/vote/BallotPreviewsScreen.tsx | 39 ++++++++++++------- app/screens/vote/BallotTypeScreen.tsx | 23 ++++++----- 10 files changed, 73 insertions(+), 56 deletions(-) diff --git a/app/components/controls/lists/rows/BallotPreviewRow.tsx b/app/components/controls/lists/rows/BallotPreviewRow.tsx index 9ba48df2..2c13c6b1 100644 --- a/app/components/controls/lists/rows/BallotPreviewRow.tsx +++ b/app/components/controls/lists/rows/BallotPreviewRow.tsx @@ -4,7 +4,7 @@ import { } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialIcons'; import { - BallotPreview, ballotTypeMap, getMessageAge, getTimeRemaining, + BallotPreview, getBallotTypeInfo, getMessageAge, getTimeRemaining, nominationsTimeRemainingFormatter, votingTimeRemainingFormatter, } from '../../../../model'; import useTheme from '../../../../Theme'; @@ -12,6 +12,9 @@ import { DisclosureIcon, HighlightedCurrentUserRowContainer, } from '../../../views'; +const UNKNOWN_BALLOT_TYPE_TITLE = 'New vote type: you must update your app to see this vote'; +const UNKNOWN_BALLOT_TYPE_SUBTITLE = 'Tap to update your app'; + const useStyles = () => { const { colors, font, sizes, spacing, @@ -85,7 +88,12 @@ export default function BallotRow({ item, onPress }: Props) { const { category, question, userId, nominationsEndAt, votingEndsAt, } = item; - const subtitle = getSubtitle(votingEndsAt, nominationsEndAt); + const ballotTypeInfo = getBallotTypeInfo(category); + const title = (ballotTypeInfo.category === 'unknown') + ? UNKNOWN_BALLOT_TYPE_TITLE : question; + const subtitle = (ballotTypeInfo.category === 'unknown') + ? UNKNOWN_BALLOT_TYPE_SUBTITLE + : getSubtitle(votingEndsAt, nominationsEndAt); const { colors, styles } = useStyles(); @@ -98,9 +106,9 @@ export default function BallotRow({ item, onPress }: Props) { style={styles.container} userIds={[userId]} > - + - {question} + {title} {subtitle} diff --git a/app/model/BallotTypes.ts b/app/model/BallotTypes.ts index 4ff20aca..d7ac2307 100644 --- a/app/model/BallotTypes.ts +++ b/app/model/BallotTypes.ts @@ -1,37 +1,34 @@ import { useEffect, useMemo } from 'react'; import { useMyPermissions } from './context'; -import { BallotCategory, BallotTypeInfo } from './types'; +import { BallotType } from './types'; -const ballotTypes: BallotTypeInfo[] = [ +const ballotTypes: BallotType[] = [ { category: 'yes_no', iconName: 'thumb-up', name: 'Yes or No', - newScreenName: 'NewYesOrNoBallot', }, { category: 'multiple_choice', iconName: 'check-box', name: 'Multiple Choice', - newScreenName: 'NewMultipleChoiceBallot', }, { category: 'election', iconName: 'person', name: 'Election', - newScreenName: 'NewElectionBallot', - subtypeSelectionScreenName: 'OfficeAvailability', }, ]; -type BallotTypeMap = { - [key in BallotCategory]: Omit; -}; - -export const ballotTypeMap = ballotTypes.reduce(( - accumulator, - { category, ...rest }, -) => ({ ...accumulator, [category]: rest }), {} as BallotTypeMap); +export function getBallotTypeInfo(category: string): BallotType { + return ballotTypes.find( + (ballotType) => ballotType.category === category, + ) ?? { + category: 'unknown', + iconName: 'warning', + name: 'Unknown', + }; +} export default function useBallotTypes() { const { can, refreshMyPermissions } = useMyPermissions({ diff --git a/app/model/index.ts b/app/model/index.ts index 656c636f..a7d1f28e 100644 --- a/app/model/index.ts +++ b/app/model/index.ts @@ -1,5 +1,5 @@ export { default as useAppState } from './AppState'; -export { default as useBallotTypes, ballotTypeMap } from './BallotTypes'; +export { default as useBallotTypes, getBallotTypeInfo } from './BallotTypes'; export * from './Config'; export { default as useConnection } from './Connection'; export { default as createCurrentUser } from './CurrentUserCreation'; @@ -26,4 +26,6 @@ export * from './formatters'; export * from './keys'; export * from './types'; -export { privacyPolicyURI, termsOfServiceURI } from '../networking'; +export { + appStoreURI, privacyPolicyURI, termsOfServiceURI, +} from '../networking'; diff --git a/app/model/types.ts b/app/model/types.ts index bca19c58..23e09509 100644 --- a/app/model/types.ts +++ b/app/model/types.ts @@ -88,17 +88,6 @@ export type BallotType = { name: string; }; -type NewBallotSubtypeSelectionScreen = 'OfficeAvailability'; -type NewBallotScreen = 'NewYesOrNoBallot' | 'NewMultipleChoiceBallot' | 'NewElectionBallot'; - -export type BallotTypeInfo = { - category: BallotCategory; - iconName: string; - name: string; - newScreenName: NewBallotScreen; - subtypeSelectionScreenName?: NewBallotSubtypeSelectionScreen; -}; - export type Model = { id: string; }; diff --git a/app/networking/BallotAPI.ts b/app/networking/BallotAPI.ts index b449eef9..4bc8140b 100644 --- a/app/networking/BallotAPI.ts +++ b/app/networking/BallotAPI.ts @@ -147,6 +147,10 @@ export async function fetchBallotPreviews({ ({ encryptedQuestion, ...b }, i) => ({ ...b, question: questions[i]! }), ); + // Uncomment the code below to test the effects of a new ballot category on an + // old app version + // ballotPreviews[0].category = 'unknown'; + return { ballotPreviews, paginationData }; } diff --git a/app/networking/Routes.ts b/app/networking/Routes.ts index 52a633a5..e27442c4 100644 --- a/app/networking/Routes.ts +++ b/app/networking/Routes.ts @@ -7,6 +7,7 @@ import { PermissionScope } from './types'; const prodOrigin = 'https://getorganize.app'; const origin = __DEV__ ? 'http://localhost:8080' : prodOrigin; +export const appStoreURI = ({ ref }: { ref: string }) => `${prodOrigin}/store?ref=${ref}`; export const privacyPolicyURI = `${prodOrigin}/privacy`; export const termsOfServiceURI = `${prodOrigin}/terms`; diff --git a/app/networking/index.ts b/app/networking/index.ts index 9258ebc0..3f873aa8 100644 --- a/app/networking/index.ts +++ b/app/networking/index.ts @@ -18,7 +18,7 @@ export { createPermission, fetchPermission, fetchMyPermissions, } from './PermissionAPI'; export { createPost, fetchPost, fetchPosts } from './PostAPI'; -export { privacyPolicyURI, termsOfServiceURI } from './Routes'; +export { appStoreURI, privacyPolicyURI, termsOfServiceURI } from './Routes'; export { createTerm } from './TermAPI'; export { default as createOrUpdateUpvote } from './UpvoteAPI'; export { diff --git a/app/networking/types.ts b/app/networking/types.ts index be905757..c02aad91 100644 --- a/app/networking/types.ts +++ b/app/networking/types.ts @@ -328,7 +328,7 @@ export function isCommentThreadResponse(object: unknown): object is CommentThrea return response?.thread && isCommentThreadComment(response.thread); } -export type BallotCategory = 'yes_no' | 'multiple_choice' | 'election'; +export type BallotCategory = 'yes_no' | 'multiple_choice' | 'election' | 'unknown'; export type BallotIndexBallot = { category: BallotCategory, diff --git a/app/screens/vote/BallotPreviewsScreen.tsx b/app/screens/vote/BallotPreviewsScreen.tsx index 2eac7098..3c7daa0a 100644 --- a/app/screens/vote/BallotPreviewsScreen.tsx +++ b/app/screens/vote/BallotPreviewsScreen.tsx @@ -1,10 +1,11 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; +import React, { useCallback } from 'react'; +import { Linking, StyleSheet } from 'react-native'; import { BallotPreviewList, PrimaryButton, ScreenBackground, } from '../../components'; import useTheme from '../../Theme'; import type { BallotPreviewsScreenProps } from '../../navigation'; +import { appStoreURI, BallotPreview } from '../../model'; const useStyles = () => { const { sizes, spacing } = useTheme(); @@ -33,22 +34,32 @@ export default function VoteScreen({ }: BallotPreviewsScreenProps) { const prependedBallotId = route.params?.prependedBallotId; const { styles } = useStyles(); + + const onItemPress = useCallback(({ + category, id, nominationsEndAt, votingEndsAt, + }: BallotPreview) => { + if (category === 'unknown') { + Linking.openURL(appStoreURI({ ref: 'ballot-preview-row' })); + return; + } + + const now = new Date().getTime(); + const inNominations = now < (nominationsEndAt?.getTime() ?? 0); + const active = now < votingEndsAt.getTime(); + let screenName: 'Ballot' | 'Nominations' | 'Result'; + if (active) { + screenName = inNominations ? 'Nominations' : 'Ballot'; + } else { + screenName = 'Result'; + } + navigation.navigate(screenName, { ballotId: id }); + }, [navigation]); + return ( { - const now = new Date().getTime(); - const inNominations = now < (nominationsEndAt?.getTime() ?? 0); - const active = now < votingEndsAt.getTime(); - let screenName: 'Ballot' | 'Nominations' | 'Result'; - if (active) { - screenName = inNominations ? 'Nominations' : 'Ballot'; - } else { - screenName = 'Result'; - } - navigation.navigate(screenName, { ballotId: id }); - }} + onItemPress={onItemPress} prependedBallotId={prependedBallotId} /> { - const { - newScreenName, subtypeSelectionScreenName, - } = ballotTypeMap[category]; - const screen = subtypeSelectionScreenName ?? newScreenName; - - // @ts-ignore because params are only required for screens that use a - // subtypeSelectionScreen first to determine the relevant params, instead of - // navigating directly to the newScreen + let screen: NavigationTarget; + if (category === 'election') { + screen = 'OfficeAvailability'; + } else if (category === 'multiple_choice') { + screen = 'NewMultipleChoiceBallot'; + } else if (category === 'yes_no') { + screen = 'NewYesOrNoBallot'; + } else { + throw new Error(`Unhandled ballot category: ${category}`); + } navigation.navigate(screen); }, [navigation]);