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
59 changes: 59 additions & 0 deletions bounty-2/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Classic Inbox View — Component Architecture

## Overview

The Classic Inbox is a full-screen mail list view. It mimics a traditional email client with a sticky top navigation, scroll-hiding header, category filtering, search, and a paginated email list with read/unread visual states.

## Layout (top to bottom)

```
┌─────────────────────────────────────┐
│ TopNav (account + nav buttons) │ ← sticky, always visible
├─────────────────────────────────────┤
│ CategoryBar (Primary/Promos/Updates)│ ← hides on scroll down
│ SearchBar (input + filter icon) │ ← hides on scroll down
├─────────────────────────────────────┤
│ EmailList (Animated.FlatList) │ ← scrollable, drives header hide
│ └─ EmailListItem (swipeable row) │
│ └─ ... │
└─────────────────────────────────────┘
```

## Component Tree

```
App
└─ ClassicInbox (orchestrator + Animated header)
├─ TopNav
├─ Animated.View (header group)
│ ├─ CategoryBar
│ └─ SearchBar
└─ Animated.FlatList
└─ EmailListItem (×N)
```

## Data Flow

- `inboxStore` (MobX) holds emails, categories, selected account, navigation route, search/filter state.
- `ClassicInbox` reads from store and passes props down.
- Scroll events on FlatList drive `Animated.Value` used to translate the header group up/down.
- `EmailListItem` receives `email: Email` + `onToggle` callback; uses local `Animated.Value` for swipe-to-reveal (archive/delete).
- `CategoryBar` updates store's `selectedCategory`; store recomputes `filteredEmails`.
- `SearchBar` updates store's `searchQuery`; store recomputes `filteredEmails`.

## States to handle

| Component | Loading | Empty | Error | Edge cases |
|----------------|---------|-------|-------|-------------------------|
| ClassicInbox | — | ✓ | — | overscroll, pull-refresh|
| EmailListItem | — | — | — | long press, swipe |
| CategoryBar | — | — | — | single category, all |
| TopNav | — | — | — | long account name |
| SearchBar | — | — | — | clear, debounce |
| InboxStore | ✓ | ✓ | ✓ | empty search results |

## Animations

1. **Header hide/reveal**: driven by scroll offset. Threshold ~50px. At scroll > threshold, translate header group up by its own height.
2. **Swipe-to-reveal**: each `EmailListItem` wraps content in `Animated.View` with `PanResponder`.
3. **Category switch**: cross-fade or no animation (design choice — we use instant filter in store).
57 changes: 57 additions & 0 deletions bounty-2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Warpspeed Bounty #2 — Classic Inbox View ($330)

A React Native + TypeScript reference implementation for a traditional email inbox UI.

## Features

- Sticky top navigation with account switcher and route buttons
- Scroll-based header hide/reveal for category tabs and search bar
- Three email categories: Primary, Promotions, Updates
- Search bar with live filtering and clear button
- Email list with read/unread visual states
- Swipe-to-reveal "Archive" action on email rows
- Star/unstar and attachment indicators
- Pull-to-refresh
- Empty state messaging
- MobX store for all state management

## File Structure

```
components/
ClassicInbox.tsx — Main orchestrator with animated header
TopNav.tsx — Account selector + nav buttons
CategoryBar.tsx — Category filter tabs
SearchBar.tsx — Search input + filter toggle
EmailListItem.tsx — Single email row with swipe
state/
inboxStore.ts — MobX store (accounts, emails, ui state)
types.ts — TypeScript types
DESIGN.md — Full component architecture
README.md — This file
```

## Getting Started

```bash
npm install
npx expo start
```

## Dependencies

- react-native
- mobx / mobx-react-lite
- typescript

## States Covered

| State | Handling |
|--------------|----------------------------------------|
| Loading | Pull-to-refresh spinner via `simulateLoad` |
| Empty | `ListEmptyComponent` with context-aware message |
| Error | Store has `error` field (extendable) |
| Search empty | Shows "Try a different search term." |
| Unread | Blue dot + bold sender/subject |
| Starred | Filled/outline star glyph toggle |
| Swipe | PanResponder reveal + spring back |
70 changes: 70 additions & 0 deletions bounty-2/components/CategoryBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { observer } from 'mobx-react-lite';
import { inboxStore } from '../state/inboxStore';
import { Category } from '../types';

const CATEGORIES: { key: Category; label: string }[] = [
{ key: 'primary', label: 'Primary' },
{ key: 'promotions', label: 'Promotions' },
{ key: 'updates', label: 'Updates' },
];

const CategoryBar: React.FC = () => {
return (
<View style={styles.container}>
{CATEGORIES.map((cat) => {
const active = inboxStore.selectedCategory === cat.key;
return (
<TouchableOpacity
key={cat.key}
style={[styles.tab, active && styles.tabActive]}
onPress={() => inboxStore.setCategory(cat.key)}
accessibilityLabel={cat.label}
>
<Text style={[styles.label, active && styles.labelActive]}>
{cat.label}
</Text>
{active && <View style={styles.indicator} />}
</TouchableOpacity>
);
})}
</View>
);
};

const styles = StyleSheet.create({
container: {
flexDirection: 'row',
paddingHorizontal: 12,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: '#e8e8e8',
},
tab: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
position: 'relative',
},
tabActive: {},
label: {
fontSize: 14,
fontWeight: '500',
color: '#666',
},
labelActive: {
color: '#1a73e8',
fontWeight: '700',
},
indicator: {
position: 'absolute',
bottom: 0,
height: 3,
width: 40,
backgroundColor: '#1a73e8',
borderRadius: 1.5,
},
});

export default observer(CategoryBar);
130 changes: 130 additions & 0 deletions bounty-2/components/ClassicInbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, { useCallback, useRef } from 'react';
import {
View,
Text,
FlatList,
Animated,
RefreshControl,
StyleSheet,
} from 'react-native';
import { observer } from 'mobx-react-lite';
import { inboxStore } from '../state/inboxStore';
import { Email } from '../types';
import TopNav from './TopNav';
import CategoryBar from './CategoryBar';
import SearchBar from './SearchBar';
import EmailListItem from './EmailListItem';

const HEADER_HEIGHT = 100; // approximate combined height of CategoryBar + SearchBar

const ClassicInbox: React.FC = () => {
const scrollY = useRef(new Animated.Value(0)).current;
const scrollOffset = useRef(0);

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

const onScroll = useCallback(
Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true },
),
[scrollY],
);

const handleRefresh = useCallback(() => {
inboxStore.simulateLoad();
}, []);

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

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

const renderEmpty = useCallback(
() => (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>No emails</Text>
<Text style={styles.emptySub}>
{inboxStore.searchQuery
? 'Try a different search term.'
: 'Nothing here yet.'}
</Text>
</View>
),
[],
);

return (
<View style={styles.container}>
{/* Always-visible top nav */}
<TopNav />

{/* Scroll-hiding header group */}
<Animated.View
style={[styles.headerGroup, { transform: [{ translateY: headerTranslate }] }]}
>
<CategoryBar />
<SearchBar />
</Animated.View>

{/* Email list */}
<Animated.FlatList
data={inboxStore.filteredEmails}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListEmptyComponent={renderEmpty}
onScroll={onScroll}
scrollEventThrottle={16}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={inboxStore.isLoading}
onRefresh={handleRefresh}
tintColor="#1a73e8"
/>
}
showsVerticalScrollIndicator={false}
/>
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
headerGroup: {
backgroundColor: '#fff',
zIndex: 5,
},
listContent: {
flexGrow: 1,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 80,
paddingHorizontal: 32,
},
emptyTitle: {
fontSize: 20,
fontWeight: '700',
color: '#333',
marginBottom: 8,
},
emptySub: {
fontSize: 14,
color: '#888',
textAlign: 'center',
},
});

export default observer(ClassicInbox);
Loading