OSDN Git Service

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