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
This commit is contained in:
parent
8e28d3c6be
commit
ea04c2bd33
26 changed files with 932 additions and 246 deletions
|
@ -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}
|
||||
|
|
191
src/view/com/modals/InviteCodes.tsx
Normal file
191
src/view/com/modals/InviteCodes.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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 />
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
112
src/view/com/notifications/InvitedUsers.tsx
Normal file
112
src/view/com/notifications/InvitedUsers.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
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>
|
||||
) : (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue