Add arrow to message bubbles and improve spacing (#2474)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Fri, 19 Sep 2025 11:06:05 +0000 (16:36 +0530)
committerGitHub <noreply@github.com>
Fri, 19 Sep 2025 11:06:05 +0000 (21:06 +1000)
* Add arrow to message bubbles and improve spacing

* make bubble message avatar smaller

* add bubble layout for event content

* adjust bubble arrow

* fix missing return statement for event content

* hide bubble for event content

* add new arrow to bubble message

* fix avatar username relative alignment

* fix types

* fix code block header background

* revert avatar size and make arrow less sharp

* show event messages timestamp to right when bubble is hidden

* fix avatar base css

* move message header outside bubble

* fix event time appears on left in hidden bubles

src/app/components/message/content/EventContent.tsx
src/app/components/message/layout/Bubble.tsx
src/app/components/message/layout/layout.css.ts
src/app/features/room/message/Message.tsx
src/app/features/room/message/styles.css.ts
src/app/styles/CustomHtml.css.ts

index 97ff26f7d1967b954b0681438eeb5941792ee132..130ba8c958d9785165b90be58d217a4400318fe2 100644 (file)
@@ -1,6 +1,6 @@
 import { Box, Icon, IconSrc } from 'folds';
 import React, { ReactNode } from 'react';
-import { CompactLayout, ModernLayout } from '..';
+import { BubbleLayout, CompactLayout, ModernLayout } from '..';
 import { MessageLayout } from '../../../state/settings';
 
 export type EventContentProps = {
@@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
     </Box>
   );
 
-  return messageLayout === MessageLayout.Compact ? (
-    <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
-  ) : (
-    <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
-  );
+  if (messageLayout === MessageLayout.Compact) {
+    return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
+  }
+  if (messageLayout === MessageLayout.Bubble) {
+    return (
+      <BubbleLayout hideBubble before={beforeJSX}>
+        {msgContentJSX}
+      </BubbleLayout>
+    );
+  }
+  return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
 }
index 6f8e70da03673fe85bfe280d638d5b90cabf4cb2..93ff84bcb0baba2325ee22c93d9360a1fb980f2c 100644 (file)
@@ -1,18 +1,63 @@
 import React, { ReactNode } from 'react';
-import { Box, as } from 'folds';
+import classNames from 'classnames';
+import { Box, ContainerColor, as, color } from 'folds';
 import * as css from './layout.css';
 
+type BubbleArrowProps = {
+  variant: ContainerColor;
+};
+function BubbleLeftArrow({ variant }: BubbleArrowProps) {
+  return (
+    <svg
+      className={css.BubbleLeftArrow}
+      width="9"
+      height="8"
+      viewBox="0 0 9 8"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
+        fill={color[variant].Container}
+      />
+    </svg>
+  );
+}
+
 type BubbleLayoutProps = {
+  hideBubble?: boolean;
   before?: ReactNode;
+  header?: ReactNode;
 };
 
-export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
-  <Box gap="300" {...props} ref={ref}>
-    <Box className={css.BubbleBefore} shrink="No">
-      {before}
-    </Box>
-    <Box className={css.BubbleContent} direction="Column">
-      {children}
+export const BubbleLayout = as<'div', BubbleLayoutProps>(
+  ({ hideBubble, before, header, children, ...props }, ref) => (
+    <Box gap="300" {...props} ref={ref}>
+      <Box className={css.BubbleBefore} shrink="No">
+        {before}
+      </Box>
+      <Box grow="Yes" direction="Column">
+        {header}
+        {hideBubble ? (
+          children
+        ) : (
+          <Box>
+            <Box
+              className={
+                hideBubble
+                  ? undefined
+                  : classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
+              }
+              direction="Column"
+            >
+              {before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
+              {children}
+            </Box>
+          </Box>
+        )}
+      </Box>
     </Box>
-  </Box>
-));
+  )
+);
index 43949cefcbc8296e982cea23dbf6bed96d770e33..cc2cd0c6401c5a3e7987285604933ac9a916afe4 100644 (file)
@@ -120,6 +120,7 @@ export const CompactHeader = style([
 export const AvatarBase = style({
   paddingTop: toRem(4),
   transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
+  display: 'flex',
   alignSelf: 'start',
 
   selectors: {
@@ -133,14 +134,31 @@ export const ModernBefore = style({
   minWidth: toRem(36),
 });
 
-export const BubbleBefore = style([ModernBefore]);
+export const BubbleBefore = style({
+  minWidth: toRem(36),
+});
 
 export const BubbleContent = style({
   maxWidth: toRem(800),
   padding: config.space.S200,
   backgroundColor: color.SurfaceVariant.Container,
   color: color.SurfaceVariant.OnContainer,
-  borderRadius: config.radii.R400,
+  borderRadius: config.radii.R500,
+  position: 'relative',
+});
+
+export const BubbleContentArrowLeft = style({
+  borderTopLeftRadius: 0,
+});
+
+export const BubbleLeftArrow = style({
+  width: toRem(9),
+  height: toRem(8),
+
+  position: 'absolute',
+  top: 0,
+  left: toRem(-8),
+  zIndex: 1,
 });
 
 export const Username = style({
index fbe35770c4b5252cad4a3b17249a4c31ec3a5bf3..9324e1c29d14d6407c955e53ce942716d61089f8 100644 (file)
@@ -723,6 +723,7 @@ export const Message = as<'div', MessageProps>(
     const mx = useMatrixClient();
     const useAuthentication = useMediaAuthentication();
     const senderId = mEvent.getSender() ?? '';
+
     const [hover, setHover] = useState(false);
     const { hoverProps } = useHover({ onHoverChange: setHover });
     const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
@@ -790,7 +791,9 @@ export const Message = as<'div', MessageProps>(
     );
 
     const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
-      <AvatarBase>
+      <AvatarBase
+        className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
+      >
         <Avatar
           className={css.MessageAvatar}
           as="button"
@@ -875,7 +878,9 @@ export const Message = as<'div', MessageProps>(
 
     return (
       <MessageBase
-        className={classNames(css.MessageBase, className)}
+        className={classNames(css.MessageBase, className, {
+          [css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
+        })}
         tabIndex={0}
         space={messageSpacing}
         collapse={collapse}
@@ -1132,8 +1137,7 @@ export const Message = as<'div', MessageProps>(
           </CompactLayout>
         )}
         {messageLayout === MessageLayout.Bubble && (
-          <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
-            {headerJSX}
+          <BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
             {msgContentJSX}
           </BubbleLayout>
         )}
index b87cb50548dffc2e44bfbbf63812dd1c2bd7bd56..4be501bdc12a628660873d89611f74ee48421d51 100644 (file)
@@ -4,6 +4,9 @@ import { DefaultReset, config, toRem } from 'folds';
 export const MessageBase = style({
   position: 'relative',
 });
+export const MessageBaseBubbleCollapsed = style({
+  paddingTop: 0,
+});
 
 export const MessageOptionsBase = style([
   DefaultReset,
@@ -21,6 +24,10 @@ export const MessageOptionsBar = style([
   },
 ]);
 
+export const BubbleAvatarBase = style({
+  paddingTop: 0,
+});
+
 export const MessageAvatar = style({
   cursor: 'pointer',
 });
index f717669ccd667d8836334c610ea8af89f388d0ec..ba7b92142229f8c8542662ba37d7eda94d2d857f 100644 (file)
@@ -1,6 +1,7 @@
 import { style } from '@vanilla-extract/css';
 import { recipe } from '@vanilla-extract/recipes';
 import { color, config, DefaultReset, toRem } from 'folds';
+import { ContainerColor } from './ContainerColor.css';
 
 export const MarginSpaced = style({
   marginBottom: config.space.S200,
@@ -92,11 +93,14 @@ export const CodeBlock = style([
     overflow: 'hidden',
   },
 ]);
-export const CodeBlockHeader = style({
-  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
-  borderBottomWidth: config.borderWidth.B300,
-  gap: config.space.S200,
-});
+export const CodeBlockHeader = style([
+  ContainerColor({ variant: 'Surface' }),
+  {
+    padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+    borderBottomWidth: config.borderWidth.B300,
+    gap: config.space.S200,
+  },
+]);
 export const CodeBlockInternal = style([
   CodeFont,
   {