diff --git a/.changeset/feat-room-abbreviations.md b/.changeset/feat-room-abbreviations.md new file mode 100644 index 000000000..871035b1c --- /dev/null +++ b/.changeset/feat-room-abbreviations.md @@ -0,0 +1,7 @@ +--- +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/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index 03714750f..679ea7422 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -1,10 +1,64 @@ +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(); + + 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} + style={{ textDecoration: 'underline dotted', cursor: 'help' }} + > + {text} + + )} + + {anchor && ( + + {null} + + )} + + ); +} + type RenderBodyProps = { body: string; customBody?: string; @@ -20,12 +74,41 @@ 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 ( + // eslint-disable-next-line react/no-array-index-key + + ); + } + 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..1dedfccf2 --- /dev/null +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -0,0 +1,328 @@ +import { FormEventHandler, useCallback, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +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'; +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 { 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 = { + requestClose: () => void; + isSpace?: boolean; +}; + +export function RoomAbbreviations({ requestClose, isSpace }: 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 : []; + + // 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); + + 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()) || + allAncestorEntries.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 + e.currentTarget.setCustomValidity('')} + /> + + + Definition + + + {saveState.status === AsyncStatus.Error && ( + + {saveState.error.message} + + )} + + + + + + + + )} + + + {(() => { + const totalCount = entries.length + (isSpace ? 0 : allAncestorEntries.length); + const label = isSpace ? 'Space' : 'Room'; + return ( + <> + + {totalCount > 0 + ? `${label} Abbreviations (${totalCount})` + : `${label} Abbreviations`} + + {totalCount === 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}`} + > + + + + )} + + ))} + {!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 2a067d4bf..918f978a9 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 { useMergedAbbreviations, 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 = useMergedAbbreviations(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/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index ddd1d5c74..586fba3f2 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 new file mode 100644 index 000000000..e2641b341 --- /dev/null +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -0,0 +1,79 @@ +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(); + +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); +}; + +/** + * 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): Map => { + const mx = useMatrixClient(); + const roomToParents = useAtomValue(roomToParentsAtom); + const [updateCount, forceUpdate] = useForceUpdate(); + + 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] + ) + ); + + 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 : []; + }); + + const roomContent = getStateEvent( + room, + StateEvent.RoomAbbreviations + )?.getContent(); + const roomEntries = Array.isArray(roomContent?.entries) ? roomContent.entries : []; + + 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]); +}; 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/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 = { diff --git a/src/app/utils/abbreviations.test.ts b/src/app/utils/abbreviations.test.ts new file mode 100644 index 000000000..7618afa4b --- /dev/null +++ b/src/app/utils/abbreviations.test.ts @@ -0,0 +1,155 @@ +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 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(); + }); + + 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('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(1); + 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('matches case-insensitively (termKey is always lowercase)', () => { + expect(splitByAbbreviations('I like foss and Foss', map)).toEqual([ + { text: 'I like ' }, + { text: 'foss', termKey: 'foss' }, + { text: ' and ' }, + { text: 'Foss', termKey: '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 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 {