@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 * @concrete-extensible
5 */
6class PhabricatorApplicationTransactionView extends AphrontView {
7
8 private $transactions;
9 private $engine;
10 private $showEditActions = true;
11 private $isPreview;
12 private $object;
13 private $objectPHID;
14 private $shouldTerminate = false;
15 private $quoteTargetID;
16 private $quoteRef;
17 private $pager;
18 private $renderAsFeed;
19 private $hideCommentOptions = false;
20 private $viewData = array();
21
22 public function setRenderAsFeed($feed) {
23 $this->renderAsFeed = $feed;
24 return $this;
25 }
26
27 public function setQuoteRef($quote_ref) {
28 $this->quoteRef = $quote_ref;
29 return $this;
30 }
31
32 public function getQuoteRef() {
33 return $this->quoteRef;
34 }
35
36 public function setQuoteTargetID($quote_target_id) {
37 $this->quoteTargetID = $quote_target_id;
38 return $this;
39 }
40
41 public function getQuoteTargetID() {
42 return $this->quoteTargetID;
43 }
44
45 public function setObject(
46 PhabricatorApplicationTransactionInterface $object) {
47 $this->object = $object;
48 return $this;
49 }
50
51 private function getObject() {
52 return $this->object;
53 }
54
55 public function setObjectPHID($object_phid) {
56 $this->objectPHID = $object_phid;
57 return $this;
58 }
59
60 public function getObjectPHID() {
61 return $this->objectPHID;
62 }
63
64 public function setIsPreview($is_preview) {
65 $this->isPreview = $is_preview;
66 return $this;
67 }
68
69 public function getIsPreview() {
70 return $this->isPreview;
71 }
72
73 public function setShowEditActions($show_edit_actions) {
74 $this->showEditActions = $show_edit_actions;
75 return $this;
76 }
77
78 public function getShowEditActions() {
79 return $this->showEditActions;
80 }
81
82 public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
83 $this->engine = $engine;
84 return $this;
85 }
86
87 /**
88 * @param array<PhabricatorApplicationTransaction> $transactions
89 */
90 public function setTransactions(array $transactions) {
91 assert_instances_of($transactions,
92 PhabricatorApplicationTransaction::class);
93 $this->transactions = $transactions;
94 return $this;
95 }
96
97 public function getTransactions() {
98 return $this->transactions;
99 }
100
101 public function setShouldTerminate($term) {
102 $this->shouldTerminate = $term;
103 return $this;
104 }
105
106 public function setPager(AphrontCursorPagerView $pager) {
107 $this->pager = $pager;
108 return $this;
109 }
110
111 public function getPager() {
112 return $this->pager;
113 }
114
115 public function setHideCommentOptions($hide_comment_options) {
116 $this->hideCommentOptions = $hide_comment_options;
117 return $this;
118 }
119
120 public function getHideCommentOptions() {
121 return $this->hideCommentOptions;
122 }
123
124 public function setViewData(array $view_data) {
125 $this->viewData = $view_data;
126 return $this;
127 }
128
129 public function getViewData() {
130 return $this->viewData;
131 }
132
133 public function buildEvents($with_hiding = false) {
134 $user = $this->getUser();
135
136 $xactions = $this->transactions;
137
138 $xactions = $this->filterHiddenTransactions($xactions);
139 $xactions = $this->groupRelatedTransactions($xactions);
140 $groups = $this->groupDisplayTransactions($xactions);
141
142 // If the viewer has interacted with this object, we hide things from
143 // before their most recent interaction by default. This tends to make
144 // very long threads much more manageable, because you don't have to
145 // scroll through a lot of history and can focus on just new stuff.
146
147 $show_group = null;
148
149 if ($with_hiding) {
150 // Find the most recent comment by the viewer.
151 $group_keys = array_keys($groups);
152 $group_keys = array_reverse($group_keys);
153
154 // If we would only hide a small number of transactions, don't hide
155 // anything. Just don't examine the last few keys. Also, we always
156 // want to show the most recent pieces of activity, so don't examine
157 // the first few keys either.
158 $group_keys = array_slice($group_keys, 2, -2);
159
160 $type_comment = PhabricatorTransactions::TYPE_COMMENT;
161 foreach ($group_keys as $group_key) {
162 $group = $groups[$group_key];
163 foreach ($group as $xaction) {
164 if ($xaction->getAuthorPHID() == $user->getPHID() &&
165 $xaction->getTransactionType() == $type_comment) {
166 // This is the most recent group where the user commented.
167 $show_group = $group_key;
168 break 2;
169 }
170 }
171 }
172 }
173
174 $events = array();
175 $hide_by_default = ($show_group !== null);
176 $set_next_page_id = false;
177
178 foreach ($groups as $group_key => $group) {
179 if ($hide_by_default && ($show_group === $group_key)) {
180 $hide_by_default = false;
181 $set_next_page_id = true;
182 }
183
184 $group_event = null;
185 foreach ($group as $xaction) {
186 $event = $this->renderEvent($xaction, $group);
187 $event->setHideByDefault($hide_by_default);
188 if (!$group_event) {
189 $group_event = $event;
190 } else {
191 $group_event->addEventToGroup($event);
192 }
193 if ($set_next_page_id) {
194 $set_next_page_id = false;
195 $pager = $this->getPager();
196 if ($pager) {
197 $pager->setNextPageID($xaction->getID());
198 }
199 }
200 }
201 $events[] = $group_event;
202
203 }
204
205 return $events;
206 }
207
208 public function render() {
209 if (!$this->getObjectPHID()) {
210 throw new PhutilInvalidStateException('setObjectPHID');
211 }
212
213 $view = $this->buildPHUITimelineView();
214
215 if ($this->getShowEditActions()) {
216 Javelin::initBehavior('phabricator-transaction-list');
217 }
218
219 return $view->render();
220 }
221
222 public function buildPHUITimelineView($with_hiding = true) {
223 if (!$this->getObjectPHID()) {
224 throw new PhutilInvalidStateException('setObjectPHID');
225 }
226
227 $view = id(new PHUITimelineView())
228 ->setViewer($this->getViewer())
229 ->setShouldTerminate($this->shouldTerminate)
230 ->setQuoteTargetID($this->getQuoteTargetID())
231 ->setQuoteRef($this->getQuoteRef())
232 ->setViewData($this->getViewData());
233
234 $events = $this->buildEvents($with_hiding);
235 foreach ($events as $event) {
236 $view->addEvent($event);
237 }
238
239 if ($this->getPager()) {
240 $view->setPager($this->getPager());
241 }
242
243 return $view;
244 }
245
246 public function isTimelineEmpty() {
247 return !count($this->buildEvents(true));
248 }
249
250 protected function getOrBuildEngine() {
251 if (!$this->engine) {
252 $field = PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT;
253
254 $engine = id(new PhabricatorMarkupEngine())
255 ->setViewer($this->getViewer());
256
257 $object = $this->getObject();
258 if ($object) {
259 $engine->setContextObject($object);
260 }
261
262 foreach ($this->transactions as $xaction) {
263 if (!$xaction->hasComment()) {
264 continue;
265 }
266 $engine->addObject($xaction->getComment(), $field);
267 }
268 $engine->process();
269
270 $this->engine = $engine;
271 }
272
273 return $this->engine;
274 }
275
276 private function buildChangeDetailsLink(
277 PhabricatorApplicationTransaction $xaction) {
278
279 return javelin_tag(
280 'a',
281 array(
282 'href' => $xaction->getChangeDetailsURI(),
283 'sigil' => 'workflow',
284 ),
285 pht('(Show Details)'));
286 }
287
288 private function buildExtraInformationLink(
289 PhabricatorApplicationTransaction $xaction) {
290
291 $link = $xaction->renderExtraInformationLink();
292 if (!$link) {
293 return null;
294 }
295
296 return phutil_tag(
297 'span',
298 array(
299 'class' => 'phui-timeline-extra-information',
300 ),
301 array(" \xC2\xB7 ", $link));
302 }
303
304 protected function shouldGroupTransactions(
305 PhabricatorApplicationTransaction $u,
306 PhabricatorApplicationTransaction $v) {
307 return false;
308 }
309
310 protected function renderTransactionContent(
311 PhabricatorApplicationTransaction $xaction) {
312
313 $field = PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT;
314 $engine = $this->getOrBuildEngine();
315 $comment = $xaction->getComment();
316
317 if ($comment) {
318 if ($comment->getIsRemoved()) {
319 return javelin_tag(
320 'span',
321 array(
322 'class' => 'comment-deleted',
323 'sigil' => 'transaction-comment',
324 'meta' => array('phid' => $comment->getTransactionPHID()),
325 ),
326 pht(
327 'This comment was removed by %s.',
328 $xaction->getHandle($comment->getAuthorPHID())->renderLink()));
329 } else if ($comment->getIsDeleted()) {
330 return javelin_tag(
331 'span',
332 array(
333 'class' => 'comment-deleted',
334 'sigil' => 'transaction-comment',
335 'meta' => array('phid' => $comment->getTransactionPHID()),
336 ),
337 pht('This comment has been deleted.'));
338 } else if ($xaction->hasComment()) {
339 return javelin_tag(
340 'span',
341 array(
342 'class' => 'transaction-comment',
343 'sigil' => 'transaction-comment',
344 'meta' => array('phid' => $comment->getTransactionPHID()),
345 ),
346 $engine->getOutput($comment, $field));
347 } else {
348 // This is an empty, non-deleted comment. Usually this happens when
349 // rendering previews.
350 return null;
351 }
352 }
353
354 return null;
355 }
356
357 private function filterHiddenTransactions(array $xactions) {
358 foreach ($xactions as $key => $xaction) {
359 if ($xaction->shouldHide()) {
360 unset($xactions[$key]);
361 }
362 }
363 return $xactions;
364 }
365
366 private function groupRelatedTransactions(array $xactions) {
367 $last = null;
368 $last_key = '';
369 $groups = array();
370 foreach ($xactions as $key => $xaction) {
371 if ($last && $this->shouldGroupTransactions($last, $xaction)) {
372 $groups[$last_key][] = $xaction;
373 unset($xactions[$key]);
374 } else {
375 $last = $xaction;
376 $last_key = $key;
377 }
378 }
379
380 foreach ($xactions as $key => $xaction) {
381 $xaction->attachTransactionGroup(idx($groups, $key, array()));
382 }
383
384 return $xactions;
385 }
386
387 private function groupDisplayTransactions(array $xactions) {
388 $groups = array();
389 $group = array();
390 foreach ($xactions as $xaction) {
391 if ($xaction->shouldDisplayGroupWith($group)) {
392 $group[] = $xaction;
393 } else {
394 if ($group) {
395 $groups[] = $group;
396 }
397 $group = array($xaction);
398 }
399 }
400
401 if ($group) {
402 $groups[] = $group;
403 }
404
405 foreach ($groups as $key => $group) {
406 $results = array();
407
408 // Sort transactions within the group by action strength, then by
409 // chronological order. This makes sure that multiple actions of the
410 // same type (like a close, then a reopen) render in the order they
411 // were performed.
412 $strength_groups = mgroup($group, 'getActionStrength');
413 krsort($strength_groups);
414 foreach ($strength_groups as $strength_group) {
415 foreach (msort($strength_group, 'getID') as $xaction) {
416 $results[] = $xaction;
417 }
418 }
419
420 $groups[$key] = $results;
421 }
422
423 return $groups;
424 }
425
426 private function renderEvent(
427 PhabricatorApplicationTransaction $xaction,
428 array $group) {
429 $viewer = $this->getViewer();
430
431 $event = id(new PHUITimelineEventView())
432 ->setViewer($viewer)
433 ->setAuthorPHID($xaction->getAuthorPHID())
434 ->setTransactionPHID($xaction->getPHID())
435 ->setUserHandle($xaction->getHandle($xaction->getAuthorPHID()))
436 ->setIcon($xaction->getIcon())
437 ->setColor($xaction->getColor())
438 ->setHideCommentOptions($this->getHideCommentOptions())
439 ->setIsSilent($xaction->getIsSilentTransaction())
440 ->setIsMFA($xaction->getIsMFATransaction())
441 ->setIsLockOverride($xaction->getIsLockOverrideTransaction());
442
443 list($token, $token_removed) = $xaction->getToken();
444 if ($token) {
445 $event->setToken($token, $token_removed);
446 }
447
448 if (!$this->shouldSuppressTitle($xaction, $group)) {
449 if ($this->renderAsFeed) {
450 $title = $xaction->getTitleForFeed();
451 } else {
452 $title = $xaction->getTitle();
453 }
454 if ($xaction->hasChangeDetails()) {
455 if (!$this->isPreview) {
456 $details = $this->buildChangeDetailsLink($xaction);
457 $title = array(
458 $title,
459 ' ',
460 $details,
461 );
462 }
463 }
464
465 if (!$this->isPreview) {
466 $more = $this->buildExtraInformationLink($xaction);
467 if ($more) {
468 $title = array($title, ' ', $more);
469 }
470 }
471
472 $event->setTitle($title);
473 }
474
475 if ($this->isPreview) {
476 $event->setIsPreview(true);
477 } else {
478 $event
479 ->setDateCreated($xaction->getDateCreated())
480 ->setContentSource($xaction->getContentSource())
481 ->setAnchor($xaction->getID());
482 }
483
484 $transaction_type = $xaction->getTransactionType();
485 $comment_type = PhabricatorTransactions::TYPE_COMMENT;
486 $is_normal_comment = ($transaction_type == $comment_type);
487
488 if ($this->getShowEditActions() &&
489 !$this->isPreview &&
490 $is_normal_comment) {
491
492 $has_deleted_comment =
493 $xaction->getComment() &&
494 $xaction->getComment()->getIsDeleted();
495
496 $has_removed_comment =
497 $xaction->getComment() &&
498 $xaction->getComment()->getIsRemoved();
499
500 // Make designers happy to make CSS customizations
501 if ($has_removed_comment) {
502 $event->addClass('phui-timeline-shell-removed');
503 }
504
505 if ($xaction->getCommentVersion() > 1 && !$has_removed_comment) {
506 $event->setIsEdited(true);
507 }
508
509 if (!$has_removed_comment) {
510 $event->setIsNormalComment(true);
511 }
512
513 // If we have a place for quoted text to go and this is a quotable
514 // comment, pass the quote target ID to the event view.
515 if ($this->getQuoteTargetID()) {
516 if ($xaction->hasComment()) {
517 if (!$has_removed_comment && !$has_deleted_comment) {
518 $event->setQuoteTargetID($this->getQuoteTargetID());
519 $event->setQuoteRef($this->getQuoteRef());
520 }
521 }
522 }
523
524 $can_edit = PhabricatorPolicyCapability::CAN_EDIT;
525
526 if ($xaction->hasComment() || $has_deleted_comment) {
527 $has_edit_capability = PhabricatorPolicyFilter::hasCapability(
528 $viewer,
529 $xaction,
530 $can_edit);
531 if ($has_edit_capability && !$has_removed_comment) {
532 $event->setIsEditable(true);
533 }
534
535 if ($has_edit_capability || $viewer->getIsAdmin()) {
536 if (!$has_removed_comment) {
537 $event->setIsRemovable(true);
538 }
539 }
540 }
541
542 $can_interact = PhabricatorPolicyFilter::canInteract(
543 $viewer,
544 $xaction->getObject());
545 $event->setCanInteract($can_interact);
546 }
547
548 $comment = $this->renderTransactionContent($xaction);
549 if ($comment) {
550 $event->appendChild($comment);
551 }
552
553 return $event;
554 }
555
556 private function shouldSuppressTitle(
557 PhabricatorApplicationTransaction $xaction,
558 array $group) {
559
560 // This is a little hard-coded, but we don't have any other reasonable
561 // cases for now. Suppress "commented on" if there are other actions in
562 // the display group.
563
564 if (count($group) > 1) {
565 $type_comment = PhabricatorTransactions::TYPE_COMMENT;
566 if ($xaction->getTransactionType() == $type_comment) {
567 return true;
568 }
569 }
570
571 return false;
572 }
573
574}