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