diff --git a/res/css/components/views/messages/_MBeaconBody.scss b/res/css/components/views/messages/_MBeaconBody.scss index 5654f14a05..64d7908df0 100644 --- a/res/css/components/views/messages/_MBeaconBody.scss +++ b/res/css/components/views/messages/_MBeaconBody.scss @@ -17,7 +17,8 @@ limitations under the License. .mx_MBeaconBody { position: relative; height: 220px; - width: 325px; + max-width: 325px; + width: 100%; border-radius: $timeline-image-border-radius; overflow: hidden; diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx index 3ef2de7a72..8760376131 100644 --- a/src/components/views/beacon/OwnBeaconStatus.tsx +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -21,7 +21,7 @@ import { _t } from '../../../languageHandler'; import { useOwnLiveBeacons } from '../../../utils/beacon'; import BeaconStatus from './BeaconStatus'; import { BeaconDisplayStatus } from './displayStatus'; -import AccessibleButton from '../elements/AccessibleButton'; +import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; interface Props { displayStatus: BeaconDisplayStatus; @@ -45,6 +45,14 @@ const OwnBeaconStatus: React.FC> = ({ onResetLocationPublishError, } = useOwnLiveBeacons([beacon?.identifier]); + // eat events here to avoid 1) the map and 2) reply or thread tiles + // moving under the beacon status on stop/retry click + const preventDefaultWrapper = (callback: () => void) => (e?: ButtonEvent) => { + e?.stopPropagation(); + e?.preventDefault(); + callback(); + }; + // combine display status with errors that only occur for user's own beacons const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ? BeaconDisplayStatus.Error : @@ -60,7 +68,7 @@ const OwnBeaconStatus: React.FC> = ({ { ownDisplayStatus === BeaconDisplayStatus.Active && @@ -70,7 +78,7 @@ const OwnBeaconStatus: React.FC> = ({ { hasLocationPublishError && { _t('Retry') } @@ -79,7 +87,7 @@ const OwnBeaconStatus: React.FC> = ({ { hasStopSharingError && { _t('Retry') } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 7bd7fd719e..690d4d60bb 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -21,6 +21,7 @@ import { EventStatus, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk/src/mo import classNames from 'classnames'; import { MsgType, RelationType } from 'matrix-js-sdk/src/@types/event'; import { Thread } from 'matrix-js-sdk/src/models/thread'; +import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import type { Relations } from 'matrix-js-sdk/src/models/relations'; import { _t } from '../../../languageHandler'; @@ -329,8 +330,14 @@ export default class MessageActionBar extends React.PureComponent { export function canForward(event: MatrixEvent): boolean { return !( - M_POLL_START.matches(event.getType()) + M_POLL_START.matches(event.getType()) || + // disallow forwarding until psf-1044 + M_BEACON_INFO.matches(event.getType()) ); } diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index 3d624187b8..97b5c0c077 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -25,20 +25,28 @@ import { MsgType, Room, } from 'matrix-js-sdk/src/matrix'; +import { Thread } from 'matrix-js-sdk/src/models/thread'; import MessageActionBar from '../../../../src/components/views/messages/MessageActionBar'; import { getMockClientWithEventEmitter, mockClientMethodsUser, mockClientMethodsEvents, + makeBeaconInfoEvent, } from '../../../test-utils'; import { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks'; import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/RoomContext'; import { IRoomState } from '../../../../src/components/structures/RoomView'; import dispatcher from '../../../../src/dispatcher/dispatcher'; import SettingsStore from '../../../../src/settings/SettingsStore'; +import { Action } from '../../../../src/dispatcher/actions'; +import { UserTab } from '../../../../src/components/views/dialogs/UserTab'; +import { showThread } from '../../../../src/dispatcher/dispatch-actions/threads'; jest.mock('../../../../src/dispatcher/dispatcher'); +jest.mock('../../../../src/dispatcher/dispatch-actions/threads', () => ({ + showThread: jest.fn(), +})); describe('', () => { const userId = '@alice:server.org'; @@ -360,4 +368,100 @@ describe('', () => { it.todo('unsends event on cancel click'); it.todo('retrys event on retry click'); }); + + describe('thread button', () => { + beforeEach(() => { + Thread.setServerSideSupport(true, false); + }); + + describe('when threads feature is not enabled', () => { + it('does not render thread button when threads does not have server support', () => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + Thread.setServerSideSupport(false, false); + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText('Reply in thread')).toBeFalsy(); + }); + + it('renders thread button when threads has server support', () => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText('Reply in thread')).toBeTruthy(); + }); + + it('opens user settings on click', () => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + + act(() => { + fireEvent.click(getByLabelText('Reply in thread')); + }); + + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + }); + }); + + describe('when threads feature is enabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockImplementation(setting => setting === 'feature_thread'); + }); + + it('renders thread button on own actionable event', () => { + const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + expect(queryByLabelText('Reply in thread')).toBeTruthy(); + }); + + it('does not render thread button for a beacon_info event', () => { + const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId); + const { queryByLabelText } = getComponent({ mxEvent: beaconInfoEvent }); + expect(queryByLabelText('Reply in thread')).toBeFalsy(); + }); + + it('opens thread on click', () => { + const { getByLabelText } = getComponent({ mxEvent: alicesMessageEvent }); + + act(() => { + fireEvent.click(getByLabelText('Reply in thread')); + }); + + expect(showThread).toHaveBeenCalledWith({ + rootEvent: alicesMessageEvent, + push: false, + }); + }); + + it('opens parent thread for a thread reply message', () => { + const threadReplyEvent = new MatrixEvent({ + type: EventType.RoomMessage, + sender: userId, + room_id: roomId, + content: { + msgtype: MsgType.Text, + body: 'this is a thread reply', + }, + }); + // mock the thread stuff + jest.spyOn(threadReplyEvent, 'isThreadRelation', 'get').mockReturnValue(true); + // set alicesMessageEvent as the root event + jest.spyOn(threadReplyEvent, 'getThread').mockReturnValue( + { rootEvent: alicesMessageEvent } as unknown as Thread, + ); + const { getByLabelText } = getComponent({ mxEvent: threadReplyEvent }); + + act(() => { + fireEvent.click(getByLabelText('Reply in thread')); + }); + + expect(showThread).toHaveBeenCalledWith({ + rootEvent: alicesMessageEvent, + initialEvent: threadReplyEvent, + highlighted: true, + scroll_into_view: true, + push: false, + }); + }); + }); + }); }); diff --git a/test/utils/EventUtils-test.ts b/test/utils/EventUtils-test.ts index df65b7e5f3..49bc26b4ef 100644 --- a/test/utils/EventUtils-test.ts +++ b/test/utils/EventUtils-test.ts @@ -62,7 +62,11 @@ describe('EventUtils', () => { }); redactedEvent.makeRedacted(redactedEvent); - const stateEvent = makeBeaconInfoEvent(userId, roomId); + const stateEvent = new MatrixEvent({ + type: EventType.RoomTopic, + state_key: '', + }); + const beaconInfoEvent = makeBeaconInfoEvent(userId, roomId); const roomMemberEvent = new MatrixEvent({ type: EventType.RoomMember, @@ -155,6 +159,7 @@ describe('EventUtils', () => { ['poll start event', pollStartEvent], ['event with empty content body', emptyContentBody], ['event with a content body', niceTextMessage], + ['beacon_info event', beaconInfoEvent], ])('returns true for %s', (_description, event) => { expect(isContentActionable(event)).toBe(true); }); @@ -325,6 +330,10 @@ describe('EventUtils', () => { const event = makePollStartEvent('Who?', userId); expect(canForward(event)).toBe(false); }); + it('returns false for a beacon_info event', () => { + const event = makeBeaconInfoEvent(userId, roomId); + expect(canForward(event)).toBe(false); + }); it('returns true for a room message event', () => { const event = new MatrixEvent({ type: EventType.RoomMessage,