Skip to content

Commit

Permalink
Display a popover when hovering over highlighted mentions
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Feb 12, 2025
1 parent 1e1103f commit 7bee700
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 10 deletions.
1 change: 1 addition & 0 deletions src/sidebar/components/Annotation/AnnotationBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function AnnotationBody({ annotation, settings }: AnnotationBodyProps) {
})}
style={textStyle}
mentions={annotation.mentions}
mentionsEnabled={store.isFeatureEnabled('at_mentions')}
/>
</Excerpt>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/sidebar/components/Annotation/test/AnnotationBody-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from '@hypothesis/frontend-testing';
import { mount } from '@hypothesis/frontend-testing';
import { act } from 'preact/test-utils';
import sinon from 'sinon';

import * as fixtures from '../../../test/annotation-fixtures';
import AnnotationBody, { $imports } from '../AnnotationBody';
Expand Down Expand Up @@ -54,6 +55,7 @@ describe('AnnotationBody', () => {
getLink: sinon
.stub()
.callsFake((linkPath, { tag }) => `http://www.example.com/${tag}`),
isFeatureEnabled: sinon.stub().returns(false),
};

$imports.$mock(mockImportedComponents());
Expand Down
1 change: 1 addition & 0 deletions src/sidebar/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ export default function MarkdownEditor({
classes="border bg-grey-1 p-2"
style={textStyle}
mentions={mentions}
mentionsEnabled={mentionsEnabled}
/>
) : (
<TextArea
Expand Down
109 changes: 105 additions & 4 deletions src/sidebar/components/MarkdownView.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
import { useEffect, useMemo, useRef } from 'preact/hooks';
import { Popover } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';

import type { Mention } from '../../types/api';
import type { InvalidUsername } from '../helpers/mentions';
import { renderMentionTags } from '../helpers/mentions';
import { replaceLinksWithEmbeds } from '../media-embedder';
import { renderMathAndMarkdown } from '../render-markdown';
import StyledText from './StyledText';
import MentionPreviewContent from './mentions/MentionPreviewContent';

export type MarkdownViewProps = {
/** The string of markdown to display as HTML. */
markdown: string;
classes?: string;
style?: Record<string, string>;
mentions?: Mention[];

/**
* Whether the at-mentions feature ir enabled or not.
* Defaults to false.
*/
mentionsEnabled?: boolean;

// Test seams
setTimeout_?: typeof setTimeout;
clearTimeout_?: typeof clearTimeout;
};

type PopoverContent = Mention | InvalidUsername | null;

/**
* A component which renders markdown as HTML and replaces recognized links
* with embedded video/audio.
Expand All @@ -22,14 +44,50 @@ export default function MarkdownView({
markdown,
classes,
style,
mentions,
mentions = [],
mentionsEnabled = false,
/* istanbul ignore next - test seam */
setTimeout_ = setTimeout,
/* istanbul ignore next - test seam */
clearTimeout_ = clearTimeout,
}: MarkdownViewProps) {
const html = useMemo(
() => (markdown ? renderMathAndMarkdown(markdown) : ''),
[markdown],
);
const content = useRef<HTMLDivElement | null>(null);

const mentionsPopoverAnchorRef = useRef<HTMLElement | null>(null);
const elementToMentionMap = useRef(
new Map<HTMLElement, Mention | InvalidUsername>(),
);
const [popoverContent, doSetPopoverContent] = useState<PopoverContent>(null);
/**
* This allows the content to be set with a small delay, so that popovers don't flickr
*/
const popoverContentTimeout = useRef<ReturnType<typeof setTimeout> | null>();
const setPopoverContent = useCallback(
(content: PopoverContent) => {
if (popoverContentTimeout.current) {
clearTimeout_(popoverContentTimeout.current);

Check warning on line 72 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L70-L72

Added lines #L70 - L72 were not covered by tests
}

const setContent = () => {
doSetPopoverContent(content);
popoverContentTimeout.current = null;

Check warning on line 77 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L75-L77

Added lines #L75 - L77 were not covered by tests
};

// Set the content immediately when resetting, so that there's no delay
// when hiding the popover, only when showing it
if (content === null) {
setContent();
} else {
popoverContentTimeout.current = setTimeout_(setContent, 400);

Check warning on line 85 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L82-L85

Added lines #L82 - L85 were not covered by tests
}
},
[clearTimeout_, setTimeout_],
);

useEffect(() => {
replaceLinksWithEmbeds(content.current!, {
// Make embeds the full width of the sidebar, unless the sidebar has been
Expand All @@ -40,7 +98,7 @@ export default function MarkdownView({
}, [markdown]);

useEffect(() => {
renderMentionTags(content.current!, mentions ?? []);
elementToMentionMap.current = renderMentionTags(content.current!, mentions);
}, [mentions]);

// NB: The following could be implemented by setting attribute props directly
Expand All @@ -50,14 +108,57 @@ export default function MarkdownView({
// a review in the future.
return (
<div className="w-full break-anywhere cursor-text">
<StyledText>
<StyledText
classes={classnames({
// A `relative` wrapper around the `Popover` component is needed for
// when the native Popover API is not supported.
relative: mentionsEnabled,
})}
>
<div
className={classes}
data-testid="markdown-text"
ref={content}
dangerouslySetInnerHTML={{ __html: html }}
style={style}
// React types do not define `onMouseEnterCapture`, but preact does
// eslint-disable-next-line react/no-unknown-property
onMouseEnterCapture={
mentionsEnabled
? ({ target }) => {
const element = target as HTMLElement;
const mention = elementToMentionMap.current.get(element);

Check warning on line 130 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L129-L130

Added lines #L129 - L130 were not covered by tests

if (mention) {
setPopoverContent(mention);
mentionsPopoverAnchorRef.current = element;

Check warning on line 134 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L132-L134

Added lines #L132 - L134 were not covered by tests
}
}
: undefined
}
// React types do not define `onMouseLeaveCapture`, but preact does
// eslint-disable-next-line react/no-unknown-property
onMouseLeaveCapture={
mentionsEnabled
? () => {
setPopoverContent(null);
mentionsPopoverAnchorRef.current = null;

Check warning on line 145 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L144-L145

Added lines #L144 - L145 were not covered by tests
}
: undefined
}
/>
{mentionsEnabled && (
<Popover
open={!!popoverContent}
onClose={() => setPopoverContent(null)}

Check warning on line 153 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L153

Added line #L153 was not covered by tests
anchorElementRef={mentionsPopoverAnchorRef}
classes="px-3 py-2"
>
{popoverContent !== null && (
<MentionPreviewContent content={popoverContent} />

Check warning on line 158 in src/sidebar/components/MarkdownView.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownView.tsx#L158

Added line #L158 was not covered by tests
)}
</Popover>
)}
</StyledText>
</div>
);
Expand Down
31 changes: 31 additions & 0 deletions src/sidebar/components/mentions/MentionPreviewContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Mention } from '../../../types/api';
import type { InvalidUsername } from '../../helpers/mentions';

export type MentionPreviewContent = {
content: Mention | InvalidUsername;
};

/**
* Information to display on a Popover when hovering over a processed mention
*/
export default function MentionPreviewContent({
content,
}: MentionPreviewContent) {
if (typeof content === 'string') {
return (

Check warning on line 15 in src/sidebar/components/mentions/MentionPreviewContent.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/mentions/MentionPreviewContent.tsx#L13-L15

Added lines #L13 - L15 were not covered by tests
<>
No user with username <span className="font-bold">{content}</span>{' '}
exists
</>
);
}

return (

Check warning on line 23 in src/sidebar/components/mentions/MentionPreviewContent.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/mentions/MentionPreviewContent.tsx#L23

Added line #L23 was not covered by tests
<div className="flex flex-col gap-y-1.5">
<div className="text-md font-bold">@{content.username}</div>
{content.display_name && (
<div className="text-color-text-light">{content.display_name}</div>

Check warning on line 27 in src/sidebar/components/mentions/MentionPreviewContent.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/mentions/MentionPreviewContent.tsx#L26-L27

Added lines #L26 - L27 were not covered by tests
)}
</div>
);
}
36 changes: 30 additions & 6 deletions src/sidebar/components/test/MarkdownView-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { checkAccessibility } from '@hypothesis/frontend-testing';
import {
checkAccessibility,
mockImportedComponents,
} from '@hypothesis/frontend-testing';
import { mount } from '@hypothesis/frontend-testing';
import sinon from 'sinon';

Expand All @@ -8,14 +11,28 @@ describe('MarkdownView', () => {
let fakeRenderMathAndMarkdown;
let fakeReplaceLinksWithEmbeds;
let fakeRenderMentionTags;
let fakeClearTimeout;

const markdownSelector = '[data-testid="markdown-text"]';

function createComponent(props = {}) {
return mount(
<MarkdownView
markdown=""
{...props}
setTimeout_={callback => setTimeout(callback, 1)}
clearTimeout_={fakeClearTimeout}
/>,
);
}

beforeEach(() => {
fakeRenderMathAndMarkdown = markdown => `rendered:${markdown}`;
fakeReplaceLinksWithEmbeds = sinon.stub();
fakeRenderMentionTags = sinon.stub();
fakeClearTimeout = sinon.stub();

$imports.$mock(mockImportedComponents());
$imports.$mock({
'../render-markdown': {
renderMathAndMarkdown: fakeRenderMathAndMarkdown,
Expand All @@ -34,18 +51,18 @@ describe('MarkdownView', () => {
});

it('renders nothing if no markdown is provided', () => {
const wrapper = mount(<MarkdownView />);
const wrapper = createComponent();
assert.equal(wrapper.text(), '');
});

it('renders markdown as HTML', () => {
const wrapper = mount(<MarkdownView markdown="**test**" />);
const wrapper = createComponent({ markdown: '**test**' });
const rendered = wrapper.find(markdownSelector).getDOMNode();
assert.equal(rendered.innerHTML, 'rendered:**test**');
});

it('re-renders markdown after an update', () => {
const wrapper = mount(<MarkdownView markdown="**test**" />);
const wrapper = createComponent({ markdown: '**test**' });
wrapper.setProps({ markdown: '_updated_' });
const rendered = wrapper.find(markdownSelector).getDOMNode();
assert.equal(rendered.innerHTML, 'rendered:_updated_');
Expand Down Expand Up @@ -77,15 +94,22 @@ describe('MarkdownView', () => {

[undefined, [{}]].forEach(mentions => {
it('renders mention tags based on provided mentions', () => {
mount(<MarkdownView mentions={mentions} />);
createComponent({ mentions });
assert.calledWith(fakeRenderMentionTags, sinon.match.any, mentions ?? []);
});
});

[true, false].forEach(mentionsEnabled => {
it('renders Popover only if mentions are enabled', () => {
const wrapper = createComponent({ mentionsEnabled });
assert.equal(wrapper.exists('Popover'), mentionsEnabled);
});
});

it(
'should pass a11y checks',
checkAccessibility({
content: () => <MarkdownView markdown="foo" />,
content: () => createComponent({ markdown: 'foo' }),
}),
);
});

0 comments on commit 7bee700

Please sign in to comment.