Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
135 changes: 76 additions & 59 deletions apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import type {
SidebarProjectGroupingMode,
SidebarThreadSortOrder,
} from "@t3tools/contracts";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useCallback, useRef } from "react";
import {
NativeHeaderToolbar,
NativeStackScreenOptions,
type AppNativeStackNavigationOptions,
} from "../../native/StackHeader";
import { useCallback, useMemo, useRef } from "react";
import { Platform } from "react-native";
import type { SearchBarCommands } from "react-native-screens";

import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useThemeColor } from "../../lib/useThemeColor";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
Expand All @@ -26,7 +29,6 @@ import {
} from "./home-list-options";

export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);

export function HomeHeader(props: {
readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
Expand All @@ -43,70 +45,85 @@ export function HomeHeader(props: {
readonly onStartNewTask: () => void;
}) {
const searchBarRef = useRef<SearchBarCommands>(null);
const iconColor = useThemeColor("--color-icon");
const propsRef = useRef(props);
propsRef.current = props;
const iconColor = String(useThemeColor("--color-icon"));
const hasCustomListOptions = hasCustomHomeListOptions(props);
const focusSearch = useCallback(() => {
searchBarRef.current?.focus();
return searchBarRef.current !== null;
}, []);
useHardwareKeyboardCommand("focusSearch", focusSearch);
const filterMenu = buildHomeListFilterMenu(props);

// Keep navigation options referentially stable. A fresh options object each
// render re-enters navigation.setOptions via NativeStackScreenOptions and can
// trip "Maximum update depth exceeded" under PreventRemoveProvider on iOS.
const screenOptions = useMemo((): AppNativeStackNavigationOptions => {
const filterSystemImageName = hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease";
return {
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
identifier: "home-settings",
label: "",
onPress: () => propsRef.current.onOpenSettings(),
type: "button",
}),
]
: undefined,
unstable_headerToolbarItems:
Platform.OS === "ios"
? () => [
createNativeMailSearchToolbarItem({
composeButtonId: "home-new-task",
composeSystemImageName: "square.and.pencil",
filterMenu: buildHomeListFilterMenu(propsRef.current),
filterButtonId: "home-filter",
filterSystemImageName,
onComposePress: () => propsRef.current.onStartNewTask(),
onSearchTextChange: (query) => propsRef.current.onSearchQueryChange(query),
placeholder: "Search",
searchTextChangeId: "home-search-text",
}),
]
: undefined,
headerSearchBarOptions:
Platform.OS === "ios"
? undefined
: {
ref: searchBarRef,
allowToolbarIntegration: true,
hideNavigationBar: false,
placeholder: "Search",
onCancelButtonPress: () => {
propsRef.current.onSearchQueryChange("");
},
onChangeText: (event) => {
propsRef.current.onSearchQueryChange(event.nativeEvent.text);
},
},
};
}, [
hasCustomListOptions,
iconColor,
props.environments,
props.projectGroupingMode,
props.projectSortOrder,
props.selectedEnvironmentId,
props.threadSortOrder,
]);

return (
<>
<NativeStackScreenOptions
options={{
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
identifier: "home-settings",
label: "",
onPress: props.onOpenSettings,
type: "button",
}),
]
: undefined,
unstable_headerToolbarItems:
Platform.OS === "ios"
? () => [
createNativeMailSearchToolbarItem({
composeButtonId: "home-new-task",
composeSystemImageName: "square.and.pencil",
filterMenu,
filterButtonId: "home-filter",
filterSystemImageName: hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease",
onComposePress: props.onStartNewTask,
onSearchTextChange: props.onSearchQueryChange,
placeholder: "Search",
searchTextChangeId: "home-search-text",
}),
]
: undefined,
headerSearchBarOptions:
Platform.OS === "ios"
? undefined
: {
ref: searchBarRef,
allowToolbarIntegration: true,
hideNavigationBar: false,
placeholder: "Search",
onCancelButtonPress: () => {
props.onSearchQueryChange("");
},
onChangeText: (event) => {
props.onSearchQueryChange(event.nativeEvent.text);
},
},
}}
/>
<NativeStackScreenOptions options={screenOptions} />

{Platform.OS === "ios" ? null : (
<NativeHeaderToolbar placement="right">
Expand Down
Loading
Loading