@fayz-ai/plugin-conversations 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/ConversationsContext.d.ts +9 -0
  4. package/dist/ConversationsContext.d.ts.map +1 -0
  5. package/dist/ConversationsPage.d.ts +7 -0
  6. package/dist/ConversationsPage.d.ts.map +1 -0
  7. package/dist/channel.d.ts +14 -0
  8. package/dist/channel.d.ts.map +1 -0
  9. package/dist/data/mock.d.ts +3 -0
  10. package/dist/data/mock.d.ts.map +1 -0
  11. package/dist/data/supabase.d.ts +11 -0
  12. package/dist/data/supabase.d.ts.map +1 -0
  13. package/dist/data/types.d.ts +9 -0
  14. package/dist/data/types.d.ts.map +1 -0
  15. package/dist/index.cjs +904 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +896 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/locales/en.d.ts +2 -0
  22. package/dist/locales/en.d.ts.map +1 -0
  23. package/dist/locales/index.d.ts +2 -0
  24. package/dist/locales/index.d.ts.map +1 -0
  25. package/dist/store.d.ts +21 -0
  26. package/dist/store.d.ts.map +1 -0
  27. package/dist/types.d.ts +40 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/views/ContactPanel.d.ts +8 -0
  30. package/dist/views/ContactPanel.d.ts.map +1 -0
  31. package/dist/views/ConversationList.d.ts +5 -0
  32. package/dist/views/ConversationList.d.ts.map +1 -0
  33. package/dist/views/InboxView.d.ts +3 -0
  34. package/dist/views/InboxView.d.ts.map +1 -0
  35. package/dist/views/MessageThread.d.ts +10 -0
  36. package/dist/views/MessageThread.d.ts.map +1 -0
  37. package/dist/views/shared.d.ts +20 -0
  38. package/dist/views/shared.d.ts.map +1 -0
  39. package/package.json +54 -0
  40. package/src/ConversationsContext.tsx +21 -0
  41. package/src/ConversationsPage.tsx +18 -0
  42. package/src/channel.ts +34 -0
  43. package/src/data/mock.ts +142 -0
  44. package/src/data/supabase.ts +202 -0
  45. package/src/data/types.ts +15 -0
  46. package/src/index.ts +132 -0
  47. package/src/locales/en.ts +4 -0
  48. package/src/locales/index.ts +5 -0
  49. package/src/store.ts +93 -0
  50. package/src/types.ts +58 -0
  51. package/src/views/ContactPanel.tsx +95 -0
  52. package/src/views/ConversationList.tsx +108 -0
  53. package/src/views/InboxView.tsx +62 -0
  54. package/src/views/MessageThread.tsx +169 -0
  55. package/src/views/shared.tsx +97 -0
@@ -0,0 +1,95 @@
1
+ import React from 'react'
2
+ import { MapPin, User, Tag, Link2, StickyNote, X } from 'lucide-react'
3
+ import { Button, cn } from '@fayz-ai/ui'
4
+ import { CHANNEL_LABELS } from '../channel'
5
+ import type { Conversation } from '../types'
6
+ import { Avatar, ChannelBadge } from './shared'
7
+
8
+ function Section({ icon: Icon, title, children }: {
9
+ icon: React.ComponentType<{ className?: string }>
10
+ title: string
11
+ children: React.ReactNode
12
+ }) {
13
+ return (
14
+ <div className="border-t border-border px-4 py-3">
15
+ <p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
16
+ <Icon className="h-3 w-3" /> {title}
17
+ </p>
18
+ {children}
19
+ </div>
20
+ )
21
+ }
22
+
23
+ export function ContactPanel({ contact, onClose, className }: {
24
+ contact: Conversation
25
+ onClose?: () => void
26
+ className?: string
27
+ }) {
28
+ return (
29
+ <aside className={cn('flex shrink-0 flex-col overflow-y-auto border-l border-border bg-card', className)}>
30
+ {onClose && (
31
+ <div className="flex items-center justify-between border-b border-border px-3 py-2 xl:hidden">
32
+ <span className="text-sm font-semibold text-foreground">Details</span>
33
+ <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close details">
34
+ <X className="h-4 w-4" />
35
+ </Button>
36
+ </div>
37
+ )}
38
+ <div className="flex flex-col items-center gap-2 px-4 py-5 text-center">
39
+ <Avatar name={contact.contactName} accent={contact.accent} size="lg" channel={contact.channel} />
40
+ <div>
41
+ <p className="text-sm font-semibold text-foreground">{contact.contactName}</p>
42
+ <p className="text-xs text-muted-foreground">{contact.contactHandle}</p>
43
+ </div>
44
+ <ChannelBadge channel={contact.channel} />
45
+ </div>
46
+
47
+ <Section icon={User} title="Details">
48
+ <dl className="space-y-1.5 text-sm">
49
+ <div className="flex items-center justify-between gap-2">
50
+ <dt className="text-muted-foreground">Channel</dt>
51
+ <dd className="text-foreground">{CHANNEL_LABELS[contact.channel]}</dd>
52
+ </div>
53
+ <div className="flex items-center justify-between gap-2">
54
+ <dt className="text-muted-foreground">Status</dt>
55
+ <dd className="capitalize text-foreground">{contact.status}</dd>
56
+ </div>
57
+ {contact.assignedTo && (
58
+ <div className="flex items-center justify-between gap-2">
59
+ <dt className="text-muted-foreground">Assigned to</dt>
60
+ <dd className="text-foreground">{contact.assignedTo}</dd>
61
+ </div>
62
+ )}
63
+ {contact.location && (
64
+ <div className="flex items-center justify-between gap-2">
65
+ <dt className="flex items-center gap-1 text-muted-foreground"><MapPin className="h-3 w-3" /> Location</dt>
66
+ <dd className="text-foreground">{contact.location}</dd>
67
+ </div>
68
+ )}
69
+ </dl>
70
+ </Section>
71
+
72
+ {contact.tags.length > 0 && (
73
+ <Section icon={Tag} title="Tags">
74
+ <div className="flex flex-wrap gap-1.5">
75
+ {contact.tags.map((tag) => (
76
+ <span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-foreground">
77
+ {tag}
78
+ </span>
79
+ ))}
80
+ </div>
81
+ </Section>
82
+ )}
83
+
84
+ {contact.note && (
85
+ <Section icon={StickyNote} title="Note">
86
+ <p className="text-sm text-foreground">{contact.note}</p>
87
+ </Section>
88
+ )}
89
+
90
+ <Section icon={Link2} title="Linked records">
91
+ <p className="text-xs text-muted-foreground">No linked records yet.</p>
92
+ </Section>
93
+ </aside>
94
+ )
95
+ }
@@ -0,0 +1,108 @@
1
+ import React from 'react'
2
+ import { Search, Inbox as InboxIcon } from 'lucide-react'
3
+ import { Input, cn } from '@fayz-ai/ui'
4
+ import { useConversationsStore } from '../ConversationsContext'
5
+ import type { Channel } from '../types'
6
+ import { Avatar, ChannelBadge, relativeTime } from './shared'
7
+
8
+ const FILTERS: Array<{ id: Channel | 'all'; label: string }> = [
9
+ { id: 'all', label: 'All' },
10
+ { id: 'whatsapp', label: 'WhatsApp' },
11
+ { id: 'sms', label: 'SMS' },
12
+ { id: 'instagram', label: 'Instagram' },
13
+ { id: 'email', label: 'Email' },
14
+ { id: 'webchat', label: 'Web' },
15
+ ]
16
+
17
+ export function ConversationList({ className }: { className?: string }) {
18
+ const {
19
+ conversations, selectedId, channelFilter, search, loading,
20
+ select, setChannelFilter, setSearch,
21
+ } = useConversationsStore((s) => s)
22
+
23
+ return (
24
+ <aside className={cn('w-full shrink-0 flex-col border-r border-border bg-card lg:w-[320px]', className)}>
25
+ <div className="border-b border-border px-3 py-3">
26
+ <div className="relative">
27
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
28
+ <Input
29
+ value={search}
30
+ onChange={(e) => setSearch(e.target.value)}
31
+ placeholder="Search conversations"
32
+ className="pl-8"
33
+ />
34
+ </div>
35
+ <div className="mt-2 flex flex-wrap gap-1">
36
+ {FILTERS.map((f) => (
37
+ <button
38
+ key={f.id}
39
+ onClick={() => setChannelFilter(f.id)}
40
+ className={cn(
41
+ 'rounded-full px-2.5 py-1 text-xs font-medium transition-colors',
42
+ channelFilter === f.id
43
+ ? 'bg-primary text-primary-foreground'
44
+ : 'bg-muted text-muted-foreground hover:bg-muted/70',
45
+ )}
46
+ >
47
+ {f.label}
48
+ </button>
49
+ ))}
50
+ </div>
51
+ </div>
52
+
53
+ <div className="min-h-0 flex-1 overflow-y-auto">
54
+ {loading && conversations.length === 0 && (
55
+ <p className="p-4 text-sm text-muted-foreground">Loading…</p>
56
+ )}
57
+ {!loading && conversations.length === 0 && (
58
+ <div className="flex flex-col items-center gap-2 p-8 text-center text-muted-foreground">
59
+ <InboxIcon className="h-6 w-6" />
60
+ <p className="text-sm">No conversations</p>
61
+ </div>
62
+ )}
63
+ {conversations.map((c) => {
64
+ const active = c.id === selectedId
65
+ const unread = c.unreadCount > 0
66
+ return (
67
+ <button
68
+ key={c.id}
69
+ onClick={() => select(c.id)}
70
+ className={cn(
71
+ 'flex w-full items-start gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors',
72
+ active ? 'bg-accent' : 'hover:bg-muted/50',
73
+ )}
74
+ >
75
+ <Avatar name={c.contactName} accent={c.accent} channel={c.channel} />
76
+ <div className="min-w-0 flex-1">
77
+ <div className="flex items-center justify-between gap-2">
78
+ <span className={cn('truncate text-sm text-foreground', unread ? 'font-semibold' : 'font-medium')}>
79
+ {c.contactName}
80
+ </span>
81
+ <span className={cn('shrink-0 text-[11px]', unread ? 'font-semibold text-primary' : 'text-muted-foreground')}>
82
+ {relativeTime(c.lastMessageAt)}
83
+ </span>
84
+ </div>
85
+ <div className="mt-1 flex items-center gap-2">
86
+ <ChannelBadge channel={c.channel} />
87
+ {c.status !== 'open' && (
88
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">{c.status}</span>
89
+ )}
90
+ </div>
91
+ <div className="mt-1 flex items-center justify-between gap-2">
92
+ <span className={cn('truncate text-xs', unread ? 'text-foreground' : 'text-muted-foreground')}>
93
+ {c.lastMessagePreview}
94
+ </span>
95
+ {unread && (
96
+ <span className="flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
97
+ {c.unreadCount}
98
+ </span>
99
+ )}
100
+ </div>
101
+ </div>
102
+ </button>
103
+ )
104
+ })}
105
+ </div>
106
+ </aside>
107
+ )
108
+ }
@@ -0,0 +1,62 @@
1
+ import React from 'react'
2
+ import { MessageSquare } from 'lucide-react'
3
+ import { cn } from '@fayz-ai/ui'
4
+ import { useConversationsStore } from '../ConversationsContext'
5
+ import { ConversationList } from './ConversationList'
6
+ import { MessageThread } from './MessageThread'
7
+ import { ContactPanel } from './ContactPanel'
8
+ import { useMediaQuery } from './shared'
9
+
10
+ export function InboxView() {
11
+ const { conversations, selectedId, deselect } = useConversationsStore((s) => s)
12
+ // xl+ shows the contact panel inline (three panes); below that it's an
13
+ // on-demand slide-over so the list + thread keep room to breathe.
14
+ const isWidePanel = useMediaQuery('(min-width: 1280px)')
15
+ const [panelOpen, setPanelOpen] = React.useState(false)
16
+ const selected = conversations.find((c) => c.id === selectedId) ?? null
17
+
18
+ // Open the panel by default only when it fits inline; collapse it otherwise.
19
+ React.useEffect(() => {
20
+ setPanelOpen(isWidePanel)
21
+ }, [isWidePanel])
22
+
23
+ return (
24
+ <div className="relative flex h-full min-h-0 overflow-hidden bg-background">
25
+ {/* Conversation list — single-pane below lg (full width), fixed rail at lg+.
26
+ Below lg it yields to the thread once a conversation is open. */}
27
+ <ConversationList className={cn(selected ? 'hidden lg:flex' : 'flex')} />
28
+
29
+ {/* Thread — hidden below lg until a conversation is selected. */}
30
+ {selected ? (
31
+ <MessageThread
32
+ selected={selected}
33
+ panelOpen={panelOpen}
34
+ onTogglePanel={() => setPanelOpen((v) => !v)}
35
+ onBack={deselect}
36
+ className="flex"
37
+ />
38
+ ) : (
39
+ <section className="hidden min-w-0 flex-1 flex-col items-center justify-center bg-muted/20 text-muted-foreground lg:flex">
40
+ <MessageSquare className="h-9 w-9" />
41
+ <p className="mt-2 text-sm">Select a conversation to start chatting</p>
42
+ </section>
43
+ )}
44
+
45
+ {/* Contact panel — inline column at xl, slide-over overlay below xl. */}
46
+ {selected && panelOpen && (
47
+ <>
48
+ <button
49
+ className="absolute inset-0 z-10 bg-black/40 xl:hidden"
50
+ aria-label="Close details"
51
+ onClick={() => setPanelOpen(false)}
52
+ />
53
+ <ContactPanel
54
+ contact={selected}
55
+ onClose={() => setPanelOpen(false)}
56
+ className="absolute inset-y-0 right-0 z-20 w-[340px] max-w-[88%] shadow-2xl xl:static xl:z-auto xl:w-[300px] xl:max-w-none xl:shadow-none"
57
+ />
58
+ </>
59
+ )}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,169 @@
1
+ import React from 'react'
2
+ import { Send, Clock, Archive, PanelRight, MessageSquare, Smile, Paperclip, ChevronLeft } from 'lucide-react'
3
+ import { Button, cn } from '@fayz-ai/ui'
4
+ import { useConversationsStore } from '../ConversationsContext'
5
+ import { CHANNEL_ACCENT, CHANNEL_LABELS } from '../channel'
6
+ import type { Conversation, Message } from '../types'
7
+ import { Avatar, ChannelBadge, dayLabel } from './shared'
8
+
9
+ // Build a flat render list: day-separator rows interleaved with messages, and
10
+ // each message flagged as the start of a new sender-run (WhatsApp grouping).
11
+ type Row =
12
+ | { kind: 'day'; id: string; label: string }
13
+ | { kind: 'msg'; id: string; message: Message; startsRun: boolean; endsRun: boolean }
14
+
15
+ function buildRows(messages: Message[]): Row[] {
16
+ const rows: Row[] = []
17
+ let lastDay = ''
18
+ messages.forEach((m, i) => {
19
+ const day = new Date(m.at).toDateString()
20
+ if (day !== lastDay) {
21
+ rows.push({ kind: 'day', id: `day-${day}`, label: dayLabel(m.at) })
22
+ lastDay = day
23
+ }
24
+ const prev = messages[i - 1]
25
+ const next = messages[i + 1]
26
+ const samePrev = prev && prev.direction === m.direction && new Date(prev.at).toDateString() === day
27
+ const sameNext = next && next.direction === m.direction && new Date(next.at).toDateString() === day
28
+ rows.push({ kind: 'msg', id: m.id, message: m, startsRun: !samePrev, endsRun: !sameNext })
29
+ })
30
+ return rows
31
+ }
32
+
33
+ export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }: {
34
+ selected: Conversation
35
+ onTogglePanel: () => void
36
+ panelOpen: boolean
37
+ onBack?: () => void
38
+ className?: string
39
+ }) {
40
+ const { messages, sending, send, setStatus } = useConversationsStore((s) => s)
41
+ const [draft, setDraft] = React.useState('')
42
+ const threadRef = React.useRef<HTMLDivElement>(null)
43
+
44
+ React.useEffect(() => {
45
+ threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: 'smooth' })
46
+ }, [messages.length, selected.id])
47
+
48
+ async function handleSend() {
49
+ if (!draft.trim()) return
50
+ const body = draft
51
+ setDraft('')
52
+ await send(body)
53
+ }
54
+
55
+ const accent = CHANNEL_ACCENT[selected.channel]
56
+ const rows = React.useMemo(() => buildRows(messages), [messages])
57
+
58
+ return (
59
+ <section className={cn('flex min-w-0 flex-1 flex-col bg-muted/20', className)}>
60
+ {/* Header */}
61
+ <div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5">
62
+ <div className="flex min-w-0 items-center gap-2 md:gap-3">
63
+ {onBack && (
64
+ <Button variant="ghost" size="icon" className="-ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label="Back to conversations">
65
+ <ChevronLeft className="h-5 w-5" />
66
+ </Button>
67
+ )}
68
+ <Avatar name={selected.contactName} accent={selected.accent} size="sm" channel={selected.channel} />
69
+ <div className="min-w-0">
70
+ <p className="truncate text-sm font-semibold text-foreground">{selected.contactName}</p>
71
+ <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
72
+ <ChannelBadge channel={selected.channel} />
73
+ <span className="truncate">{selected.contactHandle}</span>
74
+ </p>
75
+ </div>
76
+ </div>
77
+ <div className="flex shrink-0 items-center gap-1.5">
78
+ <Button variant="outline" size="sm" onClick={() => setStatus('snoozed')} aria-label="Snooze">
79
+ <Clock className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">Snooze</span>
80
+ </Button>
81
+ <Button variant="outline" size="sm" onClick={() => setStatus('closed')} aria-label="Close conversation">
82
+ <Archive className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">Close</span>
83
+ </Button>
84
+ <Button
85
+ variant={panelOpen ? 'secondary' : 'ghost'}
86
+ size="icon"
87
+ onClick={onTogglePanel}
88
+ aria-label="Toggle contact details"
89
+ >
90
+ <PanelRight className="h-4 w-4" />
91
+ </Button>
92
+ </div>
93
+ </div>
94
+
95
+ {/* Messages */}
96
+ <div ref={threadRef} className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
97
+ {rows.map((row) => {
98
+ if (row.kind === 'day') {
99
+ return (
100
+ <div key={row.id} className="my-3 flex items-center justify-center">
101
+ <span className="rounded-full bg-muted px-3 py-0.5 text-[11px] font-medium text-muted-foreground">
102
+ {row.label}
103
+ </span>
104
+ </div>
105
+ )
106
+ }
107
+ const m = row.message
108
+ const outbound = m.direction === 'outbound'
109
+ return (
110
+ <div
111
+ key={row.id}
112
+ className={cn('flex', outbound ? 'justify-end' : 'justify-start', row.startsRun ? 'mt-2.5' : 'mt-0.5')}
113
+ >
114
+ <div
115
+ className={cn(
116
+ 'max-w-[68%] px-3.5 py-2 text-sm shadow-sm',
117
+ outbound ? 'rounded-2xl text-white' : 'rounded-2xl bg-card text-foreground',
118
+ // Tail only on the last bubble of a run, on the sender's side.
119
+ outbound && row.endsRun && 'rounded-br-sm',
120
+ !outbound && row.endsRun && 'rounded-bl-sm',
121
+ )}
122
+ style={outbound ? { backgroundColor: accent.color } : undefined}
123
+ >
124
+ <p className="whitespace-pre-wrap break-words">{m.body}</p>
125
+ <div className={cn('mt-0.5 text-right text-[10px]', outbound ? 'text-white/70' : 'text-muted-foreground')}>
126
+ {new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
127
+ </div>
128
+ </div>
129
+ </div>
130
+ )
131
+ })}
132
+ {rows.length === 0 && (
133
+ <div className="flex h-full flex-col items-center justify-center text-muted-foreground">
134
+ <MessageSquare className="h-7 w-7" />
135
+ <p className="mt-2 text-sm">No messages yet</p>
136
+ </div>
137
+ )}
138
+ </div>
139
+
140
+ {/* Composer */}
141
+ <div className="border-t border-border bg-card px-4 py-3">
142
+ <div className="flex items-end gap-2">
143
+ <Button variant="ghost" size="icon" className="mb-0.5 text-muted-foreground" aria-label="Add emoji">
144
+ <Smile className="h-4 w-4" />
145
+ </Button>
146
+ <Button variant="ghost" size="icon" className="mb-0.5 text-muted-foreground" aria-label="Attach file">
147
+ <Paperclip className="h-4 w-4" />
148
+ </Button>
149
+ <textarea
150
+ value={draft}
151
+ onChange={(e) => setDraft(e.target.value)}
152
+ onKeyDown={(e) => {
153
+ if (e.key === 'Enter' && !e.shiftKey) {
154
+ e.preventDefault()
155
+ void handleSend()
156
+ }
157
+ }}
158
+ rows={1}
159
+ placeholder={`Reply via ${CHANNEL_LABELS[selected.channel]}…`}
160
+ className="max-h-32 min-h-[40px] flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
161
+ />
162
+ <Button onClick={() => void handleSend()} disabled={sending || !draft.trim()} aria-label="Send">
163
+ <Send className="h-4 w-4" />
164
+ </Button>
165
+ </div>
166
+ </div>
167
+ </section>
168
+ )
169
+ }
@@ -0,0 +1,97 @@
1
+ import React from 'react'
2
+ import { cn } from '@fayz-ai/ui'
3
+ import { CHANNEL_ACCENT, CHANNEL_ICON, CHANNEL_LABELS } from '../channel'
4
+ import type { Channel } from '../types'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Small presentational helpers shared across the inbox panes.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Reactive media-query match — drives the responsive list/thread/panel layout. */
11
+ export function useMediaQuery(query: string): boolean {
12
+ const [matches, setMatches] = React.useState(
13
+ () => typeof window !== 'undefined' && window.matchMedia(query).matches,
14
+ )
15
+ React.useEffect(() => {
16
+ if (typeof window === 'undefined') return
17
+ const mql = window.matchMedia(query)
18
+ const onChange = () => setMatches(mql.matches)
19
+ onChange()
20
+ mql.addEventListener('change', onChange)
21
+ return () => mql.removeEventListener('change', onChange)
22
+ }, [query])
23
+ return matches
24
+ }
25
+
26
+ export function initialsOf(name: string): string {
27
+ return name.replace('@', '').split(/\s+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
28
+ }
29
+
30
+ export function Avatar({ name, accent, size = 'md', channel }: {
31
+ name: string
32
+ accent: string
33
+ size?: 'sm' | 'md' | 'lg'
34
+ /** When set, renders a small channel glyph badge over the avatar. */
35
+ channel?: Channel
36
+ }) {
37
+ const dims = size === 'lg' ? 'h-14 w-14 text-lg' : size === 'sm' ? 'h-9 w-9 text-xs' : 'h-10 w-10 text-sm'
38
+ const Icon = channel ? CHANNEL_ICON[channel] : null
39
+ return (
40
+ <div className="relative shrink-0">
41
+ <div
42
+ className={cn('flex items-center justify-center rounded-full font-semibold text-white', dims)}
43
+ style={{ backgroundColor: accent }}
44
+ >
45
+ {initialsOf(name)}
46
+ </div>
47
+ {Icon && (
48
+ <span
49
+ className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full ring-2 ring-card"
50
+ style={{ backgroundColor: CHANNEL_ACCENT[channel!].color }}
51
+ >
52
+ <Icon className="h-2.5 w-2.5 text-white" />
53
+ </span>
54
+ )}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ export function ChannelBadge({ channel, className }: { channel: Channel; className?: string }) {
60
+ const Icon = CHANNEL_ICON[channel]
61
+ return (
62
+ <span
63
+ className={cn(
64
+ 'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium',
65
+ CHANNEL_ACCENT[channel].badge,
66
+ className,
67
+ )}
68
+ >
69
+ <Icon className="h-2.5 w-2.5" />
70
+ {CHANNEL_LABELS[channel]}
71
+ </span>
72
+ )
73
+ }
74
+
75
+ export function relativeTime(iso: string): string {
76
+ const diff = Date.now() - new Date(iso).getTime()
77
+ const mins = Math.round(diff / 60_000)
78
+ if (mins < 1) return 'now'
79
+ if (mins < 60) return `${mins}m`
80
+ const hours = Math.round(mins / 60)
81
+ if (hours < 24) return `${hours}h`
82
+ const days = Math.round(hours / 24)
83
+ if (days < 7) return `${days}d`
84
+ return `${Math.round(days / 7)}w`
85
+ }
86
+
87
+ /** Day label for the thread date separators. */
88
+ export function dayLabel(iso: string): string {
89
+ const d = new Date(iso)
90
+ const now = new Date()
91
+ const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
92
+ const dayDiff = Math.round((startOf(now) - startOf(d)) / 86_400_000)
93
+ if (dayDiff <= 0) return 'Today'
94
+ if (dayDiff === 1) return 'Yesterday'
95
+ if (dayDiff < 7) return d.toLocaleDateString([], { weekday: 'long' })
96
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: now.getFullYear() === d.getFullYear() ? undefined : 'numeric' })
97
+ }