A server can take a Follow with 202 and drop it afterwards, as Pleroma does while it cannot fetch our actor; the request then stayed pending for good. Following again now sends an unanswered request once more, the same activity, at most once an hour (a delivery's `again` key). accounts/search takes following=true: only accounts the persona follows, by the start of their name, display name or server, never resolved; a client fills a list with it. "already take" becomes "already taken". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
90 lines
4.5 KiB
C#
90 lines
4.5 KiB
C#
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.StaticServices;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Mappers
|
|
{
|
|
public class AccountSearch
|
|
{
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
|
|
public AccountSearch(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
}
|
|
|
|
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token);
|
|
|
|
// following=true: only accounts the persona follows, matched by the start of their name, display name or server
|
|
// (what a client offers when adding to a list), read from what PrivaPub holds and never resolved
|
|
public async Task<List<Account>> Followed(string viewerId, string q, int limit, CancellationToken token)
|
|
{
|
|
q = q?.Trim().TrimStart('@') ?? string.Empty;
|
|
var ids = (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && f.State == Models.Social.FollowState.Accepted).ExecuteAsync(token))
|
|
.Select(f => f.TargetAccountId).Where(id => !string.IsNullOrEmpty(id)).ToList();
|
|
if (ids.Count == 0)
|
|
return new List<Account>();
|
|
var parts = q.Split('@');
|
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
|
bool Matches(string userName, string name, string domain) =>
|
|
userName?.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase) == true
|
|
&& (parts.Length < 2 || domain?.StartsWith(parts[1], StringComparison.OrdinalIgnoreCase) == true)
|
|
|| parts.Length == 1 && name?.Contains(q, StringComparison.OrdinalIgnoreCase) == true;
|
|
var found = (await _dbEntities.Avatars.Match(a => ids.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
|
.Where(a => Matches(a.UserName, a.Name, localDomain)).Select(a => (a.UserName, a.ID))
|
|
.Concat((await _dbEntities.ForeignAvatars.Match(f => ids.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
|
|
.Where(f => Matches(f.UserName, f.Name, f.Domain)).Select(f => (f.UserName, f.ID)))
|
|
.OrderBy(a => a.UserName, StringComparer.OrdinalIgnoreCase).Take(limit).Select(a => a.ID).ToList();
|
|
var accounts = await _mapper.Accounts(found, token);
|
|
return found.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList();
|
|
}
|
|
|
|
// gone accounts (remote ones that deleted themselves, personas of banned or deleted roots) are never found
|
|
public async Task<List<Account>> Find(string q, bool resolve, int limit, int offset, CancellationToken token)
|
|
{
|
|
q = q?.Trim().TrimStart('@');
|
|
if (string.IsNullOrEmpty(q))
|
|
return new List<Account>();
|
|
var results = new List<Account>();
|
|
var parts = q.Split('@');
|
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
|
if (parts.Length == 2 && !parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(parts[0]))
|
|
{
|
|
var userName = parts[0];
|
|
var domain = parts[1].ToLowerInvariant();
|
|
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (known == default && resolve)
|
|
{
|
|
var actorUri = await _remoteActors.ResolveHandle(q, token);
|
|
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
|
|
}
|
|
if (known is { DeletionAt: null } && offset == 0)
|
|
results.Add(_mapper.Foreign(known));
|
|
return results;
|
|
}
|
|
|
|
var prefix = parts[0].ToLowerInvariant();
|
|
var barred = await Domain.Privacy.Counted.BarredPersonas(token);
|
|
var locals = await _dbEntities.Avatars
|
|
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
|
|
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
|
|
.Limit(offset + limit).ExecuteAsync(token);
|
|
foreach (var avatar in locals.Skip(offset))
|
|
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
|
|
var skipRemote = Math.Max(0, offset - locals.Count);
|
|
if (results.Count < limit)
|
|
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
|
|
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
|
|
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
|
|
results.Add(_mapper.Foreign(foreign));
|
|
return results;
|
|
}
|
|
}
|
|
}
|