From e43f383b4a8d644a3269bf6fd019a945c27b7c4b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:38:08 +0000 Subject: [PATCH 01/15] Update dependency linkifyjs to v4.3.2 [SECURITY] --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70826ae7..fa8ab325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "is-hotkey": "0.2.0", "jotai": "2.6.0", "linkify-react": "4.1.3", - "linkifyjs": "4.1.3", + "linkifyjs": "4.3.2", "matrix-js-sdk": "37.5.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", @@ -8657,9 +8657,10 @@ } }, "node_modules/linkifyjs": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz", - "integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==" + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", diff --git a/package.json b/package.json index f1816cdd..a0f9540c 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "is-hotkey": "0.2.0", "jotai": "2.6.0", "linkify-react": "4.1.3", - "linkifyjs": "4.1.3", + "linkifyjs": "4.3.2", "matrix-js-sdk": "37.5.0", "millify": "6.1.0", "pdfjs-dist": "4.2.67", From 09b88d164fabe13440a74dfb3137ba0e57e4e7f1 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:08:46 +0530 Subject: [PATCH 02/15] Fix message button opens left dm room (#2453) --- src/app/utils/matrix.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index b4e2e6b8..a8031202 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -17,7 +17,7 @@ import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { getStateEvent } from './room'; -import { StateEvent } from '../../types/matrix/room'; +import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -182,7 +182,12 @@ export const eventWithShortcode = (ev: MatrixEvent) => export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => { const dmLikeRooms = mx .getRooms() - .filter((room) => room.hasEncryptionStateEvent() && room.getMembers().length <= 2); + .filter( + (room) => + room.getMyMembership() === Membership.Join && + room.hasEncryptionStateEvent() && + room.getMembers().length <= 2 + ); return dmLikeRooms.find((room) => room.getMember(userId)); }; From 78a0d11f24cc1b7e37930fd3dc53ea54ac1fee53 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:09:31 +0530 Subject: [PATCH 03/15] New add existing room/space modal (#2451) --- .../create-room/AdditionalCreatorInput.tsx | 18 +- src/app/features/add-existing/AddExisting.tsx | 375 ++++++++++++++++++ src/app/features/add-existing/index.ts | 1 + .../features/create-room/CreateRoomModal.tsx | 1 - src/app/features/lobby/SpaceItem.tsx | 14 +- 5 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 src/app/features/add-existing/AddExisting.tsx create mode 100644 src/app/features/add-existing/index.ts diff --git a/src/app/components/create-room/AdditionalCreatorInput.tsx b/src/app/components/create-room/AdditionalCreatorInput.tsx index 51334b49..936b9b94 100644 --- a/src/app/components/create-room/AdditionalCreatorInput.tsx +++ b/src/app/components/create-room/AdditionalCreatorInput.tsx @@ -30,9 +30,7 @@ import { SettingTile } from '../setting-tile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { stopPropagation } from '../../utils/keyboard'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; -import { findAndReplace } from '../../utils/findAndReplace'; -import { highlightText } from '../../styles/CustomHtml.css'; -import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; +import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; export const useAdditionalCreators = (defaultCreators?: string[]) => { const mx = useMatrixClient(); @@ -245,19 +243,9 @@ export function AdditionalCreatorInput({ {queryHighlighRegex - ? findAndReplace( + ? highlightText(queryHighlighRegex, [ getMxIdLocalPart(userId) ?? userId, - queryHighlighRegex, - (match, pushIndex) => ( - - {match[0]} - - ), - (txt) => txt - ) + ]) : getMxIdLocalPart(userId)} diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx new file mode 100644 index 00000000..cbae018f --- /dev/null +++ b/src/app/features/add-existing/AddExisting.tsx @@ -0,0 +1,375 @@ +import FocusTrap from 'focus-trap-react'; +import { + Avatar, + Box, + Button, + config, + Header, + Icon, + IconButton, + Icons, + Input, + Menu, + MenuItem, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Spinner, + Text, +} from 'folds'; +import React, { + ChangeEventHandler, + MouseEventHandler, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { useAtomValue } from 'jotai'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Room } from 'matrix-js-sdk'; +import { stopPropagation } from '../../utils/keyboard'; +import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { VirtualTile } from '../../components/virtualizer'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; +import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; +import { nameInitials } from '../../utils/common'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { factoryRoomIdByAtoZ } from '../../utils/sort'; +import { + SearchItemStrGetter, + useAsyncSearch, + UseAsyncSearchOptions, +} from '../../hooks/useAsyncSearch'; +import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { StateEvent } from '../../../types/matrix/room'; +import { getViaServers } from '../../plugins/via-servers'; +import { rateLimitedActions } from '../../utils/matrix'; +import { useAlive } from '../../hooks/useAlive'; + +const SEARCH_OPTS: UseAsyncSearchOptions = { + limit: 500, + matchOptions: { + contain: true, + }, + normalizeOptions: { + ignoreWhitespace: false, + }, +}; + +type AddExistingModalProps = { + parentId: string; + space?: boolean; + requestClose: () => void; +}; +export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const alive = useAlive(); + + const mDirects = useAtomValue(mDirectAtom); + const spaces = useSpaces(mx, allRoomsAtom); + const rooms = useRooms(mx, allRoomsAtom, mDirects); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const roomIdToParents = useAtomValue(roomToParentsAtom); + const scrollRef = useRef(null); + + const [selected, setSelected] = useState([]); + + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const allItems: string[] = useMemo(() => { + const rIds = space ? [...spaces] : [...rooms, ...directs]; + + return rIds + .filter((rId) => rId !== parentId && !roomIdToParents.get(rId)?.has(parentId)) + .sort(factoryRoomIdByAtoZ(mx)); + }, [spaces, rooms, directs, space, parentId, roomIdToParents, mx]); + + const getRoomNameStr: SearchItemStrGetter = useCallback( + (rId) => getRoom(rId)?.name ?? rId, + [getRoom] + ); + + const [searchResult, searchRoom, resetSearch] = useAsyncSearch( + allItems, + getRoomNameStr, + SEARCH_OPTS + ); + const queryHighlighRegex = searchResult?.query + ? makeHighlightRegex(searchResult.query.split(' ')) + : undefined; + + const items = searchResult ? searchResult.items : allItems; + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 32, + overscan: 5, + }); + const vItems = virtualizer.getVirtualItems(); + + const handleSearchChange: ChangeEventHandler = (evt) => { + const value = evt.currentTarget.value.trim(); + if (!value) { + resetSearch(); + return; + } + searchRoom(value); + }; + + const [applyState, applyChanges] = useAsyncCallback( + useCallback( + async (selectedRooms) => { + await rateLimitedActions(selectedRooms, async (room) => { + const via = getViaServers(room); + + await mx.sendStateEvent( + parentId, + StateEvent.SpaceChild as any, + { + auto_join: false, + suggested: false, + via, + }, + room.roomId + ); + }); + }, + [mx, parentId] + ) + ); + const applyingChanges = applyState.status === AsyncStatus.Loading; + + const handleRoomClick: MouseEventHandler = (evt) => { + const roomId = evt.currentTarget.getAttribute('data-room-id'); + if (!roomId) return; + if (selected?.includes(roomId)) { + setSelected(selected?.filter((rId) => rId !== roomId)); + return; + } + const addedRooms = [...(selected ?? [])]; + addedRooms.push(roomId); + setSelected(addedRooms); + }; + + const handleApplyChanges = () => { + const selectedRooms = selected.map((rId) => getRoom(rId)).filter((room) => room !== undefined); + applyChanges(selectedRooms).then(() => { + if (alive()) { + setSelected([]); + requestClose(); + } + }); + }; + + const resetChanges = () => { + setSelected([]); + }; + + return ( + }> + + + + +
+ + Add Existing + + + + + + +
+ + + + + } + placeholder="Search" + size="400" + variant="Background" + outlined + /> + + {vItems.length === 0 && ( + + + {searchResult ? 'No Match Found' : `No ${space ? 'Spaces' : 'Rooms'}`} + + + {searchResult + ? `No match found for "${searchResult.query}".` + : `You do not have any ${space ? 'Spaces' : 'Rooms'} to display yet.`} + + + )} + + {vItems.map((vItem) => { + const roomId = items[vItem.index]; + const room = getRoom(roomId); + if (!room) return null; + const selectedItem = selected?.includes(roomId); + const dm = mDirects.has(room.roomId); + + return ( + + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + after={selectedItem && } + > + + + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [room.name]) + : room.name} + + + + + ); + })} + + {selected.length > 0 && ( + + + + {applyState.status === AsyncStatus.Error ? ( + + Failed to apply changes! Please try again. + + ) : ( + + Apply when ready. ({selected.length} Selected) + + )} + + + + + + + + )} + + + +
+
+
+
+
+ ); +} diff --git a/src/app/features/add-existing/index.ts b/src/app/features/add-existing/index.ts new file mode 100644 index 00000000..3607c062 --- /dev/null +++ b/src/app/features/add-existing/index.ts @@ -0,0 +1 @@ +export * from './AddExisting'; diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index c1c9ba3e..c9919ba9 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -54,7 +54,6 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { style={{ padding: config.space.S200, paddingLeft: config.space.S400, - borderBottomWidth: config.borderWidth.B300, }} > diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index e881a971..64a97900 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -30,12 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import * as css from './SpaceItem.css'; import * as styleCss from './style.css'; import { useDraggableItem } from './DnD'; -import { openSpaceAddExisting } from '../../../client/action/navigation'; import { stopPropagation } from '../../utils/keyboard'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal'; import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; +import { AddExistingModal } from '../add-existing'; function SpaceProfileLoading() { return ( @@ -243,6 +243,7 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP function AddRoomButton({ item }: { item: HierarchyItem }) { const [cords, setCords] = useState(); const openCreateRoomModal = useOpenCreateRoomModal(); + const [addExisting, setAddExisting] = useState(false); const handleAddRoom: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); @@ -254,7 +255,7 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { }; const handleAddExisting = () => { - openSpaceAddExisting(item.roomId); + setAddExisting(true); setCords(undefined); }; @@ -300,6 +301,9 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { > Add Room + {addExisting && ( + setAddExisting(false)} /> + )} ); } @@ -307,6 +311,7 @@ function AddRoomButton({ item }: { item: HierarchyItem }) { function AddSpaceButton({ item }: { item: HierarchyItem }) { const [cords, setCords] = useState(); const openCreateSpaceModal = useOpenCreateSpaceModal(); + const [addExisting, setAddExisting] = useState(false); const handleAddSpace: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); @@ -318,7 +323,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { }; const handleAddExisting = () => { - openSpaceAddExisting(item.roomId, true); + setAddExisting(true); setCords(undefined); }; return ( @@ -363,6 +368,9 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { > Add Space + {addExisting && ( + setAddExisting(false)} /> + )} ); } From c881b5995725246b4bba2b522b21c3b3abba8b7d Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 24 Aug 2025 18:03:20 +0530 Subject: [PATCH 04/15] =?UTF-8?q?Fix=20image=20overlap=20with=20=E2=80=9CM?= =?UTF-8?q?ark=20as=20read=E2=80=9D=20and=20typing=20indicator=20(#2457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/message/content/style.css.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index 93f3649c..bb5d8484 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -16,7 +16,6 @@ export const AbsoluteContainer = style([ position: 'absolute', top: 0, left: 0, - zIndex: 1, width: '100%', height: '100%', }, @@ -26,6 +25,7 @@ export const AbsoluteFooter = style([ DefaultReset, { position: 'absolute', + pointerEvents: 'none', bottom: config.space.S100, left: config.space.S100, right: config.space.S100, From 13cdcbcdb167cdf4f8bb124f922a078f36ebdad1 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 24 Aug 2025 18:04:21 +0530 Subject: [PATCH 05/15] New invite user to room dialog (#2460) * fix 0 displayed in invite with no timestamp * support displaying invite reason for receiver * show invite reason as compact message * remove unused import * revert: show invite reason as compact message * remove unused import * add new invite prompt --- .../invite-user-prompt/InviteUserPrompt.tsx | 291 ++++++++++++++++++ .../components/invite-user-prompt/index.ts | 1 + src/app/components/room-intro/RoomIntro.tsx | 16 +- src/app/features/lobby/HierarchyItemMenu.tsx | 45 ++- src/app/features/lobby/LobbyHeader.tsx | 17 +- src/app/features/room-nav/RoomNavItem.tsx | 17 +- src/app/features/room/RoomViewHeader.tsx | 17 +- src/app/pages/client/inbox/Invites.tsx | 52 ++-- src/app/pages/client/sidebar/SpaceTabs.tsx | 17 +- src/app/pages/client/space/Space.tsx | 17 +- 10 files changed, 434 insertions(+), 56 deletions(-) create mode 100644 src/app/components/invite-user-prompt/InviteUserPrompt.tsx create mode 100644 src/app/components/invite-user-prompt/index.ts diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx new file mode 100644 index 00000000..82313c3e --- /dev/null +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -0,0 +1,291 @@ +import React, { + ChangeEventHandler, + FormEventHandler, + KeyboardEventHandler, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { + Overlay, + OverlayBackdrop, + OverlayCenter, + Box, + Header, + config, + Text, + IconButton, + Icon, + Icons, + Input, + Button, + Spinner, + color, + TextArea, + Dialog, + Menu, + toRem, + Scroll, + MenuItem, +} from 'folds'; +import { Room } from 'matrix-js-sdk'; +import { isKeyHotkey } from 'is-hotkey'; +import FocusTrap from 'focus-trap-react'; +import { stopPropagation } from '../../utils/keyboard'; +import { useDirectUsers } from '../../hooks/useDirectUsers'; +import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix'; +import { Membership } from '../../../types/matrix/room'; +import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; +import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { BreakWord } from '../../styles/Text.css'; +import { useAlive } from '../../hooks/useAlive'; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 1000, + matchOptions: { + contain: true, + }, +}; +const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId; + +type InviteUserProps = { + room: Room; + requestClose: () => void; +}; +export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { + const mx = useMatrixClient(); + const alive = useAlive(); + + const inputRef = useRef(null); + const directUsers = useDirectUsers(); + const [validUserId, setValidUserId] = useState(); + + const filteredUsers = useMemo( + () => + directUsers.filter((userId) => { + const membership = room.getMember(userId)?.membership; + return membership !== Membership.Join; + }), + [directUsers, room] + ); + const [result, search, resetSearch] = useAsyncSearch( + filteredUsers, + getUserIdString, + SEARCH_OPTIONS + ); + const queryHighlighRegex = result?.query + ? makeHighlightRegex(result.query.split(' ')) + : undefined; + + const [inviteState, invite] = useAsyncCallback( + useCallback( + async (userId, reason) => { + await mx.invite(room.roomId, userId, reason); + }, + [mx, room] + ) + ); + + const inviting = inviteState.status === AsyncStatus.Loading; + + const handleReset = () => { + if (inputRef.current) inputRef.current.value = ''; + setValidUserId(undefined); + resetSearch(); + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + const target = evt.target as HTMLFormElement | undefined; + + if (inviting || !validUserId) return; + + const reasonInput = target?.reasonInput as HTMLTextAreaElement | undefined; + const reason = reasonInput?.value.trim(); + + invite(validUserId, reason || undefined).then(() => { + if (alive()) { + handleReset(); + if (reasonInput) reasonInput.value = ''; + } + }); + }; + + const handleSearchChange: ChangeEventHandler = (evt) => { + const value = evt.currentTarget.value.trim(); + if (isUserId(value)) { + setValidUserId(value); + } else { + setValidUserId(undefined); + const term = getMxIdLocalPart(value) ?? (value.startsWith('@') ? value.slice(1) : value); + if (term) { + search(term); + } else { + resetSearch(); + } + } + }; + + const handleUserId = (userId: string) => { + if (inputRef.current) { + inputRef.current.value = userId; + setValidUserId(userId); + resetSearch(); + inputRef.current.focus(); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + resetSearch(); + return; + } + if (isKeyHotkey('tab', evt) && result && result.items.length > 0) { + evt.preventDefault(); + const userId = result.items[0]; + handleUserId(userId); + } + }; + + return ( + }> + + inputRef.current, + clickOutsideDeactivates: true, + onDeactivate: requestClose, + escapeDeactivates: stopPropagation, + }} + > + + +
+ + + Invite + + + + + + + +
+ + + User ID +
+ + {result && result.items.length > 0 && ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + escapeDeactivates: stopPropagation, + }} + > + + + +
+ {result.items.map((userId) => { + const username = `${getMxIdLocalPart(userId)}`; + const userServer = getMxIdServer(userId); + + return ( + handleUserId(userId)} + after={ + + {userServer} + + } + disabled={inviting} + > + + + + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [ + username ?? userId, + ]) + : username} + + + + + ); + })} +
+
+
+
+
+ )} +
+
+ + Reason (Optional) +