Settings, Administration and conversations; threads that fill in

Settings, which held only the theme, now belong to the persona too:
- its profile (update_credentials, a new PATCH in IHttpService): pictures,
  name, biography and fields as written, locked, bot, discoverable,
  indexable, and how its posts start (visibility, language, sensitive
  media, who may quote them); the composer starts at that visibility;
- the root's personas, each used in turn, and a new one made;
- the accounts it muted or blocked and the servers it blocked, each undone
  there, a server blocked from a pasted address or handle;
- its follows, lists, blocks, mutes, blocked servers and bookmarks exported
  and imported as the CSV files Mastodon writes (PortabilityService): an
  import resolves each row through PrivaPub, one at a time, and can stop.

Administration opens to moderators: reports, resolved there; and for
administrators the servers blocked for everyone and the federation
statistics (traffic by server, delivery success and latency, the crawler).

The Direct tab pages conversations, badges the unread ones, and a card
marks one read or takes it off the list; opening one reads it.

A remote thread is read again 4 and 15 seconds after it opens, since
PrivaPub then fetches the replies its servers hold. A thread its
community locked shows a lock instead of a reply button, reactions stay
visible below a content warning, and a searched handle opens its
account's profile whatever else matched.

The e2e tests cover each: the profile saved and read back, a persona made,
servers blocked and imported, follows exported, an instance block and the
statistics as the pasture's admin, a conversation read and removed, and a
reply only Mastodon held appearing in its thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 06:20:18 +02:00
1 parent 03f4f2ad53
commit d7d836ba39
40 files changed
+3155 -62

No files matched your search

+94
View File
@@ -75,6 +75,35 @@ namespace decePubClient.Services
return null;
}
/// <summary>The relationships of many accounts at once, 40 a request.</summary>
public async Task<List<Relationship>> Relationships(IReadOnlyList<string> accountIds, CancellationToken cancellationToken = default)
{
var relationships = new List<Relationship>();
foreach (var batch in accountIds.Chunk(40))
{
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [.. batch.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
await LogFailure(response, nameof(Relationships), cancellationToken);
return relationships;
}
relationships.AddRange(await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []);
}
return relationships;
}
/// <summary>Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.</summary>
public async Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
new[] { new KeyValuePair<string, string>("reblogs", showReblogs ? "true" : "false") }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
}
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
{
@@ -152,6 +181,71 @@ namespace decePubClient.Services
return new SearchResults();
}
/// <summary>Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).</summary>
public async Task<WebResult> UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Patch(APIs.Mastodon.PATCH_UpdateCredentials, (Func<HttpContent>)form.Content,
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
return account?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = account };
}
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
/// <summary>A page of the accounts the persona blocked; Data is an AccountPage.</summary>
public Task<WebResult> Blocks(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Blocks, maxId, cancellationToken);
async Task<WebResult> Accounts(string route, string maxId, CancellationToken cancellationToken)
{
var parameters = new List<string> { "limit=40" };
if (maxId is not null)
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
using var response = await httpService.Get(route, [.. parameters], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var accounts = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
var (nextMaxId, _) = response.PageCursors();
return new WebResult { Data = new AccountPage { Accounts = accounts, MaxId = accounts.Count == 0 ? null : nextMaxId } };
}
/// <summary>The servers the persona blocked: nothing from them reaches it, and its followers there are removed.</summary>
public async Task<List<string>> DomainBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_DomainBlocks, ["limit=200"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.MastodonReadFromJsonAsync<List<string>>(cancellationToken) ?? [];
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
return [];
}
public async Task<WebResult> BlockDomain(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_DomainBlocks, new[] { new KeyValuePair<string, string>("domain", domain.Trim()) },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+104
View File
@@ -0,0 +1,104 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
namespace decePubClient.Services
{
/// <summary>
/// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked
/// for the whole instance and the federation statistics (/clientapi/admin). PrivaPub checks the root's role on every
/// call. Lists come back empty and values null on failure; nothing throws.
/// </summary>
public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The newest 100 reports, open or resolved.</summary>
public async Task<List<ViewReport>> Reports(bool resolved, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorReports, [$"resolved={(resolved ? "true" : "false")}"],
cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewReport>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Reports), cancellationToken);
return [];
}
public async Task<WebResult> Resolve(string reportId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_ResolveReport(reportId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>The servers blocked for everyone here, by name.</summary>
public async Task<List<ViewDomainBlock>> ServerBlocks(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminDomainBlocks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewDomainBlock>>(cancellationToken) ?? [];
await LogFailure(response, nameof(ServerBlocks), cancellationToken);
return [];
}
/// <summary>Blocks a server for everyone, or changes how; Data is the block.</summary>
public async Task<WebResult> BlockServer(DomainBlockForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminDomainBlock, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewDomainBlock>(cancellationToken) };
}
public async Task<WebResult> UnblockServer(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminDomainUnblock(domain), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task<ViewStatisticsOverview> Overview(int days, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsOverview, [$"days={days}"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewStatisticsOverview>(cancellationToken);
await LogFailure(response, nameof(Overview), cancellationToken);
return null;
}
/// <summary>One page of the servers we exchanged with; sort is traffic (default), failures, latency or host.</summary>
public async Task<ViewHostsPage> Hosts(int days, string sort, int page, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsHosts,
[$"days={days}", $"sort={Uri.EscapeDataString(sort ?? "")}", $"page={page}", "limit=50"], cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewHostsPage>(cancellationToken);
await LogFailure(response, nameof(Hosts), cancellationToken);
return null;
}
public async Task<ViewCrawler> Crawler(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_StatisticsCrawler, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewCrawler>(cancellationToken);
await LogFailure(response, nameof(Crawler), cancellationToken);
return null;
}
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
{
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AdminService)}.{caller}", cancellationToken);
}
}
}
+10
View File
@@ -52,6 +52,16 @@ namespace decePubClient.Services
return await response.DefaultReadFromJsonAsync<List<ViewAvatar>>(cancellationToken) ?? [];
}
/// <summary>Makes a new persona for the root; Data is the persona. No other persona is linked to it, anywhere.</summary>
public async Task<WebResult> CreatePersona(InsertAvatarForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPersona, form, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
}
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
public async Task<WebResult> UsePersona(string personaId, CancellationToken cancellationToken = default)
{
+5
View File
@@ -39,6 +39,8 @@ namespace decePubClient.Services
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
@@ -83,6 +85,9 @@ namespace decePubClient.Services
public Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Put), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
cancellationToken);
+279
View File
@@ -0,0 +1,279 @@
using System.Text;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// <summary>What a persona carries from one server to another, each in the CSV file Mastodon exports and imports.</summary>
public enum PortableData
{
Follows = 1,
Lists,
Blocks,
Mutes,
ServerBlocks,
Bookmarks
}
/// <summary>How an import went: how many rows were done, and the rows that could not be, with why.</summary>
public sealed class ImportReport
{
public int Total { get; set; }
public int Done { get; set; }
public List<string> Failed { get; set; } = [];
}
/// <summary>
/// The persona's follows, lists, blocks, mutes, blocked servers and bookmarks as Mastodon's CSV exports, written in
/// the browser from the Mastodon API, and the same files imported: each row resolved through PrivaPub (a remote
/// account or post is fetched by PrivaPub, never by the browser) and acted on one at a time. Nothing throws; an import
/// reports the rows it could not do.
/// </summary>
public class PortabilityService(AccountService accounts, ListService lists, StatusService statuses, TimelineService timelines,
InstanceService instances)
{
/// <summary>The most rows read from an export or an import file.</summary>
public const int MaxRows = 5000;
const string AccountHeader = "Account address";
public static string FileName(PortableData data) => data switch
{
PortableData.Follows => "following_accounts.csv",
PortableData.Lists => "lists.csv",
PortableData.Blocks => "blocked_accounts.csv",
PortableData.Mutes => "muted_accounts.csv",
PortableData.ServerBlocks => "blocked_domains.csv",
_ => "bookmarks.csv"
};
/// <summary>The file Mastodon would export, as UTF-8 bytes.</summary>
public async Task<byte[]> Export(PortableData data, string personaId, CancellationToken cancellationToken = default)
{
var rows = data switch
{
PortableData.Follows => await Follows(personaId, cancellationToken),
PortableData.Lists => await Lists(cancellationToken),
PortableData.Blocks => [.. (await Pages(accounts.Blocks, cancellationToken)).Select(a => Csv(Full(a.Acct)))],
PortableData.Mutes => await Mutes(cancellationToken),
PortableData.ServerBlocks => [.. (await accounts.DomainBlocks(cancellationToken)).Select(d => Csv(d))],
_ => await Bookmarks(personaId, cancellationToken)
};
return Encoding.UTF8.GetBytes(string.Concat(rows.Select(r => r + "\n")));
}
async Task<List<string>> Follows(string personaId, CancellationToken cancellationToken)
{
var followed = await Pages((maxId, token) => accounts.Following(personaId, maxId, token), cancellationToken);
var relationships = (await accounts.Relationships([.. followed.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
Csv(AccountHeader, "Show boosts", "Notify on new posts", "Languages"),
.. followed.Select(a => relationships.GetValueOrDefault(a.Id) is { } r
? Csv(Full(a.Acct), Flag(r.ShowingReblogs), Flag(r.Notifying), null)
: Csv(Full(a.Acct), "true", "false", null))
];
}
async Task<List<string>> Mutes(CancellationToken cancellationToken)
{
var muted = await Pages(accounts.Mutes, cancellationToken);
var relationships = (await accounts.Relationships([.. muted.Select(a => a.Id)], cancellationToken)).ToDictionary(r => r.Id);
return
[
Csv(AccountHeader, "Hide notifications"),
.. muted.Select(a => Csv(Full(a.Acct), Flag(relationships.GetValueOrDefault(a.Id)?.MutingNotifications ?? true)))
];
}
async Task<List<string>> Lists(CancellationToken cancellationToken)
{
var rows = new List<string>();
foreach (var list in await lists.All(cancellationToken))
rows.AddRange((await lists.Members(list.Id, cancellationToken)).Select(member => Csv(list.Title, Full(member.Acct))));
return rows;
}
async Task<List<string>> Bookmarks(string personaId, CancellationToken cancellationToken)
{
var rows = new List<string>();
var maxId = default(string);
do
{
var result = await timelines.Load(new FeedQuery(TimelineType.Bookmarks, personaId), maxId, cancellationToken: cancellationToken);
if (result.IsInvalid || result.Data is not FeedPage page)
return rows;
rows.AddRange(page.Posts.Select(p => Csv(p.Status.Uri)));
maxId = page.Posts.Count == 0 ? null : page.MaxId;
}
while (maxId is not null && rows.Count < MaxRows);
return rows;
}
// every page of an account list, up to MaxRows
static async Task<List<Account>> Pages(Func<string, CancellationToken, Task<WebResult>> page, CancellationToken cancellationToken)
{
var all = new List<Account>();
var maxId = default(string);
do
{
var result = await page(maxId, cancellationToken);
if (result.IsInvalid || result.Data is not AccountPage accountPage)
return all;
all.AddRange(accountPage.Accounts);
maxId = accountPage.MaxId;
}
while (maxId is not null && all.Count < MaxRows);
return all;
}
/// <summary>Does what each row of an exported file says, one row at a time; progress counts the rows done.</summary>
public async Task<ImportReport> Import(PortableData data, string csv, IProgress<ImportReport> progress = default,
CancellationToken cancellationToken = default)
{
var rows = Parse(csv.TrimStart('\uFEFF')).Where(r => r.Count > 0 && !string.IsNullOrWhiteSpace(r[0])).ToList();
if (rows.Count > 0 && rows[0][0].Equals(AccountHeader, StringComparison.OrdinalIgnoreCase))
rows.RemoveAt(0);
var report = new ImportReport { Total = Math.Min(rows.Count, MaxRows) };
var known = data == PortableData.Lists ? await lists.All(cancellationToken) : [];
foreach (var row in rows.Take(MaxRows))
{
if (cancellationToken.IsCancellationRequested)
break;
var failure = data switch
{
PortableData.Follows => await FollowRow(row, cancellationToken),
PortableData.Lists => await ListRow(row, known, cancellationToken),
PortableData.Blocks => await AccountRow(row[0], AccountAction.Block, cancellationToken),
PortableData.Mutes => await AccountRow(row[0], AccountAction.Mute, cancellationToken),
PortableData.ServerBlocks => Failure(row[0], await accounts.BlockDomain(row[0], cancellationToken)),
_ => await BookmarkRow(row[0].Trim(), cancellationToken)
};
if (failure is null)
report.Done++;
else
report.Failed.Add(failure);
progress?.Report(report);
}
return report;
}
async Task<string> FollowRow(List<string> row, CancellationToken cancellationToken)
{
if (await accounts.Lookup(row[0], cancellationToken) is not { } account)
return Unknown(row[0]);
var showReblogs = row.Count < 2 || !row[1].Trim().Equals("false", StringComparison.OrdinalIgnoreCase);
return Failure(row[0], await accounts.Follow(account.Id, showReblogs, cancellationToken));
}
async Task<string> AccountRow(string acct, AccountAction action, CancellationToken cancellationToken)
{
if (await accounts.Lookup(acct, cancellationToken) is not { } account)
return Unknown(acct);
return Failure(acct, await accounts.Act(account.Id, action, cancellationToken));
}
// a list row names the list and an account; the list is made when missing, and an account is followed first, since
// a list holds followed accounts only
async Task<string> ListRow(List<string> row, List<MastodonList> known, CancellationToken cancellationToken)
{
if (row.Count < 2 || string.IsNullOrWhiteSpace(row[1]))
return string.Join(',', row);
var title = row[0].Trim();
var list = known.FirstOrDefault(l => string.Equals(l.Title, title, StringComparison.Ordinal));
if (list is null)
{
var made = await lists.Save(new MastodonList { Title = title }, cancellationToken);
if (made.IsInvalid || made.Data is not MastodonList created)
return Failure(title, made) ?? title;
known.Add(list = created);
}
if (await accounts.Lookup(row[1], cancellationToken) is not { } account)
return Unknown(row[1]);
var relationship = await accounts.Relationship(account.Id, cancellationToken);
if (relationship is not { Following: true })
{
var followed = await accounts.Follow(account.Id, true, cancellationToken);
if (followed.IsInvalid)
return Failure(row[1], followed);
}
return Failure($"{title}: {row[1]}", await lists.SetMember(list.Id, account.Id, true, cancellationToken));
}
async Task<string> BookmarkRow(string uri, CancellationToken cancellationToken)
{
var found = await accounts.Search(uri, resolve: true, cancellationToken);
if ((found.Statuses.FirstOrDefault(s => s.Uri == uri || s.Url == uri) ?? found.Statuses.FirstOrDefault()) is not { } status)
return Unknown(uri);
return Failure(uri, await statuses.Act(status.Id, StatusAction.Bookmark, cancellationToken));
}
static string Unknown(string what) => $"{what}: ?";
static string Failure(string what, WebResult result) => result.IsInvalid ? $"{what}: {result.ErrorMessage}" : null;
// a local account's acct has no host; an export names every account in full
string Full(string acct) => acct.Contains('@') ? acct : $"{acct}@{instances.SelfHost}";
static string Flag(bool value) => value ? "true" : "false";
static string Csv(params string[] cells) => string.Join(',', cells.Select(c => c is null ? "" :
c.IndexOfAny([',', '"', '\n', '\r']) < 0 ? c : $"\"{c.Replace("\"", "\"\"")}\""));
// RFC 4180: quoted cells may hold commas, quotes (doubled) and line breaks
static IEnumerable<List<string>> Parse(string csv)
{
var row = new List<string>();
var cell = new StringBuilder();
var quoted = false;
for (var i = 0; i < csv.Length; i++)
{
var c = csv[i];
if (quoted)
{
if (c == '"' && i + 1 < csv.Length && csv[i + 1] == '"')
{
cell.Append('"');
i++;
}
else if (c == '"')
quoted = false;
else
cell.Append(c);
continue;
}
switch (c)
{
case '"':
quoted = true;
break;
case ',':
row.Add(cell.ToString());
cell.Clear();
break;
case '\r':
break;
case '\n':
row.Add(cell.ToString());
cell.Clear();
yield return row;
row = [];
break;
default:
cell.Append(c);
break;
}
}
if (cell.Length > 0 || row.Count > 0)
{
row.Add(cell.ToString());
yield return row;
}
}
}
}
+43 -5
View File
@@ -47,7 +47,7 @@ namespace decePubClient.Services
await Statuses(APIs.Mastodon.GET_M_AccountStatuses(groupId), [], maxId, minId, cancellationToken),
TimelineType.Groups when query.GroupId is { Length: > 0 } && maxId is null => await Circle(query, cancellationToken),
TimelineType.Notifications => await Notifications(maxId, minId, cancellationToken),
TimelineType.Direct when maxId is null => await Conversations(cancellationToken),
TimelineType.Direct => await Conversations(maxId, minId, cancellationToken),
TimelineType.Nearby when maxId is null && query.Latitude is not null && query.Longitude is not null => await Nearby(query, cancellationToken),
_ => new WebResult { Data = new FeedPage() }
};
@@ -119,15 +119,53 @@ namespace decePubClient.Services
return new WebResult();
}
/// <summary>Direct conversations, newest first; PrivaPub answers one page.</summary>
async Task<WebResult> Conversations(CancellationToken cancellationToken)
/// <summary>Direct conversations by their newest message, newest first, paged by that message's id.</summary>
async Task<WebResult> Conversations(string maxId, string minId, CancellationToken cancellationToken)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [$"limit={PageSize}"], cancellationToken: cancellationToken);
var parameters = new List<string> { $"limit={PageSize}" };
if (maxId is not null)
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
if (minId is not null)
parameters.Add($"min_id={Uri.EscapeDataString(minId)}");
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, [.. parameters], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var conversations = await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? [];
return new WebResult { Data = new FeedPage { Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)] } };
var (nextMaxId, nextMinId) = response.PageCursors();
return new WebResult
{
Data = new FeedPage
{
Posts = [.. conversations.Where(c => c.LastStatus is not null).Select(FeedPost.Of)],
MaxId = conversations.Count < PageSize ? null : nextMaxId,
MinId = nextMinId
}
};
}
/// <summary>How many of the newest conversations have a message the persona has not read.</summary>
public async Task<int> UnreadConversations(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.Mastodon.GET_Conversations, ["limit=40"], cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? (await response.MastodonReadFromJsonAsync<List<Conversation>>(cancellationToken) ?? []).Count(c => c.Unread) : 0;
}
/// <summary>Marks a conversation read; Data is the conversation.</summary>
public async Task<WebResult> ReadConversation(string conversationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_ReadConversation(conversationId), cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Conversation>(cancellationToken) };
}
/// <summary>Takes a conversation off the list until a newer message; its messages stay.</summary>
public async Task<WebResult> RemoveConversation(string conversationId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Conversation(conversationId), cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
/// <summary>A circle's posts: their ids from /clientapi, the posts from the Mastodon API, newest first.</summary>