@@ -356,6 +356,226 @@ describe("MessageContextMenu", () => {
356356 } ) ;
357357 } ) ;
358358
359+ describe ( "quote button" , ( ) => {
360+ beforeEach ( ( ) => {
361+ jest . clearAllMocks ( ) ;
362+ } ) ;
363+
364+ it ( "shows quote button when selection is inside one MTextBody and getSelectedText returns text" , ( ) => {
365+ mocked ( getSelectedText ) . mockReturnValue ( "quoted text" ) ;
366+ const isSelectionWithinSingleTextBody = jest
367+ . spyOn ( MessageContextMenu . prototype as any , "isSelectionWithinSingleTextBody" )
368+ . mockReturnValue ( true ) ;
369+
370+ createRightClickMenuWithContent ( createMessageEventContent ( "hello" ) ) ;
371+ const quoteButton = document . querySelector ( 'li[aria-label="Quote"]' ) ;
372+ expect ( quoteButton ) . toBeTruthy ( ) ;
373+
374+ isSelectionWithinSingleTextBody . mockRestore ( ) ;
375+ } ) ;
376+
377+ it ( "does not show quote button when getSelectedText returns empty" , ( ) => {
378+ mocked ( getSelectedText ) . mockReturnValue ( "" ) ;
379+ const isSelectionWithinSingleTextBody = jest
380+ . spyOn ( MessageContextMenu . prototype as any , "isSelectionWithinSingleTextBody" )
381+ . mockReturnValue ( true ) ;
382+
383+ createRightClickMenuWithContent ( createMessageEventContent ( "hello" ) ) ;
384+ const quoteButton = document . querySelector ( 'li[aria-label="Quote"]' ) ;
385+ expect ( quoteButton ) . toBeFalsy ( ) ;
386+
387+ isSelectionWithinSingleTextBody . mockRestore ( ) ;
388+ } ) ;
389+
390+ it ( "does not show quote button when selection is not inside one MTextBody" , ( ) => {
391+ mocked ( getSelectedText ) . mockReturnValue ( "quoted text" ) ;
392+ const isSelectionWithinSingleTextBody = jest
393+ . spyOn ( MessageContextMenu . prototype as any , "isSelectionWithinSingleTextBody" )
394+ . mockReturnValue ( false ) ;
395+
396+ createRightClickMenuWithContent ( createMessageEventContent ( "hello" ) ) ;
397+ const quoteButton = document . querySelector ( 'li[aria-label="Quote"]' ) ;
398+ expect ( quoteButton ) . toBeFalsy ( ) ;
399+
400+ isSelectionWithinSingleTextBody . mockRestore ( ) ;
401+ } ) ;
402+
403+ it ( "dispatches ComposerInsert with quoted text when quote button is clicked" , ( ) => {
404+ mocked ( getSelectedText ) . mockReturnValue ( "line1\nline2" ) ;
405+ const dispatchSpy = jest . spyOn ( dispatcher , "dispatch" ) ;
406+ const isSelectionWithinSingleTextBody = jest
407+ . spyOn ( MessageContextMenu . prototype as any , "isSelectionWithinSingleTextBody" )
408+ . mockReturnValue ( true ) ;
409+
410+ createRightClickMenuWithContent ( createMessageEventContent ( "hello" ) ) ;
411+ const quoteButton = document . querySelector ( 'li[aria-label="Quote"]' ) ! ;
412+ fireEvent . mouseDown ( quoteButton ) ;
413+
414+ expect ( dispatchSpy ) . toHaveBeenCalledWith (
415+ expect . objectContaining ( {
416+ action : Action . ComposerInsert ,
417+ text : "\n> line1\n> line2\n\n " ,
418+ } ) ,
419+ ) ;
420+
421+ isSelectionWithinSingleTextBody . mockRestore ( ) ;
422+ } ) ;
423+
424+ it ( "does not show quote button when getSelectedText returns only whitespace" , ( ) => {
425+ mocked ( getSelectedText ) . mockReturnValue ( " \n\t " ) ; // whitespace only
426+ const isSelectionWithinSingleTextBody = jest
427+ . spyOn ( MessageContextMenu . prototype as any , "isSelectionWithinSingleTextBody" )
428+ . mockReturnValue ( true ) ;
429+
430+ createRightClickMenuWithContent ( createMessageEventContent ( "hello" ) ) ;
431+ const quoteButton = document . querySelector ( 'li[aria-label="Quote"]' ) ;
432+ expect ( quoteButton ) . toBeFalsy ( ) ;
433+
434+ isSelectionWithinSingleTextBody . mockRestore ( ) ;
435+ } ) ;
436+ } ) ;
437+
438+ describe ( "isSelectionWithinSingleTextBody" , ( ) => {
439+ let mockGetSelection : jest . SpyInstance ;
440+ let contextMenuInstance : MessageContextMenu ;
441+
442+ beforeEach ( ( ) => {
443+ jest . clearAllMocks ( ) ;
444+
445+ mockGetSelection = jest . spyOn ( window , "getSelection" ) ;
446+
447+ const eventContent = createMessageEventContent ( "hello" ) ;
448+ const mxEvent = new MatrixEvent ( { type : EventType . RoomMessage , content : eventContent } ) ;
449+
450+ contextMenuInstance = new MessageContextMenu ( {
451+ mxEvent,
452+ onFinished : jest . fn ( ) ,
453+ rightClick : true ,
454+ } as any ) ;
455+ } ) ;
456+
457+ afterEach ( ( ) => {
458+ mockGetSelection . mockRestore ( ) ;
459+ } ) ;
460+
461+ it ( "returns false when there is no selection" , ( ) => {
462+ mockGetSelection . mockReturnValue ( null ) ;
463+
464+ const result = ( contextMenuInstance as any ) . isSelectionWithinSingleTextBody ( ) ;
465+ expect ( result ) . toBe ( false ) ;
466+ } ) ;
467+
468+ it ( "returns false when selection has no ranges" , ( ) => {
469+ mockGetSelection . mockReturnValue ( {
470+ rangeCount : 0 ,
471+ getRangeAt : jest . fn ( ) ,
472+ } as any ) ;
473+
474+ const result = ( contextMenuInstance as any ) . isSelectionWithinSingleTextBody ( ) ;
475+ expect ( result ) . toBe ( false ) ;
476+ } ) ;
477+
478+ it ( "returns true when selection is within a single mx_MTextBody element" , ( ) => {
479+ // Create a mock MTextBody element
480+ const textBodyElement = document . createElement ( "div" ) ;
481+ textBodyElement . classList . add ( "mx_MTextBody" ) ;
482+
483+ // Create mock text nodes within the MTextBody
484+ const startTextNode = document . createTextNode ( "start" ) ;
485+ const endTextNode = document . createTextNode ( "end" ) ;
486+ textBodyElement . appendChild ( startTextNode ) ;
487+ textBodyElement . appendChild ( endTextNode ) ;
488+
489+ // Create a mock range with the text nodes
490+ const mockRange = {
491+ startContainer : startTextNode ,
492+ endContainer : endTextNode ,
493+ } as unknown as Range ;
494+
495+ mockGetSelection . mockReturnValue ( {
496+ rangeCount : 1 ,
497+ getRangeAt : jest . fn ( ) . mockReturnValue ( mockRange ) ,
498+ } as any ) ;
499+
500+ const result = ( contextMenuInstance as any ) . isSelectionWithinSingleTextBody ( ) ;
501+ expect ( result ) . toBe ( true ) ;
502+ } ) ;
503+
504+ it ( "returns false when selection spans multiple mx_MTextBody elements" , ( ) => {
505+ // Create two different MTextBody elements
506+ const textBody1 = document . createElement ( "div" ) ;
507+ textBody1 . classList . add ( "mx_MTextBody" ) ;
508+ const textBody2 = document . createElement ( "div" ) ;
509+ textBody2 . classList . add ( "mx_MTextBody" ) ;
510+
511+ const startTextNode = document . createTextNode ( "start" ) ;
512+ const endTextNode = document . createTextNode ( "end" ) ;
513+ textBody1 . appendChild ( startTextNode ) ;
514+ textBody2 . appendChild ( endTextNode ) ;
515+
516+ // Create a mock range spanning different MTextBody elements
517+ const mockRange = {
518+ startContainer : startTextNode ,
519+ endContainer : endTextNode ,
520+ } as unknown as Range ;
521+
522+ mockGetSelection . mockReturnValue ( {
523+ rangeCount : 1 ,
524+ getRangeAt : jest . fn ( ) . mockReturnValue ( mockRange ) ,
525+ } as any ) ;
526+
527+ const result = ( contextMenuInstance as any ) . isSelectionWithinSingleTextBody ( ) ;
528+ expect ( result ) . toBe ( false ) ;
529+ } ) ;
530+
531+ it ( "returns false when selection is outside any mx_MTextBody element" , ( ) => {
532+ // Create regular div elements without mx_MTextBody class
533+ const regularDiv1 = document . createElement ( "div" ) ;
534+ const regularDiv2 = document . createElement ( "div" ) ;
535+
536+ const startTextNode = document . createTextNode ( "start" ) ;
537+ const endTextNode = document . createTextNode ( "end" ) ;
538+ regularDiv1 . appendChild ( startTextNode ) ;
539+ regularDiv2 . appendChild ( endTextNode ) ;
540+
541+ // Create a mock range outside MTextBody elements
542+ const mockRange = {
543+ startContainer : startTextNode ,
544+ endContainer : endTextNode ,
545+ } as unknown as Range ;
546+
547+ mockGetSelection . mockReturnValue ( {
548+ rangeCount : 1 ,
549+ getRangeAt : jest . fn ( ) . mockReturnValue ( mockRange ) ,
550+ } as any ) ;
551+
552+ const result = ( contextMenuInstance as any ) . isSelectionWithinSingleTextBody ( ) ;
553+ expect ( result ) . toBe ( false ) ;
554+ } ) ;
555+
556+ it ( "returns true when start and end are the same mx_MTextBody element" , ( ) => {
557+ const textBodyElement = document . createElement ( "div" ) ;
558+ textBodyElement . classList . add ( "mx_MTextBody" ) ;
559+
560+ const textNode = document . createTextNode ( "same text" ) ;
561+ textBodyElement . appendChild ( textNode ) ;
562+
563+ // Create a mock range within the same MTextBody element
564+ const mockRange = {
565+ startContainer : textNode ,
566+ endContainer : textNode ,
567+ } as unknown as Range ;
568+
569+ mockGetSelection . mockReturnValue ( {
570+ rangeCount : 1 ,
571+ getRangeAt : jest . fn ( ) . mockReturnValue ( mockRange ) ,
572+ } as any ) ;
573+
574+ const result = ( contextMenuInstance as any ) . isSelectionWithinSingleTextBody ( ) ;
575+ expect ( result ) . toBe ( true ) ;
576+ } ) ;
577+ } ) ;
578+
359579 describe ( "right click" , ( ) => {
360580 it ( "copy button does work as expected" , ( ) => {
361581 const text = "hello" ;
0 commit comments