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