diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-04-05 18:56:02 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-05 18:56:02 -0500 |
commit | ea04c2bd330dc5b46d6f9df0d7d4619bbd8f56d0 (patch) | |
tree | 870c7d3dbffe1f382cba30b858eaa2b76b31af36 /src/view/com | |
parent | 8e28d3c6be8e063b6d563b0068cb4fc907ff5df0 (diff) | |
download | voidsky-ea04c2bd330dc5b46d6f9df0d7d4619bbd8f56d0.tar.zst |
Add user invite codes (#393)
* Add mobile UIs for invite codes * Update invite code UIs for web * Finish implementing invite code behaviors (including notifications of invited users) * Bump deps * Update web right nav to use real data; also fix lint
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/auth/create/Step2.tsx | 1 | ||||
-rw-r--r-- | src/view/com/modals/InviteCodes.tsx | 191 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/notifications/InvitedUsers.tsx | 112 | ||||
-rw-r--r-- | src/view/com/profile/FollowButton.tsx | 14 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/forms/Button.tsx | 148 |
9 files changed, 400 insertions, 79 deletions
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 8df997bd3..cf941a94e 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -35,6 +35,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Invite code </Text> <TextInput + testID="inviteCodeInput" icon="ticket" placeholder="Required for this provider" value={model.inviteCode} diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx new file mode 100644 index 000000000..5e31e16a8 --- /dev/null +++ b/src/view/com/modals/InviteCodes.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import Clipboard from '@react-native-clipboard/clipboard' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {ScrollView} from './util' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' + +export const snapPoints = ['70%'] + +export function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + + const onClose = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + + if (store.me.invites.length === 0) { + return ( + <View style={[styles.container, pal.view]} testID="inviteCodesModal"> + <View style={[styles.empty, pal.viewLight]}> + <Text type="lg" style={[pal.text, styles.emptyText]}> + You don't have any invite codes yet! We'll send you some when you've + been on Bluesky for a little longer. + </Text> + </View> + <View style={styles.flex1} /> + <View style={styles.btnContainer}> + <Button + type="primary" + label="Done" + style={styles.btn} + labelStyle={styles.btnLabel} + onPress={onClose} + /> + </View> + </View> + ) + } + + return ( + <View style={[styles.container, pal.view]} testID="inviteCodesModal"> + <Text type="title-xl" style={[styles.title, pal.text]}> + Invite a Friend + </Text> + <Text type="lg" style={[styles.description, pal.text]}> + Send these invites to your friends so they can create an account. Each + code works once! + </Text> + <Text type="sm" style={[styles.description, pal.textLight]}> + ( We'll send you more periodically. ) + </Text> + <ScrollView style={[styles.scrollContainer, pal.border]}> + {store.me.invites.map((invite, i) => ( + <InviteCode + testID={`inviteCode-${i}`} + key={invite.code} + code={invite.code} + used={invite.available - invite.uses.length <= 0 || invite.disabled} + /> + ))} + </ScrollView> + <View style={styles.btnContainer}> + <Button + testID="closeBtn" + type="primary" + label="Done" + style={styles.btn} + labelStyle={styles.btnLabel} + onPress={onClose} + /> + </View> + </View> + ) +} + +function InviteCode({ + testID, + code, + used, +}: { + testID: string + code: string + used?: boolean +}) { + const pal = usePalette('default') + const [wasCopied, setWasCopied] = React.useState(false) + + const onPress = React.useCallback(() => { + Clipboard.setString(code) + Toast.show('Copied to clipboard') + setWasCopied(true) + }, [code]) + + return ( + <TouchableOpacity + testID={testID} + style={[styles.inviteCode, pal.border]} + onPress={onPress}> + <Text + testID={`${testID}-code`} + type={used ? 'md' : 'md-bold'} + style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> + {code} + </Text> + {wasCopied ? ( + <Text style={pal.textLight}>Copied</Text> + ) : !used ? ( + <FontAwesomeIcon + icon={['far', 'clone']} + style={pal.text as FontAwesomeIconStyle} + /> + ) : undefined} + </TouchableOpacity> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 50, + }, + title: { + textAlign: 'center', + marginTop: 12, + marginBottom: 12, + }, + description: { + textAlign: 'center', + paddingHorizontal: 42, + marginBottom: 14, + }, + + scrollContainer: { + flex: 1, + borderTopWidth: 1, + marginTop: 4, + marginBottom: 16, + }, + + flex1: { + flex: 1, + }, + empty: { + paddingHorizontal: 20, + paddingVertical: 20, + borderRadius: 16, + marginHorizontal: 24, + marginTop: 10, + }, + emptyText: { + textAlign: 'center', + }, + + inviteCode: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + paddingHorizontal: 20, + paddingVertical: 14, + }, + strikeThrough: { + textDecorationLine: 'line-through', + textDecorationStyle: 'solid', + }, + + btnContainer: { + flexDirection: 'row', + justifyContent: 'center', + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + paddingHorizontal: 60, + paddingVertical: 14, + }, + btnLabel: { + fontSize: 18, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 931e3fbe4..b1c7d4738 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -14,6 +14,7 @@ import * as ReportAccountModal from './ReportAccount' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' +import * as InviteCodesModal from './InviteCodes' import {usePalette} from 'lib/hooks/usePalette' import {StyleSheet} from 'react-native' @@ -73,6 +74,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'waitlist') { snapPoints = WaitlistModal.snapPoints element = <WaitlistModal.Component /> + } else if (activeModal?.name === 'invite-codes') { + snapPoints = InviteCodesModal.snapPoints + element = <InviteCodesModal.Component /> } else { return <View /> } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 1b233cf37..e6d54926b 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -16,6 +16,7 @@ import * as RepostModal from './Repost' import * as CropImageModal from './crop-image/CropImage.web' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' +import * as InviteCodesModal from './InviteCodes' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -72,6 +73,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ChangeHandleModal.Component {...modal} /> } else if (modal.name === 'waitlist') { element = <WaitlistModal.Component /> + } else if (modal.name === 'invite-codes') { + element = <InviteCodesModal.Component /> } else { return null } diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx new file mode 100644 index 000000000..2c44eb5b5 --- /dev/null +++ b/src/view/com/notifications/InvitedUsers.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {AppBskyActorDefs} from '@atproto/api' +import {UserAvatar} from '../util/UserAvatar' +import {Text} from '../util/text/Text' +import {Link, TextLink} from '../util/Link' +import {Button} from '../util/forms/Button' +import {FollowButton} from '../profile/FollowButton' +import {CenteredView} from '../util/Views.web' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +export const InvitedUsers = observer(() => { + const store = useStores() + return ( + <CenteredView> + {store.invitedUsers.profiles.map(profile => ( + <InvitedUser key={profile.did} profile={profile} /> + ))} + </CenteredView> + ) +}) + +function InvitedUser({ + profile, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const pal = usePalette('default') + const store = useStores() + + const onPressDismiss = React.useCallback(() => { + store.invitedUsers.markSeen(profile.did) + }, [store, profile]) + + return ( + <View + testID="invitedUser" + style={[ + styles.layout, + { + backgroundColor: pal.colors.unreadNotifBg, + borderColor: pal.colors.unreadNotifBorder, + }, + ]}> + <View style={styles.layoutIcon}> + <FontAwesomeIcon + icon="user-plus" + size={24} + style={[styles.icon, s.blue3 as FontAwesomeIconStyle]} + /> + </View> + <View style={s.flex1}> + <Link href={`/profile/${profile.handle}`}> + <UserAvatar avatar={profile.avatar} size={35} /> + </Link> + <Text style={[styles.desc, pal.text]}> + <TextLink + type="md-bold" + style={pal.text} + href={`/profile/${profile.handle}`} + text={profile.displayName || profile.handle} + />{' '} + joined using your invite code! + </Text> + <View style={styles.btns}> + <FollowButton + unfollowedType="primary" + followedType="primary-light" + did={profile.did} + /> + <Button + testID="dismissBtn" + type="primary-light" + label="Dismiss" + onPress={onPressDismiss} + /> + </View> + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + layout: { + flexDirection: 'row', + borderTopWidth: 1, + padding: 10, + }, + layoutIcon: { + width: 70, + alignItems: 'flex-end', + paddingTop: 2, + }, + icon: { + marginRight: 10, + marginTop: 4, + }, + desc: { + paddingVertical: 6, + }, + btns: { + flexDirection: 'row', + gap: 10, + }, +}) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index f799e26f2..7e25fd88a 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -6,13 +6,15 @@ import {useStores} from 'state/index' import * as Toast from '../util/Toast' import {FollowState} from 'state/models/cache/my-follows' -const FollowButton = observer( +export const FollowButton = observer( ({ - type = 'inverted', + unfollowedType = 'inverted', + followedType = 'inverted', did, onToggleFollow, }: { - type?: ButtonType + unfollowedType?: ButtonType + followedType?: ButtonType did: string onToggleFollow?: (v: boolean) => void }) => { @@ -48,12 +50,12 @@ const FollowButton = observer( return ( <Button - type={followState === FollowState.Following ? 'default' : type} + type={ + followState === FollowState.Following ? followedType : unfollowedType + } onPress={onToggleFollowInner} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} /> ) }, ) - -export default FollowButton diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 0beac8a7f..339e535ad 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -8,7 +8,7 @@ import {UserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' -import FollowButton from './FollowButton' +import {FollowButton} from './FollowButton' export function ProfileCard({ testID, diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 870f503f2..cebab59c0 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -7,7 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {UserAvatar} from './UserAvatar' import {observer} from 'mobx-react-lite' -import FollowButton from '../profile/FollowButton' +import {FollowButton} from '../profile/FollowButton' import {FollowState} from 'state/models/cache/my-follows' interface PostMetaOpts { @@ -78,7 +78,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <View> <FollowButton - type="default" + unfollowedType="default" did={opts.did} onToggleFollow={onToggleFollow} /> diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index b7c058d2d..a634b47a9 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -25,6 +25,7 @@ export function Button({ type = 'primary', label, style, + labelStyle, onPress, children, testID, @@ -32,87 +33,94 @@ export function Button({ type?: ButtonType label?: string style?: StyleProp<ViewStyle> + labelStyle?: StyleProp<TextStyle> onPress?: () => void testID?: string }>) { const theme = useTheme() - const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { - primary: { - backgroundColor: theme.palette.primary.background, + const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( + type, + { + primary: { + backgroundColor: theme.palette.primary.background, + }, + secondary: { + backgroundColor: theme.palette.secondary.background, + }, + default: { + backgroundColor: theme.palette.default.backgroundLight, + }, + inverted: { + backgroundColor: theme.palette.inverted.background, + }, + 'primary-outline': { + backgroundColor: theme.palette.default.background, + borderWidth: 1, + borderColor: theme.palette.primary.border, + }, + 'secondary-outline': { + backgroundColor: theme.palette.default.background, + borderWidth: 1, + borderColor: theme.palette.secondary.border, + }, + 'primary-light': { + backgroundColor: theme.palette.default.background, + }, + 'secondary-light': { + backgroundColor: theme.palette.default.background, + }, + 'default-light': { + backgroundColor: theme.palette.default.background, + }, }, - secondary: { - backgroundColor: theme.palette.secondary.background, - }, - default: { - backgroundColor: theme.palette.default.backgroundLight, - }, - inverted: { - backgroundColor: theme.palette.inverted.background, - }, - 'primary-outline': { - backgroundColor: theme.palette.default.background, - borderWidth: 1, - borderColor: theme.palette.primary.border, - }, - 'secondary-outline': { - backgroundColor: theme.palette.default.background, - borderWidth: 1, - borderColor: theme.palette.secondary.border, - }, - 'primary-light': { - backgroundColor: theme.palette.default.background, - }, - 'secondary-light': { - backgroundColor: theme.palette.default.background, - }, - 'default-light': { - backgroundColor: theme.palette.default.background, - }, - }) - const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { - primary: { - color: theme.palette.primary.text, - fontWeight: '600', - }, - secondary: { - color: theme.palette.secondary.text, - fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, - }, - default: { - color: theme.palette.default.text, - }, - inverted: { - color: theme.palette.inverted.text, - fontWeight: '600', - }, - 'primary-outline': { - color: theme.palette.primary.textInverted, - fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, - }, - 'secondary-outline': { - color: theme.palette.secondary.textInverted, - fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, - }, - 'primary-light': { - color: theme.palette.primary.textInverted, - fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, - }, - 'secondary-light': { - color: theme.palette.secondary.textInverted, - fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, - }, - 'default-light': { - color: theme.palette.default.text, - fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + ) + const typeLabelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>( + type, + { + primary: { + color: theme.palette.primary.text, + fontWeight: '600', + }, + secondary: { + color: theme.palette.secondary.text, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + default: { + color: theme.palette.default.text, + }, + inverted: { + color: theme.palette.inverted.text, + fontWeight: '600', + }, + 'primary-outline': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-outline': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'primary-light': { + color: theme.palette.primary.textInverted, + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, + }, + 'secondary-light': { + color: theme.palette.secondary.textInverted, + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, + }, + 'default-light': { + color: theme.palette.default.text, + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, + }, }, - }) + ) return ( <TouchableOpacity - style={[outerStyle, styles.outer, style]} + style={[typeOuterStyle, styles.outer, style]} onPress={onPress} testID={testID}> {label ? ( - <Text type="button" style={[labelStyle]}> + <Text type="button" style={[typeLabelStyle, labelStyle]}> {label} </Text> ) : ( |