[frontend/components] Add polls to notes and allow voting

This commit is contained in:
pancakes 2024-12-30 22:11:13 +10:00 committed by Lilian
parent 70e0d45c12
commit 288c549f20
No known key found for this signature in database
6 changed files with 195 additions and 8 deletions

View file

@ -50,6 +50,10 @@
}
<MfmText Text="@NoteBase.Text" Emoji="@NoteBase.Emoji"/>
</span>
@if (NoteBase.Poll != null)
{
<NotePoll Poll="@NoteBase.Poll" Emoji="@NoteBase.Emoji"/>
}
@if (NoteBase.Attachments.Count > 0)
{
<NoteAttachments Attachments="NoteBase.Attachments"/>

View file

@ -0,0 +1,92 @@
@using Iceshrimp.Frontend.Core.Services
@using Iceshrimp.Frontend.Localization
@using Iceshrimp.Shared.Schemas.Web
@using Microsoft.Extensions.Localization
@inject ApiService Api;
@inject IStringLocalizer<Localization> Loc;
<div class="poll">
@if (CanVote())
{
<div class="poll-options">
@foreach (var (choice, i) in Poll.Choices.Select((c, i) => (c, i)))
{
<span class="poll-option">
@if (Poll.Multiple)
{
<input type="checkbox" @onchange="() => SelectMultiple(i)" id="option-@i" name="poll" checked="@choice.Voted" disabled="@choice.Voted"/>
}
else
{
<input type="radio" @onchange="() => SelectSingle(i)" id="option-@i" name="poll"/>
}
<label for="option-@i">
<MfmText Text="@choice.Value" Emoji="@Emoji" Simple="true"/>
</label>
</span>
}
<button class="btn vote-btn" @onclick="Vote">@Loc["Vote"]</button>
</div>
}
else
{
<div class="poll-results">
@{ var total = Poll.Choices.Sum(p => p.Votes); }
@foreach (var choice in Poll.Choices)
{
<span class="poll-result @(choice.Voted ? "voted" : "")" style="--percentage: @(choice.Votes / total * 100)%;">
<span class="poll-value">
<MfmText Text="@choice.Value" Emoji="@Emoji" Simple="true"/>
</span>
<span class="poll-info"><span class="vote-count">@Loc["{0} votes", choice.Votes]</span><span class="vote-percentage">@(choice.Votes / total * 100)%</span></span>
</span>
}
</div>
}
@{
List<string> footerText = [];
@if (Poll.Multiple)
footerText.Add(Loc["Multiple choice"]);
@if (Poll.VotersCount != null)
footerText.Add(Loc["{0} users voted", Poll.VotersCount]);
@if (Poll.ExpiresAt != null)
footerText.Add(Poll.ExpiresAt <= DateTime.UtcNow ? Loc["Ended"] : Loc["Ends at {0}", Poll.ExpiresAt.Value.ToLocalTime().ToString("G")]);
}
<span class="poll-footer">@string.Join(" - ", footerText)</span>
</div>
@code {
[Parameter, EditorRequired] public required NotePollSchema Poll { get; set; }
[Parameter, EditorRequired] public required List<EmojiResponse> Emoji { get; set; }
private List<int> Choices { get; set; } = [];
private bool CanVote()
{
return (Poll.Multiple && !Poll.Choices.All(p => p.Voted) || !Poll.Choices.Any(p => p.Voted)) && (Poll.ExpiresAt == null || Poll.ExpiresAt > DateTime.UtcNow);
}
private void SelectMultiple(int choice)
{
if (Choices.Contains(choice))
Choices.RemoveAll(p => p == choice);
else
Choices.Add(choice);
}
private void SelectSingle(int choice)
{
Choices = [choice];
}
private async Task Vote()
{
if (!CanVote()) return;
var res = await Api.Notes.AddPollVoteAsync(Poll.NoteId, Choices);
if (res != null)
{
Poll = res;
StateHasChanged();
}
}
}

View file

@ -0,0 +1,85 @@
.poll-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
margin-top: 0.5em;
}
.poll-option input {
display: none;
}
.poll-option label {
display: inline-block;
width: 100%;
padding: 0.2rem 0.5rem;
border-radius: 0.5rem;
background-color: var(--highlight-color);
text-wrap: wrap;
word-break: break-word;
cursor: pointer;
}
.poll-option label:hover {
background-color: var(--hover-color);
}
.poll-option input:checked ~ label {
background: var(--accent-color);
}
.poll-results {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
margin-top: 0.5em;
}
.poll-result {
--percentage: 0%;
display: flex;
align-items: center;
gap: 0.25rem;
width: 100%;
padding: 0.2rem 0.5rem;
border-radius: 0.5rem;
background: linear-gradient(to right, var(--notice-color) var(--percentage), var(--highlight-color) var(--percentage), var(--highlight-color));
}
.poll-result.voted {
background: linear-gradient(to right, var(--accent-primary-color), var(--accent-secondary-color) var(--percentage), var(--highlight-color) var(--percentage), var(--highlight-color));
}
.poll-value {
flex-grow: 1;
text-wrap: wrap;
word-break: break-word;
}
.poll-info {
flex-shrink: 0;
}
.vote-count, .vote-percentage {
display: inline-block;
vertical-align: middle;
text-wrap: nowrap;
}
.vote-count {
margin-right: 0.5rem;
font-size: 0.7em;
}
@media (max-width: 768px) {
.poll-result {
flex-direction: column;
align-items: start;
}
.vote-count {
font-size: 1em;
}
}

View file

@ -71,6 +71,10 @@ internal class NoteControllerModel(ApiClient api)
public Task<NoteRefetchResponse?> RefetchNoteAsync(string id) =>
api.CallNullableAsync<NoteRefetchResponse>(HttpMethod.Get, $"/notes/{id}/refetch");
public Task<NotePollSchema?> AddPollVoteAsync(string id, List<int> choices) =>
api.CallNullableAsync<NotePollSchema>(HttpMethod.Post, $"/notes/{id}/vote",
data: new NotePollRequest { Choices = choices });
public Task MuteNoteAsync(string id) =>
api.CallAsync(HttpMethod.Post, $"/notes/{id}/mute");
}

View file

@ -17,7 +17,9 @@
--foreground-color: #17151e;
--highlight-color: #2e2b4c;
--hover-color: #3d3a66;
--accent-color: linear-gradient(to right, #9A92FF, #8372F5);
--accent-primary-color: #9A92FF;
--accent-secondary-color: #8372F5;
--accent-color: linear-gradient(to right, var(--accent-primary-color), var(--accent-secondary-color));
--notice-color: #92c1ff;
--font-color: #e7edff;
--link: #9E9EFF;

View file

@ -61,7 +61,7 @@ public class NoteAttachment
public class NotePollSchema
{
[JI] public required string NoteId;
public required string NoteId { get; set; }
public required DateTime? ExpiresAt { get; set; }
public required bool Multiple { get; set; }
public required List<NotePollChoice> Choices { get; set; }