@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -33,11 +33,14 @@ export const Avatar: React.FC<AvatarProps> = ({
33
33
 
34
34
  return (
35
35
  <div
36
- className={classNames(
37
- 'flex-shrink-0 overflow-hidden rounded-[1rem]',
38
- className
39
- )}
40
- style={{ width: `${size}px`, height: `${size}px` }}
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 rounded-sm bg-[#E6E5E3]',
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).toLocaleTimeString([], {
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
@@ -94,3 +94,7 @@
94
94
  .str-chat__date-separator-line {
95
95
  background-color: transparent;
96
96
  }
97
+
98
+ .str-chat__list {
99
+ background: transparent;
100
+ }
@@ -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
+ }