OSDN Git Service

ISocialAccountインタフェースを追加
[opentween/open-tween.git] / OpenTween / Tween.cs
index 277e1fe..93c423c 100644 (file)
@@ -52,11 +52,15 @@ using System.Threading.Tasks;
 using System.Windows.Forms;
 using OpenTween.Api;
 using OpenTween.Api.DataModel;
+using OpenTween.Api.GraphQL;
+using OpenTween.Api.TwitterV2;
 using OpenTween.Connection;
 using OpenTween.MediaUploadServices;
 using OpenTween.Models;
 using OpenTween.OpenTweenCustomControl;
 using OpenTween.Setting;
+using OpenTween.SocialProtocol;
+using OpenTween.SocialProtocol.Twitter;
 using OpenTween.Thumbnail;
 
 namespace OpenTween
@@ -96,27 +100,7 @@ namespace OpenTween
 
         private readonly object syncObject = new(); // ロック用
 
-        private const string DetailHtmlFormatHead =
-            "<head><meta http-equiv=\"X-UA-Compatible\" content=\"IE=8\">"
-            + "<style type=\"text/css\"><!-- "
-            + "body, p, pre {margin: 0;} "
-            + "body {font-family: \"%FONT_FAMILY%\", sans-serif; font-size: %FONT_SIZE%pt; background-color:rgb(%BG_COLOR%); word-wrap: break-word; color:rgb(%FONT_COLOR%);} "
-            + "pre {font-family: inherit;} "
-            + "a:link, a:visited, a:active, a:hover {color:rgb(%LINK_COLOR%); } "
-            + "img.emoji {width: 1em; height: 1em; margin: 0 .05em 0 .1em; vertical-align: -0.1em; border: none;} "
-            + ".quote-tweet {border: 1px solid #ccc; margin: 1em; padding: 0.5em;} "
-            + ".quote-tweet.reply {border-color: rgb(%BG_REPLY_COLOR%);} "
-            + ".quote-tweet-link {color: inherit !important; text-decoration: none;}"
-            + "--></style>"
-            + "</head>";
-
-        private const string DetailHtmlFormatTemplateMono =
-            $"<html>{DetailHtmlFormatHead}<body><pre>%CONTENT_HTML%</pre></body></html>";
-
-        private const string DetailHtmlFormatTemplateNormal =
-            $"<html>{DetailHtmlFormatHead}<body><p>%CONTENT_HTML%</p></body></html>";
-
-        private string detailHtmlFormatPreparedTemplate = null!;
+        private readonly DetailsHtmlBuilder detailsHtmlBuilder = new();
 
         private bool myStatusError = false;
         private bool myStatusOnline = false;
@@ -126,8 +110,12 @@ namespace OpenTween
         // 設定ファイル
         private readonly SettingManager settings;
 
-        // twitter解析部
-        private readonly Twitter tw;
+        // ユーザーアカウント
+        private readonly AccountCollection accounts;
+
+#pragma warning disable SA1300
+        private Twitter tw => ((TwitterAccount)this.accounts.Primary).Legacy; // AccountCollection への移行用
+#pragma warning restore SA1300
 
         // Growl呼び出し部
         private readonly GrowlHelper gh = new(ApplicationSettings.ApplicationName);
@@ -158,15 +146,12 @@ namespace OpenTween
         private readonly ThumbnailGenerator thumbGenerator;
 
         /// <summary>発言履歴</summary>
-        private readonly List<StatusTextHistory> history = new();
-
-        /// <summary>発言履歴カレントインデックス</summary>
-        private int hisIdx;
+        private readonly StatusTextHistory history = new();
 
         // 発言投稿時のAPI引数(発言編集時に設定。手書きreplyでは設定されない)
 
         /// <summary>リプライ先のステータスID・スクリーン名</summary>
-        private (long StatusId, string ScreenName)? inReplyTo = null;
+        private (PostId StatusId, string ScreenName)? inReplyTo = null;
 
         // 時速表示用
         private readonly List<DateTimeUtc> postTimestamps = new();
@@ -180,60 +165,9 @@ namespace OpenTween
         /// <summary>発言保持クラス</summary>
         private readonly TabInformations statuses;
 
+        private TimelineListViewCache? listCache;
         private TimelineListViewDrawer? listDrawer;
-
-        /// <summary>
-        /// 現在表示している発言一覧の <see cref="ListView"/> に対するキャッシュ
-        /// </summary>
-        /// <remarks>
-        /// キャッシュクリアのために null が代入されることがあるため、
-        /// 使用する場合には <see cref="listItemCache"/> に対して直接メソッド等を呼び出さずに
-        /// 一旦ローカル変数に代入してから参照すること。
-        /// </remarks>
-        private ListViewItemCache? listItemCache = null;
-
-        /// <param name="TargetList">アイテムをキャッシュする対象の <see cref="ListView"/></param>
-        /// <param name="StartIndex">キャッシュする範囲の開始インデックス</param>
-        /// <param name="EndIndex">キャッシュする範囲の終了インデックス</param>
-        /// <param name="Cache">ャッシュされた範囲に対応する <see cref="ListViewItem"/> と <see cref="PostClass"/> の組</param>
-        internal record class ListViewItemCache(
-            ListView TargetList,
-            int StartIndex,
-            int EndIndex,
-            (ListViewItem, PostClass)[] Cache
-        )
-        {
-            /// <summary>キャッシュされたアイテムの件数</summary>
-            public int Count
-                => this.EndIndex - this.StartIndex + 1;
-
-            /// <summary>指定されたインデックスがキャッシュの範囲内であるか判定します</summary>
-            /// <returns><paramref name="index"/> がキャッシュの範囲内であれば true、それ以外は false</returns>
-            public bool Contains(int index)
-                => index >= this.StartIndex && index <= this.EndIndex;
-
-            /// <summary>指定されたインデックスの範囲が全てキャッシュの範囲内であるか判定します</summary>
-            /// <returns><paramref name="rangeStart"/> から <paramref name="rangeEnd"/> の範囲が全てキャッシュの範囲内であれば true、それ以外は false</returns>
-            public bool IsSupersetOf(int rangeStart, int rangeEnd)
-                => rangeStart >= this.StartIndex && rangeEnd <= this.EndIndex;
-
-            /// <summary>指定されたインデックスの <see cref="ListViewItem"/> と <see cref="PostClass"/> をキャッシュから取得することを試みます</summary>
-            /// <returns>取得に成功すれば true、それ以外は false</returns>
-            public bool TryGetValue(int index, [NotNullWhen(true)] out ListViewItem? item, [NotNullWhen(true)] out PostClass? post)
-            {
-                if (this.Contains(index))
-                {
-                    (item, post) = this.Cache[index - this.StartIndex];
-                    return true;
-                }
-                else
-                {
-                    item = null;
-                    post = null;
-                    return false;
-                }
-            }
-        }
+        private readonly Dictionary<string, TimelineListViewState> listViewState = new();
 
         private bool isColumnChanged = false;
 
@@ -257,7 +191,9 @@ namespace OpenTween
         private readonly DebounceTimer saveConfigDebouncer;
 
         private readonly string recommendedStatusFooter;
-        private bool urlMultibyteSplit = false;
+
+        internal bool SeparateUrlAndFullwidthCharacter { get; set; } = false;
+
         private bool preventSmsCommand = true;
 
         // URL短縮のUndo用
@@ -269,8 +205,8 @@ namespace OpenTween
         private List<UrlUndo>? urlUndoBuffer = null;
 
         private readonly record struct ReplyChain(
-            long OriginalId,
-            long InReplyToId,
+            PostId OriginalId,
+            PostId InReplyToId,
             TabModel OriginalTab
         );
 
@@ -306,213 +242,12 @@ namespace OpenTween
             PrevSearch,
         }
 
-        private readonly record struct StatusTextHistory(
-            string Status,
-            (long StatusId, string ScreenName)? InReplyTo = null
-        );
-
         private readonly HookGlobalHotkey hookGlobalHotkey;
 
-        private void TweenMain_Activated(object sender, EventArgs e)
-        {
-            // 画面がアクティブになったら、発言欄の背景色戻す
-            if (this.StatusText.Focused)
-            {
-                this.StatusText_Enter(this.StatusText, System.EventArgs.Empty);
-            }
-        }
-
-        private bool disposed = false;
-
-        /// <summary>
-        /// 使用中のリソースをすべてクリーンアップします。
-        /// </summary>
-        /// <param name="disposing">マネージ リソースが破棄される場合 true、破棄されない場合は false です。</param>
-        protected override void Dispose(bool disposing)
-        {
-            base.Dispose(disposing);
-
-            if (this.disposed)
-                return;
-
-            if (disposing)
-            {
-                this.components?.Dispose();
-
-                // 後始末
-                this.SearchDialog.Dispose();
-                this.urlDialog.Dispose();
-                this.themeManager.Dispose();
-                this.sfTab.Dispose();
-
-                this.timelineScheduler.Dispose();
-                this.workerCts.Cancel();
-                this.thumbnailTokenSource?.Dispose();
-
-                this.hookGlobalHotkey.Dispose();
-            }
-
-            // 終了時にRemoveHandlerしておかないとメモリリークする
-            // http://msdn.microsoft.com/ja-jp/library/microsoft.win32.systemevents.powermodechanged.aspx
-            Microsoft.Win32.SystemEvents.PowerModeChanged -= this.SystemEvents_PowerModeChanged;
-            Microsoft.Win32.SystemEvents.TimeChanged -= this.SystemEvents_TimeChanged;
-
-            this.disposed = true;
-        }
-
-        private void InitColumns(ListView list, bool startup)
-        {
-            this.InitColumnText();
-
-            ColumnHeader[]? columns = null;
-            try
-            {
-                if (this.Use2ColumnsMode)
-                {
-                    columns = new[]
-                    {
-                        new ColumnHeader(), // アイコン
-                        new ColumnHeader(), // 本文
-                    };
-
-                    columns[0].Text = this.columnText[0];
-                    columns[1].Text = this.columnText[2];
-
-                    if (startup)
-                    {
-                        var widthScaleFactor = this.CurrentAutoScaleDimensions.Width / this.settings.Local.ScaleDimension.Width;
-
-                        columns[0].Width = ScaleBy(widthScaleFactor, this.settings.Local.ColumnsWidth[0]);
-                        columns[1].Width = ScaleBy(widthScaleFactor, this.settings.Local.ColumnsWidth[2]);
-                        columns[0].DisplayIndex = 0;
-                        columns[1].DisplayIndex = 1;
-                    }
-                    else
-                    {
-                        var idx = 0;
-                        foreach (var curListColumn in this.CurrentListView.Columns.Cast<ColumnHeader>())
-                        {
-                            columns[idx].Width = curListColumn.Width;
-                            columns[idx].DisplayIndex = curListColumn.DisplayIndex;
-                            idx++;
-                        }
-                    }
-                }
-                else
-                {
-                    columns = new[]
-                    {
-                        new ColumnHeader(), // アイコン
-                        new ColumnHeader(), // ニックネーム
-                        new ColumnHeader(), // 本文
-                        new ColumnHeader(), // 日付
-                        new ColumnHeader(), // ユーザID
-                        new ColumnHeader(), // 未読
-                        new ColumnHeader(), // マーク&プロテクト
-                        new ColumnHeader(), // ソース
-                    };
-
-                    foreach (var i in Enumerable.Range(0, columns.Length))
-                        columns[i].Text = this.columnText[i];
-
-                    if (startup)
-                    {
-                        var widthScaleFactor = this.CurrentAutoScaleDimensions.Width / this.settings.Local.ScaleDimension.Width;
-
-                        foreach (var (column, index) in columns.WithIndex())
-                        {
-                            column.Width = ScaleBy(widthScaleFactor, this.settings.Local.ColumnsWidth[index]);
-                            column.DisplayIndex = this.settings.Local.ColumnsOrder[index];
-                        }
-                    }
-                    else
-                    {
-                        var idx = 0;
-                        foreach (var curListColumn in this.CurrentListView.Columns.Cast<ColumnHeader>())
-                        {
-                            columns[idx].Width = curListColumn.Width;
-                            columns[idx].DisplayIndex = curListColumn.DisplayIndex;
-                            idx++;
-                        }
-                    }
-                }
-
-                list.Columns.AddRange(columns);
-
-                columns = null;
-            }
-            finally
-            {
-                if (columns != null)
-                {
-                    foreach (var column in columns)
-                        column?.Dispose();
-                }
-            }
-        }
-
-        private void InitColumnText()
-        {
-            this.columnText[0] = "";
-            this.columnText[1] = Properties.Resources.AddNewTabText2;
-            this.columnText[2] = Properties.Resources.AddNewTabText3;
-            this.columnText[3] = Properties.Resources.AddNewTabText4_2;
-            this.columnText[4] = Properties.Resources.AddNewTabText5;
-            this.columnText[5] = "";
-            this.columnText[6] = "";
-            this.columnText[7] = "Source";
-
-            this.columnOrgText[0] = "";
-            this.columnOrgText[1] = Properties.Resources.AddNewTabText2;
-            this.columnOrgText[2] = Properties.Resources.AddNewTabText3;
-            this.columnOrgText[3] = Properties.Resources.AddNewTabText4_2;
-            this.columnOrgText[4] = Properties.Resources.AddNewTabText5;
-            this.columnOrgText[5] = "";
-            this.columnOrgText[6] = "";
-            this.columnOrgText[7] = "Source";
-
-            var c = this.statuses.SortMode switch
-            {
-                ComparerMode.Nickname => 1, // ニックネーム
-                ComparerMode.Data => 2, // 本文
-                ComparerMode.Id => 3, // 時刻=発言Id
-                ComparerMode.Name => 4, // 名前
-                ComparerMode.Source => 7, // Source
-                _ => 0,
-            };
-
-            if (this.Use2ColumnsMode)
-            {
-                if (this.statuses.SortOrder == SortOrder.Descending)
-                {
-                    // U+25BE BLACK DOWN-POINTING SMALL TRIANGLE
-                    this.columnText[2] = this.columnOrgText[2] + "▾";
-                }
-                else
-                {
-                    // U+25B4 BLACK UP-POINTING SMALL TRIANGLE
-                    this.columnText[2] = this.columnOrgText[2] + "▴";
-                }
-            }
-            else
-            {
-                if (this.statuses.SortOrder == SortOrder.Descending)
-                {
-                    // U+25BE BLACK DOWN-POINTING SMALL TRIANGLE
-                    this.columnText[c] = this.columnOrgText[c] + "▾";
-                }
-                else
-                {
-                    // U+25B4 BLACK UP-POINTING SMALL TRIANGLE
-                    this.columnText[c] = this.columnOrgText[c] + "▴";
-                }
-            }
-        }
-
         public TweenMain(
             SettingManager settingManager,
             TabInformations tabInfo,
-            Twitter twitter,
+            AccountCollection accounts,
             ImageCache imageCache,
             IconAssetsManager iconAssets,
             ThumbnailGenerator thumbGenerator
@@ -520,7 +255,7 @@ namespace OpenTween
         {
             this.settings = settingManager;
             this.statuses = tabInfo;
-            this.tw = twitter;
+            this.accounts = accounts;
             this.iconCache = imageCache;
             this.iconAssets = iconAssets;
             this.thumbGenerator = thumbGenerator;
@@ -551,7 +286,6 @@ namespace OpenTween
             this.InitializeShortcuts();
 
             this.ignoreConfigSave = true;
-            this.Visible = false;
 
             this.TraceOutToolStripMenuItem.Checked = MyCommon.TraceFlag;
 
@@ -571,43 +305,21 @@ namespace OpenTween
             // 現在の DPI と設定保存時の DPI との比を取得する
             var configScaleFactor = this.settings.Local.GetConfigScaleFactor(this.CurrentAutoScaleDimensions);
 
-            // 認証関連
-            this.tw.Initialize(this.settings.Common.Token, this.settings.Common.TokenSecret, this.settings.Common.UserName, this.settings.Common.UserId);
-
             this.initial = true;
 
-            this.tw.RestrictFavCheck = this.settings.Common.RestrictFavCheck;
-            this.tw.ReadOwnPost = this.settings.Common.ReadOwnPost;
-
-            // アクセストークンが有効であるか確認する
-            // ここが Twitter API への最初のアクセスになるようにすること
-            try
-            {
-                this.tw.VerifyCredentials();
-            }
-            catch (WebApiException ex)
-            {
-                MessageBox.Show(
-                    this,
-                    string.Format(Properties.Resources.StartupAuthError_Text, ex.Message),
-                    ApplicationSettings.ApplicationName,
-                    MessageBoxButtons.OK,
-                    MessageBoxIcon.Warning);
-            }
-
             // サムネイル関連の初期化
             // プロキシ設定等の通信まわりの初期化が済んでから処理する
             var imgazyobizinet = this.thumbGenerator.ImgAzyobuziNet;
             imgazyobizinet.Enabled = this.settings.Common.EnableImgAzyobuziNet;
             imgazyobizinet.DisabledInDM = this.settings.Common.ImgAzyobuziNetDisabledInDM;
-            imgazyobizinet.AutoUpdate = true;
 
             Thumbnail.Services.TonTwitterCom.GetApiConnection = () => this.tw.Api.Connection;
 
             // 画像投稿サービス
-            this.ImageSelector.Initialize(this.tw, this.tw.Configuration, this.settings.Common.UseImageServiceName, this.settings.Common.UseImageService);
+            this.ImageSelector.Model.InitializeServices(this.tw, this.tw.Configuration);
+            this.ImageSelector.Model.SelectMediaService(this.settings.Common.UseImageServiceName, this.settings.Common.UseImageService);
 
-            this.tweetThumbnail1.Initialize(this.thumbGenerator);
+            this.tweetThumbnail1.Model.Initialize(this.thumbGenerator);
 
             // ハッシュタグ/@id関連
             this.AtIdSupl = new AtIdSupplement(this.settings.AtIdList.AtIdList, "@");
@@ -622,19 +334,17 @@ namespace OpenTween
 
             // フォント&文字色&背景色保持
             this.themeManager = new(this.settings.Local);
-            this.tweetDetailsView.Initialize(this, this.iconCache, this.themeManager);
+            this.tweetDetailsView.Initialize(this, this.iconCache, this.themeManager, this.detailsHtmlBuilder);
 
             // StringFormatオブジェクトへの事前設定
             this.sfTab.Alignment = StringAlignment.Center;
             this.sfTab.LineAlignment = StringAlignment.Center;
 
-            this.InitDetailHtmlFormat();
+            this.detailsHtmlBuilder.Prepare(this.settings.Common, this.themeManager);
             this.tweetDetailsView.ClearPostBrowser();
 
             this.recommendedStatusFooter = " [TWNv" + Regex.Replace(MyCommon.FileVersion.Replace(".", ""), "^0*", "") + "]";
 
-            this.history.Add(new StatusTextHistory(""));
-            this.hisIdx = 0;
             this.inReplyTo = null;
 
             // 各種ダイアログ設定
@@ -789,9 +499,9 @@ namespace OpenTween
             this.SetMainWindowTitle();
             this.SetNotifyIconText();
 
-            if (!this.settings.Common.MinimizeToTray || this.WindowState != FormWindowState.Minimized)
+            if (this.settings.Common.MinimizeToTray && this.WindowState == FormWindowState.Minimized)
             {
-                this.Visible = true;
+                this.Visible = false;
             }
 
             // タイマー設定
@@ -820,29 +530,204 @@ namespace OpenTween
             this.TimerRefreshIcon.Enabled = false;
 
             this.ignoreConfigSave = false;
-            this.TweenMain_Resize(this, EventArgs.Empty);
+            this.ApplyLayoutFromSettings();
+        }
+
+        private void TweenMain_Activated(object sender, EventArgs e)
+        {
+            // 画面がアクティブになったら、発言欄の背景色戻す
+            if (this.StatusText.Focused)
+            {
+                this.StatusText_Enter(this.StatusText, System.EventArgs.Empty);
+            }
+        }
+
+        private bool disposed = false;
+
+        /// <summary>
+        /// 使用中のリソースをすべてクリーンアップします。
+        /// </summary>
+        /// <param name="disposing">マネージ リソースが破棄される場合 true、破棄されない場合は false です。</param>
+        protected override void Dispose(bool disposing)
+        {
+            base.Dispose(disposing);
+
+            if (this.disposed)
+                return;
+
+            if (disposing)
+            {
+                this.components?.Dispose();
+
+                // 後始末
+                this.SearchDialog.Dispose();
+                this.urlDialog.Dispose();
+                this.themeManager.Dispose();
+                this.sfTab.Dispose();
+
+                this.timelineScheduler.Dispose();
+                this.workerCts.Cancel();
+                this.thumbnailTokenSource?.Dispose();
+
+                this.hookGlobalHotkey.Dispose();
+            }
+
+            // 終了時にRemoveHandlerしておかないとメモリリークする
+            // http://msdn.microsoft.com/ja-jp/library/microsoft.win32.systemevents.powermodechanged.aspx
+            Microsoft.Win32.SystemEvents.PowerModeChanged -= this.SystemEvents_PowerModeChanged;
+            Microsoft.Win32.SystemEvents.TimeChanged -= this.SystemEvents_TimeChanged;
+            MyCommon.TwitterApiInfo.AccessLimitUpdated -= this.TwitterApiStatus_AccessLimitUpdated;
+
+            this.disposed = true;
+        }
+
+        private void InitColumns(ListView list, bool startup)
+        {
+            this.InitColumnText();
+
+            ColumnHeader[]? columns = null;
+            try
+            {
+                if (this.Use2ColumnsMode)
+                {
+                    columns = new[]
+                    {
+                        new ColumnHeader(), // アイコン
+                        new ColumnHeader(), // 本文
+                    };
+
+                    columns[0].Text = this.columnText[0];
+                    columns[1].Text = this.columnText[2];
+
+                    if (startup)
+                    {
+                        var widthScaleFactor = this.CurrentAutoScaleDimensions.Width / this.settings.Local.ScaleDimension.Width;
+
+                        columns[0].Width = ScaleBy(widthScaleFactor, this.settings.Local.ColumnsWidth[0]);
+                        columns[1].Width = ScaleBy(widthScaleFactor, this.settings.Local.ColumnsWidth[2]);
+                        columns[0].DisplayIndex = 0;
+                        columns[1].DisplayIndex = 1;
+                    }
+                    else
+                    {
+                        var idx = 0;
+                        foreach (var curListColumn in this.CurrentListView.Columns.Cast<ColumnHeader>())
+                        {
+                            columns[idx].Width = curListColumn.Width;
+                            columns[idx].DisplayIndex = curListColumn.DisplayIndex;
+                            idx++;
+                        }
+                    }
+                }
+                else
+                {
+                    columns = new[]
+                    {
+                        new ColumnHeader(), // アイコン
+                        new ColumnHeader(), // ニックネーム
+                        new ColumnHeader(), // 本文
+                        new ColumnHeader(), // 日付
+                        new ColumnHeader(), // ユーザID
+                        new ColumnHeader(), // 未読
+                        new ColumnHeader(), // マーク&プロテクト
+                        new ColumnHeader(), // ソース
+                    };
+
+                    foreach (var i in Enumerable.Range(0, columns.Length))
+                        columns[i].Text = this.columnText[i];
+
+                    if (startup)
+                    {
+                        var widthScaleFactor = this.CurrentAutoScaleDimensions.Width / this.settings.Local.ScaleDimension.Width;
+
+                        foreach (var (column, index) in columns.WithIndex())
+                        {
+                            column.Width = ScaleBy(widthScaleFactor, this.settings.Local.ColumnsWidth[index]);
+                            column.DisplayIndex = this.settings.Local.ColumnsOrder[index];
+                        }
+                    }
+                    else
+                    {
+                        var idx = 0;
+                        foreach (var curListColumn in this.CurrentListView.Columns.Cast<ColumnHeader>())
+                        {
+                            columns[idx].Width = curListColumn.Width;
+                            columns[idx].DisplayIndex = curListColumn.DisplayIndex;
+                            idx++;
+                        }
+                    }
+                }
+
+                list.Columns.AddRange(columns);
+
+                columns = null;
+            }
+            finally
+            {
+                if (columns != null)
+                {
+                    foreach (var column in columns)
+                        column?.Dispose();
+                }
+            }
+        }
+
+        private void InitColumnText()
+        {
+            this.columnText[0] = "";
+            this.columnText[1] = Properties.Resources.AddNewTabText2;
+            this.columnText[2] = Properties.Resources.AddNewTabText3;
+            this.columnText[3] = Properties.Resources.AddNewTabText4_2;
+            this.columnText[4] = Properties.Resources.AddNewTabText5;
+            this.columnText[5] = "";
+            this.columnText[6] = "";
+            this.columnText[7] = "Source";
+
+            this.columnOrgText[0] = "";
+            this.columnOrgText[1] = Properties.Resources.AddNewTabText2;
+            this.columnOrgText[2] = Properties.Resources.AddNewTabText3;
+            this.columnOrgText[3] = Properties.Resources.AddNewTabText4_2;
+            this.columnOrgText[4] = Properties.Resources.AddNewTabText5;
+            this.columnOrgText[5] = "";
+            this.columnOrgText[6] = "";
+            this.columnOrgText[7] = "Source";
+
+            var c = this.statuses.SortMode switch
+            {
+                ComparerMode.Nickname => 1, // ニックネーム
+                ComparerMode.Data => 2, // 本文
+                ComparerMode.Id => 3, // 時刻=発言Id
+                ComparerMode.Name => 4, // 名前
+                ComparerMode.Source => 7, // Source
+                _ => 0,
+            };
 
-            if (this.settings.IsFirstRun)
+            if (this.Use2ColumnsMode)
             {
-                // 初回起動時だけ右下のメニューを目立たせる
-                this.HashStripSplitButton.ShowDropDown();
+                if (this.statuses.SortOrder == SortOrder.Descending)
+                {
+                    // U+25BE BLACK DOWN-POINTING SMALL TRIANGLE
+                    this.columnText[2] = this.columnOrgText[2] + "▾";
+                }
+                else
+                {
+                    // U+25B4 BLACK UP-POINTING SMALL TRIANGLE
+                    this.columnText[2] = this.columnOrgText[2] + "▴";
+                }
+            }
+            else
+            {
+                if (this.statuses.SortOrder == SortOrder.Descending)
+                {
+                    // U+25BE BLACK DOWN-POINTING SMALL TRIANGLE
+                    this.columnText[c] = this.columnOrgText[c] + "▾";
+                }
+                else
+                {
+                    // U+25B4 BLACK UP-POINTING SMALL TRIANGLE
+                    this.columnText[c] = this.columnOrgText[c] + "▴";
+                }
             }
-        }
-
-        private void InitDetailHtmlFormat()
-        {
-            var htmlTemplate = this.settings.Common.IsMonospace ? DetailHtmlFormatTemplateMono : DetailHtmlFormatTemplateNormal;
-
-            static string ColorToRGBString(Color color)
-                => $"{color.R},{color.G},{color.B}";
-
-            this.detailHtmlFormatPreparedTemplate = htmlTemplate
-                .Replace("%FONT_FAMILY%", this.themeManager.FontDetail.Name)
-                .Replace("%FONT_SIZE%", this.themeManager.FontDetail.Size.ToString())
-                .Replace("%FONT_COLOR%", ColorToRGBString(this.themeManager.ColorDetail))
-                .Replace("%LINK_COLOR%", ColorToRGBString(this.themeManager.ColorDetailLink))
-                .Replace("%BG_COLOR%", ColorToRGBString(this.themeManager.ColorDetailBackcolor))
-                .Replace("%BG_REPLY_COLOR%", ColorToRGBString(this.themeManager.ColorAtTo));
         }
 
         private void ListTab_DrawItem(object sender, DrawItemEventArgs e)
@@ -932,16 +817,13 @@ namespace OpenTween
             _ = this.saveConfigDebouncer.Call();
         }
 
-        private void RefreshTimeline()
+        internal void RefreshTimeline()
         {
-            var curTabModel = this.CurrentTab;
             var curListView = this.CurrentListView;
 
             // 現在表示中のタブのスクロール位置を退避
-            var curListScroll = this.SaveListViewScroll(curListView, curTabModel);
-
-            // 各タブのリスト上の選択位置などを退避
-            var listSelections = this.SaveListViewSelection();
+            var currentListViewState = this.listViewState[this.CurrentTabName];
+            currentListViewState.Save(this.ListLockMenuItem.Checked);
 
             // 更新確定
             int addCount;
@@ -954,33 +836,15 @@ namespace OpenTween
             if (MyCommon.EndingFlag) return;
 
             // リストに反映&選択状態復元
-            foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
+            if (this.listCache != null && (this.listCache.IsListSizeMismatched || isDelete))
             {
-                var tabPage = this.ListTab.TabPages[index];
-                var listView = (DetailsListView)tabPage.Tag;
-
-                if (listView.VirtualListSize != tab.AllCount || isDelete)
+                using (ControlTransaction.Update(curListView))
                 {
-                    using (ControlTransaction.Update(listView))
-                    {
-                        if (listView == curListView)
-                            this.PurgeListViewItemCache();
-
-                        try
-                        {
-                            // リスト件数更新
-                            listView.VirtualListSize = tab.AllCount;
-                        }
-                        catch (NullReferenceException ex)
-                        {
-                            // WinForms 内部で ListView.set_TopItem が発生させている例外
-                            // https://ja.osdn.net/ticket/browse.php?group_id=6526&tid=36588
-                            MyCommon.TraceOut(ex, $"TabType: {tab.TabType}, Count: {tab.AllCount}, ListSize: {listView.VirtualListSize}");
-                        }
+                    this.listCache.PurgeCache();
+                    this.listCache.UpdateListSize();
 
-                        // 選択位置などを復元
-                        this.RestoreListViewSelection(listView, tab, listSelections[tab.TabName]);
-                    }
+                    // 選択位置などを復元
+                    currentListViewState.RestoreSelection();
                 }
             }
 
@@ -1002,7 +866,7 @@ namespace OpenTween
             }
 
             // スクロール位置を復元
-            this.RestoreListViewScroll(curListView, curTabModel, curListScroll);
+            currentListViewState.RestoreScroll();
 
             // 新着通知
             this.NotifyNewPosts(notifyPosts, soundFile, addCount, newMentionOrDm);
@@ -1013,205 +877,6 @@ namespace OpenTween
             this.HashSupl.AddRangeItem(this.tw.GetHashList());
         }
 
-        internal readonly record struct ListViewScroll(
-            ScrollLockMode ScrollLockMode,
-            long? TopItemStatusId
-        );
-
-        internal enum ScrollLockMode
-        {
-            /// <summary>固定しない</summary>
-            None,
-
-            /// <summary>最上部に固定する</summary>
-            FixedToTop,
-
-            /// <summary>最下部に固定する</summary>
-            FixedToBottom,
-
-            /// <summary><see cref="ListViewScroll.TopItemStatusId"/> の位置に固定する</summary>
-            FixedToItem,
-        }
-
-        /// <summary>
-        /// <see cref="ListView"/> のスクロール位置に関する情報を <see cref="ListViewScroll"/> として返します
-        /// </summary>
-        private ListViewScroll SaveListViewScroll(DetailsListView listView, TabModel tab)
-        {
-            var lockMode = this.GetScrollLockMode(listView);
-            long? topItemStatusId = null;
-
-            if (lockMode == ScrollLockMode.FixedToItem)
-            {
-                var topItemIndex = listView.TopItem?.Index ?? -1;
-                if (topItemIndex != -1 && topItemIndex < tab.AllCount)
-                    topItemStatusId = tab.GetStatusIdAt(topItemIndex);
-            }
-
-            return new ListViewScroll
-            {
-                ScrollLockMode = lockMode,
-                TopItemStatusId = topItemStatusId,
-            };
-        }
-
-        private ScrollLockMode GetScrollLockMode(DetailsListView listView)
-        {
-            if (this.statuses.SortMode == ComparerMode.Id)
-            {
-                if (this.statuses.SortOrder == SortOrder.Ascending)
-                {
-                    // Id昇順
-                    if (this.ListLockMenuItem.Checked)
-                        return ScrollLockMode.None;
-
-                    // 最下行が表示されていたら、最下行へ強制スクロール。最下行が表示されていなかったら制御しない
-
-                    // 一番下に表示されているアイテム
-                    var bottomItem = listView.GetItemAt(0, listView.ClientSize.Height - 1);
-                    if (bottomItem == null || bottomItem.Index == listView.VirtualListSize - 1)
-                        return ScrollLockMode.FixedToBottom;
-                    else
-                        return ScrollLockMode.None;
-                }
-                else
-                {
-                    // Id降順
-                    if (this.ListLockMenuItem.Checked)
-                        return ScrollLockMode.FixedToItem;
-
-                    // 最上行が表示されていたら、制御しない。最上行が表示されていなかったら、現在表示位置へ強制スクロール
-                    var topItem = listView.TopItem;
-                    if (topItem == null || topItem.Index == 0)
-                        return ScrollLockMode.FixedToTop;
-                    else
-                        return ScrollLockMode.FixedToItem;
-                }
-            }
-            else
-            {
-                return ScrollLockMode.FixedToItem;
-            }
-        }
-
-        internal readonly record struct ListViewSelection(
-            long[]? SelectedStatusIds,
-            long? SelectionMarkStatusId,
-            long? FocusedStatusId
-        );
-
-        /// <summary>
-        /// <see cref="ListView"/> の選択状態を <see cref="ListViewSelection"/> として返します
-        /// </summary>
-        private IReadOnlyDictionary<string, ListViewSelection> SaveListViewSelection()
-        {
-            var listsDict = new Dictionary<string, ListViewSelection>();
-
-            foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
-            {
-                var listView = (DetailsListView)this.ListTab.TabPages[index].Tag;
-                listsDict[tab.TabName] = this.SaveListViewSelection(listView, tab);
-            }
-
-            return listsDict;
-        }
-
-        /// <summary>
-        /// <see cref="ListView"/> の選択状態を <see cref="ListViewSelection"/> として返します
-        /// </summary>
-        private ListViewSelection SaveListViewSelection(DetailsListView listView, TabModel tab)
-        {
-            if (listView.VirtualListSize == 0)
-            {
-                return new ListViewSelection
-                {
-                    SelectedStatusIds = Array.Empty<long>(),
-                    SelectionMarkStatusId = null,
-                    FocusedStatusId = null,
-                };
-            }
-
-            return new ListViewSelection
-            {
-                SelectedStatusIds = tab.SelectedStatusIds,
-                FocusedStatusId = this.GetFocusedStatusId(listView, tab),
-                SelectionMarkStatusId = this.GetSelectionMarkStatusId(listView, tab),
-            };
-        }
-
-        private long? GetFocusedStatusId(DetailsListView listView, TabModel tab)
-        {
-            var index = listView.FocusedItem?.Index ?? -1;
-
-            return index != -1 && index < tab.AllCount ? tab.GetStatusIdAt(index) : (long?)null;
-        }
-
-        private long? GetSelectionMarkStatusId(DetailsListView listView, TabModel tab)
-        {
-            var index = listView.SelectionMark;
-
-            return index != -1 && index < tab.AllCount ? tab.GetStatusIdAt(index) : (long?)null;
-        }
-
-        /// <summary>
-        /// <see cref="SaveListViewScroll"/> によって保存されたスクロール位置を復元します
-        /// </summary>
-        private void RestoreListViewScroll(DetailsListView listView, TabModel tab, ListViewScroll listScroll)
-        {
-            if (listView.VirtualListSize == 0)
-                return;
-
-            switch (listScroll.ScrollLockMode)
-            {
-                case ScrollLockMode.FixedToTop:
-                    listView.EnsureVisible(0);
-                    break;
-                case ScrollLockMode.FixedToBottom:
-                    listView.EnsureVisible(listView.VirtualListSize - 1);
-                    break;
-                case ScrollLockMode.FixedToItem:
-                    var topIndex = listScroll.TopItemStatusId != null ? tab.IndexOf(listScroll.TopItemStatusId.Value) : -1;
-                    if (topIndex != -1)
-                    {
-                        var topItem = listView.Items[topIndex];
-                        try
-                        {
-                            listView.TopItem = topItem;
-                        }
-                        catch (NullReferenceException)
-                        {
-                            listView.EnsureVisible(listView.VirtualListSize - 1);
-                            listView.EnsureVisible(topIndex);
-                        }
-                    }
-                    break;
-                case ScrollLockMode.None:
-                default:
-                    break;
-            }
-        }
-
-        /// <summary>
-        /// <see cref="SaveListViewSelection"/> によって保存された選択状態を復元します
-        /// </summary>
-        private void RestoreListViewSelection(DetailsListView listView, TabModel tab, ListViewSelection listSelection)
-        {
-            // status_id から ListView 上のインデックスに変換
-            int[]? selectedIndices = null;
-            if (listSelection.SelectedStatusIds != null)
-                selectedIndices = tab.IndexOf(listSelection.SelectedStatusIds).Where(x => x != -1).ToArray();
-
-            var focusedIndex = -1;
-            if (listSelection.FocusedStatusId != null)
-                focusedIndex = tab.IndexOf(listSelection.FocusedStatusId.Value);
-
-            var selectionMarkIndex = -1;
-            if (listSelection.SelectionMarkStatusId != null)
-                selectionMarkIndex = tab.IndexOf(listSelection.SelectionMarkStatusId.Value);
-
-            this.SelectListItem(listView, selectedIndices, focusedIndex, selectionMarkIndex);
-        }
-
         private bool BalloonRequired()
         {
             if (this.initial)
@@ -1313,7 +978,7 @@ namespace OpenTween
                             if (MyCommon.IsNullOrEmpty(bText)) return;
 
                             var image = this.iconCache.TryGetFromCache(post.ImageUrl);
-                            this.gh.Notify(nt, post.StatusId.ToString(), title.ToString(), bText, image?.Image, post.ImageUrl);
+                            this.gh.Notify(nt, post.StatusId.Id, title.ToString(), bText, image?.Image, post.ImageUrl);
                         }
                     }
                     else
@@ -1423,155 +1088,14 @@ namespace OpenTween
             var post = this.CurrentPost!;
             this.statuses.SetReadAllTab(post.StatusId, read: true);
 
-            // キャッシュの書き換え
-            this.ChangeCacheStyleRead(true, index); // 既読へ(フォント、文字色)
-
-            this.ColorizeList();
+            this.listCache?.RefreshStyle();
             await this.selectionDebouncer.Call();
         }
 
-        private void ChangeCacheStyleRead(bool read, int index)
-        {
-            var tabInfo = this.CurrentTab;
-            // Read:true=既読 false=未読
-            // 未読管理していなかったら既読として扱う
-            if (!tabInfo.UnreadManage ||
-               !this.settings.Common.UnreadManage) read = true;
-
-            var listCache = this.listItemCache;
-            if (listCache == null)
-                return;
-
-            // キャッシュに含まれていないアイテムは対象外
-            if (!listCache.TryGetValue(index, out var itm, out var post))
-                return;
-
-            this.ChangeItemStyleRead(read, itm, post, (DetailsListView)listCache.TargetList);
-        }
-
-        private void ChangeItemStyleRead(bool read, ListViewItem item, PostClass post, DetailsListView? dList)
-        {
-            Font fnt;
-            string star;
-            // フォント
-            if (read)
-            {
-                fnt = this.themeManager.FontReaded;
-                star = "";
-            }
-            else
-            {
-                fnt = this.themeManager.FontUnread;
-                star = "★";
-            }
-            if (item.SubItems[5].Text != star)
-                item.SubItems[5].Text = star;
-
-            // 文字色
-            Color cl;
-            if (post.IsFav)
-                cl = this.themeManager.ColorFav;
-            else if (post.RetweetedId != null)
-                cl = this.themeManager.ColorRetweet;
-            else if (post.IsOwl && (post.IsDm || this.settings.Common.OneWayLove))
-                cl = this.themeManager.ColorOWL;
-            else if (read || !this.settings.Common.UseUnreadStyle)
-                cl = this.themeManager.ColorRead;
-            else
-                cl = this.themeManager.ColorUnread;
-
-            if (dList == null || item.Index == -1)
-            {
-                item.ForeColor = cl;
-                if (this.settings.Common.UseUnreadStyle)
-                    item.Font = fnt;
-            }
-            else
-            {
-                dList.Update();
-                if (this.settings.Common.UseUnreadStyle)
-                    dList.ChangeItemFontAndColor(item, cl, fnt);
-                else
-                    dList.ChangeItemForeColor(item, cl);
-            }
-        }
-
-        private void ColorizeList()
-        {
-            // Index:更新対象のListviewItem.Index。Colorを返す。
-            // -1は全キャッシュ。Colorは返さない(ダミーを戻す)
-            var post = this.CurrentTab.AnchorPost ?? this.CurrentPost;
-            if (post == null)
-                return;
-
-            var listCache = this.listItemCache;
-            if (listCache == null)
-                return;
-
-            var listView = (DetailsListView)listCache.TargetList;
-
-            // ValidateRectが呼ばれる前に選択色などの描画を済ませておく
-            listView.Update();
-
-            foreach (var (listViewItem, cachedPost) in listCache.Cache)
-            {
-                var backColor = this.JudgeColor(post, cachedPost);
-                listView.ChangeItemBackColor(listViewItem, backColor);
-            }
-        }
-
-        private void ColorizeList(ListViewItem item, PostClass post)
-        {
-            // Index:更新対象のListviewItem.Index。Colorを返す。
-            // -1は全キャッシュ。Colorは返さない(ダミーを戻す)
-            var basePost = this.CurrentTab.AnchorPost ?? this.CurrentPost;
-            if (basePost == null)
-                return;
-
-            if (item.Index == -1)
-                item.BackColor = this.JudgeColor(basePost, post);
-            else
-                this.CurrentListView.ChangeItemBackColor(item, this.JudgeColor(basePost, post));
-        }
-
-        private Color JudgeColor(PostClass basePost, PostClass targetPost)
-        {
-            Color cl;
-            if (targetPost.StatusId == basePost.InReplyToStatusId)
-                // @先
-                cl = this.themeManager.ColorAtTo;
-            else if (targetPost.IsMe)
-                // 自分=発言者
-                cl = this.themeManager.ColorSelf;
-            else if (targetPost.IsReply)
-                // 自分宛返信
-                cl = this.themeManager.ColorAtSelf;
-            else if (basePost.ReplyToList.Any(x => x.UserId == targetPost.UserId))
-                // 返信先
-                cl = this.themeManager.ColorAtFromTarget;
-            else if (targetPost.ReplyToList.Any(x => x.UserId == basePost.UserId))
-                // その人への返信
-                cl = this.themeManager.ColorAtTarget;
-            else if (targetPost.UserId == basePost.UserId)
-                // 発言者
-                cl = this.themeManager.ColorTarget;
-            else
-                // その他
-                cl = this.themeManager.ColorListBackcolor;
-
-            return cl;
-        }
-
         private void StatusTextHistoryBack()
         {
-            if (!string.IsNullOrWhiteSpace(this.StatusText.Text))
-                this.history[this.hisIdx] = new StatusTextHistory(this.StatusText.Text, this.inReplyTo);
-
-            this.hisIdx -= 1;
-            if (this.hisIdx < 0)
-                this.hisIdx = 0;
-
-            var historyItem = this.history[this.hisIdx];
+            this.history.SetCurrentItem(this.StatusText.Text, this.inReplyTo);
+            var historyItem = this.history.Back();
             this.inReplyTo = historyItem.InReplyTo;
             this.StatusText.Text = historyItem.Status;
             this.StatusText.SelectionStart = this.StatusText.Text.Length;
@@ -1579,14 +1103,8 @@ namespace OpenTween
 
         private void StatusTextHistoryForward()
         {
-            if (!string.IsNullOrWhiteSpace(this.StatusText.Text))
-                this.history[this.hisIdx] = new StatusTextHistory(this.StatusText.Text, this.inReplyTo);
-
-            this.hisIdx += 1;
-            if (this.hisIdx > this.history.Count - 1)
-                this.hisIdx = this.history.Count - 1;
-
-            var historyItem = this.history[this.hisIdx];
+            this.history.SetCurrentItem(this.StatusText.Text, this.inReplyTo);
+            var historyItem = this.history.Forward();
             this.inReplyTo = historyItem.InReplyTo;
             this.StatusText.Text = historyItem.Status;
             this.StatusText.SelectionStart = this.StatusText.Text.Length;
@@ -1630,8 +1148,6 @@ namespace OpenTween
                     return;
             }
 
-            this.history[this.history.Count - 1] = new StatusTextHistory(this.StatusText.Text, this.inReplyTo);
-
             if (this.settings.Common.Nicoms)
             {
                 this.StatusText.SelectionStart = this.StatusText.Text.Length;
@@ -1644,7 +1160,7 @@ namespace OpenTween
             var status = new PostStatusParams();
 
             var statusTextCompat = this.FormatStatusText(this.StatusText.Text);
-            if (this.GetRestStatusCount(statusTextCompat) >= 0)
+            if (this.GetRestStatusCount(statusTextCompat) >= 0 && this.tw.Api.AuthType == APIAuthType.OAuth1)
             {
                 // auto_populate_reply_metadata や attachment_url を使用しなくても 140 字以内に
                 // 収まる場合はこれらのオプションを使用せずに投稿する
@@ -1687,13 +1203,14 @@ namespace OpenTween
                 if (!this.ImageSelector.TryGetSelectedMedia(out var serviceName, out uploadItems))
                     return;
 
-                uploadService = this.ImageSelector.GetService(serviceName);
+                this.ImageSelector.EndSelection();
+                uploadService = this.ImageSelector.Model.GetService(serviceName);
             }
 
+            this.history.AddLast(this.StatusText.Text, this.inReplyTo);
+
             this.inReplyTo = null;
             this.StatusText.Text = "";
-            this.history.Add(new StatusTextHistory(""));
-            this.hisIdx = this.history.Count - 1;
             if (!this.settings.Common.FocusLockToStatusText)
                 this.CurrentListView.Focus();
             this.urlUndoBuffer = null;
@@ -1817,7 +1334,7 @@ namespace OpenTween
             }
         }
 
-        private async Task FavAddAsync(long statusId, TabModel tab)
+        private async Task FavAddAsync(PostId statusId, TabModel tab)
         {
             await this.workerSemaphore.WaitAsync();
 
@@ -1839,7 +1356,7 @@ namespace OpenTween
             }
         }
 
-        private async Task FavAddAsyncInternal(IProgress<string> p, CancellationToken ct, long statusId, TabModel tab)
+        private async Task FavAddAsyncInternal(IProgress<string> p, CancellationToken ct, PostId statusId, TabModel tab)
         {
             if (ct.IsCancellationRequested)
                 return;
@@ -1859,9 +1376,10 @@ namespace OpenTween
 
                 try
                 {
+                    var twitterStatusId = (post.RetweetedId ?? post.StatusId).ToTwitterStatusId();
                     try
                     {
-                        await this.tw.Api.FavoritesCreate(post.RetweetedId ?? post.StatusId)
+                        await this.tw.Api.FavoritesCreate(twitterStatusId)
                             .IgnoreResponse()
                             .ConfigureAwait(false);
                     }
@@ -1873,7 +1391,7 @@ namespace OpenTween
 
                     if (this.settings.Common.RestrictFavCheck)
                     {
-                        var status = await this.tw.Api.StatusesShow(post.RetweetedId ?? post.StatusId)
+                        var status = await this.tw.Api.StatusesShow(twitterStatusId)
                             .ConfigureAwait(false);
 
                         if (status.Favorited != true)
@@ -1928,7 +1446,7 @@ namespace OpenTween
                 {
                     var idx = tab.IndexOf(statusId);
                     if (idx != -1)
-                        this.ChangeCacheStyleRead(post.IsRead, idx);
+                        this.listCache?.RefreshStyle(idx);
                 }
 
                 var currentPost = this.CurrentPost;
@@ -1937,7 +1455,7 @@ namespace OpenTween
             }
         }
 
-        private async Task FavRemoveAsync(IReadOnlyList<long> statusIds, TabModel tab)
+        private async Task FavRemoveAsync(IReadOnlyList<PostId> statusIds, TabModel tab)
         {
             await this.workerSemaphore.WaitAsync();
 
@@ -1959,7 +1477,7 @@ namespace OpenTween
             }
         }
 
-        private async Task FavRemoveAsyncInternal(IProgress<string> p, CancellationToken ct, IReadOnlyList<long> statusIds, TabModel tab)
+        private async Task FavRemoveAsyncInternal(IProgress<string> p, CancellationToken ct, IReadOnlyList<PostId> statusIds, TabModel tab)
         {
             if (ct.IsCancellationRequested)
                 return;
@@ -1967,7 +1485,7 @@ namespace OpenTween
             if (!CheckAccountValid())
                 throw new WebApiException("Auth error. Check your account");
 
-            var successIds = new List<long>();
+            var successIds = new List<PostId>();
 
             await Task.Run(async () =>
             {
@@ -1985,9 +1503,11 @@ namespace OpenTween
                     if (!post.IsFav)
                         continue;
 
+                    var twitterStatusId = (post.RetweetedId ?? post.StatusId).ToTwitterStatusId();
+
                     try
                     {
-                        await this.tw.Api.FavoritesDestroy(post.RetweetedId ?? post.StatusId)
+                        await this.tw.Api.FavoritesDestroy(twitterStatusId)
                             .IgnoreResponse()
                             .ConfigureAwait(false);
                     }
@@ -2037,11 +1557,8 @@ namespace OpenTween
                         foreach (var statusId in successIds)
                         {
                             var idx = tab.IndexOf(statusId);
-                            if (idx == -1)
-                                continue;
-
-                            var post = tab.Posts[statusId];
-                            this.ChangeCacheStyleRead(post.IsRead, idx);
+                            if (idx != -1)
+                                this.listCache?.RefreshStyle(idx);
                         }
                     }
 
@@ -2187,22 +1704,18 @@ namespace OpenTween
             this.SetMainWindowTitle();
 
             // TLに反映
-            if (this.settings.Common.PostAndGet)
-            {
-                await this.RefreshTabAsync<HomeTabModel>();
-            }
-            else
+            if (post != null)
             {
-                if (post != null)
-                {
-                    this.statuses.AddPost(post);
-                    this.statuses.DistributePosts();
-                }
+                this.statuses.AddPost(post);
+                this.statuses.DistributePosts();
                 this.RefreshTimeline();
             }
+
+            if (this.settings.Common.PostAndGet)
+                await this.RefreshTabAsync<HomeTabModel>();
         }
 
-        private async Task RetweetAsync(IReadOnlyList<long> statusIds)
+        private async Task RetweetAsync(IReadOnlyList<PostId> statusIds)
         {
             await this.workerSemaphore.WaitAsync();
 
@@ -2224,7 +1737,7 @@ namespace OpenTween
             }
         }
 
-        private async Task RetweetAsyncInternal(IProgress<string> p, CancellationToken ct, IReadOnlyList<long> statusIds)
+        private async Task RetweetAsyncInternal(IProgress<string> p, CancellationToken ct, IReadOnlyList<PostId> statusIds)
         {
             if (ct.IsCancellationRequested)
                 return;
@@ -2294,7 +1807,7 @@ namespace OpenTween
                 this.StatusLabel.Text = Properties.Resources.UpdateFollowersMenuItem1_ClickText3;
 
                 this.RefreshTimeline();
-                this.PurgeListViewItemCache();
+                this.listCache?.PurgeCache();
                 this.CurrentListView.Refresh();
             }
             catch (WebApiException ex)
@@ -2362,13 +1875,13 @@ namespace OpenTween
 
                 if (this.tw.Configuration.PhotoSizeLimit != 0)
                 {
-                    foreach (var service in this.ImageSelector.GetServices())
+                    foreach (var (_, service) in this.ImageSelector.Model.MediaServices)
                     {
                         service.UpdateTwitterConfiguration(this.tw.Configuration);
                     }
                 }
 
-                this.PurgeListViewItemCache();
+                this.listCache?.PurgeCache();
                 this.CurrentListView.Refresh();
             }
             catch (WebApiException ex)
@@ -2524,18 +2037,6 @@ namespace OpenTween
             }
         }
 
-        private PostClass GetCurTabPost(int index)
-        {
-            var listCache = this.listItemCache;
-            if (listCache != null)
-            {
-                if (listCache.TryGetValue(index, out _, out var post))
-                    return post;
-            }
-
-            return this.CurrentTab[index];
-        }
-
         private async void AuthorOpenInBrowserMenuItem_Click(object sender, EventArgs e)
         {
             var post = this.CurrentPost;
@@ -2652,7 +2153,7 @@ namespace OpenTween
                 }
             }
 
-            this.PurgeListViewItemCache();
+            this.listCache?.PurgeCache();
 
             var tab = this.CurrentTab;
             var post = this.CurrentPost;
@@ -2807,9 +2308,9 @@ namespace OpenTween
 
                     try
                     {
-                        if (post.IsDm)
+                        if (post.StatusId is TwitterDirectMessageId dmId)
                         {
-                            await this.tw.Api.DirectMessagesEventsDestroy(post.StatusId.ToString(CultureInfo.InvariantCulture));
+                            await this.tw.Api.DirectMessagesEventsDestroy(dmId);
                         }
                         else
                         {
@@ -2817,8 +2318,7 @@ namespace OpenTween
                             {
                                 // 自分が RT したツイート (自分が RT した自分のツイートも含む)
                                 //   => RT を取り消し
-                                await this.tw.Api.StatusesDestroy(post.StatusId)
-                                    .IgnoreResponse();
+                                await this.tw.DeleteRetweet(post);
                             }
                             else
                             {
@@ -2828,15 +2328,13 @@ namespace OpenTween
                                     {
                                         // 他人に RT された自分のツイート
                                         //   => RT 元の自分のツイートを削除
-                                        await this.tw.Api.StatusesDestroy(post.RetweetedId.Value)
-                                            .IgnoreResponse();
+                                        await this.tw.DeleteTweet(post.RetweetedId.ToTwitterStatusId());
                                     }
                                     else
                                     {
                                         // 自分のツイート
                                         //   => ツイートを削除
-                                        await this.tw.Api.StatusesDestroy(post.StatusId)
-                                            .IgnoreResponse();
+                                        await this.tw.DeleteTweet(post.StatusId.ToTwitterStatusId());
                                     }
                                 }
                             }
@@ -2856,36 +2354,31 @@ namespace OpenTween
                 else
                     this.StatusLabel.Text = Properties.Resources.DeleteStripMenuItem_ClickText3; // 失敗
 
-                this.PurgeListViewItemCache();
-
-                foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
+                using (ControlTransaction.Update(currentListView))
                 {
-                    var tabPage = this.ListTab.TabPages[index];
-                    var listView = (DetailsListView)tabPage.Tag;
-
-                    using (ControlTransaction.Update(listView))
-                    {
-                        listView.VirtualListSize = tab.AllCount;
+                    this.listCache?.PurgeCache();
+                    this.listCache?.UpdateListSize();
 
-                        if (tab.TabName == this.CurrentTabName)
-                        {
-                            listView.SelectedIndices.Clear();
+                    currentListView.SelectedIndices.Clear();
 
-                            if (tab.AllCount != 0)
-                            {
-                                int selectedIndex;
-                                if (tab.AllCount - 1 > focusedIndex && focusedIndex > -1)
-                                    selectedIndex = focusedIndex;
-                                else
-                                    selectedIndex = tab.AllCount - 1;
+                    var currentTab = this.CurrentTab;
+                    if (currentTab.AllCount != 0)
+                    {
+                        int selectedIndex;
+                        if (currentTab.AllCount - 1 > focusedIndex && focusedIndex > -1)
+                            selectedIndex = focusedIndex;
+                        else
+                            selectedIndex = currentTab.AllCount - 1;
 
-                                listView.SelectedIndices.Add(selectedIndex);
-                                listView.EnsureVisible(selectedIndex);
-                                listView.FocusedItem = listView.Items[selectedIndex];
-                            }
-                        }
+                        currentListView.SelectedIndices.Add(selectedIndex);
+                        currentListView.EnsureVisible(selectedIndex);
+                        currentListView.FocusedItem = currentListView.Items[selectedIndex];
                     }
+                }
 
+                foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
+                {
+                    var tabPage = this.ListTab.TabPages[index];
                     if (this.settings.Common.TabIconDisp && tab.UnreadCount == 0)
                     {
                         if (tabPage.ImageIndex == 0)
@@ -2910,9 +2403,8 @@ namespace OpenTween
                 {
                     this.statuses.SetReadAllTab(statusId, read: true);
                     var idx = tab.IndexOf(statusId);
-                    this.ChangeCacheStyleRead(true, idx);
+                    this.listCache?.RefreshStyle(idx);
                 }
-                this.ColorizeList();
             }
             if (this.settings.Common.TabIconDisp)
             {
@@ -2938,9 +2430,8 @@ namespace OpenTween
                 {
                     this.statuses.SetReadAllTab(statusId, read: false);
                     var idx = tab.IndexOf(statusId);
-                    this.ChangeCacheStyleRead(false, idx);
+                    this.listCache?.RefreshStyle(idx);
                 }
-                this.ColorizeList();
             }
             if (this.settings.Common.TabIconDisp)
             {
@@ -3007,14 +2498,8 @@ namespace OpenTween
                 {
                     this.settings.ApplySettings();
 
-                    if (MyCommon.IsNullOrEmpty(this.settings.Common.Token))
-                        this.tw.ClearAuthInfo();
-
-                    this.tw.Initialize(this.settings.Common.Token, this.settings.Common.TokenSecret, this.settings.Common.UserName, this.settings.Common.UserId);
-                    this.tw.RestrictFavCheck = this.settings.Common.RestrictFavCheck;
-                    this.tw.ReadOwnPost = this.settings.Common.ReadOwnPost;
-
-                    this.ImageSelector.Reset(this.tw, this.tw.Configuration);
+                    this.accounts.LoadFromSettings(this.settings.Common);
+                    this.ImageSelector.Model.InitializeServices(this.tw, this.tw.Configuration);
 
                     try
                     {
@@ -3102,7 +2587,7 @@ namespace OpenTween
 
                     try
                     {
-                        this.InitDetailHtmlFormat();
+                        this.detailsHtmlBuilder.Prepare(this.settings.Common, this.themeManager);
                     }
                     catch (Exception ex)
                     {
@@ -3143,8 +2628,6 @@ namespace OpenTween
                             using (ControlTransaction.Update(lst))
                             {
                                 lst.GridLines = this.settings.Common.ShowGrid;
-                                lst.Font = this.themeManager.FontReaded;
-                                lst.BackColor = this.themeManager.ColorListBackcolor;
 
                                 if (this.Use2ColumnsMode != oldIconCol)
                                     this.ResetColumns(lst);
@@ -3161,7 +2644,7 @@ namespace OpenTween
                     this.SetMainWindowTitle();
                     this.SetNotifyIconText();
 
-                    this.PurgeListViewItemCache();
+                    this.listCache?.PurgeCache();
                     this.CurrentListView.Refresh();
                     this.ListTab.Refresh();
 
@@ -3210,29 +2693,25 @@ namespace OpenTween
             var newAlignment = this.settings.Common.ViewTabBottom ? TabAlignment.Bottom : TabAlignment.Top;
             if (this.ListTab.Alignment == newAlignment) return;
 
-            // 各タブのリスト上の選択位置などを退避
-            var listSelections = this.SaveListViewSelection();
+            // リスト上の選択位置などを退避
+            var currentListViewState = this.listViewState[this.CurrentTabName];
+            currentListViewState.Save(this.ListLockMenuItem.Checked);
 
             this.ListTab.Alignment = newAlignment;
 
-            foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
-            {
-                var lst = (DetailsListView)this.ListTab.TabPages[index].Tag;
-                using (ControlTransaction.Update(lst))
-                {
-                    // 選択位置などを復元
-                    this.RestoreListViewSelection(lst, tab, listSelections[tab.TabName]);
-                }
-            }
+            currentListViewState.Restore(forceScroll: true);
         }
 
         private void ApplyListViewIconSize(MyCommon.IconSizes iconSz)
         {
             // アイコンサイズの再設定
             if (this.listDrawer != null)
+            {
                 this.listDrawer.IconSize = iconSz;
+                this.listDrawer.UpdateItemHeight();
+            }
 
-            this.PurgeListViewItemCache();
+            this.listCache?.PurgeCache();
         }
 
         private void ResetColumns(DetailsListView list)
@@ -3524,8 +3003,6 @@ namespace OpenTween
                 listCustom.View = View.Details;
                 listCustom.OwnerDraw = true;
                 listCustom.VirtualMode = true;
-                listCustom.Font = this.themeManager.FontReaded;
-                listCustom.BackColor = this.themeManager.ColorListBackcolor;
 
                 listCustom.GridLines = this.settings.Common.ShowGrid;
                 listCustom.AllowDrop = true;
@@ -3542,11 +3019,12 @@ namespace OpenTween
                 listCustom.MouseClick += this.MyList_MouseClick;
                 listCustom.ColumnReordered += this.MyList_ColumnReordered;
                 listCustom.ColumnWidthChanged += this.MyList_ColumnWidthChanged;
-                listCustom.CacheVirtualItems += this.MyList_CacheVirtualItems;
-                listCustom.RetrieveVirtualItem += this.MyList_RetrieveVirtualItem;
                 listCustom.HScrolled += this.MyList_HScrolled;
             }
 
+            var state = new TimelineListViewState(listCustom, tab);
+            this.listViewState[tab.TabName] = state;
+
             return true;
         }
 
@@ -3579,6 +3057,8 @@ namespace OpenTween
 
             this.SetListProperty();   // 他のタブに列幅等を反映
 
+            this.listViewState.Remove(tabName);
+
             // オブジェクトインスタンスの削除
             var listCustom = (DetailsListView)tabPage.Tag;
             tabPage.Tag = null;
@@ -3638,8 +3118,6 @@ namespace OpenTween
                 listCustom.MouseClick -= this.MyList_MouseClick;
                 listCustom.ColumnReordered -= this.MyList_ColumnReordered;
                 listCustom.ColumnWidthChanged -= this.MyList_ColumnWidthChanged;
-                listCustom.CacheVirtualItems -= this.MyList_CacheVirtualItems;
-                listCustom.RetrieveVirtualItem -= this.MyList_RetrieveVirtualItem;
                 listCustom.HScrolled -= this.MyList_HScrolled;
 
                 var cols = listCustom.Columns.Cast<ColumnHeader>().ToList<ColumnHeader>();
@@ -3655,25 +3133,19 @@ namespace OpenTween
                 listCustom.ListViewItemSorter = null;
 
                 // キャッシュのクリア
-                this.PurgeListViewItemCache();
+                this.listCache?.PurgeCache();
             }
 
             tabPage.Dispose();
             listCustom.Dispose();
             this.statuses.RemoveTab(tabName);
 
-            foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
-            {
-                var lst = (DetailsListView)this.ListTab.TabPages[index].Tag;
-                lst.VirtualListSize = tab.AllCount;
-            }
-
             return true;
         }
 
         private void ListTab_Deselected(object sender, TabControlEventArgs e)
         {
-            this.PurgeListViewItemCache();
+            this.listCache?.PurgeCache();
             this.beforeSelectedTab = e.TabPage;
         }
 
@@ -3962,7 +3434,7 @@ namespace OpenTween
             attachmentUrl = null;
 
             // attachment_url は media_id と同時に使用できない
-            if (this.ImageSelector.Visible && this.ImageSelector.SelectedService is TwitterPhoto)
+            if (this.ImageSelector.Visible && this.ImageSelector.Model.SelectedMediaService is TwitterPhoto)
                 return statusText;
 
             var match = Twitter.AttachmentUrlRegex.Match(statusText);
@@ -3993,14 +3465,17 @@ namespace OpenTween
             return this.FormatStatusText(statusText);
         }
 
+        internal string FormatStatusText(string statusText)
+            => this.FormatStatusText(statusText, Control.ModifierKeys);
+
         /// <summary>
         /// ツイート投稿前のフッター付与などの前処理を行います
         /// </summary>
-        private string FormatStatusText(string statusText)
+        internal string FormatStatusText(string statusText, Keys modifierKeys)
         {
             statusText = statusText.Replace("\r\n", "\n");
 
-            if (this.urlMultibyteSplit)
+            if (this.SeparateUrlAndFullwidthCharacter)
             {
                 // URLと全角文字の切り離し
                 statusText = Regex.Replace(statusText, @"https?:\/\/[-_.!~*'()a-zA-Z0-9;\/?:\@&=+\$,%#^]+", "$& ");
@@ -4019,14 +3494,14 @@ namespace OpenTween
             bool disableFooter;
             if (this.settings.Common.PostShiftEnter)
             {
-                disableFooter = MyCommon.IsKeyDown(Keys.Control);
+                disableFooter = MyCommon.IsKeyDown(modifierKeys, Keys.Control);
             }
             else
             {
-                if (this.StatusText.Multiline && !this.settings.Common.PostCtrlEnter)
-                    disableFooter = MyCommon.IsKeyDown(Keys.Control);
+                if (this.settings.Local.StatusMultiline && !this.settings.Common.PostCtrlEnter)
+                    disableFooter = MyCommon.IsKeyDown(modifierKeys, Keys.Control);
                 else
-                    disableFooter = MyCommon.IsKeyDown(Keys.Shift);
+                    disableFooter = MyCommon.IsKeyDown(modifierKeys, Keys.Shift);
             }
 
             if (statusText.Contains("RT @"))
@@ -4105,142 +3580,7 @@ namespace OpenTween
         }
 
         private IMediaUploadService? GetSelectedImageService()
-            => this.ImageSelector.Visible ? this.ImageSelector.SelectedService : null;
-
-        private void MyList_CacheVirtualItems(object sender, CacheVirtualItemsEventArgs e)
-        {
-            if (sender != this.CurrentListView)
-                return;
-
-            var listCache = this.listItemCache;
-            if (listCache?.TargetList == sender && listCache.IsSupersetOf(e.StartIndex, e.EndIndex))
-            {
-                // If the newly requested cache is a subset of the old cache,
-                // no need to rebuild everything, so do nothing.
-                return;
-            }
-
-            // Now we need to rebuild the cache.
-            this.CreateCache(e.StartIndex, e.EndIndex);
-        }
-
-        private void MyList_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
-        {
-            var listCache = this.listItemCache;
-            if (listCache?.TargetList == sender)
-            {
-                if (listCache.TryGetValue(e.ItemIndex, out var item, out _))
-                {
-                    e.Item = item;
-                    return;
-                }
-            }
-
-            // A cache miss, so create a new ListViewItem and pass it back.
-            var tabPage = (TabPage)((DetailsListView)sender).Parent;
-            var tab = this.statuses.Tabs[tabPage.Text];
-            try
-            {
-                e.Item = this.CreateItem(tab, tab[e.ItemIndex]);
-            }
-            catch (Exception)
-            {
-                // 不正な要求に対する間に合わせの応答
-                string[] sitem = { "", "", "", "", "", "", "", "" };
-                e.Item = new ListViewItem(sitem);
-            }
-        }
-
-        private void CreateCache(int startIndex, int endIndex)
-        {
-            var tabInfo = this.CurrentTab;
-
-            if (tabInfo.AllCount == 0)
-                return;
-
-            // インデックスを 0...(tabInfo.AllCount - 1) の範囲内にする
-            int FilterRange(int index)
-                => Math.Max(Math.Min(index, tabInfo.AllCount - 1), 0);
-
-            // キャッシュ要求(要求範囲±30を作成)
-            startIndex = FilterRange(startIndex - 30);
-            endIndex = FilterRange(endIndex + 30);
-
-            var cacheLength = endIndex - startIndex + 1;
-
-            var tab = this.CurrentTab;
-            var posts = tabInfo[startIndex, endIndex]; // 配列で取得
-            var listItems = Enumerable.Range(0, cacheLength)
-                .Select(x => this.CreateItem(tab, posts[x]))
-                .ToArray();
-
-            var listCache = new ListViewItemCache(
-                TargetList: this.CurrentListView,
-                StartIndex: startIndex,
-                EndIndex: endIndex,
-                Cache: Enumerable.Zip(listItems, posts, (x, y) => (x, y)).ToArray()
-            );
-
-            Interlocked.Exchange(ref this.listItemCache, listCache);
-        }
-
-        /// <summary>
-        /// DetailsListView のための ListViewItem のキャッシュを消去する
-        /// </summary>
-        private void PurgeListViewItemCache()
-            => Interlocked.Exchange(ref this.listItemCache, null);
-
-        private ListViewItem CreateItem(TabModel tab, PostClass post)
-        {
-            var mk = new StringBuilder();
-
-            if (post.FavoritedCount > 0) mk.Append("+" + post.FavoritedCount);
-
-            ListViewItem itm;
-            if (post.RetweetedId == null)
-            {
-                string[] sitem =
-                {
-                    "",
-                    post.Nickname,
-                    post.IsDeleted ? "(DELETED)" : post.AccessibleText.Replace('\n', ' '),
-                    post.CreatedAt.ToLocalTimeString(this.settings.Common.DateTimeFormat),
-                    post.ScreenName,
-                    "",
-                    mk.ToString(),
-                    post.Source,
-                };
-                itm = new ListViewItem(sitem);
-            }
-            else
-            {
-                string[] sitem =
-                {
-                    "",
-                    post.Nickname,
-                    post.IsDeleted ? "(DELETED)" : post.AccessibleText.Replace('\n', ' '),
-                    post.CreatedAt.ToLocalTimeString(this.settings.Common.DateTimeFormat),
-                    post.ScreenName + Environment.NewLine + "(RT:" + post.RetweetedBy + ")",
-                    "",
-                    mk.ToString(),
-                    post.Source,
-                };
-                itm = new ListViewItem(sitem);
-            }
-            itm.Tag = post;
-
-            var read = post.IsRead;
-            // 未読管理していなかったら既読として扱う
-            if (!tab.UnreadManage || !this.settings.Common.UnreadManage)
-                read = true;
-
-            this.ChangeItemStyleRead(read, itm, post, null);
-
-            if (tab.TabName == this.CurrentTabName)
-                this.ColorizeList(itm, post);
-
-            return itm;
-        }
+            => this.ImageSelector.Visible ? this.ImageSelector.Model.SelectedMediaService : null;
 
         /// <summary>
         /// 全てのタブの振り分けルールを反映し直します
@@ -4249,17 +3589,18 @@ namespace OpenTween
         {
             using (ControlTransaction.Cursor(this, Cursors.WaitCursor))
             {
-                this.PurgeListViewItemCache();
                 this.statuses.FilterAll();
 
+                var listView = this.CurrentListView;
+                using (ControlTransaction.Update(listView))
+                {
+                    this.listCache?.PurgeCache();
+                    this.listCache?.UpdateListSize();
+                }
+
                 foreach (var (tab, index) in this.statuses.Tabs.WithIndex())
                 {
                     var tabPage = this.ListTab.TabPages[index];
-                    var listview = (DetailsListView)tabPage.Tag;
-                    using (ControlTransaction.Update(listview))
-                    {
-                        listview.VirtualListSize = tab.AllCount;
-                    }
 
                     if (this.settings.Common.TabIconDisp)
                     {
@@ -4700,7 +4041,7 @@ namespace OpenTween
 
                 if (dialog.ShowDialog(this) == DialogResult.Yes)
                 {
-                    await MyCommon.OpenInBrowserAsync(this, versionInfo.DownloadUri.OriginalString);
+                    await MyCommon.OpenInBrowserAsync(this, versionInfo.DownloadUri);
                 }
                 else if (dialog.SkipButtonPressed)
                 {
@@ -4756,9 +4097,6 @@ namespace OpenTween
             this.DispSelectedPost();
         }
 
-        public string CreateDetailHtml(string orgdata)
-            => this.detailHtmlFormatPreparedTemplate.Replace("%CONTENT_HTML%", orgdata);
-
         private void DispSelectedPost()
             => this.DispSelectedPost(false);
 
@@ -4781,12 +4119,8 @@ namespace OpenTween
             if (!forceupdate && currentPost.Equals(oldDisplayPost))
                 return;
 
-            var loadTasks = new List<Task>
-            {
-                this.tweetDetailsView.ShowPostDetails(currentPost),
-            };
-
-            this.SplitContainer3.Panel2Collapsed = true;
+            var loadTasks = new TaskCollection();
+            loadTasks.Add(() => this.tweetDetailsView.ShowPostDetails(currentPost));
 
             if (this.settings.Common.PreviewEnable)
             {
@@ -4794,22 +4128,36 @@ namespace OpenTween
                 oldTokenSource?.Cancel();
 
                 var token = this.thumbnailTokenSource!.Token;
-                loadTasks.Add(this.tweetThumbnail1.ShowThumbnailAsync(currentPost, token));
+                loadTasks.Add(() => this.PrepareThumbnailControl(currentPost, token));
             }
-
-            async Task DelayedTasks()
+            else
             {
-                try
-                {
-                    await Task.WhenAll(loadTasks);
-                }
-                catch (OperationCanceledException)
-                {
-                }
+                this.SplitContainer3.Panel2Collapsed = true;
             }
 
             // サムネイルの読み込みを待たずに次に選択されたツイートを表示するため await しない
-            _ = DelayedTasks();
+            _ = loadTasks
+                .IgnoreException(x => x is OperationCanceledException)
+                .RunAll();
+        }
+
+        private async Task PrepareThumbnailControl(PostClass post, CancellationToken token)
+        {
+            var prepareTask = this.tweetThumbnail1.Model.PrepareThumbnails(post, token);
+
+            var timeout = Task.Delay(100);
+            if ((await Task.WhenAny(prepareTask, timeout)) == timeout)
+            {
+                token.ThrowIfCancellationRequested();
+
+                // サムネイル情報の読み込みに時間が掛かっている場合は一旦サムネイル領域を非表示にする
+                this.SplitContainer3.Panel2Collapsed = true;
+            }
+
+            await prepareTask;
+            token.ThrowIfCancellationRequested();
+
+            this.SplitContainer3.Panel2Collapsed = !this.tweetThumbnail1.Model.ThumbnailAvailable;
         }
 
         private async void MatomeMenuItem_Click(object sender, EventArgs e)
@@ -5265,6 +4613,9 @@ namespace OpenTween
                     .OnlyWhen(() => this.CurrentTab.TabType == MyCommon.TabUsageType.PublicSearch)
                     .Do(() => this.CurrentTabPage.Controls["panelSearch"].Controls["comboSearch"].Focus()),
 
+                ShortcutCommand.Create(Keys.Control | Keys.Shift | Keys.L)
+                    .Do(() => this.DoQuoteOfficial()),
+
                 ShortcutCommand.Create(Keys.Control | Keys.Shift | Keys.S)
                     .Do(() => this.FavoriteChange(favAdd: false)),
 
@@ -5410,15 +4761,15 @@ namespace OpenTween
                     .Do(() => this.CopyUserId()),
 
                 ShortcutCommand.Create(Keys.Alt | Keys.Shift | Keys.Up)
-                    .Do(() => this.tweetThumbnail1.ScrollUp()),
+                    .Do(() => this.tweetThumbnail1.Model.ScrollUp()),
 
                 ShortcutCommand.Create(Keys.Alt | Keys.Shift | Keys.Down)
-                    .Do(() => this.tweetThumbnail1.ScrollDown()),
+                    .Do(() => this.tweetThumbnail1.Model.ScrollDown()),
 
                 ShortcutCommand.Create(Keys.Alt | Keys.Shift | Keys.Enter)
                     .FocusedOn(FocusedControl.ListTab)
                     .OnlyWhen(() => !this.SplitContainer3.Panel2Collapsed)
-                    .Do(() => this.OpenThumbnailPicture(this.tweetThumbnail1.Thumbnail)),
+                    .Do(() => this.tweetThumbnail1.OpenImageInBrowser()),
             };
         }
 
@@ -5583,7 +4934,7 @@ namespace OpenTween
                 return;
 
             var selectedStatusId = tab.SelectedStatusId;
-            if (selectedStatusId == -1)
+            if (selectedStatusId == null)
                 return;
 
             int fIdx, toIdx, stp;
@@ -5761,7 +5112,7 @@ namespace OpenTween
             if (anchorStatusId == null)
                 return;
 
-            var idx = this.CurrentTab.IndexOf(anchorStatusId.Value);
+            var idx = this.CurrentTab.IndexOf(anchorStatusId);
             if (idx == -1)
                 return;
 
@@ -5878,12 +5229,16 @@ namespace OpenTween
             {
                 try
                 {
-                    var post = await this.tw.GetStatusApi(false, currentPost.StatusId);
+                    var post = await this.tw.GetStatusApi(false, currentPost.StatusId.ToTwitterStatusId());
 
-                    currentPost.InReplyToStatusId = post.InReplyToStatusId;
-                    currentPost.InReplyToUser = post.InReplyToUser;
-                    currentPost.IsReply = post.IsReply;
-                    this.PurgeListViewItemCache();
+                    currentPost = currentPost with
+                    {
+                        InReplyToStatusId = post.InReplyToStatusId,
+                        InReplyToUser = post.InReplyToUser,
+                        IsReply = post.IsReply,
+                    };
+                    curTabClass.ReplacePost(currentPost);
+                    this.listCache?.PurgeCache();
 
                     var index = curTabClass.SelectedIndex;
                     this.CurrentListView.RedrawItems(index, index, false);
@@ -5900,11 +5255,11 @@ namespace OpenTween
             {
                 this.replyChains = new Stack<ReplyChain>();
             }
-            this.replyChains.Push(new ReplyChain(currentPost.StatusId, currentPost.InReplyToStatusId.Value, curTabClass));
+            this.replyChains.Push(new ReplyChain(currentPost.StatusId, currentPost.InReplyToStatusId, curTabClass));
 
             int inReplyToIndex;
             string inReplyToTabName;
-            var inReplyToId = currentPost.InReplyToStatusId.Value;
+            var inReplyToId = currentPost.InReplyToStatusId;
             var inReplyToUser = currentPost.InReplyToUser;
 
             var inReplyToPosts = from tab in this.statuses.Tabs
@@ -5922,7 +5277,7 @@ namespace OpenTween
                 {
                     await Task.Run(async () =>
                     {
-                        var post = await this.tw.GetStatusApi(false, currentPost.InReplyToStatusId.Value)
+                        var post = await this.tw.GetStatusApi(false, currentPost.InReplyToStatusId.ToTwitterStatusId())
                             .ConfigureAwait(false);
                         post.IsRead = true;
 
@@ -5933,7 +5288,7 @@ namespace OpenTween
                 catch (WebApiException ex)
                 {
                     this.StatusLabel.Text = $"Err:{ex.Message}(GetStatus)";
-                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(inReplyToUser, inReplyToId));
+                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(inReplyToUser, inReplyToId.ToTwitterStatusId()));
                     return;
                 }
 
@@ -5942,7 +5297,7 @@ namespace OpenTween
                 inReplyPost = inReplyToPosts.FirstOrDefault();
                 if (inReplyPost == null)
                 {
-                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(inReplyToUser, inReplyToId));
+                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(inReplyToUser, inReplyToId.ToTwitterStatusId()));
                     return;
                 }
             }
@@ -6170,10 +5525,8 @@ namespace OpenTween
             }
         }
 
-        private bool GoStatus(long statusId)
+        private bool GoStatus(PostId statusId)
         {
-            if (statusId == 0) return false;
-
             var tab = this.statuses.Tabs
                 .Where(x => x.TabType != MyCommon.TabUsageType.DirectMessage)
                 .Where(x => x.Contains(statusId))
@@ -6194,10 +5547,8 @@ namespace OpenTween
             return true;
         }
 
-        private bool GoDirectMessage(long statusId)
+        private bool GoDirectMessage(PostId statusId)
         {
-            if (statusId == 0) return false;
-
             var tab = this.statuses.DirectMessageTab;
             var index = tab.IndexOf(statusId);
 
@@ -6281,10 +5632,6 @@ namespace OpenTween
             this.ModifySettingCommon = false;
             lock (this.syncObject)
             {
-                this.settings.Common.UserName = this.tw.Username;
-                this.settings.Common.UserId = this.tw.UserId;
-                this.settings.Common.Token = this.tw.AccessToken;
-                this.settings.Common.TokenSecret = this.tw.AccessTokenSecret;
                 this.settings.Common.SortOrder = (int)this.statuses.SortOrder;
                 this.settings.Common.SortColumn = this.statuses.SortMode switch
                 {
@@ -6307,8 +5654,8 @@ namespace OpenTween
                 this.settings.Common.HashIsHead = this.HashMgr.IsHead;
                 this.settings.Common.HashIsPermanent = this.HashMgr.IsPermanent;
                 this.settings.Common.HashIsNotAddToAtReply = this.HashMgr.IsNotAddToAtReply;
-                this.settings.Common.UseImageService = this.ImageSelector.ServiceIndex;
-                this.settings.Common.UseImageServiceName = this.ImageSelector.ServiceName;
+                this.settings.Common.UseImageService = this.ImageSelector.Model.SelectedMediaServiceIndex;
+                this.settings.Common.UseImageServiceName = this.ImageSelector.Model.SelectedMediaServiceName;
 
                 this.settings.SaveCommon();
             }
@@ -6361,6 +5708,7 @@ namespace OpenTween
                         break;
                     case UserTimelineTabModel userTab:
                         tabSetting.User = userTab.ScreenName;
+                        tabSetting.UserId = userTab.UserId;
                         break;
                     case PublicSearchTabModel searchTab:
                         tabSetting.SearchWords = searchTab.SearchWords;
@@ -6380,6 +5728,17 @@ namespace OpenTween
 
         private async void OpenURLFileMenuItem_Click(object sender, EventArgs e)
         {
+            static void ShowFormatErrorDialog(IWin32Window owner)
+            {
+                MessageBox.Show(
+                    owner,
+                    Properties.Resources.OpenURL_InvalidFormat,
+                    Properties.Resources.OpenURL_Caption,
+                    MessageBoxButtons.OK,
+                    MessageBoxIcon.Error
+                );
+            }
+
             var ret = InputDialog.Show(this, Properties.Resources.OpenURL_InputText, Properties.Resources.OpenURL_Caption, out var inputText);
             if (ret != DialogResult.OK)
                 return;
@@ -6387,20 +5746,19 @@ namespace OpenTween
             var match = Twitter.StatusUrlRegex.Match(inputText);
             if (!match.Success)
             {
-                MessageBox.Show(
-                    this,
-                    Properties.Resources.OpenURL_InvalidFormat,
-                    Properties.Resources.OpenURL_Caption,
-                    MessageBoxButtons.OK,
-                    MessageBoxIcon.Error);
+                ShowFormatErrorDialog(this);
                 return;
             }
 
             try
             {
-                var statusId = long.Parse(match.Groups["StatusId"].Value);
+                var statusId = new TwitterStatusId(match.Groups["StatusId"].Value);
                 await this.OpenRelatedTab(statusId);
             }
+            catch (OverflowException)
+            {
+                ShowFormatErrorDialog(this);
+            }
             catch (TabException ex)
             {
                 MessageBox.Show(this, ex.Message, ApplicationSettings.ApplicationName, MessageBoxButtons.OK, MessageBoxIcon.Error);
@@ -6442,7 +5800,7 @@ namespace OpenTween
                                  "\"" + post.TextFromApi.Replace("\n", "").Replace("\"", "\"\"") + "\"" + "\t" +
                                  post.CreatedAt.ToLocalTimeString() + "\t" +
                                  post.ScreenName + "\t" +
-                                 post.StatusId + "\t" +
+                                 post.StatusId.Id + "\t" +
                                  post.ImageUrl + "\t" +
                                  "\"" + post.Text.Replace("\n", "").Replace("\"", "\"\"") + "\"" + "\t" +
                                  protect);
@@ -6459,7 +5817,7 @@ namespace OpenTween
                                  "\"" + post.TextFromApi.Replace("\n", "").Replace("\"", "\"\"") + "\"" + "\t" +
                                  post.CreatedAt.ToLocalTimeString() + "\t" +
                                  post.ScreenName + "\t" +
-                                 post.StatusId + "\t" +
+                                 post.StatusId.Id + "\t" +
                                  post.ImageUrl + "\t" +
                                  "\"" + post.Text.Replace("\n", "").Replace("\"", "\"\"") + "\"" + "\t" +
                                  protect);
@@ -6500,6 +5858,10 @@ namespace OpenTween
 
                 this.statuses.RenameTab(origTabName, newTabName);
 
+                var state = this.listViewState[origTabName];
+                this.listViewState.Remove(origTabName);
+                this.listViewState[newTabName] = state;
+
                 this.SaveConfigsCommon();
                 this.SaveConfigsTabs();
                 this.rclickTabName = newTabName;
@@ -6609,6 +5971,11 @@ namespace OpenTween
 
             using (ControlTransaction.Layout(this.ListTab))
             {
+                // 選択中のタブを Remove メソッドで取り外すと選択状態が変化して Selecting イベントが発生するが、
+                // この時 TabInformations と TabControl の並び順が不一致なままで ListTabSelect メソッドが呼ばれてしまう。
+                // これを防ぐために、Remove メソッドを呼ぶ前に選択中のタブを切り替えておく必要がある
+                this.ListTab.SelectedIndex = targetIndex == 0 ? 1 : 0;
+
                 var tab = this.statuses.Tabs[targetIndex];
                 var tabPage = this.ListTab.TabPages[targetIndex];
 
@@ -6897,7 +6264,7 @@ namespace OpenTween
 
             if (this.CurrentTabName == tabName)
             {
-                this.PurgeListViewItemCache();
+                this.listCache?.PurgeCache();
                 this.CurrentListView.Refresh();
             }
 
@@ -7464,16 +6831,14 @@ namespace OpenTween
             if (this.CurrentTabName == tabName)
             {
                 this.CurrentTab.ClearAnchor();
-                this.PurgeListViewItemCache();
+                this.listCache?.PurgeCache();
+                this.listCache?.UpdateListSize();
             }
 
             var tabIndex = this.statuses.Tabs.IndexOf(tabName);
             var tabPage = this.ListTab.TabPages[tabIndex];
             tabPage.ImageIndex = -1;
 
-            var listView = (DetailsListView)tabPage.Tag;
-            listView.VirtualListSize = 0;
-
             if (!this.settings.Common.TabIconDisp) this.ListTab.Refresh();
 
             this.SetMainWindowTitle();
@@ -7509,8 +6874,8 @@ namespace OpenTween
                     ttl.Append("Ver:").Append(MyCommon.GetReadableVersion());
                     break;
                 case MyCommon.DispTitleEnum.Post:
-                    if (this.history != null && this.history.Count > 1)
-                        ttl.Append(this.history[this.history.Count - 2].Status.Replace("\r\n", " "));
+                    if (this.history.Peek() is { } lastItem)
+                        ttl.Append(lastItem.Status.Replace("\r\n", " "));
                     break;
                 case MyCommon.DispTitleEnum.UnreadRepCount:
                     ttl.AppendFormat(Properties.Resources.SetMainWindowTitleText1, this.statuses.MentionTab.UnreadCount + this.statuses.DirectMessageTab.UnreadCount);
@@ -7619,6 +6984,8 @@ namespace OpenTween
 
             if (endpointName == null)
             {
+                var authByCookie = this.tw.Api.AuthType == APIAuthType.TwitterComCookie;
+
                 // 表示中のタブに応じて更新
                 endpointName = tabType switch
                 {
@@ -7627,9 +6994,12 @@ namespace OpenTween
                     MyCommon.TabUsageType.Mentions => "/statuses/mentions_timeline",
                     MyCommon.TabUsageType.Favorites => "/favorites/list",
                     MyCommon.TabUsageType.DirectMessage => "/direct_messages/events/list",
-                    MyCommon.TabUsageType.UserTimeline => "/statuses/user_timeline",
-                    MyCommon.TabUsageType.Lists => "/lists/statuses",
-                    MyCommon.TabUsageType.PublicSearch => "/search/tweets",
+                    MyCommon.TabUsageType.UserTimeline =>
+                        authByCookie ? UserTweetsAndRepliesRequest.EndpointName : "/statuses/user_timeline",
+                    MyCommon.TabUsageType.Lists =>
+                        authByCookie ? ListLatestTweetsTimelineRequest.EndpointName : "/lists/statuses",
+                    MyCommon.TabUsageType.PublicSearch =>
+                        authByCookie ? SearchTimelineRequest.EndpointName : "/search/tweets",
                     MyCommon.TabUsageType.Related => "/statuses/show/:id",
                     _ => null,
                 };
@@ -7637,23 +7007,8 @@ namespace OpenTween
             }
             else
             {
-                // 表示中のタブに関連する endpoint であれば更新
-                var update = endpointName switch
-                {
-                    "/statuses/home_timeline" => tabType == MyCommon.TabUsageType.Home || tabType == MyCommon.TabUsageType.UserDefined,
-                    "/statuses/mentions_timeline" => tabType == MyCommon.TabUsageType.Mentions,
-                    "/favorites/list" => tabType == MyCommon.TabUsageType.Favorites,
-                    "/direct_messages/events/list" => tabType == MyCommon.TabUsageType.DirectMessage,
-                    "/statuses/user_timeline" => tabType == MyCommon.TabUsageType.UserTimeline,
-                    "/lists/statuses" => tabType == MyCommon.TabUsageType.Lists,
-                    "/search/tweets" => tabType == MyCommon.TabUsageType.PublicSearch,
-                    "/statuses/show/:id" => tabType == MyCommon.TabUsageType.Related,
-                    _ => false,
-                };
-                if (update)
-                {
-                    this.toolStripApiGauge.ApiEndpoint = endpointName;
-                }
+                var currentEndpointName = this.toolStripApiGauge.ApiEndpoint;
+                this.toolStripApiGauge.ApiEndpoint = currentEndpointName;
             }
         }
 
@@ -7759,56 +7114,56 @@ namespace OpenTween
             {
                 this.Visible = false;
             }
-            if (this.initialLayout && this.settings.Local != null && this.WindowState == FormWindowState.Normal && this.Visible)
+            if (this.WindowState != FormWindowState.Minimized)
             {
-                // 現在の DPI と設定保存時の DPI との比を取得する
-                var configScaleFactor = this.settings.Local.GetConfigScaleFactor(this.CurrentAutoScaleDimensions);
+                this.formWindowState = this.WindowState;
+            }
+        }
 
-                this.ClientSize = ScaleBy(configScaleFactor, this.settings.Local.FormSize);
+        private void ApplyLayoutFromSettings()
+        {
+            // 現在の DPI と設定保存時の DPI との比を取得する
+            var configScaleFactor = this.settings.Local.GetConfigScaleFactor(this.CurrentAutoScaleDimensions);
 
-                // Splitterの位置設定
-                var splitterDistance = ScaleBy(configScaleFactor.Height, this.settings.Local.SplitterDistance);
-                if (splitterDistance > this.SplitContainer1.Panel1MinSize &&
-                    splitterDistance < this.SplitContainer1.Height - this.SplitContainer1.Panel2MinSize - this.SplitContainer1.SplitterWidth)
-                {
-                    this.SplitContainer1.SplitterDistance = splitterDistance;
-                }
+            this.ClientSize = ScaleBy(configScaleFactor, this.settings.Local.FormSize);
 
-                // 発言欄複数行
-                this.StatusText.Multiline = this.settings.Local.StatusMultiline;
-                if (this.StatusText.Multiline)
-                {
-                    var statusTextHeight = ScaleBy(configScaleFactor.Height, this.settings.Local.StatusTextHeight);
-                    var dis = this.SplitContainer2.Height - statusTextHeight - this.SplitContainer2.SplitterWidth;
-                    if (dis > this.SplitContainer2.Panel1MinSize && dis < this.SplitContainer2.Height - this.SplitContainer2.Panel2MinSize - this.SplitContainer2.SplitterWidth)
-                    {
-                        this.SplitContainer2.SplitterDistance = this.SplitContainer2.Height - statusTextHeight - this.SplitContainer2.SplitterWidth;
-                    }
-                    this.StatusText.Height = statusTextHeight;
-                }
-                else
+            // Splitterの位置設定
+            var splitterDistance = ScaleBy(configScaleFactor.Height, this.settings.Local.SplitterDistance);
+            if (splitterDistance > this.SplitContainer1.Panel1MinSize &&
+                splitterDistance < this.SplitContainer1.Height - this.SplitContainer1.Panel2MinSize - this.SplitContainer1.SplitterWidth)
+            {
+                this.SplitContainer1.SplitterDistance = splitterDistance;
+            }
+
+            // 発言欄複数行
+            this.StatusText.Multiline = this.settings.Local.StatusMultiline;
+            if (this.StatusText.Multiline)
+            {
+                var statusTextHeight = ScaleBy(configScaleFactor.Height, this.settings.Local.StatusTextHeight);
+                var dis = this.SplitContainer2.Height - statusTextHeight - this.SplitContainer2.SplitterWidth;
+                if (dis > this.SplitContainer2.Panel1MinSize && dis < this.SplitContainer2.Height - this.SplitContainer2.Panel2MinSize - this.SplitContainer2.SplitterWidth)
                 {
-                    if (this.SplitContainer2.Height - this.SplitContainer2.Panel2MinSize - this.SplitContainer2.SplitterWidth > 0)
-                    {
-                        this.SplitContainer2.SplitterDistance = this.SplitContainer2.Height - this.SplitContainer2.Panel2MinSize - this.SplitContainer2.SplitterWidth;
-                    }
+                    this.SplitContainer2.SplitterDistance = this.SplitContainer2.Height - statusTextHeight - this.SplitContainer2.SplitterWidth;
                 }
-
-                var previewDistance = ScaleBy(configScaleFactor.Width, this.settings.Local.PreviewDistance);
-                if (previewDistance > this.SplitContainer3.Panel1MinSize && previewDistance < this.SplitContainer3.Width - this.SplitContainer3.Panel2MinSize - this.SplitContainer3.SplitterWidth)
+                this.StatusText.Height = statusTextHeight;
+            }
+            else
+            {
+                if (this.SplitContainer2.Height - this.SplitContainer2.Panel2MinSize - this.SplitContainer2.SplitterWidth > 0)
                 {
-                    this.SplitContainer3.SplitterDistance = previewDistance;
+                    this.SplitContainer2.SplitterDistance = this.SplitContainer2.Height - this.SplitContainer2.Panel2MinSize - this.SplitContainer2.SplitterWidth;
                 }
-
-                // Panel2Collapsed は SplitterDistance の設定を終えるまで true にしない
-                this.SplitContainer3.Panel2Collapsed = true;
-
-                this.initialLayout = false;
             }
-            if (this.WindowState != FormWindowState.Minimized)
+
+            var previewDistance = ScaleBy(configScaleFactor.Width, this.settings.Local.PreviewDistance);
+            if (previewDistance > this.SplitContainer3.Panel1MinSize && previewDistance < this.SplitContainer3.Width - this.SplitContainer3.Panel2MinSize - this.SplitContainer3.SplitterWidth)
             {
-                this.formWindowState = this.WindowState;
+                this.SplitContainer3.SplitterDistance = previewDistance;
             }
+
+            // Panel2Collapsed は SplitterDistance の設定を終えるまで true にしない
+            this.SplitContainer3.Panel2Collapsed = true;
+            this.initialLayout = false;
         }
 
         private void PlaySoundMenuItem_CheckedChanged(object sender, EventArgs e)
@@ -7858,10 +7213,10 @@ namespace OpenTween
             {
                 if (MyCommon.IsKeyDown(Keys.Shift))
                 {
-                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(currentPost.InReplyToUser, currentPost.InReplyToStatusId.Value));
+                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(currentPost.InReplyToUser, currentPost.InReplyToStatusId.ToTwitterStatusId()));
                     return;
                 }
-                if (this.statuses.Posts.TryGetValue(currentPost.InReplyToStatusId.Value, out var repPost))
+                if (this.statuses.Posts.TryGetValue(currentPost.InReplyToStatusId, out var repPost))
                 {
                     MessageBox.Show($"{repPost.ScreenName} / {repPost.Nickname}   ({repPost.CreatedAt.ToLocalTimeString()})" + Environment.NewLine + repPost.TextFromApi);
                 }
@@ -7869,12 +7224,12 @@ namespace OpenTween
                 {
                     foreach (var tb in this.statuses.GetTabsByType(MyCommon.TabUsageType.Lists | MyCommon.TabUsageType.PublicSearch))
                     {
-                        if (tb == null || !tb.Contains(currentPost.InReplyToStatusId.Value)) break;
-                        repPost = tb.Posts[currentPost.InReplyToStatusId.Value];
+                        if (tb == null || !tb.Contains(currentPost.InReplyToStatusId)) break;
+                        repPost = tb.Posts[currentPost.InReplyToStatusId];
                         MessageBox.Show($"{repPost.ScreenName} / {repPost.Nickname}   ({repPost.CreatedAt.ToLocalTimeString()})" + Environment.NewLine + repPost.TextFromApi);
                         return;
                     }
-                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(currentPost.InReplyToUser, currentPost.InReplyToStatusId.Value));
+                    await MyCommon.OpenInBrowserAsync(this, MyCommon.GetStatusUrl(currentPost.InReplyToUser, currentPost.InReplyToStatusId.ToTwitterStatusId()));
                 }
             }
         }
@@ -8436,7 +7791,7 @@ namespace OpenTween
             var match = Regex.Match(uri.AbsolutePath, @"^/status/(\d+)$");
             if (match.Success)
             {
-                var statusId = long.Parse(match.Groups[1].Value);
+                var statusId = new TwitterStatusId(match.Groups[1].Value);
                 await this.OpenRelatedTab(statusId);
                 return;
             }
@@ -8446,14 +7801,23 @@ namespace OpenTween
         {
             this.SetListProperty();
 
-            this.PurgeListViewItemCache();
+            var previousTabName = this.CurrentTabName;
+            if (this.listViewState.TryGetValue(previousTabName, out var previousListViewState))
+                previousListViewState.Save(this.ListLockMenuItem.Checked);
+
+            this.listCache?.PurgeCache();
 
             this.statuses.SelectTab(tabPage.Text);
 
+            this.InitializeTimelineListView();
+
+            var tab = this.CurrentTab;
+            tab.ClearAnchor();
+
             var listView = this.CurrentListView;
 
-            this.InitializeTimelineListView();
-            this.CurrentTab.ClearAnchor();
+            var currentListViewState = this.listViewState[tab.TabName];
+            currentListViewState.Restore(forceScroll: true);
 
             if (this.Use2ColumnsMode)
             {
@@ -8473,12 +7837,16 @@ namespace OpenTween
             var listView = this.CurrentListView;
             var tab = this.CurrentTab;
 
-            var newDrawer = new TimelineListViewDrawer(listView, tab, this.iconCache, this.themeManager)
-            {
-                IconSize = this.settings.Common.IconSize,
-            };
+            var newCache = new TimelineListViewCache(listView, tab, this.settings.Common);
+            (this.listCache, var oldCache) = (newCache, this.listCache);
+            oldCache?.Dispose();
+
+            var newDrawer = new TimelineListViewDrawer(listView, tab, this.listCache, this.iconCache, this.themeManager);
             (this.listDrawer, var oldDrawer) = (newDrawer, this.listDrawer);
             oldDrawer?.Dispose();
+
+            newDrawer.IconSize = this.settings.Common.IconSize;
+            newDrawer.UpdateItemHeight();
         }
 
         private void ListTab_Selecting(object sender, TabControlCancelEventArgs e)
@@ -8508,65 +7876,42 @@ namespace OpenTween
             if (flg) lView.Invalidate(bnd);
         }
 
-        private void SelectListItem(DetailsListView lView, int[]? index, int focusedIndex, int selectionMarkIndex)
+        private async void TweenMain_Shown(object sender, EventArgs e)
         {
-            // 複数
-            var bnd = new Rectangle();
-            var flg = false;
-            var item = lView.FocusedItem;
-            if (item != null)
-            {
-                bnd = item.Bounds;
-                flg = true;
-            }
+            this.NotifyIcon1.Visible = true;
+            this.StartTimers();
 
-            if (index != null)
-            {
-                lView.SelectItems(index);
-            }
-            if (selectionMarkIndex > -1 && lView.VirtualListSize > selectionMarkIndex)
-            {
-                lView.SelectionMark = selectionMarkIndex;
-            }
-            if (focusedIndex > -1 && lView.VirtualListSize > focusedIndex)
-            {
-                lView.Items[focusedIndex].Focused = true;
-            }
-            else if (index != null && index.Length != 0)
+            if (this.settings.IsFirstRun)
             {
-                lView.Items[index.Last()].Focused = true;
+                // 初回起動時だけ右下のメニューを目立たせる
+                this.HashStripSplitButton.ShowDropDown();
             }
 
-            if (flg) lView.Invalidate(bnd);
-        }
-
-        private async void TweenMain_Shown(object sender, EventArgs e)
-        {
-            this.NotifyIcon1.Visible = true;
-
             if (this.IsNetworkAvailable())
             {
-                var loadTasks = new List<Task>
-                {
-                    this.RefreshMuteUserIdsAsync(),
-                    this.RefreshBlockIdsAsync(),
-                    this.RefreshNoRetweetIdsAsync(),
-                    this.RefreshTwitterConfigurationAsync(),
-                    this.RefreshTabAsync<HomeTabModel>(),
-                    this.RefreshTabAsync<MentionsTabModel>(),
-                    this.RefreshTabAsync<DirectMessagesTabModel>(),
-                    this.RefreshTabAsync<PublicSearchTabModel>(),
-                    this.RefreshTabAsync<UserTimelineTabModel>(),
-                    this.RefreshTabAsync<ListTimelineTabModel>(),
-                };
+                var loadTasks = new TaskCollection();
+
+                loadTasks.Add(new[]
+                {
+                    this.RefreshMuteUserIdsAsync,
+                    this.RefreshBlockIdsAsync,
+                    this.RefreshNoRetweetIdsAsync,
+                    this.RefreshTwitterConfigurationAsync,
+                    this.RefreshTabAsync<HomeTabModel>,
+                    this.RefreshTabAsync<MentionsTabModel>,
+                    this.RefreshTabAsync<DirectMessagesTabModel>,
+                    this.RefreshTabAsync<PublicSearchTabModel>,
+                    this.RefreshTabAsync<UserTimelineTabModel>,
+                    this.RefreshTabAsync<ListTimelineTabModel>,
+                });
 
                 if (this.settings.Common.StartupFollowers)
-                    loadTasks.Add(this.RefreshFollowerIdsAsync());
+                    loadTasks.Add(this.RefreshFollowerIdsAsync);
 
                 if (this.settings.Common.GetFav)
-                    loadTasks.Add(this.RefreshTabAsync<FavoritesTabModel>());
+                    loadTasks.Add(this.RefreshTabAsync<FavoritesTabModel>);
 
-                var allTasks = Task.WhenAll(loadTasks);
+                var allTasks = loadTasks.RunAll();
 
                 var i = 0;
                 while (true)
@@ -8606,23 +7951,31 @@ namespace OpenTween
                 }
 
                 // 取得失敗の場合は再試行する
-                var reloadTasks = new List<Task>();
+                var reloadTasks = new TaskCollection();
 
                 if (!this.tw.GetFollowersSuccess && this.settings.Common.StartupFollowers)
-                    reloadTasks.Add(this.RefreshFollowerIdsAsync());
+                    reloadTasks.Add(() => this.RefreshFollowerIdsAsync());
 
                 if (!this.tw.GetNoRetweetSuccess)
-                    reloadTasks.Add(this.RefreshNoRetweetIdsAsync());
+                    reloadTasks.Add(() => this.RefreshNoRetweetIdsAsync());
 
                 if (this.tw.Configuration.PhotoSizeLimit == 0)
-                    reloadTasks.Add(this.RefreshTwitterConfigurationAsync());
+                    reloadTasks.Add(() => this.RefreshTwitterConfigurationAsync());
 
-                await Task.WhenAll(reloadTasks);
+                await reloadTasks.RunAll();
             }
 
             this.initial = false;
+        }
+
+        private void StartTimers()
+        {
+            if (!this.StopRefreshAllMenuItem.Checked)
+                this.timelineScheduler.Enabled = true;
 
-            this.timelineScheduler.Enabled = true;
+            this.selectionDebouncer.Enabled = true;
+            this.saveConfigDebouncer.Enabled = true;
+            this.thumbGenerator.ImgAzyobuziNet.AutoUpdate = true;
         }
 
         private async Task DoGetFollowersMenu()
@@ -8698,18 +8051,17 @@ namespace OpenTween
         {
             if (!this.ExistCurrentPost) return;
             this.doFavRetweetFlags = true;
-            var retweetTask = this.DoReTweetOfficial(true);
+
+            var tasks = new TaskCollection();
+            tasks.Add(() => this.DoReTweetOfficial(true));
+
             if (this.doFavRetweetFlags)
             {
                 this.doFavRetweetFlags = false;
-                var favoriteTask = this.FavoriteChange(true, false);
-
-                await Task.WhenAll(retweetTask, favoriteTask);
-            }
-            else
-            {
-                await retweetTask;
+                tasks.Add(() => this.FavoriteChange(true, false));
             }
+
+            await tasks.RunAll();
         }
 
         private async Task FavoritesRetweetUnofficial()
@@ -8740,13 +8092,13 @@ namespace OpenTween
             // TweetFormatterクラスによって整形された状態のHTMLを元のツイートに復元します
 
             // 通常の URL
-            statusHtml = Regex.Replace(statusHtml, "<a href=\"(?<href>.+?)\" title=\"(?<title>.+?)\">(?<text>.+?)</a>", "${title}");
+            statusHtml = Regex.Replace(statusHtml, """<a href="(?<href>.+?)" title="(?<title>.+?)">(?<text>.+?)</a>""", "${title}");
             // メンション
-            statusHtml = Regex.Replace(statusHtml, "<a class=\"mention\" href=\"(?<href>.+?)\">(?<text>.+?)</a>", "${text}");
+            statusHtml = Regex.Replace(statusHtml, """<a class="mention" href="(?<href>.+?)">(?<text>.+?)</a>""", "${text}");
             // ハッシュタグ
-            statusHtml = Regex.Replace(statusHtml, "<a class=\"hashtag\" href=\"(?<href>.+?)\">(?<text>.+?)</a>", "${text}");
+            statusHtml = Regex.Replace(statusHtml, """<a class="hashtag" href="(?<href>.+?)">(?<text>.+?)</a>""", "${text}");
             // 絵文字
-            statusHtml = Regex.Replace(statusHtml, "<img class=\"emoji\" src=\".+?\" alt=\"(?<text>.+?)\" />", "${text}");
+            statusHtml = Regex.Replace(statusHtml, """<img class="emoji" src=".+?" alt="(?<text>.+?)" />""", "${text}");
 
             // <br> 除去
             if (multiline)
@@ -8771,14 +8123,14 @@ namespace OpenTween
 
         private void MenuItemHelp_DropDownOpening(object sender, EventArgs e)
         {
-            if (MyCommon.DebugBuild || MyCommon.IsKeyDown(Keys.CapsLock, Keys.Control, Keys.Shift))
+            if (MyCommon.DebugBuild || MyCommon.IsKeyDown(Keys.CapsLock | Keys.Control | Keys.Shift))
                 this.DebugModeToolStripMenuItem.Visible = true;
             else
                 this.DebugModeToolStripMenuItem.Visible = false;
         }
 
         private void UrlMultibyteSplitMenuItem_CheckedChanged(object sender, EventArgs e)
-            => this.urlMultibyteSplit = ((ToolStripMenuItem)sender).Checked;
+            => this.SeparateUrlAndFullwidthCharacter = ((ToolStripMenuItem)sender).Checked;
 
         private void PreventSmsCommandMenuItem_CheckedChanged(object sender, EventArgs e)
             => this.preventSmsCommand = ((ToolStripMenuItem)sender).Checked;
@@ -8800,7 +8152,7 @@ namespace OpenTween
 
         private void PostModeMenuItem_DropDownOpening(object sender, EventArgs e)
         {
-            this.UrlMultibyteSplitMenuItem.Checked = this.urlMultibyteSplit;
+            this.UrlMultibyteSplitMenuItem.Checked = this.SeparateUrlAndFullwidthCharacter;
             this.PreventSmsCommandMenuItem.Checked = this.preventSmsCommand;
             this.UrlAutoShortenMenuItem.Checked = this.settings.Common.UrlConvertAuto;
             this.IdeographicSpaceToSpaceMenuItem.Checked = this.settings.Common.WideSpaceConvert;
@@ -8810,7 +8162,7 @@ namespace OpenTween
 
         private void ContextMenuPostMode_Opening(object sender, CancelEventArgs e)
         {
-            this.UrlMultibyteSplitPullDownMenuItem.Checked = this.urlMultibyteSplit;
+            this.UrlMultibyteSplitPullDownMenuItem.Checked = this.SeparateUrlAndFullwidthCharacter;
             this.PreventSmsCommandPullDownMenuItem.Checked = this.preventSmsCommand;
             this.UrlAutoShortenPullDownMenuItem.Checked = this.settings.Common.UrlConvertAuto;
             this.IdeographicSpaceToSpacePullDownMenuItem.Checked = this.settings.Common.WideSpaceConvert;
@@ -9225,9 +8577,9 @@ namespace OpenTween
                 cmb.Items.Insert(0, tb.SearchWords);
                 cmb.Text = tb.SearchWords;
                 cmb.SelectAll();
-                this.PurgeListViewItemCache();
-                listView.VirtualListSize = 0;
                 this.statuses.ClearTabIds(tbName);
+                this.listCache?.PurgeCache();
+                this.listCache?.UpdateListSize();
                 this.SaveConfigsTabs();   // 検索条件の保存
             }
 
@@ -9249,83 +8601,19 @@ namespace OpenTween
 
         private void UndoRemoveTabMenuItem_Click(object sender, EventArgs e)
         {
-            if (this.statuses.RemovedTab.Count == 0)
-            {
-                MessageBox.Show("There isn't removed tab.", "Undo", MessageBoxButtons.OK, MessageBoxIcon.Information);
-                return;
-            }
-            else
+            try
             {
-                DetailsListView? listView;
-
-                var tb = this.statuses.RemovedTab.Pop();
-                if (tb.TabType == MyCommon.TabUsageType.Related)
-                {
-                    var relatedTab = this.statuses.GetTabByType(MyCommon.TabUsageType.Related);
-                    if (relatedTab != null)
-                    {
-                        // 関連発言なら既存のタブを置き換える
-                        tb.TabName = relatedTab.TabName;
-                        this.ClearTab(tb.TabName, false);
-
-                        this.statuses.ReplaceTab(tb);
-
-                        var tabIndex = this.statuses.Tabs.IndexOf(tb);
-                        var tabPage = this.ListTab.TabPages[tabIndex];
-                        listView = (DetailsListView)tabPage.Tag;
-                        this.ListTab.SelectedIndex = tabIndex;
-                    }
-                    else
-                    {
-                        const string TabName = "Related Tweets";
-                        var renamed = TabName;
-                        for (var i = 2; i <= 100; i++)
-                        {
-                            if (!this.statuses.ContainsTab(renamed))
-                                break;
-                            renamed = TabName + i;
-                        }
-                        tb.TabName = renamed;
-
-                        this.statuses.AddTab(tb);
-                        this.AddNewTab(tb, startup: false);
-
-                        var tabIndex = this.statuses.Tabs.Count - 1;
-                        var tabPage = this.ListTab.TabPages[tabIndex];
-
-                        listView = (DetailsListView)tabPage.Tag;
-                        this.ListTab.SelectedIndex = tabIndex;
-                    }
-                }
-                else
-                {
-                    var renamed = tb.TabName;
-                    for (var i = 1; i < int.MaxValue; i++)
-                    {
-                        if (!this.statuses.ContainsTab(renamed))
-                            break;
-                        renamed = tb.TabName + "(" + i + ")";
-                    }
-                    tb.TabName = renamed;
-
-                    this.statuses.AddTab(tb);
-                    this.AddNewTab(tb, startup: false);
+                var restoredTab = this.statuses.UndoRemovedTab();
+                this.AddNewTab(restoredTab, startup: false);
 
-                    var tabIndex = this.statuses.Tabs.Count - 1;
-                    var tabPage = this.ListTab.TabPages[tabIndex];
+                var tabIndex = this.statuses.Tabs.Count - 1;
+                this.ListTab.SelectedIndex = tabIndex;
 
-                    listView = (DetailsListView)tabPage.Tag;
-                    this.ListTab.SelectedIndex = tabIndex;
-                }
                 this.SaveConfigsTabs();
-
-                if (listView != null)
-                {
-                    using (ControlTransaction.Update(listView))
-                    {
-                        listView.VirtualListSize = tb.AllCount;
-                    }
-                }
+            }
+            catch (TabException ex)
+            {
+                MessageBox.Show(this, ex.Message, ApplicationSettings.ApplicationName, MessageBoxButtons.OK, MessageBoxIcon.Error);
             }
         }
 
@@ -9575,14 +8863,7 @@ namespace OpenTween
 
         private void MenuItemEdit_DropDownOpening(object sender, EventArgs e)
         {
-            if (this.statuses.RemovedTab.Count == 0)
-            {
-                this.UndoRemoveTabMenuItem.Enabled = false;
-            }
-            else
-            {
-                this.UndoRemoveTabMenuItem.Enabled = true;
-            }
+            this.UndoRemoveTabMenuItem.Enabled = this.statuses.CanUndoRemovedTab;
 
             if (this.CurrentTab.TabType == MyCommon.TabUsageType.PublicSearch)
                 this.PublicSearchQueryMenuItem.Enabled = true;
@@ -9638,7 +8919,7 @@ namespace OpenTween
 
                 try
                 {
-                    var task = this.tw.Api.UsersShow(id);
+                    var task = this.tw.GetUserInfo(id);
                     user = await dialog.WaitForAsync(this, task);
                 }
                 catch (WebApiException ex)
@@ -9657,7 +8938,7 @@ namespace OpenTween
 
         private async Task DoShowUserStatus(TwitterUser user)
         {
-            using var userDialog = new UserInfoDialog(this, this.tw.Api);
+            using var userDialog = new UserInfoDialog(this, this.tw.Api, this.detailsHtmlBuilder);
             var showUserTask = userDialog.ShowUserAsync(user);
             userDialog.ShowDialog(this);
 
@@ -9707,7 +8988,7 @@ namespace OpenTween
 
                 try
                 {
-                    var task = this.tw.Api.StatusesShow(statusId);
+                    var task = this.tw.Api.StatusesShow(statusId.ToTwitterStatusId());
                     status = await dialog.WaitForAsync(this, task);
                 }
                 catch (WebApiException ex)
@@ -9755,7 +9036,7 @@ namespace OpenTween
 
         private void SelectMedia_DragEnter(DragEventArgs e)
         {
-            if (this.ImageSelector.HasUploadableService(((string[])e.Data.GetData(DataFormats.FileDrop, false))[0], true))
+            if (this.ImageSelector.Model.HasUploadableService(((string[])e.Data.GetData(DataFormats.FileDrop, false))[0], true))
             {
                 e.Effect = DragDropEffects.Copy;
                 return;
@@ -9767,7 +9048,10 @@ namespace OpenTween
         {
             this.Activate();
             this.BringToFront();
-            this.ImageSelector.BeginSelection((string[])e.Data.GetData(DataFormats.FileDrop, false));
+
+            var filePathArray = (string[])e.Data.GetData(DataFormats.FileDrop, false);
+            this.ImageSelector.BeginSelection();
+            this.ImageSelector.Model.AddMediaItemFromFilePath(filePathArray);
             this.StatusText.Focus();
         }
 
@@ -9816,23 +9100,16 @@ namespace OpenTween
                 }
                 else if (Clipboard.ContainsImage())
                 {
-                    // 画像があるので投稿処理を行う
-                    if (MessageBox.Show(Properties.Resources.PostPictureConfirm3,
-                                       Properties.Resources.PostPictureWarn4,
-                                       MessageBoxButtons.OKCancel,
-                                       MessageBoxIcon.Question,
-                                       MessageBoxDefaultButton.Button2)
-                                   == DialogResult.OK)
-                    {
-                        // clipboardから画像を取得
-                        using var image = Clipboard.GetImage();
-                        this.ImageSelector.BeginSelection(image);
-                    }
+                    // clipboardから画像を取得
+                    using var image = Clipboard.GetImage();
+                    this.ImageSelector.BeginSelection();
+                    this.ImageSelector.Model.AddMediaItemFromImage(image);
                 }
                 else if (Clipboard.ContainsFileDropList())
                 {
                     var files = Clipboard.GetFileDropList().Cast<string>().ToArray();
-                    this.ImageSelector.BeginSelection(files);
+                    this.ImageSelector.BeginSelection();
+                    this.ImageSelector.Model.AddMediaItemFromFilePath(files);
                 }
             }
             catch (ExternalException ex)
@@ -9902,14 +9179,14 @@ namespace OpenTween
         /// </summary>
         /// <param name="statusId">表示するツイートのID</param>
         /// <exception cref="TabException">名前の重複が多すぎてタブを作成できない場合</exception>
-        public async Task OpenRelatedTab(long statusId)
+        public async Task OpenRelatedTab(PostId statusId)
         {
             var post = this.statuses[statusId];
             if (post == null)
             {
                 try
                 {
-                    post = await this.tw.GetStatusApi(false, statusId);
+                    post = await this.tw.GetStatusApi(false, statusId.ToTwitterStatusId());
                 }
                 catch (WebApiException ex)
                 {
@@ -10055,7 +9332,7 @@ namespace OpenTween
             if (curTimeOffset != prevTimeOffset)
             {
                 // タイムゾーンの変更を反映
-                this.PurgeListViewItemCache();
+                this.listCache?.PurgeCache();
                 this.CurrentListView.Refresh();
 
                 this.DispSelectedPost(forceupdate: true);
@@ -10074,7 +9351,7 @@ namespace OpenTween
 
         private async Task OpenUserAppointUrl()
         {
-            if (this.settings.Common.UserAppointUrl != null)
+            if (!MyCommon.IsNullOrEmpty(this.settings.Common.UserAppointUrl))
             {
                 if (this.settings.Common.UserAppointUrl.Contains("{ID}") || this.settings.Common.UserAppointUrl.Contains("{STATUS}"))
                 {
@@ -10085,7 +9362,7 @@ namespace OpenTween
                         xUrl = xUrl.Replace("{ID}", post.ScreenName);
 
                         var statusId = post.RetweetedId ?? post.StatusId;
-                        xUrl = xUrl.Replace("{STATUS}", statusId.ToString());
+                        xUrl = xUrl.Replace("{STATUS}", statusId.Id);
 
                         await MyCommon.OpenInBrowserAsync(this, xUrl);
                     }
@@ -10112,11 +9389,11 @@ namespace OpenTween
                     this.BringToFront();
                     if (e.NotifyType == GrowlHelper.NotifyType.DirectMessage)
                     {
-                        if (!this.GoDirectMessage(e.StatusId)) this.StatusText.Focus();
+                        if (!this.GoDirectMessage(new TwitterStatusId(e.StatusId))) this.StatusText.Focus();
                     }
                     else
                     {
-                        if (!this.GoStatus(e.StatusId)) this.StatusText.Focus();
+                        if (!this.GoStatus(new TwitterStatusId(e.StatusId))) this.StatusText.Focus();
                     }
                 });
             }
@@ -10128,22 +9405,6 @@ namespace OpenTween
             this.AboutMenuItem.Text = MyCommon.ReplaceAppName(this.AboutMenuItem.Text);
         }
 
-        private void TweetThumbnail_ThumbnailLoading(object sender, EventArgs e)
-            => this.SplitContainer3.Panel2Collapsed = false;
-
-        private async void TweetThumbnail_ThumbnailDoubleClick(object sender, ThumbnailDoubleClickEventArgs e)
-            => await this.OpenThumbnailPicture(e.Thumbnail);
-
-        private async void TweetThumbnail_ThumbnailImageSearchClick(object sender, ThumbnailImageSearchEventArgs e)
-            => await MyCommon.OpenInBrowserAsync(this, e.ImageUrl);
-
-        private async Task OpenThumbnailPicture(ThumbnailInfo thumbnail)
-        {
-            var url = thumbnail.FullSizeImageUrl ?? thumbnail.MediaPageUrl;
-
-            await MyCommon.OpenInBrowserAsync(this, url);
-        }
-
         private async void TwitterApiStatusToolStripMenuItem_Click(object sender, EventArgs e)
             => await MyCommon.OpenInBrowserAsync(this, Twitter.ServiceAvailabilityStatusUrl);