@poolse/react-native 2.19.0 → 2.20.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to `@poolse/react-native` are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
5
  versions follow [semver](https://semver.org).
6
6
 
7
+ ## [2.20.0] — 2026-10-13
8
+
9
+ ### Changed
10
+
11
+ - **The message bubble, rebuilt.** Same props, new rendering:
12
+ - The timestamp (with `edited` and the read ticks) sits on the last
13
+ line of plain-text bodies — an invisible twin reserves its exact
14
+ footprint, so it shares the line or wraps flush right. Short
15
+ messages are one-line bubbles again; the `minWidth` floor that
16
+ padded them out is gone.
17
+ - Media bleeds to a 3px frame of bubble color, capped at 420pt,
18
+ corners concentric with the bubble (default radius 16 → 18). When
19
+ a caption or file cards follow, the mosaic squares its bottom
20
+ corners into the text instead of leaving "ears" of bubble color.
21
+ - Image-only messages float the meta on a scrim pill over the
22
+ picture, matching the web package.
23
+ - 1–3 emoji and nothing else render large, without bubble chrome.
24
+ - Quote cards: 10pt radius, darker-than-bubble tint on both sides,
25
+ and a quoted-of-deleted fallback matching web.
26
+ - A deleted message renders its tombstone even when the body was
27
+ cleared server-side.
28
+
29
+ - **The always-visible corner chevron is gone.** It stamped a control
30
+ into the bubble's rounded corner and duplicated affordances a phone
31
+ already has — long-press opens the actions sheet, swipe quotes.
32
+ `MessageBubble`'s `actionsTrigger` slot remains for custom rows.
33
+
34
+ ### Added
35
+
36
+ - `AttachmentPreview tone="onBrand"` — restyles the file card for
37
+ brand-colored self bubbles (translucent white instead of a surface
38
+ patch). `MessageBubble` passes it automatically.
39
+ - `ImageMosaic squareBottom` — small bottom radii for mosaics that
40
+ flow into a caption; `radius` now applies per-corner. The mosaic
41
+ also takes an explicit pixel width from the bubble, fixing a layout
42
+ where a percentage against the auto-sized bubble could collapse or
43
+ blow the row open.
44
+
7
45
  ## [2.19.0] — 2026-08-25
8
46
 
9
47
  ### Added
package/dist/index.cjs CHANGED
@@ -113,7 +113,7 @@ var radii = {
113
113
  lg: 16,
114
114
  xl: 24,
115
115
  pill: 999,
116
- bubble: 16
116
+ bubble: 18
117
117
  };
118
118
  var spacing = {
119
119
  xs: 4,
@@ -2345,7 +2345,12 @@ var styles12 = reactNative.StyleSheet.create({
2345
2345
  fontWeight: "500"
2346
2346
  }
2347
2347
  });
2348
- function AttachmentPreview({ attachment, onPress, maxWidth = 280 }) {
2348
+ function AttachmentPreview({
2349
+ attachment,
2350
+ onPress,
2351
+ maxWidth = 280,
2352
+ tone = "default"
2353
+ }) {
2349
2354
  const theme = usePoolseTheme();
2350
2355
  const isImage = (attachment.content_type ?? "").startsWith("image/");
2351
2356
  const { url } = react$1.useAttachmentUrl(attachment.id);
@@ -2379,6 +2384,9 @@ function AttachmentPreview({ attachment, onPress, maxWidth = 280 }) {
2379
2384
  }
2380
2385
  ) });
2381
2386
  }
2387
+ const onBrand = tone === "onBrand";
2388
+ const ink = onBrand ? theme.colors.onBrand : theme.colors.ink;
2389
+ const inkSoft = onBrand ? theme.colors.onBrand : theme.colors.ink3;
2382
2390
  return /* @__PURE__ */ jsxRuntime.jsxs(
2383
2391
  reactNative.Pressable,
2384
2392
  {
@@ -2387,23 +2395,23 @@ function AttachmentPreview({ attachment, onPress, maxWidth = 280 }) {
2387
2395
  style: [
2388
2396
  styles13.fileCard,
2389
2397
  {
2390
- backgroundColor: theme.colors.surface2,
2391
- borderColor: theme.colors.border,
2398
+ backgroundColor: onBrand ? "rgba(255,255,255,0.18)" : theme.colors.surface2,
2399
+ borderColor: onBrand ? "transparent" : theme.colors.border,
2392
2400
  borderRadius: theme.radii.md
2393
2401
  }
2394
2402
  ],
2395
2403
  children: [
2396
- /* @__PURE__ */ jsxRuntime.jsx(PoolseIcon, { name: "attachment", size: 18, color: theme.colors.ink2 }),
2404
+ /* @__PURE__ */ jsxRuntime.jsx(PoolseIcon, { name: "attachment", size: 18, color: onBrand ? ink : theme.colors.ink2 }),
2397
2405
  /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: { flex: 1, marginLeft: 8 }, children: [
2398
2406
  /* @__PURE__ */ jsxRuntime.jsx(
2399
2407
  reactNative.Text,
2400
2408
  {
2401
- style: [styles13.fileName, { color: theme.colors.ink, fontFamily: theme.type.fontBody }],
2409
+ style: [styles13.fileName, { color: ink, fontFamily: theme.type.fontBody }],
2402
2410
  numberOfLines: 1,
2403
2411
  children: attachment.original_filename ?? "file"
2404
2412
  }
2405
2413
  ),
2406
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles13.fileSize, { color: theme.colors.ink3 }], children: formatBytes2(attachment.byte_size) })
2414
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles13.fileSize, { color: inkSoft, opacity: onBrand ? 0.8 : 1 }], children: formatBytes2(attachment.byte_size) })
2407
2415
  ] })
2408
2416
  ]
2409
2417
  }
@@ -2431,9 +2439,10 @@ var styles13 = reactNative.StyleSheet.create({
2431
2439
  fontSize: 11
2432
2440
  }
2433
2441
  });
2434
- function ImageMosaic({ images, maxWidth, radius }) {
2442
+ function ImageMosaic({ images, maxWidth, radius, squareBottom = false }) {
2435
2443
  const theme = usePoolseTheme();
2436
2444
  const r = radius ?? theme.radii.md;
2445
+ const rBottom = squareBottom ? 5 : r;
2437
2446
  const [viewerIndex, setViewerIndex] = react.useState(null);
2438
2447
  if (images.length === 0) return null;
2439
2448
  const visible = images.slice(0, 4);
@@ -2446,8 +2455,17 @@ function ImageMosaic({ images, maxWidth, radius }) {
2446
2455
  style: [
2447
2456
  styles14.root,
2448
2457
  {
2449
- borderRadius: r,
2450
- ...maxWidth ? { maxWidth } : {}
2458
+ borderTopLeftRadius: r,
2459
+ borderTopRightRadius: r,
2460
+ borderBottomLeftRadius: rBottom,
2461
+ borderBottomRightRadius: rBottom,
2462
+ // Explicit pixel width, not just a cap: the mosaic sits
2463
+ // inside an auto-sized bubble, and a percentage width
2464
+ // against an indefinite parent collapses (or, worse,
2465
+ // resolves against the image's intrinsic size and blows
2466
+ // the row open). Media bubbles are fixed-width — the
2467
+ // WhatsApp convention anyway.
2468
+ ...maxWidth ? { width: maxWidth } : {}
2451
2469
  }
2452
2470
  ],
2453
2471
  children: [
@@ -2684,6 +2702,27 @@ var styles14 = reactNative.StyleSheet.create({
2684
2702
  borderRadius: 20
2685
2703
  }
2686
2704
  });
2705
+
2706
+ // src/internal/bubbleText.ts
2707
+ function hasMarkdown(body) {
2708
+ return /[*_~`#>|\\]|\[.*\]|^\s*(?:[-+]|\d+[.)])\s|https?:\/\/|www\./ms.test(body);
2709
+ }
2710
+ function emojiOnlyCount(body) {
2711
+ const text = body.trim();
2712
+ if (!text || text.length > 32) return 0;
2713
+ try {
2714
+ const unit = "(?:\\p{Regional_Indicator}{2}|\\p{Extended_Pictographic})[\\u{1F3FB}-\\u{1F3FF}]?\\uFE0F?(?:\\u200D\\p{Extended_Pictographic}[\\u{1F3FB}-\\u{1F3FF}]?\\uFE0F?)*";
2715
+ const whole = new RegExp(`^(?:${unit}){1,3}$`, "u");
2716
+ if (!whole.test(text)) return 0;
2717
+ const each = new RegExp(unit, "gu");
2718
+ return (text.match(each) ?? []).length;
2719
+ } catch {
2720
+ return 0;
2721
+ }
2722
+ }
2723
+ function emojiFontSize(count) {
2724
+ return count === 1 ? 44 : count === 2 ? 38 : 32;
2725
+ }
2687
2726
  var markdownit = Markdown.MarkdownIt({ typographer: false, linkify: true });
2688
2727
  function renderMarkdown(body, args) {
2689
2728
  const baseText = {
@@ -2750,100 +2789,154 @@ function MessageBubble({
2750
2789
  const isSelf = currentUserId !== null && message.sender_id === currentUserId;
2751
2790
  const [expanded, setExpanded] = react.useState(false);
2752
2791
  const bubbleMaxWidth = Math.floor(screenWidth * 0.78);
2753
- const contentMaxWidth = bubbleMaxWidth - 28;
2792
+ const mediaWidth = Math.min(bubbleMaxWidth - 6, 420);
2754
2793
  const sender = react$1.useUser(!isSelf ? message.sender_external_id : null);
2755
2794
  const senderFallbackName = message.sender_external_id ?? "Unknown";
2756
2795
  const senderLabel = sender.profile?.displayName ?? (message.sender_external_id ? labelFor?.(message.sender_external_id) ?? senderFallbackName : senderFallbackName);
2757
2796
  const senderTint = userColor(message.sender_external_id ?? message.sender_id ?? message.id);
2758
2797
  const tailCorner = groupPosition === "last" || groupPosition === "standalone";
2759
- const bubbleColor = isSelf ? theme.colors.selfBubble : theme.colors.otherBubble;
2798
+ const bubbleRadius = theme.radii.bubble;
2760
2799
  const textColor = isSelf ? theme.colors.selfBubbleText : theme.colors.otherBubbleText;
2800
+ const mutedOnBubble = isSelf ? theme.colors.onBrand : theme.colors.ink3;
2761
2801
  const radii2 = {
2762
- topLeft: theme.radii.bubble,
2763
- topRight: theme.radii.bubble,
2764
- bottomLeft: theme.radii.bubble,
2765
- bottomRight: theme.radii.bubble
2802
+ topLeft: bubbleRadius,
2803
+ topRight: bubbleRadius,
2804
+ bottomLeft: bubbleRadius,
2805
+ bottomRight: bubbleRadius
2766
2806
  };
2767
2807
  if (tailCorner) {
2768
- if (isSelf) radii2.bottomRight = 4;
2769
- else radii2.bottomLeft = 4;
2808
+ if (isSelf) radii2.bottomRight = 5;
2809
+ else radii2.bottomLeft = 5;
2770
2810
  }
2771
2811
  const body = message.body ?? "";
2772
- const truncated = maxBodyLength > 0 && body.length > maxBodyLength && !expanded ? body.slice(0, maxBodyLength) + "\u2026" : body;
2773
- const time = formatTime(message.inserted_at);
2774
2812
  const isDeleted = !!message.deleted_at;
2775
2813
  const isEdited = !!message.edited_at && !isDeleted;
2776
- const images = (message.attachments ?? []).filter((a) => isImageContentType(a.content_type));
2777
- const files = (message.attachments ?? []).filter((a) => !isImageContentType(a.content_type));
2778
- const renderAttachments = showAttachments && (images.length > 0 || files.length > 0);
2779
- return /* @__PURE__ */ jsxRuntime.jsx(
2780
- reactNative.View,
2781
- {
2782
- style: [
2783
- styles15.wrap,
2784
- {
2785
- alignSelf: isSelf ? "flex-end" : "flex-start",
2786
- // Pixel maxWidth (not percentage) so single-line and
2787
- // multi-line bubbles land at the same right edge — was
2788
- // 88% of the bubbleCol which produced inconsistent right
2789
- // padding when the row padding changed.
2790
- maxWidth: bubbleMaxWidth
2791
- }
2792
- ],
2793
- children: /* @__PURE__ */ jsxRuntime.jsxs(
2794
- reactNative.View,
2795
- {
2796
- style: [
2797
- styles15.bubble,
2814
+ const trimmable = maxBodyLength > 0 && body.length > maxBodyLength;
2815
+ const truncated = trimmable && !expanded ? body.slice(0, maxBodyLength) + "\u2026" : body;
2816
+ const time = formatTime(message.inserted_at);
2817
+ const images = showAttachments && !isDeleted ? (message.attachments ?? []).filter((a) => isImageContentType(a.content_type)) : [];
2818
+ const files = showAttachments && !isDeleted ? (message.attachments ?? []).filter((a) => !isImageContentType(a.content_type)) : [];
2819
+ const hasMedia = images.length > 0;
2820
+ const hasFiles = files.length > 0;
2821
+ const hasBody = !isDeleted && body.length > 0;
2822
+ const metaOverlay = hasMedia && !hasBody && !hasFiles;
2823
+ const jumboCount = hasBody && !hasMedia && !hasFiles && !message.quoted_message_id ? emojiOnlyCount(body) : 0;
2824
+ const inlineMeta = hasBody && jumboCount === 0 && !trimmable && (!markdown || !hasMarkdown(truncated));
2825
+ const sectionAbove = hasMedia || hasFiles || !!message.quoted_message || isDeleted;
2826
+ const ghost = ` ${isEdited ? "edited " : ""}${time}${isSelf && readState ? " \u2713\u2713" : ""}`;
2827
+ const metaRow = (variant) => {
2828
+ const color = variant === "overlay" ? "#FFFFFF" : variant === "jumbo" ? theme.colors.ink3 : mutedOnBubble;
2829
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2830
+ reactNative.View,
2831
+ {
2832
+ style: [
2833
+ styles15.metaRow,
2834
+ variant === "inline" && styles15.metaInline,
2835
+ variant === "overlay" && styles15.metaOverlay,
2836
+ variant === "block" && styles15.metaBlock,
2837
+ // File-only bubble: the meta row follows the cards directly —
2838
+ // give it air so the time doesn't sit glued to the card.
2839
+ variant === "block" && hasFiles && !hasBody && styles15.metaBlockAfterFiles,
2840
+ variant === "jumbo" && styles15.metaJumbo
2841
+ ],
2842
+ pointerEvents: "none",
2843
+ children: [
2844
+ isEdited ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.editedTag, { color, opacity: 0.8 }], children: "edited" }) : null,
2845
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.meta, { color, opacity: variant === "overlay" ? 1 : 0.75 }], children: time }),
2846
+ isSelf && readState ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles15.readTick, children: /* @__PURE__ */ jsxRuntime.jsx(
2847
+ PoolseIcon,
2848
+ {
2849
+ name: readState === "read" ? "check-double" : "check",
2850
+ size: 13,
2851
+ color
2852
+ }
2853
+ ) }) : null
2854
+ ]
2855
+ }
2856
+ );
2857
+ };
2858
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles15.wrap, { alignSelf: isSelf ? "flex-end" : "flex-start", maxWidth: bubbleMaxWidth }], children: [
2859
+ /* @__PURE__ */ jsxRuntime.jsxs(
2860
+ reactNative.View,
2861
+ {
2862
+ style: [
2863
+ jumboCount === 0 && {
2864
+ backgroundColor: isSelf ? theme.colors.selfBubble : theme.colors.otherBubble,
2865
+ borderColor: isSelf ? "transparent" : theme.colors.border,
2866
+ borderWidth: isSelf ? 0 : reactNative.StyleSheet.hairlineWidth,
2867
+ borderTopLeftRadius: radii2.topLeft,
2868
+ borderTopRightRadius: radii2.topRight,
2869
+ borderBottomLeftRadius: radii2.bottomLeft,
2870
+ borderBottomRightRadius: radii2.bottomRight
2871
+ },
2872
+ jumboCount === 0 && theme.shadows.sm
2873
+ ],
2874
+ children: [
2875
+ showSenderName && !isSelf && jumboCount === 0 && (groupPosition === "first" || groupPosition === "standalone") ? /* @__PURE__ */ jsxRuntime.jsx(
2876
+ reactNative.Text,
2877
+ {
2878
+ style: [styles15.senderName, { color: senderTint, fontFamily: theme.type.fontBody }],
2879
+ children: senderLabel
2880
+ }
2881
+ ) : null,
2882
+ message.quoted_message ? /* @__PURE__ */ jsxRuntime.jsx(
2883
+ QuotedCard,
2884
+ {
2885
+ quoted: message.quoted_message,
2886
+ isSelf,
2887
+ ...labelFor ? { labelFor } : {},
2888
+ ...onQuotedClick ? { onPress: () => onQuotedClick(message.quoted_message.id) } : {}
2889
+ }
2890
+ ) : null,
2891
+ hasMedia ? (
2892
+ // 3px of bubble color framing the picture; the mosaic clips
2893
+ // itself 3px tighter than the bubble so the curves stay
2894
+ // concentric.
2895
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles15.mediaFrame, children: [
2896
+ /* @__PURE__ */ jsxRuntime.jsx(
2897
+ ImageMosaic,
2898
+ {
2899
+ images,
2900
+ maxWidth: mediaWidth,
2901
+ radius: Math.max(bubbleRadius - 3, 8),
2902
+ squareBottom: !metaOverlay
2903
+ }
2904
+ ),
2905
+ metaOverlay ? metaRow("overlay") : null
2906
+ ] })
2907
+ ) : null,
2908
+ hasFiles ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles15.filesBlock, children: files.map((att) => /* @__PURE__ */ jsxRuntime.jsx(
2909
+ AttachmentPreview,
2798
2910
  {
2799
- backgroundColor: bubbleColor,
2800
- borderColor: isSelf ? "transparent" : theme.colors.border,
2801
- borderWidth: isSelf ? 0 : reactNative.StyleSheet.hairlineWidth,
2802
- borderTopLeftRadius: radii2.topLeft,
2803
- borderTopRightRadius: radii2.topRight,
2804
- borderBottomLeftRadius: radii2.bottomLeft,
2805
- borderBottomRightRadius: radii2.bottomRight,
2806
- // Slightly more breathing room — 14/10 reads tighter and
2807
- // matches the iMessage / WhatsApp bubble density.
2808
- paddingHorizontal: 14,
2809
- paddingVertical: 10,
2810
- // Floor the bubble at a width that always fits the meta row
2811
- // ("edited HH:MM ✓✓" → ~110px). Without this, short bodies
2812
- // like "ok" produce a bubble too narrow for the meta to lay
2813
- // out, and `edited` or the time gets clipped.
2814
- minWidth: isSelf && isEdited ? 130 : 90
2911
+ attachment: att,
2912
+ maxWidth: bubbleMaxWidth - 12,
2913
+ tone: isSelf ? "onBrand" : "default"
2815
2914
  },
2816
- theme.shadows.sm
2817
- ],
2818
- children: [
2819
- showSenderName && !isSelf && (groupPosition === "first" || groupPosition === "standalone") ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.senderName, { color: senderTint, fontFamily: theme.type.fontBody }], children: senderLabel }) : null,
2820
- message.quoted_message ? /* @__PURE__ */ jsxRuntime.jsx(
2821
- QuotedCard,
2822
- {
2823
- quoted: message.quoted_message,
2824
- isSelf,
2825
- ...labelFor ? { labelFor } : {},
2826
- ...onQuotedClick ? { onPress: () => onQuotedClick(message.quoted_message.id) } : {}
2827
- }
2828
- ) : null,
2829
- renderAttachments && images.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(ImageMosaic, { images, maxWidth: contentMaxWidth }) : null,
2830
- renderAttachments && files.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: { gap: theme.spacing.xs, marginBottom: theme.spacing.xs }, children: files.map((att) => /* @__PURE__ */ jsxRuntime.jsx(AttachmentPreview, { attachment: att, maxWidth: contentMaxWidth }, att.id)) }) : null,
2831
- body.length > 0 ? isDeleted ? /* @__PURE__ */ jsxRuntime.jsx(
2832
- reactNative.Text,
2833
- {
2834
- style: [
2835
- styles15.body,
2836
- {
2837
- color: theme.colors.ink3,
2838
- fontFamily: theme.type.fontBody,
2839
- fontSize: theme.type.bodySize,
2840
- lineHeight: theme.type.lineHeight,
2841
- fontStyle: "italic"
2842
- }
2843
- ],
2844
- children: "Message deleted"
2845
- }
2846
- ) : markdown ? renderMarkdown(truncated, {
2915
+ att.id
2916
+ )) }) : null,
2917
+ isDeleted ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles15.textBlock, sectionAbove && styles15.textBlockAfter], children: /* @__PURE__ */ jsxRuntime.jsx(
2918
+ reactNative.Text,
2919
+ {
2920
+ style: {
2921
+ color: mutedOnBubble,
2922
+ opacity: 0.7,
2923
+ fontFamily: theme.type.fontBody,
2924
+ fontSize: theme.type.bodySize,
2925
+ lineHeight: theme.type.lineHeight,
2926
+ fontStyle: "italic"
2927
+ },
2928
+ children: "Message deleted"
2929
+ }
2930
+ ) }) : null,
2931
+ hasBody && jumboCount > 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles15.jumboBlock, children: /* @__PURE__ */ jsxRuntime.jsx(
2932
+ reactNative.Text,
2933
+ {
2934
+ style: { fontSize: emojiFontSize(jumboCount), lineHeight: emojiFontSize(jumboCount) + 8 },
2935
+ children: body
2936
+ }
2937
+ ) }) : null,
2938
+ hasBody && jumboCount === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles15.textBlock, sectionAbove && styles15.textBlockAfter], children: [
2939
+ markdown && !inlineMeta ? renderMarkdown(truncated, {
2847
2940
  textColor,
2848
2941
  linkColor: isSelf ? theme.colors.onBrand : theme.colors.brand,
2849
2942
  fontFamily: theme.type.fontBody,
@@ -2853,22 +2946,29 @@ function MessageBubble({
2853
2946
  void reactNative.Linking.openURL(url).catch(() => void 0);
2854
2947
  return false;
2855
2948
  }
2856
- }) : /* @__PURE__ */ jsxRuntime.jsx(
2949
+ }) : /* @__PURE__ */ jsxRuntime.jsxs(
2857
2950
  reactNative.Text,
2858
2951
  {
2859
- style: [
2860
- styles15.body,
2861
- {
2862
- color: textColor,
2863
- fontFamily: theme.type.fontBody,
2864
- fontSize: theme.type.bodySize,
2865
- lineHeight: theme.type.lineHeight
2866
- }
2867
- ],
2868
- children: truncated
2952
+ style: {
2953
+ color: textColor,
2954
+ fontFamily: theme.type.fontBody,
2955
+ fontSize: theme.type.bodySize,
2956
+ lineHeight: theme.type.lineHeight
2957
+ },
2958
+ children: [
2959
+ truncated,
2960
+ inlineMeta ? (
2961
+ // The invisible twin: reserves the meta's footprint
2962
+ // at the end of the last line so the absolutely-
2963
+ // positioned real meta never covers a glyph — and
2964
+ // when the line is full, the reservation wraps and
2965
+ // the meta drops down, flush right.
2966
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: styles15.ghost, children: ghost })
2967
+ ) : null
2968
+ ]
2869
2969
  }
2870
- ) : null,
2871
- maxBodyLength > 0 && body.length > maxBodyLength ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Pressable, { onPress: () => setExpanded((e) => !e), children: /* @__PURE__ */ jsxRuntime.jsx(
2970
+ ),
2971
+ trimmable ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Pressable, { onPress: () => setExpanded((e) => !e), children: /* @__PURE__ */ jsxRuntime.jsx(
2872
2972
  reactNative.Text,
2873
2973
  {
2874
2974
  style: [
@@ -2878,48 +2978,15 @@ function MessageBubble({
2878
2978
  children: expanded ? "Show less" : "Read more"
2879
2979
  }
2880
2980
  ) }) : null,
2881
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles15.metaRow, children: [
2882
- isEdited ? /* @__PURE__ */ jsxRuntime.jsx(
2883
- reactNative.Text,
2884
- {
2885
- style: [
2886
- styles15.editedTag,
2887
- {
2888
- color: isSelf ? theme.colors.onBrand : theme.colors.ink3,
2889
- opacity: 0.7
2890
- }
2891
- ],
2892
- children: "edited"
2893
- }
2894
- ) : null,
2895
- /* @__PURE__ */ jsxRuntime.jsx(
2896
- reactNative.Text,
2897
- {
2898
- style: [
2899
- styles15.meta,
2900
- {
2901
- color: isSelf ? theme.colors.onBrand : theme.colors.ink3,
2902
- opacity: isSelf ? 0.85 : 1
2903
- }
2904
- ],
2905
- children: time
2906
- }
2907
- ),
2908
- isSelf && readState ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles15.readTick, children: /* @__PURE__ */ jsxRuntime.jsx(
2909
- PoolseIcon,
2910
- {
2911
- name: readState === "read" ? "check-double" : "check",
2912
- size: 13,
2913
- color: theme.colors.onBrand
2914
- }
2915
- ) }) : null
2916
- ] }),
2917
- actionsTrigger ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles15.actionsSlot, children: actionsTrigger }) : null
2918
- ]
2919
- }
2920
- )
2921
- }
2922
- );
2981
+ inlineMeta ? metaRow("inline") : null
2982
+ ] }) : null,
2983
+ !inlineMeta && !metaOverlay && jumboCount === 0 ? metaRow("block") : null,
2984
+ actionsTrigger ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles15.actionsSlot, children: actionsTrigger }) : null
2985
+ ]
2986
+ }
2987
+ ),
2988
+ jumboCount > 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: { alignSelf: isSelf ? "flex-end" : "flex-start" }, children: metaRow("jumbo") }) : null
2989
+ ] });
2923
2990
  }
2924
2991
  function QuotedCard({
2925
2992
  quoted,
@@ -2930,7 +2997,7 @@ function QuotedCard({
2930
2997
  const theme = usePoolseTheme();
2931
2998
  const resolved = react$1.useUser(quoted.sender_external_id);
2932
2999
  const senderName = quoted.sender_external_id ? labelFor?.(quoted.sender_external_id) ?? resolved.profile?.displayName ?? quoted.sender_external_id : "Unknown";
2933
- const bg = isSelf ? "rgba(255,255,255,0.18)" : theme.colors.surface2;
3000
+ const bg = isSelf ? "rgba(0,0,0,0.16)" : theme.colors.surface2;
2934
3001
  const accent = isSelf ? theme.colors.onBrand : theme.colors.brand;
2935
3002
  const txt = isSelf ? theme.colors.onBrand : theme.colors.ink;
2936
3003
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -2946,8 +3013,8 @@ function QuotedCard({
2946
3013
  }
2947
3014
  ],
2948
3015
  children: [
2949
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.quotedSender, { color: accent }], children: senderName }),
2950
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.quotedBody, { color: txt }], numberOfLines: 2, children: quoted.body ?? "\u2026" })
3016
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.quotedSender, { color: accent }], numberOfLines: 1, children: senderName }),
3017
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles15.quotedBody, { color: txt }], numberOfLines: 2, children: quoted.deleted_at ? "Original message deleted" : quoted.body ?? "\u2026" })
2951
3018
  ]
2952
3019
  }
2953
3020
  );
@@ -2983,16 +3050,34 @@ var styles15 = reactNative.StyleSheet.create({
2983
3050
  wrap: {
2984
3051
  marginBottom: 2
2985
3052
  },
2986
- bubble: {
2987
- position: "relative"
2988
- },
2989
3053
  senderName: {
2990
3054
  fontSize: 12,
2991
- fontWeight: "600",
2992
- marginBottom: 2
3055
+ fontWeight: "700",
3056
+ paddingTop: 7,
3057
+ paddingHorizontal: 12
2993
3058
  },
2994
- body: {
2995
- flexShrink: 1
3059
+ mediaFrame: {
3060
+ margin: 3
3061
+ },
3062
+ filesBlock: {
3063
+ paddingHorizontal: 6,
3064
+ paddingTop: 6,
3065
+ gap: 4
3066
+ },
3067
+ textBlock: {
3068
+ paddingHorizontal: 12,
3069
+ paddingVertical: 7
3070
+ },
3071
+ // A hair less air when media / files / a quote already sit above.
3072
+ textBlockAfter: {
3073
+ paddingTop: 6
3074
+ },
3075
+ jumboBlock: {
3076
+ paddingVertical: 2
3077
+ },
3078
+ ghost: {
3079
+ color: "transparent",
3080
+ fontSize: 11
2996
3081
  },
2997
3082
  readMore: {
2998
3083
  fontSize: 12,
@@ -3002,10 +3087,36 @@ var styles15 = reactNative.StyleSheet.create({
3002
3087
  metaRow: {
3003
3088
  flexDirection: "row",
3004
3089
  alignItems: "center",
3090
+ gap: 4
3091
+ },
3092
+ metaInline: {
3093
+ position: "absolute",
3094
+ right: 12,
3095
+ // Optically centers the meta against the body's last line (22px
3096
+ // line over the 7px bottom padding): 7 + (22 - 14) / 2. At 8 it
3097
+ // sat visibly low beside a one-line message.
3098
+ bottom: 11
3099
+ },
3100
+ metaOverlay: {
3101
+ position: "absolute",
3102
+ right: 6,
3103
+ bottom: 6,
3104
+ paddingHorizontal: 8,
3105
+ paddingVertical: 3,
3106
+ borderRadius: 999,
3107
+ backgroundColor: "rgba(0,0,0,0.42)"
3108
+ },
3109
+ metaBlock: {
3005
3110
  justifyContent: "flex-end",
3006
- marginTop: 6,
3007
- gap: 5,
3008
- flexShrink: 0
3111
+ paddingHorizontal: 12,
3112
+ paddingBottom: 6,
3113
+ marginTop: -2
3114
+ },
3115
+ metaBlockAfterFiles: {
3116
+ marginTop: 6
3117
+ },
3118
+ metaJumbo: {
3119
+ paddingTop: 2
3009
3120
  },
3010
3121
  meta: {
3011
3122
  fontSize: 11,
@@ -3018,7 +3129,7 @@ var styles15 = reactNative.StyleSheet.create({
3018
3129
  letterSpacing: 0.1
3019
3130
  },
3020
3131
  readTick: {
3021
- marginLeft: 2,
3132
+ marginLeft: 1,
3022
3133
  marginTop: 1
3023
3134
  },
3024
3135
  actionsSlot: {
@@ -3028,20 +3139,21 @@ var styles15 = reactNative.StyleSheet.create({
3028
3139
  },
3029
3140
  quoted: {
3030
3141
  borderLeftWidth: 3,
3031
- paddingLeft: 8,
3032
- paddingVertical: 4,
3033
- paddingRight: 6,
3034
- marginBottom: 6,
3035
- borderRadius: 4
3142
+ paddingLeft: 9,
3143
+ paddingVertical: 5,
3144
+ paddingRight: 8,
3145
+ margin: 4,
3146
+ marginBottom: 0,
3147
+ borderRadius: 10
3036
3148
  },
3037
3149
  quotedSender: {
3038
3150
  fontSize: 12,
3039
- fontWeight: "600",
3040
- marginBottom: 2
3151
+ fontWeight: "700",
3152
+ marginBottom: 1
3041
3153
  },
3042
3154
  quotedBody: {
3043
- fontSize: 12,
3044
- opacity: 0.85
3155
+ fontSize: 12.5,
3156
+ opacity: 0.8
3045
3157
  }
3046
3158
  });
3047
3159
  var DEFAULT_EMOJIS = ["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F389}", "\u{1F64F}", "\u{1F525}"];
@@ -3357,15 +3469,7 @@ function MessageRow({
3357
3469
  maxBodyLength,
3358
3470
  showSenderName,
3359
3471
  showAttachments: attachments,
3360
- markdown,
3361
- actionsTrigger: actions ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Pressable, { onPress: () => setActionsOpen(true), hitSlop: 8, children: /* @__PURE__ */ jsxRuntime.jsx(
3362
- PoolseIcon,
3363
- {
3364
- name: "chevron-down",
3365
- size: 14,
3366
- color: isSelf ? theme.colors.onBrand : theme.colors.ink3
3367
- }
3368
- ) }) : null
3472
+ markdown
3369
3473
  }
3370
3474
  )
3371
3475
  }