Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/email-inbox-classic-view/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@warpspeed/email-inbox-classic-view",
"version": "0.1.0",
"description": "Classic Inbox UI for warpSpeed email - React Native component library",
"private": true,
"scripts": {
"build": "tsc",
"lint": "eslint src/ --ext .ts,.tsx",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"react": "^18.3.1",
"react-native": "^0.74.3",
"react-native-safe-area-context": "^4.10.1",
"@react-navigation/native": "^6.1.17",
"react-native-reanimated": "^3.14.0",
"@gorhom/bottom-sheet": "^4.6.3"
},
"devDependencies": {
"@react-native/typescript-config": "^0.74.0",
"@storybook/addon-actions": "^8.1.10",
"@storybook/addon-controls": "^8.1.10",
"@storybook/addon-docs": "^8.1.10",
"@storybook/react-native": "^8.1.10",
"@types/react": "^18.3.3",
"typescript": "^5.5.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { MailLabel, LABEL_ICONS } from '../types';

interface CategoryButtonsProps {
activeLabel: MailLabel;
onLabelChange: (label: MailLabel) => void;
counts?: Record<MailLabel, number>;
}

const LABEL_NAMES: Record<MailLabel, string> = {
primary: 'Primary',
promotions: 'Promotions',
updates: 'Updates',
};

/**
* Category buttons for filtering emails by label.
*/
export const CategoryButtons: React.FC<CategoryButtonsProps> = ({
activeLabel,
onLabelChange,
counts = { primary: 0, promotions: 0, updates: 0 },
}) => {
const labels: MailLabel[] = ['primary', 'promotions', 'updates'];

return (
<View style={styles.container}>
{labels.map((label) => (
<TouchableOpacity
key={label}
style={[styles.button, activeLabel === label && styles.buttonActive]}
onPress={() => onLabelChange(label)}
>
<Text style={styles.icon}>
{label === 'primary' && '⭐'}
{label === 'promotions' && '🏷️'}
{label === 'updates' && '🔄'}
</Text>
<Text style={[styles.label, activeLabel === label && styles.labelActive]}>
{LABEL_NAMES[label]}
</Text>
{counts[label] > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{counts[label]}</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
);
};

const styles = StyleSheet.create({
container: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 8,
gap: 8,
},
button: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: '#F2F2F7',
gap: 6,
},
buttonActive: {
backgroundColor: '#007AFF',
},
icon: {
fontSize: 14,
},
label: {
fontSize: 14,
fontWeight: '500',
color: '#3A3A3C',
},
labelActive: {
color: '#FFFFFF',
},
badge: {
backgroundColor: '#FF3B30',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 1,
minWidth: 20,
alignItems: 'center',
},
badgeText: {
color: '#FFFFFF',
fontSize: 11,
fontWeight: '600',
},
});

export default CategoryButtons;
166 changes: 166 additions & 0 deletions packages/email-inbox-classic-view/src/components/ClassicInboxView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { useCallback, useRef, useMemo } from 'react';
import {
View,
FlatList,
StyleSheet,
Animated as RNAnimated,
NativeSyntheticEvent,
NativeScrollEvent,
} from 'react-native';
import { TopNavigation } from './TopNavigation';
import { CategoryButtons } from './CategoryButtons';
import { SearchFilterBar } from './SearchFilterBar';
import { EmailListItem } from './EmailListItem';
import { Email, MailLabel, InboxCategory } from '../types';

interface ClassicInboxViewProps {
emails: Email[];
activeCategory: InboxCategory;
activeLabel: MailLabel;
searchQuery: string;
isRefreshing: boolean;
onNavChange: (navId: string) => void;
onLabelChange: (label: MailLabel) => void;
onSearchChange: (query: string) => void;
onFilterPress: () => void;
onEmailPress: (email: Email) => void;
onRefresh: () => void;
}

const HEADER_VISIBLE_HEIGHT = 140; // height of header content when shown
const HEADER_HIDDEN_HEIGHT = 0;

/**
* Classic Inbox View - main page component.
* Features smooth scroll-based header hide/reveal behavior.
*/
export const ClassicInboxView: React.FC<ClassicInboxViewProps> = ({
emails,
activeCategory,
activeLabel,
searchQuery,
isRefreshing,
onNavChange,
onLabelChange,
onSearchChange,
onFilterPress,
onEmailPress,
onRefresh,
}) => {
const scrollY = useRef(new RNAnimated.Value(0)).current;
const lastScrollY = useRef(0);
const isHeaderVisible = useRef(true);

const headerTranslate = scrollY.interpolate({
inputRange: [0, 100],
outputRange: [0, -HEADER_VISIBLE_HEIGHT],
extrapolate: 'clamp',
});

const handleScroll = useCallback(
RNAnimated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{
useNativeDriver: true,
listener: (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const currentY = event.nativeEvent.contentOffset.y;
const diff = currentY - lastScrollY.current;

// Determine direction
if (diff > 5 && currentY > 30 && isHeaderVisible.current) {
// Scrolling down - hide header
isHeaderVisible.current = false;
} else if (diff < -5 && !isHeaderVisible.current) {
// Scrolling up - show header
isHeaderVisible.current = true;
}

lastScrollY.current = currentY;
},
}
),
[scrollY]
);

const renderEmailItem = useCallback(
({ item }: { item: Email }) => (
<EmailListItem email={item} onPress={onEmailPress} />
),
[onEmailPress]
);

const keyExtractor = useCallback((item: Email) => item.id, []);

return (
<View style={styles.container}>
{/* Animated Header */}
<RNAnimated.View
style={[
styles.header,
{ transform: [{ translateY: headerTranslate }] },
]}
>
<TopNavigation activeNav={activeCategory} onNavChange={onNavChange} />
<CategoryButtons activeLabel={activeLabel} onLabelChange={onLabelChange} />
<SearchFilterBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
onFilterPress={onFilterPress}
/>
</RNAnimated.View>

{/* Email List */}
<FlatList
data={emails}
renderItem={renderEmailItem}
keyExtractor={keyExtractor}
onScroll={handleScroll}
scrollEventThrottle={16}
refreshing={isRefreshing}
onRefresh={onRefresh}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyState}>
<RNAnimated.Text style={styles.emptyText}>
No emails found
</RNAnimated.Text>
</View>
}
/>
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: '#FFFFFF',
zIndex: 100,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 3,
},
listContent: {
marginTop: HEADER_VISIBLE_HEIGHT,
},
emptyState: {
padding: 40,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
color: '#8E8E93',
},
});

export default ClassicInboxView;
Loading