diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7de4ede0..ad475e3ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,7 @@ jobs: $altCoverPath = "$($env:NUGET_PACKAGES)\altcover\$($altCoverVersion)\tools\$($targetFramework)\AltCover.exe" $xunitPath = "$($env:NUGET_PACKAGES)\xunit.runner.console\$($xunitVersion)\tools\$($targetFramework)\xunit.console.exe" - Start-Process ` + $p = Start-Process ` -FilePath $altCoverPath ` -ArgumentList ( '--inputDirectory', @@ -105,9 +105,14 @@ jobs: '--visibleBranches' ) ` -NoNewWindow ` + -PassThru ` -Wait - Start-Process ` + if ($p.ExitCode -ne 0) { + exit $p.ExitCode + } + + $p = Start-Process ` -FilePath $altCoverPath ` -ArgumentList ( 'runner', @@ -119,8 +124,13 @@ jobs: '.\__Instrumented\OpenTween.Tests.dll' ) ` -NoNewWindow ` + -PassThru ` -Wait + if ($p.ExitCode -ne 0) { + exit $p.ExitCode + } + - name: Upload test results to codecov shell: pwsh run: | diff --git a/OpenTween.Tests/Models/PostClassTest.cs b/OpenTween.Tests/Models/PostClassTest.cs index eebcf63e4..05003ee87 100644 --- a/OpenTween.Tests/Models/PostClassTest.cs +++ b/OpenTween.Tests/Models/PostClassTest.cs @@ -57,7 +57,7 @@ protected override PostClass RetweetSource { get { - var retweetedId = this.RetweetedId!.Value; + var retweetedId = this.RetweetedId; var group = this.Group; if (group == null) throw new InvalidOperationException("TestPostClass needs group"); @@ -115,8 +115,8 @@ public void SetIsFavTest(long statusId, bool isFav) post.IsFav = isFav; Assert.Equal(isFav, post.IsFav); - if (post.RetweetedId != null) - Assert.Equal(isFav, this.postGroup[post.RetweetedId.Value].IsFav); + if (post.IsRetweet) + Assert.Equal(isFav, this.postGroup[post.RetweetedId].IsFav); } #pragma warning disable SA1008 // Opening parenthesis should be spaced correctly @@ -144,9 +144,10 @@ public void StateIndexTest(bool protect, bool mark, bool reply, bool geo, int ex { IsProtect = protect, IsMark = mark, - InReplyToStatusId = reply ? (long?)100L : null, PostGeo = geo ? new PostClass.StatusGeo(-126.716667, -47.15) : (PostClass.StatusGeo?)null, }; + if (reply) + post.InReplyToStatusId = 100L; Assert.Equal(expected, post.StateIndex); } @@ -213,9 +214,7 @@ public void DeleteTest() post.IsDeleted = true; - Assert.Null(post.InReplyToStatusId); - Assert.Equal("", post.InReplyToUser); - Assert.Null(post.InReplyToUserId); + Assert.False(post.HasInReplyTo); Assert.False(post.IsReply); Assert.Empty(post.ReplyToList); Assert.Equal(-1, post.StateIndex); @@ -274,6 +273,7 @@ public void CanDeleteBy_RetweetedByMeTest() { var post = new TestPostClass { + RetweetedId = 100L, RetweetedByUserId = 111L, // 自分がリツイートした UserId = 222L, // 他人のツイート }; @@ -286,6 +286,7 @@ public void CanDeleteBy_RetweetedByOthersTest() { var post = new TestPostClass { + RetweetedId = 100L, RetweetedByUserId = 333L, // 他人がリツイートした UserId = 222L, // 他人のツイート }; @@ -298,6 +299,7 @@ public void CanDeleteBy_MyTweetHaveBeenRetweetedByOthersTest() { var post = new TestPostClass { + RetweetedId = 100L, RetweetedByUserId = 222L, // 他人がリツイートした UserId = 111L, // 自分のツイート }; @@ -386,9 +388,7 @@ public void ConvertToOriginalPost_Test() Assert.Equal("@aaa", originalPost.ScreenName); Assert.Equal(1L, originalPost.UserId); - Assert.Null(originalPost.RetweetedId); - Assert.Equal("", originalPost.RetweetedBy); - Assert.Null(originalPost.RetweetedByUserId); + Assert.False(originalPost.IsRetweet); Assert.Equal(1, originalPost.RetweetedCount); } @@ -396,7 +396,7 @@ public void ConvertToOriginalPost_Test() public void ConvertToOriginalPost_ErrorTest() { // 公式 RT でないツイート - var post = new PostClass { StatusId = 100L, RetweetedId = null }; + var post = new PostClass { StatusId = 100L }; Assert.Throws(() => post.ConvertToOriginalPost()); } diff --git a/OpenTween.Tests/Models/PostFilterRuleTest.cs b/OpenTween.Tests/Models/PostFilterRuleTest.cs index 5c6846ad7..d625afc79 100644 --- a/OpenTween.Tests/Models/PostFilterRuleTest.cs +++ b/OpenTween.Tests/Models/PostFilterRuleTest.cs @@ -171,10 +171,10 @@ public void FilterName_Test() Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // FilterName は RetweetedBy にもマッチする - post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "foo", RetweetedBy = "bar" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "bar", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // FilterName は完全一致 (UseRegex = false の場合) @@ -209,10 +209,10 @@ public void ExFilterName_Test() Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // ExFilterName は RetweetedBy にもマッチする - post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "foo", RetweetedBy = "bar" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "bar", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // ExFilterName は完全一致 (ExUseRegex = false の場合) @@ -248,10 +248,10 @@ public void FilterName_RegexTest() Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // FilterName は RetweetedBy にもマッチする - post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "foo", RetweetedBy = "bar" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "bar", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // FilterName は部分一致 (UseRegex = true の場合) @@ -287,10 +287,10 @@ public void ExFilterName_RegexTest() Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // ExFilterName は RetweetedBy にもマッチする - post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "hogehoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "foo", RetweetedBy = "bar" }; + post = new PostClass { ScreenName = "foo", RetweetedBy = "bar", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // ExFilterName は部分一致 (ExUseRegex = true の場合) @@ -645,11 +645,11 @@ public void FilterBodyAndName_Test() Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // TextFromApi と RetweetedBy に FilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // ScreenName に対しては完全一致 (UseRegex = false の場合) @@ -666,7 +666,7 @@ public void FilterBodyAndName_Test() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -675,7 +675,7 @@ public void FilterBodyAndName_Test() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); } @@ -710,11 +710,11 @@ public void ExFilterBodyAndName_Test() Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // TextFromApi と RetweetedBy に ExFilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // ScreenName に対しては完全一致 (ExUseRegex = false の場合) @@ -731,7 +731,7 @@ public void ExFilterBodyAndName_Test() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -740,7 +740,7 @@ public void ExFilterBodyAndName_Test() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); } @@ -776,11 +776,11 @@ public void FilterBodyAndName_RegexTest() Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // TextFromApi と RetweetedBy に FilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // ScreenName に対しても部分一致 (UseRegex = true の場合) @@ -797,7 +797,7 @@ public void FilterBodyAndName_RegexTest() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -806,7 +806,7 @@ public void FilterBodyAndName_RegexTest() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); } @@ -842,11 +842,11 @@ public void ExFilterBodyAndName_RegexTest() Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // TextFromApi と RetweetedBy に ExFilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "bbb", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", TextFromApi = "bbb", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // ScreenName に対しても部分一致 (ExUseRegex = true の場合) @@ -863,7 +863,7 @@ public void ExFilterBodyAndName_RegexTest() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -872,7 +872,7 @@ public void ExFilterBodyAndName_RegexTest() post = new PostClass { ScreenName = "Aaa", TextFromApi = "Bbb" }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", TextFromApi = "Bbb", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); } @@ -910,11 +910,11 @@ public void FilterBodyAndName_ByUrlTest() Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // Text と ScreenName に FilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // ScreenName に対しては完全一致 (UseRegex = false の場合) @@ -931,7 +931,7 @@ public void FilterBodyAndName_ByUrlTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -940,7 +940,7 @@ public void FilterBodyAndName_ByUrlTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); } @@ -978,11 +978,11 @@ public void ExFilterBodyAndName_ByUrlTest() Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // Text と ScreenName に ExFilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // ScreenName に対しては完全一致 (ExUseRegex = false の場合) @@ -999,7 +999,7 @@ public void ExFilterBodyAndName_ByUrlTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -1008,7 +1008,7 @@ public void ExFilterBodyAndName_ByUrlTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); } @@ -1047,11 +1047,11 @@ public void FilterBodyAndName_ByUrlRegexTest() Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // Text と ScreenName に FilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); // ScreenName に対しても部分一致 (UseRegex = true の場合) @@ -1068,7 +1068,7 @@ public void FilterBodyAndName_ByUrlRegexTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -1077,7 +1077,7 @@ public void FilterBodyAndName_ByUrlRegexTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); } @@ -1116,11 +1116,11 @@ public void ExFilterBodyAndName_ByUrlRegexTest() Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // Text と ScreenName に ExFilterBody の文字列がそれぞれ含まれている - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // RetweetedBy が null でなくても依然として ScreenName にはマッチする - post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge" }; + post = new PostClass { ScreenName = "aaa", Text = "t.co/hoge", RetweetedBy = "hoge", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); // ScreenName に対しても部分一致 (ExUseRegex = true の場合) @@ -1137,7 +1137,7 @@ public void ExFilterBodyAndName_ByUrlRegexTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.None, filter.ExecFilter(post)); // 大小文字を区別しない @@ -1146,7 +1146,7 @@ public void ExFilterBodyAndName_ByUrlRegexTest() post = new PostClass { ScreenName = "Aaa", Text = "t.co/hoge" }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); - post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa" }; + post = new PostClass { ScreenName = "hoge", Text = "t.co/hoge", RetweetedBy = "Aaa", RetweetedId = 100L }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); } diff --git a/OpenTween.Tests/Models/TabInformationTest.cs b/OpenTween.Tests/Models/TabInformationTest.cs index 4545169f6..b6aeb2e7c 100644 --- a/OpenTween.Tests/Models/TabInformationTest.cs +++ b/OpenTween.Tests/Models/TabInformationTest.cs @@ -260,6 +260,7 @@ public void IsMuted_RetweetTest() var post = new PostClass { UserId = 11111L, + RetweetedId = 100L, RetweetedByUserId = 12345L, Text = "hogehoge", }; @@ -274,6 +275,7 @@ public void IsMuted_RetweetNotMutingTest() var post = new PostClass { UserId = 11111L, + RetweetedId = 100L, RetweetedByUserId = 22222L, Text = "hogehoge", }; diff --git a/OpenTween.Tests/Models/TabModelTest.cs b/OpenTween.Tests/Models/TabModelTest.cs index 2cf139d1a..a8c7e2076 100644 --- a/OpenTween.Tests/Models/TabModelTest.cs +++ b/OpenTween.Tests/Models/TabModelTest.cs @@ -729,6 +729,42 @@ public void GetterSlice_ErrorTest() Assert.Throws(() => tab[2, 0]); // 範囲内だが startIndex > endIndex } + + public static TheoryData IsDistributableTabTypeFixtures = new TheoryData + { + { false, new HomeTabModel("Recent") }, + { true, new MentionsTabModel("Reply") }, + { false, new DirectMessagesTabModel("Direct") }, + { false, new FavoritesTabModel("Favorite") }, + { true, new FilterTabModel("Filter") }, + { false, new ListTimelineTabModel("List", new ListElement()) }, + { false, new UserTimelineTabModel("User", "twitterapi") }, + { false, new PublicSearchTabModel("Search") }, + { false, new RelatedPostsTabModel("Related", new PostClass()) }, + }; + + [Theory] + [MemberData(nameof(IsDistributableTabTypeFixtures))] + public void IsDistributableTabType_Test(bool expected, TabModel tab) + => Assert.Equal(expected, tab.IsDistributableTabType); + + public static TheoryData IsInnerStorageTabTypeFixtures = new TheoryData + { + { false, new HomeTabModel("Recent") }, + { false, new MentionsTabModel("Reply") }, + { true, new DirectMessagesTabModel("Direct") }, + { false, new FavoritesTabModel("Favorite") }, + { false, new FilterTabModel("Filter") }, + { true, new ListTimelineTabModel("List", new ListElement()) }, + { true, new UserTimelineTabModel("User", "twitterapi") }, + { true, new PublicSearchTabModel("Search") }, + { true, new RelatedPostsTabModel("Related", new PostClass()) }, + }; + + [Theory] + [MemberData(nameof(IsInnerStorageTabTypeFixtures))] + public void IsInnerStorageTabType_Test(bool expected, TabModel tab) + => Assert.Equal(expected, tab.IsInnerStorageTabType); } public class TabUsageTypeExtTest @@ -745,31 +781,5 @@ public class TabUsageTypeExtTest [InlineData(MyCommon.TabUsageType.Related, false)] public void IsDefault_Test(MyCommon.TabUsageType tabType, bool expected) => Assert.Equal(expected, tabType.IsDefault()); - - [Theory] - [InlineData(MyCommon.TabUsageType.Home, false)] - [InlineData(MyCommon.TabUsageType.Mentions, true)] - [InlineData(MyCommon.TabUsageType.DirectMessage, false)] - [InlineData(MyCommon.TabUsageType.Favorites, false)] - [InlineData(MyCommon.TabUsageType.UserDefined, true)] - [InlineData(MyCommon.TabUsageType.Lists, false)] - [InlineData(MyCommon.TabUsageType.UserTimeline, false)] - [InlineData(MyCommon.TabUsageType.PublicSearch, false)] - [InlineData(MyCommon.TabUsageType.Related, false)] - public void IsDistributable_Test(MyCommon.TabUsageType tabType, bool expected) - => Assert.Equal(expected, tabType.IsDistributable()); - - [Theory] - [InlineData(MyCommon.TabUsageType.Home, false)] - [InlineData(MyCommon.TabUsageType.Mentions, false)] - [InlineData(MyCommon.TabUsageType.DirectMessage, true)] - [InlineData(MyCommon.TabUsageType.Favorites, false)] - [InlineData(MyCommon.TabUsageType.UserDefined, false)] - [InlineData(MyCommon.TabUsageType.Lists, true)] - [InlineData(MyCommon.TabUsageType.UserTimeline, true)] - [InlineData(MyCommon.TabUsageType.PublicSearch, true)] - [InlineData(MyCommon.TabUsageType.Related, true)] - public void IsInnerStorage_Test(MyCommon.TabUsageType tabType, bool expected) - => Assert.Equal(expected, tabType.IsInnerStorage()); } } diff --git a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs index a62d8a347..6394b704d 100644 --- a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs +++ b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs @@ -21,6 +21,7 @@ using System; using System.Collections.Generic; +using OpenTween.Api; using OpenTween.Api.DataModel; using Xunit; @@ -75,9 +76,11 @@ private TwitterUser CreateUser() [Fact] public void CreateFromStatus_Test() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); - var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 20000L, followerIds: EmptyIdSet); Assert.Equal(status.Id, post.StatusId); Assert.Equal(new DateTimeUtc(2022, 1, 1, 0, 0, 0), post.CreatedAt); @@ -102,13 +105,8 @@ public void CreateFromStatus_Test() Assert.False(post.IsMark); Assert.False(post.IsReply); - Assert.Null(post.InReplyToStatusId); - Assert.Null(post.InReplyToUserId); - Assert.Null(post.InReplyToUser); - - Assert.Null(post.RetweetedId); - Assert.Null(post.RetweetedBy); - Assert.Null(post.RetweetedByUserId); + Assert.False(post.HasInReplyTo); + Assert.False(post.IsRetweet); Assert.Equal(status.User.Id, post.UserId); Assert.Equal("tetete", post.ScreenName); @@ -122,10 +120,12 @@ public void CreateFromStatus_Test() [Fact] public void CreateFromStatus_AuthorTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); var selfUserId = status.User.Id; - var post = factory.CreateFromStatus(status, selfUserId, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId, followerIds: EmptyIdSet); Assert.True(post.IsMe); } @@ -133,10 +133,12 @@ public void CreateFromStatus_AuthorTest() [Fact] public void CreateFromStatus_FollowerTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); var followerIds = new HashSet { status.User.Id }; - var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 20000L, followerIds); Assert.False(post.IsOwl); } @@ -144,10 +146,12 @@ public void CreateFromStatus_FollowerTest() [Fact] public void CreateFromStatus_NotFollowerTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); var followerIds = new HashSet { 30000L }; - var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 20000L, followerIds); Assert.True(post.IsOwl); } @@ -155,6 +159,8 @@ public void CreateFromStatus_NotFollowerTest() [Fact] public void CreateFromStatus_RetweetTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var originalStatus = this.CreateStatus(); @@ -162,7 +168,7 @@ public void CreateFromStatus_RetweetTest() retweetStatus.RetweetedStatus = originalStatus; retweetStatus.Source = "Twitter Web App"; - var post = factory.CreateFromStatus(retweetStatus, selfUserId: 20000L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, retweetStatus, selfUserId: 20000L, followerIds: EmptyIdSet); Assert.Equal(retweetStatus.Id, post.StatusId); Assert.Equal(retweetStatus.User.Id, post.RetweetedByUserId); @@ -215,6 +221,8 @@ private TwitterMessageEvent CreateDirectMessage(string senderId, string recipien [Fact] public void CreateFromDirectMessageEvent_Test() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var selfUser = this.CreateUser(); @@ -226,7 +234,7 @@ public void CreateFromDirectMessageEvent_Test() [otherUser.IdStr] = otherUser, }; var apps = this.CreateApps(); - var post = factory.CreateFromDirectMessageEvent(eventItem, users, apps, selfUserId: selfUser.Id); + var post = factory.CreateFromDirectMessageEvent(twitter, eventItem, users, apps, selfUserId: selfUser.Id); Assert.Equal(long.Parse(eventItem.Id), post.StatusId); Assert.Equal(new DateTimeUtc(2022, 1, 1, 0, 0, 0), post.CreatedAt); @@ -251,13 +259,8 @@ public void CreateFromDirectMessageEvent_Test() Assert.False(post.IsMark); Assert.False(post.IsReply); - Assert.Null(post.InReplyToStatusId); - Assert.Null(post.InReplyToUserId); - Assert.Null(post.InReplyToUser); - - Assert.Null(post.RetweetedId); - Assert.Null(post.RetweetedBy); - Assert.Null(post.RetweetedByUserId); + Assert.False(post.HasInReplyTo); + Assert.False(post.IsRetweet); Assert.Equal(otherUser.Id, post.UserId); Assert.Equal("tetete", post.ScreenName); @@ -271,6 +274,8 @@ public void CreateFromDirectMessageEvent_Test() [Fact] public void CreateFromDirectMessageEvent_SenderTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var selfUser = this.CreateUser(); @@ -282,7 +287,7 @@ public void CreateFromDirectMessageEvent_SenderTest() [otherUser.IdStr] = otherUser, }; var apps = this.CreateApps(); - var post = factory.CreateFromDirectMessageEvent(eventItem, users, apps, selfUserId: selfUser.Id); + var post = factory.CreateFromDirectMessageEvent(twitter, eventItem, users, apps, selfUserId: selfUser.Id); Assert.Equal(otherUser.Id, post.UserId); Assert.False(post.IsOwl); @@ -292,6 +297,8 @@ public void CreateFromDirectMessageEvent_SenderTest() [Fact] public void CreateFromStatus_MediaAltTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); @@ -311,7 +318,7 @@ public void CreateFromStatus_MediaAltTest() }, }; - var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 100L, followerIds: EmptyIdSet); var accessibleText = string.Format(Properties.Resources.ImageAltText, "代替テキスト"); Assert.Equal(accessibleText, post.AccessibleText); @@ -323,6 +330,8 @@ public void CreateFromStatus_MediaAltTest() [Fact] public void CreateFromStatus_MediaNoAltTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); @@ -342,7 +351,7 @@ public void CreateFromStatus_MediaNoAltTest() }, }; - var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 100L, followerIds: EmptyIdSet); Assert.Equal("pic.twitter.com/hoge", post.AccessibleText); Assert.Equal("pic.twitter.com/hoge", post.Text); @@ -353,6 +362,8 @@ public void CreateFromStatus_MediaNoAltTest() [Fact] public void CreateFromStatus_QuotedUrlTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); @@ -383,7 +394,7 @@ public void CreateFromStatus_QuotedUrlTest() FullText = "test", }; - var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 100L, followerIds: EmptyIdSet); var accessibleText = string.Format(Properties.Resources.QuoteStatus_AccessibleText, "foo", "test"); Assert.Equal(accessibleText, post.AccessibleText); @@ -395,6 +406,8 @@ public void CreateFromStatus_QuotedUrlTest() [Fact] public void CreateFromStatus_QuotedUrlWithPermelinkTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); @@ -418,7 +431,7 @@ public void CreateFromStatus_QuotedUrlWithPermelinkTest() Expanded = "https://twitter.com/hoge/status/1234567890", }; - var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 100L, followerIds: EmptyIdSet); var accessibleText = "hoge " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, "foo", "test"); Assert.Equal(accessibleText, post.AccessibleText); @@ -430,6 +443,8 @@ public void CreateFromStatus_QuotedUrlWithPermelinkTest() [Fact] public void CreateFromStatus_QuotedUrlNoReferenceTest() { + using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); + using var twitter = new Twitter(twitterApi); var factory = new TwitterPostFactory(this.CreateTabinfo()); var status = this.CreateStatus(); @@ -449,7 +464,7 @@ public void CreateFromStatus_QuotedUrlNoReferenceTest() }; status.QuotedStatus = null; - var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet); + var post = factory.CreateFromStatus(twitter, status, selfUserId: 100L, followerIds: EmptyIdSet); var accessibleText = "twitter.com/hoge/status/1…"; Assert.Equal(accessibleText, post.AccessibleText); diff --git a/OpenTween.Tests/MyCommonTest.cs b/OpenTween.Tests/MyCommonTest.cs index dff7e0a92..4e9cd0c20 100644 --- a/OpenTween.Tests/MyCommonTest.cs +++ b/OpenTween.Tests/MyCommonTest.cs @@ -176,20 +176,20 @@ public void ReplaceAppNameTest(string str, string excepted) => Assert.Equal(excepted, MyCommon.ReplaceAppName(str, "OpenTween")); [Theory] - [InlineData("1.0.0.0", "1.0.0")] - [InlineData("1.0.0.1", "1.0.1-dev")] - [InlineData("1.0.0.12", "1.0.1-dev+build.12")] - [InlineData("1.0.1.0", "1.0.1")] - [InlineData("1.0.9.1", "1.0.10-dev")] - [InlineData("1.1.0.0", "1.1.0")] - [InlineData("1.9.9.1", "1.9.10-dev")] + [InlineData("1.0.0.0", "1.0.0+mastodon")] + [InlineData("1.0.0.1", "1.0.1-dev+mastodon")] + [InlineData("1.0.0.12", "1.0.1-dev+mastodon+build.12")] + [InlineData("1.0.1.0", "1.0.1+mastodon")] + [InlineData("1.0.9.1", "1.0.10-dev+mastodon")] + [InlineData("1.1.0.0", "1.1.0+mastodon")] + [InlineData("1.9.9.1", "1.9.10-dev+mastodon")] public void GetReadableVersionTest(string fileVersion, string expected) => Assert.Equal(expected, MyCommon.GetReadableVersion(fileVersion)); public static readonly TheoryData GetStatusUrlTest1TestCase = new() { { - new PostClass { StatusId = 249493863826350080L, ScreenName = "Favstar_LM", RetweetedId = null, RetweetedBy = null }, + new PostClass { StatusId = 249493863826350080L, ScreenName = "Favstar_LM" }, "https://twitter.com/Favstar_LM/status/249493863826350080" }, { diff --git a/OpenTween.Tests/TwitterTest.cs b/OpenTween.Tests/TwitterTest.cs index 2c03f9698..e5bb7e59a 100644 --- a/OpenTween.Tests/TwitterTest.cs +++ b/OpenTween.Tests/TwitterTest.cs @@ -88,7 +88,7 @@ public void FindTopOfReplyChainTest() { var posts = new Dictionary { - [950L] = new PostClass { StatusId = 950L, InReplyToStatusId = null }, // このツイートが末端 + [950L] = new PostClass { StatusId = 950L }, // このツイートが末端 [987L] = new PostClass { StatusId = 987L, InReplyToStatusId = 950L }, [999L] = new PostClass { StatusId = 999L, InReplyToStatusId = 987L }, [1000L] = new PostClass { StatusId = 1000L, InReplyToStatusId = 999L }, diff --git a/OpenTween/Api/DataModel/MastodonAccessToken.cs b/OpenTween/Api/DataModel/MastodonAccessToken.cs new file mode 100644 index 000000000..e2317a861 --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonAccessToken.cs @@ -0,0 +1,43 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonAccessToken + { + [DataMember(Name = "access_token")] + public string AccessToken { get; set; } + + [DataMember(Name = "token_type")] + public string TokenType { get; set; } + + [DataMember(Name = "scope")] + public string Scope { get; set; } + + [DataMember(Name = "created_at")] + public long CreatedAt { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonAccount.cs b/OpenTween/Api/DataModel/MastodonAccount.cs new file mode 100644 index 000000000..6053430f8 --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonAccount.cs @@ -0,0 +1,76 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonAccount + { + [DataMember(Name = "id")] + public long Id { get; set; } + + [DataMember(Name = "username")] + public string Username { get; set; } + + [DataMember(Name = "acct")] + public string Acct { get; set; } + + [DataMember(Name = "display_name")] + public string DisplayName { get; set; } + + [DataMember(Name = "locked")] + public bool Locked { get; set; } + + [DataMember(Name = "created_at")] + public string CreatedAt { get; set; } + + [DataMember(Name = "followers_count")] + public int FollowersCount { get; set; } + + [DataMember(Name = "following_count")] + public int FollowingCount { get; set; } + + [DataMember(Name = "statuses_count")] + public int StatusesCount { get; set; } + + [DataMember(Name = "note")] + public string Note { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + + [DataMember(Name = "avatar")] + public string Avatar { get; set; } + + [DataMember(Name = "avatar_static")] + public string AvatarStatic { get; set; } + + [DataMember(Name = "header")] + public string Header { get; set; } + + [DataMember(Name = "header_static")] + public string HeaderStatic { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonApplication.cs b/OpenTween/Api/DataModel/MastodonApplication.cs new file mode 100644 index 000000000..11a15af4e --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonApplication.cs @@ -0,0 +1,37 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonApplication + { + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "website")] + public string? Website { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonAttachment.cs b/OpenTween/Api/DataModel/MastodonAttachment.cs new file mode 100644 index 000000000..52d0b9d4e --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonAttachment.cs @@ -0,0 +1,49 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonAttachment + { + [DataMember(Name = "id")] + public long Id { get; set; } + + [DataMember(Name = "type")] + public string Type { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + + [DataMember(Name = "remote_url")] + public string RemoteUrl { get; set; } + + [DataMember(Name = "preview_url")] + public string PreviewUrl { get; set; } + + [DataMember(Name = "text_url")] + public string TextUrl { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonError.cs b/OpenTween/Api/DataModel/MastodonError.cs new file mode 100644 index 000000000..1b5964830 --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonError.cs @@ -0,0 +1,34 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonError + { + [DataMember(Name = "error")] + public string Error { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonInstance.cs b/OpenTween/Api/DataModel/MastodonInstance.cs new file mode 100644 index 000000000..c93a457b7 --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonInstance.cs @@ -0,0 +1,46 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonInstance + { + [DataMember(Name = "uri")] + public string Uri { get; set; } + + [DataMember(Name = "title")] + public string Title { get; set; } + + [DataMember(Name = "description")] + public string Description { get; set; } + + [DataMember(Name = "email")] + public string Email { get; set; } + + [DataMember(Name = "version")] + public string Version { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonMention.cs b/OpenTween/Api/DataModel/MastodonMention.cs new file mode 100644 index 000000000..b0fe382ce --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonMention.cs @@ -0,0 +1,43 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonMention + { + [DataMember(Name = "url")] + public string Url { get; set; } + + [DataMember(Name = "username")] + public string Username { get; set; } + + [DataMember(Name = "acct")] + public string Acct { get; set; } + + [DataMember(Name = "id")] + public long Id { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonRegisteredApp.cs b/OpenTween/Api/DataModel/MastodonRegisteredApp.cs new file mode 100644 index 000000000..51f21d18d --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonRegisteredApp.cs @@ -0,0 +1,43 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonRegisteredApp + { + [DataMember(Name = "id")] + public string Id { get; set; } + + [DataMember(Name = "redirect_uri")] + public string RedirectUri { get; set; } + + [DataMember(Name = "client_id")] + public string ClientId { get; set; } + + [DataMember(Name = "client_secret")] + public string ClientSecret { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonStatus.cs b/OpenTween/Api/DataModel/MastodonStatus.cs new file mode 100644 index 000000000..21dab8fc8 --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonStatus.cs @@ -0,0 +1,91 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonStatus + { + [DataMember(Name = "id")] + public long Id { get; set; } + + [DataMember(Name = "uri")] + public string Uri { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + + [DataMember(Name = "account")] + public MastodonAccount Account { get; set; } + + [DataMember(Name = "in_reply_to_id")] + public long? InReplyToId { get; set; } + + [DataMember(Name = "in_reply_to_account_id")] + public long? InReplyToAccountId { get; set; } + + [DataMember(Name = "reblog")] + public MastodonStatus Reblog { get; set; } + + [DataMember(Name = "content")] + public string Content { get; set; } + + [DataMember(Name = "created_at")] + public string CreatedAt { get; set; } + + [DataMember(Name = "reblogs_count")] + public int ReblogsCount { get; set; } + + [DataMember(Name = "favourites_count")] + public int FavouritesCount { get; set; } + + [DataMember(Name = "reblogged")] + public bool? Reblogged { get; set; } + + [DataMember(Name = "favourited")] + public bool? Favourited { get; set; } + + [DataMember(Name = "sensitive")] + public bool? Sensitive { get; set; } + + [DataMember(Name = "spoiler_text")] + public string SpoilerText { get; set; } + + [DataMember(Name = "visibility")] + public string Visibility { get; set; } + + [DataMember(Name = "media_attachments")] + public MastodonAttachment[] MediaAttachments { get; set; } + + [DataMember(Name = "mentions")] + public MastodonMention[] Mentions { get; set; } + + [DataMember(Name = "tags")] + public MastodonTag[] Tags { get; set; } + + [DataMember(Name = "application")] + public MastodonApplication Application { get; set; } + } +} diff --git a/OpenTween/Api/DataModel/MastodonTag.cs b/OpenTween/Api/DataModel/MastodonTag.cs new file mode 100644 index 000000000..76d7ef68a --- /dev/null +++ b/OpenTween/Api/DataModel/MastodonTag.cs @@ -0,0 +1,37 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable annotations + +using System.Runtime.Serialization; + +namespace OpenTween.Api.DataModel +{ + [DataContract] + public class MastodonTag + { + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "url")] + public string Url { get; set; } + } +} diff --git a/OpenTween/Api/MastodonApi.cs b/OpenTween/Api/MastodonApi.cs new file mode 100644 index 000000000..91d5aba02 --- /dev/null +++ b/OpenTween/Api/MastodonApi.cs @@ -0,0 +1,216 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using OpenTween.Api.DataModel; +using OpenTween.Connection; + +namespace OpenTween.Api +{ + public sealed class MastodonApi : IDisposable + { + public IMastodonApiConnection Connection { get; } + + public Uri InstanceUri { get; } + + public MastodonApi(Uri instanceUri) + : this(instanceUri, accessToken: null) + { + } + + public MastodonApi(Uri instanceUri, string? accessToken) + { + this.Connection = new MastodonApiConnection(instanceUri, accessToken); + this.InstanceUri = instanceUri; + } + + public Task AccountsVerifyCredentials() + { + var endpoint = new Uri("/api/v1/accounts/verify_credentials", UriKind.Relative); + + return this.Connection.GetAsync(endpoint, null); + } + + public async Task AppsRegister( + string clientName, + Uri redirectUris, + string scopes, + string? website = null) + { + var endpoint = new Uri("/api/v1/apps", UriKind.Relative); + var param = new Dictionary + { + ["client_name"] = clientName, + ["redirect_uris"] = redirectUris.OriginalString, + ["scopes"] = scopes, + }; + + if (website != null) + param["website"] = website; + + var response = await this.Connection.PostLazyAsync(endpoint, param) + .ConfigureAwait(false); + + return await response.LoadJsonAsync() + .ConfigureAwait(false); + } + + public Task Instance() + { + var endpoint = new Uri("/api/v1/instance", UriKind.Relative); + + return this.Connection.GetAsync(endpoint, null); + } + + public Uri OAuthAuthorize(string clientId, string responseType, Uri redirectUri, string scope) + { + var endpoint = new Uri("/oauth/authorize", UriKind.Relative); + var param = new Dictionary + { + ["client_id"] = clientId, + ["response_type"] = responseType, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["scope"] = scope, + }; + + return new Uri(new Uri(this.InstanceUri, endpoint), "?" + MyCommon.BuildQueryString(param)); + } + + public async Task OAuthToken( + string clientId, + string clientSecret, + Uri redirectUri, + string grantType, + string code, + string? scope = null) + { + var endpoint = new Uri("/oauth/token", UriKind.Relative); + var param = new Dictionary + { + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["redirect_uri"] = redirectUri.AbsoluteUri, + ["grant_type"] = grantType, + ["code"] = code, + }; + + if (scope != null) + param["scope"] = scope; + + var response = await this.Connection.PostLazyAsync(endpoint, param) + .ConfigureAwait(false); + + return await response.LoadJsonAsync() + .ConfigureAwait(false); + } + + public Task TimelinesHome(long? maxId = null, long? sinceId = null, int? limit = null) + { + var endpoint = new Uri("/api/v1/timelines/home", UriKind.Relative); + var param = new Dictionary(); + + if (maxId != null) + param["max_id"] = maxId.ToString(); + if (sinceId != null) + param["since_id"] = sinceId.ToString(); + if (limit != null) + param["limit"] = limit.ToString(); + + return this.Connection.GetAsync(endpoint, param); + } + + public Task> StatusesPost( + string status, + long? inReplyToId = null, + IReadOnlyList? mediaIds = null, + bool? sensitive = null, + string? spoilerText = null, + string? visibility = null) + { + var endpoint = new Uri("/api/v1/statuses", UriKind.Relative); + var param = new Dictionary + { + ["status"] = status, + }; + + if (inReplyToId != null) + param["in_reply_to_id"] = inReplyToId.ToString(); + if (sensitive != null) + param["sensitive"] = sensitive.Value ? "true" : "false"; + if (spoilerText != null) + param["spoiler_text"] = spoilerText; + if (visibility != null) + param["visibility"] = visibility; + + var paramList = param.ToList(); + + foreach (var mediaId in mediaIds ?? Enumerable.Empty()) + paramList.Add(new KeyValuePair("media_ids[]", mediaId.ToString())); + + return this.Connection.PostLazyAsync(endpoint, paramList); + } + + public Task StatusesDelete(long statusId) + { + var endpoint = new Uri($"/api/v1/statuses/{statusId}", UriKind.Relative); + + return this.Connection.PostLazyAsync(HttpMethod.Delete, endpoint, null).IgnoreResponse(); + } + + public Task> StatusesFavourite(long statusId) + { + var endpoint = new Uri($"/api/v1/statuses/{statusId}/favourite", UriKind.Relative); + + return this.Connection.PostLazyAsync(endpoint, null); + } + + public Task> StatusesUnfavourite(long statusId) + { + var endpoint = new Uri($"/api/v1/statuses/{statusId}/unfavourite", UriKind.Relative); + + return this.Connection.PostLazyAsync(endpoint, null); + } + + public Task> StatusesReblog(long statusId) + { + var endpoint = new Uri($"/api/v1/statuses/{statusId}/reblog", UriKind.Relative); + + return this.Connection.PostLazyAsync(endpoint, null); + } + + public Task> StatusesUnreblog(long statusId) + { + var endpoint = new Uri($"/api/v1/statuses/{statusId}/unreblog", UriKind.Relative); + + return this.Connection.PostLazyAsync(endpoint, null); + } + + public void Dispose() + => this.Connection.Dispose(); + } +} diff --git a/OpenTween/AppendSettingDialog.cs b/OpenTween/AppendSettingDialog.cs index 1c644813e..af88f27b4 100644 --- a/OpenTween/AppendSettingDialog.cs +++ b/OpenTween/AppendSettingDialog.cs @@ -121,7 +121,7 @@ private void Setting_FormClosing(object sender, FormClosingEventArgs e) { if (MyCommon.EndingFlag) return; - if (this.BasedPanel.AuthUserCombo.SelectedIndex == -1 && e.CloseReason == CloseReason.None) + if (this.BasedPanel.AuthUserCombo.SelectedIndex == -1 && !this.BasedPanel.HasMastodonCredential && e.CloseReason == CloseReason.None) { if (MessageBox.Show(Properties.Resources.Setting_FormClosing1, "Confirm", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) == DialogResult.Cancel) { @@ -252,8 +252,7 @@ public void ApplyNetworkSettings() var pinPageUrl = TwitterApiConnection.GetAuthorizeUri(requestToken); - var browserPath = this.ActionPanel.BrowserPathText.Text; - var pin = AuthDialog.DoAuth(this, pinPageUrl, browserPath); + var pin = this.ShowAuthDialog(pinPageUrl); if (MyCommon.IsNullOrEmpty(pin)) return null; // キャンセルされた場合 @@ -263,11 +262,17 @@ public void ApplyNetworkSettings() { Username = accessTokenResponse["screen_name"], UserId = long.Parse(accessTokenResponse["user_id"]), - Token = accessTokenResponse["oauth_token"], - TokenSecret = accessTokenResponse["oauth_token_secret"], + AccessToken = accessTokenResponse["oauth_token"], + AccessSecretPlain = accessTokenResponse["oauth_token_secret"], }; } + public string? ShowAuthDialog(Uri pinPageUrl) + { + var browserPath = this.ActionPanel.BrowserPathText.Text; + return AuthDialog.DoAuth(this, pinPageUrl, browserPath); + } + private void CheckPostAndGet_CheckedChanged(object sender, EventArgs e) => this.GetPeriodPanel.LabelPostAndGet.Visible = this.GetPeriodPanel.CheckPostAndGet.Checked; diff --git a/OpenTween/ApplicationSettings.cs b/OpenTween/ApplicationSettings.cs index d7c7a466d..a148f72f4 100644 --- a/OpenTween/ApplicationSettings.cs +++ b/OpenTween/ApplicationSettings.cs @@ -103,7 +103,7 @@ internal static class ApplicationSettings /// version.txt のフォーマットについては http://sourceforge.jp/projects/opentween/wiki/VersionTxt を参照。 /// 派生プロジェクトなどでこの機能を無効にする場合は null をセットして下さい。 /// - public static readonly string VersionInfoUrl = "https://www.opentween.org/status/version.txt"; + public static readonly string? VersionInfoUrl = null; // ===================================================================== // 暗号化キー @@ -127,6 +127,20 @@ internal static class ApplicationSettings /// public static readonly ApiKey TwitterConsumerSecret = ApiKey.Create("%e%p93BdDzlwbYIC5Ych/47OQ==%xYZTCYaBxzS4An3o7Qcigjp9QMtu5vi5iEAW/sNgoOoAUyuHJRPP3Ovs20ZV2fAYKxUDiu76dxLfObwI7QjSRA==%YEruRDAQdbJzO+y6kn7+U/uIyIyNra/8Ulo+L6KJcWA="); + // ===================================================================== + // Mastodon + + /// + /// Mastodon インスタンス毎に事前に発行した client_id, client_secret の組 + /// + /// + /// ここに含まれていないインスタンスでは によって + /// アプリケーションの登録を都度行います + /// + public static readonly IReadOnlyDictionary> MastodonClientIds = new Dictionary> + { + }; + // ===================================================================== // Foursquare // https://developer.foursquare.com/ から取得できます。 diff --git a/OpenTween/Connection/IMastodonApiConnection.cs b/OpenTween/Connection/IMastodonApiConnection.cs new file mode 100644 index 000000000..458c6c777 --- /dev/null +++ b/OpenTween/Connection/IMastodonApiConnection.cs @@ -0,0 +1,42 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace OpenTween.Connection +{ + public interface IMastodonApiConnection : IDisposable + { + Task GetAsync(Uri uri, IEnumerable>? param); + + Task> PostLazyAsync(Uri uri, IEnumerable>? param); + + Task> PostLazyAsync(HttpMethod method, Uri uri, IEnumerable>? param); + + Task GetStreamAsync(Uri uri, IEnumerable>? param); + } +} diff --git a/OpenTween/Connection/MastodonApiConnection.cs b/OpenTween/Connection/MastodonApiConnection.cs new file mode 100644 index 000000000..3afba40a3 --- /dev/null +++ b/OpenTween/Connection/MastodonApiConnection.cs @@ -0,0 +1,236 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Cache; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using OpenTween.Api.DataModel; + +namespace OpenTween.Connection +{ + public sealed class MastodonApiConnection : IMastodonApiConnection + { + public Uri InstanceUri { get; } + + public Uri WebsocketUri { get; } + + public string? AccessToken { get; } + + internal HttpClient Http = null!; + + public MastodonApiConnection(Uri instanceUri) + : this(instanceUri, accessToken: null) + { + } + + public MastodonApiConnection(Uri instanceUri, string? accessToken) + { + this.InstanceUri = instanceUri; + this.AccessToken = accessToken; + + var websocketUri = new UriBuilder(this.InstanceUri); + websocketUri.Scheme = websocketUri.Scheme == "https" ? "wss" : "ws"; + this.WebsocketUri = websocketUri.Uri; + + this.InitializeHttpClient(); + Networking.WebProxyChanged += this.Networking_WebProxyChanged; + } + + public async Task GetAsync(Uri uri, IEnumerable>? param) + { + var requestUri = new Uri(this.InstanceUri, uri); + + if (param != null) + requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param)); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + if (!MyCommon.IsNullOrEmpty(this.AccessToken)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.AccessToken); + + using var response = await this.Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + + await this.CheckStatusCode(response) + .ConfigureAwait(false); + + using var content = response.Content; + var responseText = await content.ReadAsStringAsync() + .ConfigureAwait(false); + + try + { + return MyCommon.CreateDataFromJson(responseText); + } + catch (SerializationException ex) + { + throw new WebApiException("Invalid Response", responseText, ex); + } + } + catch (HttpRequestException ex) + { + throw new WebApiException(ex.InnerException?.Message ?? ex.Message, ex); + } + catch (OperationCanceledException ex) + { + throw new WebApiException("Timeout", ex); + } + } + + public async Task GetStreamAsync(Uri uri, IEnumerable>? param) + { + var requestUri = new Uri(this.InstanceUri, uri); + + if (param != null) + requestUri = new Uri(requestUri, "?" + MyCommon.BuildQueryString(param)); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + if (!MyCommon.IsNullOrEmpty(this.AccessToken)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.AccessToken); + + using var response = await this.Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + + await this.CheckStatusCode(response) + .ConfigureAwait(false); + + return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + throw new WebApiException(ex.InnerException?.Message ?? ex.Message, ex); + } + catch (OperationCanceledException ex) + { + throw new WebApiException("Timeout", ex); + } + } + + public Task> PostLazyAsync(Uri uri, IEnumerable>? param) + => this.PostLazyAsync(HttpMethod.Post, uri, param); + + public async Task> PostLazyAsync(HttpMethod method, Uri uri, IEnumerable>? param) + { + var requestUri = new Uri(this.InstanceUri, uri); + + if (param == null) + param = Enumerable.Empty>(); + + try + { + using var request = new HttpRequestMessage(method, requestUri); + using var postContent = new FormUrlEncodedContent(param); + + if (!string.IsNullOrEmpty(this.AccessToken)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.AccessToken); + + request.Content = postContent; + + HttpResponseMessage? response = null; + try + { + response = await this.Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + + await this.CheckStatusCode(response) + .ConfigureAwait(false); + + var result = new LazyJson(response); + response = null; + + return result; + } + finally + { + response?.Dispose(); + } + } + catch (HttpRequestException ex) + { + throw new WebApiException(ex.InnerException?.Message ?? ex.Message, ex); + } + catch (OperationCanceledException ex) + { + throw new WebApiException("Timeout", ex); + } + } + + public void Dispose() + { + Networking.WebProxyChanged -= this.Networking_WebProxyChanged; + this.Http.Dispose(); + } + + private async Task CheckStatusCode(HttpResponseMessage response) + { + var statusCode = response.StatusCode; + if (statusCode == HttpStatusCode.OK) + return; + + string responseText; + using (var content = response.Content) + { + responseText = await content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(responseText)) + { + try + { + var error = MyCommon.CreateDataFromJson(responseText); + var errorText = error?.Error; + + if (!MyCommon.IsNullOrEmpty(errorText)) + throw new WebApiException(errorText, responseText); + } + catch (SerializationException) + { + } + } + + throw new WebApiException(statusCode.ToString(), responseText); + } + + private void InitializeHttpClient() + { + var innerHandler = Networking.CreateHttpClientHandler(); + innerHandler.CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache); + + this.Http = Networking.CreateHttpClient(innerHandler); + } + + private void Networking_WebProxyChanged(object sender, EventArgs e) + => this.InitializeHttpClient(); + } +} diff --git a/OpenTween/Connection/Networking.cs b/OpenTween/Connection/Networking.cs index 4dca4fa1c..3871c614e 100644 --- a/OpenTween/Connection/Networking.cs +++ b/OpenTween/Connection/Networking.cs @@ -28,6 +28,7 @@ using System.Net; using System.Net.Cache; using System.Net.Http; +using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -192,6 +193,14 @@ public static HttpClient CreateHttpClient(HttpMessageHandler handler) return client; } + public static void SetWebSocketOptions(ClientWebSocketOptions options) + { + if (Networking.Proxy != null) + options.Proxy = Networking.Proxy; + + options.SetRequestHeader("User-Agent", Networking.GetUserAgentString()); + } + public static string GetUserAgentString(bool fakeMSIE = false) { if (fakeMSIE) diff --git a/OpenTween/Mastodon.cs b/OpenTween/Mastodon.cs new file mode 100644 index 000000000..090b5a581 --- /dev/null +++ b/OpenTween/Mastodon.cs @@ -0,0 +1,229 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using OpenTween.Api; +using OpenTween.Api.DataModel; +using OpenTween.Models; + +namespace OpenTween +{ + public sealed class Mastodon : IDisposable + { + public long UserId { get; private set; } + + public string Username { get; private set; } = ""; + + public MastodonApi Api => this.ApiInternal ?? throw new WebApiException("Unauthorized"); + + internal MastodonApi ApiInternal = null!; + + public void Initialize(MastodonCredential account) + { + this.ApiInternal = new MastodonApi(new Uri(account.InstanceUri), account.AccessTokenPlain); + + this.UserId = account.UserId; + this.Username = account.Username; + } + + public static async Task RegisterClientAsync(Uri instanceUri) + { + if (ApplicationSettings.MastodonClientIds.TryGetValue(instanceUri.Host, out var client)) + { + return new MastodonRegisteredApp + { + ClientId = client.Item1, + ClientSecret = client.Item2, + }; + } + + using var api = new MastodonApi(instanceUri); + var redirectUri = new Uri("urn:ietf:wg:oauth:2.0:oob"); + var scope = "read write follow"; + var application = await api.AppsRegister(ApplicationSettings.ApplicationName, redirectUri, scope, ApplicationSettings.WebsiteUrl) + .ConfigureAwait(false); + + System.Diagnostics.Debug.WriteLine($"ClientId: {application.ClientId}, ClientSecret: {application.ClientSecret}"); + + return application; + } + + public static Uri GetAuthorizeUri(Uri instanceUri, string clientId) + { + var redirectUri = new Uri("urn:ietf:wg:oauth:2.0:oob"); + var scope = "read write follow"; + using var api = new MastodonApi(instanceUri); + + return api.OAuthAuthorize(clientId, "code", redirectUri, scope); + } + + public static async Task GetAccessTokenAsync( + Uri instanceUri, + string clientId, + string clientSecret, + string authorizationCode) + { + using var api = new MastodonApi(instanceUri); + var redirectUri = new Uri("urn:ietf:wg:oauth:2.0:oob"); + var scope = "read write follow"; + var token = await api.OAuthToken(clientId, clientSecret, redirectUri, "authorization_code", authorizationCode, scope) + .ConfigureAwait(false); + + return token.AccessToken; + } + + public static async Task VerifyCredentialAsync(Uri instanceUri, string accessToken) + { + using var api = new MastodonApi(instanceUri, accessToken); + var account = await api.AccountsVerifyCredentials(); + var instance = await api.Instance(); + + return new MastodonCredential + { + InstanceUri = instanceUri.AbsoluteUri, + UserId = account.Id, + Username = $"{account.Username}@{instance.Uri}", + AccessTokenPlain = accessToken, + }; + } + + public async Task PostStatusAsync(PostStatusParams param) + { + var response = await this.Api.StatusesPost(param.Text, param.InReplyToStatusId, param.MediaIds) + .ConfigureAwait(false); + + var status = await response.LoadJsonAsync() + .ConfigureAwait(false); + + return this.CreatePost(status); + } + + public async Task GetHomeTimelineAsync(MastodonHomeTab tab, bool backward) + { + MastodonStatus[] statuses; + if (backward) + { + statuses = await this.Api.TimelinesHome(maxId: tab.OldestId) + .ConfigureAwait(false); + } + else + { + statuses = await this.Api.TimelinesHome() + .ConfigureAwait(false); + } + + return statuses.Select(x => this.CreatePost(x)).ToArray(); + } + + public PostClass CreatePost(MastodonStatus status) + { + var post = new MastodonPost(this); + post.StatusId = status.Id; // TODO: Twitterの status_id と衝突する + + if (status.Reblog != null) + { + var reblog = status.Reblog; + post.CreatedAt = this.ParseDateTime(reblog.CreatedAt); + post.RetweetedId = reblog.Id; + post.TextFromApi = Regex.Replace(reblog.Content, "<[^>]+>", ""); + post.Text = reblog.Content; + post.IsFav = reblog.Favourited ?? false; + + if (reblog.InReplyToId != null) + { + post.InReplyToStatusId = reblog.InReplyToId.Value; + post.InReplyToUserId = reblog.InReplyToAccountId!.Value; + post.InReplyToUser = reblog.Mentions.FirstOrDefault()?.Acct ?? ""; + } + + post.Media = reblog.MediaAttachments + .Select(x => new MediaInfo(x.PreviewUrl)).ToList(); + + post.UserId = reblog.Account.Id; + post.ScreenName = reblog.Account.Acct; + post.Nickname = reblog.Account.DisplayName; + post.ImageUrl = reblog.Account.AvatarStatic; + post.IsProtect = !(reblog.Visibility == "public" || reblog.Visibility == "unlisted"); + + post.RetweetedBy = status.Account.Acct; + post.RetweetedByUserId = status.Account.Id; + post.IsMe = status.Account.Id == this.UserId; + } + else // status.Reblog == null + { + post.CreatedAt = this.ParseDateTime(status.CreatedAt); + post.TextFromApi = Regex.Replace(status.Content, "<[^>]+>", ""); + post.Text = status.Content; + post.IsFav = status.Favourited ?? false; + + if (status.InReplyToId != null) + { + post.InReplyToStatusId = status.InReplyToId.Value; + post.InReplyToUserId = status.InReplyToAccountId!.Value; + post.InReplyToUser = status.Mentions.FirstOrDefault()?.Acct ?? ""; + } + + post.Media = status.MediaAttachments + .Select(x => new MediaInfo(x.PreviewUrl)).ToList(); + + post.UserId = status.Account.Id; + post.ScreenName = status.Account.Acct; + post.Nickname = status.Account.DisplayName; + post.ImageUrl = status.Account.AvatarStatic; + post.IsProtect = !(status.Visibility == "public" || status.Visibility == "unlisted"); + post.IsMe = status.Account.Id == this.UserId; + } + + post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi); + post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661"); + post.AccessibleText = post.TextFromApi; + + post.QuoteStatusIds = new long[0]; + post.ExpandedUrls = new PostClass.ExpandedUrlInfo[0]; + + var application = status.Application; + if (application != null) + { + post.Source = application.Name; + post.SourceUri = application.Website != null ? new Uri(application.Website) : null; + } + else + { + post.Source = ""; + } + + return post; + } + + public DateTimeUtc ParseDateTime(string datetime) + => DateTimeUtc.Parse(datetime, DateTimeFormatInfo.InvariantInfo); + + public void Dispose() + => this.ApiInternal?.Dispose(); + } +} diff --git a/OpenTween/Models/MastodonHomeTab.cs b/OpenTween/Models/MastodonHomeTab.cs new file mode 100644 index 000000000..3e1790cef --- /dev/null +++ b/OpenTween/Models/MastodonHomeTab.cs @@ -0,0 +1,74 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Threading.Tasks; +using OpenTween.Setting; + +namespace OpenTween.Models +{ + public class MastodonHomeTab : InternalStorageTabModel + { + public override MyCommon.TabUsageType TabType + => MyCommon.TabUsageType.Undefined; + + public override bool IsPermanentTabType => false; + + private readonly Mastodon mastodon; + + public MastodonHomeTab(Mastodon mastodon, string tabName) + : base(tabName) + { + this.mastodon = mastodon; + this.UnreadManage = true; + } + + public async override Task RefreshAsync(Twitter tw, bool backward, bool startup, IProgress progress) + { + bool read; + if (!SettingManager.Instance.Common.UnreadManage) + read = true; + else + read = startup && SettingManager.Instance.Common.Read; + + progress.Report(string.Format(Properties.Resources.GetTimelineWorker_RunWorkerCompletedText5, backward ? -1 : 1)); + + var posts = await this.mastodon.GetHomeTimelineAsync(this, backward) + .ConfigureAwait(false); + + long? minimumId = null; + foreach (var post in posts) + { + if (minimumId == null || minimumId.Value > post.StatusId) + minimumId = post.StatusId; + + this.AddPostQueue(post); + } + + if (minimumId != null && minimumId.Value < this.OldestId) + this.OldestId = minimumId.Value; + + progress.Report(Properties.Resources.GetTimelineWorker_RunWorkerCompletedText1); + } + } +} diff --git a/OpenTween/Models/MastodonPost.cs b/OpenTween/Models/MastodonPost.cs new file mode 100644 index 000000000..d2ff83538 --- /dev/null +++ b/OpenTween/Models/MastodonPost.cs @@ -0,0 +1,96 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Threading.Tasks; +using OpenTween.Connection; + +namespace OpenTween.Models +{ + public class MastodonPost : PostClass + { + private readonly Mastodon mastodon; + + public MastodonPost(Mastodon mastodon) + => this.mastodon = mastodon; + + public override async Task FavoriteAsync(SettingCommon settingCommon) + { + var statusId = this.IsRetweet ? this.RetweetedId : this.StatusId; + + await this.mastodon.Api.StatusesFavourite(statusId) + .IgnoreResponse() + .ConfigureAwait(false); + + this.IsFav = true; + } + + public override async Task UnfavoriteAsync() + { + var statusId = this.IsRetweet ? this.RetweetedId : this.StatusId; + + await this.mastodon.Api.StatusesUnfavourite(statusId) + .IgnoreResponse() + .ConfigureAwait(false); + + this.IsFav = false; + } + + public override async Task RetweetAsync(SettingCommon settingCommon) + { + var statusId = this.IsRetweet ? this.RetweetedId : this.StatusId; + + var response = await this.mastodon.Api.StatusesReblog(statusId) + .ConfigureAwait(false); + + var status = await response.LoadJsonAsync() + .ConfigureAwait(false); + + return this.mastodon.CreatePost(status); + } + + public override Task DeleteAsync() + { + if (this.IsRetweet && this.RetweetedByUserId == this.mastodon.UserId) + { + // 自分がブーストしたトゥート (自分がブーストした自分のトゥートも含む) + // => ブーストを取り消し + return this.mastodon.Api.StatusesUnreblog(this.StatusId); + } + else + { + if (this.UserId != this.mastodon.UserId) + throw new InvalidOperationException(); + + if (this.IsRetweet) + // 他人にブーストされた自分のトゥート + // => ブースト元の自分のトゥートを削除 + return this.mastodon.Api.StatusesDelete(this.RetweetedId); + else + // 自分のトゥート + // => トゥートを削除 + return this.mastodon.Api.StatusesDelete(this.StatusId); + } + } + } +} diff --git a/OpenTween/Models/PostClass.cs b/OpenTween/Models/PostClass.cs index e52e333f7..bf8ffb291 100644 --- a/OpenTween/Models/PostClass.cs +++ b/OpenTween/Models/PostClass.cs @@ -92,10 +92,6 @@ public string Text private bool isMark; - public string? InReplyToUser { get; set; } - - private long? inReplyToStatusId; - public string Source { get; set; } = ""; public Uri? SourceUri { get; set; } @@ -110,19 +106,11 @@ public string Text public bool FilterHit { get; set; } - public string? RetweetedBy { get; set; } - - public long? RetweetedId { get; set; } - private bool isDeleted = false; private StatusGeo? postGeo = null; public int RetweetedCount { get; set; } - public long? RetweetedByUserId { get; set; } - - public long? InReplyToUserId { get; set; } - public List Media { get; set; } public long[] QuoteStatusIds { get; set; } @@ -187,6 +175,71 @@ object ICloneable.Clone() public int FavoritedCount { get; set; } + private long? inReplyToStatusId; + private long? inReplyToUserId; + private string? inReplyToUser; + + private long? retweetedId; + private long? retweetedByUserId; + private string? retweetedBy; + + public bool HasInReplyTo + => this.inReplyToStatusId != null; + + public long InReplyToStatusId + { + get => this.inReplyToStatusId ?? throw new InvalidOperationException(); + set + { + this.states |= States.Reply; + this.inReplyToStatusId = value; + } + } + + public long InReplyToUserId + { + get => this.inReplyToUserId ?? throw new InvalidOperationException(); + set => this.inReplyToUserId = value; + } + + public string InReplyToUser + { + get => this.inReplyToUser ?? throw new InvalidOperationException(); + set => this.inReplyToUser = value; + } + + public bool IsRetweet + { + get => this.retweetedId != null; + set + { + if (value) + throw new InvalidOperationException(); + + this.retweetedId = null; + this.retweetedBy = null; + this.retweetedByUserId = null; + } + } + + public long RetweetedId + { + get => this.retweetedId ?? throw new InvalidOperationException(); + set => this.retweetedId = value; + } + + public long RetweetedByUserId + { + get => this.retweetedByUserId ?? throw new InvalidOperationException(); + set => this.retweetedByUserId = value; + } + + public string RetweetedBy + { + get => this.retweetedBy ?? throw new InvalidOperationException(); + set => this.retweetedBy = value; + } + private States states = States.None; private bool expandComplatedAll = false; @@ -215,7 +268,7 @@ public bool IsFav { get { - if (this.RetweetedId != null) + if (this.IsRetweet) { var post = this.RetweetSource; if (post != null) @@ -230,7 +283,7 @@ public bool IsFav set { this.isFav = value; - if (this.RetweetedId != null) + if (this.IsRetweet) { var post = this.RetweetSource; if (post != null) @@ -269,20 +322,6 @@ public bool IsMark } } - public long? InReplyToStatusId - { - get => this.inReplyToStatusId; - set - { - if (value != null) - this.states |= States.Reply; - else - this.states &= ~States.Reply; - - this.inReplyToStatusId = value; - } - } - public bool IsDeleted { get => this.isDeleted; @@ -290,9 +329,9 @@ public bool IsDeleted { if (value) { - this.InReplyToStatusId = null; - this.InReplyToUser = ""; - this.InReplyToUserId = null; + this.inReplyToStatusId = null; + this.inReplyToUser = ""; + this.inReplyToUserId = null; this.IsReply = false; this.ReplyToList = new List<(long, string)>(); this.states = States.None; @@ -302,7 +341,7 @@ public bool IsDeleted } protected virtual PostClass? RetweetSource - => this.RetweetedId != null ? TabInformations.GetInstance().RetweetSource(this.RetweetedId.Value) : null; + => this.IsRetweet ? TabInformations.GetInstance().RetweetSource(this.RetweetedId) : null; public StatusGeo? PostGeo { @@ -355,7 +394,7 @@ public bool CanDeleteBy(long selfUserId) return true; // 自分が RT したツイート - if (this.RetweetedByUserId == selfUserId) + if (this.IsRetweet && this.RetweetedByUserId == selfUserId) return true; return false; @@ -381,15 +420,15 @@ public bool CanRetweetBy(long selfUserId) public PostClass ConvertToOriginalPost() { - if (this.RetweetedId == null) + if (!this.IsRetweet) throw new InvalidOperationException(); var originalPost = this.Clone(); - originalPost.StatusId = this.RetweetedId.Value; - originalPost.RetweetedId = null; - originalPost.RetweetedBy = ""; - originalPost.RetweetedByUserId = null; + originalPost.retweetedId = null; + originalPost.retweetedBy = ""; + originalPost.retweetedByUserId = null; + originalPost.StatusId = this.RetweetedId; originalPost.RetweetedCount = 1; return originalPost; @@ -436,6 +475,18 @@ private string ReplaceToExpandedUrl(string html, out bool completedAll) return html; } + public virtual Task FavoriteAsync(SettingCommon settingCommon) + => Task.CompletedTask; + + public virtual Task UnfavoriteAsync() + => Task.CompletedTask; + + public virtual Task RetweetAsync(SettingCommon settingCommon) + => throw new NotImplementedException(); + + public virtual Task DeleteAsync() + => Task.CompletedTask; + public PostClass Clone() { var clone = (PostClass)this.MemberwiseClone(); @@ -473,8 +524,9 @@ public bool Equals(PostClass? other) (this.IsProtect == other.IsProtect) && (this.IsOwl == other.IsOwl) && (this.IsMark == other.IsMark) && - (this.InReplyToUser == other.InReplyToUser) && - (this.InReplyToStatusId == other.InReplyToStatusId) && + (this.inReplyToUser == other.inReplyToUser) && + (this.inReplyToUserId == other.inReplyToUserId) && + (this.inReplyToStatusId == other.inReplyToStatusId) && (this.Source == other.Source) && (this.SourceUri == other.SourceUri) && this.ReplyToList.SequenceEqual(other.ReplyToList) && @@ -482,10 +534,9 @@ public bool Equals(PostClass? other) (this.IsDm == other.IsDm) && (this.UserId == other.UserId) && (this.FilterHit == other.FilterHit) && - (this.RetweetedBy == other.RetweetedBy) && - (this.RetweetedId == other.RetweetedId) && - (this.IsDeleted == other.IsDeleted) && - (this.InReplyToUserId == other.InReplyToUserId); + (this.retweetedBy == other.retweetedBy) && + (this.retweetedId == other.retweetedId) && + (this.IsDeleted == other.IsDeleted); } public override int GetHashCode() diff --git a/OpenTween/Models/PostFilterRule.cs b/OpenTween/Models/PostFilterRule.cs index ad6e52bad..ec3b7e7fd 100644 --- a/OpenTween/Models/PostFilterRule.cs +++ b/OpenTween/Models/PostFilterRule.cs @@ -377,7 +377,11 @@ public void Compile() { filterExprs.Add(Expression.OrElse( this.MakeGenericFilter(postParam, "ScreenName", filterName, useRegex, caseSensitive, exactMatch: true), - this.MakeGenericFilter(postParam, "RetweetedBy", filterName, useRegex, caseSensitive, exactMatch: true))); + Expression.AndAlso( + Expression.Property(postParam, "IsRetweet"), + this.MakeGenericFilter(postParam, "RetweetedBy", filterName, useRegex, caseSensitive, exactMatch: true) + ) + )); } foreach (var body in filterBody) { @@ -405,15 +409,11 @@ public void Compile() bodyExpr, this.MakeGenericFilter(postParam, "ScreenName", body, useRegex, caseSensitive, exactMatch: true)); - // bodyExpr || x.RetweetedBy != null && + // bodyExpr || x.IsRetweet && bodyExpr = Expression.OrElse( bodyExpr, Expression.AndAlso( - Expression.NotEqual( - Expression.Property( - postParam, - typeof(PostClass).GetProperty("RetweetedBy")), - Expression.Constant(null)), + Expression.Property(postParam, "IsRetweet"), this.MakeGenericFilter(postParam, "RetweetedBy", body, useRegex, caseSensitive, exactMatch: true))); } } @@ -429,12 +429,8 @@ public void Compile() } if (filterRt) { - // x.RetweetedId != null - filterExprs.Add(Expression.NotEqual( - Expression.Property( - postParam, - typeof(PostClass).GetProperty("RetweetedId")), - Expression.Constant(null))); + // x.IsRetweet + filterExprs.Add(Expression.Property(postParam, "IsRetweet")); } if (filterExprs.Count == 0) diff --git a/OpenTween/Models/TabInformations.cs b/OpenTween/Models/TabInformations.cs index 1eac80f61..cc3bbb4bc 100644 --- a/OpenTween/Models/TabInformations.cs +++ b/OpenTween/Models/TabInformations.cs @@ -566,7 +566,7 @@ public void AddPost(PostClass item) { if (item.IsFav) { - if (item.RetweetedId == null) + if (!item.IsRetweet) { status.IsFav = true; } @@ -582,10 +582,10 @@ public void AddPost(PostClass item) } else { - if (item.IsFav && item.RetweetedId != null) item.IsFav = false; + if (item.IsFav && item.IsRetweet) item.IsFav = false; // 既に持っている公式RTは捨てる - if (item.RetweetedId != null && SettingManager.Instance.Common.HideDuplicatedRetweets) + if (item.IsRetweet && SettingManager.Instance.Common.HideDuplicatedRetweets) { var retweetCount = this.UpdateRetweetCount(item); @@ -626,7 +626,7 @@ public bool IsMuted(PostClass post, bool isHomeTimeline) if (this.MuteUserIds.Contains(post.UserId)) return true; - if (post.RetweetedByUserId != null && this.MuteUserIds.Contains(post.RetweetedByUserId.Value)) + if (post.IsRetweet && this.MuteUserIds.Contains(post.RetweetedByUserId)) return true; return false; @@ -634,10 +634,10 @@ public bool IsMuted(PostClass post, bool isHomeTimeline) private int UpdateRetweetCount(PostClass retweetPost) { - if (retweetPost.RetweetedId == null) + if (!retweetPost.IsRetweet) throw new InvalidOperationException(); - var retweetedId = retweetPost.RetweetedId.Value; + var retweetedId = retweetPost.RetweetedId; return this.retweetsCount.AddOrUpdate(retweetedId, 1, (k, v) => v >= 10 ? 1 : v + 1); } diff --git a/OpenTween/Models/TabModel.cs b/OpenTween/Models/TabModel.cs index d9c054279..5d884896b 100644 --- a/OpenTween/Models/TabModel.cs +++ b/OpenTween/Models/TabModel.cs @@ -68,11 +68,20 @@ public virtual ConcurrentDictionary Posts public long[] StatusIds => this.ids.ToArray(); + /// + /// デフォルトタブかどうかを示す値を取得します。 + /// public bool IsDefaultTabType => this.TabType.IsDefault(); - public bool IsDistributableTabType => this.TabType.IsDistributable(); + /// + /// 振り分け可能タブかどうかを示す値を取得します。 + /// + public bool IsDistributableTabType => this is FilterTabModel; - public bool IsInnerStorageTabType => this.TabType.IsInnerStorage(); + /// + /// 内部ストレージを使用するタブかどうかを示す値を取得します。 + /// + public bool IsInnerStorageTabType => this is InternalStorageTabModel; /// /// 次回起動時にも保持されるタブか(SettingTabsに保存されるか) diff --git a/OpenTween/Models/TabUsageTypeExt.cs b/OpenTween/Models/TabUsageTypeExt.cs index 3f85fe44a..c038d963b 100644 --- a/OpenTween/Models/TabUsageTypeExt.cs +++ b/OpenTween/Models/TabUsageTypeExt.cs @@ -41,35 +41,10 @@ public static class TabUsageTypeExt MyCommon.TabUsageType.Favorites | MyCommon.TabUsageType.Mute; - private const MyCommon.TabUsageType DistributableTabTypeMask = - MyCommon.TabUsageType.Mentions | - MyCommon.TabUsageType.UserDefined | - MyCommon.TabUsageType.Mute; - - private const MyCommon.TabUsageType InnerStorageTabTypeMask = - MyCommon.TabUsageType.DirectMessage | - MyCommon.TabUsageType.PublicSearch | - MyCommon.TabUsageType.Lists | - MyCommon.TabUsageType.UserTimeline | - MyCommon.TabUsageType.Related | - MyCommon.TabUsageType.SearchResults; - /// /// デフォルトタブかどうかを示す値を取得します。 /// public static bool IsDefault(this MyCommon.TabUsageType tabType) => (tabType & DefaultTabTypeMask) != 0; - - /// - /// 振り分け可能タブかどうかを示す値を取得します。 - /// - public static bool IsDistributable(this MyCommon.TabUsageType tabType) - => (tabType & DistributableTabTypeMask) != 0; - - /// - /// 内部ストレージを使用するタブかどうかを示す値を取得します。 - /// - public static bool IsInnerStorage(this MyCommon.TabUsageType tabType) - => (tabType & InnerStorageTabTypeMask) != 0; } } diff --git a/OpenTween/Models/TwitterDmPost.cs b/OpenTween/Models/TwitterDmPost.cs new file mode 100644 index 000000000..a6443d07d --- /dev/null +++ b/OpenTween/Models/TwitterDmPost.cs @@ -0,0 +1,39 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System.Globalization; +using System.Threading.Tasks; + +namespace OpenTween.Models +{ + public class TwitterDmPost : PostClass + { + private readonly Twitter twitter; + + public TwitterDmPost(Twitter twitter) + => this.twitter = twitter; + + public override Task DeleteAsync() + => this.twitter.Api.DirectMessagesEventsDestroy(this.StatusId.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/OpenTween/Models/TwitterPostFactory.cs b/OpenTween/Models/TwitterPostFactory.cs index b792b4bda..57fb8295e 100644 --- a/OpenTween/Models/TwitterPostFactory.cs +++ b/OpenTween/Models/TwitterPostFactory.cs @@ -52,13 +52,14 @@ public string[] GetReceivedHashtags() } public PostClass CreateFromStatus( + Twitter twitter, TwitterStatus status, long selfUserId, ISet followerIds, bool favTweet = false ) { - var post = new PostClass(); + var post = new TwitterStatusPost(twitter); TwitterEntities entities; string sourceHtml; @@ -76,9 +77,12 @@ public PostClass CreateFromStatus( entities = retweeted.MergedEntities; sourceHtml = retweeted.Source; // Reply先 - post.InReplyToStatusId = retweeted.InReplyToStatusId; - post.InReplyToUser = retweeted.InReplyToScreenName; - post.InReplyToUserId = status.InReplyToUserId; + if (retweeted.InReplyToStatusId != null) + { + post.InReplyToStatusId = retweeted.InReplyToStatusId.Value; + post.InReplyToUser = retweeted.InReplyToScreenName!; + post.InReplyToUserId = retweeted.InReplyToUserId!.Value; + } if (favTweet) { @@ -131,9 +135,13 @@ public PostClass CreateFromStatus( post.TextFromApi = status.FullText; entities = status.MergedEntities; sourceHtml = status.Source; - post.InReplyToStatusId = status.InReplyToStatusId; - post.InReplyToUser = status.InReplyToScreenName; - post.InReplyToUserId = status.InReplyToUserId; + + if (status.InReplyToStatusId != null) + { + post.InReplyToStatusId = status.InReplyToStatusId.Value; + post.InReplyToUser = status.InReplyToScreenName!; + post.InReplyToUserId = status.InReplyToUserId!.Value; + } if (favTweet) { @@ -187,7 +195,7 @@ public PostClass CreateFromStatus( this.ExtractEntities(entities, post.ReplyToList, post.Media); post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink) - .Where(x => x != post.StatusId && x != post.RetweetedId) + .Where(x => x != post.StatusId && !(post.IsRetweet && x == post.RetweetedId)) .Distinct().ToArray(); post.ExpandedUrls = entities.OfType() @@ -204,14 +212,15 @@ public PostClass CreateFromStatus( post.ScreenName = string.Intern(post.ScreenName); post.Nickname = string.Intern(post.Nickname); post.ImageUrl = string.Intern(post.ImageUrl); - post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null; + if (post.IsRetweet) + post.RetweetedBy = string.Intern(post.RetweetedBy); // Source整形 var (sourceText, sourceUri) = ParseSource(sourceHtml); post.Source = string.Intern(sourceText); post.SourceUri = sourceUri; - post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == selfUserId); + post.IsReply = !post.IsRetweet && post.ReplyToList.Any(x => x.UserId == selfUserId); post.IsExcludeReply = false; if (post.IsMe) @@ -229,13 +238,14 @@ public PostClass CreateFromStatus( } public PostClass CreateFromDirectMessageEvent( + Twitter twitter, TwitterMessageEvent eventItem, IReadOnlyDictionary users, IReadOnlyDictionary apps, long selfUserId ) { - var post = new PostClass(); + var post = new TwitterDmPost(twitter); post.StatusId = long.Parse(eventItem.Id); var timestamp = long.Parse(eventItem.CreatedTimestamp); diff --git a/OpenTween/Models/TwitterStatusPost.cs b/OpenTween/Models/TwitterStatusPost.cs new file mode 100644 index 000000000..3c6ac44fc --- /dev/null +++ b/OpenTween/Models/TwitterStatusPost.cs @@ -0,0 +1,120 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2017 kim_upsilon (@kim_upsilon) +// All rights reserved. +// +// This file is part of OpenTween. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +// for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program. If not, see , or write to +// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor, +// Boston, MA 02110-1301, USA. + +#nullable enable + +using System; +using System.Linq; +using System.Threading.Tasks; +using OpenTween.Api; +using OpenTween.Api.DataModel; +using OpenTween.Connection; +using OpenTween.Setting; + +namespace OpenTween.Models +{ + public class TwitterStatusPost : PostClass + { + private readonly Twitter twitter; + + public TwitterStatusPost(Twitter twitter) + => this.twitter = twitter; + + public override async Task FavoriteAsync(SettingCommon settingCommon) + { + var statusId = this.IsRetweet ? this.RetweetedId : this.StatusId; + + try + { + await this.twitter.Api.FavoritesCreate(statusId) + .IgnoreResponse() + .ConfigureAwait(false); + } + catch (TwitterApiException ex) + when (ex.Errors.All(x => x.Code == TwitterErrorCode.AlreadyFavorited)) + { + // エラーコード 139 のみの場合は成功と見なす + } + + if (settingCommon.RestrictFavCheck) + { + var status = await this.twitter.Api.StatusesShow(statusId) + .ConfigureAwait(false); + + if (status.Favorited != true) + throw new WebApiException("NG(Restricted?)"); + } + + this.IsFav = true; + } + + public override async Task UnfavoriteAsync() + { + var statusId = this.IsRetweet ? this.RetweetedId : this.StatusId; + + await this.twitter.Api.FavoritesDestroy(statusId) + .IgnoreResponse() + .ConfigureAwait(false); + + this.IsFav = false; + } + + public override Task RetweetAsync(SettingCommon settingCommon) + { + var statusId = this.IsRetweet ? this.RetweetedId : this.StatusId; + + var read = !settingCommon.UnreadManage || settingCommon.Read; + + return this.twitter.PostRetweet(statusId, read); + } + + public override Task DeleteAsync() + { + if (this.IsRetweet && this.RetweetedByUserId == this.twitter.UserId) + { + // 自分が RT したツイート (自分が RT した自分のツイートも含む) + // => RT を取り消し + return this.twitter.Api.StatusesDestroy(this.StatusId) + .IgnoreResponse(); + } + else + { + if (this.UserId != this.twitter.UserId) + throw new InvalidOperationException(); + + if (this.IsRetweet) + { + // 他人に RT された自分のツイート + // => RT 元の自分のツイートを削除 + return this.twitter.Api.StatusesDestroy(this.RetweetedId) + .IgnoreResponse(); + } + else + { + // 自分のツイート + // => ツイートを削除 + return this.twitter.Api.StatusesDestroy(this.StatusId) + .IgnoreResponse(); + } + } + } + } +} diff --git a/OpenTween/MyCommon.cs b/OpenTween/MyCommon.cs index ba9d2d90a..6deecc757 100644 --- a/OpenTween/MyCommon.cs +++ b/OpenTween/MyCommon.cs @@ -828,16 +828,16 @@ public static string GetReadableVersion(Version version) if (versionNum[3] == 0) { - return string.Format("{0}.{1}.{2}", versionNum[0], versionNum[1], versionNum[2]); + return string.Format("{0}.{1}.{2}+mastodon", versionNum[0], versionNum[1], versionNum[2]); } else { versionNum[2] = versionNum[2] + 1; if (versionNum[3] == 1) - return string.Format("{0}.{1}.{2}-dev", versionNum[0], versionNum[1], versionNum[2]); + return string.Format("{0}.{1}.{2}-dev+mastodon", versionNum[0], versionNum[1], versionNum[2]); else - return string.Format("{0}.{1}.{2}-dev+build.{3}", versionNum[0], versionNum[1], versionNum[2], versionNum[3]); + return string.Format("{0}.{1}.{2}-dev+mastodon+build.{3}", versionNum[0], versionNum[1], versionNum[2], versionNum[3]); } } @@ -845,10 +845,10 @@ public static string GetReadableVersion(Version version) public static string GetStatusUrl(PostClass post) { - if (post.RetweetedId == null) + if (!post.IsRetweet) return GetStatusUrl(post.ScreenName, post.StatusId); else - return GetStatusUrl(post.ScreenName, post.RetweetedId.Value); + return GetStatusUrl(post.ScreenName, post.RetweetedId); } public static string GetStatusUrl(string screenName, long statusId) diff --git a/OpenTween/PostStatusParams.cs b/OpenTween/PostStatusParams.cs index 971e226d9..f1ed94f58 100644 --- a/OpenTween/PostStatusParams.cs +++ b/OpenTween/PostStatusParams.cs @@ -43,5 +43,7 @@ public class PostStatusParams public IReadOnlyList ExcludeReplyUserIds { get; set; } = Array.Empty(); public string? AttachmentUrl { get; set; } + + public bool PostToMastodon { get; set; } = false; } } diff --git a/OpenTween/Setting/Panel/BasedPanel.Designer.cs b/OpenTween/Setting/Panel/BasedPanel.Designer.cs index 88c42afc9..f3f85dafd 100644 --- a/OpenTween/Setting/Panel/BasedPanel.Designer.cs +++ b/OpenTween/Setting/Panel/BasedPanel.Designer.cs @@ -35,6 +35,9 @@ private void InitializeComponent() this.AuthClearButton = new System.Windows.Forms.Button(); this.Label4 = new System.Windows.Forms.Label(); this.panel1 = new System.Windows.Forms.Panel(); + this.buttonMastodonAuth = new System.Windows.Forms.Button(); + this.labelMastodonAccount = new System.Windows.Forms.Label(); + this.label1 = new System.Windows.Forms.Label(); this.panel1.SuspendLayout(); this.SuspendLayout(); // @@ -76,9 +79,30 @@ private void InitializeComponent() this.panel1.Controls.Add(this.StartAuthButton); this.panel1.Controls.Add(this.AuthClearButton); this.panel1.Controls.Add(this.Label4); + this.panel1.Controls.Add(this.buttonMastodonAuth); + this.panel1.Controls.Add(this.labelMastodonAccount); + this.panel1.Controls.Add(this.label1); resources.ApplyResources(this.panel1, "panel1"); this.panel1.Name = "panel1"; // + // buttonMastodonAuth + // + resources.ApplyResources(this.buttonMastodonAuth, "buttonMastodonAuth"); + this.buttonMastodonAuth.Name = "buttonMastodonAuth"; + this.buttonMastodonAuth.UseVisualStyleBackColor = true; + this.buttonMastodonAuth.Click += new System.EventHandler(this.ButtonMastodonAuth_Click); + // + // labelMastodonAccount + // + this.labelMastodonAccount.AutoEllipsis = true; + resources.ApplyResources(this.labelMastodonAccount, "labelMastodonAccount"); + this.labelMastodonAccount.Name = "labelMastodonAccount"; + // + // label1 + // + resources.ApplyResources(this.label1, "label1"); + this.label1.Name = "label1"; + // // BasedPanel // resources.ApplyResources(this, "$this"); @@ -99,5 +123,8 @@ private void InitializeComponent() internal System.Windows.Forms.Button AuthClearButton; internal System.Windows.Forms.Label Label4; private System.Windows.Forms.Panel panel1; + private System.Windows.Forms.Button buttonMastodonAuth; + private System.Windows.Forms.Label labelMastodonAccount; + private System.Windows.Forms.Label label1; } } diff --git a/OpenTween/Setting/Panel/BasedPanel.cs b/OpenTween/Setting/Panel/BasedPanel.cs index 95911f992..7e507cb2a 100644 --- a/OpenTween/Setting/Panel/BasedPanel.cs +++ b/OpenTween/Setting/Panel/BasedPanel.cs @@ -34,26 +34,38 @@ using System.Linq; using System.Text; using System.Windows.Forms; +using OpenTween; namespace OpenTween.Setting.Panel { public partial class BasedPanel : SettingPanelBase { + public bool HasMastodonCredential => this.mastodonCredential != null; + + private MastodonCredential? mastodonCredential = null; + public BasedPanel() - => this.InitializeComponent(); + { + this.InitializeComponent(); + this.RefreshMastodonCredential(); + } public void LoadConfig(SettingCommon settingCommon) { + var accounts = settingCommon.UserAccounts; + using (ControlTransaction.Update(this.AuthUserCombo)) { this.AuthUserCombo.Items.Clear(); - this.AuthUserCombo.Items.AddRange(settingCommon.UserAccounts.ToArray()); + this.AuthUserCombo.Items.AddRange(accounts.ToArray()); - var selectedUserId = settingCommon.UserId; - var selectedAccount = settingCommon.UserAccounts.FirstOrDefault(x => x.UserId == selectedUserId); - if (selectedAccount != null) - this.AuthUserCombo.SelectedItem = selectedAccount; + var primaryIndex = accounts.FindIndex(x => x.Primary); + if (primaryIndex != -1) + this.AuthUserCombo.SelectedIndex = primaryIndex; } + + this.mastodonCredential = settingCommon.MastodonPrimaryAccount; + this.RefreshMastodonCredential(); } public void SaveConfig(SettingCommon settingCommon) @@ -64,11 +76,14 @@ public void SaveConfig(SettingCommon settingCommon) var selectedIndex = this.AuthUserCombo.SelectedIndex; if (selectedIndex != -1) { + foreach (var (account, index) in accounts.WithIndex()) + account.Primary = selectedIndex == index; + var selectedAccount = accounts[selectedIndex]; settingCommon.UserId = selectedAccount.UserId; settingCommon.UserName = selectedAccount.Username; - settingCommon.Token = selectedAccount.Token; - settingCommon.TokenSecret = selectedAccount.TokenSecret; + settingCommon.Token = selectedAccount.AccessToken; + settingCommon.TokenSecret = selectedAccount.AccessSecretPlain; } else { @@ -77,6 +92,25 @@ public void SaveConfig(SettingCommon settingCommon) settingCommon.Token = ""; settingCommon.TokenSecret = ""; } + + var mastodonCredential = this.mastodonCredential; + if (mastodonCredential != null) + { + mastodonCredential.Primary = true; + settingCommon.MastodonAccounts = new[] { mastodonCredential }; + } + else + { + settingCommon.MastodonAccounts = new MastodonCredential[0]; + } + } + + private void RefreshMastodonCredential() + { + if (this.mastodonCredential != null) + this.labelMastodonAccount.Text = this.mastodonCredential.Username; + else + this.labelMastodonAccount.Text = "(未設定)"; } private void AuthClearButton_Click(object sender, EventArgs e) @@ -94,5 +128,38 @@ private void AuthClearButton_Click(object sender, EventArgs e) } } } + + private async void ButtonMastodonAuth_Click(object sender, EventArgs e) + { + var ret = InputDialog.Show(this, "インスタンスのURL (例: https://mstdn.jp/)", ApplicationSettings.ApplicationName, out var instanceUriStr); + if (ret != DialogResult.OK) + return; + + if (!Uri.TryCreate(instanceUriStr, UriKind.Absolute, out var instanceUri)) + return; + + try + { + var application = await Mastodon.RegisterClientAsync(instanceUri); + + var authorizeUri = Mastodon.GetAuthorizeUri(instanceUri, application.ClientId); + + var code = ((AppendSettingDialog)this.ParentForm).ShowAuthDialog(authorizeUri); + if (MyCommon.IsNullOrEmpty(code)) + return; + + var accessToken = await Mastodon.GetAccessTokenAsync(instanceUri, application.ClientId, application.ClientSecret, code); + + this.mastodonCredential = await Mastodon.VerifyCredentialAsync(instanceUri, accessToken); + + this.RefreshMastodonCredential(); + } + catch (WebApiException ex) + { + var message = Properties.Resources.AuthorizeButton_Click2 + Environment.NewLine + ex.Message; + MessageBox.Show(this, message, "Authenticate", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + } } } diff --git a/OpenTween/Setting/Panel/BasedPanel.en.resx b/OpenTween/Setting/Panel/BasedPanel.en.resx index 6eb8d1ac7..bda2d2856 100644 --- a/OpenTween/Setting/Panel/BasedPanel.en.resx +++ b/OpenTween/Setting/Panel/BasedPanel.en.resx @@ -6,7 +6,10 @@ Remove + Authorize Sign up for Twitter account + 100, 12 + Mastodon Account 47, 12 Account Start Authentication diff --git a/OpenTween/Setting/Panel/BasedPanel.resx b/OpenTween/Setting/Panel/BasedPanel.resx index 7d65746a2..2badefbfa 100644 --- a/OpenTween/Setting/Panel/BasedPanel.resx +++ b/OpenTween/Setting/Panel/BasedPanel.resx @@ -11,7 +11,7 @@ True 0, 0, 0, 0 BasedPanel - OpenTween.Setting.Panel.SettingPanelBase, OpenTween, Version=2.4.3.1, Culture=neutral, PublicKeyToken=null + OpenTween.Setting.Panel.SettingPanelBase, OpenTween, Version=2.7.1.1, Culture=neutral, PublicKeyToken=null AuthClearButton panel1 System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 @@ -20,14 +20,26 @@ panel1 System.Windows.Forms.ComboBox, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 0 + buttonMastodonAuth + panel1 + System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + 5 CreateAccountButton panel1 System.Windows.Forms.Button, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 1 + label1 + panel1 + System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + 7 Label4 panel1 System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 4 + labelMastodonAccount + panel1 + System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + 6 panel1 $this System.Windows.Forms.Panel, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 @@ -45,19 +57,36 @@ 111, 22 160, 20 1 + NoControl + 401, 182 + 75, 23 + 17 + 認証 Bottom, Right True NoControl - 310, 320 + 310, 123 186, 25 4 Twitter アカウントを作成する + True + NoControl + 23, 187 + 98, 12 + 15 + Mastodonアカウント True NoControl 23, 25 49, 12 0 アカウント + NoControl + 138, 182 + 257, 23 + 16 + labelMastodonAccount + MiddleLeft Fill 0, 0 20, 20, 20, 20 diff --git a/OpenTween/Setting/SettingCommon.cs b/OpenTween/Setting/SettingCommon.cs index a3131e527..77ac07246 100644 --- a/OpenTween/Setting/SettingCommon.cs +++ b/OpenTween/Setting/SettingCommon.cs @@ -28,6 +28,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Windows.Forms; using System.Xml.Serialization; using OpenTween.Thumbnail; @@ -39,72 +40,57 @@ public class SettingCommon : SettingBase { #region "Settingクラス基本" public static SettingCommon Load(string settingsPath) - => LoadSettings(settingsPath); + { + var settings = LoadSettings(settingsPath); + + // UserAccount.Primary が追加される前との互換性を保つ + var accounts = settings.UserAccounts; + if (accounts.Count != 0 && settings.PrimaryAccount == null) + { + var primaryAccount = accounts.FirstOrDefault(x => x.UserId == settings.UserId) ?? accounts.First(); + primaryAccount.Primary = true; + } + + return settings; + } public void Save(string settingsPath) - => SaveSettings(this, settingsPath); + { + var primaryAccount = this.PrimaryAccount; + + // UserAccount.Primary が追加される前との互換性を保つ + this.UserId = primaryAccount?.UserId ?? 0; + this.UserName = primaryAccount?.Username ?? ""; + this.Token = primaryAccount?.AccessToken ?? ""; + this.TokenSecret = primaryAccount?.AccessSecretPlain ?? ""; + + SaveSettings(this, settingsPath); + } #endregion - public List UserAccounts = new(); - public string UserName = ""; + public List UserAccounts { get; set; } = new(); [XmlIgnore] - public string Password = ""; + public UserAccount PrimaryAccount => this.UserAccounts.FirstOrDefault(x => x.Primary); - public string EncryptPassword - { - get => this.Encrypt(this.Password); - set => this.Password = this.Decrypt(value); - } + public MastodonCredential[] MastodonAccounts { get; set; } = new MastodonCredential[0]; + + [XmlIgnore] + public MastodonCredential MastodonPrimaryAccount => this.MastodonAccounts.FirstOrDefault(x => x.Primary); + public long UserId = 0; + public string UserName = ""; public string Token = ""; + [XmlIgnore] public string TokenSecret = ""; public string EncryptTokenSecret { - get => this.Encrypt(this.TokenSecret); - set => this.TokenSecret = this.Decrypt(value); - } - - private string Encrypt(string password) - { - if (MyCommon.IsNullOrEmpty(password)) password = ""; - if (password.Length > 0) - { - try - { - return MyCommon.EncryptString(password); - } - catch (Exception) - { - return ""; - } - } - else - { - return ""; - } - } - - private string Decrypt(string password) - { - if (MyCommon.IsNullOrEmpty(password)) password = ""; - if (password.Length > 0) - { - try - { - password = MyCommon.DecryptString(password); - } - catch (Exception) - { - password = ""; - } - } - return password; + get => string.IsNullOrEmpty(this.TokenSecret) ? "" : MyCommon.EncryptString(this.TokenSecret); + set => this.TokenSecret = string.IsNullOrEmpty(value) ? "" : MyCommon.DecryptString(value); } - public long UserId = 0; public List TabList = new(); public int TimelinePeriod = 90; public int ReplyPeriod = 180; @@ -329,56 +315,46 @@ public void Validate() public class UserAccount { - public string Username = ""; - public long UserId = 0; - public string Token = ""; - [XmlIgnore] - public string TokenSecret = ""; + public bool Primary { get; set; } - public string EncryptTokenSecret - { - get => this.Encrypt(this.TokenSecret); - set => this.TokenSecret = this.Decrypt(value); - } + public string Username { get; set; } = ""; - private string Encrypt(string password) - { - if (MyCommon.IsNullOrEmpty(password)) password = ""; - if (password.Length > 0) - { - try - { - return MyCommon.EncryptString(password); - } - catch (Exception) - { - return ""; - } - } - else - { - return ""; - } - } + public long UserId { get; set; } = 0; + + [XmlElement("Token")] + public string AccessToken { get; set; } = ""; - private string Decrypt(string password) + [XmlElement("EncryptTokenSecret")] + public string AccessSecretEncrypted { get; set; } = ""; + + [XmlIgnore] + public string AccessSecretPlain { - if (MyCommon.IsNullOrEmpty(password)) password = ""; - if (password.Length > 0) - { - try - { - password = MyCommon.DecryptString(password); - } - catch (Exception) - { - password = ""; - } - } - return password; + get => MyCommon.IsNullOrEmpty(this.AccessSecretEncrypted) ? "" : MyCommon.DecryptString(this.AccessSecretEncrypted); + set => this.AccessSecretEncrypted = MyCommon.IsNullOrEmpty(value) ? "" : MyCommon.EncryptString(value); } public override string ToString() => this.Username; } -} \ No newline at end of file + + public class MastodonCredential + { + public bool Primary { get; set; } + + public string InstanceUri { get; set; } = ""; + + public string Username { get; set; } = ""; + + public long UserId { get; set; } + + public string AccessTokenEncrypted { get; set; } = ""; + + [XmlIgnore] + public string AccessTokenPlain + { + get => MyCommon.IsNullOrEmpty(this.AccessTokenEncrypted) ? "" : MyCommon.DecryptString(this.AccessTokenEncrypted); + set => this.AccessTokenEncrypted = MyCommon.IsNullOrEmpty(value) ? "" : MyCommon.EncryptString(value); + } + } +} diff --git a/OpenTween/Setting/SettingManager.cs b/OpenTween/Setting/SettingManager.cs index 44babc89b..d7e8bead2 100644 --- a/OpenTween/Setting/SettingManager.cs +++ b/OpenTween/Setting/SettingManager.cs @@ -49,7 +49,7 @@ public class SettingManager /// ユーザによる設定が必要な項目が残っているか public bool IsIncomplete - => MyCommon.IsNullOrEmpty(this.Common.UserName); + => this.Common.PrimaryAccount == null && this.Common.MastodonPrimaryAccount == null; public bool IsFirstRun { get; private set; } = false; @@ -79,8 +79,8 @@ public void LoadCommon() { Username = settings.UserName, UserId = settings.UserId, - Token = settings.Token, - TokenSecret = settings.TokenSecret, + AccessToken = settings.Token, + AccessSecretPlain = settings.TokenSecret, }; settings.UserAccounts.Add(account); diff --git a/OpenTween/Thumbnail/ThumbnailGenerator.cs b/OpenTween/Thumbnail/ThumbnailGenerator.cs index 033eb35b5..e5aac599f 100644 --- a/OpenTween/Thumbnail/ThumbnailGenerator.cs +++ b/OpenTween/Thumbnail/ThumbnailGenerator.cs @@ -70,7 +70,7 @@ public ThumbnailGenerator(ImgAzyobuziNet imgAzyobuziNet) new Vimeo(), // DirectLink - new SimpleThumbnailService(@"^https?://.*(\.jpg|\.jpeg|\.gif|\.png|\.bmp)$", "${0}"), + new SimpleThumbnailService(@"^https?://.*(\.jpg|\.jpeg|\.gif|\.png|\.bmp)(\?[^#]+)?(#.+)?$", "${0}"), // img.azyobuzi.net this.ImgAzyobuziNet, diff --git a/OpenTween/TimelineListViewCache.cs b/OpenTween/TimelineListViewCache.cs index c35b95284..43029b26b 100644 --- a/OpenTween/TimelineListViewCache.cs +++ b/OpenTween/TimelineListViewCache.cs @@ -143,7 +143,7 @@ public void PurgeCache() if (post.FavoritedCount > 0) mk.Append("+" + post.FavoritedCount); ListViewItem itm; - if (post.RetweetedId == null) + if (!post.IsRetweet) { string[] sitem = { @@ -301,7 +301,7 @@ private ListItemBackColor DetermineBackColor(PostClass? basePost, PostClass post return ListItemBackColor.None; // @先 - if (post.StatusId == basePost.InReplyToStatusId) + if (basePost.HasInReplyTo && post.StatusId == basePost.InReplyToStatusId) return ListItemBackColor.AtTo; // 自分=発言者 @@ -333,7 +333,7 @@ private ListItemForeColor DetermineForeColor(PostClass post, bool useUnreadStyle if (post.IsFav) return ListItemForeColor.Fav; - if (post.RetweetedId != null) + if (post.IsRetweet) return ListItemForeColor.Retweet; if (post.IsOwl && (post.IsDm || this.settings.OneWayLove)) diff --git a/OpenTween/Tween.cs b/OpenTween/Tween.cs index 5cc1ace7e..67ddec2ac 100644 --- a/OpenTween/Tween.cs +++ b/OpenTween/Tween.cs @@ -130,6 +130,8 @@ public partial class TweenMain : OTBaseForm // twitter解析部 private readonly Twitter tw; + private Mastodon mastodon = new Mastodon(); + // Growl呼び出し部 private readonly GrowlHelper gh = new(ApplicationSettings.ApplicationName); @@ -522,29 +524,24 @@ ThumbnailGenerator thumbGenerator 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); + var twitterAccount = this.settings.Common.PrimaryAccount; + if (twitterAccount != null) + this.tw.Initialize(twitterAccount.AccessToken, twitterAccount.AccessSecretPlain, twitterAccount.Username, twitterAccount.UserId); + else + this.tw.Initialize("", "", "", 0L); + + var mastodonAccount = this.settings.Common.MastodonPrimaryAccount; + if (mastodonAccount != null) + { + this.mastodon = new Mastodon(); + this.mastodon.Initialize(mastodonAccount); + } 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; @@ -696,6 +693,18 @@ ThumbnailGenerator thumbGenerator throw new TabException(Properties.Resources.TweenMain_LoadText1); } + this.ReloadMastodonHomeTab(startup: true); + + this.ListTabSelect(this.ListTab.SelectedTab); + + TabModel firstSelectedTab; + if (this.settings.Common.PrimaryAccount == null && this.settings.Common.MastodonPrimaryAccount != null) + firstSelectedTab = this.statuses.GetTabByType()!; + else + firstSelectedTab = this.statuses.Tabs[0]; + + var firstSelectedTabIndex = this.statuses.Tabs.IndexOf(firstSelectedTab); + this.ListTab.SelectedIndex = firstSelectedTabIndex; this.ListTabSelect(this.ListTab.SelectedTab); // タブの位置を調整する @@ -746,7 +755,11 @@ ThumbnailGenerator thumbGenerator // タイマー設定 - this.timelineScheduler.UpdateFunc[TimelineSchedulerTaskType.Home] = () => this.InvokeAsync(() => this.RefreshTabAsync()); + this.timelineScheduler.UpdateFunc[TimelineSchedulerTaskType.Home] = () => this.InvokeAsync(() => Task.WhenAll(new[] + { + this.RefreshTabAsync(), + this.RefreshTabAsync(), + })); this.timelineScheduler.UpdateFunc[TimelineSchedulerTaskType.Mention] = () => this.InvokeAsync(() => this.RefreshTabAsync()); this.timelineScheduler.UpdateFunc[TimelineSchedulerTaskType.Dm] = () => this.InvokeAsync(() => this.RefreshTabAsync()); this.timelineScheduler.UpdateFunc[TimelineSchedulerTaskType.PublicSearch] = () => this.InvokeAsync(() => this.RefreshTabAsync()); @@ -1301,6 +1314,9 @@ private async void PostButton_Click(object sender, EventArgs e) await MyCommon.OpenInBrowserAsync(this, tmp); } + // 表示中のタブが Mastodon であれば投稿先を Mastodon にする + status.PostToMastodon = this.CurrentTab is MastodonHomeTab; + await this.PostMessageAsync(status, uploadService, uploadItems); } @@ -1339,24 +1355,8 @@ private void NotifyIcon1_BalloonTipClicked(object sender, EventArgs e) this.BringToFront(); } - private static int errorCount = 0; - private static bool CheckAccountValid() - { - if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) - { - errorCount += 1; - if (errorCount > 5) - { - errorCount = 0; - Twitter.AccountState = MyCommon.ACCOUNT_STATE.Valid; - return true; - } - return false; - } - errorCount = 0; - return true; - } + => true; /// 指定された型 に合致する全てのタブを更新します private Task RefreshTabAsync() @@ -1454,26 +1454,7 @@ await Task.Run(async () => try { - try - { - await this.tw.Api.FavoritesCreate(post.RetweetedId ?? post.StatusId) - .IgnoreResponse() - .ConfigureAwait(false); - } - catch (TwitterApiException ex) - when (ex.Errors.All(x => x.Code == TwitterErrorCode.AlreadyFavorited)) - { - // エラーコード 139 のみの場合は成功と見なす - } - - if (this.settings.Common.RestrictFavCheck) - { - var status = await this.tw.Api.StatusesShow(post.RetweetedId ?? post.StatusId) - .ConfigureAwait(false); - - if (status.Favorited != true) - throw new WebApiException("NG(Restricted?)"); - } + await post.FavoriteAsync(this.settings.Common).ConfigureAwait(false); this.favTimestamps.Add(DateTimeUtc.Now); @@ -1582,9 +1563,7 @@ await Task.Run(async () => try { - await this.tw.Api.FavoritesDestroy(post.RetweetedId ?? post.StatusId) - .IgnoreResponse() - .ConfigureAwait(false); + await post.UnfavoriteAsync().ConfigureAwait(false); } catch (WebApiException) { @@ -1696,8 +1675,16 @@ await Task.Run(async () => .ConfigureAwait(false); } - post = await this.tw.PostStatus(postParamsWithMedia) - .ConfigureAwait(false); + if (postParams.PostToMastodon) + { + post = await this.mastodon.PostStatusAsync(postParamsWithMedia) + .ConfigureAwait(false); + } + else + { + post = await this.tw.PostStatus(postParamsWithMedia) + .ConfigureAwait(false); + } }); p.Report(Properties.Resources.PostWorker_RunWorkerCompletedText4); @@ -1781,8 +1768,15 @@ await Task.Run(async () => // TLに反映 if (post != null) { - this.statuses.AddPost(post); - this.statuses.DistributePosts(); + if (postParams.PostToMastodon) + { + this.statuses.GetTabByType()!.AddPostQueue(post); + } + else + { + this.statuses.AddPost(post); + this.statuses.DistributePosts(); + } this.RefreshTimeline(); } @@ -1790,7 +1784,7 @@ await Task.Run(async () => await this.RefreshTabAsync(); } - private async Task RetweetAsync(IReadOnlyList statusIds) + private async Task RetweetAsync(IReadOnlyList posts) { await this.workerSemaphore.WaitAsync(); @@ -1799,7 +1793,7 @@ private async Task RetweetAsync(IReadOnlyList statusIds) var progress = new Progress(x => this.StatusLabel.Text = x); this.RefreshTasktrayIcon(); - await this.RetweetAsyncInternal(progress, this.workerCts.Token, statusIds); + await this.RetweetAsyncInternal(progress, this.workerCts.Token, posts); } catch (WebApiException ex) { @@ -1812,7 +1806,7 @@ private async Task RetweetAsync(IReadOnlyList statusIds) } } - private async Task RetweetAsyncInternal(IProgress p, CancellationToken ct, IReadOnlyList statusIds) + private async Task RetweetAsyncInternal(IProgress p, CancellationToken ct, IReadOnlyList posts) { if (ct.IsCancellationRequested) return; @@ -1820,24 +1814,16 @@ private async Task RetweetAsyncInternal(IProgress p, CancellationToken c if (!CheckAccountValid()) throw new WebApiException("Auth error. Check your account"); - bool read; - if (!this.settings.Common.UnreadManage) - read = true; - else - read = this.initial && this.settings.Common.Read; - p.Report("Posting..."); - var posts = new List(); + var retweetedPosts = new List(); - await Task.Run(async () => + foreach (var post in posts) { - foreach (var statusId in statusIds) - { - var post = await this.tw.PostRetweet(statusId, read).ConfigureAwait(false); - if (post != null) posts.Add(post); - } - }); + var retweetedPost = await post.RetweetAsync(this.settings.Common).ConfigureAwait(false); + if (retweetedPost != null) + retweetedPosts.Add(retweetedPost); + } if (ct.IsCancellationRequested) return; @@ -1855,7 +1841,7 @@ await Task.Run(async () => // 自分のRTはTLの更新では取得できない場合があるので、 // 投稿時取得の有無に関わらず追加しておく - posts.ForEach(post => this.statuses.AddPost(post)); + retweetedPosts.ForEach(post => this.statuses.AddPost(post)); if (this.settings.Common.PostAndGet) { @@ -2283,8 +2269,8 @@ private void ContextMenuOperate_Opening(object sender, CancelEventArgs e) this.UnreadStripMenuItem.Enabled = true; this.AuthorContextMenuItem.Visible = true; this.AuthorContextMenuItem.Text = $"@{post!.ScreenName}"; - this.RetweetedByContextMenuItem.Visible = post.RetweetedByUserId != null; - this.RetweetedByContextMenuItem.Text = $"@{post.RetweetedBy}"; + this.RetweetedByContextMenuItem.Visible = post.IsRetweet; + this.RetweetedByContextMenuItem.Text = post.IsRetweet ? $"@{post.RetweetedBy}" : ""; } var tab = this.CurrentTab; if (tab.TabType == MyCommon.TabUsageType.DirectMessage || !this.ExistCurrentPost || post == null || post.IsDm) @@ -2325,7 +2311,7 @@ private void ContextMenuOperate_Opening(object sender, CancelEventArgs e) } } - if (!this.ExistCurrentPost || post == null || post.InReplyToStatusId == null) + if (!this.ExistCurrentPost || post == null || !post.HasInReplyTo) { this.RepliedStatusOpenMenuItem.Enabled = false; } @@ -2337,7 +2323,7 @@ private void ContextMenuOperate_Opening(object sender, CancelEventArgs e) if (this.ExistCurrentPost && post != null) { this.DeleteStripMenuItem.Enabled = post.CanDeleteBy(this.tw.UserId); - if (post.RetweetedByUserId == this.tw.UserId) + if (post.IsRetweet && post.RetweetedByUserId == this.tw.UserId) this.DeleteStripMenuItem.Text = Properties.Resources.DeleteMenuText2; else this.DeleteStripMenuItem.Text = Properties.Resources.DeleteMenuText1; @@ -2383,40 +2369,7 @@ private async Task DoStatusDelete() try { - if (post.IsDm) - { - await this.tw.Api.DirectMessagesEventsDestroy(post.StatusId.ToString(CultureInfo.InvariantCulture)); - } - else - { - if (post.RetweetedByUserId == this.tw.UserId) - { - // 自分が RT したツイート (自分が RT した自分のツイートも含む) - // => RT を取り消し - await this.tw.Api.StatusesDestroy(post.StatusId) - .IgnoreResponse(); - } - else - { - if (post.UserId == this.tw.UserId) - { - if (post.RetweetedId != null) - { - // 他人に RT された自分のツイート - // => RT 元の自分のツイートを削除 - await this.tw.Api.StatusesDestroy(post.RetweetedId.Value) - .IgnoreResponse(); - } - else - { - // 自分のツイート - // => ツイートを削除 - await this.tw.Api.StatusesDestroy(post.StatusId) - .IgnoreResponse(); - } - } - } - } + await post.DeleteAsync(); } catch (WebApiException ex) { @@ -2564,10 +2517,24 @@ private DialogResult ShowSettingDialog() return result; } + private void ReloadMastodonHomeTab(bool startup = false) + { + var currentTab = this.statuses.GetTabByType(); + if (currentTab != null) + this.RemoveSpecifiedTab(currentTab.TabName, false); + + var tabName = currentTab?.TabName ?? "Mastodon"; + + var newTab = new MastodonHomeTab(this.mastodon, tabName); + this.statuses.AddTab(newTab); + this.AddNewTab(newTab, startup); + } + private async void SettingStripMenuItem_Click(object sender, EventArgs e) { // 設定画面表示前のユーザー情報 var previousUserId = this.settings.Common.UserId; + var oldMastodonUser = this.settings.Common.MastodonPrimaryAccount; var oldIconCol = this.Use2ColumnsMode; if (this.ShowSettingDialog() == DialogResult.OK) @@ -2576,10 +2543,24 @@ private async void SettingStripMenuItem_Click(object sender, EventArgs e) { this.settings.ApplySettings(); - if (MyCommon.IsNullOrEmpty(this.settings.Common.Token)) + var primaryAccount = this.settings.Common.PrimaryAccount; + if (primaryAccount != null) + { + this.tw.Initialize(primaryAccount.AccessToken, primaryAccount.AccessSecretPlain, primaryAccount.Username, primaryAccount.UserId); + } + else + { this.tw.ClearAuthInfo(); + this.tw.Initialize("", "", "", 0); + } + + var primaryMastodonAccount = this.settings.Common.MastodonPrimaryAccount; + if (primaryMastodonAccount != null && primaryMastodonAccount != oldMastodonUser) + { + this.mastodon.Initialize(primaryMastodonAccount); + this.ReloadMastodonHomeTab(); + } - 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; @@ -2767,6 +2748,9 @@ private async void SettingStripMenuItem_Click(object sender, EventArgs e) if (this.tw.UserId != previousUserId) await this.DoGetFollowersMenu(); + + if (this.settings.Common.MastodonPrimaryAccount != oldMastodonUser) + await this.RefreshTabAsync(); } /// @@ -4887,7 +4871,7 @@ private void CopyStot() if (post.IsDeleted) continue; if (!isDm) { - if (post.RetweetedId != null) + if (post.IsRetweet) sb.AppendFormat("{0}:{1} [https://twitter.com/{0}/status/{2}]{3}", post.ScreenName, post.TextSingleLine, post.RetweetedId, Environment.NewLine); else sb.AppendFormat("{0}:{1} [https://twitter.com/{0}/status/{2}]{3}", post.ScreenName, post.TextSingleLine, post.StatusId, Environment.NewLine); @@ -5084,7 +5068,7 @@ private void GoPost(bool forward) } string name; - if (currentPost.RetweetedBy == null) + if (!currentPost.IsRetweet) { name = currentPost.ScreenName; } @@ -5095,7 +5079,7 @@ private void GoPost(bool forward) for (var idx = fIdx; idx != toIdx; idx += stp) { var post = tab[idx]; - if (post.RetweetedId == null) + if (!post.IsRetweet) { if (post.ScreenName == name) { @@ -5154,17 +5138,49 @@ private void GoRelPost(bool forward) tab.AnchorPost = currentPost; } + bool IsRelatedPost(PostClass anchorPost, PostClass targetPost) + { + if (anchorPost.UserId == targetPost.UserId) + return true; + + if (anchorPost.ReplyToList.Any(x => x.UserId == targetPost.UserId)) + return true; + + if (targetPost.ReplyToList.Any(x => x.UserId == anchorPost.UserId)) + return true; + + if (anchorPost.IsRetweet) + { + if (anchorPost.RetweetedByUserId == targetPost.UserId) + return true; + + if (targetPost.ReplyToList.Any(x => x.UserId == anchorPost.RetweetedByUserId)) + return true; + } + + if (targetPost.IsRetweet) + { + if (anchorPost.UserId == targetPost.RetweetedByUserId) + return true; + + if (anchorPost.ReplyToList.Any(x => x.UserId == targetPost.RetweetedByUserId)) + return true; + } + + if (anchorPost.IsRetweet && targetPost.IsRetweet) + { + if (anchorPost.RetweetedByUserId == targetPost.RetweetedByUserId) + return true; + } + + return false; + } + for (var idx = fIdx; idx != toIdx; idx += stp) { var post = tab[idx]; - if (post.ScreenName == anchorPost.ScreenName || - post.RetweetedBy == anchorPost.ScreenName || - post.ScreenName == anchorPost.RetweetedBy || - (!MyCommon.IsNullOrEmpty(post.RetweetedBy) && post.RetweetedBy == anchorPost.RetweetedBy) || - anchorPost.ReplyToList.Any(x => x.UserId == post.UserId) || - anchorPost.ReplyToList.Any(x => x.UserId == post.RetweetedByUserId) || - post.ReplyToList.Any(x => x.UserId == anchorPost.UserId) || - post.ReplyToList.Any(x => x.UserId == anchorPost.RetweetedByUserId)) + + if (IsRelatedPost(anchorPost, post)) { var listView = this.CurrentListView; this.SelectListItem(listView, idx); @@ -5293,15 +5309,18 @@ private async Task GoInReplyToPostTree() if (currentPost == null) return; - if (curTabClass.TabType == MyCommon.TabUsageType.PublicSearch && currentPost.InReplyToStatusId == null && currentPost.TextFromApi.Contains("@")) + if (curTabClass.TabType == MyCommon.TabUsageType.PublicSearch && !currentPost.HasInReplyTo && currentPost.TextFromApi.Contains("@")) { try { var post = await this.tw.GetStatusApi(false, currentPost.StatusId); - currentPost.InReplyToStatusId = post.InReplyToStatusId; - currentPost.InReplyToUser = post.InReplyToUser; - currentPost.IsReply = post.IsReply; + if (post.HasInReplyTo) + { + currentPost.InReplyToStatusId = post.InReplyToStatusId; + currentPost.InReplyToUser = post.InReplyToUser; + currentPost.IsReply = post.IsReply; + } this.listCache?.PurgeCache(); var index = curTabClass.SelectedIndex; @@ -5313,17 +5332,17 @@ private async Task GoInReplyToPostTree() } } - if (!(this.ExistCurrentPost && currentPost.InReplyToUser != null && currentPost.InReplyToStatusId != null)) return; + if (!(this.ExistCurrentPost && currentPost.HasInReplyTo)) return; if (this.replyChains == null || (this.replyChains.Count > 0 && this.replyChains.Peek().InReplyToId != currentPost.StatusId)) { this.replyChains = new Stack(); } - 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 @@ -5341,7 +5360,7 @@ from post in tab.Posts.Values { await Task.Run(async () => { - var post = await this.tw.GetStatusApi(false, currentPost.InReplyToStatusId.Value) + var post = await this.tw.GetStatusApi(false, currentPost.InReplyToStatusId) .ConfigureAwait(false); post.IsRead = true; @@ -5391,11 +5410,12 @@ private void GoBackInReplyToPostTree(bool parallel = false, bool isForward = tru if (parallel) { - if (currentPost.InReplyToStatusId != null) + if (currentPost.HasInReplyTo) { var posts = from t in this.statuses.Tabs from p in t.Posts - where p.Value.StatusId != currentPost.StatusId && p.Value.InReplyToStatusId == currentPost.InReplyToStatusId + where p.Value.StatusId != currentPost.StatusId + where p.Value.HasInReplyTo && p.Value.InReplyToStatusId == currentPost.InReplyToStatusId let indexOf = t.IndexOf(p.Value.StatusId) where indexOf > -1 orderby isForward ? indexOf : indexOf * -1 @@ -5434,7 +5454,7 @@ where indexOf > -1 { var posts = from t in this.statuses.Tabs from p in t.Posts - where p.Value.InReplyToStatusId == currentPost.StatusId + where p.Value.HasInReplyTo && p.Value.InReplyToStatusId == currentPost.StatusId let indexOf = t.IndexOf(p.Value.StatusId) where indexOf > -1 orderby indexOf @@ -6093,7 +6113,7 @@ private void MakeReplyText(bool atAll = false) if (selectedPosts.Length == 1) { var post = selectedPosts.Single(); - var inReplyToStatusId = post.RetweetedId ?? post.StatusId; + var inReplyToStatusId = post.IsRetweet ? post.RetweetedId : post.StatusId; var inReplyToScreenName = post.ScreenName; this.inReplyTo = (inReplyToStatusId, inReplyToScreenName); } @@ -6474,7 +6494,7 @@ private void TabMenuItem_Click(object sender, EventArgs e) fltDialog.Owner = this; fltDialog.SetCurrent(tab.TabName); - if (post.RetweetedBy == null) + if (!post.IsRetweet) { fltDialog.AddNewFilter(post.ScreenName, post.TextFromApi); } @@ -6594,7 +6614,7 @@ private void IDRuleMenuItem_Click(object sender, EventArgs e) return; var screenNameArray = selectedPosts - .Select(x => x.RetweetedBy ?? x.ScreenName) + .Select(x => x.IsRetweet ? x.RetweetedBy : x.ScreenName) .ToArray(); this.AddFilterRuleByScreenName(screenNameArray); @@ -7298,14 +7318,14 @@ private void SplitContainer1_SplitterMoved(object sender, SplitterEventArgs e) private async Task DoRepliedStatusOpen() { var currentPost = this.CurrentPost; - if (this.ExistCurrentPost && currentPost != null && currentPost.InReplyToUser != null && currentPost.InReplyToStatusId != null) + if (this.ExistCurrentPost && currentPost != null && currentPost.HasInReplyTo) { 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)); 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); } @@ -7313,12 +7333,12 @@ private async Task DoRepliedStatusOpen() { 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)); } } } @@ -7983,6 +8003,7 @@ private async void TweenMain_Shown(object sender, EventArgs e) this.RefreshTabAsync(), this.RefreshTabAsync(), this.RefreshTabAsync(), + this.RefreshTabAsync(), }; if (this.settings.Common.StartupFollowers) @@ -8110,9 +8131,7 @@ private async Task DoReTweetOfficial(bool isConfirm) } } - var statusIds = selectedPosts.Select(x => x.StatusId).ToList(); - - await this.RetweetAsync(statusIds); + await this.RetweetAsync(selectedPosts); } } @@ -8579,7 +8598,7 @@ private void DoReTweetUnofficial() var selection = (this.StatusText.SelectionStart, this.StatusText.SelectionLength); // 投稿時に in_reply_to_status_id を付加する - var inReplyToStatusId = post.RetweetedId ?? post.StatusId; + var inReplyToStatusId = post.IsRetweet ? post.RetweetedId : post.StatusId; var inReplyToScreenName = post.ScreenName; this.inReplyTo = (inReplyToStatusId, inReplyToScreenName); @@ -8739,7 +8758,7 @@ private void UndoRemoveTabMenuItem_Click(object sender, EventArgs e) private async Task DoMoveToRTHome() { var post = this.CurrentPost; - if (post != null && post.RetweetedId != null) + if (post != null && post.IsRetweet) await MyCommon.OpenInBrowserAsync(this, "https://twitter.com/" + post.RetweetedBy); } @@ -8885,8 +8904,8 @@ private void MenuItemOperate_DropDownOpening(object sender, EventArgs e) this.UnreadOpMenuItem.Enabled = true; this.AuthorMenuItem.Visible = true; this.AuthorMenuItem.Text = $"@{post!.ScreenName}"; - this.RetweetedByMenuItem.Visible = post.RetweetedByUserId != null; - this.RetweetedByMenuItem.Text = $"@{post.RetweetedBy}"; + this.RetweetedByMenuItem.Visible = post.IsRetweet; + this.RetweetedByMenuItem.Text = post.IsRetweet ? $"@{post.RetweetedBy}" : ""; } if (tab.TabType == MyCommon.TabUsageType.DirectMessage || !this.ExistCurrentPost || post == null || post.IsDm) @@ -8934,7 +8953,7 @@ private void MenuItemOperate_DropDownOpening(object sender, EventArgs e) { this.RefreshPrevOpMenuItem.Enabled = false; } - if (!this.ExistCurrentPost || post == null || post.InReplyToStatusId == null) + if (!this.ExistCurrentPost || post == null || !post.HasInReplyTo) { this.OpenRepSourceOpMenuItem.Enabled = false; } @@ -9105,7 +9124,7 @@ private async void RtCountMenuItem_Click(object sender, EventArgs e) if (!this.ExistCurrentPost || post == null) return; - var statusId = post.RetweetedId ?? post.StatusId; + var statusId = post.IsRetweet ? post.RetweetedId : post.StatusId; TwitterStatus status; using (var dialog = new WaitingDialog(Properties.Resources.RtCountMenuItem_ClickText1)) @@ -9365,7 +9384,7 @@ private async Task OpenRelatedTab(PostClass post) var tabPage = this.ListTab.TabPages[tabIndex]; var listView = (DetailsListView)tabPage.Tag; var targetPost = tabRelated.TargetPost; - var index = tabRelated.IndexOf(targetPost.RetweetedId ?? targetPost.StatusId); + var index = tabRelated.IndexOf(targetPost.IsRetweet ? targetPost.RetweetedId : targetPost.StatusId); if (index != -1 && index < listView.Items.Count) { @@ -9491,7 +9510,7 @@ private async Task OpenUserAppointUrl() var xUrl = this.settings.Common.UserAppointUrl; xUrl = xUrl.Replace("{ID}", post.ScreenName); - var statusId = post.RetweetedId ?? post.StatusId; + var statusId = post.IsRetweet ? post.RetweetedId : post.StatusId; xUrl = xUrl.Replace("{STATUS}", statusId.ToString()); await MyCommon.OpenInBrowserAsync(this, xUrl); diff --git a/OpenTween/TweetDetailsView.cs b/OpenTween/TweetDetailsView.cs index f1aa2f2e3..420bfa85c 100644 --- a/OpenTween/TweetDetailsView.cs +++ b/OpenTween/TweetDetailsView.cs @@ -139,7 +139,7 @@ public async Task ShowPostDetails(PostClass post) nameText = ""; } nameText += post.ScreenName + "/" + post.Nickname; - if (post.RetweetedId != null) + if (post.IsRetweet) nameText += $" (RT:{post.RetweetedBy})"; this.NameLinkLabel.Text = nameText; @@ -147,7 +147,7 @@ public async Task ShowPostDetails(PostClass post) var nameForeColor = SystemColors.ControlText; if (post.IsOwl && (SettingManager.Instance.Common.OneWayLove || post.IsDm)) nameForeColor = this.Theme.ColorOWL; - if (post.RetweetedId != null) + if (post.IsRetweet) nameForeColor = this.Theme.ColorRetweet; if (post.IsFav) nameForeColor = this.Theme.ColorFav; @@ -169,8 +169,13 @@ public async Task ShowPostDetails(PostClass post) sb.AppendFormat("(PlainText) : {0}
", post.TextFromApi); sb.AppendFormat("StatusId : {0}
", post.StatusId); sb.AppendFormat("ImageUrl : {0}
", post.ImageUrl); - sb.AppendFormat("InReplyToStatusId : {0}
", post.InReplyToStatusId); - sb.AppendFormat("InReplyToUser : {0}
", post.InReplyToUser); + + if (post.HasInReplyTo) + { + sb.AppendFormat("InReplyToStatusId : {0}
", post.InReplyToStatusId); + sb.AppendFormat("InReplyToUser : {0}
", post.InReplyToUser); + } + sb.AppendFormat("IsDM : {0}
", post.IsDm); sb.AppendFormat("IsFav : {0}
", post.IsFav); sb.AppendFormat("IsMark : {0}
", post.IsMark); @@ -193,8 +198,12 @@ public async Task ShowPostDetails(PostClass post) sb.AppendFormat("Source : {0}
", post.Source); sb.AppendFormat("UserId : {0}
", post.UserId); sb.AppendFormat("FilterHit : {0}
", post.FilterHit); - sb.AppendFormat("RetweetedBy : {0}
", post.RetweetedBy); - sb.AppendFormat("RetweetedId : {0}
", post.RetweetedId); + + if (post.IsRetweet) + { + sb.AppendFormat("RetweetedBy : {0}
", post.RetweetedBy); + sb.AppendFormat("RetweetedId : {0}
", post.RetweetedId); + } sb.AppendFormat("Media.Count : {0}
", post.Media.Count); if (post.Media.Count > 0) @@ -318,15 +327,15 @@ private void ClearUserPicture() private async Task AppendQuoteTweetAsync(PostClass post) { var quoteStatusIds = post.QuoteStatusIds; - if (quoteStatusIds.Length == 0 && post.InReplyToStatusId == null) + if (quoteStatusIds.Length == 0 && !post.HasInReplyTo) return; // 「読み込み中」テキストを表示 var loadingQuoteHtml = quoteStatusIds.Select(x => FormatQuoteTweetHtml(x, Properties.Resources.LoadingText, isReply: false)); var loadingReplyHtml = string.Empty; - if (post.InReplyToStatusId != null) - loadingReplyHtml = FormatQuoteTweetHtml(post.InReplyToStatusId.Value, Properties.Resources.LoadingText, isReply: true); + if (post.HasInReplyTo) + loadingReplyHtml = FormatQuoteTweetHtml(post.InReplyToStatusId, Properties.Resources.LoadingText, isReply: true); var body = post.Text + string.Concat(loadingQuoteHtml) + loadingReplyHtml; @@ -336,8 +345,8 @@ private async Task AppendQuoteTweetAsync(PostClass post) // 引用ツイートを読み込み var loadTweetTasks = quoteStatusIds.Select(x => this.CreateQuoteTweetHtml(x, isReply: false)).ToList(); - if (post.InReplyToStatusId != null) - loadTweetTasks.Add(this.CreateQuoteTweetHtml(post.InReplyToStatusId.Value, isReply: true)); + if (post.HasInReplyTo) + loadTweetTasks.Add(this.CreateQuoteTweetHtml(post.InReplyToStatusId, isReply: true)); var quoteHtmls = await Task.WhenAll(loadTweetTasks); diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs index d5aa0419a..b936f2636 100644 --- a/OpenTween/Twitter.cs +++ b/OpenTween/Twitter.cs @@ -357,7 +357,7 @@ await this.CreateDirectMessagesEventFromJson(messageEventSingle, read: true) if (post == null) throw new WebApiException("Err:Target isn't found."); - var target = post.RetweetedId ?? id; // 再RTの場合は元発言をRT + var target = post.IsRetweet ? post.RetweetedId : id; // 再RTの場合は元発言をRT var response = await this.Api.StatusesRetweet(target) .ConfigureAwait(false); @@ -632,7 +632,7 @@ private PostClass CreatePostsFromStatusData(TwitterStatus status) => this.CreatePostsFromStatusData(status, favTweet: false); private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet) - => this.postFactory.CreateFromStatus(status, this.UserId, this.followerId, favTweet); + => this.postFactory.CreateFromStatus(this, status, this.UserId, this.followerId, favTweet); private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read) { @@ -759,11 +759,11 @@ internal static PostClass FindTopOfReplyChain(IDictionary posts throw new ArgumentException("startStatusId (" + startStatusId + ") が posts の中から見つかりませんでした。", nameof(startStatusId)); var nextPost = posts[startStatusId]; - while (nextPost.InReplyToStatusId != null) + while (nextPost.HasInReplyTo) { - if (!posts.ContainsKey(nextPost.InReplyToStatusId.Value)) + if (!posts.ContainsKey(nextPost.InReplyToStatusId)) break; - nextPost = posts[nextPost.InReplyToStatusId.Value]; + nextPost = posts[nextPost.InReplyToStatusId]; } return nextPost; @@ -773,21 +773,20 @@ public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab) { var targetPost = tab.TargetPost; - if (targetPost.RetweetedId != null) + if (targetPost.IsRetweet) { var originalPost = targetPost.Clone(); - originalPost.StatusId = targetPost.RetweetedId.Value; - originalPost.RetweetedId = null; - originalPost.RetweetedBy = null; + originalPost.StatusId = targetPost.RetweetedId; + originalPost.IsRetweet = false; targetPost = originalPost; } var relPosts = new Dictionary(); - if (targetPost.TextFromApi.Contains("@") && targetPost.InReplyToStatusId == null) + if (targetPost.TextFromApi.Contains("@") && !targetPost.HasInReplyTo) { // 検索結果対応 var p = TabInformations.GetInstance()[targetPost.StatusId]; - if (p != null && p.InReplyToStatusId != null) + if (p != null && p.HasInReplyTo) { targetPost = p; } @@ -805,9 +804,9 @@ public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab) // in_reply_to_status_id を使用してリプライチェインを辿る var nextPost = FindTopOfReplyChain(relPosts, targetPost.StatusId); var loopCount = 1; - while (nextPost.InReplyToStatusId != null && loopCount++ <= 20) + while (nextPost.HasInReplyTo && loopCount++ <= 20) { - var inReplyToId = nextPost.InReplyToStatusId.Value; + var inReplyToId = nextPost.InReplyToStatusId; var inReplyToPost = TabInformations.GetInstance()[inReplyToId]; if (inReplyToPost == null) @@ -872,7 +871,7 @@ public async Task GetRelatedResult(bool read, RelatedPostsTabModel tab) continue; // リプライチェーンが繋がらないツイートは除外 - if (post.InReplyToStatusId == null || !relPosts.ContainsKey(post.InReplyToStatusId.Value)) + if (!post.HasInReplyTo || !relPosts.ContainsKey(post.InReplyToStatusId)) continue; relPosts.Add(post.StatusId, post); @@ -1010,7 +1009,7 @@ private void CreateDirectMessagesEventFromJson( foreach (var eventItem in events) { - var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId); + var post = this.postFactory.CreateFromDirectMessageEvent(this, eventItem, users, apps, this.UserId); post.IsRead = read; if (post.IsMe && !read && this.ReadOwnPost)