decidim-comments 0.20.0 → 0.23.1.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/decidim/comments/bundle.js +66 -66
  3. data/app/assets/javascripts/decidim/comments/bundle.js.map +1 -1
  4. data/app/cells/decidim/comments/comment_activity_cell.rb +2 -22
  5. data/app/cells/decidim/comments/comment_cell.rb +22 -0
  6. data/app/cells/decidim/comments/comment_m/footer.erb +5 -0
  7. data/app/cells/decidim/comments/comment_m/top.erb +7 -0
  8. data/app/cells/decidim/comments/comment_m_cell.rb +29 -0
  9. data/app/commands/decidim/comments/create_comment.rb +8 -8
  10. data/app/events/decidim/comments/comment_by_followed_user_group_event.rb +9 -0
  11. data/app/events/decidim/comments/comment_event.rb +15 -2
  12. data/app/events/decidim/comments/user_group_mentioned_event.rb +10 -0
  13. data/app/forms/decidim/comments/comment_form.rb +17 -1
  14. data/app/frontend/application/icon.component.tsx +16 -4
  15. data/app/frontend/comments/add_comment_form.component.test.tsx +31 -29
  16. data/app/frontend/comments/add_comment_form.component.tsx +34 -18
  17. data/app/frontend/comments/comment.component.test.tsx +36 -5
  18. data/app/frontend/comments/comment.component.tsx +311 -89
  19. data/app/frontend/comments/comment_order_selector.component.tsx +26 -7
  20. data/app/frontend/comments/comment_thread.component.test.tsx +9 -8
  21. data/app/frontend/comments/comment_thread.component.tsx +3 -1
  22. data/app/frontend/comments/comments.component.test.tsx +17 -14
  23. data/app/frontend/comments/comments.component.tsx +90 -9
  24. data/app/frontend/comments/down_vote_button.component.tsx +27 -9
  25. data/app/frontend/comments/up_vote_button.component.tsx +27 -9
  26. data/app/frontend/comments/vote_button.component.tsx +4 -0
  27. data/app/frontend/comments/vote_button_component.test.tsx +14 -8
  28. data/app/frontend/entry.ts +19 -0
  29. data/app/frontend/entry_test.ts +2 -0
  30. data/app/frontend/mutations/add_comment.mutation.graphql +2 -2
  31. data/app/frontend/mutations/down_vote.mutation.graphql +2 -2
  32. data/app/frontend/mutations/up_vote.mutation.graphql +2 -2
  33. data/app/frontend/queries/comments.query.graphql +3 -3
  34. data/app/frontend/support/schema.ts +326 -0
  35. data/app/helpers/decidim/comments/comment_cells_helper.rb +33 -0
  36. data/app/models/decidim/comments/comment.rb +96 -18
  37. data/app/models/decidim/comments/seed.rb +1 -1
  38. data/app/queries/decidim/comments/metrics/comments_metric_manage.rb +1 -6
  39. data/app/queries/decidim/comments/sorted_comments.rb +8 -2
  40. data/app/scrubbers/decidim/comments/user_input_scrubber.rb +20 -0
  41. data/app/services/decidim/comments/new_comment_notification_creator.rb +28 -3
  42. data/app/types/decidim/comments/commentable_interface.rb +3 -2
  43. data/app/types/decidim/comments/commentable_mutation_type.rb +6 -3
  44. data/config/locales/am-ET.yml +1 -0
  45. data/config/locales/ar.yml +10 -1
  46. data/config/locales/bg-BG.yml +6 -0
  47. data/config/locales/bg.yml +6 -0
  48. data/config/locales/ca.yml +24 -1
  49. data/config/locales/cs.yml +36 -13
  50. data/config/locales/da-DK.yml +1 -0
  51. data/config/locales/da.yml +1 -0
  52. data/config/locales/de.yml +23 -1
  53. data/config/locales/el-GR.yml +1 -0
  54. data/config/locales/el.yml +122 -0
  55. data/config/locales/en.yml +24 -1
  56. data/config/locales/eo.yml +1 -0
  57. data/config/locales/es-MX.yml +24 -1
  58. data/config/locales/es-PY.yml +24 -1
  59. data/config/locales/es.yml +24 -1
  60. data/config/locales/et-EE.yml +1 -0
  61. data/config/locales/et.yml +1 -0
  62. data/config/locales/eu.yml +4 -1
  63. data/config/locales/fi-plain.yml +24 -1
  64. data/config/locales/fi.yml +31 -8
  65. data/config/locales/fr-CA.yml +123 -0
  66. data/config/locales/fr.yml +25 -2
  67. data/config/locales/ga-IE.yml +1 -0
  68. data/config/locales/gl.yml +4 -1
  69. data/config/locales/hr-HR.yml +1 -0
  70. data/config/locales/hr.yml +1 -0
  71. data/config/locales/hu.yml +18 -2
  72. data/config/locales/id-ID.yml +4 -1
  73. data/config/locales/is-IS.yml +74 -0
  74. data/config/locales/is.yml +76 -0
  75. data/config/locales/it.yml +23 -1
  76. data/config/locales/ja-JP.yml +120 -0
  77. data/config/locales/ja.yml +121 -0
  78. data/config/locales/ko-KR.yml +1 -0
  79. data/config/locales/ko.yml +1 -0
  80. data/config/locales/lt-LT.yml +1 -0
  81. data/config/locales/lt.yml +1 -0
  82. data/config/locales/lv.yml +118 -0
  83. data/config/locales/mt-MT.yml +1 -0
  84. data/config/locales/mt.yml +1 -0
  85. data/config/locales/nl.yml +26 -3
  86. data/config/locales/no.yml +88 -1
  87. data/config/locales/om-ET.yml +1 -0
  88. data/config/locales/pl.yml +62 -40
  89. data/config/locales/pt-BR.yml +5 -2
  90. data/config/locales/pt.yml +47 -25
  91. data/config/locales/ro-RO.yml +124 -0
  92. data/config/locales/ru.yml +4 -1
  93. data/config/locales/sk-SK.yml +116 -0
  94. data/config/locales/sk.yml +120 -0
  95. data/config/locales/sl.yml +4 -0
  96. data/config/locales/so-SO.yml +1 -0
  97. data/config/locales/sr-CS.yml +20 -0
  98. data/config/locales/sv.yml +26 -3
  99. data/config/locales/ti-ER.yml +1 -0
  100. data/config/locales/tr-TR.yml +4 -1
  101. data/config/locales/uk.yml +4 -2
  102. data/config/locales/vi-VN.yml +1 -0
  103. data/config/locales/vi.yml +1 -0
  104. data/config/locales/zh-CN.yml +121 -0
  105. data/config/locales/zh-TW.yml +1 -0
  106. data/db/migrate/20200320105911_index_foreign_keys_in_decidim_comments_comments.rb +7 -0
  107. data/db/migrate/20200706123136_make_comments_handle_i18n.rb +41 -0
  108. data/db/migrate/20200828101910_add_commentable_counter_cache_to_comments.rb +9 -0
  109. data/lib/decidim/comments.rb +1 -0
  110. data/lib/decidim/comments/api/comment_type.rb +5 -1
  111. data/lib/decidim/comments/comment_serializer.rb +7 -2
  112. data/lib/decidim/comments/comment_vote_serializer.rb +5 -1
  113. data/lib/decidim/comments/commentable.rb +11 -0
  114. data/lib/decidim/comments/comments_helper.rb +28 -4
  115. data/lib/decidim/comments/engine.rb +13 -0
  116. data/lib/decidim/comments/markdown.rb +55 -0
  117. data/lib/decidim/comments/mutation_extensions.rb +8 -0
  118. data/lib/decidim/comments/query_extensions.rb +4 -0
  119. data/lib/decidim/comments/test/factories.rb +10 -1
  120. data/lib/decidim/comments/test/shared_examples/comment_event.rb +12 -2
  121. data/lib/decidim/comments/test/shared_examples/create_comment_context.rb +3 -2
  122. data/lib/decidim/comments/version.rb +1 -1
  123. metadata +74 -11
@@ -1,8 +1,8 @@
1
- import { mount, shallow } from "enzyme";
1
+ import {mount, shallow} from "enzyme";
2
2
  import * as $ from "jquery";
3
3
  import * as React from "react";
4
4
 
5
- import { CommentFragment } from "../support/schema";
5
+ import {CommentFragment} from "../support/schema";
6
6
  import AddCommentForm from "./add_comment_form.component";
7
7
  import Comment from "./comment.component";
8
8
  import DownVoteButton from "./down_vote_button.component";
@@ -11,13 +11,15 @@ import UpVoteButton from "./up_vote_button.component";
11
11
  import generateCommentsData from "../support/generate_comments_data";
12
12
  import generateUserData from "../support/generate_user_data";
13
13
 
14
- import { loadLocaleTranslations } from "../support/load_translations";
14
+ import {loadLocaleTranslations} from "../support/load_translations";
15
15
 
16
16
  describe("<Comment />", () => {
17
+ const commentsMaxLength: number = 1000;
17
18
  const orderBy = "older";
18
19
  const rootCommentable = {
19
20
  id: "1",
20
- type: "Decidim::DummyResources::DummyResource"
21
+ type: "Decidim::DummyResources::DummyResource",
22
+ commentsMaxLength: 1000
21
23
  };
22
24
  let comment: CommentFragment;
23
25
  let session: any = null;
@@ -48,9 +50,10 @@ describe("<Comment />", () => {
48
50
  session={session}
49
51
  rootCommentable={rootCommentable}
50
52
  orderBy={orderBy}
53
+ commentsMaxLength={commentsMaxLength}
51
54
  />
52
55
  );
53
- expect(wrapper.find("article.comment").exists()).toBeTruthy();
56
+ expect(wrapper.find(".comment").exists()).toBeTruthy();
54
57
  });
55
58
 
56
59
  it("should render a time tag with comment's created at", () => {
@@ -60,6 +63,7 @@ describe("<Comment />", () => {
60
63
  session={session}
61
64
  rootCommentable={rootCommentable}
62
65
  orderBy={orderBy}
66
+ commentsMaxLength={commentsMaxLength}
63
67
  />
64
68
  );
65
69
  expect(wrapper.find("time").prop("dateTime")).toEqual(comment.createdAt);
@@ -72,6 +76,7 @@ describe("<Comment />", () => {
72
76
  session={session}
73
77
  rootCommentable={rootCommentable}
74
78
  orderBy={orderBy}
79
+ commentsMaxLength={commentsMaxLength}
75
80
  />
76
81
  );
77
82
  expect(wrapper.find("span.author__name").text()).toEqual(
@@ -86,6 +91,7 @@ describe("<Comment />", () => {
86
91
  session={session}
87
92
  rootCommentable={rootCommentable}
88
93
  orderBy={orderBy}
94
+ commentsMaxLength={commentsMaxLength}
89
95
  />
90
96
  );
91
97
  expect(wrapper.find("span.author__nickname").text()).toEqual(
@@ -105,6 +111,7 @@ describe("<Comment />", () => {
105
111
  session={session}
106
112
  rootCommentable={rootCommentable}
107
113
  orderBy={orderBy}
114
+ commentsMaxLength={commentsMaxLength}
108
115
  />
109
116
  );
110
117
  expect(
@@ -120,6 +127,7 @@ describe("<Comment />", () => {
120
127
  session={session}
121
128
  rootCommentable={rootCommentable}
122
129
  orderBy={orderBy}
130
+ commentsMaxLength={commentsMaxLength}
123
131
  />
124
132
  );
125
133
  expect(wrapper.find(".author__avatar img").prop("src")).toEqual(
@@ -134,6 +142,7 @@ describe("<Comment />", () => {
134
142
  session={session}
135
143
  rootCommentable={rootCommentable}
136
144
  orderBy={orderBy}
145
+ commentsMaxLength={commentsMaxLength}
137
146
  />
138
147
  );
139
148
  expect(wrapper.find("div.comment__content").html()).toContain(
@@ -148,6 +157,7 @@ describe("<Comment />", () => {
148
157
  session={session}
149
158
  rootCommentable={rootCommentable}
150
159
  orderBy={orderBy}
160
+ commentsMaxLength={commentsMaxLength}
151
161
  />
152
162
  );
153
163
  expect(wrapper.state()).toHaveProperty("showReplyForm", false);
@@ -160,6 +170,7 @@ describe("<Comment />", () => {
160
170
  session={session}
161
171
  rootCommentable={rootCommentable}
162
172
  orderBy={orderBy}
173
+ commentsMaxLength={commentsMaxLength}
163
174
  />
164
175
  );
165
176
  expect(wrapper.find(AddCommentForm).exists()).toBeFalsy();
@@ -181,6 +192,7 @@ describe("<Comment />", () => {
181
192
  isRootComment={true}
182
193
  rootCommentable={rootCommentable}
183
194
  orderBy={orderBy}
195
+ commentsMaxLength={commentsMaxLength}
184
196
  />
185
197
  );
186
198
  expect(wrapper.find("div.comment__additionalreply").exists()).toBeFalsy();
@@ -194,6 +206,7 @@ describe("<Comment />", () => {
194
206
  session={session}
195
207
  rootCommentable={rootCommentable}
196
208
  orderBy={orderBy}
209
+ commentsMaxLength={commentsMaxLength}
197
210
  />
198
211
  );
199
212
  expect(wrapper.find("div.comment__additionalreply").exists()).toBeFalsy();
@@ -208,6 +221,7 @@ describe("<Comment />", () => {
208
221
  isRootComment={true}
209
222
  rootCommentable={rootCommentable}
210
223
  orderBy={orderBy}
224
+ commentsMaxLength={commentsMaxLength}
211
225
  />
212
226
  );
213
227
  expect(wrapper.find("div.comment__additionalreply").exists()).toBeTruthy();
@@ -221,6 +235,7 @@ describe("<Comment />", () => {
221
235
  votable={true}
222
236
  rootCommentable={rootCommentable}
223
237
  orderBy={orderBy}
238
+ commentsMaxLength={commentsMaxLength}
224
239
  />
225
240
  );
226
241
  wrapper.find(Comment).forEach((node, idx) => {
@@ -239,6 +254,7 @@ describe("<Comment />", () => {
239
254
  articleClassName="comment comment--nested"
240
255
  rootCommentable={rootCommentable}
241
256
  orderBy={orderBy}
257
+ commentsMaxLength={commentsMaxLength}
242
258
  />
243
259
  );
244
260
  wrapper.find(Comment).forEach(node => {
@@ -255,6 +271,7 @@ describe("<Comment />", () => {
255
271
  session={session}
256
272
  rootCommentable={rootCommentable}
257
273
  orderBy={orderBy}
274
+ commentsMaxLength={commentsMaxLength}
258
275
  />
259
276
  );
260
277
  expect(wrapper.prop("articleClassName")).toEqual("comment");
@@ -267,6 +284,7 @@ describe("<Comment />", () => {
267
284
  session={session}
268
285
  rootCommentable={rootCommentable}
269
286
  orderBy={orderBy}
287
+ commentsMaxLength={commentsMaxLength}
270
288
  />
271
289
  );
272
290
  expect(wrapper.prop("isRootComment")).toBeFalsy();
@@ -284,6 +302,7 @@ describe("<Comment />", () => {
284
302
  session={session}
285
303
  rootCommentable={rootCommentable}
286
304
  orderBy={orderBy}
305
+ commentsMaxLength={commentsMaxLength}
287
306
  />
288
307
  );
289
308
  expect(wrapper.find("button.comment__reply").exists()).toBeFalsy();
@@ -302,6 +321,7 @@ describe("<Comment />", () => {
302
321
  session={session}
303
322
  rootCommentable={rootCommentable}
304
323
  orderBy={orderBy}
324
+ commentsMaxLength={commentsMaxLength}
305
325
  />
306
326
  );
307
327
  expect(wrapper.find("button.comment__reply").exists()).toBeFalsy();
@@ -314,6 +334,7 @@ describe("<Comment />", () => {
314
334
  session={session}
315
335
  rootCommentable={rootCommentable}
316
336
  orderBy={orderBy}
337
+ commentsMaxLength={commentsMaxLength}
317
338
  />
318
339
  );
319
340
  expect(wrapper.find(".flag-modal").exists()).toBeFalsy();
@@ -328,6 +349,7 @@ describe("<Comment />", () => {
328
349
  session={session}
329
350
  rootCommentable={rootCommentable}
330
351
  orderBy={orderBy}
352
+ commentsMaxLength={commentsMaxLength}
331
353
  />
332
354
  );
333
355
  expect(wrapper.find("span.alignment.label").text()).toEqual("In favor");
@@ -341,6 +363,7 @@ describe("<Comment />", () => {
341
363
  session={session}
342
364
  rootCommentable={rootCommentable}
343
365
  orderBy={orderBy}
366
+ commentsMaxLength={commentsMaxLength}
344
367
  />
345
368
  );
346
369
  expect(wrapper.find("span.alert.label").text()).toEqual("Against");
@@ -353,6 +376,7 @@ describe("<Comment />", () => {
353
376
  session={session}
354
377
  rootCommentable={rootCommentable}
355
378
  orderBy={orderBy}
379
+ commentsMaxLength={commentsMaxLength}
356
380
  />
357
381
  );
358
382
  expect(wrapper.find(".flag-modal").exists()).toBeTruthy();
@@ -367,6 +391,7 @@ describe("<Comment />", () => {
367
391
  session={session}
368
392
  rootCommentable={rootCommentable}
369
393
  orderBy={orderBy}
394
+ commentsMaxLength={commentsMaxLength}
370
395
  />
371
396
  );
372
397
  expect(wrapper.find(".flag-modal form").exists()).toBeFalsy();
@@ -382,6 +407,7 @@ describe("<Comment />", () => {
382
407
  votable={true}
383
408
  rootCommentable={rootCommentable}
384
409
  orderBy={orderBy}
410
+ commentsMaxLength={commentsMaxLength}
385
411
  />
386
412
  );
387
413
  expect(wrapper.find(UpVoteButton).prop("comment")).toEqual(comment);
@@ -395,6 +421,7 @@ describe("<Comment />", () => {
395
421
  votable={true}
396
422
  rootCommentable={rootCommentable}
397
423
  orderBy={orderBy}
424
+ commentsMaxLength={commentsMaxLength}
398
425
  />
399
426
  );
400
427
  expect(wrapper.find(DownVoteButton).prop("comment")).toEqual(comment);
@@ -413,6 +440,7 @@ describe("<Comment />", () => {
413
440
  session={session}
414
441
  rootCommentable={rootCommentable}
415
442
  orderBy={orderBy}
443
+ commentsMaxLength={commentsMaxLength}
416
444
  />
417
445
  );
418
446
  expect(wrapper.find("button.comment__reply").exists()).toBeFalsy();
@@ -425,6 +453,7 @@ describe("<Comment />", () => {
425
453
  session={session}
426
454
  rootCommentable={rootCommentable}
427
455
  orderBy={orderBy}
456
+ commentsMaxLength={commentsMaxLength}
428
457
  />
429
458
  );
430
459
  expect(wrapper.find(".flag-modal").exists()).toBeFalsy();
@@ -438,6 +467,7 @@ describe("<Comment />", () => {
438
467
  votable={true}
439
468
  rootCommentable={rootCommentable}
440
469
  orderBy={orderBy}
470
+ commentsMaxLength={commentsMaxLength}
441
471
  />
442
472
  );
443
473
  expect(wrapper.find(".comment__votes--up").exists()).toBeFalsy();
@@ -451,6 +481,7 @@ describe("<Comment />", () => {
451
481
  votable={true}
452
482
  rootCommentable={rootCommentable}
453
483
  orderBy={orderBy}
484
+ commentsMaxLength={commentsMaxLength}
454
485
  />
455
486
  );
456
487
  expect(wrapper.find(".comment__votes--down").exists()).toBeFalsy();
@@ -13,24 +13,34 @@ import {
13
13
  CommentFragment
14
14
  } from "../support/schema";
15
15
 
16
+ import { NetworkStatus } from "apollo-client";
17
+
16
18
  const { I18n } = require("react-i18nify");
17
19
 
18
20
  interface CommentProps {
19
21
  comment: CommentFragment;
20
- session: AddCommentFormSessionFragment & {
21
- user: any;
22
- } | null;
22
+ session:
23
+ | AddCommentFormSessionFragment & {
24
+ user: any;
25
+ }
26
+ | null;
23
27
  articleClassName?: string;
24
28
  isRootComment?: boolean;
25
29
  votable?: boolean;
26
30
  rootCommentable: AddCommentFormCommentableFragment;
27
31
  orderBy: string;
32
+ commentsMaxLength: number;
28
33
  }
29
34
 
30
35
  interface CommentState {
36
+ showReplies: boolean;
31
37
  showReplyForm: boolean;
32
38
  }
33
39
 
40
+ interface Dict {
41
+ [key: string]: boolean | undefined;
42
+ }
43
+
34
44
  /**
35
45
  * A single comment component with the author info and the comment's body
36
46
  * @class
@@ -44,18 +54,26 @@ class Comment extends React.Component<CommentProps, CommentState> {
44
54
  votable: false
45
55
  };
46
56
 
47
- public commentNode: HTMLElement;
57
+ public commentNode: HTMLDivElement;
48
58
 
49
59
  constructor(props: CommentProps) {
50
60
  super(props);
51
61
 
62
+ const {
63
+ comment: { id }
64
+ } = props;
65
+ const isThreadHidden = !!this.getThreadsStorage()[id];
66
+
52
67
  this.state = {
68
+ showReplies: !isThreadHidden,
53
69
  showReplyForm: false
54
70
  };
55
71
  }
56
72
 
57
73
  public componentDidMount() {
58
- const { comment: { id } } = this.props;
74
+ const {
75
+ comment: { id }
76
+ } = this.props;
59
77
  const hash = document.location.hash;
60
78
  const regex = new RegExp(`#comment_${id}`);
61
79
 
@@ -64,14 +82,14 @@ class Comment extends React.Component<CommentProps, CommentState> {
64
82
  return;
65
83
  }
66
84
  const difference = to - element.scrollTop;
67
- const perTick = difference / duration * 10;
85
+ const perTick = (difference / duration) * 10;
68
86
 
69
87
  setTimeout(() => {
70
- element.scrollTop = element.scrollTop + perTick;
71
- if (element.scrollTop === to) {
72
- return;
73
- }
74
- scrollTo(element, to, duration - 10);
88
+ element.scrollTop = element.scrollTop + perTick;
89
+ if (element.scrollTop === to) {
90
+ return;
91
+ }
92
+ scrollTo(element, to, duration - 10);
75
93
  }, 10);
76
94
  }
77
95
 
@@ -84,46 +102,91 @@ class Comment extends React.Component<CommentProps, CommentState> {
84
102
  }
85
103
  }
86
104
 
87
- public getNodeReference = (commentNode: HTMLElement) => this.commentNode = commentNode;
105
+ public getNodeReference = (commentNode: HTMLDivElement) =>
106
+ (this.commentNode = commentNode)
88
107
 
89
108
  public render(): JSX.Element {
90
- const { session, comment: { id, author, formattedBody, createdAt, formattedCreatedAt }, articleClassName } = this.props;
109
+ const {
110
+ session,
111
+ comment: { id, author, formattedBody, createdAt, formattedCreatedAt },
112
+ articleClassName
113
+ } = this.props;
91
114
  let modalName = "loginModal";
92
115
 
93
116
  if (session && session.user) {
94
117
  modalName = `flagModalComment${id}`;
95
118
  }
96
119
 
120
+ let singleCommentUrl = `${window.location.pathname}?commentId=${id}`;
121
+
122
+ if (window.location.search && window.location.search !== "") {
123
+ singleCommentUrl = `${
124
+ window.location.pathname
125
+ }${window.location.search.replace(/commentId=\d*/gi, `commentId=${id}`)}`;
126
+ }
127
+
97
128
  return (
98
- <article id={`comment_${id}`} className={articleClassName} ref={this.getNodeReference}>
129
+ <div
130
+ id={`comment_${id}`}
131
+ className={articleClassName}
132
+ ref={this.getNodeReference}
133
+ >
99
134
  <div className="comment__header">
100
135
  <div className="author-data">
101
136
  <div className="author-data__main">
102
137
  {this._renderAuthorReference()}
103
- <span><time dateTime={createdAt} title={createdAt}>{formattedCreatedAt}</time></span>
138
+ <span>
139
+ <time dateTime={createdAt} title={createdAt}>
140
+ {formattedCreatedAt}
141
+ </time>
142
+ </span>
104
143
  </div>
105
144
  <div className="author-data__extra">
106
- <button type="button" title={I18n.t("components.comment.report.title")} data-open={modalName}>
107
- <Icon name="icon-flag" iconExtraClassName="icon--small" />
145
+ <button
146
+ type="button"
147
+ className="link-alt"
148
+ title={I18n.t("components.comment.report.title")}
149
+ data-open={modalName}
150
+ >
151
+ <Icon
152
+ name="icon-flag"
153
+ iconExtraClassName="icon--small"
154
+ title={I18n.t("components.comment.report.title")}
155
+ role="img"
156
+ />
108
157
  </button>
109
158
  {this._renderFlagModal()}
159
+ <a
160
+ href={singleCommentUrl}
161
+ title={I18n.t("components.comment.single_comment_link_title")}
162
+ >
163
+ <Icon
164
+ name="icon-link-intact"
165
+ iconExtraClassName="icon--small"
166
+ title={I18n.t("components.comment.single_comment_link_title")}
167
+ role="img"
168
+ />
169
+ </a>
110
170
  </div>
111
171
  </div>
112
172
  </div>
113
173
  <div className="comment__content">
114
174
  <div>
115
175
  {this._renderAlignmentBadge()}
116
- <div dangerouslySetInnerHTML={{__html: formattedBody}} />
176
+ <div dangerouslySetInnerHTML={{ __html: formattedBody }} />
117
177
  </div>
118
178
  </div>
119
179
  <div className="comment__footer">
120
- {this._renderReplyButton()}
180
+ <div className="comment__actions">
181
+ {this._renderShowHideThreadButton()}
182
+ {this._renderReplyButton()}
183
+ </div>
121
184
  {this._renderVoteButtons()}
122
185
  </div>
123
186
  {this._renderReplies()}
124
187
  {this._renderAdditionalReplyButton()}
125
188
  {this._renderReplyForm()}
126
- </article>
189
+ </div>
127
190
  );
128
191
  }
129
192
 
@@ -132,13 +195,52 @@ class Comment extends React.Component<CommentProps, CommentState> {
132
195
  this.setState({ showReplyForm: !showReplyForm });
133
196
  }
134
197
 
198
+ private getThreadsStorage = (): Dict => {
199
+ const storage: Dict =
200
+ JSON.parse(localStorage.hiddenCommentThreads || null) || {};
201
+
202
+ return storage;
203
+ }
204
+
205
+ private saveThreadsStorage = (id: string, state: boolean) => {
206
+ const storage = this.getThreadsStorage();
207
+ storage[parseInt(id, 10)] = state;
208
+ localStorage.hiddenCommentThreads = JSON.stringify(storage);
209
+ }
210
+
211
+ private toggleReplies = () => {
212
+ const {
213
+ comment: { id }
214
+ } = this.props;
215
+ const { showReplies } = this.state;
216
+ const newState = !showReplies;
217
+
218
+ this.saveThreadsStorage(id, !newState);
219
+ this.setState({ showReplies: newState });
220
+ }
221
+
222
+ private countReplies = (comment: CommentFragment): number => {
223
+ const { comments } = comment;
224
+
225
+ if (!comments) {
226
+ return 0;
227
+ }
228
+
229
+ return (
230
+ comments.length +
231
+ comments.map(this.countReplies).reduce((a: number, b: number) => a + b, 0)
232
+ );
233
+ }
234
+
135
235
  /**
136
236
  * Render author information as a link to author's profile
137
237
  * @private
138
238
  * @returns {DOMElement} - Render a link with the author information
139
239
  */
140
240
  private _renderAuthorReference() {
141
- const { comment: { author } } = this.props;
241
+ const {
242
+ comment: { author }
243
+ } = this.props;
142
244
 
143
245
  if (author.profilePath === "") {
144
246
  return this._renderAuthor();
@@ -153,7 +255,9 @@ class Comment extends React.Component<CommentProps, CommentState> {
153
255
  * @returns {DOMElement} - Render all the author information
154
256
  */
155
257
  private _renderAuthor() {
156
- const { comment: { author } } = this.props;
258
+ const {
259
+ comment: { author }
260
+ } = this.props;
157
261
 
158
262
  if (author.deleted) {
159
263
  return this._renderDeletedAuthor();
@@ -168,7 +272,9 @@ class Comment extends React.Component<CommentProps, CommentState> {
168
272
  * @returns {DOMElement} - Render all the author information
169
273
  */
170
274
  private _renderDeletedAuthor() {
171
- const { comment: { author } } = this.props;
275
+ const {
276
+ comment: { author }
277
+ } = this.props;
172
278
 
173
279
  return (
174
280
  <div className="author author--inline">
@@ -190,7 +296,9 @@ class Comment extends React.Component<CommentProps, CommentState> {
190
296
  * @returns {DOMElement} - Render all the author information
191
297
  */
192
298
  private _renderActiveAuthor() {
193
- const { comment: { author } } = this.props;
299
+ const {
300
+ comment: { author }
301
+ } = this.props;
194
302
 
195
303
  return (
196
304
  <div className="author author--inline">
@@ -198,11 +306,11 @@ class Comment extends React.Component<CommentProps, CommentState> {
198
306
  <img src={author.avatarUrl} alt="author-avatar" />
199
307
  </span>
200
308
  <span className="author__name">{author.name}</span>
201
- { author.badge === "" ||
309
+ {author.badge === "" || (
202
310
  <span className="author__badge">
203
311
  <Icon name={`icon-${author.badge}`} />
204
312
  </span>
205
- }
313
+ )}
206
314
  <span className="author__nickname">{author.nickname}</span>
207
315
  </div>
208
316
  );
@@ -214,15 +322,21 @@ class Comment extends React.Component<CommentProps, CommentState> {
214
322
  * @returns {Void|DOMElement} - Render the reply button or not if user can reply
215
323
  */
216
324
  private _renderReplyButton() {
217
- const { comment: { acceptsNewComments, userAllowedToComment }, session } = this.props;
325
+ const {
326
+ comment: { id, acceptsNewComments, userAllowedToComment },
327
+ session
328
+ } = this.props;
218
329
 
219
330
  if (session && acceptsNewComments && userAllowedToComment) {
220
331
  return (
221
332
  <button
222
333
  className="comment__reply muted-link"
223
- aria-controls="comment1-reply"
334
+ aria-controls={`comment${id}-reply`}
335
+ data-toggle={`comment${id}-reply`}
224
336
  onClick={this.toggleReplyForm}
225
337
  >
338
+ <Icon name="icon-pencil" iconExtraClassName="icon--small" />
339
+ &nbsp;
226
340
  {I18n.t("components.comment.reply")}
227
341
  </button>
228
342
  );
@@ -237,17 +351,25 @@ class Comment extends React.Component<CommentProps, CommentState> {
237
351
  * @returns {Void|DOMElement} - Render the reply button or not if user can reply
238
352
  */
239
353
  private _renderAdditionalReplyButton() {
240
- const { comment: { acceptsNewComments, hasComments, userAllowedToComment }, session, isRootComment } = this.props;
354
+ const {
355
+ comment: { id, acceptsNewComments, hasComments, userAllowedToComment },
356
+ session,
357
+ isRootComment
358
+ } = this.props;
359
+ const { showReplies } = this.state;
241
360
 
242
361
  if (session && acceptsNewComments && userAllowedToComment) {
243
- if (hasComments && isRootComment) {
362
+ if (hasComments && isRootComment && showReplies) {
244
363
  return (
245
364
  <div className="comment__additionalreply">
246
365
  <button
247
366
  className="comment__reply muted-link"
248
- aria-controls="comment1-reply"
367
+ aria-controls={`comment${id}-reply`}
368
+ data-toggle={`comment${id}-reply`}
249
369
  onClick={this.toggleReplyForm}
250
370
  >
371
+ <Icon name="icon-pencil" iconExtraClassName="icon--small" />
372
+ &nbsp;
251
373
  {I18n.t("components.comment.reply")}
252
374
  </button>
253
375
  </div>
@@ -257,6 +379,40 @@ class Comment extends React.Component<CommentProps, CommentState> {
257
379
  return null;
258
380
  }
259
381
 
382
+ /**
383
+ * Render show/hide thread button if comment is top-level and has children.
384
+ * @private
385
+ * @returns {Void|DOMElement} - Render the reply button or not
386
+ */
387
+ private _renderShowHideThreadButton() {
388
+ const { comment, isRootComment } = this.props;
389
+ const { id, hasComments } = comment;
390
+ const { showReplies } = this.state;
391
+
392
+ if (hasComments && isRootComment) {
393
+ return (
394
+ <button
395
+ className={`comment__reply muted-link ${
396
+ showReplies ? "comment__is-open" : ""
397
+ }`}
398
+ onClick={this.toggleReplies}
399
+ >
400
+ <Icon name="icon-comment-square" iconExtraClassName="icon--small" />
401
+ &nbsp;
402
+ <span className="comment__text-is-closed">
403
+ {I18n.t("components.comment.show_replies", {
404
+ replies_count: this.countReplies(comment)
405
+ })}
406
+ </span>
407
+ <span className="comment__text-is-open">
408
+ {I18n.t("components.comment.hide_replies")}
409
+ </span>
410
+ </button>
411
+ );
412
+ }
413
+ return null;
414
+ }
415
+
260
416
  /**
261
417
  * Render upVote and downVote buttons when the comment is votable
262
418
  * @private
@@ -264,13 +420,25 @@ class Comment extends React.Component<CommentProps, CommentState> {
264
420
  */
265
421
  private _renderVoteButtons() {
266
422
  const { session, comment, votable, rootCommentable, orderBy } = this.props;
267
- const { comment: { userAllowedToComment } } = this.props;
423
+ const {
424
+ comment: { userAllowedToComment }
425
+ } = this.props;
268
426
 
269
427
  if (votable && userAllowedToComment) {
270
428
  return (
271
429
  <div className="comment__votes">
272
- <UpVoteButton session={session} comment={comment} rootCommentable={rootCommentable} orderBy={orderBy} />
273
- <DownVoteButton session={session} comment={comment} rootCommentable={rootCommentable} orderBy={orderBy} />
430
+ <UpVoteButton
431
+ session={session}
432
+ comment={comment}
433
+ rootCommentable={rootCommentable}
434
+ orderBy={orderBy}
435
+ />
436
+ <DownVoteButton
437
+ session={session}
438
+ comment={comment}
439
+ rootCommentable={rootCommentable}
440
+ orderBy={orderBy}
441
+ />
274
442
  </div>
275
443
  );
276
444
  }
@@ -284,7 +452,16 @@ class Comment extends React.Component<CommentProps, CommentState> {
284
452
  * @returns {Void|DomElement} - A wrapper element with comment's comments inside
285
453
  */
286
454
  private _renderReplies() {
287
- const { comment: { id, hasComments, comments }, session, votable, articleClassName, rootCommentable, orderBy } = this.props;
455
+ const {
456
+ comment: { id, hasComments, comments },
457
+ session,
458
+ votable,
459
+ articleClassName,
460
+ rootCommentable,
461
+ orderBy,
462
+ commentsMaxLength
463
+ } = this.props;
464
+ const { showReplies } = this.state;
288
465
  let replyArticleClassName = "comment comment--nested";
289
466
 
290
467
  if (articleClassName === "comment comment--nested") {
@@ -293,20 +470,19 @@ class Comment extends React.Component<CommentProps, CommentState> {
293
470
 
294
471
  if (hasComments) {
295
472
  return (
296
- <div>
297
- {
298
- comments.map((reply: CommentFragment) => (
299
- <Comment
300
- key={`comment_${id}_reply_${reply.id}`}
301
- comment={reply}
302
- session={session}
303
- votable={votable}
304
- articleClassName={replyArticleClassName}
305
- rootCommentable={rootCommentable}
306
- orderBy={orderBy}
307
- />
308
- ))
309
- }
473
+ <div id={`comment-${id}-replies`} className={showReplies ? "" : "hide"}>
474
+ {comments.map((reply: CommentFragment) => (
475
+ <Comment
476
+ key={`comment_${id}_reply_${reply.id}`}
477
+ comment={reply}
478
+ session={session}
479
+ votable={votable}
480
+ articleClassName={replyArticleClassName}
481
+ rootCommentable={rootCommentable}
482
+ orderBy={orderBy}
483
+ commentsMaxLength={commentsMaxLength}
484
+ />
485
+ ))}
310
486
  </div>
311
487
  );
312
488
  }
@@ -320,9 +496,11 @@ class Comment extends React.Component<CommentProps, CommentState> {
320
496
  * @returns {Void|ReactElement} - Render the AddCommentForm component or not
321
497
  */
322
498
  private _renderReplyForm() {
323
- const { session, comment, rootCommentable, orderBy } = this.props;
499
+ const { session, comment, rootCommentable, orderBy, commentsMaxLength } = this.props;
324
500
  const { showReplyForm } = this.state;
325
- const { comment: { userAllowedToComment } } = this.props;
501
+ const {
502
+ comment: { userAllowedToComment }
503
+ } = this.props;
326
504
 
327
505
  if (session && showReplyForm && userAllowedToComment) {
328
506
  return (
@@ -335,6 +513,7 @@ class Comment extends React.Component<CommentProps, CommentState> {
335
513
  autoFocus={true}
336
514
  rootCommentable={rootCommentable}
337
515
  orderBy={orderBy}
516
+ commentsMaxLength={commentsMaxLength}
338
517
  />
339
518
  );
340
519
  }
@@ -348,7 +527,9 @@ class Comment extends React.Component<CommentProps, CommentState> {
348
527
  * @returns {Void|DOMElement} - The alignment's badge or not
349
528
  */
350
529
  private _renderAlignmentBadge() {
351
- const { comment: { alignment } } = this.props;
530
+ const {
531
+ comment: { alignment }
532
+ } = this.props;
352
533
  const spanClassName = classnames("label alignment", {
353
534
  success: alignment === 1,
354
535
  alert: alignment === -1
@@ -380,7 +561,10 @@ class Comment extends React.Component<CommentProps, CommentState> {
380
561
  * @return {Void|DOMElement} - The comment's report modal or not.
381
562
  */
382
563
  private _renderFlagModal() {
383
- const { session, comment: { id, sgid, alreadyReported, userAllowedToComment } } = this.props;
564
+ const {
565
+ session,
566
+ comment: { id, sgid, alreadyReported, userAllowedToComment }
567
+ } = this.props;
384
568
  const authenticityToken = this._getAuthenticityToken();
385
569
 
386
570
  const closeModal = () => {
@@ -389,9 +573,15 @@ class Comment extends React.Component<CommentProps, CommentState> {
389
573
 
390
574
  if (session && session.user && userAllowedToComment) {
391
575
  return (
392
- <div className="reveal flag-modal" id={`flagModalComment${id}`} data-reveal={true}>
576
+ <div
577
+ className="reveal flag-modal"
578
+ id={`flagModalComment${id}`}
579
+ data-reveal={true}
580
+ >
393
581
  <div className="reveal__header">
394
- <h3 className="reveal__title">{I18n.t("components.comment.report.title")}</h3>
582
+ <h3 className="reveal__title">
583
+ {I18n.t("components.comment.report.title")}
584
+ </h3>
395
585
  <button
396
586
  className="close-button"
397
587
  aria-label={I18n.t("components.comment.report.close")}
@@ -401,40 +591,72 @@ class Comment extends React.Component<CommentProps, CommentState> {
401
591
  <span aria-hidden="true">&times;</span>
402
592
  </button>
403
593
  </div>
404
- {
405
- (() => {
406
- if (alreadyReported) {
407
- return (
408
- <p key={`already-reported-comment-${id}`}>{I18n.t("components.comment.report.already_reported")}</p>
409
- );
410
- }
411
- return [
412
- <p key={`report-description-comment-${id}`}>{I18n.t("components.comment.report.description")}</p>,
413
- (
414
- <form key={`report-form-comment-${id}`} method="post" action={`/report?sgid=${sgid}`}>
415
- <input type="hidden" name="authenticity_token" value={authenticityToken} />
416
- <label htmlFor={`report_comment_${id}_reason_spam`}>
417
- <input type="radio" value="spam" name="report[reason]" id={`report_comment_${id}_reason_spam`} defaultChecked={true} />
418
- {I18n.t("components.comment.report.reasons.spam")}
419
- </label>
420
- <label htmlFor={`report_comment_${id}_reason_offensive`}>
421
- <input type="radio" value="offensive" name="report[reason]" id={`report_comment_${id}_reason_offensive`} />
422
- {I18n.t("components.comment.report.reasons.offensive")}
423
- </label>
424
- <label htmlFor={`report_comment_${id}_reason_does_not_belong`}>
425
- <input type="radio" value="does_not_belong" name="report[reason]" id={`report_comment_${id}_reason_does_not_belong`} />
426
- {I18n.t("components.comment.report.reasons.does_not_belong", { organization_name: session.user.organizationName })}
427
- </label>
428
- <label htmlFor={`report_comment_${id}_details`}>
429
- {I18n.t("components.comment.report.details")}
430
- <textarea rows={4} name="report[details]" id={`report_comment_${id}_details`} />
431
- </label>
432
- <button type="submit" name="commit" className="button">{I18n.t("components.comment.report.action")}</button>
433
- </form>
434
- )
435
- ];
436
- })()
437
- }
594
+ {(() => {
595
+ if (alreadyReported) {
596
+ return (
597
+ <p key={`already-reported-comment-${id}`}>
598
+ {I18n.t("components.comment.report.already_reported")}
599
+ </p>
600
+ );
601
+ }
602
+ return [
603
+ <p key={`report-description-comment-${id}`}>
604
+ {I18n.t("components.comment.report.description")}
605
+ </p>,
606
+ <form
607
+ key={`report-form-comment-${id}`}
608
+ method="post"
609
+ action={`/report?sgid=${sgid}`}
610
+ >
611
+ <input
612
+ type="hidden"
613
+ name="authenticity_token"
614
+ value={authenticityToken}
615
+ />
616
+ <label htmlFor={`report_comment_${id}_reason_spam`}>
617
+ <input
618
+ type="radio"
619
+ value="spam"
620
+ name="report[reason]"
621
+ id={`report_comment_${id}_reason_spam`}
622
+ defaultChecked={true}
623
+ />
624
+ {I18n.t("components.comment.report.reasons.spam")}
625
+ </label>
626
+ <label htmlFor={`report_comment_${id}_reason_offensive`}>
627
+ <input
628
+ type="radio"
629
+ value="offensive"
630
+ name="report[reason]"
631
+ id={`report_comment_${id}_reason_offensive`}
632
+ />
633
+ {I18n.t("components.comment.report.reasons.offensive")}
634
+ </label>
635
+ <label htmlFor={`report_comment_${id}_reason_does_not_belong`}>
636
+ <input
637
+ type="radio"
638
+ value="does_not_belong"
639
+ name="report[reason]"
640
+ id={`report_comment_${id}_reason_does_not_belong`}
641
+ />
642
+ {I18n.t("components.comment.report.reasons.does_not_belong", {
643
+ organization_name: session.user.organizationName
644
+ })}
645
+ </label>
646
+ <label htmlFor={`report_comment_${id}_details`}>
647
+ {I18n.t("components.comment.report.details")}
648
+ <textarea
649
+ rows={4}
650
+ name="report[details]"
651
+ id={`report_comment_${id}_details`}
652
+ />
653
+ </label>
654
+ <button type="submit" name="commit" className="button">
655
+ {I18n.t("components.comment.report.action")}
656
+ </button>
657
+ </form>
658
+ ];
659
+ })()}
438
660
  </div>
439
661
  );
440
662
  }