// SPDX-License-Identifier: AGPL-3.0-or-later import {$getComposerLineNodes} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode'; import { $createComposerCommandNode, $isComposerCommandNode, } from '@app/features/lexical/composer/nodes/ComposerCommandNode'; import { $createComposerCustomEmojiNode, $isComposerCustomEmojiNode, } from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode'; import { $createComposerMentionNode, $isComposerMentionNode, type ComposerMentionType, } from '@app/features/lexical/composer/nodes/ComposerMentionNode'; import { $createComposerPlainSegmentNode, $isComposerPlainSegmentNode, } from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode'; import { $createComposerStandardEmojiNode, $isComposerStandardEmojiNode, } from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode'; import {$createSlashSeparatorNode} from '@app/features/lexical/composer/nodes/SlashSeparatorNode'; import { $createSlashSlotNode, $isSlashSlotNode, type SlashSlotNode, } from '@app/features/lexical/composer/nodes/SlashSlotNode'; import { createSlashCommandStateSegmentId, createSlashSlotStateSegmentId, type PersistedSlashSlotState, parseSlashCommandStateSegment, parseSlashSlotStateSegment, parseSlashSlotStateSegmentId, } from '@app/features/lexical/composer/SlashSlotPersistence'; import {isSpecialMentionKind} from '@app/features/lexical/composer/specialMentions'; import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $isElementNode, $isLineBreakNode, type LexicalNode, } from 'lexical'; import invariant from 'tiny-invariant'; export interface ComposerProjection { display: string; segments: Array; wire: string; } const CUSTOM_EMOJI_WIRE_RE = /^<(a)?:([A-Za-z0-9_+~-]+):(\d+)>$/; const STANDARD_EMOJI_WIRE_RE = /^(?:[0-9#*]\uFE0F?\u20E3|\P{ASCII})+$/u; const SLOT_USER_WIRE_RE = /^<@!?(\d+)>$/; const SLOT_ROLE_WIRE_RE = /^<@&(\d+)>$/; const SLOT_CHANNEL_WIRE_RE = /^<#(\d+)>$/; export const COMPOSER_SLASH_SLOT_SEGMENT_PREFIX = 'slash-slot:'; const SLASH_SLOT_OPTION_NAME_RE = /^[a-z0-9_-]{1,32}$/; function isStandardEmojiSegment(segment: MentionSegment): boolean { return segment.type === 'emoji' && STANDARD_EMOJI_WIRE_RE.test(segment.actualText); } function isValidSegmentWire(segment: MentionSegment): boolean { if (parseSlashCommandStateSegment(segment) != null) { return true; } if (parseSlashSlotStateSegment(segment) != null) { return true; } if (segment.id.startsWith(COMPOSER_SLASH_SLOT_SEGMENT_PREFIX)) { return ( segment.type === 'special' && SLASH_SLOT_OPTION_NAME_RE.test(segment.id.slice(COMPOSER_SLASH_SLOT_SEGMENT_PREFIX.length)) && segment.actualText.length > 0 && !segment.actualText.includes('\n') && !segment.actualText.includes('\r') ); } switch (segment.type) { case 'emoji': { const match = CUSTOM_EMOJI_WIRE_RE.exec(segment.actualText); return match == null ? isStandardEmojiSegment(segment) : match[3] === segment.id; } case 'user': { const match = SLOT_USER_WIRE_RE.exec(segment.actualText); return match != null && match[1] === segment.id; } case 'role': { const match = SLOT_ROLE_WIRE_RE.exec(segment.actualText); return match != null && match[1] === segment.id; } case 'channel': { const match = SLOT_CHANNEL_WIRE_RE.exec(segment.actualText); return match != null && match[1] === segment.id; } case 'special': return ( isSpecialMentionKind(segment.id) && segment.actualText === segment.id && segment.displayText === segment.id ); } } export function isValidComposerSegment(display: string, segment: MentionSegment, minimumStart = 0): boolean { return ( Number.isInteger(segment.start) && Number.isInteger(segment.end) && segment.start >= minimumStart && segment.start >= 0 && segment.end > segment.start && segment.end <= display.length && display.slice(segment.start, segment.end) === segment.displayText && isValidSegmentWire(segment) ); } function $slashSlotState(node: SlashSlotNode): PersistedSlashSlotState { return { version: 2, optionName: node.getOptionName(), optionType: node.getOptionType(), required: node.isRequired(), allowEmpty: node.allowsEmpty(), description: node.getDescription(), choices: node.getChoices(), validity: node.getValidity(), validationError: node.getValidationError(), touched: node.isTouched(), }; } function normalizeEditedSlashSlotState(state: PersistedSlashSlotState, displayText: string): PersistedSlashSlotState { const emptyRequired = state.required && state.allowEmpty !== true && displayText.slice(1).length === 0; return { ...state, validity: emptyRequired ? 'invalid' : 'neutral', validationError: emptyRequired ? 'required' : null, touched: true, }; } function createSlashSlotStateSegment( state: PersistedSlashSlotState, displayText: string, actualText: string, start: number, ): MentionSegment | null { const segment: MentionSegment = { type: 'special', id: createSlashSlotStateSegmentId(state), displayText, actualText, start, end: start + displayText.length, }; return parseSlashSlotStateSegment(segment) == null ? null : segment; } function createLegacySlashSlotSegment( node: SlashSlotNode, displayText: string, actualText: string, start: number, ): MentionSegment | null { if (displayText.length === 0 || actualText === displayText) { return null; } const userMatch = SLOT_USER_WIRE_RE.exec(actualText); if (userMatch) { return {type: 'user', id: userMatch[1]!, displayText, actualText, start, end: start + displayText.length}; } const roleMatch = SLOT_ROLE_WIRE_RE.exec(actualText); if (roleMatch) { return {type: 'role', id: roleMatch[1]!, displayText, actualText, start, end: start + displayText.length}; } const channelMatch = SLOT_CHANNEL_WIRE_RE.exec(actualText); if (channelMatch) { return {type: 'channel', id: channelMatch[1]!, displayText, actualText, start, end: start + displayText.length}; } return { type: 'special', id: `${COMPOSER_SLASH_SLOT_SEGMENT_PREFIX}${node.getOptionName()}`, displayText, actualText, start, end: start + displayText.length, }; } function $createSlashSlotValueNode( optionType: PersistedSlashSlotState['optionType'], displayText: string, wireText: string, ): LexicalNode { if (optionType === 'user') { const match = SLOT_USER_WIRE_RE.exec(wireText); if (match) { return $createComposerMentionNode('user', match[1]!, displayText, wireText); } } if (optionType === 'role') { const match = SLOT_ROLE_WIRE_RE.exec(wireText); if (match) { return $createComposerMentionNode('role', match[1]!, displayText, wireText); } } if (optionType === 'channel') { const match = SLOT_CHANNEL_WIRE_RE.exec(wireText); if (match) { return $createComposerMentionNode('channel', match[1]!, displayText, wireText); } } return $createTextNode(displayText); } export function $projectComposer(): ComposerProjection { const root = $getRoot(); const blocks = root.getChildren(); let display = ''; let wire = ''; const segments: Array = []; for (let b = 0; b < blocks.length; b += 1) { if (b > 0) { display += '\n'; wire += '\n'; } const block = blocks[b]!; if (!$isElementNode(block)) { display += block.getTextContent(); wire += block.getTextContent(); continue; } for (const child of $getComposerLineNodes(block)) { if ($isLineBreakNode(child)) { display += '\n'; wire += '\n'; } else if ($isComposerCommandNode(child)) { const start = display.length; const displayText = child.getTextContent(); const segmentId = createSlashCommandStateSegmentId(displayText, child.getOptionalOptions()); invariant(segmentId != null, 'Composer slash command metadata violates persistence bounds'); display += displayText; wire += displayText; segments.push({ type: 'special', id: segmentId, displayText, actualText: displayText, start, end: display.length, }); } else if ($isComposerPlainSegmentNode(child)) { const start = display.length; const displayText = child.getTextContent(); display += displayText; const slashState = parseSlashSlotStateSegmentId(child.getSegmentId()); if (slashState != null && displayText.startsWith(' ') && !displayText.includes('\n')) { const segment = child.isSegmentValid() ? createSlashSlotStateSegment(slashState, displayText, child.getWireText(), start) : createSlashSlotStateSegment( normalizeEditedSlashSlotState(slashState, displayText), displayText, displayText, start, ); if (segment != null) { wire += segment.actualText; segments.push(segment); } else { wire += displayText; } } else if (child.isSegmentValid()) { const actualText = child.getWireText(); wire += actualText; segments.push({ type: child.getSegmentType(), id: child.getSegmentId(), displayText, actualText, start, end: display.length, }); } else { wire += displayText; } } else if ($isComposerMentionNode(child)) { const start = display.length; const displayText = child.getTextContent(); display += displayText; wire += child.getWireText(); segments.push({ type: child.getSegmentType(), id: child.getMentionId(), displayText, actualText: child.getWireText(), start, end: display.length, }); } else if ($isComposerCustomEmojiNode(child)) { const start = display.length; const displayText = child.getTextContent(); display += displayText; wire += child.getWireText(); segments.push({ type: 'emoji', id: child.getEmojiId(), displayText, actualText: child.getWireText(), start, end: display.length, }); } else if ($isComposerStandardEmojiNode(child)) { const start = display.length; const displayText = child.getTextContent(); const actualText = child.getWireText(); display += displayText; wire += actualText; const segment: MentionSegment = { type: 'emoji', id: child.getEmojiName(), displayText, actualText, start, end: display.length, }; if (isStandardEmojiSegment(segment)) { segments.push(segment); } } else if ($isSlashSlotNode(child)) { const displayText = child.getTextContent(); const start = display.length; let slotWire = child.getWireText(); display += displayText; const previousSibling = child.getPreviousSibling(); const previousSegment = segments.at(-1); const previousSegmentEnd = previousSegment == null ? 0 : previousSegment.end; let persistedStructure = false; if ( start > 0 && display[start - 1] === ' ' && wire[wire.length - 1] === ' ' && previousSibling != null && previousSibling.getTextContent().endsWith(' ') === true && previousSegmentEnd <= start - 1 ) { const stateDisplay = ` ${displayText}`; let segment = createSlashSlotStateSegment($slashSlotState(child), stateDisplay, ` ${slotWire}`, start - 1); if (segment == null) { slotWire = displayText; segment = createSlashSlotStateSegment( normalizeEditedSlashSlotState($slashSlotState(child), stateDisplay), stateDisplay, stateDisplay, start - 1, ); } if (segment != null) { segments.push(segment); persistedStructure = true; } } if (!persistedStructure) { const legacySegment = createLegacySlashSlotSegment(child, displayText, slotWire, start); if (legacySegment != null) { segments.push(legacySegment); } } wire += slotWire; } else { display += child.getTextContent(); wire += child.getTextContent(); } } } return {display, segments, wire}; } export function $createComposerSegmentNodes(segment: MentionSegment, plainText: boolean): Array { const commandState = parseSlashCommandStateSegment(segment); if (commandState != null) { if (plainText) { return [$createComposerPlainSegmentNode(segment.type, segment.id, segment.displayText, segment.actualText)]; } return [$createComposerCommandNode(segment.displayText, commandState.optionalOptions)]; } const slashState = parseSlashSlotStateSegment(segment); if (slashState != null) { if (plainText) { return [$createComposerPlainSegmentNode(segment.type, segment.id, segment.displayText, segment.actualText)]; } const displayText = segment.displayText.slice(1); const wireText = segment.actualText.slice(1); const slot = $createSlashSlotNode( slashState.optionName, slashState.optionType, slashState.required, slashState.choices, slashState.description, ) .setAllowEmpty(slashState.allowEmpty == null ? false : slashState.allowEmpty) .setTouched(slashState.touched) .setValidity(slashState.validity) .setValidationError(slashState.validationError); if (slashState.validity === 'valid') { if (displayText.length > 0) { slot.append($createSlashSlotValueNode(slashState.optionType, displayText, wireText)); } slot.setResolvedWire(wireText, displayText); } else if (displayText.length > 0) { slot.append($createTextNode(displayText)); } return [$createSlashSeparatorNode(), slot]; } if (plainText || segment.id.startsWith(COMPOSER_SLASH_SLOT_SEGMENT_PREFIX)) { return [$createComposerPlainSegmentNode(segment.type, segment.id, segment.displayText, segment.actualText)]; } if (segment.type === 'emoji') { const match = CUSTOM_EMOJI_WIRE_RE.exec(segment.actualText); if (match) { return [$createComposerCustomEmojiNode(match[3]!, Boolean(match[1]), segment.displayText, segment.actualText)]; } if (isStandardEmojiSegment(segment)) { return [$createComposerStandardEmojiNode(segment.id, segment.actualText, null, segment.displayText)]; } return [$createTextNode(segment.displayText)]; } return [ $createComposerMentionNode( segment.type as ComposerMentionType, segment.id, segment.displayText, segment.actualText, ), ]; } export function $hydrateComposerFromDraft( display: string, segments: ReadonlyArray, plainText = false, ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); const appendText = (text: string) => { if (text.length === 0) { return; } const parts = text.split('\n'); for (let i = 0; i < parts.length; i += 1) { if (i > 0) { paragraph.append($createLineBreakNode()); } if (parts[i]!.length > 0) { paragraph.append($createTextNode(parts[i]!)); } } }; const sorted = [...segments].sort((a, b) => a.start - b.start); let cursor = 0; for (const segment of sorted) { if (!isValidComposerSegment(display, segment, cursor)) { continue; } if (segment.start > cursor) { appendText(display.slice(cursor, segment.start)); } paragraph.append(...$createComposerSegmentNodes(segment, plainText)); cursor = segment.end; } if (cursor < display.length) { appendText(display.slice(cursor)); } }