Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Add a banner at the top of a room to display the pinned messages
Browse files Browse the repository at this point in the history
  • Loading branch information
florianduros committed Aug 22, 2024
1 parent 3112878 commit a6bb865
Show file tree
Hide file tree
Showing 6 changed files with 403 additions and 54 deletions.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@
@import "./views/rooms/_NewRoomIntro.pcss";
@import "./views/rooms/_NotificationBadge.pcss";
@import "./views/rooms/_PinnedEventTile.pcss";
@import "./views/rooms/_PinnedMessageBanner.pcss";
@import "./views/rooms/_PresenceLabel.pcss";
@import "./views/rooms/_ReadReceiptGroup.pcss";
@import "./views/rooms/_ReplyPreview.pcss";
Expand Down
105 changes: 105 additions & 0 deletions res/css/views/rooms/_PinnedMessageBanner.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright 2024 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.
*/

.mx_PinnedMessageBanner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--cpd-space-4x);
/* 80px = 79px + 1px from the bottom border */
height: 79px;
padding: 0 var(--cpd-space-4x);

background-color: var(--cpd-color-bg-canvas-default);
border-bottom: 1px solid var(--cpd-color-gray-400);

/* From figma */
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1);

.mx_PinnedMessageBanner_main {
background: transparent;
border: none;
text-align: start;
cursor: pointer;

display: grid;
grid-template:
"indicators pinIcon title" auto
"indicators pinIcon message" auto;
column-gap: var(--cpd-space-2x);

.mx_PinnedMessageBanner_Indicators {
grid-area: indicators;
display: flex;
flex-direction: column;
gap: var(--cpd-space-0-5x);
height: 100%;

.mx_PinnedMessageBanner_Indicator {
width: var(--cpd-space-0-5x);
background-color: var(--cpd-color-gray-600);
height: 100%;
}

.mx_PinnedMessageBanner_Indicator--active {
background-color: var(--cpd-color-icon-accent-primary);
}

.mx_PinnedMessageBanner_Indicator--hided {
background-color: transparent;
}
}

.mx_PinnedMessageBanner_PinIcon {
grid-area: pinIcon;
align-self: center;
fill: var(--cpd-color-icon-secondary-alpha);
}

.mx_PinnedMessageBanner_title {
grid-area: title;
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-action-accent);
height: 20px;

.mx_PinnedMessageBanner_title_counter {
font: var(--cpd-font-body-sm-semibold);
}
}

.mx_PinnedMessageBanner_message {
grid-area: message;
font: var(--cpd-font-body-sm-regular);
height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

.mx_PinnedMessageBanner_actions {
white-space: nowrap;
}
}

.mx_PinnedMessageBanner[data-single-message="true"] {
/* 64px = 63px + 1px from the bottom border */
height: 63px;

.mx_PinnedMessageBanner_main {
grid-template: "pinIcon message" auto;
}
}
6 changes: 6 additions & 0 deletions src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoi
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";

const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
Expand Down Expand Up @@ -2409,6 +2410,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
</AuxPanel>
);

const pinnedMessageBanner = (
<PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
);

let messageComposer;
const showComposer =
// joined and not showing search results
Expand Down Expand Up @@ -2537,6 +2542,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<Measured sensor={this.roomViewBody.current} onMeasurement={this.onMeasurement} />
)}
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses}>
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
{topUnreadMessagesBar}
Expand Down
210 changes: 210 additions & 0 deletions src/components/views/rooms/PinnedMessageBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright 2024 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, { JSX, useEffect, useMemo, useState } from "react";
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin-solid.svg";
import { Button } from "@vector-im/compound-web";
import { Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";

import { useFetchPinnedEvent, usePinnedEvents } from "../../../hooks/usePinnedEvents";
import { _t } from "../../../languageHandler";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";

/**
* The props for the {@link PinnedMessageBanner} component.
*/
interface PinnedMessageBannerProps {
/**
* The permalink creator to use.
*/
permalinkCreator: RoomPermalinkCreator;
/**
* The room where the banner is displayed
*/
room: Room;
}

/**
* A banner that displays the pinned messages in a room.
*/
export function PinnedMessageBanner({ room }: PinnedMessageBannerProps): JSX.Element | null {
const pinnedEventIds = usePinnedEvents(room);
const eventCount = pinnedEventIds.length;
const isSinglePinnedEvent = eventCount === 1;

const [currentEventIndex, setCurrentEventIndex] = useState(eventCount - 1);
// If the list of pinned events changes, we need to make sure the current index isn't out of bound
useEffect(() => {
setCurrentEventIndex((currentEventIndex) => Math.min(currentEventIndex, eventCount - 1));
}, [eventCount]);

// Fetch the pinned event
const pinnedEvent = useFetchPinnedEvent(room, pinnedEventIds[currentEventIndex]);
// Generate a preview for the pinned event
const eventPreview = useMemo(() => {
if (!pinnedEvent) return null;
return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent);
}, [pinnedEvent]);

if (!pinnedEvent) return null;

return (
<div className="mx_PinnedMessageBanner" data-single-message={isSinglePinnedEvent}>
<button
aria-label={_t("room|pinned_message_banner|go_to_message")}
type="button"
className="mx_PinnedMessageBanner_main"
onClick={() => setCurrentEventIndex((currentEventIndex) => ++currentEventIndex % eventCount)}
>
{!isSinglePinnedEvent && <Indicators count={eventCount} currentIndex={currentEventIndex} />}
<PinIcon width="20" className="mx_PinnedMessageBanner_PinIcon" />
{!isSinglePinnedEvent && (
<div className="mx_PinnedMessageBanner_title">
{_t(
"room|pinned_message_banner|title",
{
index: currentEventIndex + 1,
length: eventCount,
},
{ bold: (sub) => <span className="mx_PinnedMessageBanner_title_counter">{sub}</span> },
)}
</div>
)}
{eventPreview && <span className="mx_PinnedMessageBanner_message">{eventPreview}</span>}
</button>
{!isSinglePinnedEvent && <BannerButton room={room} />}
</div>
);
}

const MAX_INDICATORS = 3;

/**
* The props for the {@link IndicatorsProps} component.
*/
interface IndicatorsProps {
/**
* The number of messages pinned
*/
count: number;
/**
* The current index of the pinned message
*/
currentIndex: number;
}

/**
* A component that displays vertical indicators for the pinned messages.
*/
function Indicators({ count, currentIndex }: IndicatorsProps): JSX.Element {
// We only display a maximum of 3 indicators at one time.
// When there is more than 3 messages pinned, we will cycle through the indicators

// If there is only 2 messages pinned, we will display 2 indicators
// In case of 1 message pinned, the indicators are not displayed, see {@link PinnedMessageBanner} logic.
const numberOfIndicators = Math.min(count, MAX_INDICATORS);
// The index of the active indicator
const index = currentIndex % numberOfIndicators;

// We hide the indicators when we are on the last cycle and there are less than 3 remaining messages pinned
const numberOfCycles = Math.ceil(count / numberOfIndicators);
// If the current index is greater than the last cycle index, we are on the last cycle
const isLastCycle = currentIndex >= (numberOfCycles - 1) * MAX_INDICATORS;
// The index of the last message in the last cycle
const lastCycleIndex = numberOfIndicators - (numberOfCycles * numberOfIndicators - count);

return (
<div className="mx_PinnedMessageBanner_Indicators">
{Array.from({ length: numberOfIndicators }).map((_, i) => (
<Indicator key={i} active={i === index} hided={isLastCycle && lastCycleIndex <= i} />
))}
</div>
);
}

/**
* The props for the {@link Indicator} component.
*/
interface IndicatorProps {
/**
* Whether the indicator is active
*/
active: boolean;
/**
* Whether the indicator is hided
*/
hided: boolean;
}

/**
* A component that displays a vertical indicator for a pinned message.
*/
function Indicator({ active, hided }: IndicatorProps): JSX.Element {
return (
<div
className={classNames("mx_PinnedMessageBanner_Indicator", {
"mx_PinnedMessageBanner_Indicator--active": active,
"mx_PinnedMessageBanner_Indicator--hided": hided,
})}
/>
);
}

function getRightPanelPhase(roomId: string): RightPanelPhases | null {
if (!RightPanelStore.instance.isOpenForRoom(roomId)) return null;
return RightPanelStore.instance.currentCard.phase;
}

/**
* The props for the {@link BannerButton} component.
*/
interface BannerButtonProps {
/**
* The room where the banner is displayed
*/
room: Room;
}

/**
* A button that allows the user to view or close the list of pinned messages.
*/
function BannerButton({ room }: BannerButtonProps): JSX.Element {
console.log("BannerButton render");

const [currentPhase, setCurrentPhase] = useState<RightPanelPhases | null>(getRightPanelPhase(room.roomId));
useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId)));
const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages;

return (
<Button
className="mx_PinnedMessageBanner_actions"
kind="tertiary"
onClick={() => {
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
}}
>
{isPinnedMessagesPhase
? _t("room|pinned_message_banner|button_close_list")
: _t("room|pinned_message_banner|button_view_all")}
</Button>
);
}
Loading

0 comments on commit a6bb865

Please sign in to comment.