Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 116
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Collaboration
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 14
1190
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 defaultConfiguration
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 buildConfigurationForm
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 configurationSummary
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 build
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 onAttachToRoot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAttachToSlot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onMove
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onHistoryChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onUpdate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onDelete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeInactiveUsers
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 buildRenderable
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 rebuild
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\Core\Datetime\DateFormatterInterface;
8use Drupal\Core\Entity\EntityFieldManagerInterface;
9use Drupal\Core\Form\FormStateInterface;
10use Drupal\Core\Session\AccountInterface;
11use Drupal\Core\StringTranslation\TranslatableMarkup;
12use Drupal\display_builder\Attribute\Island;
13use Drupal\display_builder\InstanceInterface;
14use Drupal\display_builder\IslandConfigurationFormInterface;
15use Drupal\display_builder\IslandConfigurationFormTrait;
16use Drupal\display_builder\IslandPluginBase;
17use Drupal\display_builder\IslandType;
18use Drupal\file\FileInterface;
19use Symfony\Component\DependencyInjection\ContainerInterface;
20
21/**
22 * Real-time collaboration island plugin implementation.
23 */
24#[Island(
25  id: 'collaboration',
26  label: new TranslatableMarkup('Real-time collaboration'),
27  description: new TranslatableMarkup('Allow concurrent editing with multiple users.'),
28  type: IslandType::Button,
29)]
30class Collaboration extends IslandPluginBase implements IslandConfigurationFormInterface {
31
32  use IslandConfigurationFormTrait;
33
34  /**
35   * Seconds in 15 minutes.
36   */
37  private const SECONDS_IN_15_MINUTES = 900;
38
39  /**
40   * The current user.
41   */
42  protected AccountInterface $currentUser;
43
44  /**
45   * The date formatter.
46   */
47  protected DateFormatterInterface $dateFormatter;
48
49  /**
50   * The entity field manager.
51   */
52  protected EntityFieldManagerInterface $entityFieldManager;
53
54  /**
55   * {@inheritdoc}
56   */
57  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
58    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
59    $instance->currentUser = $container->get('current_user');
60    $instance->dateFormatter = $container->get('date.formatter');
61    $instance->entityFieldManager = $container->get('entity_field.manager');
62
63    return $instance;
64  }
65
66  /**
67   * {@inheritdoc}
68   */
69  public function defaultConfiguration(): array {
70    return [
71      'image_field' => 'user_picture',
72      'image_style' => 'thumbnail',
73    ];
74  }
75
76  /**
77   * {@inheritdoc}
78   */
79  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
80    $options = [
81      '' => $this->t('- None -'),
82    ];
83    $configuration = $this->getConfiguration();
84
85    $fields = $this->entityFieldManager->getFieldDefinitions('user', 'user');
86
87    foreach ($fields as $field_id => $field) {
88      if ($field->getType() === 'image') {
89        $options[$field_id] = $field->getLabel();
90      }
91    }
92    $form['image_field'] = [
93      '#title' => $this->t('Image field'),
94      '#type' => 'select',
95      '#default_value' => $configuration['image_field'],
96      '#options' => $options,
97    ];
98
99    $styles = $this->entityTypeManager->getStorage('image_style')->loadMultiple();
100    $options = [
101      '' => $this->t('- None -'),
102    ];
103
104    foreach ($styles as $name => $style) {
105      $options[$name] = $style->label();
106    }
107    $form['image_style'] = [
108      '#title' => $this->t('Image style'),
109      '#type' => 'select',
110      '#default_value' => $configuration['image_style'],
111      '#options' => $options,
112    ];
113
114    return $form;
115  }
116
117  /**
118   * {@inheritdoc}
119   */
120  public function configurationSummary(): array {
121    $conf = $this->getConfiguration();
122    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture');
123
124    return [
125      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
126    ];
127  }
128
129  /**
130   * {@inheritdoc}
131   */
132  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
133    $builder_id = (string) $builder->id();
134    // @todo pass \Drupal\display_builder\InstanceInterface object in
135    // parameters instead of loading again.
136    /** @var \Drupal\display_builder\InstanceInterface $builder */
137    $builder = $this->entityTypeManager->getStorage('display_builder_instance')->load($builder_id);
138    $this->builder = $builder;
139    $users = $builder->getUsers();
140    $current_user = $this->currentUser->id();
141    $users = $this->removeInactiveUsers($users);
142
143    // If no users (this situation must not happen), don't show anything.
144    if (\count($users) === 0) {
145      return [];
146    }
147
148    if (\array_key_exists($current_user, $users)) {
149      // If the only user is the current user, don't show anything.
150      if (\count($users) === 1) {
151        return [];
152      }
153      // Move current user at the beginning of the list.
154      $users = [$current_user => $users[$current_user]] + $users;
155
156      return $this->buildRenderable($users);
157    }
158
159    // If the only user is not the current user, add they at the start.
160    if (\count($users) === 1) {
161      $users = [$current_user => NULL] + $users;
162    }
163
164    return $this->buildRenderable($users);
165  }
166
167  /**
168   * {@inheritdoc}
169   */
170  public function onAttachToRoot(string $builder_id, string $instance_id): array {
171    return $this->rebuild($builder_id);
172  }
173
174  /**
175   * {@inheritdoc}
176   */
177  public function onAttachToSlot(string $builder_id, string $instance_id, string $parent_id): array {
178    return $this->rebuild($builder_id);
179  }
180
181  /**
182   * {@inheritdoc}
183   */
184  public function onMove(string $builder_id, string $instance_id): array {
185    return $this->rebuild($builder_id);
186  }
187
188  /**
189   * {@inheritdoc}
190   */
191  public function onHistoryChange(string $builder_id): array {
192    return $this->rebuild($builder_id);
193  }
194
195  /**
196   * {@inheritdoc}
197   */
198  public function onUpdate(string $builder_id, string $instance_id): array {
199    return $this->rebuild($builder_id);
200  }
201
202  /**
203   * {@inheritdoc}
204   */
205  public function onDelete(string $builder_id, string $parent_id): array {
206    return $this->rebuild($builder_id);
207  }
208
209  /**
210   * Remove inactive users.
211   *
212   * @param array $users
213   *   Each key is an User entity ID, each value is a timestamp.
214   *
215   * @return array
216   *   Each key is an User entity ID, each value is a timestamp.
217   */
218  protected function removeInactiveUsers(array $users): array {
219    foreach ($users as $user_id => $time) {
220      if (\time() - $time > self::SECONDS_IN_15_MINUTES) {
221        unset($users[$user_id]);
222      }
223    }
224
225    return $users;
226  }
227
228  /**
229   * Build renderable.
230   *
231   * @param array $users
232   *   Each key is an User entity ID, each value is a timestamp.
233   *
234   * @return array
235   *   A renderable array.
236   */
237  protected function buildRenderable(array $users): array {
238    $avatars = [];
239    $configuration = $this->getConfiguration();
240
241    foreach ($users as $user_id => $time) {
242      /** @var \Drupal\user\UserInterface $user */
243      $user = $this->entityTypeManager->getStorage('user')->load($user_id);
244
245      if (!$user) {
246        // For example, if the user was deleted.
247        continue;
248      }
249      $avatar = [
250        '#type' => 'component',
251        '#component' => 'display_builder:avatar',
252        '#props' => [
253          'name' => $user->getDisplayName(),
254        ],
255        '#attributes' => [
256          'style' => '--size: 38px',
257        ],
258      ];
259
260      if ($time) {
261        // We can't use DateFormatterInterface::formatTimeDiffSince() because
262        // the displayed value will become obsolete if the island is not updated
263        // for a while.
264        $time = $this->dateFormatter->format($time, 'custom', 'G:i');
265        $avatar['#props']['name'] .= ', ' . $this->t('at @time', ['@time' => $time]);
266      }
267
268      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
269        $image = $user->get($configuration['image_field'])->entity;
270
271        if ($image instanceof FileInterface) {
272          $image_style = $configuration['image_style'];
273          $style = $this->entityTypeManager->getStorage('image_style')->load($image_style);
274          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
275        }
276      }
277      $avatars[] = $avatar;
278    }
279
280    if (\count($avatars) === 0) {
281      return [];
282    }
283
284    return [
285      '#type' => 'html_tag',
286      '#tag' => 'span',
287      '#attributes' => [
288        'class' => 'sl-avatar-group',
289      ],
290      'avatars' => $avatars,
291    ];
292  }
293
294  /**
295   * Rebuilds the island with the given builder ID.
296   *
297   * @param string $builder_id
298   *   The ID of the builder.
299   *
300   * @return array
301   *   The rebuilt island.
302   */
303  private function rebuild(string $builder_id): array {
304    if (!$this->builder) {
305      // @todo pass \Drupal\display_builder\InstanceInterface object in
306      // parameters instead of loading again.
307      /** @var \Drupal\display_builder\InstanceInterface $builder */
308      $builder = $this->entityTypeManager->getStorage('display_builder_instance')->load($builder_id);
309      $this->builder = $builder;
310    }
311
312    return $this->addOutOfBand(
313      $this->build($this->builder),
314      '#' . $this->getHtmlId($builder_id),
315      'innerHTML'
316    );
317  }
318
319}