@poolse/react-native 2.18.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/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,
2798
2848
  {
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
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,
2910
+ {
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
  }
@@ -5836,6 +5940,10 @@ function format(total) {
5836
5940
  return `${hours > 0 ? `${hours}:` : ""}${mm}:${String(seconds).padStart(2, "0")}`;
5837
5941
  }
5838
5942
  var CHROME_IDLE_MS = 4500;
5943
+ var ENDED_LINGER_MS = 2500;
5944
+ function isOver(phase) {
5945
+ return phase === "declined" || phase === "busy" || phase === "timed-out";
5946
+ }
5839
5947
  function CallScreen({
5840
5948
  userId,
5841
5949
  labelFor,
@@ -5951,6 +6059,11 @@ function CallScreen({
5951
6059
  endedOnEmpty.current = true;
5952
6060
  hangUp();
5953
6061
  }, [alone, calls.phase, hangUp]);
6062
+ react.useEffect(() => {
6063
+ if (!isOver(calls.phase)) return;
6064
+ const timer = setTimeout(calls.reset, ENDED_LINGER_MS);
6065
+ return () => clearTimeout(timer);
6066
+ }, [calls.phase, calls.reset]);
5954
6067
  const micOn = usingSfu ? sfu.micEnabled : !mesh.muted;
5955
6068
  const toggleMic = react.useCallback(() => {
5956
6069
  if (usingSfu) void sfu.toggleMic();
@@ -5964,7 +6077,7 @@ function CallScreen({
5964
6077
  const label = react.useMemo(() => labelFor ?? ((id) => `User ${id.slice(0, 6)}`), [labelFor]);
5965
6078
  const ringingIn = calls.phase === "ringing-in";
5966
6079
  const ringingOut = calls.phase === "ringing-out";
5967
- const ended = calls.phase === "declined" || calls.phase === "busy" || calls.phase === "timed-out";
6080
+ const ended = isOver(calls.phase);
5968
6081
  const visible = calls.phase !== "idle" && !(nativeIncomingUi && ringingIn);
5969
6082
  const duration = useCallDuration(connected);
5970
6083
  const participants = usingSfu ? sfu.participants : [];
@@ -6206,6 +6319,26 @@ function isStalePush(sentAt, now, staleAfterMs) {
6206
6319
  if (age < 0) return false;
6207
6320
  return age > staleAfterMs;
6208
6321
  }
6322
+ function readPush(payload) {
6323
+ const callId = payload.call_id;
6324
+ if (!callId) return { kind: "ignore" };
6325
+ if (payload.event === "ended") return { kind: "teardown", callId };
6326
+ const conversationId = payload.conversation_id;
6327
+ if (!conversationId) return { kind: "ignore" };
6328
+ return {
6329
+ kind: "ring",
6330
+ sentAt: payload.sent_at,
6331
+ call: {
6332
+ callId,
6333
+ conversationId,
6334
+ callerUserId: payload.caller_user_id ?? "",
6335
+ callerName: payload.caller_name ?? "Incoming call",
6336
+ // `has_video` is what older servers sent; `media` supersedes it.
6337
+ // Either saying video means the system UI offers video.
6338
+ media: payload.media === "video" || payload.has_video ? "video" : "audio"
6339
+ }
6340
+ };
6341
+ }
6209
6342
  function useVoipCalls({
6210
6343
  voipPush,
6211
6344
  callKeep,
@@ -6277,21 +6410,20 @@ function useVoipCalls({
6277
6410
  react.useEffect(() => {
6278
6411
  let cancelled = false;
6279
6412
  const fromPush = (payload) => {
6280
- const callId = payload.call_id;
6281
- const conversationId = payload.conversation_id;
6282
- if (!callId || !conversationId) return;
6283
- present.current(
6284
- {
6285
- callId,
6286
- conversationId,
6287
- callerUserId: payload.caller_user_id ?? "",
6288
- callerName: payload.caller_name ?? "Incoming call",
6289
- // `has_video` is what older servers sent; `media` supersedes
6290
- // it. Either says video, and the system UI offers video.
6291
- media: payload.media === "video" || payload.has_video ? "video" : "audio"
6292
- },
6293
- payload.sent_at
6294
- );
6413
+ const intent = readPush(payload);
6414
+ if (intent.kind === "ignore") return;
6415
+ if (intent.kind === "teardown") {
6416
+ dismiss.current(intent.callId);
6417
+ handlers.current.calls?.dropped(intent.callId);
6418
+ return;
6419
+ }
6420
+ present.current(intent.call, intent.sentAt);
6421
+ handlers.current.calls?.ring({
6422
+ callId: intent.call.callId,
6423
+ conversationId: intent.call.conversationId,
6424
+ callerUserId: intent.call.callerUserId,
6425
+ media: intent.call.media
6426
+ });
6295
6427
  };
6296
6428
  ready.current = callKeep.setup({
6297
6429
  ios: { appName },