using System;
using System.Collections.Generic;
using System.Diagnostics;
-using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Windows.Forms;
using OpenTween.Api;
using OpenTween.Api.DataModel;
+using OpenTween.Api.GraphQL;
+using OpenTween.Api.TwitterV2;
using OpenTween.Connection;
using OpenTween.Models;
using OpenTween.Setting;
private const string NonLatinHashtagChars = @"\u0400-\u04ff\u0500-\u0527\u1100-\u11ff\u3130-\u3185\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF";
private const string CJHashtagCharacters = @"\u30A1-\u30FA\u30FC\u3005\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u309A\u3400-\u4DBF\p{IsCJKUnifiedIdeographs}";
private const string HashtagBoundary = @"^|$|\s|「|」|。|\.|!";
- private const string HashtagAlpha = "[A-Za-z_" + LatinAccents + NonLatinHashtagChars + CJHashtagCharacters + "]";
- private const string HashtagAlphanumeric = "[A-Za-z0-9_" + LatinAccents + NonLatinHashtagChars + CJHashtagCharacters + "]";
- private const string HashtagTerminator = "[^A-Za-z0-9_" + LatinAccents + NonLatinHashtagChars + CJHashtagCharacters + "]";
- public const string Hashtag = "(" + HashtagBoundary + ")(#|#)(" + HashtagAlphanumeric + "*" + HashtagAlpha + HashtagAlphanumeric + "*)(?=" + HashtagTerminator + "|" + HashtagBoundary + ")";
+ private const string HashtagAlpha = $"[A-Za-z_{LatinAccents}{NonLatinHashtagChars}{CJHashtagCharacters}]";
+ private const string HashtagAlphanumeric = $"[A-Za-z0-9_{LatinAccents}{NonLatinHashtagChars}{CJHashtagCharacters}]";
+ private const string HashtagTerminator = $"[^A-Za-z0-9_{LatinAccents}{NonLatinHashtagChars}{CJHashtagCharacters}]";
+ public const string Hashtag = $"({HashtagBoundary})(#|#)({HashtagAlphanumeric}*{HashtagAlpha}{HashtagAlphanumeric}*)(?={HashtagTerminator}|{HashtagBoundary})";
// URL正規表現
private const string UrlValidPrecedingChars = @"(?:[^A-Za-z0-9@@$##\ufffe\ufeff\uffff\u202a-\u202e]|^)";
public const string UrlInvalidWithoutProtocolPrecedingChars = @"[-_./]$";
private const string UrlInvalidDomainChars = @"\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$\u2000-\u200a\u0009-\u000d\u0020\u0085\u00a0\u1680\u180e\u2028\u2029\u202f\u205f\u3000\ufffe\ufeff\uffff\u202a-\u202e";
- private const string UrlValidDomainChars = @"[^" + UrlInvalidDomainChars + "]";
- private const string UrlValidSubdomain = @"(?:(?:" + UrlValidDomainChars + @"(?:[_-]|" + UrlValidDomainChars + @")*)?" + UrlValidDomainChars + @"\.)";
- private const string UrlValidDomainName = @"(?:(?:" + UrlValidDomainChars + @"(?:-|" + UrlValidDomainChars + @")*)?" + UrlValidDomainChars + @"\.)";
+ private const string UrlValidDomainChars = $@"[^{UrlInvalidDomainChars}]";
+ private const string UrlValidSubdomain = $@"(?:(?:{UrlValidDomainChars}(?:[_-]|{UrlValidDomainChars})*)?{UrlValidDomainChars}\.)";
+ private const string UrlValidDomainName = $@"(?:(?:{UrlValidDomainChars}(?:-|{UrlValidDomainChars})*)?{UrlValidDomainChars}\.)";
private const string UrlValidGTLD = @"(?:(?:aero|asia|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|xxx)(?=[^0-9a-zA-Z]|$))";
private const string UrlValidCCTLD = @"(?:(?:ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)(?=[^0-9a-zA-Z]|$))";
private const string UrlValidPunycode = @"(?:xn--[0-9a-z]+)";
- private const string UrlValidDomain = @"(?<domain>" + UrlValidSubdomain + "*" + UrlValidDomainName + "(?:" + UrlValidGTLD + "|" + UrlValidCCTLD + ")|" + UrlValidPunycode + ")";
- public const string UrlValidAsciiDomain = @"(?:(?:[a-z0-9" + LatinAccents + @"]+)\.)+(?:" + UrlValidGTLD + "|" + UrlValidCCTLD + "|" + UrlValidPunycode + ")";
- public const string UrlInvalidShortDomain = "^" + UrlValidDomainName + UrlValidCCTLD + "$";
+ private const string UrlValidDomain = $@"(?<domain>{UrlValidSubdomain}*{UrlValidDomainName}(?:{UrlValidGTLD}|{UrlValidCCTLD})|{UrlValidPunycode})";
+ public const string UrlValidAsciiDomain = $@"(?:(?:[a-z0-9{LatinAccents}]+)\.)+(?:{UrlValidGTLD}|{UrlValidCCTLD}|{UrlValidPunycode})";
+ public const string UrlInvalidShortDomain = $"^{UrlValidDomainName}{UrlValidCCTLD}$";
private const string UrlValidPortNumber = @"[0-9]+";
- private const string UrlValidGeneralPathChars = @"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&" + LatinAccents + "]";
- private const string UrlBalanceParens = @"(?:\(" + UrlValidGeneralPathChars + @"+\))";
- private const string UrlValidPathEndingChars = @"(?:[+\-a-z0-9=_#/" + LatinAccents + "]|" + UrlBalanceParens + ")";
+ private const string UrlValidGeneralPathChars = $@"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&{LatinAccents}]";
+ private const string UrlBalanceParens = $@"(?:\({UrlValidGeneralPathChars}+\))";
+ private const string UrlValidPathEndingChars = $@"(?:[+\-a-z0-9=_#/{LatinAccents}]|{UrlBalanceParens})";
private const string Pth = "(?:" +
"(?:" +
- UrlValidGeneralPathChars + "*" +
- "(?:" + UrlBalanceParens + UrlValidGeneralPathChars + "*)*" +
+ $"{UrlValidGeneralPathChars}*" +
+ $"(?:{UrlBalanceParens}{UrlValidGeneralPathChars}*)*" +
UrlValidPathEndingChars +
- ")|(?:@" + UrlValidGeneralPathChars + "+/)" +
+ $")|(?:@{UrlValidGeneralPathChars}+/)" +
")";
private const string Qry = @"(?<query>\?[a-z0-9!?*'();:&=+$/%#\[\]\-_.,~|]*[a-z0-9_&=#/])?";
- public const string RgUrl = @"(?<before>" + UrlValidPrecedingChars + ")" +
+ public const string RgUrl = $@"(?<before>{UrlValidPrecedingChars})" +
"(?<url>(?<protocol>https?://)?" +
- "(?<domain>" + UrlValidDomain + ")" +
- "(?::" + UrlValidPortNumber + ")?" +
- "(?<path>/" + Pth + "*)?" +
+ $"(?<domain>{UrlValidDomain})" +
+ $"(?::{UrlValidPortNumber})?" +
+ $"(?<path>/{Pth}*)?" +
Qry +
")";
/// <summary>
/// ツイートへのパーマリンクURLを判定する正規表現
/// </summary>
- public static readonly Regex StatusUrlRegex = new Regex(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
+ public static readonly Regex StatusUrlRegex = new(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
/// <summary>
/// attachment_url に指定可能な URL を判定する正規表現
/// </summary>
- public static readonly Regex AttachmentUrlRegex = new Regex(
+ public static readonly Regex AttachmentUrlRegex = new(
@"https?://(
twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
| mobile\.twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
/// <summary>
/// FavstarやaclogなどTwitter関連サービスのパーマリンクURLからステータスIDを抽出する正規表現
/// </summary>
- public static readonly Regex ThirdPartyStatusUrlRegex = new Regex(
+ public static readonly Regex ThirdPartyStatusUrlRegex = new(
@"https?://(?:[^.]+\.)?(?:
favstar\.fm/users/[a-zA-Z0-9_]+/status/ # Favstar
| favstar\.fm/t/ # Favstar (short)
/// <summary>
/// DM送信かどうかを判定する正規表現
/// </summary>
- public static readonly Regex DMSendTextRegex = new Regex(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
+ public static readonly Regex DMSendTextRegex = new(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
public TwitterApi Api { get; }
private delegate void GetIconImageDelegate(PostClass post);
- private readonly object lockObj = new object();
+ private readonly object lockObj = new();
private ISet<long> followerId = new HashSet<long>();
private long[] noRTId = Array.Empty<long>();
- // プロパティからアクセスされる共通情報
- private readonly List<string> hashList = new List<string>();
+ private readonly TwitterPostFactory postFactory;
+ private readonly PostUrlExpander urlExpander;
- private string? nextCursorDirectMessage = null;
-
- private long previousStatusId = -1L;
+ private string? previousStatusId = null;
public Twitter(TwitterApi api)
{
+ this.postFactory = new(TabInformations.GetInstance());
+ this.urlExpander = new(ShortUrl.Instance);
+
this.Api = api;
this.Configuration = TwitterConfiguration.DefaultConfiguration();
this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
this.UpdateUserStats(user);
}
- public void Initialize(string token, string tokenSecret, string username, long userId)
+ public void Initialize(ITwitterCredential credential, string username, long userId)
{
// OAuth認証
- if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
- {
+ if (credential is TwitterCredentialNone)
Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
- }
- this.ResetApiStatus();
- this.Api.Initialize(token, tokenSecret, userId, username);
- }
-
- internal static string PreProcessUrl(string orgData)
- {
- int posl1;
- var posl2 = 0;
- var href = "<a href=\"";
-
- while (true)
- {
- if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
- {
- // IDN展開
- posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
- posl1 += href.Length;
- posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
- var urlStr = orgData.Substring(posl1, posl2 - posl1);
-
- if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
- && !urlStr.StartsWith("https://", StringComparison.Ordinal)
- && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
- {
- continue;
- }
-
- var replacedUrl = MyCommon.IDNEncode(urlStr);
- if (replacedUrl == null) continue;
- if (replacedUrl == urlStr) continue;
- orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
- posl2 = 0;
- }
- else
- {
- break;
- }
- }
- return orgData;
+ this.ResetApiStatus();
+ this.Api.Initialize(credential, userId, username);
}
public async Task<PostClass?> PostStatus(PostStatusParams param)
return null;
}
- var response = await this.Api.StatusesUpdate(
- param.Text,
- param.InReplyToStatusId,
- param.MediaIds,
- param.AutoPopulateReplyMetadata,
- param.ExcludeReplyUserIds,
- param.AttachmentUrl
- )
- .ConfigureAwait(false);
+ TwitterStatus status;
- var status = await response.LoadJsonAsync()
- .ConfigureAwait(false);
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new CreateTweetRequest
+ {
+ TweetText = param.Text,
+ InReplyToTweetId = param.InReplyToStatusId?.ToTwitterStatusId(),
+ ExcludeReplyUserIds = param.ExcludeReplyUserIds.Select(x => x.ToString()).ToArray(),
+ MediaIds = param.MediaIds.Select(x => x.ToString()).ToArray(),
+ AttachmentUrl = param.AttachmentUrl,
+ };
+
+ status = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ using var response = await this.Api.StatusesUpdate(
+ param.Text,
+ param.InReplyToStatusId?.ToTwitterStatusId(),
+ param.MediaIds,
+ param.AutoPopulateReplyMetadata,
+ param.ExcludeReplyUserIds,
+ param.AttachmentUrl
+ )
+ .ConfigureAwait(false);
+
+ status = await response.LoadJsonAsync()
+ .ConfigureAwait(false);
+ }
this.UpdateUserStats(status.User);
- if (status.Id == this.previousStatusId)
+ if (status.IdStr == this.previousStatusId)
throw new WebApiException("OK:Delaying?");
- this.previousStatusId = status.Id;
+ this.previousStatusId = status.IdStr;
// 投稿したものを返す
var post = this.CreatePostsFromStatusData(status);
return post;
}
+ public async Task DeleteTweet(TwitterStatusId tweetId)
+ {
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new DeleteTweetRequest
+ {
+ TweetId = tweetId,
+ };
+ await request.Send(this.Api.Connection);
+ }
+ else
+ {
+ await this.Api.StatusesDestroy(tweetId)
+ .IgnoreResponse();
+ }
+ }
+
public async Task<long> UploadMedia(IMediaItem item, string? mediaCategory = null)
{
this.CheckAccountState();
_ => "application/octet-stream",
};
- var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
+ using var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
.ConfigureAwait(false);
var initMedia = await initResponse.LoadJsonAsync()
await this.Api.MediaUploadAppend(mediaId, 0, item)
.ConfigureAwait(false);
- var response = await this.Api.MediaUploadFinalize(mediaId)
+ using var response = await this.Api.MediaUploadFinalize(mediaId)
.ConfigureAwait(false);
var media = await response.LoadJsonAsync()
var body = mc.Groups["body"].Value;
var recipientName = mc.Groups["id"].Value;
- var recipient = await this.Api.UsersShow(recipientName)
+ var recipient = await this.GetUserInfo(recipientName)
.ConfigureAwait(false);
- var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
+ using var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
.ConfigureAwait(false);
var messageEventSingle = await response.LoadJsonAsync()
.ConfigureAwait(false);
}
- public async Task<PostClass?> PostRetweet(long id, bool read)
+ public async Task<PostClass?> PostRetweet(PostId id, bool read)
{
this.CheckAccountState();
var target = post.RetweetedId ?? id; // 再RTの場合は元発言をRT
- var response = await this.Api.StatusesRetweet(target)
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new CreateRetweetRequest
+ {
+ TweetId = target.ToTwitterStatusId(),
+ };
+ await request.Send(this.Api.Connection).ConfigureAwait(false);
+ return null;
+ }
+
+ using var response = await this.Api.StatusesRetweet(target.ToTwitterStatusId())
.ConfigureAwait(false);
var status = await response.LoadJsonAsync()
// 二重取得回避
lock (this.lockObj)
{
- if (TabInformations.GetInstance().ContainsKey(status.Id))
+ var statusId = new TwitterStatusId(status.IdStr);
+ if (TabInformations.GetInstance().ContainsKey(statusId))
return null;
}
throw new WebApiException("Invalid Json!");
// Retweetしたものを返す
- post = this.CreatePostsFromStatusData(status);
+ return this.CreatePostsFromStatusData(status) with
+ {
+ IsMe = true,
+ IsRead = this.ReadOwnPost ? true : read,
+ IsOwl = false,
+ };
+ }
- // ユーザー情報
- post.IsMe = true;
+ public async Task DeleteRetweet(PostClass post)
+ {
+ if (post.RetweetedId == null)
+ throw new ArgumentException("post is not retweeted status", nameof(post));
- post.IsRead = read;
- post.IsOwl = false;
- if (this.ReadOwnPost) post.IsRead = true;
- post.IsDm = false;
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new DeleteRetweetRequest
+ {
+ SourceTweetId = post.RetweetedId.ToTwitterStatusId(),
+ };
+ await request.Send(this.Api.Connection).ConfigureAwait(false);
+ }
+ else
+ {
+ await this.Api.StatusesDestroy(post.StatusId.ToTwitterStatusId())
+ .IgnoreResponse();
+ }
+ }
- return post;
+ public async Task<TwitterUser> GetUserInfo(string screenName)
+ {
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new UserByScreenNameRequest
+ {
+ ScreenName = screenName,
+ };
+ var response = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+
+ return response.ToTwitterUser();
+ }
+ else
+ {
+ var user = await this.Api.UsersShow(screenName)
+ .ConfigureAwait(false);
+
+ return user;
+ }
}
public string Username
// https://dev.twitter.com/rest/public
return type switch
{
- MyCommon.WORKERTYPE.Timeline => 200,
+ MyCommon.WORKERTYPE.Timeline => 100,
MyCommon.WORKERTYPE.Reply => 200,
MyCommon.WORKERTYPE.UserTimeline => 200,
MyCommon.WORKERTYPE.Favorites => 200,
/// </summary>
public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
{
- if (SettingManager.Common.UseAdditionalCount)
+ if (SettingManager.Instance.Common.UseAdditionalCount)
{
switch (type)
{
case MyCommon.WORKERTYPE.Favorites:
- if (SettingManager.Common.FavoritesCountApi != 0)
- return SettingManager.Common.FavoritesCountApi;
+ if (SettingManager.Instance.Common.FavoritesCountApi != 0)
+ return SettingManager.Instance.Common.FavoritesCountApi;
break;
case MyCommon.WORKERTYPE.List:
- if (SettingManager.Common.ListCountApi != 0)
- return SettingManager.Common.ListCountApi;
+ if (SettingManager.Instance.Common.ListCountApi != 0)
+ return SettingManager.Instance.Common.ListCountApi;
break;
case MyCommon.WORKERTYPE.PublicSearch:
- if (SettingManager.Common.SearchCountApi != 0)
- return SettingManager.Common.SearchCountApi;
+ if (SettingManager.Instance.Common.SearchCountApi != 0)
+ return SettingManager.Instance.Common.SearchCountApi;
break;
case MyCommon.WORKERTYPE.UserTimeline:
- if (SettingManager.Common.UserTimelineCountApi != 0)
- return SettingManager.Common.UserTimelineCountApi;
+ if (SettingManager.Instance.Common.UserTimelineCountApi != 0)
+ return SettingManager.Instance.Common.UserTimelineCountApi;
break;
}
- if (more && SettingManager.Common.MoreCountApi != 0)
+ if (more && SettingManager.Instance.Common.MoreCountApi != 0)
{
- return Math.Min(SettingManager.Common.MoreCountApi, GetMaxApiResultCount(type));
+ return Math.Min(SettingManager.Instance.Common.MoreCountApi, GetMaxApiResultCount(type));
}
- if (startup && SettingManager.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
+ if (startup && SettingManager.Instance.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
{
- return Math.Min(SettingManager.Common.FirstCountApi, GetMaxApiResultCount(type));
+ return Math.Min(SettingManager.Instance.Common.FirstCountApi, GetMaxApiResultCount(type));
}
}
// 上記に当てはまらない場合の共通処理
- var count = SettingManager.Common.CountApi;
+ var count = SettingManager.Instance.Common.CountApi;
if (type == MyCommon.WORKERTYPE.Reply)
- count = SettingManager.Common.CountApiReply;
+ count = SettingManager.Instance.Common.CountApiReply;
return Math.Min(count, GetMaxApiResultCount(type));
}
var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
TwitterStatus[] statuses;
- if (more)
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- statuses = await this.Api.StatusesHomeTimeline(count, maxId: tab.OldestId)
+ var request = new HomeLatestTimelineRequest
+ {
+ Count = count,
+ Cursor = more ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+
+ statuses = response.ToTwitterStatuses();
+
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!more)
+ tab.CursorTop = response.CursorTop;
+ }
+ else if (SettingManager.Instance.Common.EnableTwitterV2Api)
+ {
+ var request = new GetTimelineRequest(this.UserId)
+ {
+ MaxResults = count,
+ UntilId = more ? tab.OldestId as TwitterStatusId : null,
+ };
+
+ var response = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+
+ if (response.Data == null || response.Data.Length == 0)
+ return;
+
+ var tweetIds = response.Data.Select(x => x.Id).ToList();
+
+ statuses = await this.Api.StatusesLookup(tweetIds)
.ConfigureAwait(false);
}
else
{
- statuses = await this.Api.StatusesHomeTimeline(count)
+ var maxId = more ? tab.OldestId : null;
+
+ statuses = await this.Api.StatusesHomeTimeline(count, maxId as TwitterStatusId)
.ConfigureAwait(false);
}
var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
if (minimumId != null)
- tab.OldestId = minimumId.Value;
+ tab.OldestId = minimumId;
}
public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
TwitterStatus[] statuses;
- if (more)
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId)
+ var request = new NotificationsMentionsRequest
+ {
+ Count = Math.Min(count, 50),
+ Cursor = more ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
.ConfigureAwait(false);
+
+ statuses = response.Statuses;
+
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!more)
+ tab.CursorTop = response.CursorTop;
}
else
{
- statuses = await this.Api.StatusesMentionsTimeline(count)
- .ConfigureAwait(false);
+ if (more)
+ {
+ statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId as TwitterStatusId)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ statuses = await this.Api.StatusesMentionsTimeline(count)
+ .ConfigureAwait(false);
+ }
}
var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
if (minimumId != null)
- tab.OldestId = minimumId.Value;
+ tab.OldestId = minimumId;
}
- public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTabModel tab, bool more)
+ public async Task GetUserTimelineApi(bool read, UserTimelineTabModel tab, bool more)
{
this.CheckAccountState();
var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
TwitterStatus[] statuses;
- if (MyCommon.IsNullOrEmpty(userName))
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- var target = tab.ScreenName;
- if (MyCommon.IsNullOrEmpty(target)) return;
- userName = target;
- statuses = await this.Api.StatusesUserTimeline(userName, count)
+ var userId = tab.UserId;
+ if (MyCommon.IsNullOrEmpty(userId))
+ {
+ var user = await this.GetUserInfo(tab.ScreenName)
+ .ConfigureAwait(false);
+
+ userId = user.IdStr;
+ tab.UserId = user.IdStr;
+ }
+
+ var request = new UserTweetsAndRepliesRequest(userId)
+ {
+ Count = count,
+ Cursor = more ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
.ConfigureAwait(false);
+
+ statuses = response.ToTwitterStatuses()
+ .Where(x => x.User.IdStr == userId) // リプライツリーに含まれる他ユーザーのツイートを除外
+ .ToArray();
+
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!more)
+ tab.CursorTop = response.CursorTop;
}
else
{
if (more)
{
- statuses = await this.Api.StatusesUserTimeline(userName, count, maxId: tab.OldestId)
+ statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count, maxId: tab.OldestId as TwitterStatusId)
.ConfigureAwait(false);
}
else
{
- statuses = await this.Api.StatusesUserTimeline(userName, count)
+ statuses = await this.Api.StatusesUserTimeline(tab.ScreenName, count)
.ConfigureAwait(false);
}
}
var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
if (minimumId != null)
- tab.OldestId = minimumId.Value;
+ tab.OldestId = minimumId;
}
- public async Task<PostClass> GetStatusApi(bool read, long id)
+ public async Task<PostClass> GetStatusApi(bool read, TwitterStatusId id)
{
this.CheckAccountState();
- var status = await this.Api.StatusesShow(id)
- .ConfigureAwait(false);
+ TwitterStatus status;
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new TweetDetailRequest
+ {
+ FocalTweetId = id,
+ };
+ var tweets = await request.Send(this.Api.Connection).ConfigureAwait(false);
+ status = tweets.Select(x => x.ToTwitterStatus())
+ .Where(x => x.IdStr == id.Id)
+ .FirstOrDefault() ?? throw new WebApiException("Empty result set");
+ }
+ else
+ {
+ status = await this.Api.StatusesShow(id)
+ .ConfigureAwait(false);
+ }
var item = this.CreatePostsFromStatusData(status);
return item;
}
- public async Task GetStatusApi(bool read, long id, TabModel tab)
+ public async Task GetStatusApi(bool read, TwitterStatusId id, TabModel tab)
{
var post = await this.GetStatusApi(read, id)
.ConfigureAwait(false);
}
private PostClass CreatePostsFromStatusData(TwitterStatus status)
- => this.CreatePostsFromStatusData(status, false);
+ => this.CreatePostsFromStatusData(status, favTweet: false);
private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
{
- var post = new PostClass();
- TwitterEntities entities;
- string sourceHtml;
-
- post.StatusId = status.Id;
- if (status.RetweetedStatus != null)
- {
- var retweeted = status.RetweetedStatus;
-
- post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
-
- // Id
- post.RetweetedId = retweeted.Id;
- // 本文
- post.TextFromApi = retweeted.FullText;
- entities = retweeted.MergedEntities;
- sourceHtml = retweeted.Source;
- // Reply先
- post.InReplyToStatusId = retweeted.InReplyToStatusId;
- post.InReplyToUser = retweeted.InReplyToScreenName;
- post.InReplyToUserId = status.InReplyToUserId;
-
- if (favTweet)
- {
- post.IsFav = true;
- }
- else
- {
- // 幻覚fav対策
- var tc = TabInformations.GetInstance().FavoriteTab;
- post.IsFav = tc.Contains(retweeted.Id);
- }
+ var post = this.postFactory.CreateFromStatus(status, this.UserId, this.followerId, favTweet);
+ _ = this.urlExpander.Expand(post);
- if (retweeted.Coordinates != null)
- post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
-
- // 以下、ユーザー情報
- var user = retweeted.User;
- if (user != null)
- {
- post.UserId = user.Id;
- post.ScreenName = user.ScreenName;
- post.Nickname = user.Name.Trim();
- post.ImageUrl = user.ProfileImageUrlHttps;
- post.IsProtect = user.Protected;
- }
- else
- {
- post.UserId = 0L;
- post.ScreenName = "?????";
- post.Nickname = "Unknown User";
- }
-
- // Retweetした人
- if (status.User != null)
- {
- post.RetweetedBy = status.User.ScreenName;
- post.RetweetedByUserId = status.User.Id;
- post.IsMe = post.RetweetedByUserId == this.UserId;
- }
- else
- {
- post.RetweetedBy = "?????";
- post.RetweetedByUserId = 0L;
- }
- }
- else
- {
- post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
- // 本文
- post.TextFromApi = status.FullText;
- entities = status.MergedEntities;
- sourceHtml = status.Source;
- post.InReplyToStatusId = status.InReplyToStatusId;
- post.InReplyToUser = status.InReplyToScreenName;
- post.InReplyToUserId = status.InReplyToUserId;
-
- if (favTweet)
- {
- post.IsFav = true;
- }
- else
- {
- // 幻覚fav対策
- var tc = TabInformations.GetInstance().FavoriteTab;
- post.IsFav = tc.Posts.TryGetValue(post.StatusId, out var tabinfoPost) && tabinfoPost.IsFav;
- }
-
- if (status.Coordinates != null)
- post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
-
- // 以下、ユーザー情報
- var user = status.User;
- if (user != null)
- {
- post.UserId = user.Id;
- post.ScreenName = user.ScreenName;
- post.Nickname = user.Name.Trim();
- post.ImageUrl = user.ProfileImageUrlHttps;
- post.IsProtect = user.Protected;
- post.IsMe = post.UserId == this.UserId;
- }
- else
- {
- post.UserId = 0L;
- post.ScreenName = "?????";
- post.Nickname = "Unknown User";
- }
- }
- // HTMLに整形
- var textFromApi = post.TextFromApi;
-
- var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
-
- if (quotedStatusLink != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
- quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
-
- post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
- post.TextFromApi = textFromApi;
- post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
- post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
- post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
- post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
- post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
- post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
-
- this.ExtractEntities(entities, post.ReplyToList, post.Media);
-
- post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
- .Where(x => x != post.StatusId && x != post.RetweetedId)
- .Distinct().ToArray();
-
- post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
- .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
- .ToArray();
-
- // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
- if (post.Text == post.TextFromApi)
- post.Text = post.TextFromApi;
- if (post.AccessibleText == post.TextFromApi)
- post.AccessibleText = post.TextFromApi;
-
- // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
- post.ScreenName = string.Intern(post.ScreenName);
- post.Nickname = string.Intern(post.Nickname);
- post.ImageUrl = string.Intern(post.ImageUrl);
- post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
-
- // Source整形
- var (sourceText, sourceUri) = ParseSource(sourceHtml);
- post.Source = string.Intern(sourceText);
- post.SourceUri = sourceUri;
-
- post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == this.UserId);
- post.IsExcludeReply = false;
-
- if (post.IsMe)
- {
- post.IsOwl = false;
- }
- else
- {
- if (this.followerId.Count > 0) post.IsOwl = !this.followerId.Contains(post.UserId);
- }
-
- post.IsDm = false;
return post;
}
- /// <summary>
- /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
- /// </summary>
- public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity>? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
+ private PostId? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
{
- entities ??= Enumerable.Empty<TwitterEntity>();
-
- var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
+ PostId? minimumId = null;
- if (quotedStatusLink != null)
- urls = urls.Append(quotedStatusLink.Expanded);
+ var posts = items.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
- return GetQuoteTweetStatusIds(urls);
- }
+ TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
- public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
- {
- foreach (var url in urls)
+ foreach (var post in posts)
{
- var match = Twitter.StatusUrlRegex.Match(url);
- if (match.Success)
+ if (!post.IsPromoted)
{
- if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
- yield return statusId;
+ if (minimumId == null || minimumId > post.StatusId)
+ minimumId = post.StatusId;
}
- }
- }
-
- private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
- {
- long? minimumId = null;
-
- foreach (var status in items)
- {
- if (minimumId == null || minimumId.Value > status.Id)
- minimumId = status.Id;
// 二重取得回避
lock (this.lockObj)
{
+ var id = post.StatusId;
if (tab == null)
{
- if (TabInformations.GetInstance().ContainsKey(status.Id)) continue;
+ if (TabInformations.GetInstance().ContainsKey(id)) continue;
}
else
{
- if (tab.Contains(status.Id)) continue;
+ if (tab.Contains(id)) continue;
}
}
// RT禁止ユーザーによるもの
if (gType != MyCommon.WORKERTYPE.UserTimeline &&
- status.RetweetedStatus != null && this.noRTId.Contains(status.User.Id)) continue;
-
- var post = this.CreatePostsFromStatusData(status);
+ post.RetweetedByUserId != null && this.noRTId.Contains(post.RetweetedByUserId.Value)) continue;
post.IsRead = read;
if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
return minimumId;
}
- private long? CreatePostsFromSearchJson(TwitterSearchResult items, PublicSearchTabModel tab, bool read, bool more)
+ private PostId? CreatePostsFromSearchJson(TwitterStatus[] statuses, PublicSearchTabModel tab, bool read, bool more)
{
- long? minimumId = null;
+ PostId? minimumId = null;
+
+ var posts = statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
+
+ TwitterPostFactory.AdjustSortKeyForPromotedPost(posts);
- foreach (var status in items.Statuses)
+ foreach (var post in posts)
{
- if (minimumId == null || minimumId.Value > status.Id)
- minimumId = status.Id;
+ if (!post.IsPromoted)
+ {
+ if (minimumId == null || minimumId > post.StatusId)
+ minimumId = post.StatusId;
+
+ if (!more && (tab.SinceId == null || post.StatusId > tab.SinceId))
+ tab.SinceId = post.StatusId;
+ }
- if (!more && status.Id > tab.SinceId) tab.SinceId = status.Id;
// 二重取得回避
lock (this.lockObj)
{
- if (tab.Contains(status.Id)) continue;
+ if (tab.Contains(post.StatusId))
+ continue;
}
- var post = this.CreatePostsFromStatusData(status);
-
post.IsRead = read;
if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
// 二重取得回避
lock (this.lockObj)
{
- if (favTab.Contains(status.Id)) continue;
+ if (favTab.Contains(new TwitterStatusId(status.IdStr)))
+ continue;
}
var post = this.CreatePostsFromStatusData(status, true);
var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
TwitterStatus[] statuses;
- if (more)
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId, includeRTs: SettingManager.Common.IsListsIncludeRts)
+ var request = new ListLatestTweetsTimelineRequest(tab.ListInfo.Id.ToString())
+ {
+ Count = count,
+ Cursor = more ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+
+ var convertedStatuses = response.ToTwitterStatuses();
+
+ if (!SettingManager.Instance.Common.IsListsIncludeRts)
+ convertedStatuses = convertedStatuses.Where(x => x.RetweetedStatus == null).ToArray();
+
+ statuses = convertedStatuses.ToArray();
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!more)
+ tab.CursorTop = response.CursorTop;
+ }
+ else if (more)
+ {
+ statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId as TwitterStatusId, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
.ConfigureAwait(false);
}
else
{
- statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Common.IsListsIncludeRts)
+ statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Instance.Common.IsListsIncludeRts)
.ConfigureAwait(false);
}
var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
if (minimumId != null)
- tab.OldestId = minimumId.Value;
+ tab.OldestId = minimumId;
}
/// <summary>
/// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
/// </summary>
/// <returns>posts の中から検索されたリプライチェインの末端</returns>
- internal static PostClass FindTopOfReplyChain(IDictionary<long, PostClass> posts, long startStatusId)
+ internal static PostClass FindTopOfReplyChain(IDictionary<PostId, PostClass> posts, PostId startStatusId)
{
if (!posts.ContainsKey(startStatusId))
- throw new ArgumentException("startStatusId (" + startStatusId + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
+ throw new ArgumentException("startStatusId (" + startStatusId.Id + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
var nextPost = posts[startStatusId];
while (nextPost.InReplyToStatusId != null)
{
- if (!posts.ContainsKey(nextPost.InReplyToStatusId.Value))
+ if (!posts.ContainsKey(nextPost.InReplyToStatusId))
break;
- nextPost = posts[nextPost.InReplyToStatusId.Value];
+ nextPost = posts[nextPost.InReplyToStatusId];
}
return nextPost;
public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
{
var targetPost = tab.TargetPost;
- var relPosts = new Dictionary<long, PostClass>();
+
+ if (targetPost.RetweetedId != null)
+ {
+ var originalPost = targetPost with
+ {
+ StatusId = targetPost.RetweetedId,
+ RetweetedId = null,
+ RetweetedBy = null,
+ };
+ targetPost = originalPost;
+ }
+
+ var relPosts = new Dictionary<PostId, PostClass>();
if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
{
// 検索結果対応
}
else
{
- p = await this.GetStatusApi(read, targetPost.StatusId)
+ p = await this.GetStatusApi(read, targetPost.StatusId.ToTwitterStatusId())
.ConfigureAwait(false);
targetPost = p;
}
var loopCount = 1;
while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
{
- var inReplyToId = nextPost.InReplyToStatusId.Value;
+ var inReplyToId = nextPost.InReplyToStatusId;
var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
if (inReplyToPost == null)
{
try
{
- inReplyToPost = await this.GetStatusApi(read, inReplyToId)
+ inReplyToPost = await this.GetStatusApi(read, inReplyToId.ToTwitterStatusId())
.ConfigureAwait(false);
}
catch (WebApiException ex)
.Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
foreach (var match in ma)
{
- if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
+ var statusId = new TwitterStatusId(match.Groups["StatusId"].Value);
+ if (!relPosts.ContainsKey(statusId))
{
- if (relPosts.ContainsKey(statusId))
- continue;
-
var p = TabInformations.GetInstance()[statusId];
if (p == null)
{
}
}
+ try
+ {
+ var firstPost = nextPost;
+ var posts = await this.GetConversationPosts(firstPost, targetPost)
+ .ConfigureAwait(false);
+
+ foreach (var post in posts.OrderBy(x => x.StatusId))
+ {
+ if (relPosts.ContainsKey(post.StatusId))
+ continue;
+
+ // リプライチェーンが繋がらないツイートは除外
+ if (post.InReplyToStatusId == null || !relPosts.ContainsKey(post.InReplyToStatusId))
+ continue;
+
+ relPosts.Add(post.StatusId, post);
+ }
+ }
+ catch (WebException ex)
+ {
+ lastException = ex;
+ }
+
relPosts.Values.ToList().ForEach(p =>
{
- if (p.IsMe && !read && this.ReadOwnPost)
- p.IsRead = true;
+ var post = p with { };
+ if (post.IsMe && !read && this.ReadOwnPost)
+ post.IsRead = true;
else
- p.IsRead = read;
+ post.IsRead = read;
- tab.AddPostQueue(p);
+ tab.AddPostQueue(post);
});
if (lastException != null)
throw new WebApiException(lastException.Message, lastException);
}
+ private async Task<PostClass[]> GetConversationPosts(PostClass firstPost, PostClass targetPost)
+ {
+ var conversationId = firstPost.StatusId;
+ var query = $"conversation_id:{conversationId.Id}";
+
+ if (targetPost.InReplyToUser != null && targetPost.InReplyToUser != targetPost.ScreenName)
+ query += $" (from:{targetPost.ScreenName} to:{targetPost.InReplyToUser}) OR (from:{targetPost.InReplyToUser} to:{targetPost.ScreenName})";
+ else
+ query += $" from:{targetPost.ScreenName} to:{targetPost.ScreenName}";
+
+ TwitterStatus[] statuses;
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new SearchTimelineRequest(query);
+ var response = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+
+ statuses = response.ToTwitterStatuses();
+ }
+ else
+ {
+ var response = await this.Api.SearchTweets(query, count: 100)
+ .ConfigureAwait(false);
+
+ statuses = response.Statuses;
+ }
+
+ return statuses.Select(x => this.CreatePostsFromStatusData(x)).ToArray();
+ }
+
public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
{
var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
- long? maxId = null;
- long? sinceId = null;
- if (more)
+ TwitterStatus[] statuses;
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- maxId = tab.OldestId - 1;
+ var query = tab.SearchWords;
+
+ if (!MyCommon.IsNullOrEmpty(tab.SearchLang))
+ query = $"({query}) lang:{tab.SearchLang}";
+
+ var request = new SearchTimelineRequest(query)
+ {
+ Count = count,
+ Cursor = more ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+
+ statuses = response.ToTwitterStatuses();
+
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!more)
+ tab.CursorTop = response.CursorTop;
}
else
{
- sinceId = tab.SinceId;
- }
+ TwitterStatusId? maxId = null;
+ TwitterStatusId? sinceId = null;
+ if (more)
+ {
+ maxId = tab.OldestId as TwitterStatusId;
+ }
+ else
+ {
+ sinceId = tab.SinceId as TwitterStatusId;
+ }
- var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
- .ConfigureAwait(false);
+ var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
+ .ConfigureAwait(false);
+
+ statuses = searchResult.Statuses;
+ }
if (!TabInformations.GetInstance().ContainsTab(tab))
return;
- var minimumId = this.CreatePostsFromSearchJson(searchResult, tab, read, more);
+ var minimumId = this.CreatePostsFromSearchJson(statuses, tab, read, more);
if (minimumId != null)
- tab.OldestId = minimumId.Value;
+ tab.OldestId = minimumId;
}
- public async Task GetDirectMessageEvents(bool read, bool backward)
+ public async Task GetDirectMessageEvents(bool read, DirectMessagesTabModel dmTab, bool backward)
{
this.CheckAccountState();
this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
TwitterMessageEventList eventList;
if (backward)
{
- eventList = await this.Api.DirectMessagesEventsList(count, this.nextCursorDirectMessage)
+ eventList = await this.Api.DirectMessagesEventsList(count, dmTab.NextCursor)
.ConfigureAwait(false);
}
else
.ConfigureAwait(false);
}
- this.nextCursorDirectMessage = eventList.NextCursor;
+ dmTab.NextCursor = eventList.NextCursor;
await this.CreateDirectMessagesEventFromJson(eventList, read)
.ConfigureAwait(false);
IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
bool read)
{
+ var dmTab = TabInformations.GetInstance().DirectMessageTab;
+
foreach (var eventItem in events)
{
- var post = new PostClass();
- post.StatusId = long.Parse(eventItem.Id);
-
- var timestamp = long.Parse(eventItem.CreatedTimestamp);
- post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
- // 本文
- var textFromApi = eventItem.MessageCreate.MessageData.Text;
-
- var entities = eventItem.MessageCreate.MessageData.Entities;
- var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
-
- if (mediaEntity != null)
- entities.Media = new[] { mediaEntity };
-
- // HTMLに整形
- post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
- post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
- post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
- post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
- post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
- post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
- post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
- post.IsFav = false;
-
- this.ExtractEntities(entities, post.ReplyToList, post.Media);
-
- post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
- .Distinct().ToArray();
-
- post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
- .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
- .ToArray();
-
- // 以下、ユーザー情報
- string userId;
- if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
- {
- userId = eventItem.MessageCreate.SenderId;
- post.IsMe = false;
- post.IsOwl = true;
- }
- else
- {
- userId = eventItem.MessageCreate.Target.RecipientId;
- post.IsMe = true;
- post.IsOwl = false;
- }
-
- if (!users.TryGetValue(userId, out var user))
- continue;
-
- post.UserId = user.Id;
- post.ScreenName = user.ScreenName;
- post.Nickname = user.Name.Trim();
- post.ImageUrl = user.ProfileImageUrlHttps;
- post.IsProtect = user.Protected;
-
- // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
- if (post.Text == post.TextFromApi)
- post.Text = post.TextFromApi;
- if (post.AccessibleText == post.TextFromApi)
- post.AccessibleText = post.TextFromApi;
-
- // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
- post.ScreenName = string.Intern(post.ScreenName);
- post.Nickname = string.Intern(post.Nickname);
- post.ImageUrl = string.Intern(post.ImageUrl);
-
- var appId = eventItem.MessageCreate.SourceAppId;
- if (appId != null && apps.TryGetValue(appId, out var app))
- {
- post.Source = string.Intern(app.Name);
-
- try
- {
- post.SourceUri = new Uri(SourceUriBase, app.Url);
- }
- catch (UriFormatException)
- {
- }
- }
+ var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId);
+ _ = this.urlExpander.Expand(post);
post.IsRead = read;
if (post.IsMe && !read && this.ReadOwnPost)
post.IsRead = true;
- post.IsReply = false;
- post.IsExcludeReply = false;
- post.IsDm = true;
- var dmTab = TabInformations.GetInstance().DirectMessageTab;
dmTab.AddPostQueue(post);
}
}
tab.OldestId = minimumId.Value;
}
- private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
- {
- if (entities != null)
- {
- if (entities.Urls != null)
- {
- foreach (var m in entities.Urls)
- {
- if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
- }
- }
- if (entities.Media != null)
- {
- foreach (var m in entities.Media)
- {
- if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
- }
- }
- }
-
- if (quotedStatusLink != null)
- text += " " + quotedStatusLink.Display;
-
- return text;
- }
-
- internal static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
- {
- if (entities == null)
- return text;
-
- if (entities.Urls != null)
- {
- foreach (var entity in entities.Urls)
- {
- if (quotedStatus != null)
- {
- var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
- if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
- {
- var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
- text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
- continue;
- }
- }
-
- if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
- text = text.Replace(entity.Url, entity.DisplayUrl);
- }
- }
-
- if (entities.Media != null)
- {
- foreach (var entity in entities.Media)
- {
- if (!MyCommon.IsNullOrEmpty(entity.AltText))
- {
- text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
- }
- else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
- {
- text = text.Replace(entity.Url, entity.DisplayUrl);
- }
- }
- }
-
- if (quotedStatus != null && quotedStatusLink != null)
- {
- var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
- text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
- }
-
- return text;
- }
-
/// <summary>
/// フォロワーIDを更新します
/// </summary>
public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
{
- var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
+ using var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
.ConfigureAwait(false);
var list = await response.LoadJsonAsync()
{
this.CheckAccountState();
- var response = await this.Api.ListsCreate(listName, description, isPrivate)
+ using var response = await this.Api.ListsCreate(listName, description, isPrivate)
.ConfigureAwait(false);
var list = await response.LoadJsonAsync()
}
}
- private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List<MediaInfo> media)
- {
- if (entities != null)
- {
- if (entities.Hashtags != null)
- {
- lock (this.lockObj)
- {
- this.hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
- }
- }
- if (entities.UserMentions != null)
- {
- foreach (var ent in entities.UserMentions)
- {
- atList.Add((ent.Id, ent.ScreenName));
- }
- }
- if (entities.Media != null)
- {
- if (media != null)
- {
- foreach (var ent in entities.Media)
- {
- if (!media.Any(x => x.Url == ent.MediaUrlHttps))
- {
- if (ent.VideoInfo != null &&
- ent.Type == "animated_gif" || ent.Type == "video")
- {
- media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
- }
- else
- {
- media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
- }
- }
- }
- }
- }
- }
- }
-
- internal static string CreateHtmlAnchor(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
- {
- var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
-
- // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
- text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
-
- text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"https://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
- text = PreProcessUrl(text); // IDN置換
-
- if (quotedStatusLink != null)
- {
- text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
- WebUtility.HtmlEncode(quotedStatusLink.Url),
- WebUtility.HtmlEncode(quotedStatusLink.Display));
- }
-
- return text;
- }
-
- private static readonly Uri SourceUriBase = new Uri("https://twitter.com/");
-
- /// <summary>
- /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
- /// </summary>
- internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
- {
- if (MyCommon.IsNullOrEmpty(sourceHtml))
- return ("", null);
-
- string sourceText;
- Uri? sourceUri;
-
- // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
-
- var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
- if (match.Success)
- {
- sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
- try
- {
- var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
- sourceUri = new Uri(SourceUriBase, uriStr);
- }
- catch (UriFormatException)
- {
- sourceUri = null;
- }
- }
- else
- {
- sourceText = WebUtility.HtmlDecode(sourceHtml);
- sourceUri = null;
- }
-
- return (sourceText, sourceUri);
- }
-
public async Task<TwitterApiStatus?> GetInfoApi()
{
if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
}
public string[] GetHashList()
- {
- string[] hashArray;
- lock (this.lockObj)
- {
- hashArray = this.hashList.ToArray();
- this.hashList.Clear();
- }
- return hashArray;
- }
-
- public string AccessToken
- => ((TwitterApiConnection)this.Api.Connection).AccessToken;
-
- public string AccessTokenSecret
- => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
+ => this.postFactory.GetReceivedHashtags();
private void CheckAccountState()
{
return remainWeight / config.Scale;
}
+ /// <summary>
+ /// プロフィール画像のサイズを指定したURLを生成
+ /// </summary>
+ /// <remarks>
+ /// https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners を参照
+ /// </remarks>
+ public static string CreateProfileImageUrl(string normalUrl, string size)
+ {
+ return size switch
+ {
+ "original" => normalUrl.Replace("_normal.", "."),
+ "normal" => normalUrl,
+ "bigger" or "mini" => normalUrl.Replace("_normal.", $"_{size}."),
+ _ => throw new ArgumentException($"Invalid size: ${size}", nameof(size)),
+ };
+ }
+
+ public static string DecideProfileImageSize(int sizePx)
+ {
+ return sizePx switch
+ {
+ <= 24 => "mini",
+ <= 48 => "normal",
+ <= 73 => "bigger",
+ _ => "original",
+ };
+ }
+
public bool IsDisposed { get; private set; } = false;
protected virtual void Dispose(bool disposing)