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

Commit f341ee2

Browse files
committed
Add a banner at the top of a room to display the pinned messages
1 parent 3112878 commit f341ee2

File tree

6 files changed

+401
-54
lines changed

6 files changed

+401
-54
lines changed

res/css/_components.pcss

+1
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@
298298
@import "./views/rooms/_NewRoomIntro.pcss";
299299
@import "./views/rooms/_NotificationBadge.pcss";
300300
@import "./views/rooms/_PinnedEventTile.pcss";
301+
@import "./views/rooms/_PinnedMessageBanner.pcss";
301302
@import "./views/rooms/_PresenceLabel.pcss";
302303
@import "./views/rooms/_ReadReceiptGroup.pcss";
303304
@import "./views/rooms/_ReplyPreview.pcss";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2024 The Matrix.org Foundation C.I.C.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
.mx_PinnedMessageBanner {
18+
display: flex;
19+
align-items: center;
20+
justify-content: space-between;
21+
gap: var(--cpd-space-4x);
22+
/* 80px = 79px + 1px from the bottom border */
23+
height: 79px;
24+
padding: 0 var(--cpd-space-4x);
25+
26+
background-color: var(--cpd-color-bg-canvas-default);
27+
border-bottom: 1px solid var(--cpd-color-gray-400);
28+
29+
/* From figma */
30+
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1);
31+
32+
.mx_PinnedMessageBanner_main {
33+
background: transparent;
34+
border: none;
35+
text-align: start;
36+
cursor: pointer;
37+
38+
display: grid;
39+
grid-template:
40+
"indicators pinIcon title" auto
41+
"indicators pinIcon message" auto;
42+
column-gap: var(--cpd-space-2x);
43+
44+
.mx_PinnedMessageBanner_Indicators {
45+
grid-area: indicators;
46+
display: flex;
47+
flex-direction: column;
48+
gap: var(--cpd-space-0-5x);
49+
height: 100%;
50+
51+
.mx_PinnedMessageBanner_Indicator {
52+
width: var(--cpd-space-0-5x);
53+
background-color: var(--cpd-color-gray-600);
54+
height: 100%;
55+
}
56+
57+
.mx_PinnedMessageBanner_Indicator--active {
58+
background-color: var(--cpd-color-icon-accent-primary);
59+
}
60+
61+
.mx_PinnedMessageBanner_Indicator--hided {
62+
background-color: transparent;
63+
}
64+
}
65+
66+
.mx_PinnedMessageBanner_PinIcon {
67+
grid-area: pinIcon;
68+
align-self: center;
69+
fill: var(--cpd-color-icon-secondary-alpha);
70+
}
71+
72+
.mx_PinnedMessageBanner_title {
73+
grid-area: title;
74+
font: var(--cpd-font-body-sm-regular);
75+
color: var(--cpd-color-text-action-accent);
76+
height: 20px;
77+
78+
.mx_PinnedMessageBanner_title_counter {
79+
font: var(--cpd-font-body-sm-semibold);
80+
}
81+
}
82+
83+
.mx_PinnedMessageBanner_message {
84+
grid-area: message;
85+
font: var(--cpd-font-body-sm-regular);
86+
height: 20px;
87+
overflow: hidden;
88+
text-overflow: ellipsis;
89+
white-space: nowrap;
90+
}
91+
}
92+
93+
.mx_PinnedMessageBanner_actions {
94+
white-space: nowrap;
95+
}
96+
}
97+
98+
.mx_PinnedMessageBanner[data-single-message="true"] {
99+
/* 64px = 63px + 1px from the bottom border */
100+
height: 63px;
101+
102+
.mx_PinnedMessageBanner_main {
103+
grid-template: "pinIcon message" auto;
104+
}
105+
}

src/components/structures/RoomView.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoi
133133
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
134134
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
135135
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
136+
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
136137

137138
const DEBUG = false;
138139
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -2409,6 +2410,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
24092410
</AuxPanel>
24102411
);
24112412

2413+
const pinnedMessageBanner = (
2414+
<PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
2415+
);
2416+
24122417
let messageComposer;
24132418
const showComposer =
24142419
// joined and not showing search results
@@ -2537,6 +2542,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
25372542
<Measured sensor={this.roomViewBody.current} onMeasurement={this.onMeasurement} />
25382543
)}
25392544
{auxPanel}
2545+
{pinnedMessageBanner}
25402546
<main className={timelineClasses}>
25412547
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
25422548
{topUnreadMessagesBar}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/*
2+
* Copyright 2024 The Matrix.org Foundation C.I.C.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React, { JSX, useEffect, useMemo, useState } from "react";
18+
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin-solid.svg";
19+
import { Button } from "@vector-im/compound-web";
20+
import { Room } from "matrix-js-sdk/src/matrix";
21+
import classNames from "classnames";
22+
23+
import { useFetchPinnedEvent, usePinnedEvents } from "../../../hooks/usePinnedEvents";
24+
import { _t } from "../../../languageHandler";
25+
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
26+
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
27+
import { useEventEmitter } from "../../../hooks/useEventEmitter";
28+
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
29+
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
30+
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
31+
32+
/**
33+
* The props for the {@link PinnedMessageBanner} component.
34+
*/
35+
interface PinnedMessageBannerProps {
36+
/**
37+
* The permalink creator to use.
38+
*/
39+
permalinkCreator: RoomPermalinkCreator;
40+
/**
41+
* The room where the banner is displayed
42+
*/
43+
room: Room;
44+
}
45+
46+
/**
47+
* A banner that displays the pinned messages in a room.
48+
*/
49+
export function PinnedMessageBanner({ room }: PinnedMessageBannerProps): JSX.Element | null {
50+
const pinnedEventIds = usePinnedEvents(room);
51+
const eventCount = pinnedEventIds.length;
52+
const isSinglePinnedEvent = eventCount === 1;
53+
54+
const [currentEventIndex, setCurrentEventIndex] = useState(eventCount - 1);
55+
// If the list of pinned events changes, we need to make sure the current index isn't out of bound
56+
useEffect(() => {
57+
setCurrentEventIndex((currentEventIndex) => Math.min(currentEventIndex, eventCount - 1));
58+
}, [eventCount]);
59+
60+
// Fetch the pinned event
61+
const pinnedEvent = useFetchPinnedEvent(room, pinnedEventIds[currentEventIndex]);
62+
// Generate a preview for the pinned event
63+
const eventPreview = useMemo(() => {
64+
if (!pinnedEvent) return null;
65+
return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent);
66+
}, [pinnedEvent]);
67+
68+
if (!pinnedEvent) return null;
69+
70+
return (
71+
<div className="mx_PinnedMessageBanner" data-single-message={isSinglePinnedEvent}>
72+
<button
73+
aria-label={_t("room|pinned_message_banner|go_to_message")}
74+
type="button"
75+
className="mx_PinnedMessageBanner_main"
76+
onClick={() => setCurrentEventIndex((currentEventIndex) => ++currentEventIndex % eventCount)}
77+
>
78+
{!isSinglePinnedEvent && <Indicators count={eventCount} currentIndex={currentEventIndex} />}
79+
<PinIcon width="20" className="mx_PinnedMessageBanner_PinIcon" />
80+
{!isSinglePinnedEvent && (
81+
<div className="mx_PinnedMessageBanner_title">
82+
{_t(
83+
"room|pinned_message_banner|title",
84+
{
85+
index: currentEventIndex + 1,
86+
length: eventCount,
87+
},
88+
{ bold: (sub) => <span className="mx_PinnedMessageBanner_title_counter">{sub}</span> },
89+
)}
90+
</div>
91+
)}
92+
{eventPreview && <span className="mx_PinnedMessageBanner_message">{eventPreview}</span>}
93+
</button>
94+
{!isSinglePinnedEvent && <BannerButton room={room} />}
95+
</div>
96+
);
97+
}
98+
99+
const MAX_INDICATORS = 3;
100+
101+
/**
102+
* The props for the {@link IndicatorsProps} component.
103+
*/
104+
interface IndicatorsProps {
105+
/**
106+
* The number of messages pinned
107+
*/
108+
count: number;
109+
/**
110+
* The current index of the pinned message
111+
*/
112+
currentIndex: number;
113+
}
114+
115+
/**
116+
* A component that displays vertical indicators for the pinned messages.
117+
*/
118+
function Indicators({ count, currentIndex }: IndicatorsProps): JSX.Element {
119+
// We only display a maximum of 3 indicators at one time.
120+
// When there is more than 3 messages pinned, we will cycle through the indicators
121+
122+
// If there is only 2 messages pinned, we will display 2 indicators
123+
// In case of 1 message pinned, the indicators are not displayed, see {@link PinnedMessageBanner} logic.
124+
const numberOfIndicators = Math.min(count, MAX_INDICATORS);
125+
// The index of the active indicator
126+
const index = currentIndex % numberOfIndicators;
127+
128+
// We hide the indicators when we are on the last cycle and there are less than 3 remaining messages pinned
129+
const numberOfCycles = Math.ceil(count / numberOfIndicators);
130+
// If the current index is greater than the last cycle index, we are on the last cycle
131+
const isLastCycle = currentIndex >= (numberOfCycles - 1) * MAX_INDICATORS;
132+
// The index of the last message in the last cycle
133+
const lastCycleIndex = numberOfIndicators - (numberOfCycles * numberOfIndicators - count);
134+
135+
return (
136+
<div className="mx_PinnedMessageBanner_Indicators">
137+
{Array.from({ length: numberOfIndicators }).map((_, i) => (
138+
<Indicator key={i} active={i === index} hided={isLastCycle && lastCycleIndex <= i} />
139+
))}
140+
</div>
141+
);
142+
}
143+
144+
/**
145+
* The props for the {@link Indicator} component.
146+
*/
147+
interface IndicatorProps {
148+
/**
149+
* Whether the indicator is active
150+
*/
151+
active: boolean;
152+
/**
153+
* Whether the indicator is hided
154+
*/
155+
hided: boolean;
156+
}
157+
158+
/**
159+
* A component that displays a vertical indicator for a pinned message.
160+
*/
161+
function Indicator({ active, hided }: IndicatorProps): JSX.Element {
162+
return (
163+
<div
164+
className={classNames("mx_PinnedMessageBanner_Indicator", {
165+
"mx_PinnedMessageBanner_Indicator--active": active,
166+
"mx_PinnedMessageBanner_Indicator--hided": hided,
167+
})}
168+
/>
169+
);
170+
}
171+
172+
function getRightPanelPhase(roomId: string): RightPanelPhases | null {
173+
if (!RightPanelStore.instance.isOpenForRoom(roomId)) return null;
174+
return RightPanelStore.instance.currentCard.phase;
175+
}
176+
177+
/**
178+
* The props for the {@link BannerButton} component.
179+
*/
180+
interface BannerButtonProps {
181+
/**
182+
* The room where the banner is displayed
183+
*/
184+
room: Room;
185+
}
186+
187+
/**
188+
* A button that allows the user to view or close the list of pinned messages.
189+
*/
190+
function BannerButton({ room }: BannerButtonProps): JSX.Element {
191+
const [currentPhase, setCurrentPhase] = useState<RightPanelPhases | null>(getRightPanelPhase(room.roomId));
192+
useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId)));
193+
const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages;
194+
195+
return (
196+
<Button
197+
className="mx_PinnedMessageBanner_actions"
198+
kind="tertiary"
199+
onClick={() => {
200+
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
201+
}}
202+
>
203+
{isPinnedMessagesPhase
204+
? _t("room|pinned_message_banner|button_close_list")
205+
: _t("room|pinned_message_banner|button_view_all")}
206+
</Button>
207+
);
208+
}

0 commit comments

Comments
 (0)