OSDN Git Service

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