From 81602d31337d7baf51d3206e923f42aa2a0d1ec2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 02:12:03 -0400 Subject: [PATCH 1/8] feat: add room abbreviations with hover tooltips (#403) - Add StateEvent.RoomAbbreviations ('moe.sable.room.abbreviations') - Include abbreviations event in sliding sync required state - Add AbbreviationsPage to RoomSettingsPage enum - New RoomAbbreviations settings page (add/remove term+definition pairs) - Wire page into RoomSettings.tsx menu - New useRoomAbbreviations hook + RoomAbbreviationsContext - Provide context in Room.tsx - RenderBody.tsx: highlight defined terms in plain-text messages with TooltipProvider+abbr tooltip; HTML messages unaffected --- .changeset/feat-room-abbreviations.md | 5 + src/app/components/message/RenderBody.tsx | 46 ++++ .../features/room-settings/RoomSettings.tsx | 9 + .../abbreviations/RoomAbbreviations.tsx | 226 ++++++++++++++++++ src/app/features/room/Room.tsx | 136 ++++++----- src/app/hooks/useRoomAbbreviations.ts | 20 ++ src/app/state/roomSettings.ts | 1 + src/app/utils/abbreviations.ts | 61 +++++ src/client/slidingSync.ts | 1 + src/types/matrix/room.ts | 1 + 10 files changed, 440 insertions(+), 66 deletions(-) create mode 100644 .changeset/feat-room-abbreviations.md create mode 100644 src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx create mode 100644 src/app/hooks/useRoomAbbreviations.ts create mode 100644 src/app/utils/abbreviations.ts diff --git a/.changeset/feat-room-abbreviations.md b/.changeset/feat-room-abbreviations.md new file mode 100644 index 000000000..c7f64998d --- /dev/null +++ b/.changeset/feat-room-abbreviations.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add room abbreviations: moderators can define a list of term/definition pairs in room settings; defined terms are highlighted with a hover tooltip in plain-text messages. diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index 03714750f..e45a3b025 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -1,8 +1,11 @@ import parse, { HTMLReactParserOptions } from 'html-react-parser'; import Linkify from 'linkify-react'; import { Opts } from 'linkifyjs'; +import { 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 RenderBodyProps = { @@ -20,12 +23,55 @@ export function RenderBody({ htmlReactParserOptions, linkifyOpts, }: Readonly) { + const abbrMap = useRoomAbbreviationsContext(); + if (customBody) { if (customBody === '') return ; return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); } if (body === '') return ; + 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 ( + + {definition} + + } + > + {(triggerRef) => ( + } title={definition}> + {seg.text} + + )} + + ); + } + return ( + // eslint-disable-next-line react/no-array-index-key + + {highlightRegex + ? highlightText(highlightRegex, scaleSystemEmoji(seg.text)) + : scaleSystemEmoji(seg.text)} + + ); + })} + + ); + } + } + return ( {highlightRegex diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 7c973d995..02fb0e849 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -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; @@ -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', @@ -196,6 +202,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { {activePage === RoomSettingsPage.DeveloperToolsPage && ( )} + {activePage === RoomSettingsPage.AbbreviationsPage && ( + + )} ); diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx new file mode 100644 index 000000000..a9e7386f4 --- /dev/null +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -0,0 +1,226 @@ +import { FormEventHandler, useCallback } from 'react'; +import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text, config } from 'folds'; +import { Page, PageContent, PageHeader } from '$components/page'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useRoom } from '$hooks/useRoom'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { usePowerLevels } from '$hooks/usePowerLevels'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useStateEvent } from '$hooks/useStateEvent'; +import { StateEvent } from '$types/matrix/room'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { MatrixError } from '$types/matrix-sdk'; +import { AbbreviationEntry, RoomAbbreviationsContent } from '$utils/abbreviations'; +import { SequenceCardStyle } from '$features/common-settings/styles.css'; + +type AbbreviationsProps = { + requestClose: () => void; +}; + +export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { + const room = useRoom(); + const mx = useMatrixClient(); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const userId = mx.getUserId() ?? ''; + + const stateEvent = useStateEvent(room, StateEvent.RoomAbbreviations); + const content = stateEvent?.getContent(); + const entries: AbbreviationEntry[] = Array.isArray(content?.entries) ? content.entries : []; + + const canEdit = permissions.stateEvent(StateEvent.RoomAbbreviations, userId); + + const [saveState, saveAbbreviations] = useAsyncCallback( + useCallback( + async (newEntries) => { + const newContent: RoomAbbreviationsContent = { entries: newEntries }; + await mx.sendStateEvent(room.roomId, StateEvent.RoomAbbreviations as any, newContent, ''); + }, + [mx, room.roomId] + ) + ); + + const saving = saveState.status === AsyncStatus.Loading; + + const handleAdd: FormEventHandler = (evt) => { + evt.preventDefault(); + if (saving || !canEdit) return; + const form = evt.target as HTMLFormElement; + const termInput = form.elements.namedItem('term') as HTMLInputElement | null; + const definitionInput = form.elements.namedItem('definition') as HTMLInputElement | null; + if (!termInput || !definitionInput) return; + const term = termInput.value.trim(); + const definition = definitionInput.value.trim(); + if (!term || !definition) return; + + const alreadyExists = entries.some((e) => e.term.toLowerCase() === term.toLowerCase()); + if (alreadyExists) { + termInput.setCustomValidity('This term already exists.'); + termInput.reportValidity(); + return; + } + termInput.setCustomValidity(''); + + const newEntries = [...entries, { term, definition }]; + saveAbbreviations(newEntries).then(() => { + form.reset(); + }); + }; + + const handleRemove = (index: number) => { + if (saving || !canEdit) return; + const newEntries = entries.filter((_, i) => i !== index); + saveAbbreviations(newEntries); + }; + + return ( + + + + + + Abbreviations + + + + + + + + + + + + + + {canEdit && ( + + Add Abbreviation + + + + + Term + + + + Definition + + + {saveState.status === AsyncStatus.Error && ( + + {saveState.error.message} + + )} + + + + + + + + )} + + + + {entries.length > 0 + ? `Defined Abbreviations (${entries.length})` + : 'Defined Abbreviations'} + + {entries.length === 0 ? ( + + + No abbreviations defined yet. + {canEdit && ' Use the form above to add some.'} + + + ) : ( + entries.map((entry, index) => ( + + + + {entry.term} + + + {entry.definition} + + + {canEdit && ( + + handleRemove(index)} + variant="Background" + size="300" + radii="300" + disabled={saving} + aria-label={`Remove abbreviation ${entry.term}`} + > + + + + )} + + )) + )} + + + + + + + ); +} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 2a067d4bf..b7fca58e0 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -18,6 +18,7 @@ import { callChatAtom } from '$state/callEmbed'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { createDebugLogger } from '$utils/debugLogger'; +import { useRoomAbbreviations, RoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; import { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; @@ -97,6 +98,7 @@ export function Room() { ); const callView = room.isCallRoom(); + const abbreviations = useRoomAbbreviations(room); // Log call view state useEffect(() => { @@ -105,58 +107,80 @@ export function Room() { return ( - - {callView && (screenSize === ScreenSize.Desktop || !chat) && ( - - - - + + + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( + + + + + - - )} - {!callView && ( - - - - + )} + {!callView && ( + + + + + - - )} + )} - {callView && chat && ( - <> - {screenSize === ScreenSize.Desktop && ( + {callView && chat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + + + )} + {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( + <> - )} - - - )} - {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( - <> - - - - )} - {screenSize === ScreenSize.Desktop && isWidgetDrawerOpen && ( - <> - - - - )} - {screenSize === ScreenSize.Desktop && openThreadId && ( - <> - + + + )} + {screenSize === ScreenSize.Desktop && isWidgetDrawerOpen && ( + <> + + + + )} + {screenSize === ScreenSize.Desktop && openThreadId && ( + <> + + setOpenThread(undefined)} + /> + + )} + {screenSize === ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( + <> + + { + setOpenThread(id); + setThreadBrowserOpen(false); + }} + onClose={() => setThreadBrowserOpen(false)} + /> + + )} + {screenSize !== ScreenSize.Desktop && openThreadId && ( setOpenThread(undefined)} + overlay /> - - )} - {screenSize === ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( - <> - + )} + {screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( setThreadBrowserOpen(false)} + overlay /> - - )} - {screenSize !== ScreenSize.Desktop && openThreadId && ( - setOpenThread(undefined)} - overlay - /> - )} - {screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( - { - setOpenThread(id); - setThreadBrowserOpen(false); - }} - onClose={() => setThreadBrowserOpen(false)} - overlay - /> - )} - + )} + + ); } diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts new file mode 100644 index 000000000..c961cc3ac --- /dev/null +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; +import { Room } from '$types/matrix-sdk'; +import { StateEvent } from '$types/matrix/room'; +import { buildAbbreviationsMap, RoomAbbreviationsContent } from '$utils/abbreviations'; +import { useStateEvent } from './useStateEvent'; + +const EMPTY_MAP: Map = new Map(); + +export const RoomAbbreviationsContext = createContext>(EMPTY_MAP); + +export const useRoomAbbreviationsContext = () => useContext(RoomAbbreviationsContext); + +/** Read the room's abbreviations state event and return a term→definition map. */ +export const useRoomAbbreviations = (room: Room): Map => { + const stateEvent = useStateEvent(room, StateEvent.RoomAbbreviations); + if (!stateEvent) return EMPTY_MAP; + const content = stateEvent.getContent(); + if (!Array.isArray(content?.entries) || content.entries.length === 0) return EMPTY_MAP; + return buildAbbreviationsMap(content.entries); +}; diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts index 4002b32ad..64bc0ef99 100644 --- a/src/app/state/roomSettings.ts +++ b/src/app/state/roomSettings.ts @@ -8,6 +8,7 @@ export enum RoomSettingsPage { DeveloperToolsPage, // Sable pages CosmeticsPage, + AbbreviationsPage, } export type RoomSettingsState = { diff --git a/src/app/utils/abbreviations.ts b/src/app/utils/abbreviations.ts new file mode 100644 index 000000000..0c9a1437c --- /dev/null +++ b/src/app/utils/abbreviations.ts @@ -0,0 +1,61 @@ +export type AbbreviationEntry = { + term: string; + definition: string; +}; + +export type RoomAbbreviationsContent = { + entries: AbbreviationEntry[]; +}; + +/** Build a map of lowercase term → definition for O(1) lookup. */ +export const buildAbbreviationsMap = (entries: AbbreviationEntry[]): Map => { + const map = new Map(); + entries.forEach(({ term, definition }) => { + const t = term.trim(); + if (t) map.set(t.toLowerCase(), definition); + }); + return map; +}; + +/** + * Split a plain-text string into alternating [plain, term, plain, term, …] segments. + * Matched terms preserve their original casing from the source string. + * Matching is whole-word and case-insensitive. + * + * Returns an array of `{ text, termKey }` objects where `termKey` is undefined for + * plain segments and is the lowercase lookup key for abbreviation segments. + */ +export type TextSegment = { + text: string; + /** Undefined for plain text; the lowercase map key for an abbreviation. */ + termKey?: string; +}; + +export const splitByAbbreviations = (text: string, abbrMap: Map): TextSegment[] => { + if (abbrMap.size === 0) return [{ text }]; + + // Build a regex that matches any of the terms at word boundaries. + // Sort longest first so "HTTP/2" matches before "HTTP". + const terms = [...abbrMap.keys()].sort((a, b) => b.length - a.length); + const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const pattern = new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'); + + const segments: TextSegment[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + // eslint-disable-next-line no-cond-assign + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ text: text.slice(lastIndex, match.index) }); + } + segments.push({ text: match[0], termKey: match[0].toLowerCase() }); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + segments.push({ text: text.slice(lastIndex) }); + } + + return segments.length > 0 ? segments : [{ text }]; +}; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 84622b0e2..096f77f43 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -123,6 +123,7 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomMember, MSC3575_STATE_KEY_ME], ['m.space.child', MSC3575_WILDCARD], ['im.ponies.room_emotes', MSC3575_WILDCARD], + ['moe.sable.room.abbreviations', ''], ]; // For an active encrypted room: fetch everything so the client can decrypt all events. diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index a429aaf70..f851b9e42 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -49,6 +49,7 @@ export enum StateEvent { RoomCosmeticsColor = 'moe.sable.room.cosmetics.color', RoomCosmeticsFont = 'moe.sable.room.cosmetics.font', RoomCosmeticsPronouns = 'moe.sable.room.cosmetics.pronouns', + RoomAbbreviations = 'moe.sable.room.abbreviations', } export enum MessageEvent { From b1ddcd8cd00480118f7d66d4730df76369d889c0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 02:17:21 -0400 Subject: [PATCH 2/8] fix: case-sensitive abbreviation matching with word boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildAbbreviationsMap now stores exact-case keys (no .toLowerCase()) - splitByAbbreviations regex uses 'g' flag instead of 'gi' — user-defined casing is matched exactly (FOSS won't match 'foss' in text) - Word boundaries (\b) still prevent matching inside longer words - RoomAbbreviations settings page duplicate check updated to exact match - Add 19 unit tests covering edge cases: empty map, no-match, start/mid/end splits, case sensitivity, word boundaries, longest-match priority --- .../abbreviations/RoomAbbreviations.tsx | 2 +- src/app/utils/abbreviations.test.ts | 145 ++++++++++++++++++ src/app/utils/abbreviations.ts | 12 +- 3 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 src/app/utils/abbreviations.test.ts diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index a9e7386f4..daa76a0ff 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -56,7 +56,7 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { const definition = definitionInput.value.trim(); if (!term || !definition) return; - const alreadyExists = entries.some((e) => e.term.toLowerCase() === term.toLowerCase()); + const alreadyExists = entries.some((e) => e.term === term); if (alreadyExists) { termInput.setCustomValidity('This term already exists.'); termInput.reportValidity(); diff --git a/src/app/utils/abbreviations.test.ts b/src/app/utils/abbreviations.test.ts new file mode 100644 index 000000000..79b59519d --- /dev/null +++ b/src/app/utils/abbreviations.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAbbreviationsMap, splitByAbbreviations } from './abbreviations'; + +describe('buildAbbreviationsMap', () => { + it('returns an empty map for empty entries', () => { + expect(buildAbbreviationsMap([])).toEqual(new Map()); + }); + + it('stores the term with its original casing as the key', () => { + const map = buildAbbreviationsMap([{ term: 'FOSS', definition: 'Free and Open Source Software' }]); + expect(map.get('FOSS')).toBe('Free and Open Source Software'); + expect(map.get('foss')).toBeUndefined(); + }); + + it('trims surrounding whitespace from terms', () => { + const map = buildAbbreviationsMap([{ term: ' FOSS ', definition: 'Free and Open Source Software' }]); + expect(map.get('FOSS')).toBe('Free and Open Source Software'); + expect(map.size).toBe(1); + }); + + it('skips empty and whitespace-only terms', () => { + const map = buildAbbreviationsMap([ + { term: '', definition: 'ignored' }, + { term: ' ', definition: 'also ignored' }, + ]); + expect(map.size).toBe(0); + }); + + it('treats the same term with different casing as separate entries', () => { + const map = buildAbbreviationsMap([ + { term: 'OSS', definition: 'Open Source Software' }, + { term: 'oss', definition: 'open source software' }, + ]); + expect(map.size).toBe(2); + expect(map.get('OSS')).toBe('Open Source Software'); + expect(map.get('oss')).toBe('open source software'); + }); + + it('stores multiple distinct entries', () => { + const map = buildAbbreviationsMap([ + { term: 'FOSS', definition: 'Free and Open Source Software' }, + { term: 'RTFM', definition: 'Read The Fine Manual' }, + ]); + expect(map.size).toBe(2); + expect(map.get('FOSS')).toBe('Free and Open Source Software'); + expect(map.get('RTFM')).toBe('Read The Fine Manual'); + }); +}); + +describe('splitByAbbreviations', () => { + const map = buildAbbreviationsMap([ + { term: 'FOSS', definition: 'Free and Open Source Software' }, + { term: 'RTFM', definition: 'Read The Fine Manual' }, + { term: 'OSS', definition: 'Open Source Software' }, + ]); + + it('returns a single plain segment when the map is empty', () => { + expect(splitByAbbreviations('hello FOSS world', new Map())).toEqual([{ text: 'hello FOSS world' }]); + }); + + it('returns a single plain segment when there are no matches', () => { + expect(splitByAbbreviations('hello world', map)).toEqual([{ text: 'hello world' }]); + }); + + it('returns a single plain segment for an empty string', () => { + expect(splitByAbbreviations('', map)).toEqual([{ text: '' }]); + }); + + it('splits a term match in the middle of a string', () => { + expect(splitByAbbreviations('Use FOSS software', map)).toEqual([ + { text: 'Use ' }, + { text: 'FOSS', termKey: 'FOSS' }, + { text: ' software' }, + ]); + }); + + it('splits a term match at the start of a string', () => { + expect(splitByAbbreviations('FOSS is great', map)).toEqual([ + { text: 'FOSS', termKey: 'FOSS' }, + { text: ' is great' }, + ]); + }); + + it('splits a term match at the end of a string', () => { + expect(splitByAbbreviations('I love FOSS', map)).toEqual([ + { text: 'I love ' }, + { text: 'FOSS', termKey: 'FOSS' }, + ]); + }); + + it('handles multiple terms in the same string', () => { + expect(splitByAbbreviations('FOSS and RTFM', map)).toEqual([ + { text: 'FOSS', termKey: 'FOSS' }, + { text: ' and ' }, + { text: 'RTFM', termKey: 'RTFM' }, + ]); + }); + + it('is case-sensitive and does not match the wrong casing', () => { + expect(splitByAbbreviations('I like foss and Foss', map)).toEqual([ + { text: 'I like foss and Foss' }, + ]); + }); + + it('does not match a term that is a prefix of a longer word (word boundary)', () => { + // OSS is in the map, but should not match inside FOSS because the F provides no boundary + const ossOnlyMap = buildAbbreviationsMap([{ term: 'OSS', definition: 'Open Source Software' }]); + expect(splitByAbbreviations('FOSS rocks', ossOnlyMap)).toEqual([{ text: 'FOSS rocks' }]); + }); + + it('does not match a term embedded inside a longer word', () => { + // OSS should not match inside CROSS or GLOSS + const ossOnlyMap = buildAbbreviationsMap([{ term: 'OSS', definition: 'Open Source Software' }]); + expect(splitByAbbreviations('CROSS the GLOSS', ossOnlyMap)).toEqual([{ text: 'CROSS the GLOSS' }]); + }); + + it('matches a shorter term standalone when the longer overlapping term is also defined', () => { + // OSS is a suffix of FOSS; when standalone it should still match + expect(splitByAbbreviations('OSS is related to FOSS', map)).toEqual([ + { text: 'OSS', termKey: 'OSS' }, + { text: ' is related to ' }, + { text: 'FOSS', termKey: 'FOSS' }, + ]); + }); + + it('prefers the longer term when a shorter one is a suffix of it', () => { + // FOSS contains OSS; FOSS should win and OSS should not be matched separately + expect(splitByAbbreviations('Use FOSS today', map)).toEqual([ + { text: 'Use ' }, + { text: 'FOSS', termKey: 'FOSS' }, + { text: ' today' }, + ]); + }); + + it('preserves plain text between two consecutive matches', () => { + expect(splitByAbbreviations('FOSS, RTFM, OSS', map)).toEqual([ + { text: 'FOSS', termKey: 'FOSS' }, + { text: ', ' }, + { text: 'RTFM', termKey: 'RTFM' }, + { text: ', ' }, + { text: 'OSS', termKey: 'OSS' }, + ]); + }); +}); diff --git a/src/app/utils/abbreviations.ts b/src/app/utils/abbreviations.ts index 0c9a1437c..873411915 100644 --- a/src/app/utils/abbreviations.ts +++ b/src/app/utils/abbreviations.ts @@ -7,12 +7,12 @@ export type RoomAbbreviationsContent = { entries: AbbreviationEntry[]; }; -/** Build a map of lowercase term → definition for O(1) lookup. */ +/** Build a map of exact-case term → definition for O(1) lookup. */ export const buildAbbreviationsMap = (entries: AbbreviationEntry[]): Map => { const map = new Map(); entries.forEach(({ term, definition }) => { const t = term.trim(); - if (t) map.set(t.toLowerCase(), definition); + if (t) map.set(t, definition); }); return map; }; @@ -20,10 +20,10 @@ export const buildAbbreviationsMap = (entries: AbbreviationEntry[]): Map) // Sort longest first so "HTTP/2" matches before "HTTP". const terms = [...abbrMap.keys()].sort((a, b) => b.length - a.length); const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const pattern = new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'); + const pattern = new RegExp(`\\b(${escaped.join('|')})\\b`, 'g'); const segments: TextSegment[] = []; let lastIndex = 0; @@ -49,7 +49,7 @@ export const splitByAbbreviations = (text: string, abbrMap: Map) if (match.index > lastIndex) { segments.push({ text: text.slice(lastIndex, match.index) }); } - segments.push({ text: match[0], termKey: match[0].toLowerCase() }); + segments.push({ text: match[0], termKey: match[0] }); lastIndex = match.index + match[0].length; } From 1cba2004d80c6a9a8b7f335f97d056f8bbd06888 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 02:36:22 -0400 Subject: [PATCH 3/8] fix: case-insensitive matching, remove double tooltip, mobile tap-to-pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert to case-insensitive matching (gi flag, lowercase map keys) and update tests and duplicate-entry check accordingly - Remove title attribute from — was causing native browser tooltip to overlap with our custom TooltipProvider tooltip - Extract AbbreviationTerm component; adds click/tap handler that pins the definition as a PopOut positioned above the term - Desktop: hover still shows tooltip via TooltipProvider - Mobile: tap pins the tooltip open; tap anywhere else dismisses it (one-shot document click listener, stopped by e.stopPropagation so the opening click does not immediately close it) --- src/app/components/message/RenderBody.tsx | 67 ++++++++++++++----- .../abbreviations/RoomAbbreviations.tsx | 2 +- src/app/utils/abbreviations.test.ts | 46 +++++++------ src/app/utils/abbreviations.ts | 12 ++-- 4 files changed, 81 insertions(+), 46 deletions(-) diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index e45a3b025..bf86ffcbf 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -1,13 +1,60 @@ +import { MouseEventHandler, useEffect, useState } from 'react'; import parse, { HTMLReactParserOptions } from 'html-react-parser'; import Linkify from 'linkify-react'; import { Opts } from 'linkifyjs'; -import { Text, Tooltip, TooltipProvider, toRem } from 'folds'; +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(); + + const handleClick: MouseEventHandler = (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 = ( + + {definition} + + ); + + return ( + <> + + {(triggerRef) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions + } onClick={handleClick}> + {text} + + )} + + {anchor && ( + + {null} + + )} + + ); +} + type RenderBodyProps = { body: string; customBody?: string; @@ -40,22 +87,8 @@ export function RenderBody({ if (seg.termKey !== undefined) { const definition = abbrMap.get(seg.termKey) ?? ''; return ( - - {definition} - - } - > - {(triggerRef) => ( - } title={definition}> - {seg.text} - - )} - + // eslint-disable-next-line react/no-array-index-key + ); } return ( diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index daa76a0ff..a9e7386f4 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -56,7 +56,7 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { const definition = definitionInput.value.trim(); if (!term || !definition) return; - const alreadyExists = entries.some((e) => e.term === term); + const alreadyExists = entries.some((e) => e.term.toLowerCase() === term.toLowerCase()); if (alreadyExists) { termInput.setCustomValidity('This term already exists.'); termInput.reportValidity(); diff --git a/src/app/utils/abbreviations.test.ts b/src/app/utils/abbreviations.test.ts index 79b59519d..7f229d70b 100644 --- a/src/app/utils/abbreviations.test.ts +++ b/src/app/utils/abbreviations.test.ts @@ -7,15 +7,15 @@ describe('buildAbbreviationsMap', () => { expect(buildAbbreviationsMap([])).toEqual(new Map()); }); - it('stores the term with its original casing as the key', () => { + it('stores the key as lowercase regardless of input casing', () => { const map = buildAbbreviationsMap([{ term: 'FOSS', definition: 'Free and Open Source Software' }]); - expect(map.get('FOSS')).toBe('Free and Open Source Software'); - expect(map.get('foss')).toBeUndefined(); + expect(map.get('foss')).toBe('Free and Open Source Software'); + expect(map.get('FOSS')).toBeUndefined(); }); it('trims surrounding whitespace from terms', () => { const map = buildAbbreviationsMap([{ term: ' FOSS ', definition: 'Free and Open Source Software' }]); - expect(map.get('FOSS')).toBe('Free and Open Source Software'); + expect(map.get('foss')).toBe('Free and Open Source Software'); expect(map.size).toBe(1); }); @@ -27,13 +27,12 @@ describe('buildAbbreviationsMap', () => { expect(map.size).toBe(0); }); - it('treats the same term with different casing as separate entries', () => { + it('deduplicates different-cased variants of the same term (last entry wins)', () => { const map = buildAbbreviationsMap([ { term: 'OSS', definition: 'Open Source Software' }, { term: 'oss', definition: 'open source software' }, ]); - expect(map.size).toBe(2); - expect(map.get('OSS')).toBe('Open Source Software'); + expect(map.size).toBe(1); expect(map.get('oss')).toBe('open source software'); }); @@ -43,8 +42,8 @@ describe('buildAbbreviationsMap', () => { { term: 'RTFM', definition: 'Read The Fine Manual' }, ]); expect(map.size).toBe(2); - expect(map.get('FOSS')).toBe('Free and Open Source Software'); - expect(map.get('RTFM')).toBe('Read The Fine Manual'); + expect(map.get('foss')).toBe('Free and Open Source Software'); + expect(map.get('rtfm')).toBe('Read The Fine Manual'); }); }); @@ -70,14 +69,14 @@ describe('splitByAbbreviations', () => { it('splits a term match in the middle of a string', () => { expect(splitByAbbreviations('Use FOSS software', map)).toEqual([ { text: 'Use ' }, - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, { text: ' software' }, ]); }); it('splits a term match at the start of a string', () => { expect(splitByAbbreviations('FOSS is great', map)).toEqual([ - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, { text: ' is great' }, ]); }); @@ -85,21 +84,24 @@ describe('splitByAbbreviations', () => { it('splits a term match at the end of a string', () => { expect(splitByAbbreviations('I love FOSS', map)).toEqual([ { text: 'I love ' }, - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, ]); }); it('handles multiple terms in the same string', () => { expect(splitByAbbreviations('FOSS and RTFM', map)).toEqual([ - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, { text: ' and ' }, - { text: 'RTFM', termKey: 'RTFM' }, + { text: 'RTFM', termKey: 'rtfm' }, ]); }); - it('is case-sensitive and does not match the wrong casing', () => { + it('matches case-insensitively (termKey is always lowercase)', () => { expect(splitByAbbreviations('I like foss and Foss', map)).toEqual([ - { text: 'I like foss and Foss' }, + { text: 'I like ' }, + { text: 'foss', termKey: 'foss' }, + { text: ' and ' }, + { text: 'Foss', termKey: 'foss' }, ]); }); @@ -118,9 +120,9 @@ describe('splitByAbbreviations', () => { it('matches a shorter term standalone when the longer overlapping term is also defined', () => { // OSS is a suffix of FOSS; when standalone it should still match expect(splitByAbbreviations('OSS is related to FOSS', map)).toEqual([ - { text: 'OSS', termKey: 'OSS' }, + { text: 'OSS', termKey: 'oss' }, { text: ' is related to ' }, - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, ]); }); @@ -128,18 +130,18 @@ describe('splitByAbbreviations', () => { // FOSS contains OSS; FOSS should win and OSS should not be matched separately expect(splitByAbbreviations('Use FOSS today', map)).toEqual([ { text: 'Use ' }, - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, { text: ' today' }, ]); }); it('preserves plain text between two consecutive matches', () => { expect(splitByAbbreviations('FOSS, RTFM, OSS', map)).toEqual([ - { text: 'FOSS', termKey: 'FOSS' }, + { text: 'FOSS', termKey: 'foss' }, { text: ', ' }, - { text: 'RTFM', termKey: 'RTFM' }, + { text: 'RTFM', termKey: 'rtfm' }, { text: ', ' }, - { text: 'OSS', termKey: 'OSS' }, + { text: 'OSS', termKey: 'oss' }, ]); }); }); diff --git a/src/app/utils/abbreviations.ts b/src/app/utils/abbreviations.ts index 873411915..0c9a1437c 100644 --- a/src/app/utils/abbreviations.ts +++ b/src/app/utils/abbreviations.ts @@ -7,12 +7,12 @@ export type RoomAbbreviationsContent = { entries: AbbreviationEntry[]; }; -/** Build a map of exact-case term → definition for O(1) lookup. */ +/** Build a map of lowercase term → definition for O(1) lookup. */ export const buildAbbreviationsMap = (entries: AbbreviationEntry[]): Map => { const map = new Map(); entries.forEach(({ term, definition }) => { const t = term.trim(); - if (t) map.set(t, definition); + if (t) map.set(t.toLowerCase(), definition); }); return map; }; @@ -20,10 +20,10 @@ export const buildAbbreviationsMap = (entries: AbbreviationEntry[]): Map) // Sort longest first so "HTTP/2" matches before "HTTP". const terms = [...abbrMap.keys()].sort((a, b) => b.length - a.length); const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const pattern = new RegExp(`\\b(${escaped.join('|')})\\b`, 'g'); + const pattern = new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'); const segments: TextSegment[] = []; let lastIndex = 0; @@ -49,7 +49,7 @@ export const splitByAbbreviations = (text: string, abbrMap: Map) if (match.index > lastIndex) { segments.push({ text: text.slice(lastIndex, match.index) }); } - segments.push({ text: match[0], termKey: match[0] }); + segments.push({ text: match[0], termKey: match[0].toLowerCase() }); lastIndex = match.index + match[0].length; } From bf9862f4157866964bac44a720ce183658695721 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 02:46:48 -0400 Subject: [PATCH 4/8] feat: inherit space abbreviations in rooms, add abbreviations to space settings --- .../abbreviations/RoomAbbreviations.tsx | 84 +++++++++++++++++-- src/app/features/room/Room.tsx | 6 +- .../features/space-settings/SpaceSettings.tsx | 9 ++ src/app/hooks/useRoomAbbreviations.ts | 23 +++++ src/app/state/spaceSettings.ts | 1 + 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index a9e7386f4..08761c437 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -1,5 +1,17 @@ import { FormEventHandler, useCallback } from 'react'; -import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text, config } from 'folds'; +import { + Box, + Button, + Chip, + Icon, + IconButton, + Icons, + Input, + Scroll, + Spinner, + Text, + config, +} from 'folds'; import { Page, PageContent, PageHeader } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -9,6 +21,8 @@ import { usePowerLevels } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useStateEvent } from '$hooks/useStateEvent'; +import { useSpaceOptionally } from '$hooks/useSpace'; +import { useRoomName } from '$hooks/useRoomMeta'; import { StateEvent } from '$types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { MatrixError } from '$types/matrix-sdk'; @@ -31,6 +45,17 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { const content = stateEvent?.getContent(); const entries: AbbreviationEntry[] = Array.isArray(content?.entries) ? content.entries : []; + // Parent space abbreviations (read-only, inherited) + const parentSpace = useSpaceOptionally(); + const parentSpaceName = useRoomName(parentSpace ?? room); + const spaceStateEvent = useStateEvent(parentSpace ?? room, StateEvent.RoomAbbreviations); + const spaceContent = parentSpace + ? spaceStateEvent?.getContent() + : undefined; + const spaceEntries: AbbreviationEntry[] = Array.isArray(spaceContent?.entries) + ? spaceContent.entries + : []; + const canEdit = permissions.stateEvent(StateEvent.RoomAbbreviations, userId); const [saveState, saveAbbreviations] = useAsyncCallback( @@ -56,7 +81,9 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { const definition = definitionInput.value.trim(); if (!term || !definition) return; - const alreadyExists = entries.some((e) => e.term.toLowerCase() === term.toLowerCase()); + const alreadyExists = + entries.some((e) => e.term.toLowerCase() === term.toLowerCase()) || + spaceEntries.some((e) => e.term.toLowerCase() === term.toLowerCase()); if (alreadyExists) { termInput.setCustomValidity('This term already exists.'); termInput.reportValidity(); @@ -163,11 +190,58 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { )} + {parentSpace && ( + + + {spaceEntries.length > 0 + ? `Inherited from Space (${spaceEntries.length})` + : 'Inherited from Space'} + + {spaceEntries.length === 0 ? ( + + + No abbreviations defined in {parentSpaceName}. + + + ) : ( + spaceEntries.map((entry, index) => ( + + + + + {entry.term} + + + Space + + + + {entry.definition} + + + + )) + )} + + )} + {entries.length > 0 - ? `Defined Abbreviations (${entries.length})` - : 'Defined Abbreviations'} + ? `Room Abbreviations (${entries.length})` + : 'Room Abbreviations'} {entries.length === 0 ? ( - No abbreviations defined yet. + No room-level abbreviations defined yet. {canEdit && ' Use the form above to add some.'} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index b7fca58e0..1f7593ef8 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -18,7 +18,8 @@ import { callChatAtom } from '$state/callEmbed'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { createDebugLogger } from '$utils/debugLogger'; -import { useRoomAbbreviations, RoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; +import { useMergedAbbreviations, RoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; +import { useSpaceOptionally } from '$hooks/useSpace'; import { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; @@ -98,7 +99,8 @@ export function Room() { ); const callView = room.isCallRoom(); - const abbreviations = useRoomAbbreviations(room); + const parentSpace = useSpaceOptionally(); + const abbreviations = useMergedAbbreviations(room, parentSpace); // Log call view state useEffect(() => { diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index ddd1d5c74..87eb2a9c2 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -16,6 +16,7 @@ import { EmojisStickers } from '$features/common-settings/emojis-stickers'; import { Members } from '$features/common-settings/members'; import { DeveloperTools } from '$features/common-settings/developer-tools'; import { Cosmetics } from '$features/common-settings/cosmetics/Cosmetics'; +import { RoomAbbreviations } from '$features/room-settings/abbreviations/RoomAbbreviations'; import { General } from './general'; import { Permissions } from './permissions'; @@ -50,6 +51,11 @@ const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] => icon: Icons.Alphabet, activeIcon: Icons.AlphabetUnderline, }, + { + page: SpaceSettingsPage.AbbreviationsPage, + name: 'Abbreviations', + icon: Icons.Info, + }, { page: SpaceSettingsPage.EmojisStickersPage, name: 'Emojis & Stickers', @@ -187,6 +193,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {activePage === SpaceSettingsPage.DeveloperToolsPage && ( )} + {activePage === SpaceSettingsPage.AbbreviationsPage && ( + + )} ); } diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts index c961cc3ac..897e42b73 100644 --- a/src/app/hooks/useRoomAbbreviations.ts +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -18,3 +18,26 @@ export const useRoomAbbreviations = (room: Room): Map => { if (!Array.isArray(content?.entries) || content.entries.length === 0) return EMPTY_MAP; return buildAbbreviationsMap(content.entries); }; + +/** + * Return a merged map of abbreviations from the parent space (if any) and the room. + * Room-level entries override space-level entries for the same term (case-insensitive). + * Pass `space` as the parent space Room, or null if there is none. + * Both hooks are always called unconditionally to satisfy the Rules of Hooks. + */ +export const useMergedAbbreviations = (room: Room, space: Room | null): Map => { + // Always call with a valid Room — use `room` as a harmless fallback when space is null. + const spaceStateEvent = useStateEvent(space ?? room, StateEvent.RoomAbbreviations); + const roomStateEvent = useStateEvent(room, StateEvent.RoomAbbreviations); + + const spaceContent = space ? spaceStateEvent?.getContent() : undefined; + const roomContent = roomStateEvent?.getContent(); + + const spaceEntries = Array.isArray(spaceContent?.entries) ? spaceContent.entries : []; + const roomEntries = Array.isArray(roomContent?.entries) ? roomContent.entries : []; + + if (spaceEntries.length === 0 && roomEntries.length === 0) return EMPTY_MAP; + + // Space entries first; room entries are appended so they override duplicates. + return buildAbbreviationsMap([...spaceEntries, ...roomEntries]); +}; diff --git a/src/app/state/spaceSettings.ts b/src/app/state/spaceSettings.ts index 28464812a..c33b5e957 100644 --- a/src/app/state/spaceSettings.ts +++ b/src/app/state/spaceSettings.ts @@ -8,6 +8,7 @@ export enum SpaceSettingsPage { DeveloperToolsPage, // Sable pages CosmeticsPage, + AbbreviationsPage, } export type SpaceSettingsState = { From 403bd5197dd51657d35f850b233b0c5be9b89715 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 08:29:32 -0400 Subject: [PATCH 5/8] fix: abbr dotted underline + space settings label fixes - Add textDecoration: 'underline dotted' and cursor: 'help' to elements in AbbreviationTerm so users can see the hover indicator - RoomAbbreviations: add isSpace prop; when true, show 'Space Abbreviations' / 'space-level' labels and hide the inherited-from-space section - SpaceSettings: pass isSpace to RoomAbbreviations --- src/app/components/message/RenderBody.tsx | 6 +++++- .../room-settings/abbreviations/RoomAbbreviations.tsx | 11 ++++++----- src/app/features/space-settings/SpaceSettings.tsx | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index bf86ffcbf..679ea7422 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -41,7 +41,11 @@ function AbbreviationTerm({ text, definition }: AbbreviationTermProps) { {(triggerRef) => ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions - } onClick={handleClick}> + } + onClick={handleClick} + style={{ textDecoration: 'underline dotted', cursor: 'help' }} + > {text} )} diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index 08761c437..cedaf74aa 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -31,9 +31,10 @@ import { SequenceCardStyle } from '$features/common-settings/styles.css'; type AbbreviationsProps = { requestClose: () => void; + isSpace?: boolean; }; -export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { +export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) { const room = useRoom(); const mx = useMatrixClient(); const powerLevels = usePowerLevels(room); @@ -190,7 +191,7 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { )} - {parentSpace && ( + {!isSpace && parentSpace && ( {spaceEntries.length > 0 @@ -240,8 +241,8 @@ export function RoomAbbreviations({ requestClose }: AbbreviationsProps) { {entries.length > 0 - ? `Room Abbreviations (${entries.length})` - : 'Room Abbreviations'} + ? `${isSpace ? 'Space' : 'Room'} Abbreviations (${entries.length})` + : `${isSpace ? 'Space' : 'Room'} Abbreviations`} {entries.length === 0 ? ( - No room-level abbreviations defined yet. + No {isSpace ? 'space' : 'room'}-level abbreviations defined yet. {canEdit && ' Use the form above to add some.'} diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index 87eb2a9c2..586fba3f2 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -194,7 +194,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) )} {activePage === SpaceSettingsPage.AbbreviationsPage && ( - + )} ); From b8ae2e830f0fdcb071012dac0588c771e86e5ad0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 08:36:57 -0400 Subject: [PATCH 6/8] feat: multi-level space abbreviation inheritance + flat settings list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMergedAbbreviations: drop space param; use getAllParents+roomToParentsAtom to traverse the full ancestor chain and subscribe to state events at every level (not just direct parent) via useStateEventCallback on the mx client - Room.tsx: drop parentSpace/useSpaceOptionally — hook handles it internally - RoomAbbreviations settings: replace two-section layout with a single flat list; room entries shown without badge, inherited entries show a 'Space - ' chip badge; supports arbitrary nesting depth (mirrors the useImagePackRooms multi-level pattern) --- .../abbreviations/RoomAbbreviations.tsx | 248 ++++++++++-------- src/app/features/room/Room.tsx | 4 +- src/app/hooks/useRoomAbbreviations.ts | 68 +++-- 3 files changed, 190 insertions(+), 130 deletions(-) diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index cedaf74aa..c406cc6f3 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -1,4 +1,5 @@ -import { FormEventHandler, useCallback } from 'react'; +import { FormEventHandler, useCallback, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { Box, Button, @@ -21,12 +22,14 @@ import { usePowerLevels } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useStateEvent } from '$hooks/useStateEvent'; -import { useSpaceOptionally } from '$hooks/useSpace'; -import { useRoomName } from '$hooks/useRoomMeta'; +import { useStateEventCallback } from '$hooks/useStateEventCallback'; +import { useForceUpdate } from '$hooks/useForceUpdate'; import { StateEvent } from '$types/matrix/room'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { MatrixError } from '$types/matrix-sdk'; import { AbbreviationEntry, RoomAbbreviationsContent } from '$utils/abbreviations'; +import { getAllParents, getStateEvent } from '$utils/room'; +import { roomToParentsAtom } from '$state/room/roomToParents'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; type AbbreviationsProps = { @@ -46,16 +49,48 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) const content = stateEvent?.getContent(); const entries: AbbreviationEntry[] = Array.isArray(content?.entries) ? content.entries : []; - // Parent space abbreviations (read-only, inherited) - const parentSpace = useSpaceOptionally(); - const parentSpaceName = useRoomName(parentSpace ?? room); - const spaceStateEvent = useStateEvent(parentSpace ?? room, StateEvent.RoomAbbreviations); - const spaceContent = parentSpace - ? spaceStateEvent?.getContent() - : undefined; - const spaceEntries: AbbreviationEntry[] = Array.isArray(spaceContent?.entries) - ? spaceContent.entries - : []; + // Ancestor space abbreviations (read-only, inherited) — full multi-level support + const roomToParents = useAtomValue(roomToParentsAtom); + const [ancestorUpdateCount, forceAncestorUpdate] = useForceUpdate(); + + useStateEventCallback( + mx, + useCallback( + (event) => { + if (event.getType() !== StateEvent.RoomAbbreviations) return; + const eventRoomId = event.getRoomId(); + if (eventRoomId && getAllParents(roomToParents, room.roomId).has(eventRoomId)) { + forceAncestorUpdate(); + } + }, + [room.roomId, roomToParents, forceAncestorUpdate] + ) + ); + + type SpaceEntryGroup = { spaceId: string; spaceName: string; entries: AbbreviationEntry[] }; + const ancestorGroups = useMemo( + (): SpaceEntryGroup[] => + Array.from(getAllParents(roomToParents, room.roomId)).reduce( + (groups, parentId) => { + const parentRoom = mx.getRoom(parentId); + if (!parentRoom) return groups; + const ev = getStateEvent(parentRoom, StateEvent.RoomAbbreviations); + const c = ev?.getContent(); + const parentEntries: AbbreviationEntry[] = Array.isArray(c?.entries) ? c.entries : []; + if (parentEntries.length > 0) { + groups.push({ spaceId: parentId, spaceName: parentRoom.name, entries: parentEntries }); + } + return groups; + }, + [] + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [mx, roomToParents, room.roomId, ancestorUpdateCount] + ); + const allAncestorEntries = useMemo( + () => ancestorGroups.flatMap((g) => g.entries), + [ancestorGroups] + ); const canEdit = permissions.stateEvent(StateEvent.RoomAbbreviations, userId); @@ -84,7 +119,7 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) const alreadyExists = entries.some((e) => e.term.toLowerCase() === term.toLowerCase()) || - spaceEntries.some((e) => e.term.toLowerCase() === term.toLowerCase()); + allAncestorEntries.some((e) => e.term.toLowerCase() === term.toLowerCase()); if (alreadyExists) { termInput.setCustomValidity('This term already exists.'); termInput.reportValidity(); @@ -191,106 +226,97 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) )} - {!isSpace && parentSpace && ( - - - {spaceEntries.length > 0 - ? `Inherited from Space (${spaceEntries.length})` - : 'Inherited from Space'} - - {spaceEntries.length === 0 ? ( - - - No abbreviations defined in {parentSpaceName}. + + {(() => { + const totalCount = entries.length + (isSpace ? 0 : allAncestorEntries.length); + const label = isSpace ? 'Space' : 'Room'; + return ( + <> + + {totalCount > 0 + ? `${label} Abbreviations (${totalCount})` + : `${label} Abbreviations`} - - ) : ( - spaceEntries.map((entry, index) => ( - - - - - {entry.term} - - - Space - - - - {entry.definition} + {totalCount === 0 ? ( + + + No {isSpace ? 'space' : 'room'}-level abbreviations defined yet. + {canEdit && ' Use the form above to add some.'} - - - )) - )} - - )} - - - - {entries.length > 0 - ? `${isSpace ? 'Space' : 'Room'} Abbreviations (${entries.length})` - : `${isSpace ? 'Space' : 'Room'} Abbreviations`} - - {entries.length === 0 ? ( - - - No {isSpace ? 'space' : 'room'}-level abbreviations defined yet. - {canEdit && ' Use the form above to add some.'} - - - ) : ( - entries.map((entry, index) => ( - - - - {entry.term} - - - {entry.definition} - - - {canEdit && ( - - handleRemove(index)} - variant="Background" - size="300" - radii="300" - disabled={saving} - aria-label={`Remove abbreviation ${entry.term}`} - > - - - + + ) : ( + <> + {entries.map((entry, index) => ( + + + + {entry.term} + + + {entry.definition} + + + {canEdit && ( + + handleRemove(index)} + variant="Background" + size="300" + radii="300" + disabled={saving} + aria-label={`Remove abbreviation ${entry.term}`} + > + + + + )} + + ))} + {!isSpace && + ancestorGroups.flatMap(({ spaceId, spaceName, entries: spEntries }) => + spEntries.map((entry, index) => ( + + + + + {entry.term} + + + Space - {spaceName} + + + + {entry.definition} + + + + )) + )} + )} - - )) - )} + + ); + })()} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 1f7593ef8..918f978a9 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -19,7 +19,6 @@ import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { createDebugLogger } from '$utils/debugLogger'; import { useMergedAbbreviations, RoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; -import { useSpaceOptionally } from '$hooks/useSpace'; import { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; @@ -99,8 +98,7 @@ export function Room() { ); const callView = room.isCallRoom(); - const parentSpace = useSpaceOptionally(); - const abbreviations = useMergedAbbreviations(room, parentSpace); + const abbreviations = useMergedAbbreviations(room); // Log call view state useEffect(() => { diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts index 897e42b73..e2641b341 100644 --- a/src/app/hooks/useRoomAbbreviations.ts +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -1,8 +1,14 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, useCallback, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { Room } from '$types/matrix-sdk'; import { StateEvent } from '$types/matrix/room'; import { buildAbbreviationsMap, RoomAbbreviationsContent } from '$utils/abbreviations'; +import { getAllParents, getStateEvent } from '$utils/room'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { useMatrixClient } from './useMatrixClient'; import { useStateEvent } from './useStateEvent'; +import { useStateEventCallback } from './useStateEventCallback'; +import { useForceUpdate } from './useForceUpdate'; const EMPTY_MAP: Map = new Map(); @@ -20,24 +26,54 @@ export const useRoomAbbreviations = (room: Room): Map => { }; /** - * Return a merged map of abbreviations from the parent space (if any) and the room. - * Room-level entries override space-level entries for the same term (case-insensitive). - * Pass `space` as the parent space Room, or null if there is none. - * Both hooks are always called unconditionally to satisfy the Rules of Hooks. + * Return a merged map of abbreviations from ALL ancestor spaces and the room. + * Nearest ancestor entries override farther ancestor entries; room entries override everything. + * Subscribes to abbreviation state changes across the full space hierarchy. */ -export const useMergedAbbreviations = (room: Room, space: Room | null): Map => { - // Always call with a valid Room — use `room` as a harmless fallback when space is null. - const spaceStateEvent = useStateEvent(space ?? room, StateEvent.RoomAbbreviations); - const roomStateEvent = useStateEvent(room, StateEvent.RoomAbbreviations); +export const useMergedAbbreviations = (room: Room): Map => { + const mx = useMatrixClient(); + const roomToParents = useAtomValue(roomToParentsAtom); + const [updateCount, forceUpdate] = useForceUpdate(); - const spaceContent = space ? spaceStateEvent?.getContent() : undefined; - const roomContent = roomStateEvent?.getContent(); + useStateEventCallback( + mx, + useCallback( + (event) => { + if (event.getType() !== StateEvent.RoomAbbreviations) return; + const eventRoomId = event.getRoomId(); + if (!eventRoomId) return; + if ( + eventRoomId === room.roomId || + getAllParents(roomToParents, room.roomId).has(eventRoomId) + ) { + forceUpdate(); + } + }, + [room.roomId, roomToParents, forceUpdate] + ) + ); - const spaceEntries = Array.isArray(spaceContent?.entries) ? spaceContent.entries : []; - const roomEntries = Array.isArray(roomContent?.entries) ? roomContent.entries : []; + return useMemo(() => { + const allParentIds = Array.from(getAllParents(roomToParents, room.roomId)); + const ancestorEntries = allParentIds.flatMap((parentId) => { + const parentRoom = mx.getRoom(parentId); + if (!parentRoom) return []; + const content = getStateEvent( + parentRoom, + StateEvent.RoomAbbreviations + )?.getContent(); + return Array.isArray(content?.entries) ? content.entries : []; + }); - if (spaceEntries.length === 0 && roomEntries.length === 0) return EMPTY_MAP; + const roomContent = getStateEvent( + room, + StateEvent.RoomAbbreviations + )?.getContent(); + const roomEntries = Array.isArray(roomContent?.entries) ? roomContent.entries : []; - // Space entries first; room entries are appended so they override duplicates. - return buildAbbreviationsMap([...spaceEntries, ...roomEntries]); + if (ancestorEntries.length === 0 && roomEntries.length === 0) return EMPTY_MAP; + // Ancestor entries first; room entries appended so they override duplicates. + return buildAbbreviationsMap([...ancestorEntries, ...roomEntries]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mx, roomToParents, room, updateCount]); }; From 98053e1eb12adab2d2aa552a36d049a2757ab3f6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 08:40:10 -0400 Subject: [PATCH 7/8] fix: correct Chip size prop and add changeset heading --- .changeset/feat-room-abbreviations.md | 2 ++ .../features/room-settings/abbreviations/RoomAbbreviations.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/feat-room-abbreviations.md b/.changeset/feat-room-abbreviations.md index c7f64998d..871035b1c 100644 --- a/.changeset/feat-room-abbreviations.md +++ b/.changeset/feat-room-abbreviations.md @@ -2,4 +2,6 @@ default: minor --- +# feat: room abbreviations with hover tooltips + Add room abbreviations: moderators can define a list of term/definition pairs in room settings; defined terms are highlighted with a hover tooltip in plain-text messages. diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index c406cc6f3..0655da35d 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -301,7 +301,7 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) {entry.term} - + Space - {spaceName} From 33f04eba0c68d53a7c4b41f4754079063a03a268 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 20 Mar 2026 08:42:45 -0400 Subject: [PATCH 8/8] fix: allow overriding inherited space abbreviations at room level --- .../room-settings/abbreviations/RoomAbbreviations.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index 0655da35d..cb4130bc6 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -117,9 +117,7 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) const definition = definitionInput.value.trim(); if (!term || !definition) return; - const alreadyExists = - entries.some((e) => e.term.toLowerCase() === term.toLowerCase()) || - allAncestorEntries.some((e) => e.term.toLowerCase() === term.toLowerCase()); + const alreadyExists = entries.some((e) => e.term.toLowerCase() === term.toLowerCase()); if (alreadyExists) { termInput.setCustomValidity('This term already exists.'); termInput.reportValidity();