Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5c5c5cb
feat: mock users
hejsztynx May 29, 2026
0f27040
fix: considering finalized mention in new mentions
hejsztynx Jun 11, 2026
e527a44
refactor: consistent mention event hook with other event hooks
hejsztynx Jun 11, 2026
a876871
fix(web): endMention event call when switching mentions
hejsztynx Jun 11, 2026
43c2670
fix(ios): call end mention event when switching between mentions
hejsztynx Jun 11, 2026
1dc29cd
fix(android): mention events emitting
hejsztynx Jun 11, 2026
cf482ac
fix(ios): mentions not remanaged when no onChangeText in props
hejsztynx Jun 12, 2026
8c8503d
fix(ios): mention event emission dedup
hejsztynx Jun 12, 2026
e369d0c
feat: updated example apps with correct mention event emittion behavior
hejsztynx Jun 12, 2026
4226044
fix(web): query changing on cursor travel inside candidate mention
hejsztynx Jun 12, 2026
5a6ccad
feat(web): avoid double space when inserting a mention
hejsztynx Jun 12, 2026
fdc310f
feat(ios): avoid double space when inserting a mention
hejsztynx Jun 13, 2026
71fbb85
refactor: testing code cleanup
hejsztynx Jun 13, 2026
e4b050d
fix(web): double space edge case
hejsztynx Jun 14, 2026
59411bc
fix(ios): last emitted mention event comparison
hejsztynx Jun 14, 2026
c285576
test: e2e tests for switching between mentions
hejsztynx Jun 15, 2026
e631d40
Merge branch 'main' into @ksienkiewicz/fix-mention-events
kacperzolkiewski Jun 25, 2026
21b9028
fix(web): move cursor when setMention before the indicator
hejsztynx Jun 25, 2026
ba2c9b9
fix(ios): updating _recentInputString only when the emitter is defined
hejsztynx Jul 1, 2026
ea20849
fix(ios): always move the selection after adding mention to the avail…
hejsztynx Jul 1, 2026
b5bb257
refactor(ios): text equality check
hejsztynx Jul 1, 2026
21b7319
fix: Update build CI xcode version to latest stable
szydlovsky Jul 1, 2026
4d5dbbc
refactor(ios): additional info about _recentInputString 'if' branches
hejsztynx Jul 1, 2026
4c48aaf
Revert "fix: Update build CI xcode version to latest stable"
hejsztynx Jul 1, 2026
eb64438
fix: setMention on Android replacing surrounding text (#669)
hejsztynx Jul 2, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.4'
xcode-version: latest-stable
Comment thread
hejsztynx marked this conversation as resolved.
Outdated

- name: Reset build folder and pods
run: rm -rf apps/example/build apps/example/ios/Pods apps/example/ios/Podfile.lock
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
appId: swmansion.enriched.example
---
# fix PR #637 - mention popups not closing when switching between mentions

- launchApp

- tapOn:
id: "toggle-screen-button"

- tapOn:
id: "editor-input"

- inputText: "mentions #gen @J"

# user popup visible
- runFlow:
file: "../subflows/capture_or_assert_fullscreen_screenshot.yaml"
env:
SCREENSHOT_NAME: "mention_popup_closing_on_cursor_travel_1"

- tapOn:
id: "focus-button"

- tapOn:
id: "editor-input"
point: "30%, 50%"

# channel popup visible
- runFlow:
file: "../subflows/capture_or_assert_fullscreen_screenshot.yaml"
env:
SCREENSHOT_NAME: "mention_popup_closing_on_cursor_travel_2"

- tapOn:
id: "focus-button"

- tapOn:
id: "editor-input"
point: "10%, 50%"

# no popup visible
- runFlow:
file: "../subflows/capture_or_assert_fullscreen_screenshot.yaml"
env:
SCREENSHOT_NAME: "mention_popup_closing_on_cursor_travel_3"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
appId: swmansion.enriched.example
---
- tapOn:
id: 'blur-button'

- runFlow:
file: '../../subflows/capture_or_assert_screenshot.yaml'
env:
ELEMENT_ID: 'full-screen'
SCREENSHOT_PREFIX: 'enrichedInput'
50 changes: 50 additions & 0 deletions .playwright/tests/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const sel = {
eventType: '[data-testid="mention-event-type"]',
eventIndicator: '[data-testid="mention-event-indicator"]',
eventText: '[data-testid="mention-event-text"]',
lastEndEvent: '[data-testid="mention-last-end-event"]',
htmlOutput: '[data-testid="mention-html-output"]',
detectedCount: '[data-testid="mention-detected-count"]',
detectedText: '[data-testid="mention-detected-text"]',
Expand Down Expand Up @@ -41,6 +42,9 @@ function eventIndicator(page: Page) {
function eventText(page: Page) {
return page.locator(sel.eventText);
}
function lastEndEvent(page: Page) {
return page.locator(sel.lastEndEvent);
}
function htmlOutput(page: Page) {
return page.locator(sel.htmlOutput);
}
Expand Down Expand Up @@ -263,3 +267,49 @@ test('mention renders correctly', async ({ page }) => {
);
await expect(editorLocator(page)).toHaveScreenshot('mention-visual.png');
});

test('switching to a different mention starts it and ends the previous one', async ({
page,
}) => {
await gotoMentionTest(page);
const editor = mentionEditor(page);
await editor.click();
await editor.pressSequentially('foo #g ', { delay: 80 });
await expect(eventType(page)).toHaveText('change');
await expect(eventIndicator(page)).toHaveText('#');
await editor.pressSequentially('@', { delay: 80 });
await expect(eventType(page)).toHaveText('start');
await expect(eventIndicator(page)).toHaveText('@');
await expect(lastEndEvent(page)).toHaveText('#');
await editor.press('ArrowLeft');
await editor.press('ArrowLeft'); // back to the '#' mention
await expect(eventType(page)).toHaveText('change');
await expect(eventIndicator(page)).toHaveText('#');
await expect(lastEndEvent(page)).toHaveText('@');
await editor.press('ArrowLeft');
await editor.press('ArrowLeft');
await editor.press('ArrowLeft'); // leaving the '#' mention
await expect(eventType(page)).toHaveText('end');
await expect(eventIndicator(page)).toHaveText('#');
});

test("inserting a mention between text doesn't produce a double space", async ({
page,
}) => {
await gotoMentionTest(page);
const editor = mentionEditor(page);
await editor.click();
await editor.pressSequentially('example ', { delay: 80 });
await editor.pressSequentially(' test', { delay: 80 });
for (let i = 0; i < 5; i++) {
await editor.press('ArrowLeft');
}
await editor.press('@');
await page.locator(sel.setUserButton).click();
await page.waitForTimeout(2000);
await expect
.poll(async () => await htmlOutput(page).textContent())
.toEqual(
'<html><p>example <mention text="Jane" indicator="@" id="1">Jane</mention> test</p></html>'
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class EnrichedTextInputView :
val alignmentStyles: AlignmentStyles? = AlignmentStyles(this)
var isDuringTransaction: Boolean = false
var isRemovingMany: Boolean = false
var recentInputString: String = ""
var scrollEnabled: Boolean = true
var allowFontScaling: Boolean = EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT
set(value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,30 @@ class MentionHandler(
indicator: String,
text: String?,
) {
var startMention = false

// switching directly to an active mention
if (previousIndicator != indicator) {
startMention = true
endMention()
}

// explicit startMention event before changeMention event
if (startMention && !text.isNullOrEmpty()) {
emitEvent(indicator, "")
}

emitEvent(indicator, text)
previousIndicator = indicator
}

private fun emitEvent(
indicator: String,
text: String?,
) {
// Do not emit events too often
if (previousText == text) return
if (previousIndicator == indicator && previousText == text) return

previousIndicator = indicator
previousText = text
val context = view.context as ReactContext
val surfaceId = UIManagerHelper.getSurfaceId(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class OnMentionEvent(
) : Event<OnMentionEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME

// start/change/end can be emitted as a burst within a single frame
// (e.g. when switching mentions: end -> start -> change).
// The default coalescing would merge them in the batch and drop the
// intermediate ones, so it must be disabled to deliver every event in order.
override fun canCoalesce(): Boolean = false

override fun getEventData(): WritableMap? {
val eventData: WritableMap = Arguments.createMap()
eventData.putString("indicator", indicator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,23 @@ class ParametrizedStyles(
endCursorPosition: Int,
) {
afterTextChangedLinks(startCursorPosition, endCursorPosition)
afterTextChangedMentions(s, startCursorPosition)
detectActiveMention(s, startCursorPosition)
}

// Re-runs in-progress mention detection on a pure caret move (no text change),
fun afterSelectionChangedMentions(
start: Int,
end: Int,
) {
val s = view.text ?: return

// A non-collapsed selection can't be editing a single mention.
if (start != end) {
view.mentionHandler?.endMention()
return
}

detectActiveMention(s, end)
}

fun onStyleToggled(
Expand Down Expand Up @@ -240,7 +256,7 @@ class ParametrizedStyles(
detectLinksInRange(spannable, affectedRange.first, affectedRange.last)
}

private fun afterTextChangedMentions(
private fun detectActiveMention(
s: CharSequence,
endCursorPosition: Int,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ class EnrichedSelection(
start = finalStart
end = finalEnd
validateStyles()

if (view.text?.toString() == view.recentInputString) {
view.parametrizedStyles?.afterSelectionChangedMentions(finalStart, finalEnd)
}

emitSelectionChangeEvent(view.text, finalStart, finalEnd)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class EnrichedTextWatcher(
if (s == null) return
emitEvents(s)

view.recentInputString = s.toString()

if (view.isDuringTransaction) return
applyStyles(s)
view.layoutManager.invalidateLayout()
Expand Down
2 changes: 0 additions & 2 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,8 @@ function App() {
console.log('[EnrichedTextInput] Change mention', indicator, text);
if (indicator === '@') {
userMention.onMentionChange(text);
if (!isUserPopupOpen) setIsUserPopupOpen(true);
} else {
channelMention.onMentionChange(text);
if (!isChannelPopupOpen) setIsChannelPopupOpen(true);
}
};

Expand Down
9 changes: 7 additions & 2 deletions apps/example-web/src/testScreens/TestMentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function TestMentions() {
const [detectedCount, setDetectedCount] = useState(0);
const [detectedText, setDetectedText] = useState('');
const [detectedIndicator, setDetectedIndicator] = useState('');
const [lastEndEvent, setLastEndEvent] = useState('');

const preventDefault = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
Expand All @@ -40,10 +41,11 @@ export function TestMentions() {
setEventIndicator(indicator);
setEventText(text);
}}
onEndMention={() => {
onEndMention={(indicator) => {
setEventType('end');
setEventIndicator('');
setEventIndicator(indicator);
setEventText('');
setLastEndEvent(indicator);
}}
onMentionDetected={({ text, indicator }) => {
setDetectedCount((c) => c + 1);
Expand All @@ -65,6 +67,9 @@ export function TestMentions() {
<Field label="event text">
<span data-testid="mention-event-text">{eventText}</span>
</Field>
<Field label="last end event">
<span data-testid="mention-last-end-event">{lastEndEvent}</span>
</Field>
<Field label="detected count">
<span data-testid="mention-detected-count">{detectedCount}</span>
</Field>
Expand Down
16 changes: 15 additions & 1 deletion apps/example/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,21 @@ const root = path.resolve(__dirname, '../..');
*
* @type {import('metro-config').MetroConfig}
*/
module.exports = withMetroConfig(getDefaultConfig(__dirname), {
const config = withMetroConfig(getDefaultConfig(__dirname), {
root,
dirname: __dirname,
});

config.resolver = {
...config.resolver,
blockList: [
...(Array.isArray(config.resolver?.blockList)
? config.resolver.blockList
: config.resolver?.blockList
? [config.resolver.blockList]
: []),
/.*\/\.maestro\/.*/,
],
};

module.exports = config;
6 changes: 3 additions & 3 deletions apps/example/src/hooks/useEditorState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function useEditorState() {
};

const handleStartMention = (indicator: string) => {
console.log('Start Mention', indicator);
if (indicator === '@') {
userMention.onMentionChange('');
openUserMentionPopup();
Expand All @@ -115,6 +116,7 @@ export function useEditorState() {
};

const handleEndMention = (indicator: string) => {
console.log('End Mention', indicator);
if (indicator === '@') {
closeUserMentionPopup();
userMention.onMentionChange('');
Expand All @@ -125,12 +127,10 @@ export function useEditorState() {
};

const handleChangeMention = ({ indicator, text }: OnChangeMentionEvent) => {
console.log('Change Mention', indicator, text);
indicator === '@'
? userMention.onMentionChange(text)
: channelMention.onMentionChange(text);
indicator === '@'
? !isUserPopupOpen && setIsUserPopupOpen(true)
: !isChannelPopupOpen && setIsChannelPopupOpen(true);
};

const handleUserMentionSelected = (item: MentionItem) => {
Expand Down
16 changes: 10 additions & 6 deletions apps/example/src/screens/TestScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ export function TestScreen({
const [sizeMode, setSizeMode] = useState<'base' | 'max'>('base');

return (
<>
<View style={styles.container}>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
style={styles.scrollContainer}
contentContainerStyle={styles.scrollContent}
testID="full-screen"
>
<View style={styles.buttonStack}>
<Button
Expand Down Expand Up @@ -165,19 +166,22 @@ export function TestScreen({
isOpen={editor.isChannelPopupOpen}
onItemPress={editor.handleChannelMentionSelected}
/>
</>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
paddingTop: 100,
},
scrollContainer: {
flex: 1,
},
content: {
scrollContent: {
flexGrow: 1,
padding: 16,
paddingTop: 100,
alignItems: 'center',
},
editor: {
Expand Down
Loading
Loading