Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Collaboration
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 8
756
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 / 14
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
 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
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\Htmx\Htmx;
11use Drupal\Core\Session\AccountInterface;
12use Drupal\Core\StringTranslation\TranslatableMarkup;
13use Drupal\Core\Url;
14use Drupal\display_builder\Attribute\Island;
15use Drupal\display_builder\InstanceInterface;
16use Drupal\display_builder\Island\IslandConfigurationFormInterface;
17use Drupal\display_builder\Island\IslandConfigurationFormTrait;
18use Drupal\display_builder\Island\IslandPluginBase;
19use Drupal\display_builder\Island\IslandReloadEventsTrait;
20use Drupal\display_builder\Island\IslandType;
21use Drupal\file\FileInterface;
22use Symfony\Component\DependencyInjection\ContainerInterface;
23
24/**
25 * Real-time collaboration island plugin implementation.
26 */
27#[Island(
28  id: 'collaboration',
29  label: new TranslatableMarkup('Real-time collaboration'),
30  description: new TranslatableMarkup('Allow concurrent editing with multiple users.'),
31  type: IslandType::Button,
32  default_region: 'end',
33)]
34class Collaboration extends IslandPluginBase implements IslandConfigurationFormInterface {
35
36  use IslandReloadEventsTrait;
37  use IslandConfigurationFormTrait;
38
39  /**
40   * Seconds in 15 minutes.
41   */
42  private const SECONDS_IN_15_MINUTES = 900;
43
44  /**
45   * The current user.
46   */
47  protected AccountInterface $currentUser;
48
49  /**
50   * The date formatter.
51   */
52  protected DateFormatterInterface $dateFormatter;
53
54  /**
55   * The entity field manager.
56   */
57  protected EntityFieldManagerInterface $entityFieldManager;
58
59  /**
60   * {@inheritdoc}
61   */
62  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
63    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
64    $instance->currentUser = $container->get('current_user');
65    $instance->dateFormatter = $container->get('date.formatter');
66    $instance->entityFieldManager = $container->get('entity_field.manager');
67
68    return $instance;
69  }
70
71  /**
72   * {@inheritdoc}
73   */
74  public function defaultConfiguration(): array {
75    return [
76      'image_field' => 'user_picture',
77      'image_style' => 'thumbnail',
78    ];
79  }
80
81  /**
82   * {@inheritdoc}
83   */
84  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
85    $options = [
86      '' => $this->t('- None -'),
87    ];
88    $configuration = $this->getConfiguration();
89
90    $fields = $this->entityFieldManager->getFieldDefinitions('user', 'user');
91
92    foreach ($fields as $field_id => $field) {
93      if ($field->getType() === 'image') {
94        $options[$field_id] = $field->getLabel();
95      }
96    }
97    $form['image_field'] = [
98      '#title' => $this->t('Image field'),
99      '#type' => 'select',
100      '#default_value' => $configuration['image_field'],
101      '#options' => $options,
102    ];
103
104    $styles = $this->entityTypeManager->getStorage('image_style')->loadMultiple();
105    $options = [
106      '' => $this->t('- None -'),
107    ];
108
109    foreach ($styles as $name => $style) {
110      $options[$name] = $style->label();
111    }
112    $form['image_style'] = [
113      '#title' => $this->t('Image style'),
114      '#type' => 'select',
115      '#default_value' => $configuration['image_style'],
116      '#options' => $options,
117    ];
118
119    return $form;
120  }
121
122  /**
123   * {@inheritdoc}
124   */
125  public function configurationSummary(): array {
126    $conf = $this->getConfiguration();
127    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture.');
128
129    return [
130      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
131    ];
132  }
133
134  /**
135   * {@inheritdoc}
136   */
137  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
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 alterRenderable(InstanceInterface $instance, array $build): array {
171    $build['#attributes'] = [
172      'sse-connect' => Url::fromRoute('display_builder.api_sse', ['display_builder_instance' => (string) $instance->id()])->toString(),
173    ];
174    (new Htmx())->ext('sse')->applyTo($build);
175    // We don't attach it from the ::build() because the island doesn't
176    // always render something in the toolbar.
177    $build['#attached']['library'][] = 'display_builder/htmx_sse';
178
179    return $build;
180  }
181
182  /**
183   * Remove inactive users.
184   *
185   * @param array $users
186   *   Each key is an User entity ID, each value is a timestamp.
187   *
188   * @return array
189   *   Each key is an User entity ID, each value is a timestamp.
190   */
191  protected function removeInactiveUsers(array $users): array {
192    foreach ($users as $user_id => $time) {
193      if (\time() - $time > self::SECONDS_IN_15_MINUTES) {
194        unset($users[$user_id]);
195      }
196    }
197
198    return $users;
199  }
200
201  /**
202   * Build renderable.
203   *
204   * @param array $users
205   *   Each key is an User entity ID, each value is a timestamp.
206   *
207   * @return array
208   *   A renderable array.
209   */
210  protected function buildRenderable(array $users): array {
211    $avatars = [];
212    $configuration = $this->getConfiguration();
213
214    foreach ($users as $user_id => $time) {
215      /** @var \Drupal\user\UserInterface $user */
216      $user = $this->entityTypeManager->getStorage('user')->load($user_id);
217
218      if (!$user) {
219        // For example, if the user was deleted.
220        continue;
221      }
222      $avatar = [
223        '#type' => 'component',
224        '#component' => 'display_builder:avatar',
225        '#props' => [
226          'name' => $user->getDisplayName(),
227        ],
228        '#attributes' => [
229          'style' => '--size: 38px',
230        ],
231      ];
232
233      if ($time) {
234        // We can't use DateFormatterInterface::formatTimeDiffSince() because
235        // the displayed value will become obsolete if the island is not updated
236        // for a while.
237        $time = $this->dateFormatter->format($time, 'custom', 'G:i');
238        $avatar['#props']['name'] .= ', ' . $this->t('at @time', ['@time' => $time]);
239      }
240
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
242        $image = $user->get($configuration['image_field'])->entity;
243
244        if ($image instanceof FileInterface) {
245          $image_style = $configuration['image_style'];
246          $style = $this->entityTypeManager->getStorage('image_style')->load($image_style);
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
248        }
249      }
250      $avatars[] = $avatar;
251    }
252
253    if (\count($avatars) === 0) {
254      return [];
255    }
256
257    return [
258      '#type' => 'html_tag',
259      '#tag' => 'span',
260      '#attributes' => [
261        'class' => 'sl-avatar-group',
262      ],
263      'avatars' => $avatars,
264    ];
265  }
266
267}