Initial cut of Pinned event card in the right panel
This commit is contained in:
parent
4fa6d3599b
commit
59f4c728c9
13 changed files with 483 additions and 152 deletions
145
src/components/views/right_panel/PinnedMessagesCard.tsx
Normal file
145
src/components/views/right_panel/PinnedMessagesCard.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseCard from "./BaseCard";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||
import PinnedEventTile from "../rooms/PinnedEventTile";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export const usePinnedEvents = (room: Room): string[] => {
|
||||
const [pinnedEvents, setPinnedEvents] = useState<string[]>([]);
|
||||
|
||||
const update = useCallback((ev?: MatrixEvent) => {
|
||||
if (!room) return;
|
||||
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
|
||||
setPinnedEvents(room.currentState.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent()?.pinned || []);
|
||||
}, [room]);
|
||||
|
||||
useEventEmitter(room.currentState, "RoomState.events", update);
|
||||
useEffect(() => {
|
||||
update();
|
||||
return () => {
|
||||
setPinnedEvents([]);
|
||||
};
|
||||
}, [update]);
|
||||
return pinnedEvents;
|
||||
};
|
||||
|
||||
const ReadPinsEventId = "im.vector.room.read_pins";
|
||||
const ReadPinsNumIds = 10;
|
||||
|
||||
export const useReadPinnedEvents = (room: Room): Set<string> => {
|
||||
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
||||
|
||||
const update = useCallback((ev?: MatrixEvent) => {
|
||||
if (!room) return;
|
||||
if (ev && ev.getType() !== ReadPinsEventId) return;
|
||||
const readPins = room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids;
|
||||
setReadPinnedEvents(new Set(readPins || []));
|
||||
}, [room]);
|
||||
|
||||
useEventEmitter(room, "Room.accountData", update);
|
||||
useEffect(() => {
|
||||
update();
|
||||
return () => {
|
||||
setReadPinnedEvents(new Set());
|
||||
};
|
||||
}, [update]);
|
||||
return readPinnedEvents;
|
||||
};
|
||||
|
||||
const PinnedMessagesCard = ({ room, onClose }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const pinnedEventIds = usePinnedEvents(room);
|
||||
const readPinnedEvents = useReadPinnedEvents(room);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyRead = pinnedEventIds.filter(id => !readPinnedEvents.has(id));
|
||||
if (newlyRead.length > 0) {
|
||||
// Only keep the last N event IDs to avoid infinite growth
|
||||
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||
event_ids: [
|
||||
...newlyRead.reverse(),
|
||||
...readPinnedEvents,
|
||||
].splice(0, ReadPinsNumIds),
|
||||
});
|
||||
}
|
||||
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
|
||||
|
||||
const pinnedEvents = useAsyncMemo(() => {
|
||||
const promises = pinnedEventIds.map(async eventId => {
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
const localEvent = timelineSet?.getTimelineForEvent(eventId)?.getEvents().find(e => e.getId() === eventId);
|
||||
if (localEvent) return localEvent;
|
||||
|
||||
try {
|
||||
const evJson = await cli.fetchRoomEvent(room.roomId, eventId);
|
||||
const event = new MatrixEvent(evJson);
|
||||
if (event.isEncrypted()) {
|
||||
await cli.decryptEventIfNeeded(event); // TODO await?
|
||||
}
|
||||
if (event && PinningUtils.isPinnable(event)) {
|
||||
return event;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error looking up pinned event " + eventId + " in room " + room.roomId);
|
||||
console.error(err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}, [cli, room, pinnedEventIds], null);
|
||||
|
||||
let content;
|
||||
if (!pinnedEvents) {
|
||||
content = <Spinner />;
|
||||
} else if (pinnedEvents.length > 0) {
|
||||
content = pinnedEvents.filter(Boolean).map(ev => (
|
||||
<PinnedEventTile
|
||||
key={ev.getId()}
|
||||
mxRoom={room}
|
||||
mxEvent={ev}
|
||||
onUnpinned={() => {}}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
content = <div className="mx_RightPanel_empty mx_NotificationPanel_empty">
|
||||
<h2>{_t("You’re all caught up")}</h2>
|
||||
<p>{_t("You have no visible notifications.")}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <BaseCard className="mx_NotificationPanel" onClose={onClose} withoutScrollContainer>
|
||||
{ content }
|
||||
</BaseCard>;
|
||||
};
|
||||
|
||||
export default PinnedMessagesCard;
|
|
@ -18,7 +18,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import {_t} from '../../../languageHandler';
|
||||
import HeaderButton from './HeaderButton';
|
||||
import HeaderButtons, {HeaderKind} from './HeaderButtons';
|
||||
|
@ -27,6 +29,8 @@ import {Action} from "../../../dispatcher/actions";
|
|||
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||
import RightPanelStore from "../../../stores/RightPanelStore";
|
||||
import {replaceableComponent} from "../../../utils/replaceableComponent";
|
||||
import {useSettingValue} from "../../../hooks/useSettings";
|
||||
import { useReadPinnedEvents, usePinnedEvents } from './PinnedMessagesCard';
|
||||
|
||||
const ROOM_INFO_PHASES = [
|
||||
RightPanelPhases.RoomSummary,
|
||||
|
@ -38,9 +42,35 @@ const ROOM_INFO_PHASES = [
|
|||
RightPanelPhases.Room3pidMemberInfo,
|
||||
];
|
||||
|
||||
const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }) => {
|
||||
const pinningEnabled = useSettingValue("feature_pinning");
|
||||
const pinnedEvents = usePinnedEvents(pinningEnabled && room);
|
||||
const readPinnedEvents = useReadPinnedEvents(pinningEnabled && room);
|
||||
if (!pinningEnabled) return null;
|
||||
|
||||
let unreadIndicator;
|
||||
if (pinnedEvents.some(id => !readPinnedEvents.has(id))) {
|
||||
unreadIndicator = <div className="mx_RightPanel_pinnedMessagesButton_unreadIndicator" />;
|
||||
}
|
||||
|
||||
return <HeaderButton
|
||||
name="pinnedMessagesButton"
|
||||
title={_t("Pinned Messages")}
|
||||
isHighlighted={isHighlighted}
|
||||
onClick={onClick}
|
||||
analytics={["Right Panel", "Pinned Messages Button", "click"]}
|
||||
>
|
||||
{ unreadIndicator }
|
||||
</HeaderButton>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
room?: Room;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.right_panel.RoomHeaderButtons")
|
||||
export default class RoomHeaderButtons extends HeaderButtons {
|
||||
constructor(props) {
|
||||
export default class RoomHeaderButtons extends HeaderButtons<IProps> {
|
||||
constructor(props: IProps) {
|
||||
super(props, HeaderKind.Room);
|
||||
}
|
||||
|
||||
|
@ -80,8 +110,18 @@ export default class RoomHeaderButtons extends HeaderButtons {
|
|||
this.setPhase(RightPanelPhases.NotificationPanel);
|
||||
};
|
||||
|
||||
private onPinnedMessagesClicked = () => {
|
||||
// This toggles for us, if needed
|
||||
this.setPhase(RightPanelPhases.PinnedMessages);
|
||||
};
|
||||
|
||||
public renderButtons() {
|
||||
return <>
|
||||
<PinnedMessagesHeaderButton
|
||||
room={this.props.room}
|
||||
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
|
||||
onClick={this.onPinnedMessagesClicked}
|
||||
/>
|
||||
<HeaderButton
|
||||
name="notifsButton"
|
||||
title={_t('Notifications')}
|
||||
|
|
|
@ -514,9 +514,6 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
|
|||
} else {
|
||||
setPowerLevels({});
|
||||
}
|
||||
return () => {
|
||||
setPowerLevels({});
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
useEventEmitter(cli, "RoomState.events", update);
|
||||
|
|
|
@ -40,7 +40,6 @@ export default class RoomHeader extends React.Component {
|
|||
oobData: PropTypes.object,
|
||||
inRoom: PropTypes.bool,
|
||||
onSettingsClick: PropTypes.func,
|
||||
onPinnedClick: PropTypes.func,
|
||||
onSearchClick: PropTypes.func,
|
||||
onLeaveClick: PropTypes.func,
|
||||
onCancelClick: PropTypes.func,
|
||||
|
@ -59,14 +58,12 @@ export default class RoomHeader extends React.Component {
|
|||
componentDidMount() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli.on("RoomState.events", this._onRoomStateEvents);
|
||||
cli.on("Room.accountData", this._onRoomAccountData);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener("RoomState.events", this._onRoomStateEvents);
|
||||
cli.removeListener("Room.accountData", this._onRoomAccountData);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,41 +76,14 @@ export default class RoomHeader extends React.Component {
|
|||
this._rateLimitedUpdate();
|
||||
};
|
||||
|
||||
_onRoomAccountData = (event, room) => {
|
||||
if (!this.props.room || room.roomId !== this.props.room.roomId) return;
|
||||
if (event.getType() !== "im.vector.room.read_pins") return;
|
||||
|
||||
this._rateLimitedUpdate();
|
||||
};
|
||||
|
||||
_rateLimitedUpdate = new RateLimitedFunc(function() {
|
||||
/* eslint-disable babel/no-invalid-this */
|
||||
this.forceUpdate();
|
||||
}, 500);
|
||||
|
||||
_hasUnreadPins() {
|
||||
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
|
||||
if (!currentPinEvent) return false;
|
||||
if (currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0) {
|
||||
return false; // no pins == nothing to read
|
||||
}
|
||||
|
||||
const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
|
||||
if (readPinsEvent && readPinsEvent.getContent()) {
|
||||
const readStateEvents = readPinsEvent.getContent().event_ids || [];
|
||||
if (readStateEvents) {
|
||||
return !readStateEvents.includes(currentPinEvent.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// There's pins, and we haven't read any of them
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
let searchStatus = null;
|
||||
let cancelButton = null;
|
||||
let pinnedEventsButton = null;
|
||||
|
||||
if (this.props.onCancelClick) {
|
||||
cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
|
||||
|
@ -174,22 +144,6 @@ export default class RoomHeader extends React.Component {
|
|||
/>;
|
||||
}
|
||||
|
||||
if (this.props.onPinnedClick && SettingsStore.getValue('feature_pinning')) {
|
||||
let pinsIndicator = null;
|
||||
if (this._hasUnreadPins()) {
|
||||
pinsIndicator = (<div className="mx_RoomHeader_pinsIndicatorUnread" />);
|
||||
}
|
||||
|
||||
pinnedEventsButton =
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
|
||||
onClick={this.props.onPinnedClick}
|
||||
title={_t("Pinned Messages")}
|
||||
>
|
||||
{ pinsIndicator }
|
||||
</AccessibleTooltipButton>;
|
||||
}
|
||||
|
||||
let forgetButton;
|
||||
if (this.props.onForgetClick) {
|
||||
forgetButton =
|
||||
|
@ -239,7 +193,6 @@ export default class RoomHeader extends React.Component {
|
|||
<div className="mx_RoomHeader_buttons">
|
||||
{ videoCallButton }
|
||||
{ voiceCallButton }
|
||||
{ pinnedEventsButton }
|
||||
{ forgetButton }
|
||||
{ appsButton }
|
||||
{ searchButton }
|
||||
|
@ -256,7 +209,7 @@ export default class RoomHeader extends React.Component {
|
|||
{ topicElement }
|
||||
{ cancelButton }
|
||||
{ rightRow }
|
||||
<RoomHeaderButtons />
|
||||
<RoomHeaderButtons room={this.props.room} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue