diff --git a/bounty-2/DESIGN.md b/bounty-2/DESIGN.md new file mode 100644 index 00000000..54fd4a60 --- /dev/null +++ b/bounty-2/DESIGN.md @@ -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). diff --git a/bounty-2/README.md b/bounty-2/README.md new file mode 100644 index 00000000..6c45afd0 --- /dev/null +++ b/bounty-2/README.md @@ -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 | diff --git a/bounty-2/components/CategoryBar.tsx b/bounty-2/components/CategoryBar.tsx new file mode 100644 index 00000000..f12035e1 --- /dev/null +++ b/bounty-2/components/CategoryBar.tsx @@ -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 ( + + {CATEGORIES.map((cat) => { + const active = inboxStore.selectedCategory === cat.key; + return ( + inboxStore.setCategory(cat.key)} + accessibilityLabel={cat.label} + > + + {cat.label} + + {active && } + + ); + })} + + ); +}; + +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); diff --git a/bounty-2/components/ClassicInbox.tsx b/bounty-2/components/ClassicInbox.tsx new file mode 100644 index 00000000..d4ab0366 --- /dev/null +++ b/bounty-2/components/ClassicInbox.tsx @@ -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 }) => , + [], + ); + + const keyExtractor = useCallback((item: Email) => item.id, []); + + const renderEmpty = useCallback( + () => ( + + No emails + + {inboxStore.searchQuery + ? 'Try a different search term.' + : 'Nothing here yet.'} + + + ), + [], + ); + + return ( + + {/* Always-visible top nav */} + + + {/* Scroll-hiding header group */} + + + + + + {/* Email list */} + + } + showsVerticalScrollIndicator={false} + /> + + ); +}; + +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); diff --git a/bounty-2/components/EmailListItem.tsx b/bounty-2/components/EmailListItem.tsx new file mode 100644 index 00000000..5804b350 --- /dev/null +++ b/bounty-2/components/EmailListItem.tsx @@ -0,0 +1,270 @@ +import React, { useCallback, useRef } from 'react'; +import { + View, + Text, + TouchableOpacity, + Animated, + PanResponder, + StyleSheet, +} from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { Email } from '../types'; +import { inboxStore } from '../state/inboxStore'; + +interface Props { + email: Email; +} + +const SWIPE_THRESHOLD = 80; + +const EmailListItem: React.FC = ({ email }) => { + const translateX = useRef(new Animated.Value(0)).current; + const panY = useRef(new Animated.Value(0)).current; + + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gs) => + Math.abs(gs.dx) > 10 && Math.abs(gs.dx) > Math.abs(gs.dy), + onPanResponderMove: (_, gs) => { + translateX.setValue(Math.min(0, gs.dx)); + }, + onPanResponderRelease: (_, gs) => { + if (gs.dx < -SWIPE_THRESHOLD) { + Animated.spring(translateX, { + toValue: -80, + useNativeDriver: true, + }).start(); + } else { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + }).start(); + } + }, + }), + ).current; + + const resetSwipe = useCallback(() => { + Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); + }, [translateX]); + + const handlePress = useCallback(() => { + if (email.isUnread) inboxStore.markRead(email.id); + }, [email]); + + return ( + + {/* Revealed actions behind the row */} + + + Archive + + + + {/* Main row */} + + inboxStore.toggleStar(email.id)} + accessibilityLabel={`${email.isUnread ? 'Unread' : 'Read'}: ${email.subject}`} + > + + {/* Left: avatar + unread dot */} + + + + {email.from.name.charAt(0).toUpperCase()} + + + {email.isUnread && } + + + {/* Center: content */} + + + + {email.from.name} + + + {formatTime(email.timestamp)} + + + + {email.subject} + + + {email.preview} + + + + {/* Right: star + attachment */} + + { + e.stopPropagation?.(); + inboxStore.toggleStar(email.id); + }} + accessibilityLabel={email.isStarred ? 'Unstar' : 'Star'} + > + + {email.isStarred ? '★' : '☆'} + + + {email.hasAttachment && 📎} + + + + + + ); +}; + +function formatTime(ts: number): string { + const d = new Date(ts); + const now = new Date(); + const diffDays = Math.floor( + (now.getTime() - d.getTime()) / 86_400_000, + ); + if (diffDays === 0) { + return d.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + } + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) { + return d.toLocaleDateString(undefined, { weekday: 'short' }); + } + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +const styles = StyleSheet.create({ + wrapper: { + position: 'relative', + }, + actionsRow: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: 80, + justifyContent: 'center', + alignItems: 'center', + }, + actionArchive: { + backgroundColor: '#1a73e8', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 10, + }, + actionText: { + color: '#fff', + fontWeight: '600', + fontSize: 13, + }, + row: { + backgroundColor: '#fff', + }, + inner: { + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: '#eee', + }, + leftCol: { + alignItems: 'center', + marginRight: 12, + width: 40, + }, + avatar: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#ddd', + alignItems: 'center', + justifyContent: 'center', + }, + avatarUnread: { + backgroundColor: '#1a73e8', + }, + avatarText: { + color: '#fff', + fontWeight: '600', + fontSize: 16, + }, + unreadDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#1a73e8', + marginTop: 4, + }, + content: { + flex: 1, + marginRight: 8, + }, + topRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + sender: { + fontSize: 15, + color: '#444', + flex: 1, + marginRight: 8, + }, + senderUnread: { + fontWeight: '700', + color: '#111', + }, + time: { + fontSize: 12, + color: '#888', + }, + subject: { + fontSize: 14, + color: '#222', + marginTop: 2, + }, + subjectUnread: { + fontWeight: '700', + color: '#000', + }, + preview: { + fontSize: 13, + color: '#777', + marginTop: 2, + lineHeight: 18, + }, + rightCol: { + alignItems: 'center', + justifyContent: 'flex-start', + minWidth: 28, + }, + star: { + fontSize: 18, + color: '#f5a623', + marginBottom: 4, + }, + clip: { + fontSize: 14, + }, +}); + +export default observer(EmailListItem); diff --git a/bounty-2/components/SearchBar.tsx b/bounty-2/components/SearchBar.tsx new file mode 100644 index 00000000..867e615e --- /dev/null +++ b/bounty-2/components/SearchBar.tsx @@ -0,0 +1,108 @@ +import React, { useCallback, useRef } from 'react'; +import { + View, + TextInput, + TouchableOpacity, + StyleSheet, + Text, +} from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { inboxStore } from '../state/inboxStore'; + +const SearchBar: React.FC = () => { + const inputRef = useRef(null); + + const handleChange = useCallback((text: string) => { + inboxStore.setSearchQuery(text); + }, []); + + const handleClear = useCallback(() => { + inboxStore.setSearchQuery(''); + inputRef.current?.focus(); + }, []); + + return ( + + + 🔍 + + {inboxStore.searchQuery.length > 0 && ( + + + + )} + inboxStore.toggleSearch()} + style={styles.filterBtn} + accessibilityLabel="Toggle filter" + > + ⏷ Filter + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: '#e8e8e8', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#f1f3f4', + borderRadius: 10, + paddingHorizontal: 10, + height: 40, + }, + icon: { + fontSize: 16, + marginRight: 6, + }, + input: { + flex: 1, + fontSize: 15, + color: '#222', + paddingVertical: 0, + }, + clearBtn: { + padding: 4, + marginRight: 4, + }, + clearText: { + fontSize: 14, + color: '#888', + }, + filterBtn: { + paddingHorizontal: 8, + paddingVertical: 4, + backgroundColor: '#e8e8e8', + borderRadius: 6, + }, + filterText: { + fontSize: 12, + fontWeight: '600', + color: '#444', + }, +}); + +export default observer(SearchBar); diff --git a/bounty-2/components/TopNav.tsx b/bounty-2/components/TopNav.tsx new file mode 100644 index 00000000..704165d2 --- /dev/null +++ b/bounty-2/components/TopNav.tsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + FlatList, + StyleSheet, + StatusBar, +} from 'react-native'; +import { observer } from 'mobx-react-lite'; +import { inboxStore } from '../state/inboxStore'; +import { NavigationRoute } from '../types'; + +const ROUTES: NavigationRoute[] = ['flow', 'dashboard', 'classic', 'compose']; +const ROUTE_LABELS: Record = { + flow: 'Flow', + dashboard: 'Dashboard', + classic: 'Classic', + compose: 'Compose', +}; + +const TopNav: React.FC = () => { + const [showAccounts, setShowAccounts] = useState(false); + + return ( + + + + {/* Left: account selector */} + setShowAccounts(true)} + accessibilityLabel="Switch account" + > + + + {inboxStore.selectedAccount.displayName.charAt(0).toUpperCase()} + + + + {inboxStore.selectedAccount.displayName} + + + + + {/* Right: nav buttons */} + + {ROUTES.map((route) => { + const active = inboxStore.activeRoute === route; + return ( + inboxStore.setRoute(route)} + accessibilityLabel={ROUTE_LABELS[route]} + > + + {ROUTE_LABELS[route]} + + + ); + })} + + + {/* Account switcher modal */} + setShowAccounts(false)} + > + setShowAccounts(false)} + > + + Switch Account + item.id} + renderItem={({ item }) => ( + { + inboxStore.setAccount(item.id); + setShowAccounts(false); + }} + > + {item.displayName} + {item.email} + + )} + /> + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#fff', + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: '#e0e0e0', + zIndex: 10, + }, + accountBtn: { + flexDirection: 'row', + alignItems: 'center', + maxWidth: 160, + }, + avatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#1a73e8', + alignItems: 'center', + justifyContent: 'center', + marginRight: 8, + }, + avatarText: { + color: '#fff', + fontWeight: '600', + fontSize: 14, + }, + accountName: { + fontSize: 15, + fontWeight: '600', + color: '#222', + maxWidth: 100, + }, + chevron: { + marginLeft: 4, + fontSize: 12, + color: '#666', + }, + navRow: { + flexDirection: 'row', + }, + navBtn: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 6, + marginLeft: 4, + }, + navBtnActive: { + backgroundColor: '#e8f0fe', + }, + navLabel: { + fontSize: 13, + fontWeight: '500', + color: '#555', + }, + navLabelActive: { + color: '#1a73e8', + fontWeight: '600', + }, + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + justifyContent: 'center', + paddingHorizontal: 40, + }, + modal: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + maxHeight: 300, + }, + modalTitle: { + fontSize: 16, + fontWeight: '700', + marginBottom: 12, + color: '#222', + }, + accountRow: { + paddingVertical: 12, + paddingHorizontal: 8, + borderRadius: 8, + }, + accountRowActive: { + backgroundColor: '#e8f0fe', + }, + accountRowText: { + fontSize: 15, + fontWeight: '600', + color: '#222', + }, + accountRowEmail: { + fontSize: 13, + color: '#666', + marginTop: 2, + }, +}); + +export default observer(TopNav); diff --git a/bounty-2/state/inboxStore.ts b/bounty-2/state/inboxStore.ts new file mode 100644 index 00000000..4eeae85d --- /dev/null +++ b/bounty-2/state/inboxStore.ts @@ -0,0 +1,98 @@ +import { makeAutoObservable, computed } from 'mobx'; +import { Account, Category, Email, NavigationRoute } from '../types'; + +const MOCK_ACCOUNTS: Account[] = [ + { id: '1', email: 'alice@example.com', displayName: 'Alice Chen' }, + { id: '2', email: 'bob@work.com', displayName: 'Bob Martinez' }, +]; + +const MOCK_EMAILS: Email[] = Array.from({ length: 30 }, (_, i) => ({ + id: `email-${i}`, + from: { + name: `Sender ${i}`, + email: `sender${i}@example.com`, + }, + subject: `Email subject ${i} — Lorem ipsum dolor sit amet`, + preview: `This is a preview of email number ${i}. It goes up to two lines at most.`, + body: `Full body of email ${i}.`, + category: (['primary', 'promotions', 'updates'] as Category)[i % 3], + isUnread: i < 8, + isStarred: i % 5 === 0, + hasAttachment: i % 4 === 0, + timestamp: Date.now() - i * 86_400_000, +})); + +export class InboxStore { + accounts: Account[] = MOCK_ACCOUNTS; + selectedAccountId: string = MOCK_ACCOUNTS[0].id; + activeRoute: NavigationRoute = 'classic'; + selectedCategory: Category = 'primary'; + emails: Email[] = MOCK_EMAILS; + searchQuery: string = ''; + isSearchVisible: boolean = false; + isLoading: boolean = false; + error: string | null = null; + + constructor() { + makeAutoObservable(this); + } + + get selectedAccount(): Account { + return this.accounts.find((a) => a.id === this.selectedAccountId)! + } + + get filteredEmails(): Email[] { + let list = this.emails.filter((e) => e.category === this.selectedCategory); + const q = this.searchQuery.trim().toLowerCase(); + if (q) { + list = list.filter( + (e) => + e.subject.toLowerCase().includes(q) || + e.preview.toLowerCase().includes(q) || + e.from.name.toLowerCase().includes(q), + ); + } + return list; + } + + setAccount = (id: string) => { + this.selectedAccountId = id; + }; + + setRoute = (route: NavigationRoute) => { + this.activeRoute = route; + }; + + setCategory = (cat: Category) => { + this.selectedCategory = cat; + }; + + toggleStar = (id: string) => { + const email = this.emails.find((e) => e.id === id); + if (email) email.isStarred = !email.isStarred; + }; + + markRead = (id: string) => { + const email = this.emails.find((e) => e.id === id); + if (email) email.isUnread = false; + }; + + setSearchQuery = (q: string) => { + this.searchQuery = q; + }; + + toggleSearch = () => { + this.isSearchVisible = !this.isSearchVisible; + if (!this.isSearchVisible) this.searchQuery = ''; + }; + + simulateLoad = () => { + this.isLoading = true; + this.error = null; + setTimeout(() => { + this.isLoading = false; + }, 800); + }; +} + +export const inboxStore = new InboxStore(); diff --git a/bounty-2/types.ts b/bounty-2/types.ts new file mode 100644 index 00000000..0a6acc75 --- /dev/null +++ b/bounty-2/types.ts @@ -0,0 +1,35 @@ +export type Category = 'primary' | 'promotions' | 'updates'; + +export type NavigationRoute = 'flow' | 'dashboard' | 'classic' | 'compose'; + +export interface Account { + id: string; + email: string; + displayName: string; + avatarUri?: string; +} + +export interface Email { + id: string; + from: { name: string; email: string; avatarUri?: string }; + subject: string; + preview: string; + body: string; + category: Category; + isUnread: boolean; + isStarred: boolean; + hasAttachment: boolean; + timestamp: number; // unix ms +} + +export interface InboxState { + accounts: Account[]; + selectedAccountId: string; + activeRoute: NavigationRoute; + selectedCategory: Category; + emails: Email[]; + searchQuery: string; + isSearchVisible: boolean; + isLoading: boolean; + error: string | null; +}