@linktr.ee/messaging-react 2.3.0 → 2.3.3-rc-1779777764

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 (35) hide show
  1. package/dist/{Card-DdpdnSh_.js → Card-Bz1cMPwJ.js} +16 -16
  2. package/dist/{Card-DdpdnSh_.js.map → Card-Bz1cMPwJ.js.map} +1 -1
  3. package/dist/{Card-ot16XqS2.cjs → Card-CBWof9at.cjs} +2 -2
  4. package/dist/{Card-ot16XqS2.cjs.map → Card-CBWof9at.cjs.map} +1 -1
  5. package/dist/{Card-CexShqpK.cjs → Card-D2KIDqPs.cjs} +2 -2
  6. package/dist/{Card-CexShqpK.cjs.map → Card-D2KIDqPs.cjs.map} +1 -1
  7. package/dist/{Card-4takoN_-.js → Card-D9iWkHKh.js} +6 -6
  8. package/dist/{Card-4takoN_-.js.map → Card-D9iWkHKh.js.map} +1 -1
  9. package/dist/{Card-BuROm0u7.js → Card-DFG3rvzF.js} +19 -19
  10. package/dist/{Card-BuROm0u7.js.map → Card-DFG3rvzF.js.map} +1 -1
  11. package/dist/{Card-CgpHBx-W.cjs → Card-DgXoTo6c.cjs} +2 -2
  12. package/dist/{Card-CgpHBx-W.cjs.map → Card-DgXoTo6c.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-Drsh4B5o.js → LockedThumbnail-BkeCwTCw.js} +8 -8
  14. package/dist/{LockedThumbnail-Drsh4B5o.js.map → LockedThumbnail-BkeCwTCw.js.map} +1 -1
  15. package/dist/{LockedThumbnail-CydtYOSA.cjs → LockedThumbnail-CD9YTQ0r.cjs} +2 -2
  16. package/dist/{LockedThumbnail-CydtYOSA.cjs.map → LockedThumbnail-CD9YTQ0r.cjs.map} +1 -1
  17. package/dist/assets/index.css +1 -1
  18. package/dist/index-DuGzAVyy.cjs +18 -0
  19. package/dist/index-DuGzAVyy.cjs.map +1 -0
  20. package/dist/index-v6yofqub.js +8128 -0
  21. package/dist/index-v6yofqub.js.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.js +1 -1
  24. package/package.json +1 -1
  25. package/src/components/ChannelView.tsx +8 -2
  26. package/src/components/CustomMessage/CustomMessage.stories.tsx +140 -0
  27. package/src/components/CustomMessage/CustomMessageActions.tsx +35 -0
  28. package/src/components/CustomMessage/index.tsx +20 -15
  29. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +12 -11
  30. package/src/components/CustomMessageInput/index.tsx +7 -3
  31. package/src/styles.css +129 -19
  32. package/dist/index-BCbVXFHI.js +0 -4698
  33. package/dist/index-BCbVXFHI.js.map +0 -1
  34. package/dist/index-CQ913euH.cjs +0 -2
  35. package/dist/index-CQ913euH.cjs.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-CQ913euH.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-DuGzAVyy.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-BCbVXFHI.js";
1
+ import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-v6yofqub.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "2.3.0",
3
+ "version": "2.3.3-rc-1779777764",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -26,6 +26,7 @@ import { Avatar } from './Avatar'
26
26
  import { ChannelInfoDialog } from './ChannelInfoDialog'
27
27
  import { CustomDateSeparator } from './CustomDateSeparator'
28
28
  import { CustomMessage } from './CustomMessage'
29
+ import { CustomMessageActions } from './CustomMessage/CustomMessageActions'
29
30
  import { CustomMessageInput } from './CustomMessageInput'
30
31
  import { CustomSystemMessage } from './CustomSystemMessage'
31
32
  import CustomTypingIndicator from './CustomTypingIndicator'
@@ -35,6 +36,7 @@ import { LoadingState } from './MessagingShell/LoadingState'
35
36
 
36
37
  const ICON_BTN_CLASS =
37
38
  'size-10 rounded-full bg-[#F1F0EE] hover:bg-[#E5E4E1] flex items-center justify-center transition-colors duration-150 focus-ring'
39
+
38
40
  const DM_AGENT_HEADER_HELPER_TEXT = 'Replies instantly with AI assistant'
39
41
 
40
42
  /**
@@ -374,7 +376,12 @@ const ChannelViewInner: React.FC<{
374
376
 
375
377
  return (
376
378
  <>
377
- <WithComponents overrides={{ Message: MessageOverride }}>
379
+ <WithComponents
380
+ overrides={{
381
+ Message: MessageOverride,
382
+ MessageActions: CustomMessageActions,
383
+ }}
384
+ >
378
385
  <Window>
379
386
  {/* Custom Channel Header */}
380
387
  <div key="lt-channel-header" className="p-4">
@@ -404,7 +411,6 @@ const ChannelViewInner: React.FC<{
404
411
  <MessageList
405
412
  hideDeletedMessages
406
413
  hideNewMessageSeparator={false}
407
- messageActions={undefined}
408
414
  />
409
415
  </div>
410
416
 
@@ -1,6 +1,7 @@
1
1
  import '../../stream-custom-data'
2
2
 
3
3
  import type { Meta, StoryFn } from '@storybook/react'
4
+ import { expect, userEvent, within } from '@storybook/test'
4
5
  import React, { useEffect } from 'react'
5
6
  import {
6
7
  Channel as ChannelType,
@@ -13,6 +14,7 @@ import {
13
14
  MessageList,
14
15
  MessageUIComponentProps,
15
16
  Window,
17
+ WithComponents,
16
18
  } from 'stream-chat-react'
17
19
 
18
20
  import {
@@ -22,6 +24,8 @@ import {
22
24
  } from '../../stories/decorators/storyUser'
23
25
  import CustomTypingIndicator from '../CustomTypingIndicator'
24
26
 
27
+ import { CustomMessageActions } from './CustomMessageActions'
28
+
25
29
  import { CustomMessage } from './index'
26
30
 
27
31
  const meta: Meta = {
@@ -58,6 +62,8 @@ const createMockChannel = async (
58
62
 
59
63
  const channelData = {
60
64
  members: [storyUsers.creator.id, storyUsers.visitor.id],
65
+ // Required for useUserRole: canDelete needs delete-own-message, canFlag needs flag-message
66
+ own_capabilities: ['delete-own-message', 'flag-message'],
61
67
  }
62
68
 
63
69
  const channel = client.channel(
@@ -352,6 +358,8 @@ LockedAttachment.args = {
352
358
  ],
353
359
  }
354
360
 
361
+
362
+
355
363
  export const ChatbotVariants: StoryFn<TemplateProps> = Template.bind({})
356
364
  ChatbotVariants.args = {
357
365
  messages: [
@@ -445,3 +453,135 @@ WithTypingIndicatorComparison.parameters = {
445
453
  },
446
454
  },
447
455
  }
456
+
457
+ // ─── Message Actions ──────────────────────────────────────────────────────────
458
+
459
+ /**
460
+ * Same as TemplateInner but wraps with WithComponents so CustomMessageActions
461
+ * is wired in — matching what ChannelView does in production.
462
+ */
463
+ const WithActionsTemplateInner: React.FC<{
464
+ currentUser: StoryUser
465
+ messages: TemplateProps['messages']
466
+ }> = ({ currentUser, messages }) => {
467
+ const [client] = React.useState(() => {
468
+ const c = new StreamChat('mock-api-key', { allowServerSideConnect: true })
469
+ c.userID = currentUser.id
470
+ c.user = currentUser
471
+ return c
472
+ })
473
+
474
+ const [channel, setChannel] = React.useState<ChannelType | null>(null)
475
+
476
+ useEffect(() => {
477
+ createMockChannel(client, messages).then(setChannel)
478
+ }, [client, messages])
479
+
480
+ const MessageComponent = React.useMemo(() => {
481
+ return function CustomMessageComponent(props: MessageUIComponentProps) {
482
+ return <CustomMessage {...props} />
483
+ }
484
+ }, [])
485
+
486
+ if (!channel) {
487
+ return <div className="p-4">Loading...</div>
488
+ }
489
+
490
+ return (
491
+ <Chat client={client}>
492
+ <div className="h-screen w-full bg-white">
493
+ <Channel channel={channel} TypingIndicator={CustomTypingIndicator}>
494
+ <WithComponents
495
+ overrides={{
496
+ Message: MessageComponent,
497
+ MessageActions: CustomMessageActions,
498
+ }}
499
+ >
500
+ <Window>
501
+ <MessageList />
502
+ </Window>
503
+ </WithComponents>
504
+ </Channel>
505
+ </div>
506
+ </Chat>
507
+ )
508
+ }
509
+
510
+ const WithActionsTemplate: StoryFn<TemplateProps> = ({
511
+ currentUser = storyUsers.creator,
512
+ messages,
513
+ }) => (
514
+ <WithActionsTemplateInner
515
+ key={currentUser.id}
516
+ currentUser={currentUser}
517
+ messages={messages}
518
+ />
519
+ )
520
+
521
+ export const DeleteOwnMessage: StoryFn<TemplateProps> =
522
+ WithActionsTemplate.bind({})
523
+ DeleteOwnMessage.args = {
524
+ currentUser: storyUsers.creator,
525
+ messages: [
526
+ {
527
+ id: 'msg-1',
528
+ text: 'Hey, how are you?',
529
+ user: storyUsers.visitor,
530
+ },
531
+ {
532
+ id: 'msg-2',
533
+ text: 'Doing great — hover me to delete!',
534
+ user: storyUsers.creator,
535
+ },
536
+ ],
537
+ }
538
+ DeleteOwnMessage.play = async ({ canvasElement }) => {
539
+ const canvas = within(canvasElement)
540
+ const ownBubble = await canvas.findByText('Doing great — hover me to delete!')
541
+ await userEvent.hover(ownBubble)
542
+ const toggle = await canvas.findByTestId('message-actions-toggle-button')
543
+ await userEvent.click(toggle)
544
+ await expect(canvas.getByText('Delete')).toBeInTheDocument()
545
+ }
546
+ DeleteOwnMessage.parameters = {
547
+ docs: {
548
+ description: {
549
+ story:
550
+ 'Current user hovers their own message. Only "Delete" is shown — "Report" is hidden.',
551
+ },
552
+ },
553
+ }
554
+
555
+ export const ReportOthersMessage: StoryFn<TemplateProps> =
556
+ WithActionsTemplate.bind({})
557
+ ReportOthersMessage.args = {
558
+ currentUser: storyUsers.creator,
559
+ messages: [
560
+ {
561
+ id: 'msg-1',
562
+ text: 'Hover me to report!',
563
+ user: storyUsers.visitor,
564
+ },
565
+ {
566
+ id: 'msg-2',
567
+ text: "That's a fine message.",
568
+ user: storyUsers.creator,
569
+ },
570
+ ],
571
+ }
572
+ ReportOthersMessage.play = async ({ canvasElement }) => {
573
+ const canvas = within(canvasElement)
574
+ const otherBubble = await canvas.findByText('Hover me to report!')
575
+ await userEvent.hover(otherBubble)
576
+ const toggle = await canvas.findByTestId('message-actions-toggle-button')
577
+ await userEvent.click(toggle)
578
+ await expect(canvas.getByText('Report')).toBeInTheDocument()
579
+ }
580
+ ReportOthersMessage.parameters = {
581
+ docs: {
582
+ description: {
583
+ story:
584
+ "Current user hovers the other participant's message. Only \"Report\" is shown — \"Delete\" is hidden.",
585
+ },
586
+ },
587
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react'
2
+ import { useMessageContext, useTranslationContext } from 'stream-chat-react'
3
+ import {
4
+ DefaultDropdownActionButton,
5
+ MessageActions,
6
+ type MessageActionSetItem,
7
+ } from 'stream-chat-react/experimental'
8
+
9
+ const DeleteAction = () => {
10
+ const { handleDelete } = useMessageContext('CustomMessageActions')
11
+ const { t } = useTranslationContext('CustomMessageActions')
12
+ return (
13
+ <DefaultDropdownActionButton onClick={handleDelete}>
14
+ {t('Delete')}
15
+ </DefaultDropdownActionButton>
16
+ )
17
+ }
18
+
19
+ const ReportAction = () => {
20
+ const { handleFlag } = useMessageContext('CustomMessageActions')
21
+ return (
22
+ <DefaultDropdownActionButton onClick={handleFlag}>
23
+ Report
24
+ </DefaultDropdownActionButton>
25
+ )
26
+ }
27
+
28
+ const MESSAGE_ACTION_SET: MessageActionSetItem[] = [
29
+ { Component: DeleteAction, placement: 'dropdown', type: 'delete' },
30
+ { Component: ReportAction, placement: 'dropdown', type: 'flag' },
31
+ ]
32
+
33
+ export const CustomMessageActions = () => (
34
+ <MessageActions messageActionSet={MESSAGE_ACTION_SET} />
35
+ )
@@ -85,6 +85,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
85
85
  const {
86
86
  Attachment = DefaultAttachment,
87
87
  EditMessageModal = DefaultEditMessageModal,
88
+ MessageActions,
88
89
  MessageBlocked = DefaultMessageBlocked,
89
90
  MessageBouncePrompt = DefaultMessageBouncePrompt,
90
91
  MessageDeleted = DefaultMessageDeleted,
@@ -297,24 +298,28 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
297
298
  )}
298
299
  <MessageErrorIcon />
299
300
  </div>
300
- {/* Tip/paid tags stay outside; chatbot attachment indicator stays outside too */}
301
- {(!isChatbot || useAttachmentFooterChatbotTag) && (
302
- <MessageTag
303
- message={message}
304
- hasAttachment={hasRenderableAttachments}
305
- isMyMessage={isMine}
306
- />
307
- )}
308
- {chatbotVotingEnabled && isChatbot && (
309
- <MessageVoteButtons
310
- selected={voteState}
311
- onVoteUp={voteUp}
312
- onVoteDown={voteDown}
313
- />
314
- )}
315
301
  </div>
316
302
  )}
303
+ {MessageActions && <MessageActions />}
317
304
  </div>
305
+ {!isAttachment && !isTipOnly && (
306
+ <div className="str-chat__message-footer">
307
+ {(!isChatbot || useAttachmentFooterChatbotTag) && (
308
+ <MessageTag
309
+ message={message}
310
+ hasAttachment={hasRenderableAttachments}
311
+ isMyMessage={isMine}
312
+ />
313
+ )}
314
+ {chatbotVotingEnabled && isChatbot && (
315
+ <MessageVoteButtons
316
+ selected={voteState}
317
+ onVoteUp={voteUp}
318
+ onVoteDown={voteDown}
319
+ />
320
+ )}
321
+ </div>
322
+ )}
318
323
  {showReplyCountButton && (
319
324
  <MessageRepliesCountButton
320
325
  onClick={handleOpenThread}
@@ -23,11 +23,7 @@ let mockContextSendButton:
23
23
  )
24
24
 
25
25
  vi.mock('stream-chat-react', () => ({
26
- MessageInput: ({
27
- Input,
28
- }: {
29
- Input: React.ComponentType
30
- }) => (
26
+ MessageInput: ({ Input }: { Input: React.ComponentType }) => (
31
27
  <div data-testid="stream-message-input">
32
28
  <Input />
33
29
  </div>
@@ -38,9 +34,9 @@ vi.mock('stream-chat-react', () => ({
38
34
  TextareaComposer: ({
39
35
  maxRows: _maxRows,
40
36
  ...props
41
- }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { maxRows?: number }) => (
42
- <textarea data-testid="textarea-composer" {...props} />
43
- ),
37
+ }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
38
+ maxRows?: number
39
+ }) => <textarea data-testid="textarea-composer" {...props} />,
44
40
  AttachmentPreviewList: () => <div data-testid="attachment-preview-list" />,
45
41
  QuotedMessagePreview: () => <div data-testid="quoted-message-preview" />,
46
42
  useChannelStateContext: () => ({
@@ -81,7 +77,6 @@ describe('CustomMessageInput', () => {
81
77
  expect(messageInput).not.toHaveAttribute('aria-disabled')
82
78
  expect(messageInput).not.toHaveAttribute('inert')
83
79
  expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
84
- expect(screen.getByTestId('textarea-composer')).not.toHaveAttribute('autofocus')
85
80
  })
86
81
 
87
82
  it('renders the frozen message input when channel is frozen', () => {
@@ -159,7 +154,11 @@ describe('CustomMessageInput', () => {
159
154
  it('renders the custom SendButton from component context', () => {
160
155
  mockChannelData = {}
161
156
  mockContextSendButton = () => (
162
- <button type="button" data-testid="custom-context-send-button" aria-label="Custom Send" />
157
+ <button
158
+ type="button"
159
+ data-testid="custom-context-send-button"
160
+ aria-label="Custom Send"
161
+ />
163
162
  )
164
163
 
165
164
  renderWithProviders(<CustomMessageInput />)
@@ -175,7 +174,9 @@ describe('CustomMessageInput', () => {
175
174
  renderWithProviders(<CustomMessageInput />)
176
175
 
177
176
  expect(screen.getByTestId('send-button')).toBeInTheDocument()
178
- expect(screen.queryByTestId('custom-context-send-button')).not.toBeInTheDocument()
177
+ expect(
178
+ screen.queryByTestId('custom-context-send-button')
179
+ ).not.toBeInTheDocument()
179
180
  })
180
181
 
181
182
  it('preserves disabled state on the send button when there is no sendable data', () => {
@@ -34,9 +34,8 @@ const CustomMessageInputInner: React.FC = () => {
34
34
  const { channel } = useChannelStateContext()
35
35
  const isFrozen = channel?.data?.frozen === true
36
36
  const { handleSubmit } = useMessageInputContext()
37
- const { SendButton: SendButtonFromContext } = useComponentContext(
38
- 'CustomMessageInput',
39
- )
37
+ const { SendButton: SendButtonFromContext } =
38
+ useComponentContext('CustomMessageInput')
40
39
  const SendButton = SendButtonFromContext ?? DefaultSendButton
41
40
  const hasSendableData = useMessageComposerHasSendableData()
42
41
  const isSendDisabled = isFrozen || !hasSendableData
@@ -55,6 +54,11 @@ const CustomMessageInputInner: React.FC = () => {
55
54
  <TextareaComposer
56
55
  aria-disabled={isFrozen || undefined}
57
56
  className="w-full resize-none outline-none leading-6"
57
+ // While this might usually be considered an anti-pattern, in most
58
+ // cases, when a message thread is rendered, we want the input to
59
+ // gain focus automatically.
60
+ // eslint-disable-next-line jsx-a11y/no-autofocus
61
+ autoFocus={!isFrozen}
58
62
  maxRows={4}
59
63
  readOnly={isFrozen}
60
64
  tabIndex={isFrozen ? -1 : undefined}
package/src/styles.css CHANGED
@@ -317,32 +317,82 @@
317
317
  }
318
318
 
319
319
  .str-chat__li .str-chat__message-inner {
320
- grid-column-gap: 0;
321
- }
322
-
323
- .str-chat__li .str-chat__message--me .str-chat__message-bubble-wrapper {
324
- display: flex;
325
- flex-direction: column;
326
- align-items: flex-end;
327
- gap: 2px;
320
+ grid-column-gap: 4px;
328
321
  }
329
322
 
323
+ /* Outer grid for other (incoming) messages — add footer row after message */
330
324
  .str-chat__li .str-chat__message--other {
331
- position: relative;
325
+ grid-template-areas:
326
+ '. message-reminder'
327
+ 'avatar message'
328
+ '. footer'
329
+ '. replies'
330
+ '. translation-notice'
331
+ '. custom-metadata'
332
+ '. metadata';
332
333
  grid-template-columns: 28px 1fr;
334
+ justify-items: flex-start;
333
335
  }
334
336
 
335
- .str-chat__li .str-chat__message--other .str-chat__message-bubble-wrapper {
337
+ /* Outer grid for me (outgoing) messages — add footer row after message */
338
+ .str-chat__li .str-chat__message--me {
339
+ grid-template-areas:
340
+ 'message-reminder'
341
+ 'message'
342
+ 'footer'
343
+ 'replies'
344
+ 'translation-notice'
345
+ 'custom-metadata'
346
+ 'metadata';
347
+ justify-items: end;
348
+ }
349
+
350
+ /* Avatar: proper grid item aligned to bottom of its row */
351
+ .str-chat__li .str-chat__message--other .str-chat__message-sender-avatar {
352
+ grid-area: avatar;
353
+ align-self: end;
354
+ }
355
+
356
+ /* message-inner sits in the message grid area */
357
+ .str-chat__li .str-chat__message-inner {
358
+ grid-area: message;
359
+ }
360
+
361
+ /* Inner grid: bubble beside options only (no footer row) */
362
+ .str-chat__li .str-chat__message--other .str-chat__message-inner {
363
+ grid-template-areas: 'message-bubble options';
364
+ grid-template-columns: auto auto;
365
+ }
366
+
367
+ .str-chat__li .str-chat__message--me .str-chat__message-inner {
368
+ grid-template-areas: 'options message-bubble';
369
+ grid-template-columns: auto auto;
370
+ }
371
+
372
+ /* Bubble wrapper occupies the message-bubble grid area */
373
+ .str-chat__li .str-chat__message-inner > .str-chat__message-bubble-wrapper {
374
+ grid-area: message-bubble;
375
+ }
376
+
377
+ /* Actions button centers vertically with the bubble */
378
+ .str-chat__li .str-chat__message-inner .str-chat__message-options {
379
+ align-self: center;
380
+ }
381
+
382
+ /* Footer: tag then vote buttons stacked, in outer grid footer area */
383
+ .str-chat__li .str-chat__message-footer {
384
+ grid-area: footer;
336
385
  display: flex;
337
386
  flex-direction: column;
338
- align-items: flex-start;
339
387
  gap: 2px;
340
388
  }
341
389
 
342
- .str-chat__li .str-chat__message--other .str-chat__avatar {
343
- position: absolute;
344
- left: 0;
345
- bottom: 0;
390
+ .str-chat__li .str-chat__message--other .str-chat__message-footer {
391
+ align-items: flex-start;
392
+ }
393
+
394
+ .str-chat__li .str-chat__message--me .str-chat__message-footer {
395
+ align-items: flex-end;
346
396
  }
347
397
 
348
398
  /* Channel list load-more button overrides */
@@ -415,9 +465,7 @@
415
465
  }
416
466
 
417
467
  .message-chatbot-indicator--attachment {
418
- width: 100%;
419
468
  gap: 8px;
420
- margin-top: 4px;
421
469
  color: rgba(0, 0, 0, 0.3);
422
470
  }
423
471
 
@@ -436,7 +484,6 @@
436
484
  display: inline-flex;
437
485
  align-items: center;
438
486
  gap: 2px;
439
- margin-top: 4px;
440
487
  }
441
488
 
442
489
  .message-vote-button {
@@ -535,8 +582,52 @@
535
582
  word-break: break-word;
536
583
  }
537
584
 
538
- .str-chat__message-text-inner p {
585
+ .str-chat__message
586
+ .str-chat__message-inner
587
+ .str-chat__message-bubble
588
+ .str-chat__message-text
589
+ .str-chat__message-text-inner
590
+ p,
591
+ .str-chat__quoted-message-preview
592
+ .str-chat__message-inner
593
+ .str-chat__message-bubble
594
+ .str-chat__message-text
595
+ .str-chat__message-text-inner
596
+ p {
539
597
  margin: 0.25rem 0;
598
+ }
599
+
600
+ .str-chat__message
601
+ .str-chat__message-inner
602
+ .str-chat__message-bubble
603
+ .str-chat__message-text
604
+ .str-chat__message-text-inner
605
+ > p:first-child,
606
+ .str-chat__quoted-message-preview
607
+ .str-chat__message-inner
608
+ .str-chat__message-bubble
609
+ .str-chat__message-text
610
+ .str-chat__message-text-inner
611
+ > p:first-child {
612
+ margin-top: 0;
613
+ }
614
+
615
+ .str-chat__message
616
+ .str-chat__message-inner
617
+ .str-chat__message-bubble
618
+ .str-chat__message-text
619
+ .str-chat__message-text-inner
620
+ > p:last-child,
621
+ .str-chat__quoted-message-preview
622
+ .str-chat__message-inner
623
+ .str-chat__message-bubble
624
+ .str-chat__message-text
625
+ .str-chat__message-text-inner
626
+ > p:last-child {
627
+ margin-bottom: 0;
628
+ }
629
+
630
+ .str-chat__message-text-inner p {
540
631
  text-wrap: pretty;
541
632
  }
542
633
  .str-chat__message-text-inner > :first-child {
@@ -546,6 +637,25 @@
546
637
  margin-bottom: 0;
547
638
  }
548
639
 
640
+ /* Locked attachment wrapper: stack card + text bubble in the correct direction */
641
+ .str-chat__li .str-chat__message--me .str-chat__message-bubble-wrapper {
642
+ display: flex;
643
+ flex-direction: column;
644
+ align-items: flex-end;
645
+ }
646
+
647
+ .str-chat__li .str-chat__message--other .str-chat__message-bubble-wrapper {
648
+ display: flex;
649
+ flex-direction: column;
650
+ align-items: flex-start;
651
+ }
652
+
653
+ /* Gap between locked attachment card and accompanying text bubble */
654
+ .str-chat__message-bubble-wrapper
655
+ > .str-chat__message-bubble:not(:first-child) {
656
+ margin-top: 4px;
657
+ }
658
+
549
659
  /* Standalone tip message (tip without text) */
550
660
  .message-tip-standalone {
551
661
  display: inline-flex;