diff --git a/docs/classic-inbox.md b/docs/classic-inbox.md new file mode 100644 index 0000000..ebaa2b8 --- /dev/null +++ b/docs/classic-inbox.md @@ -0,0 +1,47 @@ +# Classic Inbox UI + +This package implements the Classic Inbox surface for the paid warpSpeed OPEN +bounty. It is designed as a reusable React Native/TypeScript module that can be +mounted by the host app and exercised in Storybook. + +## Included Acceptance Surface + +- Top navigation row with account selector, Flow, Dashboard, Classic, and Compose actions +- Category buttons for Inbox, Sent, Drafts, All Mail, Primary, Promotions, and Updates +- Search and filter controls +- Email list rendering with read and unread states +- Selected message state +- Empty state +- Smooth scroll header behavior via animated scroll offset +- Storybook fixture with representative accounts, categories, and messages +- Static verification script for bounty-critical props and UI paths + +## Integration + +```tsx +import { ClassicInbox } from "../packages/classic-inbox/src"; + + +``` + +The component is intentionally controlled by the host app. It does not own +network fetching, authentication, or persistence. That keeps the integration +portable across warpSpeed apps while preserving a complete UI contract. + +## Validation + +Run: + +```bash +npm test +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..d71989a --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "warpspeed-integrations", + "private": true, + "type": "module", + "scripts": { + "test": "node scripts/verify-classic-inbox.mjs" + }, + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/packages/classic-inbox/src/ClassicInbox.stories.tsx b/packages/classic-inbox/src/ClassicInbox.stories.tsx new file mode 100644 index 0000000..cdfb7a5 --- /dev/null +++ b/packages/classic-inbox/src/ClassicInbox.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React, { useState } from "react"; +import { ClassicInbox } from "./ClassicInbox"; +import type { ClassicInboxCategory, ClassicInboxMessage, InboxAccount } from "./types"; + +const accounts: InboxAccount[] = [ + { id: "personal", label: "Personal", address: "nate@example.com" }, + { id: "work", label: "Work", address: "nate@warpspeed.dev" }, +]; + +const categories: ClassicInboxCategory[] = [ + { id: "inbox", label: "Inbox", icon: "inbox", count: 24 }, + { id: "sent", label: "Sent", icon: "send" }, + { id: "drafts", label: "Drafts", icon: "draft", count: 3 }, + { id: "all", label: "All Mail", icon: "all" }, + { id: "primary", label: "Primary", icon: "primary", count: 12 }, + { id: "promotions", label: "Promotions", icon: "tag", count: 5 }, + { id: "updates", label: "Updates", icon: "bell", count: 7 }, +]; + +const messages: ClassicInboxMessage[] = [ + { + id: "m-1", + accountId: "personal", + categoryId: "primary", + senderName: "warpSpeed OPEN", + senderEmail: "bounties@warpspeedopen.org", + subject: "Classic Inbox bounty update", + preview: "Your implementation checklist has been reviewed and is ready for final polish.", + timestampLabel: "9:41 AM", + unread: true, + starred: true, + hasAttachment: false, + }, + { + id: "m-2", + accountId: "personal", + categoryId: "updates", + senderName: "Design System", + senderEmail: "tokens@warpspeed.dev", + subject: "Icon set refresh", + preview: "The latest category and navigation icons are available in the design system package.", + timestampLabel: "Yesterday", + unread: false, + starred: false, + hasAttachment: true, + }, + { + id: "m-3", + accountId: "work", + categoryId: "promotions", + senderName: "Growth", + senderEmail: "growth@warpspeed.dev", + subject: "Campaign draft ready", + preview: "Promotional campaign copy is waiting for your final approval.", + timestampLabel: "Mon", + unread: true, + starred: false, + hasAttachment: false, + }, +]; + +const meta: Meta = { + title: "Bounties/ClassicInbox", + component: ClassicInbox, +}; + +export default meta; + +type Story = StoryObj; + +export const Interactive: Story = { + render: () => { + const [activeAccountId, setActiveAccountId] = useState("personal"); + const [activeCategoryId, setActiveCategoryId] = useState("inbox"); + const [selectedMessageId, setSelectedMessageId] = useState(); + + return ( + undefined} + onFlowPress={() => undefined} + onDashboardPress={() => undefined} + onClassicPress={() => undefined} + onMessagePress={(message) => setSelectedMessageId(message.id)} + /> + ); + }, +}; diff --git a/packages/classic-inbox/src/ClassicInbox.tsx b/packages/classic-inbox/src/ClassicInbox.tsx new file mode 100644 index 0000000..b12b21a --- /dev/null +++ b/packages/classic-inbox/src/ClassicInbox.tsx @@ -0,0 +1,444 @@ +import React, { useMemo, useRef, useState } from "react"; +import { + Animated, + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import type { + ClassicInboxCategory, + ClassicInboxMessage, + ClassicInboxProps, + InboxAccount, +} from "./types"; + +const HEADER_HIDE_DISTANCE = 88; + +const DEFAULT_FILTERS = [ + { id: "all", label: "All" }, + { id: "unread", label: "Unread" }, + { id: "starred", label: "Starred" }, + { id: "attachments", label: "Files" }, +] as const; + +type FilterId = (typeof DEFAULT_FILTERS)[number]["id"]; + +export function ClassicInbox({ + accounts, + categories, + messages, + activeAccountId, + activeCategoryId, + selectedMessageId, + onAccountChange, + onCategoryChange, + onCompose, + onFlowPress, + onDashboardPress, + onClassicPress, + onMessagePress, +}: ClassicInboxProps) { + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const scrollY = useRef(new Animated.Value(0)).current; + + const activeAccount = useMemo( + () => accounts.find((account) => account.id === activeAccountId) ?? accounts[0], + [accounts, activeAccountId], + ); + + const filteredMessages = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + return messages.filter((message) => { + if (message.accountId !== activeAccountId) { + return false; + } + if ( + activeCategoryId !== "inbox" && + activeCategoryId !== "all" && + message.categoryId !== activeCategoryId + ) { + return false; + } + if (filter === "unread" && !message.unread) { + return false; + } + if (filter === "starred" && !message.starred) { + return false; + } + if (filter === "attachments" && !message.hasAttachment) { + return false; + } + if (!normalizedQuery) { + return true; + } + const haystack = [ + message.senderName, + message.senderEmail, + message.subject, + message.preview, + ].join(" ").toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [activeAccountId, activeCategoryId, filter, messages, query]); + + const headerTranslateY = scrollY.interpolate({ + inputRange: [0, HEADER_HIDE_DISTANCE], + outputRange: [0, -HEADER_HIDE_DISTANCE], + extrapolate: "clamp", + }); + + return ( + + + + + + + + Filter + + + + {DEFAULT_FILTERS.map((item) => ( + setFilter(item.id)} + style={[styles.filterChip, filter === item.id && styles.filterChipActive]} + > + + {item.label} + + + ))} + + + + item.id} + contentContainerStyle={styles.listContent} + onScroll={Animated.event( + [{ nativeEvent: { contentOffset: { y: scrollY } } }], + { useNativeDriver: true }, + )} + scrollEventThrottle={16} + ListEmptyComponent={} + renderItem={({ item }) => ( + onMessagePress(item)} + /> + )} + /> + + ); +} + +function TopNavigation({ + accounts, + activeAccount, + onAccountChange, + onCompose, + onFlowPress, + onDashboardPress, + onClassicPress, +}: { + accounts: InboxAccount[]; + activeAccount?: InboxAccount; + onAccountChange: (accountId: string) => void; + onCompose: () => void; + onFlowPress?: () => void; + onDashboardPress?: () => void; + onClassicPress?: () => void; +}) { + return ( + + + Account + + {accounts.map((account) => ( + onAccountChange(account.id)} + style={[ + styles.accountPill, + account.id === activeAccount?.id && styles.accountPillActive, + ]} + > + + {account.label} + + + ))} + + + {activeAccount?.address} + + + + + + + + Compose + + + + ); +} + +function NavAction({ + label, + selected, + onPress, +}: { + label: string; + selected?: boolean; + onPress?: () => void; +}) { + return ( + + {label} + + ); +} + +function CategoryRail({ + categories, + activeCategoryId, + onCategoryChange, +}: { + categories: ClassicInboxCategory[]; + activeCategoryId: string; + onCategoryChange: (categoryId: string) => void; +}) { + return ( + + {categories.map((category) => { + const active = category.id === activeCategoryId; + return ( + onCategoryChange(category.id)} + style={[styles.categoryButton, active && styles.categoryButtonActive]} + > + + {iconForCategory(category.icon)} + + + {category.label} + + {typeof category.count === "number" && ( + + {category.count} + + )} + + ); + })} + + ); +} + +function MessageRow({ + message, + selected, + onPress, +}: { + message: ClassicInboxMessage; + selected: boolean; + onPress: () => void; +}) { + return ( + + + {initials(message.senderName)} + + + + + {message.senderName} + + + {message.timestampLabel} + + + + {message.subject} + + + {message.preview} + + + + {message.starred && } + {message.hasAttachment && } + {message.unread && } + + + ); +} + +function EmptyInbox({ query }: { query: string }) { + return ( + + {query ? "No matching messages" : "No mail here"} + + {query ? "Try another search or clear filters." : "Switch category or account to keep browsing."} + + + ); +} + +function initials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || "?"; +} + +function iconForCategory(icon: string | undefined): string { + switch (icon) { + case "send": + return "↗"; + case "draft": + return "✎"; + case "tag": + return "#"; + case "bell": + return "!"; + case "primary": + return "◆"; + case "all": + return "◎"; + default: + return "▣"; + } +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: "#f6f8fb" }, + header: { + position: "absolute", + top: 0, + left: 0, + right: 0, + zIndex: 10, + backgroundColor: "#ffffff", + borderBottomWidth: 1, + borderBottomColor: "#e5e9f0", + }, + topNav: { paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10 }, + accountBlock: { marginBottom: 10 }, + eyebrow: { color: "#697386", fontSize: 11, fontWeight: "700", textTransform: "uppercase" }, + accountChoices: { flexDirection: "row", gap: 8, marginTop: 6 }, + accountPill: { borderWidth: 1, borderColor: "#d9dee8", borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 }, + accountPillActive: { backgroundColor: "#172033", borderColor: "#172033" }, + accountPillText: { color: "#3b4454", fontWeight: "700" }, + accountPillTextActive: { color: "#ffffff" }, + accountAddress: { color: "#697386", fontSize: 12, marginTop: 5 }, + primaryActions: { flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center" }, + navAction: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 8, backgroundColor: "#eef2f7" }, + navActionSelected: { backgroundColor: "#d8e7ff" }, + navActionText: { color: "#3b4454", fontWeight: "700" }, + navActionTextSelected: { color: "#1456a3" }, + composeButton: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8, backgroundColor: "#1456a3" }, + composeText: { color: "#ffffff", fontWeight: "800" }, + categoryRail: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingBottom: 10, flexWrap: "wrap" }, + categoryButton: { flexDirection: "row", alignItems: "center", gap: 6, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 7, backgroundColor: "#f4f6fa" }, + categoryButtonActive: { backgroundColor: "#172033" }, + categoryIcon: { color: "#697386", fontWeight: "800" }, + categoryText: { color: "#3b4454", fontWeight: "700" }, + categoryTextActive: { color: "#ffffff" }, + categoryCount: { color: "#697386", fontSize: 12, fontWeight: "700" }, + searchRow: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingBottom: 10 }, + searchInput: { flex: 1, borderRadius: 8, borderWidth: 1, borderColor: "#d9dee8", paddingHorizontal: 12, paddingVertical: 9, backgroundColor: "#ffffff" }, + filterButton: { borderRadius: 8, borderWidth: 1, borderColor: "#d9dee8", paddingHorizontal: 12, justifyContent: "center" }, + filterButtonText: { color: "#172033", fontWeight: "800" }, + filterRail: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingBottom: 12 }, + filterChip: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, backgroundColor: "#eef2f7" }, + filterChipActive: { backgroundColor: "#1456a3" }, + filterChipText: { color: "#3b4454", fontWeight: "700" }, + filterChipTextActive: { color: "#ffffff" }, + listContent: { paddingTop: 226, paddingHorizontal: 12, paddingBottom: 24 }, + messageRow: { flexDirection: "row", gap: 12, borderRadius: 8, backgroundColor: "#ffffff", borderWidth: 1, borderColor: "#e5e9f0", padding: 12, marginBottom: 8 }, + messageRowUnread: { borderColor: "#9cc7ff", backgroundColor: "#f8fbff" }, + messageRowSelected: { borderColor: "#1456a3" }, + senderAvatar: { width: 38, height: 38, borderRadius: 19, backgroundColor: "#d9dee8", alignItems: "center", justifyContent: "center" }, + senderAvatarUnread: { backgroundColor: "#1456a3" }, + senderAvatarText: { color: "#ffffff", fontWeight: "800" }, + messageContent: { flex: 1, minWidth: 0 }, + messageHeader: { flexDirection: "row", justifyContent: "space-between", gap: 12 }, + senderName: { flex: 1, color: "#172033", fontWeight: "600" }, + unreadText: { fontWeight: "900" }, + timestamp: { color: "#697386", fontSize: 12 }, + subject: { color: "#172033", marginTop: 3 }, + preview: { color: "#697386", marginTop: 3 }, + messageMeta: { width: 22, alignItems: "center", gap: 6 }, + star: { color: "#d9a400" }, + attachment: { color: "#697386" }, + unreadDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#1456a3" }, + emptyState: { alignItems: "center", paddingVertical: 48 }, + emptyTitle: { color: "#172033", fontWeight: "800", fontSize: 16 }, + emptyText: { color: "#697386", marginTop: 6 }, +}); diff --git a/packages/classic-inbox/src/index.ts b/packages/classic-inbox/src/index.ts new file mode 100644 index 0000000..51151f1 --- /dev/null +++ b/packages/classic-inbox/src/index.ts @@ -0,0 +1,2 @@ +export { ClassicInbox } from "./ClassicInbox"; +export type { ClassicInboxCategory, ClassicInboxMessage, ClassicInboxProps, InboxAccount } from "./types"; diff --git a/packages/classic-inbox/src/types.ts b/packages/classic-inbox/src/types.ts new file mode 100644 index 0000000..9bd8f31 --- /dev/null +++ b/packages/classic-inbox/src/types.ts @@ -0,0 +1,42 @@ +export interface InboxAccount { + id: string; + label: string; + address: string; +} + +export interface ClassicInboxCategory { + id: string; + label: string; + icon?: "inbox" | "send" | "draft" | "all" | "primary" | "tag" | "bell"; + count?: number; +} + +export interface ClassicInboxMessage { + id: string; + accountId: string; + categoryId: string; + senderName: string; + senderEmail: string; + subject: string; + preview: string; + timestampLabel: string; + unread: boolean; + starred?: boolean; + hasAttachment?: boolean; +} + +export interface ClassicInboxProps { + accounts: InboxAccount[]; + categories: ClassicInboxCategory[]; + messages: ClassicInboxMessage[]; + activeAccountId: string; + activeCategoryId: string; + selectedMessageId?: string; + onAccountChange: (accountId: string) => void; + onCategoryChange: (categoryId: string) => void; + onCompose: () => void; + onFlowPress?: () => void; + onDashboardPress?: () => void; + onClassicPress?: () => void; + onMessagePress: (message: ClassicInboxMessage) => void; +} diff --git a/scripts/verify-classic-inbox.mjs b/scripts/verify-classic-inbox.mjs new file mode 100644 index 0000000..47759b2 --- /dev/null +++ b/scripts/verify-classic-inbox.mjs @@ -0,0 +1,67 @@ +import { readFileSync, existsSync } from "node:fs"; + +const requiredFiles = [ + "packages/classic-inbox/src/ClassicInbox.tsx", + "packages/classic-inbox/src/ClassicInbox.stories.tsx", + "packages/classic-inbox/src/types.ts", + "packages/classic-inbox/src/index.ts", + "docs/classic-inbox.md", +]; + +for (const file of requiredFiles) { + if (!existsSync(file)) { + throw new Error(`Missing required file: ${file}`); + } +} + +const source = readFileSync("packages/classic-inbox/src/ClassicInbox.tsx", "utf8"); +const story = readFileSync("packages/classic-inbox/src/ClassicInbox.stories.tsx", "utf8"); + +const sourceRequirements = [ + "ClassicInbox", + "TopNavigation", + "CategoryRail", + "TextInput", + "FlatList", + "Animated.event", + "headerTranslateY", + "Search mail", + "Filter", + "Compose", + "Flow", + "Dashboard", + "Classic", + "unread", + "starred", + "hasAttachment", + "onAccountChange", + "onCategoryChange", + "onMessagePress", +]; + +for (const token of sourceRequirements) { + if (!source.includes(token)) { + throw new Error(`ClassicInbox missing required token: ${token}`); + } +} + +const storyRequirements = [ + "Bounties/ClassicInbox", + "accounts", + "categories", + "messages", + "primary", + "promotions", + "updates", + "sent", + "drafts", + "all", +]; + +for (const token of storyRequirements) { + if (!story.includes(token)) { + throw new Error(`Story missing required token: ${token}`); + } +} + +console.log("Classic Inbox bounty verification passed");