@fayz-ai/plugin-conversations 0.9.0 → 0.9.2

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 (39) hide show
  1. package/README.md +4 -4
  2. package/dist/{ConversationsContext.d.ts → context.d.ts} +2 -2
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/{channel.d.ts → lib/channel.d.ts} +1 -1
  9. package/dist/lib/channel.d.ts.map +1 -0
  10. package/dist/{ConversationsPage.d.ts → views/ConversationsPage.d.ts} +2 -2
  11. package/dist/views/ConversationsPage.d.ts.map +1 -0
  12. package/package.json +5 -7
  13. package/dist/ConversationsContext.d.ts.map +0 -1
  14. package/dist/ConversationsPage.d.ts.map +0 -1
  15. package/dist/channel.d.ts.map +0 -1
  16. package/src/ConversationsContext.tsx +0 -51
  17. package/src/ConversationsPage.tsx +0 -21
  18. package/src/channel.ts +0 -34
  19. package/src/data/accents.ts +0 -12
  20. package/src/data/mock.test.ts +0 -90
  21. package/src/data/mock.ts +0 -261
  22. package/src/data/supabase.ts +0 -264
  23. package/src/data/tables.ts +0 -7
  24. package/src/data/types.ts +0 -17
  25. package/src/index.ts +0 -190
  26. package/src/locales/en.ts +0 -68
  27. package/src/locales/index.ts +0 -7
  28. package/src/locales/pt-BR.ts +0 -68
  29. package/src/migrations/001_conversations.sql +0 -74
  30. package/src/migrations/002_contact_person.sql +0 -22
  31. package/src/migrations/index.ts +0 -108
  32. package/src/store.ts +0 -136
  33. package/src/types.ts +0 -77
  34. package/src/views/ContactPanel.tsx +0 -97
  35. package/src/views/ConversationList.tsx +0 -135
  36. package/src/views/InboxView.tsx +0 -64
  37. package/src/views/MessageThread.tsx +0 -167
  38. package/src/views/NewConversationModal.tsx +0 -204
  39. package/src/views/shared.tsx +0 -97
@@ -1,64 +0,0 @@
1
- import React from 'react'
2
- import { MessageSquare } from 'lucide-react'
3
- import { cn } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import { useConversationsStore } from '../ConversationsContext'
6
- import { ConversationList } from './ConversationList'
7
- import { MessageThread } from './MessageThread'
8
- import { ContactPanel } from './ContactPanel'
9
- import { useMediaQuery } from './shared'
10
-
11
- export function InboxView() {
12
- const t = useTranslation()
13
- const { conversations, selectedId, deselect } = useConversationsStore((s) => s)
14
- // xl+ shows the contact panel inline (three panes); below that it's an
15
- // on-demand slide-over so the list + thread keep room to breathe.
16
- const isWidePanel = useMediaQuery('(min-width: 1280px)')
17
- const [panelOpen, setPanelOpen] = React.useState(false)
18
- const selected = conversations.find((c) => c.id === selectedId) ?? null
19
-
20
- // Open the panel by default only when it fits inline; collapse it otherwise.
21
- React.useEffect(() => {
22
- setPanelOpen(isWidePanel)
23
- }, [isWidePanel])
24
-
25
- return (
26
- <div className="relative flex h-full min-h-0 overflow-hidden bg-background">
27
- {/* Conversation list — single-pane below lg (full width), fixed rail at lg+.
28
- Below lg it yields to the thread once a conversation is open. */}
29
- <ConversationList className={cn(selected ? 'hidden lg:flex' : 'flex')} />
30
-
31
- {/* Thread — hidden below lg until a conversation is selected. */}
32
- {selected ? (
33
- <MessageThread
34
- selected={selected}
35
- panelOpen={panelOpen}
36
- onTogglePanel={() => setPanelOpen((v) => !v)}
37
- onBack={deselect}
38
- className="flex"
39
- />
40
- ) : (
41
- <section className="hidden min-w-0 flex-1 flex-col items-center justify-center bg-muted/20 text-muted-foreground lg:flex">
42
- <MessageSquare className="h-9 w-9" />
43
- <p className="mt-2 text-sm">{t('conversations.empty.select')}</p>
44
- </section>
45
- )}
46
-
47
- {/* Contact panel — inline column at xl, slide-over overlay below xl. */}
48
- {selected && panelOpen && (
49
- <>
50
- <button
51
- className="absolute inset-0 z-10 bg-black/40 xl:hidden"
52
- aria-label="Close details"
53
- onClick={() => setPanelOpen(false)}
54
- />
55
- <ContactPanel
56
- contact={selected}
57
- onClose={() => setPanelOpen(false)}
58
- 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"
59
- />
60
- </>
61
- )}
62
- </div>
63
- )
64
- }
@@ -1,167 +0,0 @@
1
- import React from 'react'
2
- import { Send, Clock, Archive, PanelRight, MessageSquare, ChevronLeft } from 'lucide-react'
3
- import { Button, cn } from '@fayz-ai/ui'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import { useConversationsStore } from '../ConversationsContext'
6
- import { CHANNEL_ACCENT, CHANNEL_LABELS } from '../channel'
7
- import type { Conversation, Message } from '../types'
8
- import { Avatar, ChannelBadge, dayLabel } from './shared'
9
-
10
- // Build a flat render list: day-separator rows interleaved with messages, and
11
- // each message flagged as the start of a new sender-run (WhatsApp grouping).
12
- type Row =
13
- | { kind: 'day'; id: string; label: string }
14
- | { kind: 'msg'; id: string; message: Message; startsRun: boolean; endsRun: boolean }
15
-
16
- function buildRows(messages: Message[]): Row[] {
17
- const rows: Row[] = []
18
- let lastDay = ''
19
- messages.forEach((m, i) => {
20
- const day = new Date(m.at).toDateString()
21
- if (day !== lastDay) {
22
- rows.push({ kind: 'day', id: `day-${day}`, label: dayLabel(m.at) })
23
- lastDay = day
24
- }
25
- const prev = messages[i - 1]
26
- const next = messages[i + 1]
27
- const samePrev = prev && prev.direction === m.direction && new Date(prev.at).toDateString() === day
28
- const sameNext = next && next.direction === m.direction && new Date(next.at).toDateString() === day
29
- rows.push({ kind: 'msg', id: m.id, message: m, startsRun: !samePrev, endsRun: !sameNext })
30
- })
31
- return rows
32
- }
33
-
34
- export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }: {
35
- selected: Conversation
36
- onTogglePanel: () => void
37
- panelOpen: boolean
38
- onBack?: () => void
39
- className?: string
40
- }) {
41
- const t = useTranslation()
42
- const { messages, sending, send, setStatus } = useConversationsStore((s) => s)
43
- const [draft, setDraft] = React.useState('')
44
- const threadRef = React.useRef<HTMLDivElement>(null)
45
-
46
- React.useEffect(() => {
47
- threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: 'smooth' })
48
- }, [messages.length, selected.id])
49
-
50
- async function handleSend() {
51
- if (!draft.trim()) return
52
- const body = draft
53
- setDraft('')
54
- await send(body)
55
- }
56
-
57
- const accent = CHANNEL_ACCENT[selected.channel]
58
- const rows = React.useMemo(() => buildRows(messages), [messages])
59
-
60
- return (
61
- <section className={cn('flex min-w-0 flex-1 flex-col bg-muted/20', className)}>
62
- {/* Header */}
63
- <div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5">
64
- <div className="flex min-w-0 items-center gap-2 md:gap-3">
65
- {onBack && (
66
- <Button variant="ghost" size="icon" className="-ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={t('conversations.thread.back')}>
67
- <ChevronLeft className="h-5 w-5" />
68
- </Button>
69
- )}
70
- <Avatar name={selected.contactName} accent={selected.accent} size="sm" channel={selected.channel} />
71
- <div className="min-w-0">
72
- <p className="truncate text-sm font-semibold text-foreground">{selected.contactName}</p>
73
- <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
74
- <ChannelBadge channel={selected.channel} />
75
- <span className="truncate">{selected.contactHandle}</span>
76
- </p>
77
- </div>
78
- </div>
79
- <div className="flex shrink-0 items-center gap-1.5">
80
- <Button variant="outline" size="sm" onClick={() => setStatus('snoozed')} aria-label={t('conversations.thread.snooze')}>
81
- <Clock className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">{t('conversations.thread.snooze')}</span>
82
- </Button>
83
- <Button variant="outline" size="sm" onClick={() => setStatus('closed')} aria-label={t('conversations.thread.close')}>
84
- <Archive className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">{t('conversations.thread.close')}</span>
85
- </Button>
86
- <Button
87
- variant={panelOpen ? 'secondary' : 'ghost'}
88
- size="icon"
89
- onClick={onTogglePanel}
90
- aria-label={t('conversations.thread.details')}
91
- >
92
- <PanelRight className="h-4 w-4" />
93
- </Button>
94
- </div>
95
- </div>
96
-
97
- {/* Messages */}
98
- <div ref={threadRef} className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
99
- {rows.map((row) => {
100
- if (row.kind === 'day') {
101
- return (
102
- <div key={row.id} className="my-3 flex items-center justify-center">
103
- <span className="rounded-full bg-muted px-3 py-0.5 text-[11px] font-medium text-muted-foreground">
104
- {row.label}
105
- </span>
106
- </div>
107
- )
108
- }
109
- const m = row.message
110
- const outbound = m.direction === 'outbound'
111
- return (
112
- <div
113
- key={row.id}
114
- className={cn('flex', outbound ? 'justify-end' : 'justify-start', row.startsRun ? 'mt-2.5' : 'mt-0.5')}
115
- >
116
- <div
117
- className={cn(
118
- 'max-w-[68%] px-3.5 py-2 text-sm shadow-sm',
119
- outbound ? 'rounded-2xl text-white' : 'rounded-2xl bg-card text-foreground',
120
- // Tail only on the last bubble of a run, on the sender's side.
121
- outbound && row.endsRun && 'rounded-br-sm',
122
- !outbound && row.endsRun && 'rounded-bl-sm',
123
- )}
124
- style={outbound ? { backgroundColor: accent.color } : undefined}
125
- >
126
- <p className="whitespace-pre-wrap break-words">{m.body}</p>
127
- <div className={cn('mt-0.5 text-right text-[10px]', outbound ? 'text-white/70' : 'text-muted-foreground')}>
128
- {new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
129
- </div>
130
- </div>
131
- </div>
132
- )
133
- })}
134
- {rows.length === 0 && (
135
- <div className="flex h-full flex-col items-center justify-center text-muted-foreground">
136
- <MessageSquare className="h-7 w-7" />
137
- <p className="mt-2 text-sm">{t('conversations.thread.empty')}</p>
138
- </div>
139
- )}
140
- </div>
141
-
142
- {/* Composer */}
143
- {/* TODO(follow-up): wire real emoji picker + attachment upload (removed the
144
- dead placeholder buttons rather than shipping non-functional UI). */}
145
- <div className="border-t border-border bg-card px-4 py-3">
146
- <div className="flex items-end gap-2">
147
- <textarea
148
- value={draft}
149
- onChange={(e) => setDraft(e.target.value)}
150
- onKeyDown={(e) => {
151
- if (e.key === 'Enter' && !e.shiftKey) {
152
- e.preventDefault()
153
- void handleSend()
154
- }
155
- }}
156
- rows={1}
157
- placeholder={t('conversations.thread.reply', { channel: CHANNEL_LABELS[selected.channel] })}
158
- 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"
159
- />
160
- <Button onClick={() => void handleSend()} disabled={sending || !draft.trim()} aria-label={t('conversations.thread.send')}>
161
- <Send className="h-4 w-4" />
162
- </Button>
163
- </div>
164
- </div>
165
- </section>
166
- )
167
- }
@@ -1,204 +0,0 @@
1
- import React from 'react'
2
- import { Button, Modal, ModalContent, cn, toast } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import { useLimitGuard, invalidateLimit, ContactPicker, type ContactPickerValue } from '@fayz-ai/saas'
5
- import { useConversationsStore, useConversationsConfig } from '../ConversationsContext'
6
- import { CHANNEL_ACCENT, CHANNEL_ICON, CHANNEL_LABELS } from '../channel'
7
- import type { Channel } from '../types'
8
-
9
- const CHANNELS: Channel[] = ['whatsapp', 'sms', 'instagram', 'email', 'webchat']
10
-
11
- /**
12
- * Which field of a person IS the handle on a given channel. Phone channels read
13
- * the phone, email reads the email; Instagram and web chat have no counterpart
14
- * on a person record, so they always have to be typed. Deliberately NOT
15
- * cross-filling (an email in a WhatsApp handle looked plausible on screen and
16
- * was simply wrong).
17
- */
18
- function personHandleFor(channel: Channel, contact: ContactPickerValue | null): string {
19
- if (!contact) return ''
20
- if (channel === 'email') return contact.email ?? ''
21
- if (channel === 'sms' || channel === 'whatsapp') return contact.phone ?? ''
22
- return ''
23
- }
24
-
25
- const HANDLE_LABEL_KEY: Record<Channel, string> = {
26
- whatsapp: 'conversations.new.handleLabel.phone',
27
- sms: 'conversations.new.handleLabel.phone',
28
- email: 'conversations.new.handleLabel.email',
29
- instagram: 'conversations.new.handleLabel.instagram',
30
- webchat: 'conversations.new.handleLabel.webchat',
31
- }
32
-
33
- export function NewConversationModal({
34
- open,
35
- onOpenChange,
36
- }: {
37
- open: boolean
38
- onOpenChange: (open: boolean) => void
39
- }) {
40
- const t = useTranslation()
41
- const create = useConversationsStore((s) => s.create)
42
- const config = useConversationsConfig()
43
- const guardConversations = useLimitGuard('conversations_month')
44
-
45
- const [channel, setChannel] = React.useState<Channel>('whatsapp')
46
- const [contact, setContact] = React.useState<ContactPickerValue | null>(null)
47
- // Handle the user typed themselves. The one taken FROM the contact is derived
48
- // (see personHandleFor) and shown on the picker's chip — same as the agenda,
49
- // which never had a second phone field.
50
- const [typedHandle, setTypedHandle] = React.useState('')
51
- // While the picker's inline create form is open it already asks for phone and
52
- // email, so showing our own handle field would ask for the phone twice.
53
- const [creatingContact, setCreatingContact] = React.useState(false)
54
- const [firstMessage, setFirstMessage] = React.useState('')
55
- const [submitting, setSubmitting] = React.useState(false)
56
-
57
- // Reset the form each time the modal opens. `pickerKey` remounts the picker so
58
- // its internal search text resets too.
59
- const [pickerKey, setPickerKey] = React.useState(0)
60
- React.useEffect(() => {
61
- if (open) {
62
- setChannel('whatsapp')
63
- setContact(null)
64
- setTypedHandle('')
65
- setCreatingContact(false)
66
- setFirstMessage('')
67
- setSubmitting(false)
68
- setPickerKey((k) => k + 1)
69
- }
70
- }, [open])
71
-
72
- // What we'd message on this channel: the contact's own datum when they have
73
- // one, otherwise whatever the user typed. Recomputed on every channel switch,
74
- // so flipping WhatsApp → Email swaps phone for email with no stale state.
75
- const derivedHandle = personHandleFor(channel, contact)
76
- const effectiveHandle = derivedHandle || typedHandle
77
- const handleLabel = t(HANDLE_LABEL_KEY[channel])
78
-
79
- const canSubmit = (contact?.name.trim().length ?? 0) > 0 && !submitting
80
-
81
- async function handleSubmit(e: React.FormEvent) {
82
- e.preventDefault()
83
- if (!canSubmit) return
84
- setSubmitting(true)
85
- try {
86
- // Plan quantity guard (client-side, before the store call): opens the
87
- // global UpgradeModal and aborts when the monthly cap is reached. Inside
88
- // the try on purpose — it hits the network to count usage, and a rejection
89
- // out here used to escape unhandled, leaving the modal open with no
90
- // feedback at all (indistinguishable from a dead button).
91
- if ((await guardConversations()) === 'blocked') return
92
- await create({
93
- channel,
94
- contactName: contact!.name.trim(),
95
- contactPersonId: contact?.id,
96
- contactHandle: effectiveHandle.trim() || undefined,
97
- firstMessage: firstMessage.trim() || undefined,
98
- })
99
- invalidateLimit('conversations_month')
100
- onOpenChange(false)
101
- } catch (err) {
102
- // A failed insert (unresolved tenant, RLS rejection, offline…) previously
103
- // threw past the `finally` — `onOpenChange(false)` never ran, so the modal
104
- // stayed open with no feedback and the thread silently vanished. Surface
105
- // the failure and keep the form open so the user can retry.
106
- const message = err instanceof Error ? err.message : String(err)
107
- toast.error(t('conversations.new.createFailed'), { description: message })
108
- } finally {
109
- setSubmitting(false)
110
- }
111
- }
112
-
113
- return (
114
- <Modal open={open} onOpenChange={onOpenChange}>
115
- <ModalContent size="md">
116
- <form onSubmit={handleSubmit} className="flex flex-col gap-4">
117
- <h2 className="text-base font-semibold text-foreground">{t('conversations.new.title')}</h2>
118
-
119
- {/* Channel picker */}
120
- <div>
121
- <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
122
- {t('conversations.new.channel')}
123
- </label>
124
- <div className="flex flex-wrap gap-1.5">
125
- {CHANNELS.map((ch) => {
126
- const Icon = CHANNEL_ICON[ch]
127
- const active = channel === ch
128
- return (
129
- <button
130
- key={ch}
131
- type="button"
132
- onClick={() => setChannel(ch)}
133
- aria-pressed={active}
134
- className={cn(
135
- 'inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors',
136
- active
137
- ? 'text-white'
138
- : 'bg-muted text-muted-foreground hover:bg-muted/70',
139
- )}
140
- style={active ? { backgroundColor: CHANNEL_ACCENT[ch].color } : undefined}
141
- >
142
- <Icon className="h-3 w-3" />
143
- {CHANNEL_LABELS[ch]}
144
- </button>
145
- )
146
- })}
147
- </div>
148
- </div>
149
-
150
- {/* Contact — shared find-or-create flow (@fayz-ai/saas), the same one
151
- the agenda uses to pick a client. The picker owns its label, the
152
- search field, the chip and the channel handle, so this composer and
153
- the appointment modal render the SAME thing from the same code. */}
154
- <ContactPicker
155
- key={pickerKey}
156
- value={contact}
157
- onChange={setContact}
158
- kind={config.contactKind}
159
- extensionTable={config.contactExtensionTable}
160
- lookup={config.contactLookup}
161
- allowFreeText
162
- onCreatingChange={setCreatingContact}
163
- autoFocus
164
- label={t('conversations.new.contactName')}
165
- placeholder={t('conversations.new.contactNamePlaceholder')}
166
- secondaryText={derivedHandle || undefined}
167
- handleField={{
168
- label: handleLabel,
169
- derived: derivedHandle || undefined,
170
- value: typedHandle,
171
- onChange: setTypedHandle,
172
- fieldLabel: t('conversations.new.handle'),
173
- placeholder: t('conversations.new.handlePlaceholder'),
174
- }}
175
- />
176
-
177
- {/* First message */}
178
- <div>
179
- <label htmlFor="conv-first-message" className="mb-1.5 block text-xs font-medium text-muted-foreground">
180
- {t('conversations.new.firstMessage')}
181
- </label>
182
- <textarea
183
- id="conv-first-message"
184
- value={firstMessage}
185
- onChange={(e) => setFirstMessage(e.target.value)}
186
- rows={3}
187
- placeholder={t('conversations.new.firstMessagePlaceholder')}
188
- className="max-h-40 min-h-[64px] w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
189
- />
190
- </div>
191
-
192
- <div className="mt-1 flex justify-end gap-2">
193
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
194
- {t('conversations.new.cancel')}
195
- </Button>
196
- <Button type="submit" disabled={!canSubmit}>
197
- {submitting ? t('conversations.new.creating') : t('conversations.new.create')}
198
- </Button>
199
- </div>
200
- </form>
201
- </ModalContent>
202
- </Modal>
203
- )
204
- }
@@ -1,97 +0,0 @@
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
- }