Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 48 additions & 12 deletions apps/web/res/css/views/dialogs/_PollCreateDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
.mx_PollCreateDialog_busy {
position: absolute;
inset: 0;
background-color: $overlay-background;
background-color: var(--cpd-color-alpha-gray-1300);
z-index: 1;
}

Expand All @@ -19,48 +19,84 @@ Please see LICENSE files in the repository root for full details.
font-size: $font-15px;
line-height: $font-24px;
margin-top: 0;
margin-bottom: 8px;
margin-bottom: var(--cpd-space-2x);

&:nth-child(n + 2) {
margin-top: 20px;
margin-top: var(--cpd-space-5x);
}
}

p {
color: $secondary-content;
color: var(--cpd-color-text-secondary);
}

.mx_PollCreateDialog_option {
display: flex;
align-items: center;
margin-top: 11px;
margin-bottom: 16px; /* 11px from the top will collapse, so this creates a 16px gap between options */
margin-top: var(--cpd-space-2-5x);
margin-bottom: var(--cpd-space-4x); /* 11px from the top will collapse, so this creates a 16px gap between options */

.mx_Field {
flex: 1;
margin: 0;
}

.mx_PollCreateDialog_removeOption {
margin-left: 12px;
width: 16px;
height: 16px;
margin-left: var(--cpd-space-3x);
width: var(--cpd-space-4x);
height: var(--cpd-space-4x);
padding: var(--cpd-space-0-5x);
border-radius: 50%;
background-color: $quinary-content;
background-color: var(--cpd-color-gray-400);
cursor: pointer;

svg {
width: inherit;
height: inherit;
color: $secondary-content;
color: var(--cpd-color-text-secondary);
}
}
}

.mx_PollCreateDialog_addOption {
padding: 0;
margin-bottom: 40px; /* arbitrary to create scrollable area under the poll */
margin-bottom: var(--cpd-space-10x); /* arbitrary to create scrollable area under the poll */
}

.mx_PollCreateDialog_maxSelections {
display: flex;
align-items: center;
gap: var(--cpd-space-4x);

.mx_PollCreateDialog_maxSelectionsButton {
width: var(--cpd-space-8x);
height: var(--cpd-space-8x);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--cpd-color-gray-400);
cursor: pointer;

svg {
width: var(--cpd-space-4x);
height: var(--cpd-space-4x);
color: var(--cpd-color-text-secondary);
}

&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}

.mx_PollCreateDialog_maxSelectionsValue {
font-weight: var(--cpd-font-weight-semibold);
font-size: $font-15px;
min-width: var(--cpd-space-15x);
text-align: center;
}
}

.mx_AccessibleButton_disabled {
Expand Down
54 changes: 47 additions & 7 deletions apps/web/src/components/views/elements/PollCreateDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
type TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { CloseIcon, PlusIcon, MinusIcon } from "@vector-im/compound-design-tokens/assets/web/icons";

import ScrollableBaseModal, { type IScrollableBaseState } from "../dialogs/ScrollableBaseModal";
import QuestionDialog from "../dialogs/QuestionDialog";
Expand Down Expand Up @@ -47,6 +47,7 @@ interface IState extends IScrollableBaseState {
busy: boolean;
kind: KnownPollKind;
autoFocusTarget: FocusTarget;
maxSelections: number;
}

const MIN_OPTIONS = 2;
Expand All @@ -65,6 +66,7 @@ function creatingInitialState(): IState {
busy: false,
kind: M_POLL_KIND_DISCLOSED,
autoFocusTarget: FocusTarget.Topic,
maxSelections: 1,
};
}

Expand All @@ -81,6 +83,7 @@ function editingInitialState(editingMxEvent: MatrixEvent): IState {
busy: false,
kind: poll.kind,
autoFocusTarget: FocusTarget.Topic,
maxSelections: poll.maxSelections ?? 1,
};
}

Expand Down Expand Up @@ -115,24 +118,40 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
private onOptionRemove = (i: number): void => {
const newOptions = arrayFastClone(this.state.options);
newOptions.splice(i, 1);
this.setState({ options: newOptions }, () => this.checkCanSubmit());
const maxOptions = newOptions.filter((op) => op.trim().length > 0).length;
const newMaxSelections = Math.min(this.state.maxSelections, maxOptions);
this.setState({ options: newOptions, maxSelections: newMaxSelections }, () => this.checkCanSubmit());
};

private onOptionAdd = (): void => {
const newOptions = arrayFastClone(this.state.options);
newOptions.push("");
this.setState({ options: newOptions, autoFocusTarget: FocusTarget.NewOption }, () => {
// Scroll the button into view after the state update to ensure we don't experience
// a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render.
this.addOptionRef.current?.scrollIntoView?.();
});
const maxOptions = newOptions.filter((op) => op.trim().length > 0).length;
const newMaxSelections = Math.min(this.state.maxSelections, maxOptions);
this.setState(
{ options: newOptions, maxSelections: newMaxSelections, autoFocusTarget: FocusTarget.NewOption },
() => {
// Scroll the button into view after the state update to ensure we don't experience
// a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render.
this.addOptionRef.current?.scrollIntoView?.();
},
);
};

private onMaxSelectionsChange = (delta: number): void => {
const maxOptions = this.state.options.filter((op) => op.trim().length > 0).length;
const newValue = this.state.maxSelections + delta;
if (newValue >= 1 && newValue <= maxOptions) {
this.setState({ maxSelections: newValue });
}
};

private createEvent(): IPartialEvent<object> {
const pollStart = PollStartEvent.from(
this.state.question.trim(),
this.state.options.map((a) => a.trim()).filter((a) => !!a),
this.state.kind.name,
this.state.maxSelections,
).serialize();

if (!this.props.editingMxEvent) {
Expand Down Expand Up @@ -248,6 +267,27 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
>
{_t("poll|options_add_button")}
</AccessibleButton>
<h2>{_t("poll|max_selections_heading")}</h2>
<div className="mx_PollCreateDialog_maxSelections">
<AccessibleButton
onClick={() => this.onMaxSelectionsChange(-1)}
disabled={this.state.busy || this.state.maxSelections <= 1}
className="mx_PollCreateDialog_maxSelectionsButton"
>
<MinusIcon />
</AccessibleButton>
<span className="mx_PollCreateDialog_maxSelectionsValue">{this.state.maxSelections}</span>
<AccessibleButton
onClick={() => this.onMaxSelectionsChange(1)}
disabled={
this.state.busy ||
this.state.maxSelections >= this.state.options.filter((op) => op.trim().length > 0).length
}
className="mx_PollCreateDialog_maxSelectionsButton"
>
<PlusIcon />
</AccessibleButton>
</div>
{this.state.busy && (
<div className="mx_PollCreateDialog_busy">
<Spinner />
Expand Down
56 changes: 36 additions & 20 deletions apps/web/src/components/views/messages/MPollBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ interface IState {
poll?: Poll;
// poll instance has fetched at least one page of responses
pollInitialised: boolean;
selected?: string | null | undefined; // Which option was clicked by the local user
selected?: string[] | null; // Which options were selected by the local user
voteRelations?: Relations; // Voting (response) events
isVoting: boolean; // Whether a vote is currently being sent
}

export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations {
Expand Down Expand Up @@ -148,8 +149,9 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
super(props);

this.state = {
selected: null,
selected: [],
pollInitialised: false,
isVoting: false,
};
}

Expand Down Expand Up @@ -208,17 +210,28 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
};

private selectOption(answerId: string): void {
if (this.state.poll?.isEnded) {
if (this.state.poll?.isEnded || this.state.isVoting) {
return;
}
const userVotes = this.collectUserVotes();
const userId = this.context.getSafeUserId();
const myVote = userVotes.get(userId)?.answers[0];
if (answerId === myVote) {
return;

const pollEvent = this.state.poll?.pollEvent;
const maxSelections = pollEvent?.maxSelections ?? 1;

let newSelected: string[];
const currentSelected = this.state.selected ?? [];

if (currentSelected.includes(answerId)) {
newSelected = currentSelected.filter((id) => id !== answerId);
} else {
if (currentSelected.length >= maxSelections) {
return;
}
newSelected = [...currentSelected, answerId];
}

const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()!).serialize();
const response = PollResponseEvent.from(newSelected, this.props.mxEvent.getId()!).serialize();

this.setState({ selected: newSelected, isVoting: true });

this.context
.sendEvent(
Expand All @@ -233,9 +246,10 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
title: _t("poll|error_voting_title"),
description: _t("poll|error_voting_description"),
});
})
.finally(() => {
this.setState({ isVoting: false });
});

this.setState({ selected: answerId });
}

/**
Expand All @@ -261,12 +275,12 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
const newEvents: MatrixEvent[] = relations.filter(
(mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!),
);
let newSelected = this.state.selected;
let newSelected: string[] | null | undefined = this.state.selected;

if (newEvents.length > 0) {
for (const mxEvent of newEvents) {
if (mxEvent.getSender() === this.context.getUserId()) {
newSelected = null;
newSelected = [];
}
}
}
Expand Down Expand Up @@ -298,12 +312,12 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
const totalVotes = this.totalVotes(votes);
const winCount = Math.max(...votes.values());
const userId = this.context.getSafeUserId();
const myVote = userVotes?.get(userId)?.answers[0];
const myVotes = userVotes?.get(userId)?.answers ?? [];
const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name);

// Disclosed: votes are hidden until I vote or the poll ends
// Undisclosed: votes are hidden until poll ends
const showResults = poll.isEnded || (disclosed && myVote !== undefined);
const showResults = poll.isEnded || (disclosed && myVotes.length > 0);

let totalText: string;
if (showResults && poll.undecryptableRelationsCount) {
Expand All @@ -312,7 +326,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
totalText = _t("right_panel|poll|final_result", { count: totalVotes });
} else if (!disclosed) {
totalText = _t("poll|total_not_ended");
} else if (myVote === undefined) {
} else if (myVotes.length === 0) {
if (totalVotes === 0) {
totalText = _t("poll|total_no_votes");
} else {
Expand Down Expand Up @@ -345,7 +359,8 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
}

const checked =
(!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount);
(!poll.isEnded && myVotes.includes(answer.id)) ||
(poll.isEnded && answerVotes === winCount);

return (
<PollOption
Expand All @@ -359,6 +374,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
totalVoteCount={totalVotes}
displayVoteCount={showResults}
onOptionSelected={this.selectOption.bind(this)}
maxSelections={pollEvent.maxSelections}
/>
);
})}
Expand Down Expand Up @@ -410,7 +426,7 @@ export function allVotes(voteRelations: Relations): Array<UserVote> {
export function collectUserVotes(
userResponses: Array<UserVote>,
userId?: string | null | undefined,
selected?: string | null | undefined,
selected?: string[] | null | undefined,
): Map<string, UserVote> {
const userVotes: Map<string, UserVote> = new Map();

Expand All @@ -421,8 +437,8 @@ export function collectUserVotes(
}
}

if (selected && userId) {
userVotes.set(userId, new UserVote(0, userId, [selected]));
if (selected && selected.length > 0 && userId) {
userVotes.set(userId, new UserVote(0, userId, selected));
}

return userVotes;
Expand Down
Loading
Loading