OSDN Git Service

C# 8.0 のnull許容参照型を有効化
[opentween/open-tween.git] / OpenTween / Models / PostClass.cs
1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 //           (c) 2008-2011 Moz (@syo68k)
4 //           (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 //           (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 //           (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 //           (c) 2011      Egtra (@egtra) <http://dev.activebasic.com/egtra/>
8 //           (c) 2012      kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
9 // All rights reserved.
10 //
11 // This file is part of OpenTween.
12 //
13 // This program is free software; you can redistribute it and/or modify it
14 // under the terms of the GNU General Public License as published by the Free
15 // Software Foundation; either version 3 of the License, or (at your option)
16 // any later version.
17 //
18 // This program is distributed in the hope that it will be useful, but
19 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
20 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21 // for more details.
22 //
23 // You should have received a copy of the GNU General Public License along
24 // with this program. If not, see <http://www.gnu.org/licenses/>, or write to
25 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
26 // Boston, MA 02110-1301, USA.
27
28 #nullable enable
29
30 using System;
31 using System.Collections.Generic;
32 using System.Diagnostics.CodeAnalysis;
33 using System.Linq;
34 using System.Net;
35 using System.Text;
36 using System.Threading;
37 using System.Threading.Tasks;
38
39 namespace OpenTween.Models
40 {
41     public class PostClass : ICloneable
42     {
43         public readonly struct StatusGeo : IEquatable<StatusGeo>
44         {
45             public double Longitude { get; }
46             public double Latitude { get; }
47
48             public StatusGeo(double longitude, double latitude)
49             {
50                 this.Longitude = longitude;
51                 this.Latitude = latitude;
52             }
53
54             public override int GetHashCode()
55                 => this.Longitude.GetHashCode() ^ this.Latitude.GetHashCode();
56
57             public override bool Equals(object obj)
58                 => obj is StatusGeo && this.Equals((StatusGeo)obj);
59
60             public bool Equals(StatusGeo other)
61                 => this.Longitude == other.Longitude && this.Latitude == other.Longitude;
62
63             public static bool operator ==(StatusGeo left, StatusGeo right)
64                 => left.Equals(right);
65
66             public static bool operator !=(StatusGeo left, StatusGeo right)
67                 => !left.Equals(right);
68         }
69
70         public string Nickname { get; set; } = "";
71         public string TextFromApi { get; set; } = "";
72
73         /// <summary>スクリーンリーダーでの読み上げを考慮したテキスト</summary>
74         public string AccessibleText { get; set; } = "";
75
76         public string ImageUrl { get; set; } = "";
77         public string ScreenName { get; set; } = "";
78         public DateTimeUtc CreatedAt { get; set; }
79         public long StatusId { get; set; }
80         private bool _IsFav;
81
82         public string Text
83         {
84             get
85             {
86                 if (this.expandComplatedAll)
87                     return this._text;
88
89                 var expandedHtml = this.ReplaceToExpandedUrl(this._text, out this.expandComplatedAll);
90                 if (this.expandComplatedAll)
91                     this._text = expandedHtml;
92
93                 return expandedHtml;
94             }
95             set => this._text = value;
96         }
97         private string _text = "";
98
99         public bool IsRead { get; set; }
100         public bool IsReply { get; set; }
101         public bool IsExcludeReply { get; set; }
102         private bool _IsProtect;
103         public bool IsOwl { get; set; }
104         private bool _IsMark;
105         public string? InReplyToUser { get; set; }
106         private long? _InReplyToStatusId;
107         public string Source { get; set; } = "";
108         public Uri? SourceUri { get; set; }
109         public List<(long UserId, string ScreenName)> ReplyToList { get; set; }
110         public bool IsMe { get; set; }
111         public bool IsDm { get; set; }
112         public long UserId { get; set; }
113         public bool FilterHit { get; set; }
114         public string? RetweetedBy { get; set; }
115         public long? RetweetedId { get; set; }
116         private bool _IsDeleted = false;
117         private StatusGeo? _postGeo = null;
118         public int RetweetedCount { get; set; }
119         public long? RetweetedByUserId { get; set; }
120         public long? InReplyToUserId { get; set; }
121         public List<MediaInfo> Media { get; set; }
122         public long[] QuoteStatusIds { get; set; }
123         public ExpandedUrlInfo[] ExpandedUrls { get; set; }
124
125         /// <summary>
126         /// <see cref="PostClass"/> に含まれる t.co の展開後の URL を保持するクラス
127         /// </summary>
128         public class ExpandedUrlInfo : ICloneable
129         {
130             /// <summary>展開前の t.co ドメインの URL</summary>
131             public string Url { get; }
132
133             /// <summary>展開後の URL</summary>
134             /// <remarks>
135             /// <see cref="ShortUrl"/> による展開が完了するまでは Entity に含まれる expanded_url の値を返します
136             /// </remarks>
137             public string ExpandedUrl => this._expandedUrl;
138
139             /// <summary><see cref="ShortUrl"/> による展開を行うタスク</summary>
140             public Task ExpandTask { get; private set; }
141
142             /// <summary><see cref="DeepExpandAsync"/> による展開が完了したか否か</summary>
143             public bool ExpandedCompleted => this.ExpandTask.IsCompleted;
144
145             protected string _expandedUrl;
146
147             public ExpandedUrlInfo(string url, string expandedUrl)
148                 : this(url, expandedUrl, deepExpand: true)
149             {
150             }
151
152             public ExpandedUrlInfo(string url, string expandedUrl, bool deepExpand)
153             {
154                 this.Url = url;
155                 this._expandedUrl = expandedUrl;
156
157                 if (deepExpand)
158                     this.ExpandTask = this.DeepExpandAsync();
159                 else
160                     this.ExpandTask = Task.CompletedTask;
161             }
162
163             protected virtual async Task DeepExpandAsync()
164             {
165                 var origUrl = this._expandedUrl;
166                 var newUrl = await ShortUrl.Instance.ExpandUrlAsync(origUrl)
167                     .ConfigureAwait(false);
168
169                 Interlocked.CompareExchange(ref this._expandedUrl, newUrl, origUrl);
170             }
171
172             public ExpandedUrlInfo Clone()
173                 => new ExpandedUrlInfo(this.Url, this.ExpandedUrl, deepExpand: false);
174
175             object ICloneable.Clone()
176                 => this.Clone();
177         }
178
179         public int FavoritedCount { get; set; }
180
181         private States _states = States.None;
182         private bool expandComplatedAll = false;
183
184         [Flags]
185         private enum States
186         {
187             None = 0,
188             Protect = 1,
189             Mark = 2,
190             Reply = 4,
191             Geo = 8,
192         }
193
194         public PostClass()
195         {
196             Media = new List<MediaInfo>();
197             ReplyToList = new List<(long, string)>();
198             QuoteStatusIds = Array.Empty<long>();
199             ExpandedUrls = Array.Empty<ExpandedUrlInfo>();
200         }
201
202         public string TextSingleLine
203             => this.TextFromApi.Replace("\n", " ");
204
205         public bool IsFav
206         {
207             get
208             {
209                 if (this.RetweetedId != null)
210                 {
211                     var post = this.RetweetSource;
212                     if (post != null)
213                     {
214                         return post.IsFav;
215                     }
216                 }
217
218                 return _IsFav;
219             }
220             set
221             {
222                 _IsFav = value;
223                 if (this.RetweetedId != null)
224                 {
225                     var post = this.RetweetSource;
226                     if (post != null)
227                     {
228                         post.IsFav = value;
229                     }
230                 }
231             }
232         }
233
234         public bool IsProtect
235         {
236             get => this._IsProtect;
237             set
238             {
239                 if (value)
240                     _states |= States.Protect;
241                 else
242                     _states &= ~States.Protect;
243
244                 _IsProtect = value;
245             }
246         }
247         public bool IsMark
248         {
249             get => this._IsMark;
250             set
251             {
252                 if (value)
253                     _states |= States.Mark;
254                 else
255                     _states &= ~States.Mark;
256
257                 _IsMark = value;
258             }
259         }
260         public long? InReplyToStatusId
261         {
262             get => this._InReplyToStatusId;
263             set
264             {
265                 if (value != null)
266                     _states |= States.Reply;
267                 else
268                     _states &= ~States.Reply;
269
270                 _InReplyToStatusId = value;
271             }
272         }
273
274         public bool IsDeleted
275         {
276             get => this._IsDeleted;
277             set
278             {
279                 if (value)
280                 {
281                     this.InReplyToStatusId = null;
282                     this.InReplyToUser = "";
283                     this.InReplyToUserId = null;
284                     this.IsReply = false;
285                     this.ReplyToList = new List<(long, string)>();
286                     this._states = States.None;
287                 }
288                 _IsDeleted = value;
289             }
290         }
291
292         protected virtual PostClass? RetweetSource
293             => this.RetweetedId != null ? TabInformations.GetInstance().RetweetSource(this.RetweetedId.Value) : null;
294
295         public StatusGeo? PostGeo
296         {
297             get => this._postGeo;
298             set
299             {
300                 if (value != null)
301                 {
302                     _states |= States.Geo;
303                 }
304                 else
305                 {
306                     _states &= ~States.Geo;
307                 }
308                 _postGeo = value;
309             }
310         }
311
312         public int StateIndex
313             => (int)_states - 1;
314
315         // 互換性のために用意
316         public string SourceHtml
317         {
318             get
319             {
320                 if (this.SourceUri == null)
321                     return WebUtility.HtmlEncode(this.Source);
322
323                 return string.Format("<a href=\"{0}\" rel=\"nofollow\">{1}</a>",
324                     WebUtility.HtmlEncode(this.SourceUri.AbsoluteUri), WebUtility.HtmlEncode(this.Source));
325             }
326         }
327
328         /// <summary>
329         /// このツイートが指定したユーザーによって削除可能であるかを判定します
330         /// </summary>
331         /// <param name="selfUserId">ツイートを削除しようとするユーザーのID</param>
332         /// <returns>削除可能であれば true、そうでなければ false</returns>
333         public bool CanDeleteBy(long selfUserId)
334         {
335             // 自分が送った DM と自分に届いた DM のどちらも削除可能
336             if (this.IsDm)
337                 return true;
338
339             // 自分のツイート or 他人に RT された自分のツイート
340             if (this.UserId == selfUserId)
341                 return true;
342
343             // 自分が RT したツイート
344             if (this.RetweetedByUserId == selfUserId)
345                 return true;
346
347             return false;
348         }
349
350         /// <summary>
351         /// このツイートが指定したユーザーによってリツイート可能であるかを判定します
352         /// </summary>
353         /// <param name="selfUserId">リツイートしようとするユーザーのID</param>
354         /// <returns>リツイート可能であれば true、そうでなければ false</returns>
355         public bool CanRetweetBy(long selfUserId)
356         {
357             // DM は常にリツイート不可
358             if (this.IsDm)
359                 return false;
360
361             // 自分のツイートであれば鍵垢であるかに関わらずリツイート可
362             if (this.UserId == selfUserId)
363                 return true;
364
365             return !this.IsProtect;
366         }
367
368         public PostClass ConvertToOriginalPost()
369         {
370             if (this.RetweetedId == null)
371                 throw new InvalidOperationException();
372
373             var originalPost = this.Clone();
374
375             originalPost.StatusId = this.RetweetedId.Value;
376             originalPost.RetweetedId = null;
377             originalPost.RetweetedBy = "";
378             originalPost.RetweetedByUserId = null;
379             originalPost.RetweetedCount = 1;
380
381             return originalPost;
382         }
383
384         public string GetExpandedUrl(string urlStr)
385         {
386             var urlInfo = this.ExpandedUrls.FirstOrDefault(x => x.Url == urlStr);
387             if (urlInfo == null)
388                 return urlStr;
389
390             return urlInfo.ExpandedUrl;
391         }
392
393         public string[] GetExpandedUrls()
394             => this.ExpandedUrls.Select(x => x.ExpandedUrl).ToArray();
395
396         /// <summary>
397         /// <paramref name="html"/> に含まれる短縮 URL を展開済みの URL に置換します
398         /// </summary>
399         /// <param name="html">置換する対象の HTML 文字列</param>
400         /// <param name="completedAll">全ての URL の展開が完了していれば true、未完了の URL があれば false</param>
401         private string ReplaceToExpandedUrl(string html, out bool completedAll)
402         {
403             if (this.ExpandedUrls.Length == 0)
404             {
405                 completedAll = true;
406                 return html;
407             }
408
409             completedAll = true;
410
411             foreach (var urlInfo in this.ExpandedUrls)
412             {
413                 if (!urlInfo.ExpandedCompleted)
414                     completedAll = false;
415
416                 var tcoUrl = urlInfo.Url;
417                 var expandedUrl = MyCommon.ConvertToReadableUrl(urlInfo.ExpandedUrl);
418                 html = html.Replace($"title=\"{WebUtility.HtmlEncode(tcoUrl)}\"",
419                     $"title=\"{WebUtility.HtmlEncode(expandedUrl)}\"");
420             }
421
422             return html;
423         }
424
425         public PostClass Clone()
426         {
427             var clone = (PostClass)this.MemberwiseClone();
428             clone.ReplyToList = new List<(long, string)>(this.ReplyToList);
429             clone.Media = new List<MediaInfo>(this.Media);
430             clone.QuoteStatusIds = this.QuoteStatusIds.ToArray();
431             clone.ExpandedUrls = this.ExpandedUrls.Select(x => x.Clone()).ToArray();
432
433             return clone;
434         }
435
436         object ICloneable.Clone()
437             => this.Clone();
438
439         public override bool Equals(object? obj)
440         {
441             if (obj == null || this.GetType() != obj.GetType()) return false;
442             return this.Equals((PostClass)obj);
443         }
444
445         public bool Equals(PostClass? other)
446         {
447             if (other == null) return false;
448             return (this.Nickname == other.Nickname) &&
449                     (this.TextFromApi == other.TextFromApi) &&
450                     (this.ImageUrl == other.ImageUrl) &&
451                     (this.ScreenName == other.ScreenName) &&
452                     (this.CreatedAt == other.CreatedAt) &&
453                     (this.StatusId == other.StatusId) &&
454                     (this.IsFav == other.IsFav) &&
455                     (this.Text == other.Text) &&
456                     (this.IsRead == other.IsRead) &&
457                     (this.IsReply == other.IsReply) &&
458                     (this.IsExcludeReply == other.IsExcludeReply) &&
459                     (this.IsProtect == other.IsProtect) &&
460                     (this.IsOwl == other.IsOwl) &&
461                     (this.IsMark == other.IsMark) &&
462                     (this.InReplyToUser == other.InReplyToUser) &&
463                     (this.InReplyToStatusId == other.InReplyToStatusId) &&
464                     (this.Source == other.Source) &&
465                     (this.SourceUri == other.SourceUri) &&
466                     (this.ReplyToList.SequenceEqual(other.ReplyToList)) &&
467                     (this.IsMe == other.IsMe) &&
468                     (this.IsDm == other.IsDm) &&
469                     (this.UserId == other.UserId) &&
470                     (this.FilterHit == other.FilterHit) &&
471                     (this.RetweetedBy == other.RetweetedBy) &&
472                     (this.RetweetedId == other.RetweetedId) &&
473                     (this.IsDeleted == other.IsDeleted) &&
474                     (this.InReplyToUserId == other.InReplyToUserId);
475
476         }
477
478         public override int GetHashCode()
479             => this.StatusId.GetHashCode();
480     }
481 }