P6: custom emoji, fuller remote profiles, and polls both ways
- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in Status.emojis and Account.emojis. - Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and image descriptions (a locked GoToSocial account no longer shows as open). - Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed; our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST /api/v1/polls/:id/votes. - An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision. Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
81de470f64
commit
7be6749a23
33 files changed
+937
-28
No files matched your search
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class PollsController : MastodonController
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IPollService _polls;
|
||||
|
||||
public PollsController(DbEntities dbEntities, MastodonMapper mapper, IPollService polls)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
_polls = polls;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/polls/{id}"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
return post?.Poll == default ? NotFoundError() : Json(_mapper.Poll(post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/polls/{id}/votes"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Vote(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post?.Poll == default)
|
||||
return NotFoundError();
|
||||
var choices = Params.List("choices").Select(c => int.TryParse(c, out var index) ? index : -1).ToList();
|
||||
var outcome = await _polls.Vote(Me, post, choices, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
return Json(_mapper.Poll(outcome.Post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (existing != default)
|
||||
return Json(await _mapper.Status(existing, MyId, token));
|
||||
}
|
||||
if (Params.Has("poll[options]"))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
|
||||
|
||||
var outcome = await _statuses.Publish(Me, new StatusDraft
|
||||
{
|
||||
Text = Params.Get("status"),
|
||||
@@ -56,7 +53,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
|
||||
InReplyTo = Params.Get("in_reply_to_id"),
|
||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
||||
MediaIds = Params.List("media_ids")
|
||||
MediaIds = Params.List("media_ids"),
|
||||
Poll = Params.Has("poll[options]")
|
||||
? new PollDraft(Params.List("poll[options]"), Params.Int("poll[expires_in]") ?? 0, Params.Bool("poll[multiple]") == true,
|
||||
Params.Bool("poll[hide_totals]") == true)
|
||||
: default
|
||||
}, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
|
||||
@@ -162,7 +162,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[NotificationType.FollowRequest] = "follow_request",
|
||||
[NotificationType.Favourite] = "favourite",
|
||||
[NotificationType.Reblog] = "reblog",
|
||||
[NotificationType.Update] = "update"
|
||||
[NotificationType.Update] = "update",
|
||||
[NotificationType.Poll] = "poll"
|
||||
};
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
@@ -25,12 +25,24 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string LastStatusAt { get; set; }
|
||||
public bool? HideCollections { get; set; }
|
||||
public bool Noindex { get; set; } = true;
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public List<object> Roles { get; set; } = new();
|
||||
public bool? Memorial { get; set; }
|
||||
public string AvatarDescription { get; set; } = string.Empty;
|
||||
public string HeaderDescription { get; set; } = string.Empty;
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public Source Source { get; set; }
|
||||
}
|
||||
|
||||
public class CustomEmojiEntity
|
||||
{
|
||||
public string Shortcode { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StaticUrl { get; set; }
|
||||
public bool VisibleInPicker { get; set; }
|
||||
public string Category { get; set; }
|
||||
}
|
||||
|
||||
public class Field
|
||||
{
|
||||
public string Name { get; set; }
|
||||
@@ -149,6 +161,26 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public int Down { get; set; }
|
||||
}
|
||||
|
||||
public class PollEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string ExpiresAt { get; set; }
|
||||
public bool Expired { get; set; }
|
||||
public bool Multiple { get; set; }
|
||||
public int VotesCount { get; set; }
|
||||
public int? VotersCount { get; set; }
|
||||
public bool? Voted { get; set; }
|
||||
public List<int> OwnVotes { get; set; }
|
||||
public List<PollOptionEntity> Options { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PollOptionEntity
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int? VotesCount { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewCard
|
||||
{
|
||||
public string Url { get; set; }
|
||||
@@ -183,7 +215,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string CreatedAt { get; set; }
|
||||
public string EditedAt { get; set; }
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public int RepliesCount { get; set; }
|
||||
public int ReblogsCount { get; set; }
|
||||
public int FavouritesCount { get; set; }
|
||||
@@ -199,7 +231,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public List<StatusMention> Mentions { get; set; } = new();
|
||||
public List<StatusTag> Tags { get; set; } = new();
|
||||
public PreviewCard Card { get; set; }
|
||||
public object Poll { get; set; }
|
||||
public PollEntity Poll { get; set; }
|
||||
public Application Application { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string Text { get; set; }
|
||||
@@ -289,7 +321,7 @@ namespace PrivaPub.Api.Mastodon.Entities
|
||||
public string CreatedAt { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<object> Emojis { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StatusSource
|
||||
|
||||
@@ -8,6 +8,7 @@ using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
@@ -103,8 +104,15 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
||||
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
||||
Group = foreign.AvatarType == AvatarType.Group,
|
||||
Locked = foreign.IsLocked,
|
||||
Discoverable = foreign.IsDiscoverable,
|
||||
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
|
||||
Indexable = foreign.IsIndexable,
|
||||
Memorial = foreign.IsMemorial ? true : default(bool?),
|
||||
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
|
||||
Emojis = Emojis(foreign.Emojis),
|
||||
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
AvatarDescription = foreign.PictureDescription ?? string.Empty,
|
||||
HeaderDescription = foreign.HeaderDescription ?? string.Empty,
|
||||
Note = foreign.Biography ?? string.Empty,
|
||||
Url = foreign.Url ?? foreign.ActorURI,
|
||||
Uri = foreign.ActorURI,
|
||||
@@ -158,6 +166,11 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||||
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
|
||||
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
|
||||
var ownVotes = viewerId == default || pollIds.Count == 0
|
||||
? new Dictionary<string, List<int>>()
|
||||
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
|
||||
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
|
||||
var hidden = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
|
||||
@@ -194,6 +207,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Language = post.Language,
|
||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||
Card = Card(post),
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
Emojis = Emojis(post.Emojis),
|
||||
Privapub = Extension(post),
|
||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||
@@ -270,6 +285,30 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
}
|
||||
}
|
||||
|
||||
public PollEntity Poll(PostEntity post, string viewerId, IReadOnlyCollection<int> ownVotes)
|
||||
{
|
||||
if (post.Poll is not { } poll)
|
||||
return default;
|
||||
var expired = PollService.Ended(poll);
|
||||
var hidden = poll.HideTotals && !expired && post.GroupUserId != viewerId;
|
||||
return new PollEntity
|
||||
{
|
||||
Id = post.ID,
|
||||
ExpiresAt = poll.ExpiresAt is { } ends ? MastodonJson.Time(ends) : default,
|
||||
Expired = expired,
|
||||
Multiple = poll.Multiple,
|
||||
VotesCount = poll.Options.Sum(o => o.Votes),
|
||||
VotersCount = poll.Multiple ? poll.VotersCount : poll.VotersCount ?? poll.Options.Sum(o => o.Votes),
|
||||
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
|
||||
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
|
||||
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
|
||||
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||||
};
|
||||
}
|
||||
|
||||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||||
|
||||
PrivaPubStatus Extension(PostEntity post) => new()
|
||||
{
|
||||
ObjectType = post.ObjectType,
|
||||
|
||||
Reference in new issue
Block a user