Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/mobile/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Installed via a side-effect import listed first so the fatal-error handler
// is in place before the rest of the app module graph evaluates.
import "./src/lib/installCrashLog";

import { registerRootComponent } from "expo";
import "react-native-gesture-handler";
import { featureFlags } from "react-native-screens";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange {
Float firstLineHeadIndent;
Float headIndent;
Float paragraphSpacing;

bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default;
};

struct T3MarkdownTextAttachmentRange {
size_t location;
size_t length;
std::string imageUri;

bool operator==(const T3MarkdownTextAttachmentRange&) const = default;
};

inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
Expand All @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> {
public:
using ConcreteViewShadowNode::ConcreteViewShadowNode;

T3MarkdownTextShadowNode(
const ShadowNode& sourceShadowNode,
const ShadowNodeFragment& fragment
);

static ShadowNodeTraits BaseTraits() {
auto traits = ConcreteViewShadowNode::BaseTraits();
traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
Expand All @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> {
const LayoutConstraints& layoutConstraints) const override;

private:
mutable AttributedString _attributedString;
mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
// Content must be derived from the current children whenever it is needed.
// Yoga can invoke layout() on a fresh clone without ever calling
// measureContent() on it (for example when both dimensions are already
// exact), so caching measure-time content in mutable members and publishing
// it from layout() lets state fall behind the children and drop text.
struct Content {
AttributedString attributedString;
std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
};

Content buildContent(const LayoutContext& layoutContext) const;
void updateStateIfNeeded(Content&& content);
};
} // namespace facebook::React
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,8 @@ static void applyAttachments(
}
}

T3MarkdownTextShadowNode::T3MarkdownTextShadowNode(
const ShadowNode& sourceShadowNode,
const ShadowNodeFragment& fragment
) : ConcreteViewShadowNode(sourceShadowNode, fragment) {
};

Size T3MarkdownTextShadowNode::measureContent(
const LayoutContext& layoutContext,
const LayoutConstraints& layoutConstraints) const {
T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent(
const LayoutContext& layoutContext) const {
const auto &baseProps = getConcreteProps();

auto baseTextAttributes = TextAttributes::defaultTextAttributes();
Expand Down Expand Up @@ -207,14 +200,23 @@ static void applyAttachments(
}
}

_attributedString = baseAttributedString;
_paragraphStyleRanges = paragraphStyleRanges;
_attachmentRanges = attachmentRanges;
return Content{
std::move(baseAttributedString),
std::move(paragraphStyleRanges),
std::move(attachmentRanges),
};
}

Size T3MarkdownTextShadowNode::measureContent(
const LayoutContext& layoutContext,
const LayoutConstraints& layoutConstraints) const {
const auto &baseProps = getConcreteProps();
const auto content = buildContent(layoutContext);

NSMutableAttributedString *convertedAttributedString =
[RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
applyParagraphStyles(convertedAttributedString, paragraphStyleRanges);
applyAttachments(convertedAttributedString, attachmentRanges);
[RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy];
applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges);
applyAttachments(convertedAttributedString, content.attachmentRanges);

const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width)
? layoutConstraints.maximumSize.width
Expand Down Expand Up @@ -255,10 +257,21 @@ static void applyAttachments(

void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {
ensureUnsealed();
updateStateIfNeeded(buildContent(layoutContext));
}

void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) {
const auto &stateData = getStateData();
if (stateData.attributedString == content.attributedString &&
stateData.paragraphStyleRanges == content.paragraphStyleRanges &&
stateData.attachmentRanges == content.attachmentRanges) {
return;
}

setStateData(T3MarkdownTextStateReal{
_attributedString,
_paragraphStyleRanges,
_attachmentRanges,
std::move(content.attributedString),
std::move(content.paragraphStyleRanges),
std::move(content.attachmentRanges),
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function SelectableMarkdownText({
skills = EMPTY_SKILLS,
textStyle,
highlightCode,
fillWidth = false,
preserveSoftBreaks = false,
onLinkPress,
marginTop = 0,
Expand Down Expand Up @@ -63,7 +64,15 @@ export function SelectableMarkdownText({
// shrink-to-fit containers such as user-message bubbles. Yoga then gives
// the native text node an unbounded second pass and the parent only clips
// the resulting single-line width instead of reflowing it.
<View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
<View
style={{
flexShrink: 1,
minWidth: 0,
...(fillWidth ? { alignSelf: "stretch" } : null),
marginTop,
marginBottom,
}}
>
{chunks.map((chunk, index) => {
const content =
chunk.kind === "rich" ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps {
readonly markdown: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly fillWidth?: boolean;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
readonly preserveSoftBreaks?: boolean;
readonly onLinkPress?: (href: string) => void;
Expand Down
25 changes: 21 additions & 4 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { Platform, View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -176,6 +176,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
// overlay). Matches the rendered pill: pt-2 + pb-2 (16) wrapping a bordered
// px-3/py-2 row (~36), so ~52 — keep it in sync with WorkingDurationPill.
const WORKING_INDICATOR_HEIGHT = 52;
// Extra list end-inset so the last message rests above the floating composer
// instead of flush against (or under) it. Applied via heightAdjustment so both
// the pre-layout estimate and the measured overlay height pick it up.
const THREAD_FEED_COMPOSER_GAP = 24;

const WorkingDurationPill = memo(function WorkingDurationPill(props: {
readonly startedAt: string;
Expand Down Expand Up @@ -248,6 +252,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const composerOverlapHeight = composerChrome + composerBottomInset;
const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight);
// The overlay's measured height includes the home-indicator inset (the
// composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
// UIKit add the safe-area bottom to the content inset AGAIN — leaving a
Expand All @@ -257,11 +262,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// its end-scroll math matches the real resting position.
const nativeInsetOvercount =
props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;
// heightAdjustment is added to measured composer height. Keep the safe-area
// under-report (UIKit automatic insets) and add breathing room above chrome.
const composerEndHeightAdjustment = THREAD_FEED_COMPOSER_GAP - nativeInsetOvercount;
const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
listRef,
composerOverlayRef,
Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
-nativeInsetOvercount,
Math.max(0, estimatedOverlayHeight + composerEndHeightAdjustment),
composerEndHeightAdjustment,
);
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => {
onComposerLayout(event);
const height = event.nativeEvent.layout.height;
setComposerOverlayHeight((current) => (Math.abs(current - height) > 1 ? height : current));
},
[onComposerLayout],
);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
Expand Down Expand Up @@ -408,6 +424,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
freeze={freeze}
anchorMessageId={anchorMessageId}
contentInsetEndAdjustment={contentInsetEndAdjustment}
contentInsetEndEstimate={composerOverlayHeight + THREAD_FEED_COMPOSER_GAP}
Comment thread
cursor[bot] marked this conversation as resolved.
contentTopInset={0}
contentBottomInset={estimatedOverlayHeight}
contentMaxWidth={contentMaxWidth}
Expand All @@ -430,7 +447,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View ref={composerOverlayRef} onLayout={handleComposerLayout} className="w-full">
<Animated.View
className="w-full self-center"
layout={LinearTransition.duration(220)}
Expand Down
Loading
Loading