Skip to content

Commit 95cdfa1

Browse files
authored
feat: room abbreviations with hover tooltips and space inheritance (#514)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description Adds a room abbreviations feature: moderators can define term/definition pairs in room settings. Defined terms are highlighted with a dotted underline in messages and show a tooltip on hover (tap-to-pin on mobile). Abbreviations defined in a parent space are inherited by child rooms and nested spaces, with overrides allowed at each level. The settings UI shows a flat list of inherited and locally-defined entries. Fixes # #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [X] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. --> The code adds changes that allow for setting and viewing room abbreviations, with an on-hover tooltip (or press on mobile - semi-persistent). It stores these in the room state under a new key, and recursively fetches abbreviations from the space level. This also adds regex so that abbreviations correctly work at word boundaries. Also adds the needed hooks to `room.ts` and `slidingSync.ts`
2 parents 99d2b4b + cc2c51a commit 95cdfa1

16 files changed

Lines changed: 861 additions & 67 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add room abbreviations with hover tooltips: moderators define term/definition pairs in room settings; matching terms are highlighted in messages.

src/app/components/message/RenderBody.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,94 @@
1+
import { MouseEventHandler, useEffect, useState } from 'react';
12
import parse, { HTMLReactParserOptions } from 'html-react-parser';
23
import Linkify from 'linkify-react';
34
import { Opts } from 'linkifyjs';
5+
import { PopOut, RectCords, Text, Tooltip, TooltipProvider, toRem } from 'folds';
46
import { sanitizeCustomHtml } from '$utils/sanitize';
57
import { highlightText, scaleSystemEmoji } from '$plugins/react-custom-html-parser';
8+
import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations';
9+
import { splitByAbbreviations } from '$utils/abbreviations';
610
import { MessageEmptyContent } from './content';
711

12+
type AbbreviationTermProps = {
13+
text: string;
14+
definition: string;
15+
};
16+
function AbbreviationTerm({ text, definition }: AbbreviationTermProps) {
17+
const [anchor, setAnchor] = useState<RectCords | undefined>();
18+
19+
const handleClick: MouseEventHandler<HTMLElement> = (e) => {
20+
e.stopPropagation();
21+
setAnchor((prev) => (prev ? undefined : e.currentTarget.getBoundingClientRect()));
22+
};
23+
24+
// On mobile, tapping an abbreviation pins the tooltip open.
25+
// Tapping anywhere else (outside the abbr) dismisses it.
26+
useEffect(() => {
27+
if (!anchor) return undefined;
28+
const dismiss = () => setAnchor(undefined);
29+
document.addEventListener('click', dismiss, { once: true });
30+
return () => document.removeEventListener('click', dismiss);
31+
}, [anchor]);
32+
33+
const tooltipContent = (
34+
<Tooltip style={{ maxWidth: toRem(250) }}>
35+
<Text size="T200">{definition}</Text>
36+
</Tooltip>
37+
);
38+
39+
return (
40+
<>
41+
<TooltipProvider position="Top" tooltip={tooltipContent}>
42+
{(triggerRef) => (
43+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
44+
<abbr
45+
ref={triggerRef as React.Ref<HTMLElement>}
46+
onClick={handleClick}
47+
style={{ textDecoration: 'underline dotted', cursor: 'help' }}
48+
>
49+
{text}
50+
</abbr>
51+
)}
52+
</TooltipProvider>
53+
{anchor && (
54+
<PopOut anchor={anchor} position="Top" align="Center" content={tooltipContent}>
55+
{null}
56+
</PopOut>
57+
)}
58+
</>
59+
);
60+
}
61+
62+
/**
63+
* Builds a `replaceTextNode` callback for use with {@link getReactCustomHtmlParser}.
64+
* Returns `undefined` when there are no abbreviations to apply (avoids creating
65+
* extra closures in the common case).
66+
*/
67+
export function buildAbbrReplaceTextNode(
68+
abbrMap: Map<string, string>
69+
): ((text: string) => JSX.Element | undefined) | undefined {
70+
if (abbrMap.size === 0) return undefined;
71+
return function replaceTextNode(text: string) {
72+
const segments = splitByAbbreviations(text, abbrMap);
73+
if (!segments.some((s) => s.termKey !== undefined)) return undefined;
74+
return (
75+
<>
76+
{segments.map((seg) =>
77+
seg.termKey !== undefined ? (
78+
<AbbreviationTerm
79+
key={seg.id}
80+
text={seg.text}
81+
definition={abbrMap.get(seg.termKey) ?? ''}
82+
/>
83+
) : (
84+
seg.text
85+
)
86+
)}
87+
</>
88+
);
89+
};
90+
}
91+
892
type RenderBodyProps = {
993
body: string;
1094
customBody?: string;
@@ -20,12 +104,37 @@ export function RenderBody({
20104
htmlReactParserOptions,
21105
linkifyOpts,
22106
}: Readonly<RenderBodyProps>) {
107+
const abbrMap = useRoomAbbreviationsContext();
108+
23109
if (customBody) {
24110
if (customBody === '') return <MessageEmptyContent />;
25111
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
26112
}
27113
if (body === '') return <MessageEmptyContent />;
28114

115+
if (abbrMap.size > 0) {
116+
const segments = splitByAbbreviations(body, abbrMap);
117+
if (segments.some((s) => s.termKey !== undefined)) {
118+
return (
119+
<>
120+
{segments.map((seg) => {
121+
if (seg.termKey !== undefined) {
122+
const definition = abbrMap.get(seg.termKey) ?? '';
123+
return <AbbreviationTerm key={seg.id} text={seg.text} definition={definition} />;
124+
}
125+
return (
126+
<Linkify key={seg.id} options={linkifyOpts}>
127+
{highlightRegex
128+
? highlightText(highlightRegex, scaleSystemEmoji(seg.text))
129+
: scaleSystemEmoji(seg.text)}
130+
</Linkify>
131+
);
132+
})}
133+
</>
134+
);
135+
}
136+
}
137+
29138
return (
30139
<Linkify options={linkifyOpts}>
31140
{highlightRegex

src/app/features/room-settings/RoomSettings.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { DeveloperTools } from '$features/common-settings/developer-tools';
1919
import { Cosmetics } from '$features/common-settings/cosmetics/Cosmetics';
2020
import { Permissions } from './permissions';
2121
import { General } from './general';
22+
import { RoomAbbreviations } from './abbreviations/RoomAbbreviations';
2223

2324
type RoomSettingsMenuItem = {
2425
page: RoomSettingsPage;
@@ -51,6 +52,11 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
5152
icon: Icons.Alphabet,
5253
activeIcon: Icons.AlphabetUnderline,
5354
},
55+
{
56+
page: RoomSettingsPage.AbbreviationsPage,
57+
name: 'Abbreviations',
58+
icon: Icons.Info,
59+
},
5460
{
5561
page: RoomSettingsPage.EmojisStickersPage,
5662
name: 'Emojis & Stickers',
@@ -196,6 +202,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
196202
{activePage === RoomSettingsPage.DeveloperToolsPage && (
197203
<DeveloperTools requestClose={handlePageRequestClose} />
198204
)}
205+
{activePage === RoomSettingsPage.AbbreviationsPage && (
206+
<RoomAbbreviations requestClose={handlePageRequestClose} />
207+
)}
199208
</PageRoot>
200209
</SwipeableOverlayWrapper>
201210
);

0 commit comments

Comments
 (0)