OSDN Git Service

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