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