OSDN Git Service

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