OSDN Git Service

ユーザー情報で大文字アルファベットがハッシュタグとして認識されていなかったのを修正
[opentween/open-tween.git] / OpenTween / Twitter.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 //           (c) 2008-2011 Moz (@syo68k)
4 //           (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 //           (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 //           (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 //           (c) 2011      Egtra (@egtra) <http://dev.activebasic.com/egtra/>
8 //           (c) 2013      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
9 // All rights reserved.
10 //
11 // This file is part of OpenTween.
12 //
13 // This program is free software; you can redistribute it and/or modify it
14 // under the terms of the GNU General Public License as published by the Free
15 // Software Foundation; either version 3 of the License, or (at your option)
16 // any later version.
17 //
18 // This program is distributed in the hope that it will be useful, but
19 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
20 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21 // for more details.
22 //
23 // You should have received a copy of the GNU General Public License along
24 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
25 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
26 // Boston, MA 02110-1301, USA.
27
28 #nullable enable
29
30 using System.Diagnostics;
31 using System.IO;
32 using System.Linq;
33 using System.Net;
34 using System.Net.Http;
35 using System.Runtime.CompilerServices;
36 using System.Text;
37 using System.Text.RegularExpressions;
38 using System.Threading;
39 using System.Threading.Tasks;
40 using System;
41 using System.Reflection;
42 using System.Collections.Generic;
43 using System.Windows.Forms;
44 using OpenTween.Api;
45 using OpenTween.Api.DataModel;
46 using OpenTween.Connection;
47 using OpenTween.Models;
48 using OpenTween.Setting;
49 using System.Globalization;
50
51 namespace OpenTween
52 {
53     public class Twitter : IDisposable
54     {
55         #region Regexp from twitter-text-js
56
57         // The code in this region code block incorporates works covered by
58         // the following copyright and permission notices:
59         //
60         //   Copyright 2011 Twitter, Inc.
61         //
62         //   Licensed under the Apache License, Version 2.0 (the "License"); you
63         //   may not use this work except in compliance with the License. You
64         //   may obtain a copy of the License in the LICENSE file, or at:
65         //
66         //   http://www.apache.org/licenses/LICENSE-2.0
67         //
68         //   Unless required by applicable law or agreed to in writing, software
69         //   distributed under the License is distributed on an "AS IS" BASIS,
70         //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
71         //   implied. See the License for the specific language governing
72         //   permissions and limitations under the License.
73
74         //Hashtag用正規表現
75         private const string LATIN_ACCENTS = @"\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff\u0100-\u024f\u0253\u0254\u0256\u0257\u0259\u025b\u0263\u0268\u026f\u0272\u0289\u028b\u02bb\u1e00-\u1eff";
76         private const string NON_LATIN_HASHTAG_CHARS = @"\u0400-\u04ff\u0500-\u0527\u1100-\u11ff\u3130-\u3185\uA960-\uA97F\uAC00-\uD7AF\uD7B0-\uD7FF";
77         private const string CJ_HASHTAG_CHARACTERS = @"\u30A1-\u30FA\u30FC\u3005\uFF66-\uFF9F\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\u3041-\u309A\u3400-\u4DBF\p{IsCJKUnifiedIdeographs}";
78         private const string HASHTAG_BOUNDARY = @"^|$|\s|「|」|。|\.|!";
79         private const string HASHTAG_ALPHA = "[A-Za-z_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
80         private const string HASHTAG_ALPHANUMERIC = "[A-Za-z0-9_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
81         private const string HASHTAG_TERMINATOR = "[^A-Za-z0-9_" + LATIN_ACCENTS + NON_LATIN_HASHTAG_CHARS + CJ_HASHTAG_CHARACTERS + "]";
82         public const string HASHTAG = "(" + HASHTAG_BOUNDARY + ")(#|#)(" + HASHTAG_ALPHANUMERIC + "*" + HASHTAG_ALPHA + HASHTAG_ALPHANUMERIC + "*)(?=" + HASHTAG_TERMINATOR + "|" + HASHTAG_BOUNDARY + ")";
83         //URL正規表現
84         private const string url_valid_preceding_chars = @"(?:[^A-Za-z0-9@@$##\ufffe\ufeff\uffff\u202a-\u202e]|^)";
85         public const string url_invalid_without_protocol_preceding_chars = @"[-_./]$";
86         private const string url_invalid_domain_chars = @"\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$\u2000-\u200a\u0009-\u000d\u0020\u0085\u00a0\u1680\u180e\u2028\u2029\u202f\u205f\u3000\ufffe\ufeff\uffff\u202a-\u202e";
87         private const string url_valid_domain_chars = @"[^" + url_invalid_domain_chars + "]";
88         private const string url_valid_subdomain = @"(?:(?:" + url_valid_domain_chars + @"(?:[_-]|" + url_valid_domain_chars + @")*)?" + url_valid_domain_chars + @"\.)";
89         private const string url_valid_domain_name = @"(?:(?:" + url_valid_domain_chars + @"(?:-|" + url_valid_domain_chars + @")*)?" + url_valid_domain_chars + @"\.)";
90         private const string url_valid_GTLD = @"(?:(?: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]|$))";
91         private const string url_valid_CCTLD = @"(?:(?: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]|$))";
92         private const string url_valid_punycode = @"(?:xn--[0-9a-z]+)";
93         private const string url_valid_domain = @"(?<domain>" + url_valid_subdomain + "*" + url_valid_domain_name + "(?:" + url_valid_GTLD + "|" + url_valid_CCTLD + ")|" + url_valid_punycode + ")";
94         public const string url_valid_ascii_domain = @"(?:(?:[a-z0-9" + LATIN_ACCENTS + @"]+)\.)+(?:" + url_valid_GTLD + "|" + url_valid_CCTLD + "|" + url_valid_punycode + ")";
95         public const string url_invalid_short_domain = "^" + url_valid_domain_name + url_valid_CCTLD + "$";
96         private const string url_valid_port_number = @"[0-9]+";
97
98         private const string url_valid_general_path_chars = @"[a-z0-9!*';:=+,.$/%#\[\]\-_~|&" + LATIN_ACCENTS + "]";
99         private const string url_balance_parens = @"(?:\(" + url_valid_general_path_chars + @"+\))";
100         private const string url_valid_path_ending_chars = @"(?:[+\-a-z0-9=_#/" + LATIN_ACCENTS + "]|" + url_balance_parens + ")";
101         private const string pth = "(?:" +
102             "(?:" +
103                 url_valid_general_path_chars + "*" +
104                 "(?:" + url_balance_parens + url_valid_general_path_chars + "*)*" +
105                 url_valid_path_ending_chars +
106                 ")|(?:@" + url_valid_general_path_chars + "+/)" +
107             ")";
108         private const string qry = @"(?<query>\?[a-z0-9!?*'();:&=+$/%#\[\]\-_.,~|]*[a-z0-9_&=#/])?";
109         public const string rgUrl = @"(?<before>" + url_valid_preceding_chars + ")" +
110                                     "(?<url>(?<protocol>https?://)?" +
111                                     "(?<domain>" + url_valid_domain + ")" +
112                                     "(?::" + url_valid_port_number + ")?" +
113                                     "(?<path>/" + pth + "*)?" +
114                                     qry +
115                                     ")";
116
117         #endregion
118
119         /// <summary>
120         /// Twitter API のステータスページのURL
121         /// </summary>
122         public const string ServiceAvailabilityStatusUrl = "https://status.io.watchmouse.com/7617";
123
124         /// <summary>
125         /// ツイートへのパーマリンクURLを判定する正規表現
126         /// </summary>
127         public static readonly Regex StatusUrlRegex = new Regex(@"https?://([^.]+\.)?twitter\.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)/status(es)?/(?<StatusId>[0-9]+)(/photo)?", RegexOptions.IgnoreCase);
128
129         /// <summary>
130         /// attachment_url に指定可能な URL を判定する正規表現
131         /// </summary>
132         public static readonly Regex AttachmentUrlRegex = new Regex(@"https?://(
133    twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
134  | mobile\.twitter\.com/[0-9A-Za-z_]+/status/[0-9]+
135  | twitter\.com/messages/compose\?recipient_id=[0-9]+(&.+)?
136 )$", RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
137
138         /// <summary>
139         /// FavstarやaclogなどTwitter関連サービスのパーマリンクURLからステータスIDを抽出する正規表現
140         /// </summary>
141         public static readonly Regex ThirdPartyStatusUrlRegex = new Regex(@"https?://(?:[^.]+\.)?(?:
142   favstar\.fm/users/[a-zA-Z0-9_]+/status/       # Favstar
143 | favstar\.fm/t/                                # Favstar (short)
144 | aclog\.koba789\.com/i/                        # aclog
145 | frtrt\.net/solo_status\.php\?status=          # RtRT
146 )(?<StatusId>[0-9]+)", RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
147
148         /// <summary>
149         /// DM送信かどうかを判定する正規表現
150         /// </summary>
151         public static readonly Regex DMSendTextRegex = new Regex(@"^DM? +(?<id>[a-zA-Z0-9_]+) +(?<body>.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline);
152
153         public TwitterApi Api { get; }
154         public TwitterConfiguration Configuration { get; private set; }
155         public TwitterTextConfiguration TextConfiguration { get; private set; }
156
157         public bool GetFollowersSuccess { get; private set; } = false;
158         public bool GetNoRetweetSuccess { get; private set; } = false;
159
160         delegate void GetIconImageDelegate(PostClass post);
161         private readonly object LockObj = new object();
162         private ISet<long> followerId = new HashSet<long>();
163         private long[] noRTId = Array.Empty<long>();
164
165         //プロパティからアクセスされる共通情報
166         private readonly List<string> _hashList = new List<string>();
167
168         private string? nextCursorDirectMessage = null;
169
170         private long previousStatusId = -1L;
171
172         public Twitter() : this(new TwitterApi())
173         {
174         }
175
176         public Twitter(TwitterApi api)
177         {
178             this.Api = api;
179             this.Configuration = TwitterConfiguration.DefaultConfiguration();
180             this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
181         }
182
183         public TwitterApiAccessLevel AccessLevel
184             => MyCommon.TwitterApiInfo.AccessLevel;
185
186         protected void ResetApiStatus()
187             => MyCommon.TwitterApiInfo.Reset();
188
189         public void ClearAuthInfo()
190         {
191             Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
192             this.ResetApiStatus();
193         }
194
195         [Obsolete]
196         public void VerifyCredentials()
197         {
198             try
199             {
200                 this.VerifyCredentialsAsync().Wait();
201             }
202             catch (AggregateException ex) when (ex.InnerException is WebApiException)
203             {
204                 throw new WebApiException(ex.InnerException.Message, ex);
205             }
206         }
207
208         public async Task VerifyCredentialsAsync()
209         {
210             var user = await this.Api.AccountVerifyCredentials()
211                 .ConfigureAwait(false);
212
213             this.UpdateUserStats(user);
214         }
215
216         public void Initialize(string token, string tokenSecret, string username, long userId)
217         {
218             //OAuth認証
219             if (MyCommon.IsNullOrEmpty(token) || MyCommon.IsNullOrEmpty(tokenSecret) || MyCommon.IsNullOrEmpty(username))
220             {
221                 Twitter.AccountState = MyCommon.ACCOUNT_STATE.Invalid;
222             }
223             this.ResetApiStatus();
224             this.Api.Initialize(token, tokenSecret, userId, username);
225             if (SettingManager.Common.UserstreamStartup) this.ReconnectUserStream();
226         }
227
228         internal static string PreProcessUrl(string orgData)
229         {
230             int posl1;
231             var posl2 = 0;
232             var href = "<a href=\"";
233
234             while (true)
235             {
236                 if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
237                 {
238                     // IDN展開
239                     posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
240                     posl1 += href.Length;
241                     posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
242                     var urlStr = orgData.Substring(posl1, posl2 - posl1);
243
244                     if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
245                         && !urlStr.StartsWith("https://", StringComparison.Ordinal)
246                         && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
247                     {
248                         continue;
249                     }
250
251                     var replacedUrl = MyCommon.IDNEncode(urlStr);
252                     if (replacedUrl == null) continue;
253                     if (replacedUrl == urlStr) continue;
254
255                     orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
256                     posl2 = 0;
257                 }
258                 else
259                 {
260                     break;
261                 }
262             }
263             return orgData;
264         }
265
266         public async Task<PostClass?> PostStatus(PostStatusParams param)
267         {
268             this.CheckAccountState();
269
270             if (Twitter.DMSendTextRegex.IsMatch(param.Text))
271             {
272                 var mediaId = param.MediaIds != null && param.MediaIds.Any() ? param.MediaIds[0] : (long?)null;
273
274                 await this.SendDirectMessage(param.Text, mediaId)
275                     .ConfigureAwait(false);
276                 return null;
277             }
278
279             var response = await this.Api.StatusesUpdate(param.Text, param.InReplyToStatusId, param.MediaIds,
280                     param.AutoPopulateReplyMetadata, param.ExcludeReplyUserIds, param.AttachmentUrl)
281                 .ConfigureAwait(false);
282
283             var status = await response.LoadJsonAsync()
284                 .ConfigureAwait(false);
285
286             this.UpdateUserStats(status.User);
287
288             if (status.Id == this.previousStatusId)
289                 throw new WebApiException("OK:Delaying?");
290
291             this.previousStatusId = status.Id;
292
293             //投稿したものを返す
294             var post = CreatePostsFromStatusData(status);
295             if (this.ReadOwnPost) post.IsRead = true;
296             return post;
297         }
298
299         public async Task<long> UploadMedia(IMediaItem item, string? mediaCategory = null)
300         {
301             this.CheckAccountState();
302
303             var mediaType = item.Extension switch
304             {
305                 ".png" => "image/png",
306                 ".jpg" => "image/jpeg",
307                 ".jpeg" => "image/jpeg",
308                 ".gif" => "image/gif",
309                 _ => "application/octet-stream",
310             };
311
312             var initResponse = await this.Api.MediaUploadInit(item.Size, mediaType, mediaCategory)
313                 .ConfigureAwait(false);
314
315             var initMedia = await initResponse.LoadJsonAsync()
316                 .ConfigureAwait(false);
317
318             var mediaId = initMedia.MediaId;
319
320             await this.Api.MediaUploadAppend(mediaId, 0, item)
321                 .ConfigureAwait(false);
322
323             var response = await this.Api.MediaUploadFinalize(mediaId)
324                 .ConfigureAwait(false);
325
326             var media = await response.LoadJsonAsync()
327                 .ConfigureAwait(false);
328
329             while (media.ProcessingInfo is TwitterUploadMediaResult.MediaProcessingInfo processingInfo)
330             {
331                 switch (processingInfo.State)
332                 {
333                     case "pending":
334                         break;
335                     case "in_progress":
336                         break;
337                     case "succeeded":
338                         goto succeeded;
339                     case "failed":
340                         throw new WebApiException($"Err:Upload failed ({processingInfo.Error?.Name})");
341                     default:
342                         throw new WebApiException($"Err:Invalid state ({processingInfo.State})");
343                 }
344
345                 await Task.Delay(TimeSpan.FromSeconds(processingInfo.CheckAfterSecs ?? 5))
346                     .ConfigureAwait(false);
347
348                 media = await this.Api.MediaUploadStatus(mediaId)
349                     .ConfigureAwait(false);
350             }
351
352             succeeded:
353             return media.MediaId;
354         }
355
356         public async Task SendDirectMessage(string postStr, long? mediaId = null)
357         {
358             this.CheckAccountState();
359             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
360
361             var mc = Twitter.DMSendTextRegex.Match(postStr);
362
363             var body = mc.Groups["body"].Value;
364             var recipientName = mc.Groups["id"].Value;
365
366             var recipient = await this.Api.UsersShow(recipientName)
367                 .ConfigureAwait(false);
368
369             var response = await this.Api.DirectMessagesEventsNew(recipient.Id, body, mediaId)
370                 .ConfigureAwait(false);
371
372             var messageEventSingle = await response.LoadJsonAsync()
373                 .ConfigureAwait(false);
374
375             await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true)
376                 .ConfigureAwait(false);
377         }
378
379         public async Task<PostClass?> PostRetweet(long id, bool read)
380         {
381             this.CheckAccountState();
382
383             //データ部分の生成
384             var post = TabInformations.GetInstance()[id];
385             if (post == null)
386                 throw new WebApiException("Err:Target isn't found.");
387
388             var target = post.RetweetedId ?? id;  //再RTの場合は元発言をRT
389
390             var response = await this.Api.StatusesRetweet(target)
391                 .ConfigureAwait(false);
392
393             var status = await response.LoadJsonAsync()
394                 .ConfigureAwait(false);
395
396             //二重取得回避
397             lock (LockObj)
398             {
399                 if (TabInformations.GetInstance().ContainsKey(status.Id))
400                     return null;
401             }
402
403             //Retweet判定
404             if (status.RetweetedStatus == null)
405                 throw new WebApiException("Invalid Json!");
406
407             //Retweetしたものを返す
408             post = CreatePostsFromStatusData(status);
409
410             //ユーザー情報
411             post.IsMe = true;
412
413             post.IsRead = read;
414             post.IsOwl = false;
415             if (this.ReadOwnPost) post.IsRead = true;
416             post.IsDm = false;
417
418             return post;
419         }
420
421         public string Username
422             => this.Api.CurrentScreenName;
423
424         public long UserId
425             => this.Api.CurrentUserId;
426
427         public static MyCommon.ACCOUNT_STATE AccountState { get; set; } = MyCommon.ACCOUNT_STATE.Valid;
428         public bool RestrictFavCheck { get; set; }
429         public bool ReadOwnPost { get; set; }
430
431         public int FollowersCount { get; private set; }
432         public int FriendsCount { get; private set; }
433         public int StatusesCount { get; private set; }
434         public string Location { get; private set; } = "";
435         public string Bio { get; private set; } = "";
436
437         /// <summary>ユーザーのフォロワー数などの情報を更新します</summary>
438         private void UpdateUserStats(TwitterUser self)
439         {
440             this.FollowersCount = self.FollowersCount;
441             this.FriendsCount = self.FriendsCount;
442             this.StatusesCount = self.StatusesCount;
443             this.Location = self.Location ?? "";
444             this.Bio = self.Description ?? "";
445         }
446
447         /// <summary>
448         /// 渡された取得件数がWORKERTYPEに応じた取得可能範囲に収まっているか検証する
449         /// </summary>
450         public static bool VerifyApiResultCount(MyCommon.WORKERTYPE type, int count)
451             => count >= 20 && count <= GetMaxApiResultCount(type);
452
453         /// <summary>
454         /// 渡された取得件数が更新時の取得可能範囲に収まっているか検証する
455         /// </summary>
456         public static bool VerifyMoreApiResultCount(int count)
457             => count >= 20 && count <= 200;
458
459         /// <summary>
460         /// 渡された取得件数が起動時の取得可能範囲に収まっているか検証する
461         /// </summary>
462         public static bool VerifyFirstApiResultCount(int count)
463             => count >= 20 && count <= 200;
464
465         /// <summary>
466         /// WORKERTYPEに応じた取得可能な最大件数を取得する
467         /// </summary>
468         public static int GetMaxApiResultCount(MyCommon.WORKERTYPE type)
469         {
470             // 参照: REST APIs - 各endpointのcountパラメータ
471             // https://dev.twitter.com/rest/public
472             return type switch
473             {
474                 MyCommon.WORKERTYPE.Timeline => 200,
475                 MyCommon.WORKERTYPE.Reply => 200,
476                 MyCommon.WORKERTYPE.UserTimeline => 200,
477                 MyCommon.WORKERTYPE.Favorites => 200,
478                 MyCommon.WORKERTYPE.List => 200, // 不明
479                 MyCommon.WORKERTYPE.PublicSearch => 100,
480                 _ => throw new InvalidOperationException("Invalid type: " + type),
481             };
482         }
483
484         /// <summary>
485         /// WORKERTYPEに応じた取得件数を取得する
486         /// </summary>
487         public static int GetApiResultCount(MyCommon.WORKERTYPE type, bool more, bool startup)
488         {
489             if (SettingManager.Common.UseAdditionalCount)
490             {
491                 switch (type)
492                 {
493                     case MyCommon.WORKERTYPE.Favorites:
494                         if (SettingManager.Common.FavoritesCountApi != 0)
495                             return SettingManager.Common.FavoritesCountApi;
496                         break;
497                     case MyCommon.WORKERTYPE.List:
498                         if (SettingManager.Common.ListCountApi != 0)
499                             return SettingManager.Common.ListCountApi;
500                         break;
501                     case MyCommon.WORKERTYPE.PublicSearch:
502                         if (SettingManager.Common.SearchCountApi != 0)
503                             return SettingManager.Common.SearchCountApi;
504                         break;
505                     case MyCommon.WORKERTYPE.UserTimeline:
506                         if (SettingManager.Common.UserTimelineCountApi != 0)
507                             return SettingManager.Common.UserTimelineCountApi;
508                         break;
509                 }
510                 if (more && SettingManager.Common.MoreCountApi != 0)
511                 {
512                     return Math.Min(SettingManager.Common.MoreCountApi, GetMaxApiResultCount(type));
513                 }
514                 if (startup && SettingManager.Common.FirstCountApi != 0 && type != MyCommon.WORKERTYPE.Reply)
515                 {
516                     return Math.Min(SettingManager.Common.FirstCountApi, GetMaxApiResultCount(type));
517                 }
518             }
519
520             // 上記に当てはまらない場合の共通処理
521             var count = SettingManager.Common.CountApi;
522
523             if (type == MyCommon.WORKERTYPE.Reply)
524                 count = SettingManager.Common.CountApiReply;
525
526             return Math.Min(count, GetMaxApiResultCount(type));
527         }
528
529         public async Task GetHomeTimelineApi(bool read, HomeTabModel tab, bool more, bool startup)
530         {
531             this.CheckAccountState();
532
533             var count = GetApiResultCount(MyCommon.WORKERTYPE.Timeline, more, startup);
534
535             TwitterStatus[] statuses;
536             if (more)
537             {
538                 statuses = await this.Api.StatusesHomeTimeline(count, maxId: tab.OldestId)
539                     .ConfigureAwait(false);
540             }
541             else
542             {
543                 statuses = await this.Api.StatusesHomeTimeline(count)
544                     .ConfigureAwait(false);
545             }
546
547             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Timeline, tab, read);
548             if (minimumId != null)
549                 tab.OldestId = minimumId.Value;
550         }
551
552         public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool more, bool startup)
553         {
554             this.CheckAccountState();
555
556             var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
557
558             TwitterStatus[] statuses;
559             if (more)
560             {
561                 statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId)
562                     .ConfigureAwait(false);
563             }
564             else
565             {
566                 statuses = await this.Api.StatusesMentionsTimeline(count)
567                     .ConfigureAwait(false);
568             }
569
570             var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
571             if (minimumId != null)
572                 tab.OldestId = minimumId.Value;
573         }
574
575         public async Task GetUserTimelineApi(bool read, string userName, UserTimelineTabModel tab, bool more)
576         {
577             this.CheckAccountState();
578
579             var count = GetApiResultCount(MyCommon.WORKERTYPE.UserTimeline, more, false);
580
581             TwitterStatus[] statuses;
582             if (MyCommon.IsNullOrEmpty(userName))
583             {
584                 var target = tab.ScreenName;
585                 if (MyCommon.IsNullOrEmpty(target)) return;
586                 userName = target;
587                 statuses = await this.Api.StatusesUserTimeline(userName, count)
588                     .ConfigureAwait(false);
589             }
590             else
591             {
592                 if (more)
593                 {
594                     statuses = await this.Api.StatusesUserTimeline(userName, count, maxId: tab.OldestId)
595                         .ConfigureAwait(false);
596                 }
597                 else
598                 {
599                     statuses = await this.Api.StatusesUserTimeline(userName, count)
600                         .ConfigureAwait(false);
601                 }
602             }
603
604             var minimumId = CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.UserTimeline, tab, read);
605
606             if (minimumId != null)
607                 tab.OldestId = minimumId.Value;
608         }
609
610         public async Task<PostClass> GetStatusApi(bool read, long id)
611         {
612             this.CheckAccountState();
613
614             var status = await this.Api.StatusesShow(id)
615                 .ConfigureAwait(false);
616
617             var item = CreatePostsFromStatusData(status);
618
619             item.IsRead = read;
620             if (item.IsMe && !read && this.ReadOwnPost) item.IsRead = true;
621
622             return item;
623         }
624
625         public async Task GetStatusApi(bool read, long id, TabModel tab)
626         {
627             var post = await this.GetStatusApi(read, id)
628                 .ConfigureAwait(false);
629
630             //非同期アイコン取得&StatusDictionaryに追加
631             if (tab != null && tab.IsInnerStorageTabType)
632                 tab.AddPostQueue(post);
633             else
634                 TabInformations.GetInstance().AddPost(post);
635         }
636
637         private PostClass CreatePostsFromStatusData(TwitterStatus status)
638             => this.CreatePostsFromStatusData(status, false);
639
640         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
641         {
642             var post = new PostClass();
643             TwitterEntities entities;
644             string sourceHtml;
645
646             post.StatusId = status.Id;
647             if (status.RetweetedStatus != null)
648             {
649                 var retweeted = status.RetweetedStatus;
650
651                 post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
652
653                 //Id
654                 post.RetweetedId = retweeted.Id;
655                 //本文
656                 post.TextFromApi = retweeted.FullText;
657                 entities = retweeted.MergedEntities;
658                 sourceHtml = retweeted.Source;
659                 //Reply先
660                 post.InReplyToStatusId = retweeted.InReplyToStatusId;
661                 post.InReplyToUser = retweeted.InReplyToScreenName;
662                 post.InReplyToUserId = status.InReplyToUserId;
663
664                 if (favTweet)
665                 {
666                     post.IsFav = true;
667                 }
668                 else
669                 {
670                     //幻覚fav対策
671                     var tc = TabInformations.GetInstance().FavoriteTab;
672                     post.IsFav = tc.Contains(retweeted.Id);
673                 }
674
675                 if (retweeted.Coordinates != null)
676                     post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
677
678                 //以下、ユーザー情報
679                 var user = retweeted.User;
680                 if (user != null)
681                 {
682                     post.UserId = user.Id;
683                     post.ScreenName = user.ScreenName;
684                     post.Nickname = user.Name.Trim();
685                     post.ImageUrl = user.ProfileImageUrlHttps;
686                     post.IsProtect = user.Protected;
687                 }
688                 else
689                 {
690                     post.UserId = 0L;
691                     post.ScreenName = "?????";
692                     post.Nickname = "Unknown User";
693                 }
694
695                 //Retweetした人
696                 if (status.User != null)
697                 {
698                     post.RetweetedBy = status.User.ScreenName;
699                     post.RetweetedByUserId = status.User.Id;
700                     post.IsMe = post.RetweetedByUserId == this.UserId;
701                 }
702                 else
703                 {
704                     post.RetweetedBy = "?????";
705                     post.RetweetedByUserId = 0L;
706                 }
707             }
708             else
709             {
710                 post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
711                 //本文
712                 post.TextFromApi = status.FullText;
713                 entities = status.MergedEntities;
714                 sourceHtml = status.Source;
715                 post.InReplyToStatusId = status.InReplyToStatusId;
716                 post.InReplyToUser = status.InReplyToScreenName;
717                 post.InReplyToUserId = status.InReplyToUserId;
718
719                 if (favTweet)
720                 {
721                     post.IsFav = true;
722                 }
723                 else
724                 {
725                     //幻覚fav対策
726                     var tc = TabInformations.GetInstance().FavoriteTab;
727                     post.IsFav = tc.Posts.TryGetValue(post.StatusId, out var tabinfoPost) && tabinfoPost.IsFav;
728                 }
729
730                 if (status.Coordinates != null)
731                     post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
732
733                 //以下、ユーザー情報
734                 var user = status.User;
735                 if (user != null)
736                 {
737                     post.UserId = user.Id;
738                     post.ScreenName = user.ScreenName;
739                     post.Nickname = user.Name.Trim();
740                     post.ImageUrl = user.ProfileImageUrlHttps;
741                     post.IsProtect = user.Protected;
742                     post.IsMe = post.UserId == this.UserId;
743                 }
744                 else
745                 {
746                     post.UserId = 0L;
747                     post.ScreenName = "?????";
748                     post.Nickname = "Unknown User";
749                 }
750             }
751             //HTMLに整形
752             var textFromApi = post.TextFromApi;
753
754             var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
755
756             if (quotedStatusLink != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
757                 quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
758
759             post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
760             post.TextFromApi = textFromApi;
761             post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
762             post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
763             post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
764             post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
765             post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
766             post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
767
768             this.ExtractEntities(entities, post.ReplyToList, post.Media);
769
770             post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
771                 .Where(x => x != post.StatusId && x != post.RetweetedId)
772                 .Distinct().ToArray();
773
774             post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
775                 .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
776                 .ToArray();
777
778             // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
779             if (post.Text == post.TextFromApi)
780                 post.Text = post.TextFromApi;
781             if (post.AccessibleText == post.TextFromApi)
782                 post.AccessibleText = post.TextFromApi;
783
784             // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
785             post.ScreenName = string.Intern(post.ScreenName);
786             post.Nickname = string.Intern(post.Nickname);
787             post.ImageUrl = string.Intern(post.ImageUrl);
788             post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
789
790             //Source整形
791             var (sourceText, sourceUri) = ParseSource(sourceHtml);
792             post.Source = string.Intern(sourceText);
793             post.SourceUri = sourceUri;
794
795             post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == this.UserId);
796             post.IsExcludeReply = false;
797
798             if (post.IsMe)
799             {
800                 post.IsOwl = false;
801             }
802             else
803             {
804                 if (followerId.Count > 0) post.IsOwl = !followerId.Contains(post.UserId);
805             }
806
807             post.IsDm = false;
808             return post;
809         }
810
811         /// <summary>
812         /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
813         /// </summary>
814         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity>? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
815         {
816             entities ??= Enumerable.Empty<TwitterEntity>();
817
818             var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
819
820             if (quotedStatusLink != null)
821                 urls = urls.Append(quotedStatusLink.Expanded);
822
823             return GetQuoteTweetStatusIds(urls);
824         }
825
826         public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
827         {
828             foreach (var url in urls)
829             {
830                 var match = Twitter.StatusUrlRegex.Match(url);
831                 if (match.Success)
832                 {
833                     if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
834                         yield return statusId;
835                 }
836             }
837         }
838
839         private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
840         {
841             long? minimumId = null;
842
843             foreach (var status in items)
844             {
845                 if (minimumId == null || minimumId.Value > status.Id)
846                     minimumId = status.Id;
847
848                 //二重取得回避
849                 lock (LockObj)
850                 {
851                     if (tab == null)
852                     {
853                         if (TabInformations.GetInstance().ContainsKey(status.Id)) continue;
854                     }
855                     else
856                     {
857                         if (tab.Contains(status.Id)) continue;
858                     }
859                 }
860
861                 //RT禁止ユーザーによるもの
862                 if (gType != MyCommon.WORKERTYPE.UserTimeline &&
863                     status.RetweetedStatus != null && this.noRTId.Contains(status.User.Id)) continue;
864
865                 var post = CreatePostsFromStatusData(status);
866
867                 post.IsRead = read;
868                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
869
870                 if (tab != null && tab.IsInnerStorageTabType)
871                     tab.AddPostQueue(post);
872                 else
873                     TabInformations.GetInstance().AddPost(post);
874             }
875
876             return minimumId;
877         }
878
879         private long? CreatePostsFromSearchJson(TwitterSearchResult items, PublicSearchTabModel tab, bool read, bool more)
880         {
881             long? minimumId = null;
882
883             foreach (var status in items.Statuses)
884             {
885                 if (minimumId == null || minimumId.Value > status.Id)
886                     minimumId = status.Id;
887
888                 if (!more && status.Id > tab.SinceId) tab.SinceId = status.Id;
889                 //二重取得回避
890                 lock (LockObj)
891                 {
892                     if (tab.Contains(status.Id)) continue;
893                 }
894
895                 var post = CreatePostsFromStatusData(status);
896
897                 post.IsRead = read;
898                 if ((post.IsMe && !read) && this.ReadOwnPost) post.IsRead = true;
899
900                 tab.AddPostQueue(post);
901             }
902
903             return minimumId;
904         }
905
906         private long? CreateFavoritePostsFromJson(TwitterStatus[] items, bool read)
907         {
908             var favTab = TabInformations.GetInstance().FavoriteTab;
909             long? minimumId = null;
910
911             foreach (var status in items)
912             {
913                 if (minimumId == null || minimumId.Value > status.Id)
914                     minimumId = status.Id;
915
916                 //二重取得回避
917                 lock (LockObj)
918                 {
919                     if (favTab.Contains(status.Id)) continue;
920                 }
921
922                 var post = CreatePostsFromStatusData(status, true);
923
924                 post.IsRead = read;
925
926                 TabInformations.GetInstance().AddPost(post);
927             }
928
929             return minimumId;
930         }
931
932         public async Task GetListStatus(bool read, ListTimelineTabModel tab, bool more, bool startup)
933         {
934             var count = GetApiResultCount(MyCommon.WORKERTYPE.List, more, startup);
935
936             TwitterStatus[] statuses;
937             if (more)
938             {
939                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, maxId: tab.OldestId, includeRTs: SettingManager.Common.IsListsIncludeRts)
940                     .ConfigureAwait(false);
941             }
942             else
943             {
944                 statuses = await this.Api.ListsStatuses(tab.ListInfo.Id, count, includeRTs: SettingManager.Common.IsListsIncludeRts)
945                     .ConfigureAwait(false);
946             }
947
948             var minimumId = CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.List, tab, read);
949
950             if (minimumId != null)
951                 tab.OldestId = minimumId.Value;
952         }
953
954         /// <summary>
955         /// startStatusId からリプライ先の発言を辿る。発言は posts 以外からは検索しない。
956         /// </summary>
957         /// <returns>posts の中から検索されたリプライチェインの末端</returns>
958         internal static PostClass FindTopOfReplyChain(IDictionary<long, PostClass> posts, long startStatusId)
959         {
960             if (!posts.ContainsKey(startStatusId))
961                 throw new ArgumentException("startStatusId (" + startStatusId + ") が posts の中から見つかりませんでした。", nameof(startStatusId));
962
963             var nextPost = posts[startStatusId];
964             while (nextPost.InReplyToStatusId != null)
965             {
966                 if (!posts.ContainsKey(nextPost.InReplyToStatusId.Value))
967                     break;
968                 nextPost = posts[nextPost.InReplyToStatusId.Value];
969             }
970
971             return nextPost;
972         }
973
974         public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab)
975         {
976             var targetPost = tab.TargetPost;
977             var relPosts = new Dictionary<long, PostClass>();
978             if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null)
979             {
980                 //検索結果対応
981                 var p = TabInformations.GetInstance()[targetPost.StatusId];
982                 if (p != null && p.InReplyToStatusId != null)
983                 {
984                     targetPost = p;
985                 }
986                 else
987                 {
988                     p = await this.GetStatusApi(read, targetPost.StatusId)
989                         .ConfigureAwait(false);
990                     targetPost = p;
991                 }
992             }
993             relPosts.Add(targetPost.StatusId, targetPost);
994
995             Exception? lastException = null;
996
997             // in_reply_to_status_id を使用してリプライチェインを辿る
998             var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId);
999             var loopCount = 1;
1000             while (nextPost.InReplyToStatusId != null && loopCount++ <= 20)
1001             {
1002                 var inReplyToId = nextPost.InReplyToStatusId.Value;
1003
1004                 var inReplyToPost = TabInformations.GetInstance()[inReplyToId];
1005                 if (inReplyToPost == null)
1006                 {
1007                     try
1008                     {
1009                         inReplyToPost = await this.GetStatusApi(read, inReplyToId)
1010                             .ConfigureAwait(false);
1011                     }
1012                     catch (WebApiException ex)
1013                     {
1014                         lastException = ex;
1015                         break;
1016                     }
1017                 }
1018
1019                 relPosts.Add(inReplyToPost.StatusId, inReplyToPost);
1020
1021                 nextPost = FindTopOfReplyChain(relPosts, nextPost.StatusId);
1022             }
1023
1024             //MRTとかに対応のためツイート内にあるツイートを指すURLを取り込む
1025             var text = targetPost.Text;
1026             var ma = Twitter.StatusUrlRegex.Matches(text).Cast<Match>()
1027                 .Concat(Twitter.ThirdPartyStatusUrlRegex.Matches(text).Cast<Match>());
1028             foreach (var _match in ma)
1029             {
1030                 if (long.TryParse(_match.Groups["StatusId"].Value, out var _statusId))
1031                 {
1032                     if (relPosts.ContainsKey(_statusId))
1033                         continue;
1034
1035                     var p = TabInformations.GetInstance()[_statusId];
1036                     if (p == null)
1037                     {
1038                         try
1039                         {
1040                             p = await this.GetStatusApi(read, _statusId)
1041                                 .ConfigureAwait(false);
1042                         }
1043                         catch (WebApiException ex)
1044                         {
1045                             lastException = ex;
1046                             break;
1047                         }
1048                     }
1049
1050                     if (p != null)
1051                         relPosts.Add(p.StatusId, p);
1052                 }
1053             }
1054
1055             relPosts.Values.ToList().ForEach(p =>
1056             {
1057                 if (p.IsMe && !read && this.ReadOwnPost)
1058                     p.IsRead = true;
1059                 else
1060                     p.IsRead = read;
1061
1062                 tab.AddPostQueue(p);
1063             });
1064
1065             if (lastException != null)
1066                 throw new WebApiException(lastException.Message, lastException);
1067         }
1068
1069         public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
1070         {
1071             var count = GetApiResultCount(MyCommon.WORKERTYPE.PublicSearch, more, false);
1072
1073             long? maxId = null;
1074             long? sinceId = null;
1075             if (more)
1076             {
1077                 maxId = tab.OldestId - 1;
1078             }
1079             else
1080             {
1081                 sinceId = tab.SinceId;
1082             }
1083
1084             var searchResult = await this.Api.SearchTweets(tab.SearchWords, tab.SearchLang, count, maxId, sinceId)
1085                 .ConfigureAwait(false);
1086
1087             if (!TabInformations.GetInstance().ContainsTab(tab))
1088                 return;
1089
1090             var minimumId = this.CreatePostsFromSearchJson(searchResult, tab, read, more);
1091
1092             if (minimumId != null)
1093                 tab.OldestId = minimumId.Value;
1094         }
1095
1096         private void CreateDirectMessagesFromJson(TwitterDirectMessage[] item, MyCommon.WORKERTYPE gType, bool read)
1097         {
1098             foreach (var message in item)
1099             {
1100                 var post = new PostClass();
1101                 try
1102                 {
1103                     post.StatusId = message.Id;
1104
1105                     //二重取得回避
1106                     lock (LockObj)
1107                     {
1108                         if (TabInformations.GetInstance().DirectMessageTab.Contains(post.StatusId)) continue;
1109                     }
1110                     //sender_id
1111                     //recipient_id
1112                     post.CreatedAt = MyCommon.DateTimeParse(message.CreatedAt);
1113                     //本文
1114                     var textFromApi = message.Text;
1115                     //HTMLに整形
1116                     post.Text = CreateHtmlAnchor(textFromApi, message.Entities, quotedStatusLink: null);
1117                     post.TextFromApi = this.ReplaceTextFromApi(textFromApi, message.Entities, quotedStatusLink: null);
1118                     post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1119                     post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1120                     post.AccessibleText = CreateAccessibleText(textFromApi, message.Entities, quotedStatus: null, quotedStatusLink: null);
1121                     post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1122                     post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1123                     post.IsFav = false;
1124
1125                     this.ExtractEntities(message.Entities, post.ReplyToList, post.Media);
1126
1127                     post.QuoteStatusIds = GetQuoteTweetStatusIds(message.Entities, quotedStatusLink: null)
1128                         .Distinct().ToArray();
1129
1130                     post.ExpandedUrls = message.Entities.OfType<TwitterEntityUrl>()
1131                         .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1132                         .ToArray();
1133
1134                     //以下、ユーザー情報
1135                     TwitterUser? user;
1136                     if (gType == MyCommon.WORKERTYPE.UserStream)
1137                     {
1138                         if (this.Api.CurrentUserId == message.Recipient?.Id)
1139                         {
1140                             user = message.Sender;
1141                             post.IsMe = false;
1142                             post.IsOwl = true;
1143                         }
1144                         else
1145                         {
1146                             user = message.Recipient;
1147                             post.IsMe = true;
1148                             post.IsOwl = false;
1149                         }
1150                     }
1151                     else
1152                     {
1153                         if (gType == MyCommon.WORKERTYPE.DirectMessegeRcv)
1154                         {
1155                             user = message.Sender;
1156                             post.IsMe = false;
1157                             post.IsOwl = true;
1158                         }
1159                         else
1160                         {
1161                             user = message.Recipient;
1162                             post.IsMe = true;
1163                             post.IsOwl = false;
1164                         }
1165                     }
1166
1167                     if (user != null)
1168                     {
1169                         post.UserId = user.Id;
1170                         post.ScreenName = user.ScreenName;
1171                         post.Nickname = user.Name.Trim();
1172                         post.ImageUrl = user.ProfileImageUrlHttps;
1173                         post.IsProtect = user.Protected;
1174                         post.IsMe = post.UserId == this.UserId;
1175                     }
1176                     else
1177                     {
1178                         post.UserId = 0L;
1179                         post.ScreenName = "?????";
1180                         post.Nickname = "Unknown User";
1181                     }
1182
1183                     // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1184                     if (post.Text == post.TextFromApi)
1185                         post.Text = post.TextFromApi;
1186                     if (post.AccessibleText == post.TextFromApi)
1187                         post.AccessibleText = post.TextFromApi;
1188
1189                     // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1190                     post.ScreenName = string.Intern(post.ScreenName);
1191                     post.Nickname = string.Intern(post.Nickname);
1192                     post.ImageUrl = string.Intern(post.ImageUrl);
1193                 }
1194                 catch(Exception ex)
1195                 {
1196                     MyCommon.TraceOut(ex, MethodBase.GetCurrentMethod().Name);
1197                     MessageBox.Show("Parse Error(CreateDirectMessagesFromJson)");
1198                     continue;
1199                 }
1200
1201                 post.IsRead = read;
1202                 if (post.IsMe && !read && this.ReadOwnPost) post.IsRead = true;
1203                 post.IsReply = false;
1204                 post.IsExcludeReply = false;
1205                 post.IsDm = true;
1206
1207                 var dmTab = TabInformations.GetInstance().DirectMessageTab;
1208                 dmTab.AddPostQueue(post);
1209             }
1210         }
1211
1212         public async Task GetDirectMessageEvents(bool read, bool backward)
1213         {
1214             this.CheckAccountState();
1215             this.CheckAccessLevel(TwitterApiAccessLevel.ReadWriteAndDirectMessage);
1216
1217             var count = 50;
1218
1219             TwitterMessageEventList eventList;
1220             if (backward)
1221             {
1222                 eventList = await this.Api.DirectMessagesEventsList(count, this.nextCursorDirectMessage)
1223                     .ConfigureAwait(false);
1224             }
1225             else
1226             {
1227                 eventList = await this.Api.DirectMessagesEventsList(count)
1228                     .ConfigureAwait(false);
1229             }
1230
1231             this.nextCursorDirectMessage = eventList.NextCursor;
1232
1233             await this.CreateDirectMessagesEventFromJson(eventList, read)
1234                 .ConfigureAwait(false);
1235         }
1236
1237         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventSingle eventSingle, bool read)
1238         {
1239             var eventList = new TwitterMessageEventList
1240             {
1241                 Apps = new Dictionary<string, TwitterMessageEventList.App>(),
1242                 Events = new[] { eventSingle.Event },
1243             };
1244
1245             await this.CreateDirectMessagesEventFromJson(eventList, read)
1246                 .ConfigureAwait(false);
1247         }
1248
1249         private async Task CreateDirectMessagesEventFromJson(TwitterMessageEventList eventList, bool read)
1250         {
1251             var events = eventList.Events
1252                 .Where(x => x.Type == "message_create")
1253                 .ToArray();
1254
1255             if (events.Length == 0)
1256                 return;
1257
1258             var userIds = Enumerable.Concat(
1259                 events.Select(x => x.MessageCreate.SenderId),
1260                 events.Select(x => x.MessageCreate.Target.RecipientId)
1261             ).Distinct().ToArray();
1262
1263             var users = (await this.Api.UsersLookup(userIds).ConfigureAwait(false))
1264                 .ToDictionary(x => x.IdStr);
1265
1266             var apps = eventList.Apps ?? new Dictionary<string, TwitterMessageEventList.App>();
1267
1268             this.CreateDirectMessagesEventFromJson(events, users, apps, read);
1269         }
1270
1271         private void CreateDirectMessagesEventFromJson(IEnumerable<TwitterMessageEvent> events, IReadOnlyDictionary<string, TwitterUser> users,
1272             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps, bool read)
1273         {
1274             foreach (var eventItem in events)
1275             {
1276                 var post = new PostClass();
1277                 post.StatusId = long.Parse(eventItem.Id);
1278
1279                 var timestamp = long.Parse(eventItem.CreatedTimestamp);
1280                 post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
1281                 //本文
1282                 var textFromApi = eventItem.MessageCreate.MessageData.Text;
1283
1284                 var entities = eventItem.MessageCreate.MessageData.Entities;
1285                 var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
1286
1287                 if (mediaEntity != null)
1288                     entities.Media = new[] { mediaEntity };
1289
1290                 //HTMLに整形
1291                 post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
1292                 post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
1293                 post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
1294                 post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
1295                 post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
1296                 post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
1297                 post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
1298                 post.IsFav = false;
1299
1300                 this.ExtractEntities(entities, post.ReplyToList, post.Media);
1301
1302                 post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
1303                     .Distinct().ToArray();
1304
1305                 post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
1306                     .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
1307                     .ToArray();
1308
1309                 //以下、ユーザー情報
1310                 string userId;
1311                 if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
1312                 {
1313                     userId = eventItem.MessageCreate.SenderId;
1314                     post.IsMe = false;
1315                     post.IsOwl = true;
1316                 }
1317                 else
1318                 {
1319                     userId = eventItem.MessageCreate.Target.RecipientId;
1320                     post.IsMe = true;
1321                     post.IsOwl = false;
1322                 }
1323
1324                 if (!users.TryGetValue(userId, out var user))
1325                     continue;
1326
1327                 post.UserId = user.Id;
1328                 post.ScreenName = user.ScreenName;
1329                 post.Nickname = user.Name.Trim();
1330                 post.ImageUrl = user.ProfileImageUrlHttps;
1331                 post.IsProtect = user.Protected;
1332
1333                 // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
1334                 if (post.Text == post.TextFromApi)
1335                     post.Text = post.TextFromApi;
1336                 if (post.AccessibleText == post.TextFromApi)
1337                     post.AccessibleText = post.TextFromApi;
1338
1339                 // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
1340                 post.ScreenName = string.Intern(post.ScreenName);
1341                 post.Nickname = string.Intern(post.Nickname);
1342                 post.ImageUrl = string.Intern(post.ImageUrl);
1343
1344                 var appId = eventItem.MessageCreate.SourceAppId;
1345                 if (appId != null && apps.TryGetValue(appId, out var app))
1346                 {
1347                     post.Source = string.Intern(app.Name);
1348
1349                     try
1350                     {
1351                         post.SourceUri = new Uri(SourceUriBase, app.Url);
1352                     }
1353                     catch (UriFormatException) { }
1354                 }
1355
1356                 post.IsRead = read;
1357                 if (post.IsMe && !read && this.ReadOwnPost)
1358                     post.IsRead = true;
1359                 post.IsReply = false;
1360                 post.IsExcludeReply = false;
1361                 post.IsDm = true;
1362
1363                 var dmTab = TabInformations.GetInstance().DirectMessageTab;
1364                 dmTab.AddPostQueue(post);
1365             }
1366         }
1367
1368         public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backward)
1369         {
1370             this.CheckAccountState();
1371
1372             var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
1373
1374             TwitterStatus[] statuses;
1375             if (backward)
1376             {
1377                 statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
1378                     .ConfigureAwait(false);
1379             }
1380             else
1381             {
1382                 statuses = await this.Api.FavoritesList(count)
1383                     .ConfigureAwait(false);
1384             }
1385
1386             var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
1387
1388             if (minimumId != null)
1389                 tab.OldestId = minimumId.Value;
1390         }
1391
1392         private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1393         {
1394             if (entities != null)
1395             {
1396                 if (entities.Urls != null)
1397                 {
1398                     foreach (var m in entities.Urls)
1399                     {
1400                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1401                     }
1402                 }
1403                 if (entities.Media != null)
1404                 {
1405                     foreach (var m in entities.Media)
1406                     {
1407                         if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
1408                     }
1409                 }
1410             }
1411
1412             if (quotedStatusLink != null)
1413                 text += " " + quotedStatusLink.Display;
1414
1415             return text;
1416         }
1417
1418         internal static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
1419         {
1420             if (entities == null)
1421                 return text;
1422
1423             if (entities.Urls != null)
1424             {
1425                 foreach (var entity in entities.Urls)
1426                 {
1427                     if (quotedStatus != null)
1428                     {
1429                         var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
1430                         if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
1431                         {
1432                             var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1433                             text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
1434                             continue;
1435                         }
1436                     }
1437
1438                     if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1439                         text = text.Replace(entity.Url, entity.DisplayUrl);
1440                 }
1441             }
1442
1443             if (entities.Media != null)
1444             {
1445                 foreach (var entity in entities.Media)
1446                 {
1447                     if (!MyCommon.IsNullOrEmpty(entity.AltText))
1448                     {
1449                         text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
1450                     }
1451                     else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
1452                     {
1453                         text = text.Replace(entity.Url, entity.DisplayUrl);
1454                     }
1455                 }
1456             }
1457
1458             if (quotedStatus != null && quotedStatusLink != null)
1459             {
1460                 var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
1461                 text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
1462             }
1463
1464             return text;
1465         }
1466
1467         /// <summary>
1468         /// フォロワーIDを更新します
1469         /// </summary>
1470         /// <exception cref="WebApiException"/>
1471         public async Task RefreshFollowerIds()
1472         {
1473             if (MyCommon._endingFlag) return;
1474
1475             var cursor = -1L;
1476             var newFollowerIds = Enumerable.Empty<long>();
1477             do
1478             {
1479                 var ret = await this.Api.FollowersIds(cursor)
1480                     .ConfigureAwait(false);
1481
1482                 if (ret.Ids == null)
1483                     throw new WebApiException("ret.ids == null");
1484
1485                 newFollowerIds = newFollowerIds.Concat(ret.Ids);
1486                 cursor = ret.NextCursor;
1487             } while (cursor != 0);
1488
1489             this.followerId = newFollowerIds.ToHashSet();
1490             TabInformations.GetInstance().RefreshOwl(this.followerId);
1491
1492             this.GetFollowersSuccess = true;
1493         }
1494
1495         /// <summary>
1496         /// RT 非表示ユーザーを更新します
1497         /// </summary>
1498         /// <exception cref="WebApiException"/>
1499         public async Task RefreshNoRetweetIds()
1500         {
1501             if (MyCommon._endingFlag) return;
1502
1503             this.noRTId = await this.Api.NoRetweetIds()
1504                 .ConfigureAwait(false);
1505
1506             this.GetNoRetweetSuccess = true;
1507         }
1508
1509         /// <summary>
1510         /// t.co の文字列長などの設定情報を更新します
1511         /// </summary>
1512         /// <exception cref="WebApiException"/>
1513         public async Task RefreshConfiguration()
1514         {
1515             this.Configuration = await this.Api.Configuration()
1516                 .ConfigureAwait(false);
1517
1518             // TextConfiguration 相当の JSON を得る API が存在しないため、TransformedURLLength のみ help/configuration.json に合わせて更新する
1519             this.TextConfiguration.TransformedURLLength = this.Configuration.ShortUrlLengthHttps;
1520         }
1521
1522         public async Task GetListsApi()
1523         {
1524             this.CheckAccountState();
1525
1526             var ownedLists = await TwitterLists.GetAllItemsAsync(x =>
1527                 this.Api.ListsOwnerships(this.Username, cursor: x, count: 1000))
1528                     .ConfigureAwait(false);
1529
1530             var subscribedLists = await TwitterLists.GetAllItemsAsync(x =>
1531                 this.Api.ListsSubscriptions(this.Username, cursor: x, count: 1000))
1532                     .ConfigureAwait(false);
1533
1534             TabInformations.GetInstance().SubscribableLists = Enumerable.Concat(ownedLists, subscribedLists)
1535                 .Select(x => new ListElement(x, this))
1536                 .ToList();
1537         }
1538
1539         public async Task DeleteList(long listId)
1540         {
1541             await this.Api.ListsDestroy(listId)
1542                 .IgnoreResponse()
1543                 .ConfigureAwait(false);
1544
1545             var tabinfo = TabInformations.GetInstance();
1546
1547             tabinfo.SubscribableLists = tabinfo.SubscribableLists
1548                 .Where(x => x.Id != listId)
1549                 .ToList();
1550         }
1551
1552         public async Task<ListElement> EditList(long listId, string new_name, bool isPrivate, string description)
1553         {
1554             var response = await this.Api.ListsUpdate(listId, new_name, description, isPrivate)
1555                 .ConfigureAwait(false);
1556
1557             var list = await response.LoadJsonAsync()
1558                 .ConfigureAwait(false);
1559
1560             return new ListElement(list, this);
1561         }
1562
1563         public async Task<long> GetListMembers(long listId, List<UserInfo> lists, long cursor)
1564         {
1565             this.CheckAccountState();
1566
1567             var users = await this.Api.ListsMembers(listId, cursor)
1568                 .ConfigureAwait(false);
1569
1570             Array.ForEach(users.Users, u => lists.Add(new UserInfo(u)));
1571
1572             return users.NextCursor;
1573         }
1574
1575         public async Task CreateListApi(string listName, bool isPrivate, string description)
1576         {
1577             this.CheckAccountState();
1578
1579             var response = await this.Api.ListsCreate(listName, description, isPrivate)
1580                 .ConfigureAwait(false);
1581
1582             var list = await response.LoadJsonAsync()
1583                 .ConfigureAwait(false);
1584
1585             TabInformations.GetInstance().SubscribableLists.Add(new ListElement(list, this));
1586         }
1587
1588         public async Task<bool> ContainsUserAtList(long listId, string user)
1589         {
1590             this.CheckAccountState();
1591
1592             try
1593             {
1594                 await this.Api.ListsMembersShow(listId, user)
1595                     .ConfigureAwait(false);
1596
1597                 return true;
1598             }
1599             catch (TwitterApiException ex)
1600                 when (ex.Errors.Any(x => x.Code == TwitterErrorCode.NotFound))
1601             {
1602                 return false;
1603             }
1604         }
1605
1606         private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> AtList, List<MediaInfo> media)
1607         {
1608             if (entities != null)
1609             {
1610                 if (entities.Hashtags != null)
1611                 {
1612                     lock (this.LockObj)
1613                     {
1614                         this._hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
1615                     }
1616                 }
1617                 if (entities.UserMentions != null)
1618                 {
1619                     foreach (var ent in entities.UserMentions)
1620                     {
1621                         AtList.Add((ent.Id, ent.ScreenName));
1622                     }
1623                 }
1624                 if (entities.Media != null)
1625                 {
1626                     if (media != null)
1627                     {
1628                         foreach (var ent in entities.Media)
1629                         {
1630                             if (!media.Any(x => x.Url == ent.MediaUrlHttps))
1631                             {
1632                                 if (ent.VideoInfo != null &&
1633                                     ent.Type == "animated_gif" || ent.Type == "video")
1634                                 {
1635                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
1636                                 }
1637                                 else
1638                                     media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
1639                             }
1640                         }
1641                     }
1642                 }
1643             }
1644         }
1645
1646         internal static string CreateHtmlAnchor(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
1647         {
1648             var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
1649
1650             // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
1651             text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
1652
1653             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>");
1654             text = PreProcessUrl(text); //IDN置換
1655
1656             if (quotedStatusLink != null)
1657             {
1658                 text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
1659                     WebUtility.HtmlEncode(quotedStatusLink.Url),
1660                     WebUtility.HtmlEncode(quotedStatusLink.Display));
1661             }
1662
1663             return text;
1664         }
1665
1666         private static readonly Uri SourceUriBase = new Uri("https://twitter.com/");
1667
1668         /// <summary>
1669         /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
1670         /// </summary>
1671         internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
1672         {
1673             if (MyCommon.IsNullOrEmpty(sourceHtml))
1674                 return ("", null);
1675
1676             string sourceText;
1677             Uri? sourceUri;
1678
1679             // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
1680
1681             var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
1682             if (match.Success)
1683             {
1684                 sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
1685                 try
1686                 {
1687                     var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
1688                     sourceUri = new Uri(SourceUriBase, uriStr);
1689                 }
1690                 catch (UriFormatException)
1691                 {
1692                     sourceUri = null;
1693                 }
1694             }
1695             else
1696             {
1697                 sourceText = WebUtility.HtmlDecode(sourceHtml);
1698                 sourceUri = null;
1699             }
1700
1701             return (sourceText, sourceUri);
1702         }
1703
1704         public async Task<TwitterApiStatus?> GetInfoApi()
1705         {
1706             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
1707
1708             if (MyCommon._endingFlag) return null;
1709
1710             var limits = await this.Api.ApplicationRateLimitStatus()
1711                 .ConfigureAwait(false);
1712
1713             MyCommon.TwitterApiInfo.UpdateFromJson(limits);
1714
1715             return MyCommon.TwitterApiInfo;
1716         }
1717
1718         /// <summary>
1719         /// ブロック中のユーザーを更新します
1720         /// </summary>
1721         /// <exception cref="WebApiException"/>
1722         public async Task RefreshBlockIds()
1723         {
1724             if (MyCommon._endingFlag) return;
1725
1726             var cursor = -1L;
1727             var newBlockIds = Enumerable.Empty<long>();
1728             do
1729             {
1730                 var ret = await this.Api.BlocksIds(cursor)
1731                     .ConfigureAwait(false);
1732
1733                 newBlockIds = newBlockIds.Concat(ret.Ids);
1734                 cursor = ret.NextCursor;
1735             } while (cursor != 0);
1736
1737             var blockIdsSet = newBlockIds.ToHashSet();
1738             blockIdsSet.Remove(this.UserId); // 元のソースにあったので一応残しておく
1739
1740             TabInformations.GetInstance().BlockIds = blockIdsSet;
1741         }
1742
1743         /// <summary>
1744         /// ミュート中のユーザーIDを更新します
1745         /// </summary>
1746         /// <exception cref="WebApiException"/>
1747         public async Task RefreshMuteUserIdsAsync()
1748         {
1749             if (MyCommon._endingFlag) return;
1750
1751             var ids = await TwitterIds.GetAllItemsAsync(x => this.Api.MutesUsersIds(x))
1752                 .ConfigureAwait(false);
1753
1754             TabInformations.GetInstance().MuteUserIds = ids.ToHashSet();
1755         }
1756
1757         public string[] GetHashList()
1758         {
1759             string[] hashArray;
1760             lock (LockObj)
1761             {
1762                 hashArray = _hashList.ToArray();
1763                 _hashList.Clear();
1764             }
1765             return hashArray;
1766         }
1767
1768         public string AccessToken
1769             => ((TwitterApiConnection)this.Api.Connection).AccessToken;
1770
1771         public string AccessTokenSecret
1772             => ((TwitterApiConnection)this.Api.Connection).AccessSecret;
1773
1774         private void CheckAccountState()
1775         {
1776             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid)
1777                 throw new WebApiException("Auth error. Check your account");
1778         }
1779
1780         private void CheckAccessLevel(TwitterApiAccessLevel accessLevelFlags)
1781         {
1782             if (!this.AccessLevel.HasFlag(accessLevelFlags))
1783                 throw new WebApiException("Auth Err:try to re-authorization.");
1784         }
1785
1786         public int GetTextLengthRemain(string postText)
1787         {
1788             var matchDm = Twitter.DMSendTextRegex.Match(postText);
1789             if (matchDm.Success)
1790                 return this.GetTextLengthRemainDM(matchDm.Groups["body"].Value);
1791
1792             return this.GetTextLengthRemainWeighted(postText);
1793         }
1794
1795         private int GetTextLengthRemainDM(string postText)
1796         {
1797             var textLength = 0;
1798
1799             var pos = 0;
1800             while (pos < postText.Length)
1801             {
1802                 textLength++;
1803
1804                 if (char.IsSurrogatePair(postText, pos))
1805                     pos += 2; // サロゲートペアの場合は2文字分進める
1806                 else
1807                     pos++;
1808             }
1809
1810             var urls = TweetExtractor.ExtractUrls(postText);
1811             foreach (var url in urls)
1812             {
1813                 var shortUrlLength = url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
1814                     ? this.Configuration.ShortUrlLengthHttps
1815                     : this.Configuration.ShortUrlLength;
1816
1817                 textLength += shortUrlLength - url.Length;
1818             }
1819
1820             return this.Configuration.DmTextCharacterLimit - textLength;
1821         }
1822
1823         private int GetTextLengthRemainWeighted(string postText)
1824         {
1825             var config = this.TextConfiguration;
1826             var totalWeight = 0;
1827
1828             int GetWeightFromCodepoint(int codepoint)
1829             {
1830                 foreach (var weightRange in config.Ranges)
1831                 {
1832                     if (codepoint >= weightRange.Start && codepoint <= weightRange.End)
1833                         return weightRange.Weight;
1834                 }
1835
1836                 return config.DefaultWeight;
1837             }
1838
1839             var urls = TweetExtractor.ExtractUrlEntities(postText).ToArray();
1840             var emojis = config.EmojiParsingEnabled
1841                 ? TweetExtractor.ExtractEmojiEntities(postText).ToArray()
1842                 : Array.Empty<TwitterEntityEmoji>();
1843
1844             var codepoints = postText.ToCodepoints().ToArray();
1845             var index = 0;
1846             while (index < codepoints.Length)
1847             {
1848                 var urlEntity = urls.FirstOrDefault(x => x.Indices[0] == index);
1849                 if (urlEntity != null)
1850                 {
1851                     totalWeight += config.TransformedURLLength * config.Scale;
1852                     index = urlEntity.Indices[1];
1853                     continue;
1854                 }
1855
1856                 var emojiEntity = emojis.FirstOrDefault(x => x.Indices[0] == index);
1857                 if (emojiEntity != null)
1858                 {
1859                     totalWeight += GetWeightFromCodepoint(codepoints[index]);
1860                     index = emojiEntity.Indices[1];
1861                     continue;
1862                 }
1863
1864                 var codepoint = codepoints[index];
1865                 totalWeight += GetWeightFromCodepoint(codepoint);
1866
1867                 index++;
1868             }
1869
1870             var remainWeight = config.MaxWeightedTweetLength * config.Scale - totalWeight;
1871
1872             return remainWeight / config.Scale;
1873         }
1874
1875
1876 #region "UserStream"
1877         public string TrackWord { get; set; } = "";
1878         public bool AllAtReply { get; set; } = false;
1879
1880         public event EventHandler? NewPostFromStream;
1881         public event EventHandler? UserStreamStarted;
1882         public event EventHandler? UserStreamStopped;
1883         public event EventHandler<PostDeletedEventArgs>? PostDeleted;
1884         public event EventHandler<UserStreamEventReceivedEventArgs>? UserStreamEventReceived;
1885         private DateTimeUtc _lastUserstreamDataReceived;
1886         private StreamAutoConnector? userStreamConnector;
1887
1888         public class FormattedEvent
1889         {
1890             public MyCommon.EVENTTYPE Eventtype { get; set; }
1891             public DateTimeUtc CreatedAt { get; set; }
1892             public string Event { get; set; } = "";
1893             public string Username { get; set; } = "";
1894             public string Target { get; set; } = "";
1895             public long Id { get; set; }
1896             public bool IsMe { get; set; }
1897         }
1898
1899         public List<FormattedEvent> StoredEvent { get; } = new List<FormattedEvent>();
1900
1901         private readonly IReadOnlyDictionary<string, MyCommon.EVENTTYPE> eventTable = new Dictionary<string, MyCommon.EVENTTYPE>
1902         {
1903             ["favorite"] = MyCommon.EVENTTYPE.Favorite,
1904             ["unfavorite"] = MyCommon.EVENTTYPE.Unfavorite,
1905             ["follow"] = MyCommon.EVENTTYPE.Follow,
1906             ["list_member_added"] = MyCommon.EVENTTYPE.ListMemberAdded,
1907             ["list_member_removed"] = MyCommon.EVENTTYPE.ListMemberRemoved,
1908             ["block"] = MyCommon.EVENTTYPE.Block,
1909             ["unblock"] = MyCommon.EVENTTYPE.Unblock,
1910             ["user_update"] = MyCommon.EVENTTYPE.UserUpdate,
1911             ["deleted"] = MyCommon.EVENTTYPE.Deleted,
1912             ["list_created"] = MyCommon.EVENTTYPE.ListCreated,
1913             ["list_destroyed"] = MyCommon.EVENTTYPE.ListDestroyed,
1914             ["list_updated"] = MyCommon.EVENTTYPE.ListUpdated,
1915             ["unfollow"] = MyCommon.EVENTTYPE.Unfollow,
1916             ["list_user_subscribed"] = MyCommon.EVENTTYPE.ListUserSubscribed,
1917             ["list_user_unsubscribed"] = MyCommon.EVENTTYPE.ListUserUnsubscribed,
1918             ["mute"] = MyCommon.EVENTTYPE.Mute,
1919             ["unmute"] = MyCommon.EVENTTYPE.Unmute,
1920             ["quoted_tweet"] = MyCommon.EVENTTYPE.QuotedTweet,
1921         };
1922
1923         public bool IsUserstreamDataReceived
1924             => (DateTimeUtc.Now - this._lastUserstreamDataReceived).TotalSeconds < 31;
1925
1926         private void userStream_MessageReceived(ITwitterStreamMessage message)
1927         {
1928             this._lastUserstreamDataReceived = DateTimeUtc.Now;
1929
1930             switch (message)
1931             {
1932                 case StreamMessageStatus statusMessage:
1933                     var status = statusMessage.Status.Normalize();
1934
1935                     if (status.RetweetedStatus is TwitterStatus retweetedStatus)
1936                     {
1937                         var sourceUserId = statusMessage.Status.User.Id;
1938                         var targetUserId = retweetedStatus.User.Id;
1939
1940                         // 自分に関係しないリツイートの場合は無視する
1941                         var selfUserId = this.UserId;
1942                         if (sourceUserId == selfUserId || targetUserId == selfUserId)
1943                         {
1944                             // 公式 RT をイベントとしても扱う
1945                             var evt = this.CreateEventFromRetweet(status);
1946                             this.StoredEvent.Insert(0, evt);
1947                             this.UserStreamEventReceived?.Invoke(this, new UserStreamEventReceivedEventArgs(evt));
1948                         }
1949                         // 従来通り公式 RT の表示も行うため break しない
1950                     }
1951
1952                     this.CreatePostsFromJson(new[] { status }, MyCommon.WORKERTYPE.UserStream, null, false);
1953                     this.NewPostFromStream?.Invoke(this, EventArgs.Empty);
1954                     break;
1955
1956                 case StreamMessageDirectMessage dmMessage:
1957                     this.CreateDirectMessagesFromJson(new[] { dmMessage.DirectMessage }, MyCommon.WORKERTYPE.UserStream, false);
1958                     this.NewPostFromStream?.Invoke(this, EventArgs.Empty);
1959                     break;
1960
1961                 case StreamMessageDelete deleteMessage:
1962                     var deletedId = deleteMessage.Status?.Id ?? deleteMessage.DirectMessage?.Id;
1963                     if (deletedId == null)
1964                         break;
1965
1966                     this.PostDeleted?.Invoke(this, new PostDeletedEventArgs(deletedId.Value));
1967
1968                     foreach (var index in MyCommon.CountDown(this.StoredEvent.Count - 1, 0))
1969                     {
1970                         var evt = this.StoredEvent[index];
1971                         if (evt.Id == deletedId.Value && (evt.Event == "favorite" || evt.Event == "unfavorite"))
1972                         {
1973                             this.StoredEvent.RemoveAt(index);
1974                         }
1975                     }
1976                     break;
1977
1978                 case StreamMessageEvent eventMessage:
1979                     this.CreateEventFromJson(eventMessage);
1980                     break;
1981
1982                 case StreamMessageScrubGeo scrubGeoMessage:
1983                     TabInformations.GetInstance().ScrubGeoReserve(scrubGeoMessage.UserId, scrubGeoMessage.UpToStatusId);
1984                     break;
1985
1986                 default:
1987                     break;
1988             }
1989         }
1990
1991         /// <summary>
1992         /// UserStreamsから受信した公式RTをイベントに変換します
1993         /// </summary>
1994         private FormattedEvent CreateEventFromRetweet(TwitterStatus status)
1995         {
1996             if (status.RetweetedStatus == null)
1997                 throw new InvalidOperationException();
1998
1999             return new FormattedEvent
2000             {
2001                 Eventtype = MyCommon.EVENTTYPE.Retweet,
2002                 Event = "retweet",
2003                 CreatedAt = MyCommon.DateTimeParse(status.CreatedAt),
2004                 IsMe = status.User.Id == this.UserId,
2005                 Username = status.User.ScreenName,
2006                 Target = string.Format("@{0}:{1}", new[]
2007                 {
2008                     status.RetweetedStatus.User.ScreenName,
2009                     WebUtility.HtmlDecode(status.RetweetedStatus.FullText),
2010                 }),
2011                 Id = status.RetweetedStatus.Id,
2012             };
2013         }
2014
2015         private void CreateEventFromJson(StreamMessageEvent message)
2016         {
2017             var eventData = message.Event;
2018
2019             var evt = new FormattedEvent
2020             {
2021                 CreatedAt = MyCommon.DateTimeParse(eventData.CreatedAt),
2022                 Event = eventData.Event,
2023                 Username = eventData.Source.ScreenName,
2024                 IsMe = eventData.Source.Id == this.UserId,
2025                 Eventtype = eventTable.TryGetValue(eventData.Event, out var eventType) ? eventType : MyCommon.EVENTTYPE.None,
2026             };
2027
2028             TwitterStreamEvent<TwitterStatusCompat> tweetEvent;
2029             TwitterStatus tweet;
2030
2031             switch (eventData.Event)
2032             {
2033                 case "access_revoked":
2034                 case "access_unrevoked":
2035                 case "user_delete":
2036                 case "user_suspend":
2037                     return;
2038                 case "follow":
2039                     if (eventData.Target.Id == this.UserId)
2040                     {
2041                         if (!this.followerId.Contains(eventData.Source.Id)) this.followerId.Add(eventData.Source.Id);
2042                     }
2043                     else
2044                     {
2045                         return;    //Block後のUndoをすると、SourceとTargetが逆転したfollowイベントが帰ってくるため。
2046                     }
2047                     evt.Target = "";
2048                     break;
2049                 case "unfollow":
2050                     evt.Target = "@" + eventData.Target.ScreenName;
2051                     break;
2052                 case "favorited_retweet":
2053                 case "retweeted_retweet":
2054                     return;
2055                 case "favorite":
2056                 case "unfavorite":
2057                     tweetEvent = message.ParseTargetObjectAs<TwitterStatusCompat>();
2058                     tweet = tweetEvent.TargetObject.Normalize();
2059                     evt.Target = "@" + tweet.User.ScreenName + ":" + WebUtility.HtmlDecode(tweet.FullText);
2060                     evt.Id = tweet.Id;
2061
2062                     if (SettingManager.Common.IsRemoveSameEvent)
2063                     {
2064                         if (this.StoredEvent.Any(ev => ev.Username == evt.Username && ev.Eventtype == evt.Eventtype && ev.Target == evt.Target))
2065                             return;
2066                     }
2067
2068                     var tabinfo = TabInformations.GetInstance();
2069
2070                     var statusId = tweet.Id;
2071                     if (!tabinfo.Posts.TryGetValue(statusId, out var post))
2072                         break;
2073
2074                     if (eventData.Event == "favorite")
2075                     {
2076                         var favTab = tabinfo.FavoriteTab;
2077                         favTab.AddPostQueue(post);
2078
2079                         if (tweetEvent.Source.Id == this.UserId)
2080                         {
2081                             post.IsFav = true;
2082                         }
2083                         else if (tweetEvent.Target.Id == this.UserId)
2084                         {
2085                             post.FavoritedCount++;
2086
2087                             if (SettingManager.Common.FavEventUnread)
2088                                 tabinfo.SetReadAllTab(post.StatusId, read: false);
2089                         }
2090                     }
2091                     else // unfavorite
2092                     {
2093                         if (tweetEvent.Source.Id == this.UserId)
2094                         {
2095                             post.IsFav = false;
2096                         }
2097                         else if (tweetEvent.Target.Id == this.UserId)
2098                         {
2099                             post.FavoritedCount = Math.Max(0, post.FavoritedCount - 1);
2100                         }
2101                     }
2102                     break;
2103                 case "quoted_tweet":
2104                     if (evt.IsMe) return;
2105
2106                     tweetEvent = message.ParseTargetObjectAs<TwitterStatusCompat>();
2107                     tweet = tweetEvent.TargetObject.Normalize();
2108                     evt.Target = "@" + tweet.User.ScreenName + ":" + WebUtility.HtmlDecode(tweet.FullText);
2109                     evt.Id = tweet.Id;
2110
2111                     if (SettingManager.Common.IsRemoveSameEvent)
2112                     {
2113                         if (this.StoredEvent.Any(ev => ev.Username == evt.Username && ev.Eventtype == evt.Eventtype && ev.Target == evt.Target))
2114                             return;
2115                     }
2116                     break;
2117                 case "list_member_added":
2118                 case "list_member_removed":
2119                 case "list_created":
2120                 case "list_destroyed":
2121                 case "list_updated":
2122                 case "list_user_subscribed":
2123                 case "list_user_unsubscribed":
2124                     var listEvent = message.ParseTargetObjectAs<TwitterList>();
2125                     evt.Target = listEvent.TargetObject.FullName;
2126                     break;
2127                 case "block":
2128                     if (!TabInformations.GetInstance().BlockIds.Contains(eventData.Target.Id)) TabInformations.GetInstance().BlockIds.Add(eventData.Target.Id);
2129                     evt.Target = "";
2130                     break;
2131                 case "unblock":
2132                     if (TabInformations.GetInstance().BlockIds.Contains(eventData.Target.Id)) TabInformations.GetInstance().BlockIds.Remove(eventData.Target.Id);
2133                     evt.Target = "";
2134                     break;
2135                 case "user_update":
2136                     evt.Target = "";
2137                     break;
2138                 
2139                 // Mute / Unmute
2140                 case "mute":
2141                     evt.Target = "@" + eventData.Target.ScreenName;
2142                     if (!TabInformations.GetInstance().MuteUserIds.Contains(eventData.Target.Id))
2143                     {
2144                         TabInformations.GetInstance().MuteUserIds.Add(eventData.Target.Id);
2145                     }
2146                     break;
2147                 case "unmute":
2148                     evt.Target = "@" + eventData.Target.ScreenName;
2149                     if (TabInformations.GetInstance().MuteUserIds.Contains(eventData.Target.Id))
2150                     {
2151                         TabInformations.GetInstance().MuteUserIds.Remove(eventData.Target.Id);
2152                     }
2153                     break;
2154
2155                 default:
2156                     MyCommon.TraceOut("Unknown Event:" + evt.Event + Environment.NewLine + message.Json);
2157                     break;
2158             }
2159             this.StoredEvent.Insert(0, evt);
2160
2161             this.UserStreamEventReceived?.Invoke(this, new UserStreamEventReceivedEventArgs(evt));
2162         }
2163
2164         private void userStream_Started()
2165             => this.UserStreamStarted?.Invoke(this, EventArgs.Empty);
2166
2167         private void userStream_Stopped()
2168             => this.UserStreamStopped?.Invoke(this, EventArgs.Empty);
2169
2170         public bool UserStreamActive
2171             => this.userStreamConnector != null && this.userStreamConnector.IsStreamActive;
2172
2173         public void StartUserStream()
2174         {
2175             var replies = this.AllAtReply ? "all" : null;
2176             var streamObservable = this.Api.UserStreams(replies, this.TrackWord);
2177             var newConnector = new StreamAutoConnector(streamObservable);
2178
2179             newConnector.MessageReceived += userStream_MessageReceived;
2180             newConnector.Started += userStream_Started;
2181             newConnector.Stopped += userStream_Stopped;
2182
2183             newConnector.Start();
2184
2185             var oldConnector = Interlocked.Exchange(ref this.userStreamConnector, newConnector);
2186             oldConnector?.Dispose();
2187         }
2188
2189         public void StopUserStream()
2190         {
2191             var oldConnector = Interlocked.Exchange(ref this.userStreamConnector, null);
2192             oldConnector?.Dispose();
2193         }
2194
2195         public void ReconnectUserStream()
2196         {
2197             if (this.userStreamConnector != null)
2198             {
2199                 this.StartUserStream();
2200             }
2201         }
2202
2203         private class StreamAutoConnector : IDisposable
2204         {
2205             private readonly TwitterStreamObservable streamObservable;
2206
2207             public bool IsStreamActive { get; private set; }
2208             public bool IsDisposed { get; private set; }
2209
2210             public event Action<ITwitterStreamMessage>? MessageReceived;
2211             public event Action? Stopped;
2212             public event Action? Started;
2213
2214             private Task? streamTask;
2215             private CancellationTokenSource streamCts = new CancellationTokenSource();
2216
2217             public StreamAutoConnector(TwitterStreamObservable streamObservable)
2218                 => this.streamObservable = streamObservable;
2219
2220             public void Start()
2221             {
2222                 var cts = new CancellationTokenSource();
2223
2224                 this.streamCts = cts;
2225                 this.streamTask = Task.Run(async () =>
2226                 {
2227                     try
2228                     {
2229                         await this.StreamLoop(cts.Token)
2230                             .ConfigureAwait(false);
2231                     }
2232                     catch (OperationCanceledException) { }
2233                 });
2234             }
2235
2236             public void Stop()
2237             {
2238                 this.streamCts?.Cancel();
2239
2240                 // streamTask の完了を待たずに IsStreamActive を false にセットする
2241                 this.IsStreamActive = false;
2242                 this.Stopped?.Invoke();
2243             }
2244
2245             private async Task StreamLoop(CancellationToken cancellationToken)
2246             {
2247                 var sleep = TimeSpan.Zero;
2248                 for (; ; )
2249                 {
2250                     if (sleep != TimeSpan.Zero)
2251                     {
2252                         await Task.Delay(sleep, cancellationToken)
2253                             .ConfigureAwait(false);
2254                         sleep = TimeSpan.Zero;
2255                     }
2256
2257                     if (!MyCommon.IsNetworkAvailable())
2258                     {
2259                         sleep = TimeSpan.FromSeconds(30);
2260                         continue;
2261                     }
2262
2263                     this.IsStreamActive = true;
2264                     this.Started?.Invoke();
2265
2266                     try
2267                     {
2268                         await this.streamObservable.ForEachAsync(
2269                             x => this.MessageReceived?.Invoke(x),
2270                             cancellationToken);
2271
2272                         // キャンセルされていないのにストリームが終了した場合
2273                         sleep = TimeSpan.FromSeconds(30);
2274                     }
2275                     catch (TwitterApiException ex) when (ex.StatusCode == HttpStatusCode.Gone)
2276                     {
2277                         // UserStreams停止によるエラーの場合は長めに間隔を開ける
2278                         sleep = TimeSpan.FromMinutes(10);
2279                     }
2280                     catch (TwitterApiException) { sleep = TimeSpan.FromSeconds(30); }
2281                     catch (IOException) { sleep = TimeSpan.FromSeconds(30); }
2282                     catch (OperationCanceledException)
2283                     {
2284                         if (cancellationToken.IsCancellationRequested)
2285                             throw;
2286
2287                         // cancellationToken によるキャンセルではない(=タイムアウトエラー)
2288                         sleep = TimeSpan.FromSeconds(30);
2289                     }
2290                     catch (Exception ex)
2291                     {
2292                         MyCommon.ExceptionOut(ex);
2293                         sleep = TimeSpan.FromSeconds(30);
2294                     }
2295                     finally
2296                     {
2297                         this.IsStreamActive = false;
2298                         this.Stopped?.Invoke();
2299                     }
2300                 }
2301             }
2302
2303             public void Dispose()
2304             {
2305                 if (this.IsDisposed)
2306                     return;
2307
2308                 this.IsDisposed = true;
2309
2310                 this.Stop();
2311
2312                 this.Started = null;
2313                 this.Stopped = null;
2314                 this.MessageReceived = null;
2315             }
2316         }
2317 #endregion
2318
2319 #region "IDisposable Support"
2320         private bool disposedValue; // 重複する呼び出しを検出するには
2321
2322         // IDisposable
2323         protected virtual void Dispose(bool disposing)
2324         {
2325             if (!this.disposedValue)
2326             {
2327                 if (disposing)
2328                 {
2329                     this.StopUserStream();
2330                 }
2331             }
2332             this.disposedValue = true;
2333         }
2334
2335         // このコードは、破棄可能なパターンを正しく実装できるように Visual Basic によって追加されました。
2336         public void Dispose()
2337         {
2338             // このコードを変更しないでください。クリーンアップ コードを上の Dispose(bool disposing) に記述します。
2339             Dispose(true);
2340             GC.SuppressFinalize(this);
2341         }
2342 #endregion
2343     }
2344
2345     public class PostDeletedEventArgs : EventArgs
2346     {
2347         public long StatusId { get; }
2348
2349         public PostDeletedEventArgs(long statusId)
2350             => this.StatusId = statusId;
2351     }
2352
2353     public class UserStreamEventReceivedEventArgs : EventArgs
2354     {
2355         public Twitter.FormattedEvent EventData { get; }
2356
2357         public UserStreamEventReceivedEventArgs(Twitter.FormattedEvent eventData)
2358             => this.EventData = eventData;
2359     }
2360 }