Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/feat-room-abbreviations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add room abbreviations with hover tooltips: moderators define term/definition pairs in room settings; matching terms are highlighted in messages.
110 changes: 110 additions & 0 deletions src/app/components/message/RenderBody.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,91 @@
import { MouseEventHandler, useEffect, useState } from 'react';
import parse, { HTMLReactParserOptions } from 'html-react-parser';
import Linkify from 'linkify-react';
import { Opts } from 'linkifyjs';
import { PopOut, RectCords, Text, Tooltip, TooltipProvider, toRem } from 'folds';
import { sanitizeCustomHtml } from '$utils/sanitize';
import { highlightText, scaleSystemEmoji } from '$plugins/react-custom-html-parser';
import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations';
import { splitByAbbreviations } from '$utils/abbreviations';
import { MessageEmptyContent } from './content';

type AbbreviationTermProps = {
text: string;
definition: string;
};
function AbbreviationTerm({ text, definition }: AbbreviationTermProps) {
const [anchor, setAnchor] = useState<RectCords | undefined>();

const handleClick: MouseEventHandler<HTMLElement> = (e) => {
e.stopPropagation();
setAnchor((prev) => (prev ? undefined : e.currentTarget.getBoundingClientRect()));
};

// On mobile, tapping an abbreviation pins the tooltip open.
// Tapping anywhere else (outside the abbr) dismisses it.
useEffect(() => {
if (!anchor) return undefined;
const dismiss = () => setAnchor(undefined);
document.addEventListener('click', dismiss, { once: true });
return () => document.removeEventListener('click', dismiss);
}, [anchor]);

const tooltipContent = (
<Tooltip style={{ maxWidth: toRem(250) }}>
<Text size="T200">{definition}</Text>
</Tooltip>
);

return (
<>
<TooltipProvider position="Top" tooltip={tooltipContent}>
{(triggerRef) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<abbr
ref={triggerRef as React.Ref<HTMLElement>}
onClick={handleClick}
style={{ textDecoration: 'underline dotted', cursor: 'help' }}
>
{text}
</abbr>
)}
</TooltipProvider>
{anchor && (
<PopOut anchor={anchor} position="Top" align="Center" content={tooltipContent}>
{null}
</PopOut>
)}
</>
);
}

/**
* Builds a `replaceTextNode` callback for use with {@link getReactCustomHtmlParser}.
* Returns `undefined` when there are no abbreviations to apply (avoids creating
* extra closures in the common case).
*/
export function buildAbbrReplaceTextNode(
abbrMap: Map<string, string>
): ((text: string) => JSX.Element | undefined) | undefined {
if (abbrMap.size === 0) return undefined;
return function replaceTextNode(text: string) {
const segments = splitByAbbreviations(text, abbrMap);
if (!segments.some((s) => s.termKey !== undefined)) return undefined;
return (
<>
{segments.map((seg, i) =>
seg.termKey !== undefined ? (
// eslint-disable-next-line react/no-array-index-key
<AbbreviationTerm key={i} text={seg.text} definition={abbrMap.get(seg.termKey) ?? ''} />
) : (
seg.text
)
)}
</>
);
};
}

type RenderBodyProps = {
body: string;
customBody?: string;
Expand All @@ -20,12 +101,41 @@ export function RenderBody({
htmlReactParserOptions,
linkifyOpts,
}: Readonly<RenderBodyProps>) {
const abbrMap = useRoomAbbreviationsContext();

if (customBody) {
if (customBody === '') return <MessageEmptyContent />;
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
}
if (body === '') return <MessageEmptyContent />;

if (abbrMap.size > 0) {
const segments = splitByAbbreviations(body, abbrMap);
if (segments.some((s) => s.termKey !== undefined)) {
return (
<>
{segments.map((seg, i) => {
if (seg.termKey !== undefined) {
const definition = abbrMap.get(seg.termKey) ?? '';
return (
// eslint-disable-next-line react/no-array-index-key
<AbbreviationTerm key={i} text={seg.text} definition={definition} />
);
}
return (
// eslint-disable-next-line react/no-array-index-key
<Linkify key={i} options={linkifyOpts}>
{highlightRegex
? highlightText(highlightRegex, scaleSystemEmoji(seg.text))
: scaleSystemEmoji(seg.text)}
</Linkify>
);
})}
</>
);
}
}

return (
<Linkify options={linkifyOpts}>
{highlightRegex
Expand Down
9 changes: 9 additions & 0 deletions src/app/features/room-settings/RoomSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DeveloperTools } from '$features/common-settings/developer-tools';
import { Cosmetics } from '$features/common-settings/cosmetics/Cosmetics';
import { Permissions } from './permissions';
import { General } from './general';
import { RoomAbbreviations } from './abbreviations/RoomAbbreviations';

type RoomSettingsMenuItem = {
page: RoomSettingsPage;
Expand Down Expand Up @@ -51,6 +52,11 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
icon: Icons.Alphabet,
activeIcon: Icons.AlphabetUnderline,
},
{
page: RoomSettingsPage.AbbreviationsPage,
name: 'Abbreviations',
icon: Icons.Info,
},
{
page: RoomSettingsPage.EmojisStickersPage,
name: 'Emojis & Stickers',
Expand Down Expand Up @@ -196,6 +202,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.AbbreviationsPage && (
<RoomAbbreviations requestClose={handlePageRequestClose} />
)}
</PageRoot>
</SwipeableOverlayWrapper>
);
Expand Down
Loading
Loading