@linktr.ee/messaging-react 1.8.2 → 1.8.4
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.
- package/dist/assets/index.css +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +220 -198
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Avatar/index.tsx +9 -6
- package/src/components/ChannelList/CustomChannelPreview.stories.tsx +216 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +4 -5
- package/src/index.ts +3 -0
- package/src/styles.css +4 -0
- package/src/utils/formatRelativeTime.test.ts +161 -0
- package/src/utils/formatRelativeTime.ts +62 -0
package/package.json
CHANGED
|
@@ -33,11 +33,14 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
33
33
|
|
|
34
34
|
return (
|
|
35
35
|
<div
|
|
36
|
-
className={classNames(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
className={classNames('flex-shrink-0 overflow-hidden', className)}
|
|
37
|
+
style={{
|
|
38
|
+
width: `${size}px`,
|
|
39
|
+
height: `${size}px`,
|
|
40
|
+
borderRadius: '33%',
|
|
41
|
+
// @ts-expect-error - corner-shape is not recognized by react types
|
|
42
|
+
'corner-shape': 'superellipse(1.3)',
|
|
43
|
+
}}
|
|
41
44
|
>
|
|
42
45
|
{image ? (
|
|
43
46
|
<img
|
|
@@ -49,7 +52,7 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
49
52
|
<div
|
|
50
53
|
aria-hidden="true"
|
|
51
54
|
className={classNames(
|
|
52
|
-
'flex h-full w-full items-center justify-center font-semibold
|
|
55
|
+
'avatar-fallback flex h-full w-full items-center justify-center font-semibold bg-[#E6E5E3] select-none transition-colors',
|
|
53
56
|
fontSizeClass
|
|
54
57
|
)}
|
|
55
58
|
>
|
|
@@ -305,3 +305,219 @@ WithVeryLongUrl.args = {
|
|
|
305
305
|
}),
|
|
306
306
|
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
// Time-based stories
|
|
310
|
+
export const JustNow: StoryFn<ComponentProps> = Template.bind({})
|
|
311
|
+
JustNow.args = {
|
|
312
|
+
channel: createMockChannel({
|
|
313
|
+
id: 'channel-just-now',
|
|
314
|
+
participantName: 'Kevin Chen',
|
|
315
|
+
participantId: 'participant-just-now',
|
|
316
|
+
participantImage: 'https://i.pravatar.cc/150?img=12',
|
|
317
|
+
lastMessageText: 'I just sent this!',
|
|
318
|
+
lastMessageTime: new Date(Date.now() - 1000 * 30), // 30 seconds ago
|
|
319
|
+
}),
|
|
320
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const FiveMinutesAgo: StoryFn<ComponentProps> = Template.bind({})
|
|
324
|
+
FiveMinutesAgo.args = {
|
|
325
|
+
channel: createMockChannel({
|
|
326
|
+
id: 'channel-5m',
|
|
327
|
+
participantName: 'Laura Thompson',
|
|
328
|
+
participantId: 'participant-5m',
|
|
329
|
+
participantImage: 'https://i.pravatar.cc/150?img=13',
|
|
330
|
+
lastMessageText: 'Be right back',
|
|
331
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago
|
|
332
|
+
}),
|
|
333
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export const ThreeHoursAgo: StoryFn<ComponentProps> = Template.bind({})
|
|
337
|
+
ThreeHoursAgo.args = {
|
|
338
|
+
channel: createMockChannel({
|
|
339
|
+
id: 'channel-3h',
|
|
340
|
+
participantName: 'Michael Yang',
|
|
341
|
+
participantId: 'participant-3h',
|
|
342
|
+
participantImage: 'https://i.pravatar.cc/150?img=14',
|
|
343
|
+
lastMessageText: 'Got it, thanks!',
|
|
344
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 3), // 3 hours ago
|
|
345
|
+
}),
|
|
346
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const Yesterday: StoryFn<ComponentProps> = Template.bind({})
|
|
350
|
+
Yesterday.args = {
|
|
351
|
+
channel: createMockChannel({
|
|
352
|
+
id: 'channel-yesterday',
|
|
353
|
+
participantName: 'Nancy Wilson',
|
|
354
|
+
participantId: 'participant-yesterday',
|
|
355
|
+
participantImage: 'https://i.pravatar.cc/150?img=15',
|
|
356
|
+
lastMessageText: 'See you tomorrow',
|
|
357
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 30), // 30 hours ago
|
|
358
|
+
}),
|
|
359
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const ThreeDaysAgo: StoryFn<ComponentProps> = Template.bind({})
|
|
363
|
+
ThreeDaysAgo.args = {
|
|
364
|
+
channel: createMockChannel({
|
|
365
|
+
id: 'channel-3d',
|
|
366
|
+
participantName: 'Oscar Martinez',
|
|
367
|
+
participantId: 'participant-3d',
|
|
368
|
+
participantImage: 'https://i.pravatar.cc/150?img=16',
|
|
369
|
+
lastMessageText: 'Have a great weekend!',
|
|
370
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3), // 3 days ago
|
|
371
|
+
}),
|
|
372
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export const LastWeek: StoryFn<ComponentProps> = Template.bind({})
|
|
376
|
+
LastWeek.args = {
|
|
377
|
+
channel: createMockChannel({
|
|
378
|
+
id: 'channel-1w',
|
|
379
|
+
participantName: 'Patricia Brown',
|
|
380
|
+
participantId: 'participant-1w',
|
|
381
|
+
participantImage: 'https://i.pravatar.cc/150?img=17',
|
|
382
|
+
lastMessageText: 'Talk to you next week',
|
|
383
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), // 1 week ago
|
|
384
|
+
}),
|
|
385
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export const TwoWeeksAgo: StoryFn<ComponentProps> = Template.bind({})
|
|
389
|
+
TwoWeeksAgo.args = {
|
|
390
|
+
channel: createMockChannel({
|
|
391
|
+
id: 'channel-2w',
|
|
392
|
+
participantName: 'Quinn Anderson',
|
|
393
|
+
participantId: 'participant-2w',
|
|
394
|
+
participantImage: 'https://i.pravatar.cc/150?img=18',
|
|
395
|
+
lastMessageText: 'Sounds good',
|
|
396
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14), // 2 weeks ago
|
|
397
|
+
}),
|
|
398
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export const OneMonthAgo: StoryFn<ComponentProps> = Template.bind({})
|
|
402
|
+
OneMonthAgo.args = {
|
|
403
|
+
channel: createMockChannel({
|
|
404
|
+
id: 'channel-1m',
|
|
405
|
+
participantName: 'Rachel Green',
|
|
406
|
+
participantId: 'participant-1m',
|
|
407
|
+
participantImage: 'https://i.pravatar.cc/150?img=19',
|
|
408
|
+
lastMessageText: 'Happy New Year!',
|
|
409
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), // 1 month ago
|
|
410
|
+
}),
|
|
411
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export const SixMonthsAgo: StoryFn<ComponentProps> = Template.bind({})
|
|
415
|
+
SixMonthsAgo.args = {
|
|
416
|
+
channel: createMockChannel({
|
|
417
|
+
id: 'channel-6m',
|
|
418
|
+
participantName: 'Sam Taylor',
|
|
419
|
+
participantId: 'participant-6m',
|
|
420
|
+
participantImage: 'https://i.pravatar.cc/150?img=20',
|
|
421
|
+
lastMessageText: 'Long time no talk!',
|
|
422
|
+
lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 180), // 6 months ago
|
|
423
|
+
}),
|
|
424
|
+
onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export const TimeVariations: StoryFn = () => {
|
|
428
|
+
const [selectedChannelId, _setSelectedChannelId] = React.useState<
|
|
429
|
+
string | null
|
|
430
|
+
>(null)
|
|
431
|
+
|
|
432
|
+
const timeVariations = [
|
|
433
|
+
{
|
|
434
|
+
id: 'time-just-now',
|
|
435
|
+
name: 'Just Now',
|
|
436
|
+
time: new Date(Date.now() - 1000 * 30), // 30 seconds
|
|
437
|
+
message: 'Just sent',
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: 'time-5m',
|
|
441
|
+
name: '5 Minutes',
|
|
442
|
+
time: new Date(Date.now() - 1000 * 60 * 5),
|
|
443
|
+
message: '5 minutes ago',
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
id: 'time-30m',
|
|
447
|
+
name: '30 Minutes',
|
|
448
|
+
time: new Date(Date.now() - 1000 * 60 * 30),
|
|
449
|
+
message: '30 minutes ago',
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: 'time-3h',
|
|
453
|
+
name: '3 Hours',
|
|
454
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 3),
|
|
455
|
+
message: '3 hours ago',
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
id: 'time-yesterday',
|
|
459
|
+
name: 'Yesterday',
|
|
460
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 30),
|
|
461
|
+
message: 'Yesterday',
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: 'time-3d',
|
|
465
|
+
name: '3 Days',
|
|
466
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3),
|
|
467
|
+
message: '3 days ago',
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: 'time-1w',
|
|
471
|
+
name: '1 Week',
|
|
472
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
|
473
|
+
message: '1 week ago',
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: 'time-2w',
|
|
477
|
+
name: '2 Weeks',
|
|
478
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14),
|
|
479
|
+
message: '2 weeks ago',
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
id: 'time-1m',
|
|
483
|
+
name: '1 Month',
|
|
484
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
|
|
485
|
+
message: '1 month ago',
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: 'time-6m',
|
|
489
|
+
name: '6 Months',
|
|
490
|
+
time: new Date(Date.now() - 1000 * 60 * 60 * 24 * 180),
|
|
491
|
+
message: '6 months ago',
|
|
492
|
+
},
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
const channels = timeVariations.map((variation, index) =>
|
|
496
|
+
createMockChannel({
|
|
497
|
+
id: variation.id,
|
|
498
|
+
participantName: variation.name,
|
|
499
|
+
participantId: `participant-${variation.id}`,
|
|
500
|
+
participantImage: `https://i.pravatar.cc/150?img=${21 + index}`,
|
|
501
|
+
lastMessageText: variation.message,
|
|
502
|
+
lastMessageTime: variation.time,
|
|
503
|
+
})
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
const selectedChannel =
|
|
507
|
+
channels.find((c) => c.id === selectedChannelId) || null
|
|
508
|
+
|
|
509
|
+
return (
|
|
510
|
+
<div className="w-[360px] bg-chalk border border-sand rounded-lg overflow-hidden">
|
|
511
|
+
{channels.map((channel) => (
|
|
512
|
+
<CustomChannelPreview
|
|
513
|
+
key={channel.id}
|
|
514
|
+
channel={channel}
|
|
515
|
+
selectedChannel={selectedChannel}
|
|
516
|
+
onChannelSelect={(channel) => {
|
|
517
|
+
console.log('Channel selected:', channel.id)
|
|
518
|
+
}}
|
|
519
|
+
/>
|
|
520
|
+
))}
|
|
521
|
+
</div>
|
|
522
|
+
)
|
|
523
|
+
}
|
|
@@ -3,6 +3,7 @@ import React from 'react'
|
|
|
3
3
|
import { Channel } from 'stream-chat'
|
|
4
4
|
import { ChannelPreviewUIComponentProps } from 'stream-chat-react'
|
|
5
5
|
|
|
6
|
+
import { formatRelativeTime } from '../../utils/formatRelativeTime'
|
|
6
7
|
import { Avatar } from '../Avatar'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -36,10 +37,7 @@ const CustomChannelPreview: React.FC<
|
|
|
36
37
|
channel?.state?.messages?.[channel.state.messages.length - 1]
|
|
37
38
|
const lastMessageText = lastMessage?.text || 'No messages yet'
|
|
38
39
|
const lastMessageTime = lastMessage?.created_at
|
|
39
|
-
? new Date(lastMessage.created_at)
|
|
40
|
-
hour: '2-digit',
|
|
41
|
-
minute: '2-digit',
|
|
42
|
-
})
|
|
40
|
+
? formatRelativeTime(new Date(lastMessage.created_at))
|
|
43
41
|
: ''
|
|
44
42
|
|
|
45
43
|
// Use the unread prop passed by Stream Chat (reactive and updates automatically)
|
|
@@ -60,7 +58,7 @@ const CustomChannelPreview: React.FC<
|
|
|
60
58
|
type="button"
|
|
61
59
|
onClick={handleClick}
|
|
62
60
|
className={classNames(
|
|
63
|
-
'w-full px-4 py-3 transition-colors border-b border-sand text-left max-w-full overflow-hidden focus-ring',
|
|
61
|
+
'group w-full px-4 py-3 transition-colors border-b border-sand text-left max-w-full overflow-hidden focus-ring',
|
|
64
62
|
{
|
|
65
63
|
'bg-primary-alt/10 border-l-4 border-l-primary': isSelected,
|
|
66
64
|
'hover:bg-sand': !isSelected,
|
|
@@ -74,6 +72,7 @@ const CustomChannelPreview: React.FC<
|
|
|
74
72
|
name={participantName}
|
|
75
73
|
image={participantImage}
|
|
76
74
|
size={44}
|
|
75
|
+
className="[&_.avatar-fallback]:group-hover:bg-[#eeeeee]"
|
|
77
76
|
/>
|
|
78
77
|
|
|
79
78
|
{/* Content column */}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export { MessagingProvider } from './providers/MessagingProvider'
|
|
|
18
18
|
export { useMessaging } from './hooks/useMessaging'
|
|
19
19
|
export { useParticipants } from './hooks/useParticipants'
|
|
20
20
|
|
|
21
|
+
// Utils
|
|
22
|
+
export { formatRelativeTime } from './utils/formatRelativeTime'
|
|
23
|
+
|
|
21
24
|
// Types
|
|
22
25
|
export type {
|
|
23
26
|
MessagingShellProps,
|
package/src/styles.css
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { formatRelativeTime } from './formatRelativeTime'
|
|
2
|
+
|
|
3
|
+
describe('formatRelativeTime', () => {
|
|
4
|
+
let originalDate: typeof Date
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
// Save the original Date
|
|
8
|
+
originalDate = global.Date
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
// Restore the original Date
|
|
13
|
+
global.Date = originalDate
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const mockDate = (isoString: string) => {
|
|
17
|
+
const mockNow = new Date(isoString)
|
|
18
|
+
// @ts-expect-error - need to override Date constructor for testing
|
|
19
|
+
global.Date = class extends originalDate {
|
|
20
|
+
// @ts-expect-error - need to override Date constructor for testing
|
|
21
|
+
constructor(...args) {
|
|
22
|
+
if (args.length === 0) {
|
|
23
|
+
super(isoString)
|
|
24
|
+
} else {
|
|
25
|
+
// @ts-expect-error - need to override Date constructor for testing
|
|
26
|
+
super(...args)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static now() {
|
|
31
|
+
return mockNow.getTime()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('Just now', () => {
|
|
37
|
+
it('should return "Just now" for messages less than 1 minute ago', () => {
|
|
38
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
39
|
+
const date = new Date('2024-01-15T11:59:30Z') // 30 seconds ago
|
|
40
|
+
expect(formatRelativeTime(date)).toBe('Just now')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should return "Just now" for messages 0 seconds ago', () => {
|
|
44
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
45
|
+
const date = new Date('2024-01-15T12:00:00Z')
|
|
46
|
+
expect(formatRelativeTime(date)).toBe('Just now')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('Today', () => {
|
|
51
|
+
it('should return time for messages from earlier today', () => {
|
|
52
|
+
mockDate('2024-01-15T14:08:00Z')
|
|
53
|
+
const date = new Date('2024-01-15T09:30:00Z')
|
|
54
|
+
const result = formatRelativeTime(date)
|
|
55
|
+
// Should be a time like "9:30 AM" (exact format depends on locale)
|
|
56
|
+
expect(result).toMatch(/\d{1,2}:\d{2}/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should return time for messages from late last night (same day)', () => {
|
|
60
|
+
mockDate('2024-01-15T23:59:00Z')
|
|
61
|
+
const date = new Date('2024-01-15T00:01:00Z')
|
|
62
|
+
const result = formatRelativeTime(date)
|
|
63
|
+
expect(result).toMatch(/\d{1,2}:\d{2}/)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('Yesterday', () => {
|
|
68
|
+
it('should return "Yesterday" for messages from the previous calendar day', () => {
|
|
69
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
70
|
+
const date = new Date('2024-01-14T12:00:00Z')
|
|
71
|
+
expect(formatRelativeTime(date)).toBe('Yesterday')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should return "Yesterday" even if less than 24 hours ago but previous day', () => {
|
|
75
|
+
mockDate('2024-01-15T01:00:00Z')
|
|
76
|
+
const date = new Date('2024-01-14T23:00:00Z') // Only 2 hours ago
|
|
77
|
+
expect(formatRelativeTime(date)).toBe('Yesterday')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should NOT return "Yesterday" for messages from 2 days ago', () => {
|
|
81
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
82
|
+
const date = new Date('2024-01-13T12:00:00Z')
|
|
83
|
+
expect(formatRelativeTime(date)).not.toBe('Yesterday')
|
|
84
|
+
expect(formatRelativeTime(date)).toBe('2d')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('Days ago', () => {
|
|
89
|
+
it('should return "2d" for messages 2 days ago', () => {
|
|
90
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
91
|
+
const date = new Date('2024-01-13T12:00:00Z')
|
|
92
|
+
expect(formatRelativeTime(date)).toBe('2d')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should return "3d" for messages 3 days ago', () => {
|
|
96
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
97
|
+
const date = new Date('2024-01-12T12:00:00Z')
|
|
98
|
+
expect(formatRelativeTime(date)).toBe('3d')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should return "6d" for messages 6 days ago', () => {
|
|
102
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
103
|
+
const date = new Date('2024-01-09T12:00:00Z')
|
|
104
|
+
expect(formatRelativeTime(date)).toBe('6d')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('Weeks ago', () => {
|
|
109
|
+
it('should return "1w" for messages 1 week ago', () => {
|
|
110
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
111
|
+
const date = new Date('2024-01-08T12:00:00Z')
|
|
112
|
+
expect(formatRelativeTime(date)).toBe('1w')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should return "2w" for messages 2 weeks ago', () => {
|
|
116
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
117
|
+
const date = new Date('2024-01-01T12:00:00Z')
|
|
118
|
+
expect(formatRelativeTime(date)).toBe('2w')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should return "3w" for messages 3 weeks ago', () => {
|
|
122
|
+
mockDate('2024-01-29T12:00:00Z')
|
|
123
|
+
const date = new Date('2024-01-08T12:00:00Z')
|
|
124
|
+
expect(formatRelativeTime(date)).toBe('3w')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('Date format', () => {
|
|
129
|
+
it('should return MM/DD/YY format for messages more than 4 weeks ago', () => {
|
|
130
|
+
mockDate('2024-02-15T12:00:00Z')
|
|
131
|
+
const date = new Date('2024-01-01T12:00:00Z')
|
|
132
|
+
expect(formatRelativeTime(date)).toBe('1/1/24')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should return MM/DD/YY format for messages from last year', () => {
|
|
136
|
+
mockDate('2024-01-15T12:00:00Z')
|
|
137
|
+
const date = new Date('2023-12-01T12:00:00Z')
|
|
138
|
+
expect(formatRelativeTime(date)).toBe('12/1/23')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('Edge cases', () => {
|
|
143
|
+
it('should handle month boundaries correctly', () => {
|
|
144
|
+
mockDate('2024-02-01T12:00:00Z')
|
|
145
|
+
const date = new Date('2024-01-31T12:00:00Z')
|
|
146
|
+
expect(formatRelativeTime(date)).toBe('Yesterday')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should handle year boundaries correctly', () => {
|
|
150
|
+
mockDate('2024-01-01T12:00:00Z')
|
|
151
|
+
const date = new Date('2023-12-31T12:00:00Z')
|
|
152
|
+
expect(formatRelativeTime(date)).toBe('Yesterday')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should handle leap year correctly', () => {
|
|
156
|
+
mockDate('2024-03-01T12:00:00Z')
|
|
157
|
+
const date = new Date('2024-02-29T12:00:00Z')
|
|
158
|
+
expect(formatRelativeTime(date)).toBe('Yesterday')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the number of days between two dates (calendar days in UTC, not 24-hour periods)
|
|
3
|
+
* Uses UTC to ensure consistent day calculations globally since messages are stored in UTC
|
|
4
|
+
*/
|
|
5
|
+
const getDaysDifference = (date1: Date, date2: Date): number => {
|
|
6
|
+
const d1 = new Date(
|
|
7
|
+
Date.UTC(date1.getUTCFullYear(), date1.getUTCMonth(), date1.getUTCDate())
|
|
8
|
+
)
|
|
9
|
+
const d2 = new Date(
|
|
10
|
+
Date.UTC(date2.getUTCFullYear(), date2.getUTCMonth(), date2.getUTCDate())
|
|
11
|
+
)
|
|
12
|
+
const diffTime = d2.getTime() - d1.getTime()
|
|
13
|
+
return Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a date - shows time for today, relative time for older messages
|
|
18
|
+
* (e.g., "Just now", "2:08 PM" for today, "Yesterday" for yesterday, "2d" for 2 days ago)
|
|
19
|
+
*/
|
|
20
|
+
export const formatRelativeTime = (date: Date): string => {
|
|
21
|
+
const now = new Date()
|
|
22
|
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
|
23
|
+
|
|
24
|
+
// Less than 1 minute
|
|
25
|
+
if (diffInSeconds < 60) {
|
|
26
|
+
return 'Just now'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if it's today (same calendar day)
|
|
30
|
+
const daysDiff = getDaysDifference(date, now)
|
|
31
|
+
|
|
32
|
+
// If today, show the time (e.g., "2:08 PM")
|
|
33
|
+
if (daysDiff === 0) {
|
|
34
|
+
return date.toLocaleTimeString([], {
|
|
35
|
+
hour: 'numeric',
|
|
36
|
+
minute: '2-digit',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Yesterday
|
|
41
|
+
if (daysDiff === 1) {
|
|
42
|
+
return 'Yesterday'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Less than 7 days - show days
|
|
46
|
+
if (daysDiff < 7) {
|
|
47
|
+
return `${daysDiff}d`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Less than 4 weeks - show weeks
|
|
51
|
+
if (daysDiff < 28) {
|
|
52
|
+
const weeks = Math.floor(daysDiff / 7)
|
|
53
|
+
return `${weeks}w`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// More than 4 weeks - show date as MM/DD/YY
|
|
57
|
+
return date.toLocaleDateString('en-US', {
|
|
58
|
+
month: 'numeric',
|
|
59
|
+
day: 'numeric',
|
|
60
|
+
year: '2-digit',
|
|
61
|
+
})
|
|
62
|
+
}
|