OSDN Git Service

C# 8.0 のnull許容参照型を有効化
[opentween/open-tween.git] / OpenTween / TweetDetailsView.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      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
8 // All rights reserved.
9 //
10 // This file is part of OpenTween.
11 //
12 // This program is free software; you can redistribute it and/or modify it
13 // under the terms of the GNU General public License as published by the Free
14 // Software Foundation; either version 3 of the License, or (at your option)
15 // any later version.
16 //
17 // This program is distributed in the hope that it will be useful, but
18 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
20 // for more details.
21 //
22 // You should have received a copy of the GNU General public License along
23 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
24 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
25 // Boston, MA 02110-1301, USA.
26
27 #nullable enable
28
29 using System;
30 using System.Collections.Generic;
31 using System.ComponentModel;
32 using System.Data;
33 using System.Drawing;
34 using System.IO;
35 using System.Linq;
36 using System.Net;
37 using System.Net.Http;
38 using System.Text;
39 using System.Text.RegularExpressions;
40 using System.Threading;
41 using System.Threading.Tasks;
42 using System.Windows.Forms;
43 using OpenTween.Models;
44 using OpenTween.Setting;
45
46 namespace OpenTween
47 {
48     public partial class TweetDetailsView : UserControl
49     {
50         public TweenMain Owner { get; set; } = null!;
51
52         /// <summary>プロフィール画像のキャッシュ</summary>
53         public ImageCache IconCache { get; set; } = null!;
54
55         /// <summary><see cref="PostClass"/> のダンプを表示するか</summary>
56         public bool DumpPostClass { get; set; }
57
58         /// <summary>現在表示中の発言</summary>
59         public PostClass? CurrentPost { get; private set; }
60
61         [DefaultValue(false)]
62         public new bool TabStop
63         {
64             get => base.TabStop;
65             set => base.TabStop = value;
66         }
67
68         /// <summary>ステータスバーに表示するテキストの変化を通知するイベント</summary>
69         public event EventHandler<TweetDetailsViewStatusChengedEventArgs> StatusChanged;
70
71         /// <summary><see cref="ContextMenuPostBrowser"/> 展開時の <see cref="PostBrowser"/>.StatusText を保持するフィールド</summary>
72         private string _postBrowserStatusText = "";
73
74         public TweetDetailsView()
75         {
76             this.InitializeComponent();
77
78             this.TabStop = false;
79
80             //発言詳細部の初期化
81             NameLabel.Text = "";
82             DateTimeLabel.Text = "";
83             SourceLinkLabel.Text = "";
84
85             new InternetSecurityManager(PostBrowser);
86             this.PostBrowser.AllowWebBrowserDrop = false;  // COMException を回避するため、ActiveX の初期化が終わってから設定する
87         }
88
89         public async Task ShowPostDetails(PostClass post)
90         {
91             this.CurrentPost = post;
92
93             var loadTasks = new List<Task>();
94
95             using (ControlTransaction.Update(this.TableLayoutPanel1))
96             {
97                 SourceLinkLabel.Text = post.Source;
98                 SourceLinkLabel.TabStop = false; // Text を更新すると勝手に true にされる
99
100                 string nameText;
101                 if (post.IsDm)
102                 {
103                     if (post.IsOwl)
104                         nameText = "DM FROM <- ";
105                     else
106                         nameText = "DM TO -> ";
107                 }
108                 else
109                 {
110                     nameText = "";
111                 }
112                 nameText += post.ScreenName + "/" + post.Nickname;
113                 if (post.RetweetedId != null)
114                     nameText += " (RT:" + post.RetweetedBy + ")";
115
116                 NameLabel.Text = nameText;
117
118                 var nameForeColor = SystemColors.ControlText;
119                 if (post.IsOwl && (SettingManager.Common.OneWayLove || post.IsDm))
120                     nameForeColor = SettingManager.Local.ColorOWL;
121                 if (post.RetweetedId != null)
122                     nameForeColor = SettingManager.Local.ColorRetweet;
123                 if (post.IsFav)
124                     nameForeColor = SettingManager.Local.ColorFav;
125                 NameLabel.ForeColor = nameForeColor;
126
127                 loadTasks.Add(this.SetUserPictureAsync(post.ImageUrl));
128
129                 DateTimeLabel.Text = post.CreatedAt.ToLocalTimeString();
130             }
131
132             if (this.DumpPostClass)
133             {
134                 var sb = new StringBuilder(512);
135
136                 sb.Append("-----Start PostClass Dump<br>");
137                 sb.AppendFormat("TextFromApi           : {0}<br>", post.TextFromApi);
138                 sb.AppendFormat("(PlainText)    : <xmp>{0}</xmp><br>", post.TextFromApi);
139                 sb.AppendFormat("StatusId             : {0}<br>", post.StatusId);
140                 //sb.AppendFormat("ImageIndex     : {0}<br>", post.ImageIndex);
141                 sb.AppendFormat("ImageUrl       : {0}<br>", post.ImageUrl);
142                 sb.AppendFormat("InReplyToStatusId    : {0}<br>", post.InReplyToStatusId);
143                 sb.AppendFormat("InReplyToUser  : {0}<br>", post.InReplyToUser);
144                 sb.AppendFormat("IsDM           : {0}<br>", post.IsDm);
145                 sb.AppendFormat("IsFav          : {0}<br>", post.IsFav);
146                 sb.AppendFormat("IsMark         : {0}<br>", post.IsMark);
147                 sb.AppendFormat("IsMe           : {0}<br>", post.IsMe);
148                 sb.AppendFormat("IsOwl          : {0}<br>", post.IsOwl);
149                 sb.AppendFormat("IsProtect      : {0}<br>", post.IsProtect);
150                 sb.AppendFormat("IsRead         : {0}<br>", post.IsRead);
151                 sb.AppendFormat("IsReply        : {0}<br>", post.IsReply);
152
153                 foreach (var nm in post.ReplyToList.Select(x => x.ScreenName))
154                 {
155                     sb.AppendFormat("ReplyToList    : {0}<br>", nm);
156                 }
157
158                 sb.AppendFormat("ScreenName           : {0}<br>", post.ScreenName);
159                 sb.AppendFormat("NickName       : {0}<br>", post.Nickname);
160                 sb.AppendFormat("Text   : {0}<br>", post.Text);
161                 sb.AppendFormat("(PlainText)    : <xmp>{0}</xmp><br>", post.Text);
162                 sb.AppendFormat("CreatedAt          : {0}<br>", post.CreatedAt.ToLocalTimeString());
163                 sb.AppendFormat("Source         : {0}<br>", post.Source);
164                 sb.AppendFormat("UserId            : {0}<br>", post.UserId);
165                 sb.AppendFormat("FilterHit      : {0}<br>", post.FilterHit);
166                 sb.AppendFormat("RetweetedBy    : {0}<br>", post.RetweetedBy);
167                 sb.AppendFormat("RetweetedId    : {0}<br>", post.RetweetedId);
168
169                 sb.AppendFormat("Media.Count    : {0}<br>", post.Media.Count);
170                 if (post.Media.Count > 0)
171                 {
172                     for (var i = 0; i < post.Media.Count; i++)
173                     {
174                         var info = post.Media[i];
175                         sb.AppendFormat("Media[{0}].Url         : {1}<br>", i, info.Url);
176                         sb.AppendFormat("Media[{0}].VideoUrl    : {1}<br>", i, info.VideoUrl ?? "---");
177                     }
178                 }
179                 sb.Append("-----End PostClass Dump<br>");
180
181                 PostBrowser.DocumentText = this.Owner.createDetailHtml(sb.ToString());
182                 return;
183             }
184
185             using (ControlTransaction.Update(this.PostBrowser))
186             {
187                 this.PostBrowser.DocumentText =
188                     this.Owner.createDetailHtml(post.IsDeleted ? "(DELETED)" : post.Text);
189
190                 this.PostBrowser.Document.Window.ScrollTo(0, 0);
191             }
192
193             loadTasks.Add(this.AppendQuoteTweetAsync(post));
194
195             await Task.WhenAll(loadTasks);
196         }
197
198         public void ScrollDownPostBrowser(bool forward)
199         {
200             var doc = PostBrowser.Document;
201             if (doc == null) return;
202
203             var tags = doc.GetElementsByTagName("html");
204             if (tags.Count > 0)
205             {
206                 if (forward)
207                     tags[0].ScrollTop += SettingManager.Local.FontDetail.Height;
208                 else
209                     tags[0].ScrollTop -= SettingManager.Local.FontDetail.Height;
210             }
211         }
212
213         public void PageDownPostBrowser(bool forward)
214         {
215             var doc = PostBrowser.Document;
216             if (doc == null) return;
217
218             var tags = doc.GetElementsByTagName("html");
219             if (tags.Count > 0)
220             {
221                 if (forward)
222                     tags[0].ScrollTop += PostBrowser.ClientRectangle.Height - SettingManager.Local.FontDetail.Height;
223                 else
224                     tags[0].ScrollTop -= PostBrowser.ClientRectangle.Height - SettingManager.Local.FontDetail.Height;
225             }
226         }
227
228         public HtmlElement[] GetLinkElements()
229         {
230             return this.PostBrowser.Document.Links.Cast<HtmlElement>()
231                 .Where(x => x.GetAttribute("className") != "tweet-quote-link") // 引用ツイートで追加されたリンクを除く
232                 .ToArray();
233         }
234
235         private async Task SetUserPictureAsync(string imageUrl, bool force = false)
236         {
237             if (string.IsNullOrEmpty(imageUrl))
238                 return;
239
240             if (this.IconCache == null)
241                 return;
242
243             this.ClearUserPicture();
244
245             await this.UserPicture.SetImageFromTask(async () =>
246             {
247                 var image = await this.IconCache.DownloadImageAsync(imageUrl, force)
248                     .ConfigureAwait(false);
249
250                 return await image.CloneAsync()
251                     .ConfigureAwait(false);
252             });
253         }
254
255         /// <summary>
256         /// UserPicture.Image に設定されている画像を破棄します。
257         /// </summary>
258         private void ClearUserPicture()
259         {
260             if (this.UserPicture.Image != null)
261             {
262                 var oldImage = this.UserPicture.Image;
263                 this.UserPicture.Image = null;
264                 oldImage.Dispose();
265             }
266         }
267
268         /// <summary>
269         /// 発言詳細欄のツイートURLを展開する
270         /// </summary>
271         private async Task AppendQuoteTweetAsync(PostClass post)
272         {
273             var quoteStatusIds = post.QuoteStatusIds;
274             if (quoteStatusIds.Length == 0 && post.InReplyToStatusId == null)
275                 return;
276
277             // 「読み込み中」テキストを表示
278             var loadingQuoteHtml = quoteStatusIds.Select(x => FormatQuoteTweetHtml(x, Properties.Resources.LoadingText, isReply: false));
279
280             var loadingReplyHtml = string.Empty;
281             if (post.InReplyToStatusId != null)
282                 loadingReplyHtml = FormatQuoteTweetHtml(post.InReplyToStatusId.Value, Properties.Resources.LoadingText, isReply: true);
283
284             var body = post.Text + string.Concat(loadingQuoteHtml) + loadingReplyHtml;
285
286             using (ControlTransaction.Update(this.PostBrowser))
287                 this.PostBrowser.DocumentText = this.Owner.createDetailHtml(body);
288
289             // 引用ツイートを読み込み
290             var loadTweetTasks = quoteStatusIds.Select(x => this.CreateQuoteTweetHtml(x, isReply: false)).ToList();
291
292             if (post.InReplyToStatusId != null)
293                 loadTweetTasks.Add(this.CreateQuoteTweetHtml(post.InReplyToStatusId.Value, isReply: true));
294
295             var quoteHtmls = await Task.WhenAll(loadTweetTasks);
296
297             // 非同期処理中に表示中のツイートが変わっていたらキャンセルされたものと扱う
298             if (this.CurrentPost != post || this.CurrentPost.IsDeleted)
299                 return;
300
301             body = post.Text + string.Concat(quoteHtmls);
302
303             using (ControlTransaction.Update(this.PostBrowser))
304                 this.PostBrowser.DocumentText = this.Owner.createDetailHtml(body);
305         }
306
307         private async Task<string> CreateQuoteTweetHtml(long statusId, bool isReply)
308         {
309             var post = TabInformations.GetInstance()[statusId];
310             if (post == null)
311             {
312                 try
313                 {
314                     post = await this.Owner.TwitterInstance.GetStatusApi(false, statusId)
315                         .ConfigureAwait(false);
316                 }
317                 catch (WebApiException ex)
318                 {
319                     return FormatQuoteTweetHtml(statusId, WebUtility.HtmlEncode($"Err:{ex.Message}(GetStatus)"), isReply);
320                 }
321
322                 post.IsRead = true;
323                 if (!TabInformations.GetInstance().AddQuoteTweet(post))
324                     return FormatQuoteTweetHtml(statusId, "This Tweet is unavailable.", isReply);
325             }
326
327             return FormatQuoteTweetHtml(post, isReply);
328         }
329
330         internal static string FormatQuoteTweetHtml(PostClass post, bool isReply)
331         {
332             var innerHtml = "<p>" + StripLinkTagHtml(post.Text) + "</p>" +
333                 " &mdash; " + WebUtility.HtmlEncode(post.Nickname) +
334                 " (@" + WebUtility.HtmlEncode(post.ScreenName) + ") " +
335                 WebUtility.HtmlEncode(post.CreatedAt.ToLocalTimeString());
336
337             return FormatQuoteTweetHtml(post.StatusId, innerHtml, isReply);
338         }
339
340         internal static string FormatQuoteTweetHtml(long statusId, string innerHtml, bool isReply)
341         {
342             var blockClassName = "quote-tweet";
343
344             if (isReply)
345                 blockClassName += " reply";
346
347             return "<a class=\"quote-tweet-link\" href=\"//opentween/status/" + statusId + "\">" +
348                 $"<blockquote class=\"{blockClassName}\">{innerHtml}</blockquote>" +
349                 "</a>";
350         }
351
352         /// <summary>
353         /// 指定されたHTMLからリンクを除去します
354         /// </summary>
355         internal static string StripLinkTagHtml(string html)
356             => Regex.Replace(html, @"<a[^>]*>(.*?)</a>", "$1"); // a 要素はネストされていない前提の正規表現パターン
357
358         public async Task DoTranslation()
359         {
360             if (this.CurrentPost == null || this.CurrentPost.IsDeleted)
361                 return;
362
363             await this.DoTranslation(this.CurrentPost.TextFromApi);
364         }
365
366         private async Task DoTranslation(string str)
367         {
368             if (string.IsNullOrEmpty(str))
369                 return;
370
371             var bing = new Bing();
372             try
373             {
374                 var translatedText = await bing.TranslateAsync(str,
375                     langFrom: null,
376                     langTo: SettingManager.Common.TranslateLanguage);
377
378                 this.PostBrowser.DocumentText = this.Owner.createDetailHtml(translatedText);
379             }
380             catch (HttpRequestException e)
381             {
382                 this.RaiseStatusChanged("Err:" + e.Message);
383             }
384             catch (OperationCanceledException)
385             {
386                 this.RaiseStatusChanged("Err:Timeout");
387             }
388         }
389
390         private async Task DoSearchToolStrip(string url)
391         {
392             //発言詳細で「選択文字列で検索」(選択文字列取得)
393             var _selText = this.PostBrowser.GetSelectedText();
394
395             if (_selText != null)
396             {
397                 if (url == Properties.Resources.SearchItem4Url)
398                 {
399                     //公式検索
400                     this.Owner.AddNewTabForSearch(_selText);
401                     return;
402                 }
403
404                 var tmp = string.Format(url, Uri.EscapeDataString(_selText));
405                 await this.Owner.OpenUriInBrowserAsync(tmp);
406             }
407         }
408
409         private string? GetUserId()
410         {
411             var m = Regex.Match(this._postBrowserStatusText, @"^https?://twitter.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)(/status(es)?/[0-9]+)?$");
412             if (m.Success && this.Owner.IsTwitterId(m.Result("${ScreenName}")))
413                 return m.Result("${ScreenName}");
414             else
415                 return null;
416         }
417
418         protected void RaiseStatusChanged(string statusText)
419             => this.StatusChanged?.Invoke(this, new TweetDetailsViewStatusChengedEventArgs(statusText));
420
421         private void TweetDetailsView_FontChanged(object sender, EventArgs e)
422         {
423             // OTBaseForm.GlobalFont による UI フォントの変更に対応
424             var origFont = this.NameLabel.Font;
425             this.NameLabel.Font = new Font(this.Font.Name, origFont.Size, origFont.Style);
426         }
427
428         #region TableLayoutPanel1
429
430         private async void UserPicture_DoubleClick(object sender, EventArgs e)
431         {
432             if (this.CurrentPost == null)
433                 return;
434
435             await this.Owner.OpenUriInBrowserAsync(MyCommon.TwitterUrl + this.CurrentPost.ScreenName);
436         }
437
438         private void UserPicture_MouseEnter(object sender, EventArgs e)
439             => this.UserPicture.Cursor = Cursors.Hand;
440
441         private void UserPicture_MouseLeave(object sender, EventArgs e)
442             => this.UserPicture.Cursor = Cursors.Default;
443
444         private async void PostBrowser_Navigated(object sender, WebBrowserNavigatedEventArgs e)
445         {
446             if (e.Url.AbsoluteUri != "about:blank")
447             {
448                 await this.ShowPostDetails(this.CurrentPost!); // 現在の発言を表示し直す (Navigated の段階ではキャンセルできない)
449                 await this.Owner.OpenUriInBrowserAsync(e.Url.OriginalString);
450             }
451         }
452
453         private async void PostBrowser_Navigating(object sender, WebBrowserNavigatingEventArgs e)
454         {
455             if (e.Url.Scheme == "data")
456             {
457                 this.RaiseStatusChanged(this.PostBrowser.StatusText.Replace("&", "&&"));
458             }
459             else if (e.Url.AbsoluteUri != "about:blank")
460             {
461                 e.Cancel = true;
462                 // Ctrlを押しながらリンクを開いた場合は、設定と逆の動作をするフラグを true としておく
463                 await this.Owner.OpenUriAsync(e.Url, MyCommon.IsKeyDown(Keys.Control));
464             }
465         }
466
467         private async void PostBrowser_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e)
468         {
469             var KeyRes = this.Owner.CommonKeyDown(e.KeyData, FocusedControl.PostBrowser, out var asyncTask);
470             if (KeyRes)
471             {
472                 e.IsInputKey = true;
473             }
474             else
475             {
476                 if (Enum.IsDefined(typeof(Shortcut), (Shortcut)e.KeyData))
477                 {
478                     var shortcut = (Shortcut)e.KeyData;
479                     switch (shortcut)
480                     {
481                         case Shortcut.CtrlA:
482                         case Shortcut.CtrlC:
483                         case Shortcut.CtrlIns:
484                             // 既定の動作を有効にする
485                             break;
486                         default:
487                             // その他のショートカットキーは無効にする
488                             e.IsInputKey = true;
489                             break;
490                     }
491                 }
492             }
493
494             if (asyncTask != null)
495                 await asyncTask;
496         }
497
498         private void PostBrowser_StatusTextChanged(object sender, EventArgs e)
499         {
500             try
501             {
502                 if (PostBrowser.StatusText.StartsWith("http", StringComparison.Ordinal)
503                     || PostBrowser.StatusText.StartsWith("ftp", StringComparison.Ordinal)
504                     || PostBrowser.StatusText.StartsWith("data", StringComparison.Ordinal))
505                 {
506                     this.RaiseStatusChanged(this.PostBrowser.StatusText.Replace("&", "&&"));
507                 }
508                 if (string.IsNullOrEmpty(PostBrowser.StatusText))
509                 {
510                     this.RaiseStatusChanged(statusText: "");
511                 }
512             }
513             catch (Exception)
514             {
515             }
516         }
517
518         private async void SourceLinkLabel_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
519         {
520             var sourceUri = this.CurrentPost?.SourceUri;
521             if (sourceUri != null && e.Button == MouseButtons.Left)
522             {
523                 await this.Owner.OpenUriInBrowserAsync(sourceUri.AbsoluteUri);
524             }
525         }
526
527         private void SourceLinkLabel_MouseEnter(object sender, EventArgs e)
528         {
529             var sourceUri = this.CurrentPost?.SourceUri;
530             if (sourceUri != null)
531             {
532                 this.RaiseStatusChanged(MyCommon.ConvertToReadableUrl(sourceUri.AbsoluteUri));
533             }
534         }
535
536         private void SourceLinkLabel_MouseLeave(object sender, EventArgs e)
537             => this.RaiseStatusChanged(statusText: "");
538
539         #endregion
540
541         #region ContextMenuUserPicture
542
543         private void ContextMenuUserPicture_Opening(object sender, CancelEventArgs e)
544         {
545             //発言詳細のアイコン右クリック時のメニュー制御
546             if (this.CurrentPost != null)
547             {
548                 var name = this.CurrentPost.ImageUrl;
549                 if (!string.IsNullOrEmpty(name))
550                 {
551                     var idx = name.LastIndexOf('/');
552                     if (idx != -1)
553                     {
554                         name = Path.GetFileName(name.Substring(idx));
555                         if (name.Contains("_normal.") || name.EndsWith("_normal", StringComparison.Ordinal))
556                         {
557                             name = name.Replace("_normal", "");
558                             this.IconNameToolStripMenuItem.Text = name;
559                             this.IconNameToolStripMenuItem.Enabled = true;
560                         }
561                         else
562                         {
563                             this.IconNameToolStripMenuItem.Enabled = false;
564                             this.IconNameToolStripMenuItem.Text = Properties.Resources.ContextMenuStrip3_OpeningText1;
565                         }
566                     }
567                     else
568                     {
569                         this.IconNameToolStripMenuItem.Enabled = false;
570                         this.IconNameToolStripMenuItem.Text = Properties.Resources.ContextMenuStrip3_OpeningText1;
571                     }
572
573                     this.ReloadIconToolStripMenuItem.Enabled = true;
574
575                     if (this.IconCache.TryGetFromCache(this.CurrentPost.ImageUrl) != null)
576                     {
577                         this.SaveIconPictureToolStripMenuItem.Enabled = true;
578                     }
579                     else
580                     {
581                         this.SaveIconPictureToolStripMenuItem.Enabled = false;
582                     }
583                 }
584                 else
585                 {
586                     this.IconNameToolStripMenuItem.Enabled = false;
587                     this.ReloadIconToolStripMenuItem.Enabled = false;
588                     this.SaveIconPictureToolStripMenuItem.Enabled = false;
589                     this.IconNameToolStripMenuItem.Text = Properties.Resources.ContextMenuStrip3_OpeningText1;
590                 }
591             }
592             else
593             {
594                 this.IconNameToolStripMenuItem.Enabled = false;
595                 this.ReloadIconToolStripMenuItem.Enabled = false;
596                 this.SaveIconPictureToolStripMenuItem.Enabled = false;
597                 this.IconNameToolStripMenuItem.Text = Properties.Resources.ContextMenuStrip3_OpeningText2;
598             }
599             if (this.CurrentPost != null)
600             {
601                 if (this.CurrentPost.UserId == this.Owner.TwitterInstance.UserId)
602                 {
603                     FollowToolStripMenuItem.Enabled = false;
604                     UnFollowToolStripMenuItem.Enabled = false;
605                     ShowFriendShipToolStripMenuItem.Enabled = false;
606                     ShowUserStatusToolStripMenuItem.Enabled = true;
607                     SearchPostsDetailNameToolStripMenuItem.Enabled = true;
608                     SearchAtPostsDetailNameToolStripMenuItem.Enabled = false;
609                     ListManageUserContextToolStripMenuItem3.Enabled = true;
610                 }
611                 else
612                 {
613                     FollowToolStripMenuItem.Enabled = true;
614                     UnFollowToolStripMenuItem.Enabled = true;
615                     ShowFriendShipToolStripMenuItem.Enabled = true;
616                     ShowUserStatusToolStripMenuItem.Enabled = true;
617                     SearchPostsDetailNameToolStripMenuItem.Enabled = true;
618                     SearchAtPostsDetailNameToolStripMenuItem.Enabled = true;
619                     ListManageUserContextToolStripMenuItem3.Enabled = true;
620                 }
621             }
622             else
623             {
624                 FollowToolStripMenuItem.Enabled = false;
625                 UnFollowToolStripMenuItem.Enabled = false;
626                 ShowFriendShipToolStripMenuItem.Enabled = false;
627                 ShowUserStatusToolStripMenuItem.Enabled = false;
628                 SearchPostsDetailNameToolStripMenuItem.Enabled = false;
629                 SearchAtPostsDetailNameToolStripMenuItem.Enabled = false;
630                 ListManageUserContextToolStripMenuItem3.Enabled = false;
631             }
632         }
633
634         private async void FollowToolStripMenuItem_Click(object sender, EventArgs e)
635         {
636             if (this.CurrentPost == null)
637                 return;
638
639             if (this.CurrentPost.UserId == this.Owner.TwitterInstance.UserId)
640                 return;
641
642             await this.Owner.FollowCommand(this.CurrentPost.ScreenName);
643         }
644
645         private async void UnFollowToolStripMenuItem_Click(object sender, EventArgs e)
646         {
647             if (this.CurrentPost == null)
648                 return;
649
650             if (this.CurrentPost.UserId == this.Owner.TwitterInstance.UserId)
651                 return;
652
653             await this.Owner.RemoveCommand(this.CurrentPost.ScreenName, false);
654         }
655
656         private async void ShowFriendShipToolStripMenuItem_Click(object sender, EventArgs e)
657         {
658             if (this.CurrentPost == null)
659                 return;
660
661             if (this.CurrentPost.UserId == this.Owner.TwitterInstance.UserId)
662                 return;
663
664             await this.Owner.ShowFriendship(this.CurrentPost.ScreenName);
665         }
666
667         // ListManageUserContextToolStripMenuItem3.Click は ListManageUserContextToolStripMenuItem_Click を共用
668
669         private async void ShowUserStatusToolStripMenuItem_Click(object sender, EventArgs e)
670         {
671             if (this.CurrentPost == null)
672                 return;
673
674             await this.Owner.ShowUserStatus(this.CurrentPost.ScreenName, false);
675         }
676
677         private async void SearchPostsDetailNameToolStripMenuItem_Click(object sender, EventArgs e)
678         {
679             if (this.CurrentPost == null)
680                 return;
681
682             await this.Owner.AddNewTabForUserTimeline(this.CurrentPost.ScreenName);
683         }
684
685         private void SearchAtPostsDetailNameToolStripMenuItem_Click(object sender, EventArgs e)
686         {
687             if (this.CurrentPost == null)
688                 return;
689
690             this.Owner.AddNewTabForSearch("@" + this.CurrentPost.ScreenName);
691         }
692
693         private async void IconNameToolStripMenuItem_Click(object sender, EventArgs e)
694         {
695             var imageUrl = this.CurrentPost?.ImageUrl;
696             if (string.IsNullOrEmpty(imageUrl))
697                 return;
698
699             await this.Owner.OpenUriInBrowserAsync(imageUrl.Remove(imageUrl.LastIndexOf("_normal", StringComparison.Ordinal), 7)); // "_normal".Length
700         }
701
702         private async void ReloadIconToolStripMenuItem_Click(object sender, EventArgs e)
703         {
704             var imageUrl = this.CurrentPost?.ImageUrl;
705             if (string.IsNullOrEmpty(imageUrl))
706                 return;
707
708             await this.SetUserPictureAsync(imageUrl, force: true);
709         }
710
711         private void SaveIconPictureToolStripMenuItem_Click(object sender, EventArgs e)
712         {
713             var imageUrl = this.CurrentPost?.ImageUrl;
714             if (string.IsNullOrEmpty(imageUrl))
715                 return;
716
717             var memoryImage = this.IconCache.TryGetFromCache(imageUrl);
718             if (memoryImage == null)
719                 return;
720
721             this.Owner.SaveFileDialog1.FileName = imageUrl.Substring(imageUrl.LastIndexOf('/') + 1);
722
723             if (this.Owner.SaveFileDialog1.ShowDialog() == DialogResult.OK)
724             {
725                 try
726                 {
727                     using var orgBmp = new Bitmap(memoryImage.Image);
728                     using var bmp2 = new Bitmap(orgBmp.Size.Width, orgBmp.Size.Height);
729
730                     using (var g = Graphics.FromImage(bmp2))
731                     {
732                         g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.High;
733                         g.DrawImage(orgBmp, 0, 0, orgBmp.Size.Width, orgBmp.Size.Height);
734                     }
735                     bmp2.Save(this.Owner.SaveFileDialog1.FileName);
736                 }
737                 catch (Exception)
738                 {
739                     //処理中にキャッシュアウトする可能性あり
740                 }
741             }
742         }
743
744         #endregion
745
746         #region ContextMenuPostBrowser
747
748         private void ContextMenuPostBrowser_Opening(object ender, CancelEventArgs e)
749         {
750             // URLコピーの項目の表示/非表示
751             if (PostBrowser.StatusText.StartsWith("http", StringComparison.Ordinal))
752             {
753                 this._postBrowserStatusText = PostBrowser.StatusText;
754                 var name = GetUserId();
755                 UrlCopyContextMenuItem.Enabled = true;
756                 if (name != null)
757                 {
758                     FollowContextMenuItem.Enabled = true;
759                     RemoveContextMenuItem.Enabled = true;
760                     FriendshipContextMenuItem.Enabled = true;
761                     ShowUserStatusContextMenuItem.Enabled = true;
762                     SearchPostsDetailToolStripMenuItem.Enabled = true;
763                     IdFilterAddMenuItem.Enabled = true;
764                     ListManageUserContextToolStripMenuItem.Enabled = true;
765                     SearchAtPostsDetailToolStripMenuItem.Enabled = true;
766                 }
767                 else
768                 {
769                     FollowContextMenuItem.Enabled = false;
770                     RemoveContextMenuItem.Enabled = false;
771                     FriendshipContextMenuItem.Enabled = false;
772                     ShowUserStatusContextMenuItem.Enabled = false;
773                     SearchPostsDetailToolStripMenuItem.Enabled = false;
774                     IdFilterAddMenuItem.Enabled = false;
775                     ListManageUserContextToolStripMenuItem.Enabled = false;
776                     SearchAtPostsDetailToolStripMenuItem.Enabled = false;
777                 }
778
779                 if (Regex.IsMatch(this._postBrowserStatusText, @"^https?://twitter.com/search\?q=%23"))
780                     UseHashtagMenuItem.Enabled = true;
781                 else
782                     UseHashtagMenuItem.Enabled = false;
783             }
784             else
785             {
786                 this._postBrowserStatusText = "";
787                 UrlCopyContextMenuItem.Enabled = false;
788                 FollowContextMenuItem.Enabled = false;
789                 RemoveContextMenuItem.Enabled = false;
790                 FriendshipContextMenuItem.Enabled = false;
791                 ShowUserStatusContextMenuItem.Enabled = false;
792                 SearchPostsDetailToolStripMenuItem.Enabled = false;
793                 SearchAtPostsDetailToolStripMenuItem.Enabled = false;
794                 UseHashtagMenuItem.Enabled = false;
795                 IdFilterAddMenuItem.Enabled = false;
796                 ListManageUserContextToolStripMenuItem.Enabled = false;
797             }
798             // 文字列選択されていないときは選択文字列関係の項目を非表示に
799             var _selText = this.PostBrowser.GetSelectedText();
800             if (_selText == null)
801             {
802                 SelectionSearchContextMenuItem.Enabled = false;
803                 SelectionCopyContextMenuItem.Enabled = false;
804                 SelectionTranslationToolStripMenuItem.Enabled = false;
805             }
806             else
807             {
808                 SelectionSearchContextMenuItem.Enabled = true;
809                 SelectionCopyContextMenuItem.Enabled = true;
810                 SelectionTranslationToolStripMenuItem.Enabled = true;
811             }
812             //発言内に自分以外のユーザーが含まれてればフォロー状態全表示を有効に
813             var ma = Regex.Matches(this.PostBrowser.DocumentText, @"href=""https?://twitter.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)(/status(es)?/[0-9]+)?""");
814             var fAllFlag = false;
815             foreach (Match mu in ma)
816             {
817                 if (!mu.Result("${ScreenName}").Equals(this.Owner.TwitterInstance.Username, StringComparison.InvariantCultureIgnoreCase))
818                 {
819                     fAllFlag = true;
820                     break;
821                 }
822             }
823             this.FriendshipAllMenuItem.Enabled = fAllFlag;
824
825             if (this.CurrentPost == null)
826                 TranslationToolStripMenuItem.Enabled = false;
827             else
828                 TranslationToolStripMenuItem.Enabled = true;
829
830             e.Cancel = false;
831         }
832
833         private async void SearchGoogleContextMenuItem_Click(object sender, EventArgs e)
834             => await this.DoSearchToolStrip(Properties.Resources.SearchItem2Url);
835
836         private async void SearchWikipediaContextMenuItem_Click(object sender, EventArgs e)
837             => await this.DoSearchToolStrip(Properties.Resources.SearchItem1Url);
838
839         private async void SearchPublicSearchContextMenuItem_Click(object sender, EventArgs e)
840             => await this.DoSearchToolStrip(Properties.Resources.SearchItem4Url);
841
842         private void CurrentTabToolStripMenuItem_Click(object sender, EventArgs e)
843         {
844             //発言詳細の選択文字列で現在のタブを検索
845             var _selText = this.PostBrowser.GetSelectedText();
846
847             if (_selText != null)
848             {
849                 var searchOptions = new SearchWordDialog.SearchOptions(
850                     SearchWordDialog.SearchType.Timeline,
851                     _selText,
852                     newTab: false,
853                     caseSensitive: false,
854                     useRegex: false);
855
856                 this.Owner.SearchDialog.ResultOptions = searchOptions;
857
858                 this.Owner.DoTabSearch(
859                     searchOptions.Query,
860                     searchOptions.CaseSensitive,
861                     searchOptions.UseRegex,
862                     TweenMain.SEARCHTYPE.NextSearch);
863             }
864         }
865
866         private void SelectionCopyContextMenuItem_Click(object sender, EventArgs e)
867         {
868             //発言詳細で「選択文字列をコピー」
869             var _selText = this.PostBrowser.GetSelectedText();
870             try
871             {
872                 Clipboard.SetDataObject(_selText, false, 5, 100);
873             }
874             catch (Exception ex)
875             {
876                 MessageBox.Show(ex.Message);
877             }
878         }
879
880         private void UrlCopyContextMenuItem_Click(object sender, EventArgs e)
881         {
882             try
883             {
884                 foreach (var link in this.PostBrowser.Document.Links.Cast<HtmlElement>())
885                 {
886                     if (link.GetAttribute("href") == this._postBrowserStatusText)
887                     {
888                         var linkStr = link.GetAttribute("title");
889                         if (string.IsNullOrEmpty(linkStr))
890                             linkStr = link.GetAttribute("href");
891
892                         Clipboard.SetDataObject(linkStr, false, 5, 100);
893                         return;
894                     }
895                 }
896
897                 Clipboard.SetDataObject(this._postBrowserStatusText, false, 5, 100);
898             }
899             catch (Exception ex)
900             {
901                 MessageBox.Show(ex.Message);
902             }
903         }
904
905         private void SelectionAllContextMenuItem_Click(object sender, EventArgs e)
906             => this.PostBrowser.Document.ExecCommand("SelectAll", false, null); // 発言詳細ですべて選択
907
908         private async void FollowContextMenuItem_Click(object sender, EventArgs e)
909         {
910             var name = GetUserId();
911             if (name != null)
912                 await this.Owner.FollowCommand(name);
913         }
914
915         private async void RemoveContextMenuItem_Click(object sender, EventArgs e)
916         {
917             var name = GetUserId();
918             if (name != null)
919                 await this.Owner.RemoveCommand(name, false);
920         }
921
922         private async void FriendshipContextMenuItem_Click(object sender, EventArgs e)
923         {
924             var name = GetUserId();
925             if (name != null)
926                 await this.Owner.ShowFriendship(name);
927         }
928
929         private async void FriendshipAllMenuItem_Click(object sender, EventArgs e)
930         {
931             var ma = Regex.Matches(this.PostBrowser.DocumentText, @"href=""https?://twitter.com/(#!/)?(?<ScreenName>[a-zA-Z0-9_]+)(/status(es)?/[0-9]+)?""");
932             var ids = new List<string>();
933             foreach (Match mu in ma)
934             {
935                 if (!mu.Result("${ScreenName}").Equals(this.Owner.TwitterInstance.Username, StringComparison.InvariantCultureIgnoreCase))
936                 {
937                     ids.Add(mu.Result("${ScreenName}"));
938                 }
939             }
940
941             await this.Owner.ShowFriendship(ids.ToArray());
942         }
943
944         private async void ShowUserStatusContextMenuItem_Click(object sender, EventArgs e)
945         {
946             var name = GetUserId();
947             if (name != null)
948                 await this.Owner.ShowUserStatus(name);
949         }
950
951         private async void SearchPostsDetailToolStripMenuItem_Click(object sender, EventArgs e)
952         {
953             var name = GetUserId();
954             if (name != null)
955                 await this.Owner.AddNewTabForUserTimeline(name);
956         }
957
958         private void SearchAtPostsDetailToolStripMenuItem_Click(object sender, EventArgs e)
959         {
960             var name = GetUserId();
961             if (name != null) this.Owner.AddNewTabForSearch("@" + name);
962         }
963
964         private void IdFilterAddMenuItem_Click(object sender, EventArgs e)
965         {
966             var name = GetUserId();
967             if (name != null)
968                 this.Owner.AddFilterRuleByScreenName(name);
969         }
970
971         private void ListManageUserContextToolStripMenuItem_Click(object sender, EventArgs e)
972         {
973             var menuItem = (ToolStripMenuItem)sender;
974
975             string? user;
976             if (menuItem.Owner == this.ContextMenuPostBrowser)
977             {
978                 user = GetUserId();
979                 if (user == null) return;
980             }
981             else if (this.CurrentPost != null)
982             {
983                 user = this.CurrentPost.ScreenName;
984             }
985             else
986             {
987                 return;
988             }
989
990             this.Owner.ListManageUserContext(user);
991         }
992
993         private void UseHashtagMenuItem_Click(object sender, EventArgs e)
994         {
995             var m = Regex.Match(this._postBrowserStatusText, @"^https?://twitter.com/search\?q=%23(?<hash>.+)$");
996             if (m.Success)
997                 this.Owner.SetPermanentHashtag(Uri.UnescapeDataString(m.Groups["hash"].Value));
998         }
999
1000         private async void SelectionTranslationToolStripMenuItem_Click(object sender, EventArgs e)
1001         {
1002             var text = this.PostBrowser.GetSelectedText();
1003             await this.DoTranslation(text);
1004         }
1005
1006         private async void TranslationToolStripMenuItem_Click(object sender, EventArgs e)
1007             => await this.DoTranslation();
1008
1009         #endregion
1010
1011         #region ContextMenuSource
1012
1013         private void ContextMenuSource_Opening(object sender, CancelEventArgs e)
1014         {
1015             if (this.CurrentPost == null || this.CurrentPost.IsDeleted || this.CurrentPost.IsDm)
1016             {
1017                 SourceCopyMenuItem.Enabled = false;
1018                 SourceUrlCopyMenuItem.Enabled = false;
1019             }
1020             else
1021             {
1022                 SourceCopyMenuItem.Enabled = true;
1023                 SourceUrlCopyMenuItem.Enabled = true;
1024             }
1025         }
1026
1027         private void SourceCopyMenuItem_Click(object sender, EventArgs e)
1028         {
1029             if (this.CurrentPost == null)
1030                 return;
1031
1032             try
1033             {
1034                 Clipboard.SetDataObject(this.CurrentPost.Source, false, 5, 100);
1035             }
1036             catch (Exception ex)
1037             {
1038                 MessageBox.Show(ex.Message);
1039             }
1040         }
1041
1042         private void SourceUrlCopyMenuItem_Click(object sender, EventArgs e)
1043         {
1044             var sourceUri = this.CurrentPost?.SourceUri;
1045             if (sourceUri == null)
1046                 return;
1047
1048             try
1049             {
1050                 Clipboard.SetDataObject(sourceUri.AbsoluteUri, false, 5, 100);
1051             }
1052             catch (Exception ex)
1053             {
1054                 MessageBox.Show(ex.Message);
1055             }
1056         }
1057
1058         #endregion
1059     }
1060
1061     public class TweetDetailsViewStatusChengedEventArgs : EventArgs
1062     {
1063         /// <summary>ステータスバーに表示するテキスト</summary>
1064         /// <remarks>
1065         /// 空文字列の場合は <see cref="TweenMain"/> の既定のテキストを表示する
1066         /// </remarks>
1067         public string StatusText { get; }
1068
1069         public TweetDetailsViewStatusChengedEventArgs(string statusText)
1070             => this.StatusText = statusText;
1071     }
1072 }