OSDN Git Service

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