collavre 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +0 -1
- data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
- data/app/assets/stylesheets/collavre/creatives.css +346 -37
- data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
- data/app/assets/stylesheets/collavre/gnb.css +64 -3
- data/app/assets/stylesheets/collavre/landing.css +108 -3
- data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +14 -0
- data/app/assets/stylesheets/collavre/print.css +0 -1
- data/app/assets/stylesheets/collavre/search_popup.css +1 -1
- data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
- data/app/assets/stylesheets/collavre/workspace.css +307 -0
- data/app/channels/collavre/agent_channel.rb +14 -21
- data/app/channels/collavre/comments_presence_channel.rb +181 -21
- data/app/channels/collavre/inbox_badge_channel.rb +7 -1
- data/app/components/collavre/inbox/badge_component.rb +6 -13
- data/app/components/collavre/progress_filter_component.html.erb +2 -2
- data/app/components/collavre/progress_filter_component.rb +20 -0
- data/app/controllers/collavre/admin/settings_controller.rb +4 -1
- data/app/controllers/collavre/agent_connections_controller.rb +165 -0
- data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
- data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
- data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
- data/app/controllers/collavre/application_controller.rb +13 -2
- data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
- data/app/controllers/collavre/comments_controller.rb +45 -131
- data/app/controllers/collavre/concerns/shareable.rb +9 -3
- data/app/controllers/collavre/creatives_controller.rb +144 -11
- data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
- data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
- data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
- data/app/controllers/collavre/features_controller.rb +57 -0
- data/app/controllers/collavre/invites_controller.rb +12 -1
- data/app/controllers/collavre/llm_models_controller.rb +8 -0
- data/app/controllers/collavre/notices_controller.rb +20 -0
- data/app/controllers/collavre/tasks_controller.rb +40 -6
- data/app/controllers/collavre/topics_controller.rb +130 -53
- data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
- data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
- data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
- data/app/errors/collavre/turn_deadline_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +38 -0
- data/app/helpers/collavre/creatives_helper.rb +56 -22
- data/app/helpers/collavre/navigation_helper.rb +12 -0
- data/app/javascript/collavre.js +1 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
- data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
- data/app/javascript/components/creative_tree_row.js +62 -22
- data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
- data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
- data/app/javascript/controllers/__tests__/index.test.js +42 -0
- data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
- data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
- data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
- data/app/javascript/controllers/agent_connection_controller.js +357 -0
- data/app/javascript/controllers/agent_vendor_controller.js +23 -0
- data/app/javascript/controllers/comment_controller.js +3 -1
- data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
- data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
- data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
- data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
- data/app/javascript/controllers/comments/form_controller.js +795 -14
- data/app/javascript/controllers/comments/list_controller.js +338 -52
- data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
- data/app/javascript/controllers/comments/popup_controller.js +425 -83
- data/app/javascript/controllers/comments/presence_controller.js +317 -55
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +1772 -100
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
- data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
- data/app/javascript/controllers/creatives/tree_controller.js +140 -31
- data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
- data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
- data/app/javascript/controllers/gateway_check_controller.js +36 -0
- data/app/javascript/controllers/index.js +25 -1
- data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
- data/app/javascript/controllers/link_creative_controller.js +89 -18
- data/app/javascript/controllers/progress_filter_controller.js +12 -22
- data/app/javascript/controllers/search_popup_controller.js +64 -46
- data/app/javascript/controllers/share_user_search_controller.js +2 -2
- data/app/javascript/controllers/topic_list_controller.js +44 -7
- data/app/javascript/controllers/workspace_tree_controller.js +662 -0
- data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
- data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
- data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
- data/app/javascript/creatives/tree_renderer.js +65 -22
- data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
- data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
- data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
- data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
- data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
- data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
- data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
- data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
- data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
- data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
- data/app/javascript/lib/api/creatives.js +32 -13
- data/app/javascript/lib/api/csrf_fetch.js +6 -4
- data/app/javascript/lib/api/topics.js +63 -5
- data/app/javascript/lib/chat_drafts.js +786 -0
- data/app/javascript/lib/common_popup.js +164 -25
- data/app/javascript/lib/creative_link_prefetch.js +27 -0
- data/app/javascript/lib/last_visited_creative.js +97 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
- data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
- data/app/javascript/lib/lexical/creative_link_node.js +101 -0
- data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
- data/app/javascript/lib/lexical/link_toolbar.js +23 -0
- data/app/javascript/lib/turbo_stream_actions.js +23 -16
- data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
- data/app/javascript/lib/utils/filter_navigation.js +100 -0
- data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
- data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
- data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
- data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
- data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
- data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
- data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
- data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
- data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
- data/app/javascript/modules/command_args_form.js +0 -12
- data/app/javascript/modules/command_menu.js +80 -38
- data/app/javascript/modules/creative_link_picker.js +56 -0
- data/app/javascript/modules/creative_row_editor.js +446 -243
- data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
- data/app/javascript/modules/creative_tree_dom.js +4 -0
- data/app/javascript/modules/creative_tree_empty_state.js +80 -0
- data/app/javascript/modules/dom_listener_registry.js +23 -0
- data/app/javascript/modules/mention_menu.js +2 -3
- data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
- data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
- data/app/javascript/utils/caret_position.js +10 -0
- data/app/javascript/utils/chevron_icons.js +7 -0
- data/app/jobs/collavre/ai_agent_job.rb +262 -72
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
- data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
- data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
- data/app/jobs/collavre/comment_notification_job.rb +14 -0
- data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
- data/app/jobs/collavre/compress_job.rb +33 -22
- data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
- data/app/jobs/collavre/cron_action_job.rb +36 -18
- data/app/jobs/collavre/drop_trigger_job.rb +23 -9
- data/app/jobs/collavre/merge_comments_job.rb +33 -16
- data/app/jobs/collavre/push_notification_job.rb +62 -18
- data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
- data/app/jobs/collavre/stuck_detector_job.rb +1 -1
- data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
- data/app/models/collavre/agent_gateway.rb +200 -0
- data/app/models/collavre/agent_subscription.rb +4 -4
- data/app/models/collavre/agent_workspace.rb +210 -0
- data/app/models/collavre/comment/broadcastable.rb +22 -52
- data/app/models/collavre/comment/notifiable.rb +154 -77
- data/app/models/collavre/comment/topic_membership.rb +41 -0
- data/app/models/collavre/comment.rb +336 -31
- data/app/models/collavre/comment_notification_delivery.rb +61 -0
- data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
- data/app/models/collavre/comment_presence_store.rb +272 -12
- data/app/models/collavre/comment_read_pointer.rb +9 -15
- data/app/models/collavre/creative/describable.rb +8 -2
- data/app/models/collavre/creative/permissible.rb +11 -0
- data/app/models/collavre/creative.rb +29 -4
- data/app/models/collavre/creative_share.rb +109 -4
- data/app/models/collavre/current.rb +1 -0
- data/app/models/collavre/llm_model.rb +62 -0
- data/app/models/collavre/system_setting.rb +12 -1
- data/app/models/collavre/task.rb +121 -2
- data/app/models/collavre/topic.rb +51 -0
- data/app/models/collavre/user.rb +101 -41
- data/app/models/collavre/user_creative_preference.rb +3 -1
- data/app/services/collavre/agent_provisioning/archive.rb +108 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
- data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
- data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
- data/app/services/collavre/ai_agent/message_builder.rb +103 -16
- data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
- data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
- data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
- data/app/services/collavre/ai_agent_service.rb +108 -20
- data/app/services/collavre/ai_client.rb +211 -21
- data/app/services/collavre/cli_proxy/client.rb +147 -0
- data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
- data/app/services/collavre/cli_proxy/identity.rb +47 -0
- data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
- data/app/services/collavre/command_menu_service.rb +0 -10
- data/app/services/collavre/comment_move_service.rb +96 -7
- data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
- data/app/services/collavre/comments/action_executor.rb +48 -20
- data/app/services/collavre/comments/command_processor.rb +0 -1
- data/app/services/collavre/comments/list_response.rb +133 -0
- data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
- data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
- data/app/services/collavre/comments/read_receipt_index.rb +152 -0
- data/app/services/collavre/comments/topic_command.rb +72 -1
- data/app/services/collavre/comments/topic_mutation.rb +20 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
- data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
- data/app/services/collavre/creatives/reorderer.rb +15 -3
- data/app/services/collavre/creatives/tree_builder.rb +1 -1
- data/app/services/collavre/creatives/tree_formatter.rb +5 -6
- data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
- data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
- data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
- data/app/services/collavre/home_page_path.rb +55 -0
- data/app/services/collavre/html_text.rb +64 -0
- data/app/services/collavre/http_client.rb +11 -2
- data/app/services/collavre/inbox_reply_service.rb +1 -1
- data/app/services/collavre/markdown_converter.rb +13 -1
- data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
- data/app/services/collavre/orchestration/arbiter.rb +48 -5
- data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
- data/app/services/collavre/orchestration/delivery_record.rb +792 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
- data/app/services/collavre/orchestration/matcher.rb +132 -17
- data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
- data/app/services/collavre/orchestration/scheduler.rb +10 -4
- data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
- data/app/services/collavre/orchestration/selection.rb +43 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
- data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
- data/app/services/collavre/orchestration/topic_slot.rb +73 -0
- data/app/services/collavre/system_events/context_builder.rb +60 -11
- data/app/services/collavre/system_events/dispatcher.rb +58 -12
- data/app/services/collavre/system_events/envelope.rb +73 -0
- data/app/services/collavre/system_events/vocabulary.rb +45 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
- data/app/services/collavre/tools/cron_create_service.rb +32 -18
- data/app/services/collavre/tools/id_list.rb +38 -0
- data/app/services/collavre/tools/topic_authorizer.rb +61 -7
- data/app/services/collavre/tools/topic_branch_service.rb +85 -0
- data/app/services/collavre/tools/topic_create_service.rb +117 -0
- data/app/services/collavre/tools/topic_list_service.rb +101 -0
- data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
- data/app/services/collavre/tools/topic_messages_service.rb +346 -0
- data/app/services/collavre/tools/topic_move_service.rb +108 -0
- data/app/services/collavre/tools/topic_selection.rb +59 -0
- data/app/services/collavre/tools/topic_update_service.rb +165 -0
- data/app/services/collavre/topic_branch_service.rb +39 -10
- data/app/services/collavre/topics/agent_resolver.rb +87 -0
- data/app/services/collavre/topics/char_budget.rb +70 -0
- data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
- data/app/services/collavre/topics/message_page.rb +380 -0
- data/app/services/collavre/topics/message_page_markdown.rb +102 -0
- data/app/services/collavre/topics/message_scope.rb +61 -0
- data/app/services/collavre/topics/message_stats.rb +92 -0
- data/app/services/collavre/topics/move_blocker.rb +47 -0
- data/app/services/collavre/topics/next_name.rb +35 -0
- data/app/services/collavre/topics/pin_rejection.rb +25 -0
- data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
- data/app/services/collavre/topics/reserved_name.rb +18 -0
- data/app/services/collavre/topics/serializer.rb +92 -0
- data/app/services/collavre/topics/topic_destroy.rb +30 -0
- data/app/services/collavre/topics/topic_move.rb +80 -0
- data/app/services/collavre/topics/topic_move_effects.rb +35 -0
- data/app/services/collavre/topics/topic_mutation.rb +27 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
- data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
- data/app/views/collavre/agent_connections/show.html.erb +77 -0
- data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
- data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
- data/app/views/collavre/agent_gateways/index.html.erb +54 -0
- data/app/views/collavre/agent_gateways/new.html.erb +2 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
- data/app/views/collavre/comments/_empty_state.html.erb +33 -0
- data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
- data/app/views/collavre/comments/_feature_card.html.erb +35 -0
- data/app/views/collavre/comments/_list.html.erb +3 -2
- data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
- data/app/views/collavre/creatives/_add_button.html.erb +17 -19
- data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
- data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
- data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
- data/app/views/collavre/creatives/index.html.erb +79 -18
- data/app/views/collavre/desktop_setup/show.html.erb +114 -0
- data/app/views/collavre/features/index.html.erb +42 -0
- data/app/views/collavre/features/show.html.erb +52 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
- data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
- data/app/views/collavre/invites/show.html.erb +14 -1
- data/app/views/collavre/landing/show.html.erb +5 -1
- data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
- data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
- data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
- data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
- data/app/views/collavre/user_themes/index.html.erb +1 -1
- data/app/views/collavre/users/_llm_fields.html.erb +65 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
- data/app/views/collavre/users/edit_ai.html.erb +33 -31
- data/app/views/collavre/users/new.html.erb +4 -2
- data/app/views/collavre/users/new_ai.html.erb +14 -32
- data/app/views/collavre/users/show.html.erb +13 -1
- data/app/views/layouts/collavre/landing.html.erb +9 -4
- data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
- data/config/locales/admin.en.yml +3 -1
- data/config/locales/admin.ko.yml +3 -1
- data/config/locales/agent_gateway.en.yml +217 -0
- data/config/locales/agent_gateway.ko.yml +217 -0
- data/config/locales/ai_agent.en.yml +0 -1
- data/config/locales/ai_agent.ko.yml +0 -1
- data/config/locales/comments.en.yml +72 -30
- data/config/locales/comments.ko.yml +72 -30
- data/config/locales/creatives.en.yml +32 -1
- data/config/locales/creatives.ko.yml +32 -1
- data/config/locales/features.en.yml +140 -0
- data/config/locales/features.ko.yml +140 -0
- data/config/locales/invites.en.yml +3 -0
- data/config/locales/invites.ko.yml +3 -0
- data/config/locales/landing.en.yml +2 -1
- data/config/locales/landing.ko.yml +2 -1
- data/config/locales/tools.en.yml +20 -0
- data/config/locales/tools.ko.yml +20 -0
- data/config/locales/users.en.yml +15 -14
- data/config/locales/users.ko.yml +14 -14
- data/config/routes.rb +45 -19
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
- data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
- data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
- data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
- data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
- data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
- data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
- data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
- data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
- data/db/migrate/20260809000001_create_llm_models.rb +64 -0
- data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
- data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
- data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
- data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
- data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
- data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
- data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
- data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
- data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
- data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
- data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
- data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
- data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
- data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
- data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
- data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
- data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
- data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
- data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
- data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
- data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
- data/db/seeds.rb +1 -51
- data/lib/collavre/engine.rb +108 -1
- data/lib/collavre/feature_card_registry.rb +168 -0
- data/lib/collavre/hashed_access_token_lookup.rb +22 -0
- data/lib/collavre/sensitive_request_silencer.rb +23 -0
- data/lib/collavre/user_extensions.rb +0 -2
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +1 -0
- data/skills/collavre/SKILL.md +165 -0
- data/skills/collavre/package.json +8 -0
- data/skills/collavre/references/tool-reference.md +347 -0
- data/skills/collavre/scripts/collavre +634 -0
- data/skills/collavre/scripts/html_entities.js +24 -0
- metadata +228 -30
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
- data/app/controllers/collavre/inbox_items_controller.rb +0 -70
- data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
- data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
- data/app/javascript/lib/typo_correction.js +0 -146
- data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
- data/app/javascript/modules/creative_guide.js +0 -67
- data/app/javascript/modules/typo_corrector.js +0 -534
- data/app/jobs/collavre/inbox_summary_job.rb +0 -24
- data/app/mailers/collavre/inbox_mailer.rb +0 -19
- data/app/models/collavre/inbox_item.rb +0 -89
- data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
- data/app/services/collavre/comments/work_command.rb +0 -161
- data/app/services/collavre/comments/workflow_executor.rb +0 -276
- data/app/services/collavre/gemini_parent_recommender.rb +0 -77
- data/app/services/collavre/mobile/event_summarizer.rb +0 -40
- data/app/services/collavre/typo_corrector.rb +0 -188
- data/app/views/collavre/inbox_items/_item.html.erb +0 -18
- data/app/views/collavre/inbox_items/_items.html.erb +0 -3
- data/app/views/collavre/inbox_items/_list.html.erb +0 -7
- data/app/views/collavre/inbox_items/index.html.erb +0 -1
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
- data/app/views/collavre/users/typo_correction.html.erb +0 -50
- data/config/locales/mobile.en.yml +0 -16
- data/config/locales/mobile.ko.yml +0 -16
|
@@ -5,35 +5,78 @@ import { alertDialog, confirmDialog } from "../../lib/utils/dialog"
|
|
|
5
5
|
|
|
6
6
|
const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
|
|
7
7
|
const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
|
|
8
|
+
const AMBIGUOUS_SAVE_CLAIM_TIMEOUT = 5_000
|
|
9
|
+
const SAVE_REQUEST_TIMEOUT = 5_000
|
|
10
|
+
const UNREAD_COUNT_REFRESH_DELAY = 250
|
|
11
|
+
const TOPIC_SCROLL_DURATION = 250
|
|
12
|
+
const LAST_TOPIC_SAVE_SESSION_STORAGE_KEY = "collavre:last-topic-save-session-id"
|
|
13
|
+
const LAST_TOPIC_SAVE_SEQUENCE_STORAGE_KEY = "collavre:last-topic-save-sequence"
|
|
14
|
+
const LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX = "collavre:last-topic-save-session:"
|
|
15
|
+
let fallbackClientIdSequence = 0
|
|
16
|
+
|
|
17
|
+
// Names one save, so its broadcast can be told from a sibling session's. It
|
|
18
|
+
// has to be unique across the user's tabs, not just within this one: two tabs
|
|
19
|
+
// counting from zero would answer to each other's echoes.
|
|
20
|
+
function newClientId() {
|
|
21
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
|
22
|
+
|
|
23
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
24
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
|
25
|
+
return `save-${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fallbackClientIdSequence += 1
|
|
29
|
+
return `save-${Date.now().toString(36)}-${fallbackClientIdSequence.toString(36)}`
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
export default class extends Controller {
|
|
10
|
-
static targets = ["list"]
|
|
33
|
+
static targets = ["list", "creationContainer", "topicListButton"]
|
|
11
34
|
|
|
12
35
|
connect() {
|
|
13
36
|
this.topics = []
|
|
14
37
|
this.canManageTopics = false
|
|
15
38
|
this.canCreateTopic = false
|
|
39
|
+
this.canSetPrimaryAgent = false
|
|
16
40
|
this.subscribedCreativeId = null
|
|
17
41
|
this.topicsSubscription = null
|
|
18
|
-
this._loadTopicsVersion
|
|
42
|
+
this._loadTopicsVersion ||= 0
|
|
43
|
+
this._unreadCountsOverlay = null
|
|
44
|
+
this._topicScrollFrame = null
|
|
45
|
+
this._topicScrollInterrupted = false
|
|
46
|
+
this._topicScrollInterruptionVersion ||= 0
|
|
19
47
|
// Initial load if creativeId is available (e.g. from dataset if set server-side)
|
|
20
|
-
if (this.creativeId) {
|
|
48
|
+
if (this.creativeId && this.element.dataset.docked !== 'true') {
|
|
21
49
|
this.loadTopics()
|
|
22
50
|
this.subscribe()
|
|
23
51
|
}
|
|
24
52
|
this.handleNewMessage = this.handleNewMessage.bind(this)
|
|
25
53
|
this.handleTopicMoved = this.handleTopicMoved.bind(this)
|
|
54
|
+
this.handleTopicListClose = this.handleTopicListClose.bind(this)
|
|
55
|
+
this.handleTopicScrollInput = this.handleTopicScrollInput.bind(this)
|
|
26
56
|
window.addEventListener('comments--topics:new-message', this.handleNewMessage)
|
|
27
57
|
window.addEventListener('collavre:topic-moved', this.handleTopicMoved)
|
|
58
|
+
this.element.addEventListener('topic-list:close', this.handleTopicListClose)
|
|
59
|
+
this.listTarget.addEventListener('wheel', this.handleTopicScrollInput, { passive: true })
|
|
60
|
+
this.listTarget.addEventListener('touchstart', this.handleTopicScrollInput, { passive: true })
|
|
28
61
|
}
|
|
29
62
|
|
|
30
63
|
disconnect() {
|
|
64
|
+
this.cancelProgrammaticScroll()
|
|
31
65
|
window.removeEventListener('comments--topics:new-message', this.handleNewMessage)
|
|
32
66
|
window.removeEventListener('collavre:topic-moved', this.handleTopicMoved)
|
|
67
|
+
this.element.removeEventListener('topic-list:close', this.handleTopicListClose)
|
|
68
|
+
this.listTarget.removeEventListener('wheel', this.handleTopicScrollInput)
|
|
69
|
+
this.listTarget.removeEventListener('touchstart', this.handleTopicScrollInput)
|
|
70
|
+
this._loadTopicsVersion += 1
|
|
71
|
+
this.cancelUnreadCountRefresh()
|
|
33
72
|
this.unsubscribe()
|
|
34
73
|
}
|
|
35
74
|
|
|
36
75
|
onPopupOpened({ creativeId }) {
|
|
76
|
+
this.cancelProgrammaticScroll()
|
|
77
|
+
this._topicScrollInterrupted = false
|
|
78
|
+
this._popupClosed = false
|
|
79
|
+
const previousCreativeId = this.creativeIdValue
|
|
37
80
|
this.creativeIdValue = creativeId
|
|
38
81
|
// Clear stale cached state from the previous creative — otherwise
|
|
39
82
|
// chat-context autofill (command_menu) reads stale values during the
|
|
@@ -44,13 +87,105 @@ export default class extends Controller {
|
|
|
44
87
|
// effective_creative_id. loadTopics() repopulates both.
|
|
45
88
|
delete this.element.dataset.effectiveCreativeId
|
|
46
89
|
this.mainTopicId = null
|
|
47
|
-
this
|
|
90
|
+
// Switching creatives reuses this instance without an onPopupClosed
|
|
91
|
+
// (popup_controller._navigateToEntry calls open()/openForCreative()
|
|
92
|
+
// again), so unread marks from the previous creative would badge the new
|
|
93
|
+
// creative's archived toggle — and never clear, since those ids are not
|
|
94
|
+
// among its topics. Only on an actual switch, though: a docked chat
|
|
95
|
+
// re-opens the *same* creative when a workspace-sync event carries a
|
|
96
|
+
// highlightId (popup_controller.handleCreativeClick), and these marks are
|
|
97
|
+
// transient — loadTopics() cannot rebuild them.
|
|
98
|
+
if (String(previousCreativeId || '') !== String(creativeId || '')) {
|
|
99
|
+
this.archivedWithNewMessages.clear()
|
|
100
|
+
// Scoped to the creative it was archived in; a topic id from another
|
|
101
|
+
// creative must not veto this one's restored selection.
|
|
102
|
+
this.archivedAwayTopicId = null
|
|
103
|
+
}
|
|
104
|
+
// Do not prune retained claims until loadTopics() has resolved this
|
|
105
|
+
// requested creative to its effective stream id. A linked shell and
|
|
106
|
+
// its origin use the same TopicsChannel stream, but only the response
|
|
107
|
+
// tells us that after this cached value has been cleared.
|
|
108
|
+
// Direct history navigation replaces the subscription without firing
|
|
109
|
+
// onPopupClosed(). Keep claims until loadTopics() can resolve whether
|
|
110
|
+
// this requested creative shares the previous effective stream.
|
|
111
|
+
this.subscribe({ preservePendingSelfEchoes: true })
|
|
48
112
|
return this.loadTopics()
|
|
49
113
|
}
|
|
50
114
|
|
|
51
115
|
onPopupClosed() {
|
|
52
|
-
this.
|
|
116
|
+
this.cancelProgrammaticScroll()
|
|
117
|
+
this._popupClosed = true
|
|
118
|
+
this.releaseAcknowledgedPendingSelfEchoes()
|
|
119
|
+
this.markPendingSelfEchoesAsPossiblyMissed()
|
|
120
|
+
this._loadTopicsVersion += 1
|
|
121
|
+
this.cancelUnreadCountRefresh()
|
|
122
|
+
// The same creative can reopen before an in-flight save broadcasts.
|
|
123
|
+
// Keep that save's claim so its delayed echo remains recognisable on
|
|
124
|
+
// the replacement subscription; a different creative clears it when
|
|
125
|
+
// it opens.
|
|
126
|
+
this.unsubscribe({ preservePendingSelfEchoes: true })
|
|
53
127
|
this.creativeIdValue = null
|
|
128
|
+
this.topics = []
|
|
129
|
+
this.mainTopicId = null
|
|
130
|
+
this.archivedWithNewMessages.clear()
|
|
131
|
+
this.archivedAwayTopicId = null
|
|
132
|
+
delete this.element.dataset.effectiveCreativeId
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Archived topics still accept messages, but their chips only exist in the
|
|
136
|
+
// DOM while the archived section is expanded. Remember which ones have
|
|
137
|
+
// unread traffic so the badge can live on the collapsed toggle and survive
|
|
138
|
+
// re-renders. Created lazily: a docked controller can receive popup
|
|
139
|
+
// lifecycle callbacks before connect() has run.
|
|
140
|
+
get archivedWithNewMessages() {
|
|
141
|
+
if (!this._archivedWithNewMessages) this._archivedWithNewMessages = new Set()
|
|
142
|
+
return this._archivedWithNewMessages
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// list_controller asks this before appending a live message to All Messages:
|
|
146
|
+
// CommentsController#index leaves archived-topic comments out of that view,
|
|
147
|
+
// so a stream must not put one there either.
|
|
148
|
+
isArchivedTopic(id) {
|
|
149
|
+
if (!id) return false
|
|
150
|
+
return (this.archivedTopics || []).some(t => String(t.id) === String(id))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Move a topic between the live and archived caches without waiting for the
|
|
154
|
+
// reload. Every caller follows with loadTopics(), which clears this.topics
|
|
155
|
+
// synchronously and refills both lists when its fetch lands — so this only
|
|
156
|
+
// has to be right for the window in between, which is exactly the window
|
|
157
|
+
// isArchivedTopic is consulted in.
|
|
158
|
+
applyArchiveTransition(action, topic) {
|
|
159
|
+
const id = topic && topic.id
|
|
160
|
+
if (!id) return
|
|
161
|
+
|
|
162
|
+
const key = String(id)
|
|
163
|
+
const known = [ ...(this.topics || []), ...(this.archivedTopics || []) ]
|
|
164
|
+
.find(t => String(t.id) === key)
|
|
165
|
+
const entry = { ...(known || {}), ...topic }
|
|
166
|
+
|
|
167
|
+
this.topics = (this.topics || []).filter(t => String(t.id) !== key)
|
|
168
|
+
this.archivedTopics = (this.archivedTopics || []).filter(t => String(t.id) !== key)
|
|
169
|
+
|
|
170
|
+
if (action === "archived") {
|
|
171
|
+
this.archivedTopics = [ ...this.archivedTopics, entry ]
|
|
172
|
+
} else {
|
|
173
|
+
this.topics = [ ...this.topics, entry ]
|
|
174
|
+
// Nothing archived is left to clear the badge from, so drop it here
|
|
175
|
+
// rather than leaving the toggle lit until the reload lands.
|
|
176
|
+
this.pruneArchivedBadges()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The toggle badge is derived from set size, so an id that is no longer
|
|
181
|
+
// archived — unarchived, deleted, or left behind by a creative switch —
|
|
182
|
+
// would keep it lit with no chip to click and clear it.
|
|
183
|
+
pruneArchivedBadges() {
|
|
184
|
+
if (this.archivedWithNewMessages.size === 0) return
|
|
185
|
+
const archivedIds = new Set((this.archivedTopics || []).map(t => String(t.id)))
|
|
186
|
+
this.archivedWithNewMessages.forEach(id => {
|
|
187
|
+
if (!archivedIds.has(id)) this.archivedWithNewMessages.delete(id)
|
|
188
|
+
})
|
|
54
189
|
}
|
|
55
190
|
|
|
56
191
|
get creativeId() {
|
|
@@ -69,16 +204,42 @@ export default class extends Controller {
|
|
|
69
204
|
return match ? match[1] : null
|
|
70
205
|
}
|
|
71
206
|
|
|
207
|
+
// TopicsChannel and update_last_topic both resolve linked shells through
|
|
208
|
+
// effective_origin. Before a response provides that id, the requested id
|
|
209
|
+
// is the best available stream key. A shell that has already loaded keeps
|
|
210
|
+
// its resolved stream across a close/reopen, so an acknowledgement arriving
|
|
211
|
+
// before the replacement load can still tell that its echo is receivable.
|
|
212
|
+
get effectiveCreativeId() {
|
|
213
|
+
return this.element.dataset.effectiveCreativeId || this.effectiveCreativeIdFor(this.creativeId)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A pending pick records the shell rendered in the popup, while channel
|
|
217
|
+
// claims are keyed by the origin stream. Resolve both through the cached
|
|
218
|
+
// mapping before comparing them so a linked-shell replacement keeps a newer
|
|
219
|
+
// pick ahead of an orphaned echo from the previous controller.
|
|
220
|
+
effectiveCreativeIdFor(creativeId) {
|
|
221
|
+
return this.knownEffectiveCreativeIds.get(String(creativeId)) || creativeId
|
|
222
|
+
}
|
|
223
|
+
|
|
72
224
|
async loadTopics() {
|
|
73
225
|
if (!this.creativeId) return
|
|
74
226
|
|
|
75
227
|
const version = ++this._loadTopicsVersion
|
|
228
|
+
this._unreadCountsOverlay = null
|
|
229
|
+
const selectionEpoch = this.selectionEpoch
|
|
230
|
+
// A response can carry a snapshot produced before a save, while that
|
|
231
|
+
// save's HTTP response reaches us before the snapshot is processed.
|
|
232
|
+
// Remember which local saves had been acknowledged when this load
|
|
233
|
+
// started so their retained claims can still outrank that older view.
|
|
234
|
+
const acknowledgedSaveVersion = this.saveAcknowledgementVersion
|
|
235
|
+
this.activeLoadAcknowledgementVersions.set(version, acknowledgedSaveVersion)
|
|
236
|
+
const creativeId = this.creativeId
|
|
76
237
|
// Clear stale topics from previous creative to prevent name-based
|
|
77
238
|
// dedupe in handleTopicMessage from blocking valid broadcasts
|
|
78
239
|
this.topics = []
|
|
79
240
|
|
|
80
241
|
try {
|
|
81
|
-
const response = await fetch(`/creatives/${
|
|
242
|
+
const response = await fetch(`/creatives/${creativeId}/topics`)
|
|
82
243
|
// Discard stale response if a newer loadTopics() call was made
|
|
83
244
|
if (version !== this._loadTopicsVersion) return
|
|
84
245
|
|
|
@@ -87,51 +248,215 @@ export default class extends Controller {
|
|
|
87
248
|
}
|
|
88
249
|
if (response.ok) {
|
|
89
250
|
const data = await response.json()
|
|
90
|
-
const
|
|
251
|
+
const unreadCounts = this._unreadCountsOverlay?.loadVersion === version
|
|
252
|
+
? this._unreadCountsOverlay.counts
|
|
253
|
+
: null
|
|
254
|
+
const topics = this.applyUnreadCounts(Array.isArray(data) ? data : data.topics, unreadCounts)
|
|
91
255
|
const canManage = Array.isArray(data) ? false : data.can_manage
|
|
92
256
|
const canCreateTopic = Array.isArray(data) ? false : (data.can_create_topic ?? canManage)
|
|
257
|
+
// Assigning an agent is authorized at :write, not :admin, so the
|
|
258
|
+
// release control must follow the same capability — otherwise a
|
|
259
|
+
// write collaborator can pin an agent by drag-and-drop and then
|
|
260
|
+
// has no way to undo it. Falls back to canManage for a server
|
|
261
|
+
// that predates the field.
|
|
262
|
+
const canSetPrimaryAgent = Array.isArray(data) ? false : (data.can_set_primary_agent ?? canManage)
|
|
93
263
|
this.topics = topics
|
|
94
264
|
this.canManageTopics = canManage
|
|
95
265
|
this.canCreateTopic = canCreateTopic
|
|
96
|
-
this.
|
|
97
|
-
this.
|
|
266
|
+
this.canSetPrimaryAgent = canSetPrimaryAgent
|
|
267
|
+
this.archivedTopics = this.applyUnreadCounts(data.archived_topics || [], unreadCounts)
|
|
268
|
+
this.pruneArchivedBadges()
|
|
269
|
+
const effectiveCreativeId = data.effective_creative_id
|
|
270
|
+
? String(data.effective_creative_id)
|
|
271
|
+
: String(this.creativeId)
|
|
272
|
+
this.knownEffectiveCreativeIds.set(String(creativeId), effectiveCreativeId)
|
|
273
|
+
const snapshotTopicId = data.last_topic_id ? String(data.last_topic_id) : ""
|
|
274
|
+
const snapshotTopicRevision = this.normalizeLastTopicRevision(data.last_topic_revision)
|
|
275
|
+
this.element.dataset.effectiveCreativeId = effectiveCreativeId
|
|
276
|
+
this.remapPendingSelfEchoesForCreative(creativeId, effectiveCreativeId)
|
|
277
|
+
this.releaseAcknowledgedPendingSelfEchoesOutside(effectiveCreativeId)
|
|
278
|
+
const staleLastTopicSnapshot = !this.observeLastTopicRevision(
|
|
279
|
+
effectiveCreativeId,
|
|
280
|
+
snapshotTopicRevision
|
|
281
|
+
)
|
|
282
|
+
const deferLastTopicReconciliation = this.hasUnacknowledgedRevisionedSaveFor(
|
|
283
|
+
effectiveCreativeId,
|
|
284
|
+
snapshotTopicRevision
|
|
285
|
+
)
|
|
286
|
+
if (deferLastTopicReconciliation) {
|
|
287
|
+
this.deferredLastTopicReconciliations.add(effectiveCreativeId)
|
|
288
|
+
}
|
|
289
|
+
// Claims are keyed by their effective creative stream. Keep claims
|
|
290
|
+
// for a stream we have temporarily left: a save can still be in
|
|
291
|
+
// flight while the popup visits another creative, and returning before
|
|
292
|
+
// its echo arrives must still recognise that echo as our own.
|
|
293
|
+
// A topic picked while this fetch was in flight is newer intent
|
|
294
|
+
// than the answer coming back: last_topic_id still names the
|
|
295
|
+
// topic the user left, because the save for the pick is
|
|
296
|
+
// debounced and has not landed. Overwriting it here — and then
|
|
297
|
+
// restoring from it below — throws the click away, snapping the
|
|
298
|
+
// strip and the message list back to the previous topic.
|
|
299
|
+
// _loadTopicsVersion does not cover this: it only discards a
|
|
300
|
+
// response outrun by another loadTopics(), not by a selection.
|
|
301
|
+
//
|
|
302
|
+
// Only a pick about this creative, though. Switching creatives
|
|
303
|
+
// leaves the previous creative's chips on screen until this
|
|
304
|
+
// fetch lands, and a click on one of those is intent about the
|
|
305
|
+
// creative being left. Its id would survive as the preference,
|
|
306
|
+
// fail the lookup in restoreSelection(), drop the user on Main
|
|
307
|
+
// and then persist Main as the new creative's saved topic.
|
|
308
|
+
const skipLastTopicReconciliation = staleLastTopicSnapshot || deferLastTopicReconciliation
|
|
309
|
+
const pickWon = !skipLastTopicReconciliation && !this.pendingPickWasSupersededWhileClosed(
|
|
310
|
+
effectiveCreativeId,
|
|
311
|
+
snapshotTopicId,
|
|
312
|
+
snapshotTopicRevision
|
|
313
|
+
) && this.pickOutranks(selectionEpoch, creativeId, topics, this.archivedTopics)
|
|
314
|
+
if (skipLastTopicReconciliation) {
|
|
315
|
+
// The GET has a server ordering token, but this save has neither
|
|
316
|
+
// its response revision nor its echo yet. Its predecessor topic is
|
|
317
|
+
// not an ordering token (an ABA write can repeat it), so preserve
|
|
318
|
+
// the current selection until the request settles and retry below.
|
|
319
|
+
// A snapshot older than an already observed revision is likewise
|
|
320
|
+
// display data only; it must not restore an older preference.
|
|
321
|
+
} else if (pickWon) {
|
|
322
|
+
// An interim restore can run while this fetch has the strip
|
|
323
|
+
// empty and replace serverLastTopicId with Main. The epoch
|
|
324
|
+
// belongs to the actual pick, so restore that picked value
|
|
325
|
+
// rather than treating the derived fallback as its value.
|
|
326
|
+
this.serverLastTopicId = this._pickTopicId
|
|
327
|
+
// The picked value has not necessarily reached the server yet.
|
|
328
|
+
// Keep the snapshot as the first known remote baseline so a
|
|
329
|
+
// claim created after this load records what its pending save
|
|
330
|
+
// actually supersedes. Never replace a newer Action Cable or
|
|
331
|
+
// acknowledged-save baseline with this older response.
|
|
332
|
+
if (this.lastKnownRemoteTopicIdFor(effectiveCreativeId) === undefined) {
|
|
333
|
+
this.setLastKnownRemoteTopicId(effectiveCreativeId, snapshotTopicId)
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
const pendingTopicId = this.latestPendingSelfEchoTopicIdFor(
|
|
337
|
+
effectiveCreativeId,
|
|
338
|
+
snapshotTopicId,
|
|
339
|
+
acknowledgedSaveVersion,
|
|
340
|
+
snapshotTopicRevision
|
|
341
|
+
)
|
|
342
|
+
if (pendingTopicId === undefined) {
|
|
343
|
+
this.serverLastTopicId = snapshotTopicId
|
|
344
|
+
// A retained claim proves this response was an older view of
|
|
345
|
+
// the preference. Do not let that stale snapshot roll back
|
|
346
|
+
// the baseline used by the next save's closed-reopen check.
|
|
347
|
+
this.setLastKnownRemoteTopicId(effectiveCreativeId, snapshotTopicId)
|
|
348
|
+
} else {
|
|
349
|
+
this.serverLastTopicId = pendingTopicId
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// The archive guard only has to outlive the sources that still
|
|
353
|
+
// name the topic. Test the effective selection, not just the
|
|
354
|
+
// preference: an unchanged ?topic_id= outranks it in the getter,
|
|
355
|
+
// so keying on serverLastTopicId would drop the guard while the
|
|
356
|
+
// URL was still pointing at the archived topic. Once nothing
|
|
357
|
+
// names it, a later reselection is honoured normally.
|
|
358
|
+
if (this.archivedAwayTopicId && String(this.currentTopicId) !== this.archivedAwayTopicId) {
|
|
359
|
+
this.archivedAwayTopicId = null
|
|
360
|
+
}
|
|
98
361
|
this.isInbox = !!data.is_inbox
|
|
99
362
|
this.systemTopicId = data.system_topic_id ? String(data.system_topic_id) : null
|
|
100
363
|
this.mainTopicId = data.main_topic_id ? String(data.main_topic_id) : null
|
|
101
364
|
// Expose effective origin id so chat-context autofill (slash commands)
|
|
102
365
|
// and any other consumer can target the same creative the server uses
|
|
103
366
|
// (linked creatives resolve params[:creative_id] through effective_origin).
|
|
104
|
-
if
|
|
105
|
-
|
|
367
|
+
// Migrate localStorage to server if server has no value. An
|
|
368
|
+
// unresolved revisioned save must not turn its retained local
|
|
369
|
+
// selection into another PATCH while reconciliation is deferred.
|
|
370
|
+
if (!skipLastTopicReconciliation) {
|
|
371
|
+
this.migrateLocalStorage({ keepEmptyPick: pickWon })
|
|
106
372
|
}
|
|
107
373
|
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
374
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent, creativeId)
|
|
375
|
+
if (this.currentTopicId) this.clearNewMessageBadge(this.currentTopicId)
|
|
376
|
+
if (!skipLastTopicReconciliation) {
|
|
377
|
+
this.restoreSelection({ keepEmptyPick: pickWon })
|
|
378
|
+
}
|
|
379
|
+
this.refreshOpenTopicListPopup()
|
|
113
380
|
}
|
|
114
381
|
} catch (e) {
|
|
115
382
|
console.error("Failed to load topics", e)
|
|
116
383
|
throw e
|
|
384
|
+
} finally {
|
|
385
|
+
this.activeLoadAcknowledgementVersions.delete(version)
|
|
386
|
+
this.discardSettledSelfEchoesNoLongerNeeded()
|
|
117
387
|
}
|
|
118
388
|
}
|
|
119
389
|
|
|
120
|
-
|
|
390
|
+
// keepEmptyPick: the caller established that the user picked All Messages
|
|
391
|
+
// after this render was set in motion. That pick names no topic, so it
|
|
392
|
+
// cannot be restored from a topic list — it must be reapplied. Without
|
|
393
|
+
// this the Main fallback below would treat it as "nothing selected",
|
|
394
|
+
// navigate away from it and persist Main, which is the same revert a chip
|
|
395
|
+
// click suffers. A prior interim restore may also have dispatched Main, so
|
|
396
|
+
// reapplying sends the authoritative empty selection to downstream
|
|
397
|
+
// controllers again.
|
|
398
|
+
restoreSelection({ keepEmptyPick = false } = {}) {
|
|
121
399
|
const lastTopicId = this.currentTopicId
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
400
|
+
// A deep link controls this popup's view, but it is not a replacement
|
|
401
|
+
// for the saved preference. Replaying the linked selection after a
|
|
402
|
+
// preference broadcast must therefore update the UI without writing the
|
|
403
|
+
// link back over that newer server value.
|
|
404
|
+
const preservePreference = this.hasDeepLinkSelection && !keepEmptyPick
|
|
405
|
+
// archiveTopic() switched away from this topic on purpose. The server
|
|
406
|
+
// preference still names it until the debounced save lands, so accept
|
|
407
|
+
// the local intent over the stale server answer for that window.
|
|
408
|
+
if (lastTopicId && String(lastTopicId) !== this.archivedAwayTopicId) {
|
|
409
|
+
// Validate against the topic data, not the rendered DOM. An archived
|
|
410
|
+
// topic is still readable and writable — archiving hides it from the
|
|
411
|
+
// strip, it does not close the conversation. A DOM lookup would bounce
|
|
412
|
+
// the user back to Main every time the archived section is collapsed,
|
|
413
|
+
// because collapsed chips are never rendered. selectTopic expands the
|
|
414
|
+
// section when the selection lands inside it.
|
|
415
|
+
const known = [ ...(this.topics || []), ...(this.archivedTopics || []) ]
|
|
416
|
+
if (known.some(t => String(t.id) === String(lastTopicId))) {
|
|
417
|
+
// Restoring is not a navigation, so it must not undo a collapse
|
|
418
|
+
// the user chose while sitting in this very topic. Only
|
|
419
|
+
// restoreSelection is suppressed; every other selectTopic caller
|
|
420
|
+
// is the user going somewhere, and reveals normally.
|
|
421
|
+
const reveal = this.archivedCollapsedTopicId !== String(lastTopicId)
|
|
422
|
+
this.selectTopic(lastTopicId, { reveal, pick: false, persist: !preservePreference })
|
|
127
423
|
return
|
|
128
424
|
}
|
|
129
425
|
}
|
|
130
426
|
|
|
131
|
-
|
|
427
|
+
if (keepEmptyPick && !lastTopicId) {
|
|
428
|
+
this.selectTopic("", { pick: false })
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (preservePreference && !lastTopicId) {
|
|
433
|
+
this.selectTopic("", { pick: false, persist: false })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Not a pick: this fallback is what the current state resolves to, and
|
|
438
|
+
// loadTopics() empties the strip for the length of its fetch, so any
|
|
439
|
+
// re-render landing in that window resolves to Main whatever the user
|
|
440
|
+
// has selected. Counting it as intent would let it outrank the answer
|
|
441
|
+
// it was derived from — and drop the deep link on the way.
|
|
442
|
+
this.selectTopic(this.mainTopicId || "", { pick: false, persist: !preservePreference })
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// An archived topic can be opened from the topic strip, the topic-list popup,
|
|
446
|
+
// a deep link, or a restored last-topic. Expand the archived section from this
|
|
447
|
+
// one choke point so the strip always shows which topic is open.
|
|
448
|
+
revealArchivedTopic(id) {
|
|
449
|
+
if (!id || this.showingArchived) return
|
|
450
|
+
if (!this.isArchivedTopic(id)) return
|
|
451
|
+
|
|
452
|
+
// Reaching here is a deliberate navigation into the section, so an
|
|
453
|
+
// earlier collapse no longer describes what the user wants.
|
|
454
|
+
this.archivedCollapsedTopicId = null
|
|
455
|
+
this.showingArchived = true
|
|
456
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
132
457
|
}
|
|
133
458
|
|
|
134
|
-
renderTopics(topics, canManage = false, canCreateTopic = canManage) {
|
|
459
|
+
renderTopics(topics, canManage = false, canCreateTopic = canManage, canSetPrimaryAgent = canManage, sourceCreativeId = this._topicsCreativeId || this.creativeId) {
|
|
135
460
|
const dragActions = canManage
|
|
136
461
|
? 'dragstart->comments--topics#handleTopicDragStart dragend->comments--topics#handleTopicDragEnd'
|
|
137
462
|
: ''
|
|
@@ -150,15 +475,22 @@ export default class extends Controller {
|
|
|
150
475
|
const renderTopic = (topic) => {
|
|
151
476
|
const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
|
|
152
477
|
const draggable = canManage ? 'draggable="true"' : ''
|
|
478
|
+
// agent_locked marks a live agent session topic, whose primary agent is
|
|
479
|
+
// session identity rather than a routing pin — the server refuses to
|
|
480
|
+
// change it, so the avatar must not offer to release it.
|
|
481
|
+
const releasableTopicId = canSetPrimaryAgent && !topic.agent_locked ? topic.id : null
|
|
153
482
|
const agentAvatar = topic.primary_agent
|
|
154
|
-
? this.renderAgentAvatar(topic.primary_agent)
|
|
483
|
+
? this.renderAgentAvatar(topic.primary_agent, releasableTopicId)
|
|
155
484
|
: ''
|
|
156
485
|
const branchIcon = topic.source_topic_id ? '<span class="topic-branch-icon" title="Branched">↗</span>' : ''
|
|
486
|
+
const unreadBadge = topic.unread_count > 0
|
|
487
|
+
? `<span class="topic-unread-badge">${topic.unread_count}</span>`
|
|
488
|
+
: ''
|
|
157
489
|
const isMainTopic = this.mainTopicId && String(topic.id) === String(this.mainTopicId)
|
|
158
490
|
let s = `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
|
|
159
491
|
data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
|
|
160
492
|
data-id="${topic.id}"${topic.source_topic_id ? ` data-source-topic-id="${topic.source_topic_id}"` : ''}>
|
|
161
|
-
${agentAvatar}${branchIcon}#${topic.name}`
|
|
493
|
+
${agentAvatar}${branchIcon}#${topic.name}${unreadBadge}`
|
|
162
494
|
if (canManage && !isMainTopic) {
|
|
163
495
|
s += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
|
|
164
496
|
s += `<button class="delete-topic-btn" data-action="click->comments--topics#deleteTopic" data-id="${topic.id}">×</button>`
|
|
@@ -176,28 +508,64 @@ export default class extends Controller {
|
|
|
176
508
|
|
|
177
509
|
// Archived topics section
|
|
178
510
|
if (this.archivedTopics && this.archivedTopics.length > 0) {
|
|
179
|
-
|
|
511
|
+
// Badges are re-applied from state on every render because innerHTML
|
|
512
|
+
// below wipes the classes set by handleNewMessage.
|
|
513
|
+
const toggleBadge = this.archivedWithNewMessages.size > 0 ? ' has-new-messages' : ''
|
|
514
|
+
html += `<span class="topic-archived-toggle${toggleBadge}" data-action="click->comments--topics#toggleArchivedTopics">
|
|
180
515
|
${ICON_ARCHIVE} ${this.archivedTopics.length}
|
|
181
516
|
</span>`
|
|
182
517
|
if (this.showingArchived) {
|
|
183
518
|
this.archivedTopics.forEach(topic => {
|
|
184
|
-
|
|
185
|
-
|
|
519
|
+
const isActive = String(this.currentTopicId) === String(topic.id) ? ' active' : ''
|
|
520
|
+
const hasNew = this.archivedWithNewMessages.has(String(topic.id)) ? ' has-new-messages' : ''
|
|
521
|
+
const unreadBadge = topic.unread_count > 0
|
|
522
|
+
? `<span class="topic-unread-badge">${topic.unread_count}</span>`
|
|
523
|
+
: ''
|
|
524
|
+
html += `<span class="topic-tag topic-archived${isActive}${hasNew}"
|
|
525
|
+
data-action="click->comments--topics#select"
|
|
526
|
+
data-id="${topic.id}">
|
|
527
|
+
#${topic.name}${unreadBadge}
|
|
186
528
|
${canManage ? `<button class="unarchive-topic-btn" data-action="click->comments--topics#unarchiveTopic" data-id="${topic.id}" title="Restore">${ICON_RESTORE}</button>` : ''}
|
|
187
529
|
</span>`
|
|
188
530
|
})
|
|
189
531
|
}
|
|
190
532
|
}
|
|
191
533
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
534
|
+
this.listTarget.innerHTML = html
|
|
535
|
+
// Which creative the chips now on screen belong to. A click can only
|
|
536
|
+
// ever be about this one, whatever this.creativeId has since become.
|
|
537
|
+
this._renderedCreativeId = sourceCreativeId
|
|
538
|
+
this._topicsCreativeId = sourceCreativeId
|
|
539
|
+
|
|
540
|
+
// The create button lives outside the scrolling strip so it stays reachable
|
|
541
|
+
// without horizontal scrolling, no matter how many topics there are.
|
|
542
|
+
this.renderCreationContainer(canCreateTopic)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Write permission is sufficient for topic creation.
|
|
546
|
+
renderCreationContainer(canCreateTopic) {
|
|
547
|
+
if (!this.hasCreationContainerTarget) return
|
|
548
|
+
const container = this.creationContainerTarget
|
|
549
|
+
|
|
550
|
+
container.hidden = !canCreateTopic
|
|
551
|
+
if (!canCreateTopic) {
|
|
552
|
+
container.innerHTML = ''
|
|
553
|
+
return
|
|
198
554
|
}
|
|
199
555
|
|
|
200
|
-
|
|
556
|
+
// renderTopics re-runs on every topic broadcast; don't wipe a name being typed.
|
|
557
|
+
// `creating` marks an already-submitted name, whose input must give way to the button.
|
|
558
|
+
// A draft only survives re-renders of the creative it was typed for: chat-nav
|
|
559
|
+
// switches creatives without blurring, and posting it there is the wrong creative.
|
|
560
|
+
const draftIsCurrent = String(this._draftCreativeId) === String(this.creativeId)
|
|
561
|
+
if (!this.creating && draftIsCurrent && container.querySelector('.topic-input')) return
|
|
562
|
+
|
|
563
|
+
this.renderAddButton()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
renderAddButton() {
|
|
567
|
+
this.creationContainerTarget.innerHTML =
|
|
568
|
+
`<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
|
|
201
569
|
}
|
|
202
570
|
|
|
203
571
|
handleDragOver(event) {
|
|
@@ -345,7 +713,7 @@ export default class extends Controller {
|
|
|
345
713
|
|
|
346
714
|
// Update local state and UI immediately
|
|
347
715
|
this.topics = topics
|
|
348
|
-
this.renderTopics(topics, this.canManageTopics, this.canCreateTopic)
|
|
716
|
+
this.renderTopics(topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
349
717
|
this.restoreSelection()
|
|
350
718
|
|
|
351
719
|
// Send to server
|
|
@@ -389,6 +757,9 @@ export default class extends Controller {
|
|
|
389
757
|
|
|
390
758
|
if (!topicId) return
|
|
391
759
|
|
|
760
|
+
const deletingSelectedTopic = String(this.currentTopicId) === String(topicId)
|
|
761
|
+
const scrollInterruptionVersion = this._topicScrollInterruptionVersion
|
|
762
|
+
|
|
392
763
|
try {
|
|
393
764
|
const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}`, {
|
|
394
765
|
method: 'DELETE',
|
|
@@ -398,13 +769,25 @@ export default class extends Controller {
|
|
|
398
769
|
})
|
|
399
770
|
|
|
400
771
|
if (response.ok) {
|
|
772
|
+
// The server can broadcast "deleted" before this response. In
|
|
773
|
+
// that case removeTopic() has already cleared currentTopicId,
|
|
774
|
+
// but this local removal should still reveal All Messages.
|
|
775
|
+
// A newer wheel/touch gesture always wins, though.
|
|
776
|
+
if (deletingSelectedTopic
|
|
777
|
+
&& this._topicScrollInterruptionVersion === scrollInterruptionVersion) {
|
|
778
|
+
this._topicScrollInterrupted = false
|
|
779
|
+
}
|
|
401
780
|
if (String(this.currentTopicId) === String(topicId)) {
|
|
781
|
+
// Same deep-link hazard as the "deleted" broadcast: this
|
|
782
|
+
// path reaches restoreSelection through loadTopics instead
|
|
783
|
+
// of removeTopic, but the getter is the same one.
|
|
784
|
+
this.releaseDeepLinkSelection(topicId)
|
|
402
785
|
this.currentTopicId = "" // Switch to Main
|
|
403
786
|
this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
|
|
404
787
|
}
|
|
405
788
|
this.loadTopics()
|
|
406
789
|
} else {
|
|
407
|
-
alertDialog("
|
|
790
|
+
alertDialog(this._i18n("delete_error"))
|
|
408
791
|
}
|
|
409
792
|
} catch (e) {
|
|
410
793
|
console.error("Error deleting topic", e)
|
|
@@ -426,12 +809,31 @@ export default class extends Controller {
|
|
|
426
809
|
|
|
427
810
|
if (response.ok) {
|
|
428
811
|
if (String(this.currentTopicId) === String(topicId)) {
|
|
812
|
+
this._topicScrollInterrupted = false
|
|
813
|
+
// Archiving the topic in view switches to All Messages, but
|
|
814
|
+
// the preference save is debounced 500ms and both this
|
|
815
|
+
// reload and the "archived" broadcast's own reload can beat
|
|
816
|
+
// it. restoreSelection() accepts archived ids now, so the
|
|
817
|
+
// stale last_topic_id would drop the user straight back into
|
|
818
|
+
// the topic they just archived out of. Flag it instead of
|
|
819
|
+
// flushing the save: the broadcast reload cannot be ordered
|
|
820
|
+
// behind the flush, only ignored.
|
|
821
|
+
this.archivedAwayTopicId = String(topicId)
|
|
822
|
+
// Both sources that outrank serverLastTopicId in the
|
|
823
|
+
// currentTopicId getter have to go too, or the archived
|
|
824
|
+
// topic stays the effective selection no matter what the
|
|
825
|
+
// preference says: overrideTopicId (deep link) and the
|
|
826
|
+
// ?topic_id= query parameter.
|
|
827
|
+
this.releaseDeepLinkSelection(topicId)
|
|
429
828
|
this.currentTopicId = ""
|
|
430
829
|
this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
|
|
431
830
|
}
|
|
831
|
+
// The actor has the same stale-membership window as everyone
|
|
832
|
+
// receiving the broadcast, so close it on this side too.
|
|
833
|
+
this.applyArchiveTransition("archived", { id: topicId })
|
|
432
834
|
this.loadTopics()
|
|
433
835
|
} else {
|
|
434
|
-
alertDialog("
|
|
836
|
+
alertDialog(this._i18n("archive_error"))
|
|
435
837
|
}
|
|
436
838
|
} catch (e) {
|
|
437
839
|
console.error("Error archiving topic", e)
|
|
@@ -452,9 +854,10 @@ export default class extends Controller {
|
|
|
452
854
|
})
|
|
453
855
|
|
|
454
856
|
if (response.ok) {
|
|
857
|
+
this.applyArchiveTransition("unarchived", { id: topicId })
|
|
455
858
|
this.loadTopics()
|
|
456
859
|
} else {
|
|
457
|
-
alertDialog("
|
|
860
|
+
alertDialog(this._i18n("restore_error"))
|
|
458
861
|
}
|
|
459
862
|
} catch (e) {
|
|
460
863
|
console.error("Error restoring topic", e)
|
|
@@ -464,31 +867,47 @@ export default class extends Controller {
|
|
|
464
867
|
toggleArchivedTopics(event) {
|
|
465
868
|
event.stopPropagation()
|
|
466
869
|
this.showingArchived = !this.showingArchived
|
|
467
|
-
|
|
468
|
-
|
|
870
|
+
// Collapsing while the open topic lives in this section is a deliberate
|
|
871
|
+
// choice to hide it, so record it. Every re-render runs restoreSelection
|
|
872
|
+
// → selectTopic → revealArchivedTopic, which would otherwise reopen the
|
|
873
|
+
// section on any unrelated rename, reorder, or topic broadcast.
|
|
874
|
+
this.archivedCollapsedTopicId = !this.showingArchived && this.isArchivedTopic(this.currentTopicId)
|
|
875
|
+
? String(this.currentTopicId)
|
|
876
|
+
: null
|
|
877
|
+
// No restoreSelection() here: toggling cannot invalidate the selection, and
|
|
878
|
+
// renderTopics already re-applies the active class. Re-selecting would
|
|
879
|
+
// re-expand the section the user just collapsed (selectTopic reveals an
|
|
880
|
+
// archived selection) and pointlessly reload the message list.
|
|
881
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
469
882
|
}
|
|
470
883
|
|
|
471
884
|
openTopicListPopup(event) {
|
|
885
|
+
if (this.topicListTogglePointerDown) {
|
|
886
|
+
this.cancelTopicListToggle()
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
|
|
472
890
|
const btnRect = event.currentTarget.getBoundingClientRect()
|
|
473
891
|
|
|
474
892
|
const openWith = (popup) => {
|
|
475
893
|
popup.openForTopics(
|
|
476
|
-
|
|
477
|
-
topics: this.topics || [],
|
|
478
|
-
archivedTopics: this.archivedTopics || [],
|
|
479
|
-
mainTopicId: this.mainTopicId,
|
|
480
|
-
allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
|
|
481
|
-
},
|
|
894
|
+
this.topicListData(),
|
|
482
895
|
btnRect,
|
|
483
|
-
(item) => this.selectTopic(item.id),
|
|
896
|
+
(item) => this.selectTopic(item.id, { userInitiated: true }),
|
|
484
897
|
this.element
|
|
485
898
|
)
|
|
899
|
+
this.setTopicListButtonExpanded(true)
|
|
486
900
|
}
|
|
487
901
|
|
|
488
902
|
let modal = document.getElementById('topic-list-modal')
|
|
489
903
|
if (modal) {
|
|
490
904
|
const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
491
|
-
if (popup)
|
|
905
|
+
if (popup?.popup?.isOpen()) {
|
|
906
|
+
popup?.close()
|
|
907
|
+
this.setTopicListButtonExpanded(false)
|
|
908
|
+
} else if (popup) {
|
|
909
|
+
openWith(popup)
|
|
910
|
+
}
|
|
492
911
|
return
|
|
493
912
|
}
|
|
494
913
|
|
|
@@ -515,11 +934,164 @@ export default class extends Controller {
|
|
|
515
934
|
})
|
|
516
935
|
}
|
|
517
936
|
|
|
937
|
+
topicListData() {
|
|
938
|
+
return {
|
|
939
|
+
topics: this.topics || [],
|
|
940
|
+
archivedTopics: this.archivedTopics || [],
|
|
941
|
+
mainTopicId: this.mainTopicId,
|
|
942
|
+
allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
refreshOpenTopicListPopup() {
|
|
947
|
+
const modal = this.element.querySelector('#topic-list-modal')
|
|
948
|
+
const popup = modal && this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
949
|
+
if (popup?.popup?.isOpen()) popup.updateTopics(this.topicListData())
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
scheduleUnreadCountRefresh() {
|
|
953
|
+
if (this._unreadCountRefreshInFlight) {
|
|
954
|
+
this._unreadCountRefreshQueued = true
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
if (this._unreadCountRefreshTimer) return
|
|
958
|
+
|
|
959
|
+
this._unreadCountRefreshTimer = setTimeout(() => {
|
|
960
|
+
this._unreadCountRefreshTimer = null
|
|
961
|
+
this._unreadCountRefreshInFlight = true
|
|
962
|
+
this.refreshUnreadCounts()
|
|
963
|
+
.catch(error => console.error("Failed to refresh topic unread counts", error))
|
|
964
|
+
.finally(() => {
|
|
965
|
+
this._unreadCountRefreshInFlight = false
|
|
966
|
+
if (!this._unreadCountRefreshQueued) return
|
|
967
|
+
|
|
968
|
+
this._unreadCountRefreshQueued = false
|
|
969
|
+
this.scheduleUnreadCountRefresh()
|
|
970
|
+
})
|
|
971
|
+
}, UNREAD_COUNT_REFRESH_DELAY)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
cancelUnreadCountRefresh() {
|
|
975
|
+
clearTimeout(this._unreadCountRefreshTimer)
|
|
976
|
+
this._unreadCountRefreshTimer = null
|
|
977
|
+
this._unreadCountRefreshQueued = false
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async refreshUnreadCounts() {
|
|
981
|
+
if (!this.creativeId) return
|
|
982
|
+
|
|
983
|
+
const creativeId = String(this.creativeId)
|
|
984
|
+
const loadVersion = this._loadTopicsVersion
|
|
985
|
+
const response = await fetch(`/creatives/${creativeId}/topics`)
|
|
986
|
+
if (!response.ok) return
|
|
987
|
+
|
|
988
|
+
const data = await response.json()
|
|
989
|
+
if (String(this.creativeId) !== creativeId || this._loadTopicsVersion !== loadVersion) return
|
|
990
|
+
|
|
991
|
+
const activeTopics = Array.isArray(data) ? data : data.topics
|
|
992
|
+
const counts = new Map(
|
|
993
|
+
[...(activeTopics || []), ...(data.archived_topics || [])]
|
|
994
|
+
.map(topic => [String(topic.id), Number(topic.unread_count) || 0])
|
|
995
|
+
)
|
|
996
|
+
this._unreadCountsOverlay = { loadVersion, counts }
|
|
997
|
+
this.topics = this.applyUnreadCounts(this.topics, counts)
|
|
998
|
+
this.archivedTopics = this.applyUnreadCounts(this.archivedTopics, counts)
|
|
999
|
+
if (this.currentTopicId) this.clearNewMessageBadge(this.currentTopicId)
|
|
1000
|
+
this.refreshRenderedUnreadCounts()
|
|
1001
|
+
this.refreshOpenTopicListPopup()
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
applyUnreadCounts(topics = [], counts = null) {
|
|
1005
|
+
if (!counts) return topics
|
|
1006
|
+
|
|
1007
|
+
return topics.map(topic => counts.has(String(topic.id))
|
|
1008
|
+
? { ...topic, unread_count: counts.get(String(topic.id)) }
|
|
1009
|
+
: topic)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
refreshRenderedUnreadCounts() {
|
|
1013
|
+
const topicsById = new Map(
|
|
1014
|
+
[...(this.topics || []), ...(this.archivedTopics || [])]
|
|
1015
|
+
.map(topic => [String(topic.id), topic])
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
this.listTarget.querySelectorAll('.topic-tag[data-id]').forEach(topicEl => {
|
|
1019
|
+
const topic = topicsById.get(String(topicEl.dataset.id))
|
|
1020
|
+
if (!topic || topicEl.querySelector('.topic-edit-input')) return
|
|
1021
|
+
|
|
1022
|
+
const count = Number(topic.unread_count) || 0
|
|
1023
|
+
const badge = topicEl.querySelector('.topic-unread-badge')
|
|
1024
|
+
if (count <= 0) {
|
|
1025
|
+
badge?.remove()
|
|
1026
|
+
return
|
|
1027
|
+
}
|
|
1028
|
+
if (badge) {
|
|
1029
|
+
badge.textContent = String(count)
|
|
1030
|
+
return
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const newBadge = document.createElement('span')
|
|
1034
|
+
newBadge.className = 'topic-unread-badge'
|
|
1035
|
+
newBadge.textContent = String(count)
|
|
1036
|
+
topicEl.insertBefore(newBadge, topicEl.querySelector('button'))
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
prepareTopicListToggle(event) {
|
|
1041
|
+
if (event.isPrimary === false || event.button !== 0) return
|
|
1042
|
+
|
|
1043
|
+
const modal = document.getElementById('topic-list-modal')
|
|
1044
|
+
const popup = modal && this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
1045
|
+
// Let every open popup receive this pointer event and perform its normal
|
|
1046
|
+
// outside-click cleanup. If this popup was one of them, consume the
|
|
1047
|
+
// following click so it does not immediately reopen.
|
|
1048
|
+
if (popup?.popup?.isOpen()) {
|
|
1049
|
+
this.topicListTogglePointerDown = true
|
|
1050
|
+
this.topicListTogglePointerId = event.pointerId
|
|
1051
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
finishTopicListToggle(event) {
|
|
1056
|
+
if (event.pointerId !== this.topicListTogglePointerId) return
|
|
1057
|
+
|
|
1058
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
1059
|
+
const releasedOutsideButton = event.clientX < rect.left || event.clientX > rect.right ||
|
|
1060
|
+
event.clientY < rect.top || event.clientY > rect.bottom
|
|
1061
|
+
if (releasedOutsideButton) {
|
|
1062
|
+
this.cancelTopicListToggle(event)
|
|
1063
|
+
} else {
|
|
1064
|
+
// A completed activation dispatches click before the next task. Clear a
|
|
1065
|
+
// canceled in-button gesture afterwards so it cannot consume a later click.
|
|
1066
|
+
this.topicListToggleClearTimeout = setTimeout(() => this.cancelTopicListToggle(event), 0)
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
cancelTopicListToggle(event = {}) {
|
|
1071
|
+
if (event.pointerId != null && event.pointerId !== this.topicListTogglePointerId) return
|
|
1072
|
+
|
|
1073
|
+
clearTimeout(this.topicListToggleClearTimeout)
|
|
1074
|
+
this.topicListToggleClearTimeout = undefined
|
|
1075
|
+
this.topicListTogglePointerDown = false
|
|
1076
|
+
this.topicListTogglePointerId = undefined
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
handleTopicListClose() {
|
|
1080
|
+
this.setTopicListButtonExpanded(false)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
setTopicListButtonExpanded(expanded) {
|
|
1084
|
+
if (this.hasTopicListButtonTarget) {
|
|
1085
|
+
this.topicListButtonTarget.setAttribute('aria-expanded', String(expanded))
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
518
1089
|
showInput(event) {
|
|
519
1090
|
event.preventDefault()
|
|
520
|
-
|
|
521
|
-
|
|
1091
|
+
if (!this.hasCreationContainerTarget) return
|
|
1092
|
+
const container = this.creationContainerTarget
|
|
522
1093
|
|
|
1094
|
+
this._draftCreativeId = this.creativeId
|
|
523
1095
|
const placeholder = this.listTarget.dataset.newTopicPlaceholder || "New Topic"
|
|
524
1096
|
container.innerHTML = `<input type="text" class="topic-input" placeholder="${placeholder}"
|
|
525
1097
|
data-action="keydown->comments--topics#handleInputKey blur->comments--topics#resetInput"
|
|
@@ -532,9 +1104,8 @@ export default class extends Controller {
|
|
|
532
1104
|
resetInput() {
|
|
533
1105
|
// Small delay to allow enter key to process first if that was the cause
|
|
534
1106
|
setTimeout(() => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
container.innerHTML = `<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
|
|
1107
|
+
if (this.hasCreationContainerTarget && !this.creating) {
|
|
1108
|
+
this.renderAddButton()
|
|
538
1109
|
}
|
|
539
1110
|
}, 200)
|
|
540
1111
|
}
|
|
@@ -563,7 +1134,7 @@ export default class extends Controller {
|
|
|
563
1134
|
if (event.target.closest('.topic-branch-icon')) {
|
|
564
1135
|
const sourceTopicId = event.currentTarget.dataset.sourceTopicId
|
|
565
1136
|
if (sourceTopicId) {
|
|
566
|
-
this.selectTopic(sourceTopicId)
|
|
1137
|
+
this.selectTopic(sourceTopicId, { userInitiated: true })
|
|
567
1138
|
return
|
|
568
1139
|
}
|
|
569
1140
|
}
|
|
@@ -576,11 +1147,18 @@ export default class extends Controller {
|
|
|
576
1147
|
return
|
|
577
1148
|
}
|
|
578
1149
|
|
|
579
|
-
this.selectTopic(id)
|
|
1150
|
+
this.selectTopic(id, { userInitiated: true })
|
|
580
1151
|
}
|
|
581
1152
|
|
|
582
|
-
selectTopic(id) {
|
|
583
|
-
|
|
1153
|
+
selectTopic(id, { reveal = true, pick = true, persist = true, userInitiated = false } = {}) {
|
|
1154
|
+
// Only restoreSelection() is guarded, so reaching selectTopic with this
|
|
1155
|
+
// id means the user deliberately went back into the archived topic.
|
|
1156
|
+
// The transition is over.
|
|
1157
|
+
if (this.archivedAwayTopicId && String(id) === this.archivedAwayTopicId) {
|
|
1158
|
+
this.archivedAwayTopicId = null
|
|
1159
|
+
}
|
|
1160
|
+
if (reveal) this.revealArchivedTopic(id)
|
|
1161
|
+
this.updateSelectionUI(id, { pick, persist, pending: pick, userInitiated })
|
|
584
1162
|
if (id) {
|
|
585
1163
|
this.clearNewMessageBadge(id)
|
|
586
1164
|
}
|
|
@@ -634,6 +1212,7 @@ export default class extends Controller {
|
|
|
634
1212
|
if (topicEl && topicEl.dataset.originalHtml) {
|
|
635
1213
|
topicEl.innerHTML = topicEl.dataset.originalHtml
|
|
636
1214
|
delete topicEl.dataset.originalHtml
|
|
1215
|
+
this.refreshRenderedUnreadCounts()
|
|
637
1216
|
}
|
|
638
1217
|
|
|
639
1218
|
setTimeout(() => { this.editCancelling = false }, 100)
|
|
@@ -658,10 +1237,10 @@ export default class extends Controller {
|
|
|
658
1237
|
if (index !== -1) {
|
|
659
1238
|
this.topics[index] = { ...this.topics[index], ...updatedTopic }
|
|
660
1239
|
}
|
|
661
|
-
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
1240
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
662
1241
|
this.restoreSelection()
|
|
663
1242
|
} else {
|
|
664
|
-
alertDialog("
|
|
1243
|
+
alertDialog(this._i18n("update_error"))
|
|
665
1244
|
this.loadTopics() // Reload to restore state
|
|
666
1245
|
}
|
|
667
1246
|
} catch (e) {
|
|
@@ -670,8 +1249,9 @@ export default class extends Controller {
|
|
|
670
1249
|
}
|
|
671
1250
|
}
|
|
672
1251
|
|
|
673
|
-
updateSelectionUI(id) {
|
|
674
|
-
this.
|
|
1252
|
+
updateSelectionUI(id, { pick = true, persist = true, pending = false, userInitiated = false } = {}) {
|
|
1253
|
+
if (userInitiated) this._topicScrollInterrupted = false
|
|
1254
|
+
this.applySelection(id, { pick, persist, pending })
|
|
675
1255
|
// Update UI
|
|
676
1256
|
let activeEl = null
|
|
677
1257
|
this.listTarget.querySelectorAll('.topic-tag').forEach(el => {
|
|
@@ -682,17 +1262,56 @@ export default class extends Controller {
|
|
|
682
1262
|
activeEl = el
|
|
683
1263
|
}
|
|
684
1264
|
})
|
|
685
|
-
|
|
686
|
-
if (activeEl) {
|
|
687
|
-
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
|
688
|
-
}
|
|
1265
|
+
if (activeEl) this.scrollTopicIntoView(activeEl)
|
|
689
1266
|
}
|
|
690
1267
|
|
|
691
1268
|
scrollToActiveTopic() {
|
|
692
1269
|
const activeEl = this.listTarget.querySelector('.topic-tag.active')
|
|
693
|
-
if (activeEl)
|
|
694
|
-
|
|
1270
|
+
if (activeEl) this.scrollTopicIntoView(activeEl)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
scrollTopicIntoView(topic) {
|
|
1274
|
+
if (this._popupClosed || this._topicScrollInterrupted) return
|
|
1275
|
+
|
|
1276
|
+
this.cancelProgrammaticScroll()
|
|
1277
|
+
const list = this.listTarget
|
|
1278
|
+
const listRect = list.getBoundingClientRect()
|
|
1279
|
+
const topicRect = topic.getBoundingClientRect()
|
|
1280
|
+
const maxLeft = Math.max(0, list.scrollWidth - list.clientWidth)
|
|
1281
|
+
const targetLeft = Math.max(0, Math.min(
|
|
1282
|
+
maxLeft,
|
|
1283
|
+
list.scrollLeft + topicRect.left + (topicRect.width / 2)
|
|
1284
|
+
- listRect.left - (listRect.width / 2),
|
|
1285
|
+
))
|
|
1286
|
+
const startLeft = list.scrollLeft
|
|
1287
|
+
const distance = targetLeft - startLeft
|
|
1288
|
+
if (Math.abs(distance) < 1) return
|
|
1289
|
+
|
|
1290
|
+
const startedAt = performance.now()
|
|
1291
|
+
const step = now => {
|
|
1292
|
+
const progress = Math.min((now - startedAt) / TOPIC_SCROLL_DURATION, 1)
|
|
1293
|
+
const easedProgress = 1 - ((1 - progress) ** 3)
|
|
1294
|
+
list.scrollLeft = startLeft + (distance * easedProgress)
|
|
1295
|
+
if (progress < 1) {
|
|
1296
|
+
this._topicScrollFrame = requestAnimationFrame(step)
|
|
1297
|
+
} else {
|
|
1298
|
+
this._topicScrollFrame = null
|
|
1299
|
+
}
|
|
695
1300
|
}
|
|
1301
|
+
this._topicScrollFrame = requestAnimationFrame(step)
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
cancelProgrammaticScroll() {
|
|
1305
|
+
if (this._topicScrollFrame === null) return
|
|
1306
|
+
|
|
1307
|
+
cancelAnimationFrame(this._topicScrollFrame)
|
|
1308
|
+
this._topicScrollFrame = null
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
handleTopicScrollInput() {
|
|
1312
|
+
this._topicScrollInterruptionVersion += 1
|
|
1313
|
+
this._topicScrollInterrupted = true
|
|
1314
|
+
this.cancelProgrammaticScroll()
|
|
696
1315
|
}
|
|
697
1316
|
|
|
698
1317
|
handleTopicMoved(event) {
|
|
@@ -701,6 +1320,7 @@ export default class extends Controller {
|
|
|
701
1320
|
if (String(sourceCreativeId) === String(this.creativeId)) {
|
|
702
1321
|
// If we were viewing the moved topic, switch to Main
|
|
703
1322
|
if (String(this.currentTopicId) === String(topicId)) {
|
|
1323
|
+
this._topicScrollInterrupted = false
|
|
704
1324
|
this.currentTopicId = ""
|
|
705
1325
|
this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
|
|
706
1326
|
}
|
|
@@ -715,9 +1335,18 @@ export default class extends Controller {
|
|
|
715
1335
|
// Don't show badge if we are currently in this topic (shouldn't happen due to list_controller logic, but safety check)
|
|
716
1336
|
if (String(this.currentTopicId) === String(topicId)) return
|
|
717
1337
|
|
|
1338
|
+
const isArchived = this.isArchivedTopic(topicId)
|
|
1339
|
+
if (isArchived) this.archivedWithNewMessages.add(String(topicId))
|
|
1340
|
+
|
|
718
1341
|
const topicEl = this.listTarget.querySelector(`.topic-tag[data-id="${topicId}"]`)
|
|
719
|
-
if (topicEl)
|
|
720
|
-
|
|
1342
|
+
if (topicEl) topicEl.classList.add('has-new-messages')
|
|
1343
|
+
|
|
1344
|
+
this.scheduleUnreadCountRefresh()
|
|
1345
|
+
|
|
1346
|
+
// A collapsed archived section has no chip to badge, so the toggle carries
|
|
1347
|
+
// the notice — otherwise a message in an archived topic is invisible.
|
|
1348
|
+
if (isArchived) {
|
|
1349
|
+
this.listTarget.querySelector('.topic-archived-toggle')?.classList.add('has-new-messages')
|
|
721
1350
|
}
|
|
722
1351
|
}
|
|
723
1352
|
|
|
@@ -725,6 +1354,17 @@ export default class extends Controller {
|
|
|
725
1354
|
const topicEl = this.listTarget.querySelector(`.topic-tag[data-id="${topicId}"]`)
|
|
726
1355
|
if (topicEl) {
|
|
727
1356
|
topicEl.classList.remove('has-new-messages')
|
|
1357
|
+
topicEl.querySelector('.topic-unread-badge')?.remove()
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const topic = [...(this.topics || []), ...(this.archivedTopics || [])]
|
|
1361
|
+
.find(candidate => String(candidate.id) === String(topicId))
|
|
1362
|
+
if (topic) topic.unread_count = 0
|
|
1363
|
+
|
|
1364
|
+
// The toggle aggregates every archived topic, so it only clears once none
|
|
1365
|
+
// of them is left unread.
|
|
1366
|
+
if (this.archivedWithNewMessages.delete(String(topicId)) && this.archivedWithNewMessages.size === 0) {
|
|
1367
|
+
this.listTarget.querySelector('.topic-archived-toggle')?.classList.remove('has-new-messages')
|
|
728
1368
|
}
|
|
729
1369
|
}
|
|
730
1370
|
|
|
@@ -745,6 +1385,7 @@ export default class extends Controller {
|
|
|
745
1385
|
|
|
746
1386
|
if (response.ok) {
|
|
747
1387
|
const topic = await response.json()
|
|
1388
|
+
this._topicScrollInterrupted = false
|
|
748
1389
|
this.currentTopicId = topic.id
|
|
749
1390
|
// Flush save immediately so loadTopics gets the correct value from server
|
|
750
1391
|
await this.flushSaveLastTopic(topic.id)
|
|
@@ -752,7 +1393,7 @@ export default class extends Controller {
|
|
|
752
1393
|
// Dispatch change event manually since we skipped the click handler
|
|
753
1394
|
this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
|
|
754
1395
|
} else {
|
|
755
|
-
alertDialog("
|
|
1396
|
+
alertDialog(this._i18n("create_error"))
|
|
756
1397
|
}
|
|
757
1398
|
} catch (e) {
|
|
758
1399
|
console.error("Error creating topic", e)
|
|
@@ -776,6 +1417,37 @@ export default class extends Controller {
|
|
|
776
1417
|
return this.serverLastTopicId || ""
|
|
777
1418
|
}
|
|
778
1419
|
|
|
1420
|
+
// Is one of the two sources above the preference still set? Not the same
|
|
1421
|
+
// question as "is the getter's answer nonempty" — an override of "" outranks
|
|
1422
|
+
// the preference just as a named one does.
|
|
1423
|
+
get hasDeepLinkSelection() {
|
|
1424
|
+
if (this.overrideTopicId !== undefined && this.overrideTopicId !== null) return true
|
|
1425
|
+
|
|
1426
|
+
return Boolean(new URLSearchParams(window.location.search).get('topic_id'))
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// A topic leaving the strip — archived or deleted — has to leave both
|
|
1430
|
+
// sources that outrank serverLastTopicId in the currentTopicId getter.
|
|
1431
|
+
// Assigning "" only touches the preference, so on its own the getter goes
|
|
1432
|
+
// on naming the gone topic and every later restoreSelection() finds it
|
|
1433
|
+
// unknown and resets to Main, discarding whatever the user picked instead.
|
|
1434
|
+
releaseDeepLinkSelection(topicId) {
|
|
1435
|
+
this.clearOverrideTopicId()
|
|
1436
|
+
this.clearUrlTopicId(topicId)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Drop ?topic_id= when it names the topic being archived. It is a selection
|
|
1440
|
+
// source in its own right and survives every reload, so leaving it would
|
|
1441
|
+
// re-select the topic the user just archived out of. replaceState, not a
|
|
1442
|
+
// navigation: the chat state around it must stay put.
|
|
1443
|
+
clearUrlTopicId(topicId) {
|
|
1444
|
+
const url = new URL(window.location.href)
|
|
1445
|
+
if (url.searchParams.get('topic_id') !== String(topicId)) return
|
|
1446
|
+
|
|
1447
|
+
url.searchParams.delete('topic_id')
|
|
1448
|
+
window.history.replaceState(window.history.state, '', url.toString())
|
|
1449
|
+
}
|
|
1450
|
+
|
|
779
1451
|
setOverrideTopicId(id) {
|
|
780
1452
|
this.overrideTopicId = id ? String(id) : ""
|
|
781
1453
|
}
|
|
@@ -785,60 +1457,907 @@ export default class extends Controller {
|
|
|
785
1457
|
}
|
|
786
1458
|
|
|
787
1459
|
set currentTopicId(id) {
|
|
788
|
-
this.
|
|
789
|
-
|
|
1460
|
+
this.applySelection(id)
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// pick: this selection is new intent — the user picked it, or the server
|
|
1464
|
+
// told us their preference moved. A restore is not: it re-derives the
|
|
1465
|
+
// selection from state already held, so it is never newer than anything and
|
|
1466
|
+
// must leave the sources that outrank the preference alone. Recording the
|
|
1467
|
+
// preference and saving it happen either way; only the two consequences of
|
|
1468
|
+
// *intent* are gated.
|
|
1469
|
+
applySelection(id, { pick = true, persist = true, pending = false } = {}) {
|
|
1470
|
+
if (persist) this.serverLastTopicId = id ? String(id) : ""
|
|
1471
|
+
if (pick) {
|
|
1472
|
+
// Writing only the preference leaves the two sources that outrank it
|
|
1473
|
+
// in the getter still naming the topic being left, so the getter
|
|
1474
|
+
// keeps answering with it: the next renderTopics() lights the old
|
|
1475
|
+
// chip and the next restoreSelection() navigates back to it. Both
|
|
1476
|
+
// are one-shot pointers at a topic to open, and this selection has
|
|
1477
|
+
// moved off it. Dropped only when they disagree — a deep link that
|
|
1478
|
+
// resolved to the topic now being selected must keep outranking the
|
|
1479
|
+
// stale server last_topic_id for the rest of the popup session.
|
|
1480
|
+
this.releaseSelectionSourcesOtherThan(this.serverLastTopicId)
|
|
1481
|
+
this.selectionEpoch += 1
|
|
1482
|
+
this._pickCreativeId = this._renderedCreativeId
|
|
1483
|
+
this._pickTopicId = this.serverLastTopicId
|
|
1484
|
+
if (pending) {
|
|
1485
|
+
this._pendingPick = {
|
|
1486
|
+
creativeId: this._pickCreativeId,
|
|
1487
|
+
topicId: this._pickTopicId,
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
if (persist) this.debounceSaveLastTopic(id)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Bumped on every selection so an in-flight loadTopics() can tell whether
|
|
1495
|
+
// its answer predates a pick the user has since made.
|
|
1496
|
+
get selectionEpoch() {
|
|
1497
|
+
return this._selectionEpoch || 0
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
set selectionEpoch(value) {
|
|
1501
|
+
this._selectionEpoch = value
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Did a pick land after the load at `epoch` started, or is there still an
|
|
1505
|
+
// unsaved pick about the creative that load describes? A later load can
|
|
1506
|
+
// begin before the pick's debounce lands, so request age alone cannot tell
|
|
1507
|
+
// whether its answer predates the pick. The strip the click landed on,
|
|
1508
|
+
// not by this.creativeId — onPopupOpened assigns that synchronously before
|
|
1509
|
+
// the fetch, so a click on the outgoing creative's chips already carries the
|
|
1510
|
+
// incoming id. The rendered strip is what the user was actually looking at.
|
|
1511
|
+
//
|
|
1512
|
+
// An empty pick (All Messages) names no topic to match against the response,
|
|
1513
|
+
// and its provenance is the only thing that distinguishes it from a click on
|
|
1514
|
+
// a stale strip — so it rests on that test alone. A named pick is checked
|
|
1515
|
+
// against the topics too: one whose topic the response does not list was
|
|
1516
|
+
// made against a strip that predates a delete, and keeping it would fail the
|
|
1517
|
+
// lookup in restoreSelection() and persist Main in its place.
|
|
1518
|
+
pickOutranks(epoch, creativeId, topics, archivedTopics) {
|
|
1519
|
+
const hasNewerPick = this.selectionEpoch !== epoch
|
|
1520
|
+
const hasUnsavedPick = this._pendingPick &&
|
|
1521
|
+
String(this._pendingPick.creativeId) === String(creativeId)
|
|
1522
|
+
if (!hasNewerPick && !hasUnsavedPick) return false
|
|
1523
|
+
// creativeId is always truthy here — loadTopics() returns without it —
|
|
1524
|
+
// so a pick made before any strip was rendered fails this too.
|
|
1525
|
+
if (String(this._pickCreativeId) !== String(creativeId)) return false
|
|
1526
|
+
if (!this._pickTopicId) return true
|
|
1527
|
+
|
|
1528
|
+
return [ ...(topics || []), ...(archivedTopics || []) ]
|
|
1529
|
+
.some(t => String(t.id) === String(this._pickTopicId))
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
releaseSelectionSourcesOtherThan(id) {
|
|
1533
|
+
if (this.overrideTopicId !== undefined && this.overrideTopicId !== null &&
|
|
1534
|
+
String(this.overrideTopicId) !== String(id)) {
|
|
1535
|
+
this.clearOverrideTopicId()
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const urlTopicId = new URLSearchParams(window.location.search).get('topic_id')
|
|
1539
|
+
if (urlTopicId && urlTopicId !== String(id)) {
|
|
1540
|
+
this.clearUrlTopicId(urlTopicId)
|
|
1541
|
+
}
|
|
790
1542
|
}
|
|
791
1543
|
|
|
792
1544
|
debounceSaveLastTopic(id) {
|
|
793
|
-
|
|
1545
|
+
this.cancelPendingSaveLastTopic()
|
|
1546
|
+
// A linked shell can close before this debounce fires. Closing clears
|
|
1547
|
+
// the cached effective origin, and reopening another shell before the
|
|
1548
|
+
// callback runs would otherwise claim that shell's raw id even though
|
|
1549
|
+
// the save belongs to the stream selected here. Capture the requested
|
|
1550
|
+
// creative too: it is the PATCH endpoint, not the effective stream.
|
|
1551
|
+
const effectiveCreativeId = this.effectiveCreativeId
|
|
1552
|
+
const creativeId = this.creativeId
|
|
794
1553
|
this._saveLastTopicTimer = setTimeout(() => {
|
|
795
|
-
this.flushSaveLastTopic(id)
|
|
1554
|
+
this.flushSaveLastTopic(id, effectiveCreativeId, creativeId)
|
|
796
1555
|
}, 500)
|
|
797
1556
|
}
|
|
798
1557
|
|
|
799
|
-
|
|
800
|
-
|
|
1558
|
+
// Drop a queued save without sending it. The timer closes over the id it
|
|
1559
|
+
// was scheduled with, so a save that is no longer wanted cannot be talked
|
|
1560
|
+
// out of its value — only cancelled.
|
|
1561
|
+
cancelPendingSaveLastTopic() {
|
|
1562
|
+
if (this._saveLastTopicTimer !== undefined && this._saveLastTopicTimer !== null) {
|
|
801
1563
|
clearTimeout(this._saveLastTopicTimer)
|
|
802
1564
|
this._saveLastTopicTimer = null
|
|
803
1565
|
}
|
|
804
|
-
|
|
805
|
-
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async flushSaveLastTopic(
|
|
1569
|
+
id,
|
|
1570
|
+
claimedEffectiveCreativeId = this.effectiveCreativeId,
|
|
1571
|
+
requestedCreativeId = this.creativeId
|
|
1572
|
+
) {
|
|
1573
|
+
this.cancelPendingSaveLastTopic()
|
|
1574
|
+
if (!requestedCreativeId) return
|
|
1575
|
+
|
|
1576
|
+
const creativeId = requestedCreativeId
|
|
1577
|
+
const effectiveCreativeId = claimedEffectiveCreativeId
|
|
1578
|
+
const clientId = this.newLastTopicSaveClientId()
|
|
1579
|
+
const pendingPick = this._pendingPick
|
|
1580
|
+
// The subscription the echo of this save would arrive on. The claim is
|
|
1581
|
+
// taken inside the callback below, which runs whenever the save ahead
|
|
1582
|
+
// of it finishes — by then the stream may already be a different one,
|
|
1583
|
+
// and a claim taken against a stream we have left is owed a message
|
|
1584
|
+
// that cannot arrive.
|
|
1585
|
+
const generation = this.subscriptionGenerationFor(effectiveCreativeId)
|
|
1586
|
+
// One save at a time. Two PATCHes in flight together are persisted in
|
|
1587
|
+
// whatever order the server finishes them, so the preference that
|
|
1588
|
+
// sticks need not be the last one picked. Waiting for the one in
|
|
1589
|
+
// flight is what makes the order they were picked in the order they
|
|
1590
|
+
// are written.
|
|
1591
|
+
this._saveChain = (this._saveChain || Promise.resolve()).then(async () => {
|
|
1592
|
+
// update_last_topic broadcasts to every session of this user, this
|
|
1593
|
+
// one included. Claim the echo here, before the request goes out —
|
|
1594
|
+
// the broadcast is sent server-side before the response is
|
|
1595
|
+
// rendered, so it can beat the save returning.
|
|
1596
|
+
const claimed = generation === this.subscriptionGenerationFor(effectiveCreativeId)
|
|
1597
|
+
if (claimed) {
|
|
1598
|
+
this.pendingSelfEchoes.push(clientId)
|
|
1599
|
+
this.pendingSelfEchoCreativeIds.set(clientId, String(effectiveCreativeId))
|
|
1600
|
+
this.pendingSelfEchoTopicIds.set(clientId, id ? String(id) : "")
|
|
1601
|
+
this.pendingSelfEchoPreviousTopicIds.set(
|
|
1602
|
+
clientId,
|
|
1603
|
+
this.lastKnownRemoteTopicIdFor(effectiveCreativeId) === undefined
|
|
1604
|
+
? (this.serverLastTopicId ? String(this.serverLastTopicId) : "")
|
|
1605
|
+
: this.lastKnownRemoteTopicIdFor(effectiveCreativeId)
|
|
1606
|
+
)
|
|
1607
|
+
// A save can wait behind another request while the popup closes. It
|
|
1608
|
+
// takes its claim only when the queue reaches it, after the close
|
|
1609
|
+
// handler has already marked the claims that existed then. Its echo
|
|
1610
|
+
// will also be sent into that closed gap, so mark it at creation.
|
|
1611
|
+
if (this._popupClosed && !this.topicsSubscription) {
|
|
1612
|
+
this.possiblyMissedPendingSelfEchoes.add(clientId)
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
// A thrown fetch has an unknown outcome: the server may have saved
|
|
1616
|
+
// and broadcast before the connection failed, so keep its claim for
|
|
1617
|
+
// that delayed echo. An HTTP failure is definitive, however, and
|
|
1618
|
+
// update_last_topic returns before broadcasting in that case.
|
|
1619
|
+
const saveResult = await this.saveLastTopicWithTimeout(creativeId, id || null, clientId)
|
|
1620
|
+
const saved = saveResult === true || saveResult?.success === true
|
|
1621
|
+
const savedRevision = this.normalizeLastTopicRevision(saveResult?.lastTopicRevision)
|
|
1622
|
+
const savedRevisionIsCurrent = this.observeLastTopicRevision(
|
|
1623
|
+
effectiveCreativeId,
|
|
1624
|
+
savedRevision
|
|
1625
|
+
)
|
|
1626
|
+
const topicId = id ? String(id) : ""
|
|
1627
|
+
const saveRejected = saveResult === false || saveResult?.success === false
|
|
1628
|
+
const staleLastTopicSave = saveResult?.staleLastTopicSave === true
|
|
1629
|
+
if (claimed && saved) {
|
|
1630
|
+
// The Action Cable echo can arrive before this response. In that
|
|
1631
|
+
// case it has already consumed the claim and removed its metadata;
|
|
1632
|
+
// do not recreate an acknowledgement entry for a completed save.
|
|
1633
|
+
const hasPendingSelfEcho = this.pendingSelfEchoes.includes(clientId)
|
|
1634
|
+
if (hasPendingSelfEcho) {
|
|
1635
|
+
this.saveAcknowledgementVersion += 1
|
|
1636
|
+
this.pendingSelfEchoAcknowledgementVersions.set(
|
|
1637
|
+
clientId,
|
|
1638
|
+
this.saveAcknowledgementVersion
|
|
1639
|
+
)
|
|
1640
|
+
// The response can beat its Action Cable echo. Preserve the
|
|
1641
|
+
// server-issued revision now so a delayed GET can order this
|
|
1642
|
+
// acknowledged claim against a newer ABA snapshot.
|
|
1643
|
+
if (savedRevision) {
|
|
1644
|
+
this.pendingSelfEchoRemoteRevisions.set(clientId, savedRevision)
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const currentCreativeId = String(this.creativeId)
|
|
1648
|
+
const currentStreamIsResolved = this.element.dataset.effectiveCreativeId ||
|
|
1649
|
+
this.knownEffectiveCreativeIds.has(currentCreativeId)
|
|
1650
|
+
const subscribedToAnotherStream = this.topicsSubscription && currentStreamIsResolved &&
|
|
1651
|
+
String(this.effectiveCreativeId) !== String(effectiveCreativeId)
|
|
1652
|
+
const possiblyMissedDuringDisconnect =
|
|
1653
|
+
this.possiblyMissedPendingSelfEchoesDuringDisconnect.has(clientId)
|
|
1654
|
+
if ((this.possiblyMissedPendingSelfEchoes.has(clientId) && !this.topicsSubscription) ||
|
|
1655
|
+
subscribedToAnotherStream ||
|
|
1656
|
+
possiblyMissedDuringDisconnect) {
|
|
1657
|
+
// update_last_topic broadcasts before it returns. With the popup
|
|
1658
|
+
// closed, or after its stream was replaced, that echo was necessarily
|
|
1659
|
+
// sent somewhere this controller can no longer receive it and cannot
|
|
1660
|
+
// settle this claim.
|
|
1661
|
+
// This completed save is also the remote baseline for the next
|
|
1662
|
+
// queued save, which may have claimed after the popup closed.
|
|
1663
|
+
this.setLastKnownRemoteTopicId(effectiveCreativeId, topicId)
|
|
1664
|
+
if (possiblyMissedDuringDisconnect) {
|
|
1665
|
+
this.retirePendingSelfEcho(clientId)
|
|
1666
|
+
} else {
|
|
1667
|
+
this.releasePendingSelfEcho(clientId)
|
|
1668
|
+
}
|
|
1669
|
+
} else {
|
|
1670
|
+
// The replacement subscription may have been active before this
|
|
1671
|
+
// response beat the WebSocket message back to the browser. Keep the
|
|
1672
|
+
// id to consume that echo, but do not use this acknowledged claim to
|
|
1673
|
+
// override a later reopen snapshot.
|
|
1674
|
+
// A completed save establishes the server value that the
|
|
1675
|
+
// next claim was made from. Without moving this baseline,
|
|
1676
|
+
// a later closed save compares its reopen snapshot to a
|
|
1677
|
+
// value from before an earlier local save and mistakes the
|
|
1678
|
+
// legitimate previous value for another session's update.
|
|
1679
|
+
// Do not overwrite a newer Action Cable update after this
|
|
1680
|
+
// save's echo has already been consumed: that echo records
|
|
1681
|
+
// the baseline at broadcast time, and another message may
|
|
1682
|
+
// have advanced it before the HTTP response arrived.
|
|
1683
|
+
if (hasPendingSelfEcho && savedRevisionIsCurrent) {
|
|
1684
|
+
this.setLastKnownRemoteTopicId(effectiveCreativeId, topicId)
|
|
1685
|
+
}
|
|
1686
|
+
this.acknowledgePendingSelfEcho(clientId)
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (claimed && saveRejected) this.releasePendingSelfEcho(clientId)
|
|
1690
|
+
if (claimed && saveResult === null) {
|
|
1691
|
+
this.scheduleAmbiguousPendingSelfEchoRetirement(clientId)
|
|
1692
|
+
}
|
|
1693
|
+
const rejectedPendingPick = staleLastTopicSave && this._pendingPick === pendingPick &&
|
|
1694
|
+
String(pendingPick?.creativeId) === String(creativeId) &&
|
|
1695
|
+
pendingPick?.topicId === topicId
|
|
1696
|
+
if (rejectedPendingPick) this._pendingPick = null
|
|
1697
|
+
if (saveResult !== null) this.retryDeferredLastTopicReconciliation(effectiveCreativeId)
|
|
1698
|
+
if (saveResult !== false && this._pendingPick === pendingPick &&
|
|
1699
|
+
String(pendingPick?.creativeId) === String(creativeId) &&
|
|
1700
|
+
pendingPick?.topicId === topicId) {
|
|
1701
|
+
this._pendingPick = null
|
|
1702
|
+
}
|
|
1703
|
+
})
|
|
1704
|
+
return this._saveChain
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// A timed-out fetch can still be running on Rails. Prefix each echo id with
|
|
1708
|
+
// a controller-local session and monotonically increasing sequence so Rails
|
|
1709
|
+
// can reject an older request if a later one from this controller commits
|
|
1710
|
+
// first. The trailing nonce keeps the Action Cable echo identifier unique.
|
|
1711
|
+
newLastTopicSaveClientId() {
|
|
1712
|
+
this._lastTopicSaveSessionId ||= this.lastTopicSaveSessionId()
|
|
1713
|
+
this._lastTopicSaveSequence = this.nextLastTopicSaveSequence()
|
|
1714
|
+
return `${this._lastTopicSaveSessionId}.${this._lastTopicSaveSequence}.${newClientId()}`
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// A Turbo replacement creates a new controller while an earlier request can
|
|
1718
|
+
// still be running on Rails. Keep the fence identity and counter for this
|
|
1719
|
+
// browser tab so the server can reject that earlier request after the new
|
|
1720
|
+
// controller has saved a later selection.
|
|
1721
|
+
lastTopicSaveSessionId() {
|
|
1722
|
+
try {
|
|
1723
|
+
const sessionId = this.lastTopicSaveWindowSessionId()
|
|
1724
|
+
sessionStorage.setItem(LAST_TOPIC_SAVE_SESSION_STORAGE_KEY, sessionId)
|
|
1725
|
+
return sessionId
|
|
1726
|
+
} catch (_) {
|
|
1727
|
+
return newClientId()
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// sessionStorage survives a reload, but browsers copy it into a duplicated
|
|
1732
|
+
// tab. window.name belongs to one top-level browsing context and also
|
|
1733
|
+
// survives reloads, so it keeps Turbo replacements on one fence while a
|
|
1734
|
+
// copied tab starts a separate ordering stream.
|
|
1735
|
+
lastTopicSaveWindowSessionId() {
|
|
1736
|
+
const currentName = window.name || ""
|
|
1737
|
+
if (currentName.startsWith(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX)) {
|
|
1738
|
+
const sessionId = currentName.slice(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX.length)
|
|
1739
|
+
if (/^[A-Za-z0-9-]+$/.test(sessionId)) return sessionId
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const sessionId = newClientId()
|
|
1743
|
+
window.name = `${LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX}${sessionId}`
|
|
1744
|
+
return sessionId
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
nextLastTopicSaveSequence() {
|
|
1748
|
+
try {
|
|
1749
|
+
const stored = Number(sessionStorage.getItem(LAST_TOPIC_SAVE_SEQUENCE_STORAGE_KEY))
|
|
1750
|
+
const previous = Number.isSafeInteger(stored) && stored >= 0 ? stored : 0
|
|
1751
|
+
const sequence = Math.max(this._lastTopicSaveSequence || 0, previous) + 1
|
|
1752
|
+
sessionStorage.setItem(LAST_TOPIC_SAVE_SEQUENCE_STORAGE_KEY, String(sequence))
|
|
1753
|
+
return sequence
|
|
1754
|
+
} catch (_) {
|
|
1755
|
+
return (this._lastTopicSaveSequence || 0) + 1
|
|
806
1756
|
}
|
|
807
1757
|
}
|
|
808
1758
|
|
|
809
|
-
|
|
1759
|
+
// A request that never settles must not hold every later pick behind it.
|
|
1760
|
+
// Its server outcome remains ambiguous, so the caller still retains a short
|
|
1761
|
+
// lived echo claim just as it does for a rejected fetch.
|
|
1762
|
+
saveLastTopicWithTimeout(creativeId, topicId, clientId) {
|
|
1763
|
+
const abortController = new AbortController()
|
|
1764
|
+
const request = Promise.resolve(
|
|
1765
|
+
saveLastTopic(creativeId, topicId, clientId, abortController.signal)
|
|
1766
|
+
).catch(() => null)
|
|
1767
|
+
let timeoutId
|
|
1768
|
+
const timedOutRequest = new Promise(resolve => {
|
|
1769
|
+
timeoutId = setTimeout(() => {
|
|
1770
|
+
// Do not advance the serialized queue while this request can still
|
|
1771
|
+
// reach Rails. An abort makes fetch settle before the next save starts.
|
|
1772
|
+
abortController.abort()
|
|
1773
|
+
request.then(resolve)
|
|
1774
|
+
}, SAVE_REQUEST_TIMEOUT)
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
return Promise.race([request, timedOutRequest]).finally(() => clearTimeout(timeoutId))
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// The echo names the save it came from, so this is identity, not a guess.
|
|
1781
|
+
// Matching on the topic id instead would let a sibling session that picked
|
|
1782
|
+
// the same topic settle one of our claims, leaving our own echo to arrive
|
|
1783
|
+
// later looking like news and revert whatever the user picked meanwhile.
|
|
1784
|
+
consumeSelfEcho(clientId, lastTopicRevision) {
|
|
1785
|
+
if (!clientId) return false
|
|
1786
|
+
|
|
1787
|
+
const index = this.pendingSelfEchoes.indexOf(clientId)
|
|
1788
|
+
if (index === -1) return false
|
|
1789
|
+
|
|
1790
|
+
// An echo proves the save committed even when it wins the race with its
|
|
1791
|
+
// HTTP response. Keep its ordering data only while an older GET can still
|
|
1792
|
+
// arrive; otherwise that GET could restore the pre-save preference after
|
|
1793
|
+
// the response has cleared _pendingPick.
|
|
1794
|
+
this.saveAcknowledgementVersion += 1
|
|
1795
|
+
this.pendingSelfEchoAcknowledgementVersions.set(
|
|
1796
|
+
clientId,
|
|
1797
|
+
this.saveAcknowledgementVersion
|
|
1798
|
+
)
|
|
1799
|
+
const normalizedRevision = this.normalizeLastTopicRevision(lastTopicRevision)
|
|
1800
|
+
if (normalizedRevision) {
|
|
1801
|
+
this.pendingSelfEchoRemoteRevisions.set(clientId, normalizedRevision)
|
|
1802
|
+
}
|
|
1803
|
+
this.acknowledgedPendingSelfEchoes.add(clientId)
|
|
1804
|
+
this.pendingSelfEchoes.splice(index, 1)
|
|
1805
|
+
// Keep the closed-gap marker with the tombstone. A reopened GET can be
|
|
1806
|
+
// newer than this committed save even when its response races ahead of
|
|
1807
|
+
// the PATCH response; releasing this marker here would make that newer
|
|
1808
|
+
// sibling-session snapshot look stale and replay our old pick over it.
|
|
1809
|
+
this.settledSelfEchoes.add(clientId)
|
|
1810
|
+
this.discardSettledSelfEchoesNoLongerNeeded()
|
|
1811
|
+
return true
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
releasePendingSelfEcho(clientId) {
|
|
1815
|
+
const index = this.pendingSelfEchoes.indexOf(clientId)
|
|
1816
|
+
if (index === -1) return false
|
|
1817
|
+
|
|
1818
|
+
this.pendingSelfEchoes.splice(index, 1)
|
|
1819
|
+
this.discardSelfEchoMetadata(clientId)
|
|
1820
|
+
return true
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// A Turbo replacement clears controller-local claims while an earlier PATCH
|
|
1824
|
+
// can still commit. The save-session prefix belongs to this top-level tab,
|
|
1825
|
+
// so its orphaned echo remains ours and must not be applied as a sibling
|
|
1826
|
+
// update over the replacement controller's newer pick.
|
|
1827
|
+
isLastTopicSaveFromThisTab(clientId) {
|
|
1828
|
+
const match = String(clientId || "").match(/^([A-Za-z0-9-]+)\.([1-9]\d*)\.[A-Za-z0-9-]+$/)
|
|
1829
|
+
const currentName = window.name || ""
|
|
1830
|
+
if (!currentName.startsWith(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX)) return false
|
|
1831
|
+
|
|
1832
|
+
const sessionId = currentName.slice(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX.length)
|
|
1833
|
+
return Boolean(match) && /^[A-Za-z0-9-]+$/.test(sessionId) && match[1] === sessionId
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// A retired ambiguous request can still have committed after its connection
|
|
1837
|
+
// failed. Its sequence is monotonic within a tab session, so one high-water
|
|
1838
|
+
// mark per session keeps all older late echoes non-actionable without retaining
|
|
1839
|
+
// one tombstone per failed save.
|
|
1840
|
+
retirePendingSelfEcho(clientId) {
|
|
1841
|
+
const released = this.releasePendingSelfEcho(clientId)
|
|
1842
|
+
if (released) this.observeRetiredSelfEcho(clientId)
|
|
1843
|
+
return released
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
lastTopicSaveClientIdParts(clientId) {
|
|
1847
|
+
const match = String(clientId || "").match(/^([A-Za-z0-9-]+)\.([1-9]\d*)\.[A-Za-z0-9-]+$/)
|
|
1848
|
+
if (!match) return null
|
|
1849
|
+
|
|
1850
|
+
return { sessionId: match[1], sequence: Number(match[2]) }
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
observeRetiredSelfEcho(clientId) {
|
|
1854
|
+
const parts = this.lastTopicSaveClientIdParts(clientId)
|
|
1855
|
+
if (!parts) return
|
|
1856
|
+
|
|
1857
|
+
const previous = this.retiredSelfEchoSequenceHighWaters.get(parts.sessionId) || 0
|
|
1858
|
+
if (parts.sequence > previous) {
|
|
1859
|
+
this.retiredSelfEchoSequenceHighWaters.set(parts.sessionId, parts.sequence)
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
isRetiredSelfEcho(clientId) {
|
|
1864
|
+
const parts = this.lastTopicSaveClientIdParts(clientId)
|
|
1865
|
+
if (!parts) return false
|
|
1866
|
+
|
|
1867
|
+
return parts.sequence <= (this.retiredSelfEchoSequenceHighWaters.get(parts.sessionId) || 0)
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
discardSelfEchoMetadata(clientId) {
|
|
1871
|
+
this.cancelAmbiguousPendingSelfEchoRetirement(clientId)
|
|
1872
|
+
this.pendingSelfEchoCreativeIds.delete(clientId)
|
|
1873
|
+
this.pendingSelfEchoTopicIds.delete(clientId)
|
|
1874
|
+
this.pendingSelfEchoPreviousTopicIds.delete(clientId)
|
|
1875
|
+
this.pendingSelfEchoAcknowledgementVersions.delete(clientId)
|
|
1876
|
+
this.pendingSelfEchoRemoteRevisions.delete(clientId)
|
|
1877
|
+
this.possiblyMissedPendingSelfEchoes.delete(clientId)
|
|
1878
|
+
this.possiblyMissedPendingSelfEchoesDuringDisconnect.delete(clientId)
|
|
1879
|
+
this.acknowledgedPendingSelfEchoes.delete(clientId)
|
|
1880
|
+
this.settledSelfEchoes.delete(clientId)
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// A rejected fetch has an ambiguous server outcome, so retain its claim long
|
|
1884
|
+
// enough for a delayed self-echo. It cannot remain forever, though: a request
|
|
1885
|
+
// that never reached Rails has no echo or revision to resolve it, and would
|
|
1886
|
+
// otherwise defer every later revisioned snapshot on this stream.
|
|
1887
|
+
scheduleAmbiguousPendingSelfEchoRetirement(clientId) {
|
|
1888
|
+
if (!this.pendingSelfEchoes.includes(clientId)) return
|
|
1889
|
+
|
|
1890
|
+
this.cancelAmbiguousPendingSelfEchoRetirement(clientId)
|
|
1891
|
+
const timeout = setTimeout(() => {
|
|
1892
|
+
this.ambiguousPendingSelfEchoRetirements.delete(clientId)
|
|
1893
|
+
const creativeId = this.pendingSelfEchoCreativeIds.get(clientId)
|
|
1894
|
+
if (creativeId === undefined ||
|
|
1895
|
+
this.acknowledgedPendingSelfEchoes.has(clientId) ||
|
|
1896
|
+
this.pendingSelfEchoRemoteRevisions.has(clientId)) return
|
|
1897
|
+
|
|
1898
|
+
this.retirePendingSelfEcho(clientId)
|
|
1899
|
+
this.retryDeferredLastTopicReconciliation(creativeId)
|
|
1900
|
+
}, AMBIGUOUS_SAVE_CLAIM_TIMEOUT)
|
|
1901
|
+
this.ambiguousPendingSelfEchoRetirements.set(clientId, timeout)
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
cancelAmbiguousPendingSelfEchoRetirement(clientId) {
|
|
1905
|
+
const timeout = this.ambiguousPendingSelfEchoRetirements.get(clientId)
|
|
1906
|
+
if (timeout !== undefined) clearTimeout(timeout)
|
|
1907
|
+
this.ambiguousPendingSelfEchoRetirements.delete(clientId)
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
get ambiguousPendingSelfEchoRetirements() {
|
|
1911
|
+
return this._ambiguousPendingSelfEchoRetirements ||
|
|
1912
|
+
(this._ambiguousPendingSelfEchoRetirements = new Map())
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
get retiredSelfEchoSequenceHighWaters() {
|
|
1916
|
+
return this._retiredSelfEchoSequenceHighWaters ||
|
|
1917
|
+
(this._retiredSelfEchoSequenceHighWaters = new Map())
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// An early echo can be the only proof that an in-flight GET predates a
|
|
1921
|
+
// committed save. Once every GET that started before it has finished, the
|
|
1922
|
+
// tombstone has no ordering work left and must not accumulate in a long-lived
|
|
1923
|
+
// popup controller. Compare the save version captured by each active load,
|
|
1924
|
+
// not its unrelated request sequence number.
|
|
1925
|
+
discardSettledSelfEchoesNoLongerNeeded() {
|
|
1926
|
+
for (const clientId of [...this.settledSelfEchoes]) {
|
|
1927
|
+
const acknowledgementVersion = this.pendingSelfEchoAcknowledgementVersions.get(clientId)
|
|
1928
|
+
const hasOlderLoad = [...this.activeLoadAcknowledgementVersions.values()].some(
|
|
1929
|
+
loadAcknowledgementVersion => loadAcknowledgementVersion < acknowledgementVersion
|
|
1930
|
+
)
|
|
1931
|
+
if (!hasOlderLoad) this.discardSelfEchoMetadata(clientId)
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Ids of saves this client has made and not yet seen come back. The echo of
|
|
1936
|
+
// our own save is not news: it names what we already asked for, and by the
|
|
1937
|
+
// time it arrives the user may have picked something else — applying it
|
|
1938
|
+
// then reverts the newer pick and persists the older topic over it.
|
|
1939
|
+
get pendingSelfEchoes() {
|
|
1940
|
+
return this._pendingSelfEchoes || (this._pendingSelfEchoes = [])
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
get pendingSelfEchoCreativeIds() {
|
|
1944
|
+
return this._pendingSelfEchoCreativeIds || (this._pendingSelfEchoCreativeIds = new Map())
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
get pendingSelfEchoTopicIds() {
|
|
1948
|
+
return this._pendingSelfEchoTopicIds || (this._pendingSelfEchoTopicIds = new Map())
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// The last preference observed from the server is the baseline for a save.
|
|
1952
|
+
// It belongs to an effective creative stream: a controller can move between
|
|
1953
|
+
// creatives before the next fetch resolves, and one stream's preference is
|
|
1954
|
+
// never evidence about another stream.
|
|
1955
|
+
get lastKnownRemoteTopicId() {
|
|
1956
|
+
return this.lastKnownRemoteTopicIdFor(this.effectiveCreativeId)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
set lastKnownRemoteTopicId(value) {
|
|
1960
|
+
this.setLastKnownRemoteTopicId(this.effectiveCreativeId, value)
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
lastKnownRemoteTopicIdFor(creativeId) {
|
|
1964
|
+
return this.lastKnownRemoteTopicIds.get(String(creativeId))
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
setLastKnownRemoteTopicId(creativeId, value) {
|
|
1968
|
+
this.lastKnownRemoteTopicIds.set(String(creativeId), value ? String(value) : "")
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
get lastKnownRemoteTopicIds() {
|
|
1972
|
+
return this._lastKnownRemoteTopicIds || (this._lastKnownRemoteTopicIds = new Map())
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// Action Cable is ordered only within a connection. A delayed older message
|
|
1976
|
+
// can therefore arrive after a newer GET, response, or broadcast. Keep the
|
|
1977
|
+
// server-issued revision per effective stream and never apply it backward.
|
|
1978
|
+
observeLastTopicRevision(creativeId, revision) {
|
|
1979
|
+
if (!revision) return true
|
|
1980
|
+
|
|
1981
|
+
const streamCreativeId = String(creativeId)
|
|
1982
|
+
const previous = this.highestLastTopicRevisions.get(streamCreativeId)
|
|
1983
|
+
const comparison = this.compareLastTopicRevisions(revision, previous)
|
|
1984
|
+
if (comparison !== null && comparison < 0) return false
|
|
1985
|
+
if (comparison === null || comparison > 0) {
|
|
1986
|
+
this.highestLastTopicRevisions.set(streamCreativeId, revision)
|
|
1987
|
+
}
|
|
1988
|
+
return true
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
get highestLastTopicRevisions() {
|
|
1992
|
+
return this._highestLastTopicRevisions || (this._highestLastTopicRevisions = new Map())
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// The server resolves TopicsChannel subscriptions for linked shells to their
|
|
1996
|
+
// origin stream. Keep that result separately from the popup-scoped dataset:
|
|
1997
|
+
// the latter is deliberately cleared while a replacement load is pending.
|
|
1998
|
+
get knownEffectiveCreativeIds() {
|
|
1999
|
+
return this._knownEffectiveCreativeIds || (this._knownEffectiveCreativeIds = new Map())
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
get pendingSelfEchoPreviousTopicIds() {
|
|
2003
|
+
return this._pendingSelfEchoPreviousTopicIds || (this._pendingSelfEchoPreviousTopicIds = new Map())
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
get pendingSelfEchoAcknowledgementVersions() {
|
|
2007
|
+
return this._pendingSelfEchoAcknowledgementVersions || (this._pendingSelfEchoAcknowledgementVersions = new Map())
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// The preference row id distinguishes a record recreated after clearing the
|
|
2011
|
+
// selection; the per-row revision orders saves made while it exists. Together
|
|
2012
|
+
// they are a server-issued ordering token, rather than an inference from a
|
|
2013
|
+
// topic id that can repeat in an ABA change (Main -> Alpha -> Main).
|
|
2014
|
+
normalizeLastTopicRevision(value) {
|
|
2015
|
+
if (!Array.isArray(value) || value.length !== 2) return null
|
|
2016
|
+
const revision = value.map(Number)
|
|
2017
|
+
return revision.every(Number.isSafeInteger) ? revision : null
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
compareLastTopicRevisions(left, right) {
|
|
2021
|
+
if (!left || !right) return null
|
|
2022
|
+
return left[0] === right[0] ? left[1] - right[1] : left[0] - right[0]
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
get pendingSelfEchoRemoteRevisions() {
|
|
2026
|
+
return this._pendingSelfEchoRemoteRevisions || (this._pendingSelfEchoRemoteRevisions = new Map())
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// Echoes normally remove their claims. A short-lived tombstone retains the
|
|
2030
|
+
// save order for GETs already in progress when the echo arrived.
|
|
2031
|
+
get settledSelfEchoes() {
|
|
2032
|
+
return this._settledSelfEchoes || (this._settledSelfEchoes = new Set())
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
get activeLoadAcknowledgementVersions() {
|
|
2036
|
+
return this._activeLoadAcknowledgementVersions || (this._activeLoadAcknowledgementVersions = new Map())
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
get saveAcknowledgementVersion() {
|
|
2040
|
+
return this._saveAcknowledgementVersion || 0
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
set saveAcknowledgementVersion(value) {
|
|
2044
|
+
this._saveAcknowledgementVersion = value
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// These claims were already in flight when the popup unsubscribed. If their
|
|
2048
|
+
// response arrives while the popup remains closed, the broadcast necessarily
|
|
2049
|
+
// went into that gap. A replacement subscription can receive an echo when it
|
|
2050
|
+
// reopens before the response arrives, though.
|
|
2051
|
+
get possiblyMissedPendingSelfEchoes() {
|
|
2052
|
+
return this._possiblyMissedPendingSelfEchoes || (this._possiblyMissedPendingSelfEchoes = new Set())
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// A broadcast sent after Action Cable disconnects is not replayed after the
|
|
2056
|
+
// connection returns. Keep unresolved saves until their HTTP outcome tells us
|
|
2057
|
+
// whether one could have been sent in that gap; acknowledged saves can be
|
|
2058
|
+
// retired immediately because their broadcast has already been sent.
|
|
2059
|
+
get possiblyMissedPendingSelfEchoesDuringDisconnect() {
|
|
2060
|
+
return this._possiblyMissedPendingSelfEchoesDuringDisconnect ||
|
|
2061
|
+
(this._possiblyMissedPendingSelfEchoesDuringDisconnect = new Set())
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
handleTopicsSubscriptionDisconnected() {
|
|
2065
|
+
const deferredCreativeIds = new Set()
|
|
2066
|
+
for (const clientId of [...this.pendingSelfEchoes]) {
|
|
2067
|
+
const creativeId = this.pendingSelfEchoCreativeIds.get(clientId)
|
|
2068
|
+
if (creativeId === undefined) continue
|
|
2069
|
+
|
|
2070
|
+
if (!this.acknowledgedPendingSelfEchoes.has(clientId)) {
|
|
2071
|
+
this.possiblyMissedPendingSelfEchoesDuringDisconnect.add(clientId)
|
|
2072
|
+
continue
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
this.setLastKnownRemoteTopicId(creativeId, this.pendingSelfEchoTopicIds.get(clientId))
|
|
2076
|
+
this.retirePendingSelfEcho(clientId)
|
|
2077
|
+
deferredCreativeIds.add(creativeId)
|
|
2078
|
+
}
|
|
2079
|
+
for (const creativeId of deferredCreativeIds) {
|
|
2080
|
+
this.retryDeferredLastTopicReconciliation(creativeId)
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
markPendingSelfEchoesAsPossiblyMissed() {
|
|
2085
|
+
for (const clientId of this.pendingSelfEchoes) {
|
|
2086
|
+
if (!this.acknowledgedPendingSelfEchoes.has(clientId)) {
|
|
2087
|
+
this.possiblyMissedPendingSelfEchoes.add(clientId)
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// A successful response means the broadcast was already sent. Once this
|
|
2093
|
+
// popup unsubscribes, that echo cannot be replayed to settle its claim, so
|
|
2094
|
+
// retaining the acknowledgement metadata would leak it indefinitely.
|
|
2095
|
+
releaseAcknowledgedPendingSelfEchoes() {
|
|
2096
|
+
for (const clientId of [...this.pendingSelfEchoes]) {
|
|
2097
|
+
if (this.acknowledgedPendingSelfEchoes.has(clientId)) {
|
|
2098
|
+
this.releasePendingSelfEcho(clientId)
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// A new requested creative initially has no client-side effective stream
|
|
2104
|
+
// entry. Its subscription may still resolve server-side to the stream of a
|
|
2105
|
+
// pending save, so do not release that claim until this GET supplies the
|
|
2106
|
+
// effective id. Once it does, an acknowledged claim on another stream can
|
|
2107
|
+
// no longer receive an echo on this subscription and must not be retained.
|
|
2108
|
+
releaseAcknowledgedPendingSelfEchoesOutside(effectiveCreativeId) {
|
|
2109
|
+
const streamCreativeId = String(effectiveCreativeId)
|
|
2110
|
+
for (const clientId of [...this.pendingSelfEchoes]) {
|
|
2111
|
+
if (this.pendingSelfEchoCreativeIds.get(clientId) !== streamCreativeId &&
|
|
2112
|
+
this.acknowledgedPendingSelfEchoes.has(clientId)) {
|
|
2113
|
+
this.releasePendingSelfEcho(clientId)
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// A successful response proves the request committed. A close before the
|
|
2119
|
+
// response means the matching broadcast was missed and can be retired; an
|
|
2120
|
+
// open popup keeps its id long enough to consume an echo still in flight,
|
|
2121
|
+
// without allowing it to override a later reopen snapshot.
|
|
2122
|
+
get acknowledgedPendingSelfEchoes() {
|
|
2123
|
+
return this._acknowledgedPendingSelfEchoes || (this._acknowledgedPendingSelfEchoes = new Set())
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
acknowledgePendingSelfEcho(clientId) {
|
|
2127
|
+
if (this.pendingSelfEchoes.includes(clientId)) {
|
|
2128
|
+
this.possiblyMissedPendingSelfEchoes.delete(clientId)
|
|
2129
|
+
this.acknowledgedPendingSelfEchoes.add(clientId)
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
latestPendingSelfEchoTopicIdFor(creativeId, snapshotTopicId, acknowledgedSaveVersion, snapshotTopicRevision) {
|
|
2134
|
+
const streamCreativeId = String(creativeId)
|
|
2135
|
+
let topicId = snapshotTopicId
|
|
2136
|
+
let found = false
|
|
2137
|
+
const orderingClaimIds = [ ...this.pendingSelfEchoes, ...this.settledSelfEchoes ]
|
|
2138
|
+
const remainingClaimIds = new Set(orderingClaimIds)
|
|
2139
|
+
|
|
2140
|
+
// Saves are serialized, so each claim records the topic written by the
|
|
2141
|
+
// previous claim. A stale response can predate more than one completed
|
|
2142
|
+
// save; follow that chain instead of restoring only its first successor.
|
|
2143
|
+
// A claim acknowledged before this load began describes an older snapshot
|
|
2144
|
+
// and cannot advance it.
|
|
2145
|
+
for (;;) {
|
|
2146
|
+
const clientId = orderingClaimIds.find(id => {
|
|
2147
|
+
if (!remainingClaimIds.has(id)) return false
|
|
2148
|
+
const acknowledgedAfterLoadStarted =
|
|
2149
|
+
this.acknowledgedPendingSelfEchoes.has(id) &&
|
|
2150
|
+
this.pendingSelfEchoAcknowledgementVersions.get(id) > acknowledgedSaveVersion
|
|
2151
|
+
const revisionComparison = this.compareLastTopicRevisions(
|
|
2152
|
+
this.pendingSelfEchoRemoteRevisions.get(id),
|
|
2153
|
+
snapshotTopicRevision
|
|
2154
|
+
)
|
|
2155
|
+
return this.pendingSelfEchoCreativeIds.get(id) === streamCreativeId &&
|
|
2156
|
+
(!this.acknowledgedPendingSelfEchoes.has(id) || acknowledgedAfterLoadStarted) &&
|
|
2157
|
+
(revisionComparison === null
|
|
2158
|
+
? this.pendingSelfEchoPreviousTopicIds.get(id) === topicId
|
|
2159
|
+
: revisionComparison > 0)
|
|
2160
|
+
})
|
|
2161
|
+
if (!clientId) return found ? topicId : undefined
|
|
2162
|
+
|
|
2163
|
+
found = true
|
|
2164
|
+
remainingClaimIds.delete(clientId)
|
|
2165
|
+
topicId = this.pendingSelfEchoTopicIds.get(clientId)
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// A pick made before this replacement load normally outranks its answer: the
|
|
2170
|
+
// answer may simply predate the save. A claim that was in flight across a
|
|
2171
|
+
// close is the exception. When the snapshot has advanced from that save's
|
|
2172
|
+
// known baseline, the closed stream missed a newer cross-session write, so
|
|
2173
|
+
// replaying the local pick would immediately write the old topic back.
|
|
2174
|
+
pendingPickWasSupersededWhileClosed(creativeId, snapshotTopicId, snapshotTopicRevision) {
|
|
2175
|
+
if (!this._pendingPick) return false
|
|
2176
|
+
|
|
2177
|
+
const streamCreativeId = String(creativeId)
|
|
2178
|
+
return [ ...this.pendingSelfEchoes, ...this.settledSelfEchoes ].some(clientId => {
|
|
2179
|
+
if (!this.possiblyMissedPendingSelfEchoes.has(clientId) ||
|
|
2180
|
+
this.pendingSelfEchoCreativeIds.get(clientId) !== streamCreativeId ||
|
|
2181
|
+
this.pendingSelfEchoTopicIds.get(clientId) !== this._pendingPick.topicId) return false
|
|
2182
|
+
|
|
2183
|
+
const revisionComparison = this.compareLastTopicRevisions(
|
|
2184
|
+
snapshotTopicRevision,
|
|
2185
|
+
this.pendingSelfEchoRemoteRevisions.get(clientId)
|
|
2186
|
+
)
|
|
2187
|
+
return revisionComparison === null
|
|
2188
|
+
? this.pendingSelfEchoPreviousTopicIds.get(clientId) !== snapshotTopicId
|
|
2189
|
+
: revisionComparison > 0
|
|
2190
|
+
})
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// A revisioned snapshot can only be compared safely once every local save
|
|
2194
|
+
// involved in that comparison has supplied its revision. Until then an ABA
|
|
2195
|
+
// snapshot can look like the predecessor of an in-flight save by topic id.
|
|
2196
|
+
hasUnacknowledgedRevisionedSaveFor(creativeId, snapshotTopicRevision) {
|
|
2197
|
+
if (!snapshotTopicRevision) return false
|
|
2198
|
+
|
|
2199
|
+
const streamCreativeId = String(creativeId)
|
|
2200
|
+
return this.pendingSelfEchoes.some(clientId =>
|
|
2201
|
+
this.pendingSelfEchoCreativeIds.get(clientId) === streamCreativeId &&
|
|
2202
|
+
!this.acknowledgedPendingSelfEchoes.has(clientId) &&
|
|
2203
|
+
!this.pendingSelfEchoRemoteRevisions.has(clientId)
|
|
2204
|
+
)
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
get deferredLastTopicReconciliations() {
|
|
2208
|
+
return this._deferredLastTopicReconciliations || (this._deferredLastTopicReconciliations = new Set())
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
retryDeferredLastTopicReconciliation(creativeId) {
|
|
2212
|
+
const streamCreativeId = String(creativeId)
|
|
2213
|
+
if (!this.deferredLastTopicReconciliations.has(streamCreativeId) ||
|
|
2214
|
+
String(this.effectiveCreativeId) !== streamCreativeId) return
|
|
2215
|
+
|
|
2216
|
+
this.deferredLastTopicReconciliations.delete(streamCreativeId)
|
|
2217
|
+
this.loadTopics()
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// Bumped for an individual stream when that stream is permanently gone. A
|
|
2221
|
+
// popup can temporarily subscribe to another creative, so invalidating that
|
|
2222
|
+
// subscription must not prevent a queued save for the original stream from
|
|
2223
|
+
// claiming its own echo when the user returns to it.
|
|
2224
|
+
subscriptionGenerationFor(creativeId) {
|
|
2225
|
+
const streamCreativeId = String(creativeId)
|
|
2226
|
+
if (!this.subscriptionGenerations.has(streamCreativeId)) {
|
|
2227
|
+
this.subscriptionGenerations.set(streamCreativeId, 0)
|
|
2228
|
+
}
|
|
2229
|
+
return this.subscriptionGenerations.get(streamCreativeId)
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
bumpSubscriptionGenerationFor(creativeId) {
|
|
2233
|
+
const streamCreativeId = String(creativeId)
|
|
2234
|
+
this.subscriptionGenerations.set(
|
|
2235
|
+
streamCreativeId,
|
|
2236
|
+
this.subscriptionGenerationFor(streamCreativeId) + 1
|
|
2237
|
+
)
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
get subscriptionGenerations() {
|
|
2241
|
+
return this._subscriptionGenerations || (this._subscriptionGenerations = new Map())
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// The legacy key is adopted only as a stand-in for a preference the server
|
|
2245
|
+
// does not hold yet. A winning empty pick is a preference — it just names no
|
|
2246
|
+
// topic, so it is indistinguishable here from "server holds nothing", and
|
|
2247
|
+
// adopting the legacy value would hand the user back a topic they did not
|
|
2248
|
+
// ask for and persist it. The caller knows which of the two it is.
|
|
2249
|
+
//
|
|
2250
|
+
// The key still goes, either way: the pick supersedes it, and its own save
|
|
2251
|
+
// is already on the way, so leaving it behind would only re-apply a value
|
|
2252
|
+
// the user has moved off on the next load.
|
|
2253
|
+
migrateLocalStorage({ keepEmptyPick = false } = {}) {
|
|
810
2254
|
const key = `collavre_creative_${this.creativeId}_last_topic`
|
|
811
2255
|
const localValue = localStorage.getItem(key)
|
|
812
|
-
if (localValue && !this.serverLastTopicId) {
|
|
2256
|
+
if (localValue && !this.serverLastTopicId && !keepEmptyPick) {
|
|
813
2257
|
this.serverLastTopicId = localValue
|
|
814
|
-
|
|
2258
|
+
// The migration is a save like any other. Giving it a client id
|
|
2259
|
+
// prevents its broadcast from being mistaken for another session's
|
|
2260
|
+
// update if the user chooses a topic before that echo arrives.
|
|
2261
|
+
this.flushSaveLastTopic(localValue)
|
|
815
2262
|
}
|
|
816
2263
|
localStorage.removeItem(key)
|
|
817
2264
|
}
|
|
818
2265
|
|
|
819
|
-
subscribe() {
|
|
2266
|
+
subscribe({ preservePendingSelfEchoes = false } = {}) {
|
|
820
2267
|
const creativeId = this.creativeId
|
|
821
2268
|
if (!creativeId) return
|
|
822
2269
|
|
|
823
2270
|
if (this.topicsSubscription && this.subscribedCreativeId === String(creativeId)) return
|
|
824
2271
|
|
|
825
|
-
this.unsubscribe()
|
|
2272
|
+
if (this.topicsSubscription) this.unsubscribe({ preservePendingSelfEchoes })
|
|
826
2273
|
|
|
827
2274
|
this.subscribedCreativeId = String(creativeId)
|
|
828
2275
|
this.topicsSubscription = createSubscription(
|
|
829
2276
|
{ channel: 'TopicsChannel', creative_id: this.creativeId },
|
|
830
2277
|
{
|
|
831
|
-
received: (data) => this.handleTopicMessage(data)
|
|
2278
|
+
received: (data) => this.handleTopicMessage(data),
|
|
2279
|
+
disconnected: () => this.handleTopicsSubscriptionDisconnected(),
|
|
2280
|
+
// A refused subscription is different: it is not retried, so
|
|
2281
|
+
// nothing outstanding or queued on this stream can ever settle.
|
|
2282
|
+
// Claims for another creative may still settle if the popup returns.
|
|
2283
|
+
rejected: () => this.dropPendingSelfEchoesForCreative(creativeId),
|
|
832
2284
|
}
|
|
833
2285
|
)
|
|
834
2286
|
}
|
|
835
2287
|
|
|
836
|
-
|
|
2288
|
+
dropPendingSelfEchoes() {
|
|
2289
|
+
const streamCreativeIds = new Set([
|
|
2290
|
+
...this.pendingSelfEchoCreativeIds.values(),
|
|
2291
|
+
...this.subscriptionGenerations.keys(),
|
|
2292
|
+
])
|
|
2293
|
+
this.pendingSelfEchoes.length = 0
|
|
2294
|
+
this.pendingSelfEchoCreativeIds.clear()
|
|
2295
|
+
this.pendingSelfEchoTopicIds.clear()
|
|
2296
|
+
this.pendingSelfEchoPreviousTopicIds.clear()
|
|
2297
|
+
this.pendingSelfEchoAcknowledgementVersions.clear()
|
|
2298
|
+
this.pendingSelfEchoRemoteRevisions.clear()
|
|
2299
|
+
this.possiblyMissedPendingSelfEchoes.clear()
|
|
2300
|
+
this.possiblyMissedPendingSelfEchoesDuringDisconnect.clear()
|
|
2301
|
+
this.acknowledgedPendingSelfEchoes.clear()
|
|
2302
|
+
this.settledSelfEchoes.clear()
|
|
2303
|
+
this.retiredSelfEchoSequenceHighWaters.clear()
|
|
2304
|
+
// Emptying the array cannot reach a claim that has not been taken yet.
|
|
2305
|
+
// A save waiting its turn on the chain takes one when it runs, and by
|
|
2306
|
+
// then every stream has gone; its stream generation tells it so.
|
|
2307
|
+
for (const creativeId of streamCreativeIds) {
|
|
2308
|
+
this.bumpSubscriptionGenerationFor(creativeId)
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
dropPendingSelfEchoesForCreative(creativeId) {
|
|
2313
|
+
const streamCreativeId = String(creativeId)
|
|
2314
|
+
const clientIds = new Set([
|
|
2315
|
+
...this.pendingSelfEchoes,
|
|
2316
|
+
...this.settledSelfEchoes,
|
|
2317
|
+
].filter(clientId => this.pendingSelfEchoCreativeIds.get(clientId) === streamCreativeId))
|
|
2318
|
+
|
|
2319
|
+
this._pendingSelfEchoes = this.pendingSelfEchoes.filter(clientId => !clientIds.has(clientId))
|
|
2320
|
+
for (const clientId of clientIds) this.discardSelfEchoMetadata(clientId)
|
|
2321
|
+
this.bumpSubscriptionGenerationFor(streamCreativeId)
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// A save can begin before a linked shell's topics response reveals that it
|
|
2325
|
+
// shares its origin's preference stream. Re-key that claim before pruning
|
|
2326
|
+
// so its identified echo remains recognisable on the resolved stream.
|
|
2327
|
+
remapPendingSelfEchoesForCreative(requestedCreativeId, effectiveCreativeId) {
|
|
2328
|
+
const requestedId = String(requestedCreativeId)
|
|
2329
|
+
const resolvedId = String(effectiveCreativeId)
|
|
2330
|
+
if (requestedId === resolvedId) return
|
|
2331
|
+
const requestedBaseline = this.lastKnownRemoteTopicIds.get(requestedId)
|
|
2332
|
+
if (requestedBaseline !== undefined) {
|
|
2333
|
+
if (!this.lastKnownRemoteTopicIds.has(resolvedId)) {
|
|
2334
|
+
this.lastKnownRemoteTopicIds.set(resolvedId, requestedBaseline)
|
|
2335
|
+
}
|
|
2336
|
+
this.lastKnownRemoteTopicIds.delete(requestedId)
|
|
2337
|
+
}
|
|
2338
|
+
const requestedRevision = this.highestLastTopicRevisions.get(requestedId)
|
|
2339
|
+
if (requestedRevision) {
|
|
2340
|
+
this.observeLastTopicRevision(resolvedId, requestedRevision)
|
|
2341
|
+
this.highestLastTopicRevisions.delete(requestedId)
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
for (const clientId of [ ...this.pendingSelfEchoes, ...this.settledSelfEchoes ]) {
|
|
2345
|
+
if (this.pendingSelfEchoCreativeIds.get(clientId) === requestedId) {
|
|
2346
|
+
this.pendingSelfEchoCreativeIds.set(clientId, resolvedId)
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
unsubscribe({ preservePendingSelfEchoes = false } = {}) {
|
|
837
2352
|
if (this.topicsSubscription) {
|
|
838
2353
|
this.topicsSubscription.unsubscribe()
|
|
839
2354
|
this.topicsSubscription = null
|
|
840
2355
|
}
|
|
841
2356
|
this.subscribedCreativeId = null
|
|
2357
|
+
// The stream is per creative, so echoes still owed to us on the one we
|
|
2358
|
+
// are leaving are not coming and nothing can settle their claims.
|
|
2359
|
+
// Unlike a dropped connection, there is no reconnect to wait for.
|
|
2360
|
+
if (!preservePendingSelfEchoes) this.dropPendingSelfEchoes()
|
|
842
2361
|
}
|
|
843
2362
|
|
|
844
2363
|
handleTopicMessage(data) {
|
|
@@ -849,9 +2368,68 @@ export default class extends Controller {
|
|
|
849
2368
|
if (action === "last_topic_changed") {
|
|
850
2369
|
// Broadcast is already scoped to the current user via user-specific channel
|
|
851
2370
|
const newTopicId = data.last_topic_id ? String(data.last_topic_id) : ""
|
|
2371
|
+
const lastTopicRevision = this.normalizeLastTopicRevision(data.last_topic_revision)
|
|
2372
|
+
// A retired ambiguous save remains non-actionable, but it can still have
|
|
2373
|
+
// committed after the request failed. Its revision must advance this
|
|
2374
|
+
// stream's high-water mark so an older in-flight GET cannot restore the
|
|
2375
|
+
// preference from before that save.
|
|
2376
|
+
if (!this.pendingSelfEchoes.includes(data.client_id) && this.isRetiredSelfEcho(data.client_id)) {
|
|
2377
|
+
this.observeLastTopicRevision(this.effectiveCreativeId, lastTopicRevision)
|
|
2378
|
+
return
|
|
2379
|
+
}
|
|
2380
|
+
// A linked shell can receive an echo before its load resolves the origin.
|
|
2381
|
+
// The claim already knows that effective stream, while effectiveCreativeId
|
|
2382
|
+
// still names the shell; advance the baseline on the claimed stream.
|
|
2383
|
+
const claimedEffectiveCreativeId = this.pendingSelfEchoCreativeIds.get(data.client_id) || this.effectiveCreativeId
|
|
2384
|
+
const isCurrentRevision = this.observeLastTopicRevision(
|
|
2385
|
+
claimedEffectiveCreativeId,
|
|
2386
|
+
lastTopicRevision
|
|
2387
|
+
)
|
|
2388
|
+
const consumedSelfEcho = this.consumeSelfEcho(data.client_id, lastTopicRevision)
|
|
2389
|
+
const orphanedSelfEcho = !consumedSelfEcho && this.isLastTopicSaveFromThisTab(data.client_id)
|
|
2390
|
+
const hasNewerPendingPick = this._pendingPick &&
|
|
2391
|
+
String(this.effectiveCreativeIdFor(this._pendingPick.creativeId)) === String(claimedEffectiveCreativeId)
|
|
2392
|
+
if (consumedSelfEcho || (orphanedSelfEcho && hasNewerPendingPick)) {
|
|
2393
|
+
if (isCurrentRevision) {
|
|
2394
|
+
this.setLastKnownRemoteTopicId(claimedEffectiveCreativeId, newTopicId)
|
|
2395
|
+
}
|
|
2396
|
+
this.retryDeferredLastTopicReconciliation(claimedEffectiveCreativeId)
|
|
2397
|
+
return
|
|
2398
|
+
}
|
|
2399
|
+
if (!isCurrentRevision) return
|
|
2400
|
+
this.setLastKnownRemoteTopicId(this.effectiveCreativeId, newTopicId)
|
|
2401
|
+
// A deep-link restore may have queued a write even when this broadcast
|
|
2402
|
+
// repeats the preference already in serverLastTopicId. The sibling
|
|
2403
|
+
// session still established that preference, so the queued one-shot link
|
|
2404
|
+
// must not write itself back over it.
|
|
2405
|
+
if (this.hasDeepLinkSelection) this.cancelPendingSaveLastTopic()
|
|
852
2406
|
if (newTopicId !== this.serverLastTopicId) {
|
|
853
2407
|
this.serverLastTopicId = newTopicId
|
|
854
|
-
this
|
|
2408
|
+
// Another session moved the preference; nobody clicked in this
|
|
2409
|
+
// popup. A deep link outranks the preference in the getter, so
|
|
2410
|
+
// following the broadcast would light a chip the getter does not
|
|
2411
|
+
// name — and selectTopic() writes through the setter, which
|
|
2412
|
+
// releases that link on the way. It is a one-shot pointer with
|
|
2413
|
+
// nowhere to be recovered from: ?topic_id= is dropped from the
|
|
2414
|
+
// URL, so not even a reload gets the linked conversation back.
|
|
2415
|
+
// Record the preference, leave the view where the link put it.
|
|
2416
|
+
//
|
|
2417
|
+
// Every selection queues a save, restores included, so landing
|
|
2418
|
+
// on the link 500ms ago left one holding the linked topic. It
|
|
2419
|
+
// describes a selection this popup has just conceded is not the
|
|
2420
|
+
// preference; letting it land would write the link back over
|
|
2421
|
+
// what the other session set. Following the broadcast re-arms
|
|
2422
|
+
// the debounce with the broadcast's own value, so only the path
|
|
2423
|
+
// that does not follow has anything to cancel.
|
|
2424
|
+
if (!this.hasDeepLinkSelection) {
|
|
2425
|
+
// The save belonged to the controller Turbo replaced, not to a
|
|
2426
|
+
// new pick in this one. Reflect its committed preference without
|
|
2427
|
+
// creating another pending pick or writing it back to the server.
|
|
2428
|
+
this.selectTopic(
|
|
2429
|
+
newTopicId,
|
|
2430
|
+
orphanedSelfEcho ? { pick: false, persist: false } : undefined
|
|
2431
|
+
)
|
|
2432
|
+
}
|
|
855
2433
|
}
|
|
856
2434
|
return
|
|
857
2435
|
}
|
|
@@ -867,6 +2445,12 @@ export default class extends Controller {
|
|
|
867
2445
|
}
|
|
868
2446
|
|
|
869
2447
|
if (action === "archived" || action === "unarchived") {
|
|
2448
|
+
// loadTopics() only refreshes archivedTopics when its fetch resolves,
|
|
2449
|
+
// and list_controller routes every incoming stream through
|
|
2450
|
+
// isArchivedTopic in the meantime. The broadcast already carries the
|
|
2451
|
+
// membership change, so apply it now rather than letting that window
|
|
2452
|
+
// route on the old answer.
|
|
2453
|
+
this.applyArchiveTransition(action, data.topic || { id: data.topic_id })
|
|
870
2454
|
this.loadTopics()
|
|
871
2455
|
return
|
|
872
2456
|
}
|
|
@@ -887,7 +2471,16 @@ export default class extends Controller {
|
|
|
887
2471
|
if (existsByName) return
|
|
888
2472
|
|
|
889
2473
|
this.topics = [...topics, data.topic]
|
|
890
|
-
|
|
2474
|
+
// This arrived on the subscription for the creative currently open.
|
|
2475
|
+
// Unlike a local re-render of cached topics, it is not about the
|
|
2476
|
+
// outgoing creative whose strip may still be on screen during a switch.
|
|
2477
|
+
this.renderTopics(
|
|
2478
|
+
this.topics,
|
|
2479
|
+
this.canManageTopics,
|
|
2480
|
+
this.canCreateTopic,
|
|
2481
|
+
this.canSetPrimaryAgent,
|
|
2482
|
+
this.creativeId
|
|
2483
|
+
)
|
|
891
2484
|
|
|
892
2485
|
// Auto-select the new topic if created by the current user
|
|
893
2486
|
const currentUserId = document.body.dataset.currentUserId
|
|
@@ -915,7 +2508,7 @@ export default class extends Controller {
|
|
|
915
2508
|
})
|
|
916
2509
|
|
|
917
2510
|
this.topics = reorderedTopics
|
|
918
|
-
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
2511
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
919
2512
|
this.restoreSelection()
|
|
920
2513
|
}
|
|
921
2514
|
|
|
@@ -925,7 +2518,7 @@ export default class extends Controller {
|
|
|
925
2518
|
if (index === -1) return
|
|
926
2519
|
|
|
927
2520
|
this.topics[index] = { ...this.topics[index], ...updatedTopic }
|
|
928
|
-
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
2521
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
929
2522
|
this.restoreSelection()
|
|
930
2523
|
}
|
|
931
2524
|
|
|
@@ -933,16 +2526,26 @@ export default class extends Controller {
|
|
|
933
2526
|
if (!topicId) return
|
|
934
2527
|
|
|
935
2528
|
const topics = this.topics || []
|
|
2529
|
+
const archivedTopics = this.archivedTopics || []
|
|
936
2530
|
const nextTopics = topics.filter((topic) => String(topic.id) !== String(topicId))
|
|
937
|
-
|
|
2531
|
+
// An archived topic is deletable and now selectable, so the "deleted"
|
|
2532
|
+
// broadcast has to reach this cache too — nothing else does. It is the
|
|
2533
|
+
// only removal path that runs without a loadTopics(), so a topic dropped
|
|
2534
|
+
// from here would keep an openable chip and, through pruneArchivedBadges
|
|
2535
|
+
// never running, a lit toggle for a conversation that no longer exists.
|
|
2536
|
+
const nextArchivedTopics = archivedTopics.filter((topic) => String(topic.id) !== String(topicId))
|
|
2537
|
+
if (nextTopics.length === topics.length && nextArchivedTopics.length === archivedTopics.length) return
|
|
938
2538
|
|
|
939
2539
|
this.topics = nextTopics
|
|
2540
|
+
this.archivedTopics = nextArchivedTopics
|
|
2541
|
+
this.pruneArchivedBadges()
|
|
940
2542
|
if (String(this.currentTopicId) === String(topicId)) {
|
|
2543
|
+
this.releaseDeepLinkSelection(topicId)
|
|
941
2544
|
this.currentTopicId = ""
|
|
942
2545
|
this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
|
|
943
2546
|
}
|
|
944
2547
|
|
|
945
|
-
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
2548
|
+
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
|
|
946
2549
|
this.restoreSelection()
|
|
947
2550
|
}
|
|
948
2551
|
|
|
@@ -985,6 +2588,7 @@ export default class extends Controller {
|
|
|
985
2588
|
|
|
986
2589
|
const result = await createTopicWithComments(this.creativeId, name, commentIds)
|
|
987
2590
|
if (result.ok) {
|
|
2591
|
+
this._topicScrollInterrupted = false
|
|
988
2592
|
this.currentTopicId = result.topic.id
|
|
989
2593
|
await this.flushSaveLastTopic(result.topic.id)
|
|
990
2594
|
await this.loadTopics()
|
|
@@ -1000,6 +2604,45 @@ export default class extends Controller {
|
|
|
1000
2604
|
}
|
|
1001
2605
|
}
|
|
1002
2606
|
|
|
2607
|
+
// Localized strings are handed down from the ERB partial as data
|
|
2608
|
+
// attributes; the English literals are last-resort fallbacks for when the
|
|
2609
|
+
// controller is mounted without them.
|
|
2610
|
+
_i18n(key) {
|
|
2611
|
+
const translations = {
|
|
2612
|
+
set_agent_error: this.element.dataset.topicSetAgentError || 'Unable to assign the agent to this topic.',
|
|
2613
|
+
agent_assigned_title: this.element.dataset.topicAgentAssignedTitle || '%{name} is assigned to this topic — click to release',
|
|
2614
|
+
clear_agent_confirm: this.element.dataset.topicClearAgentConfirm || 'Release %{name} from this topic? Every agent will be able to respond here again.',
|
|
2615
|
+
create_error: this.element.dataset.topicCreateError || 'Unable to create the topic.',
|
|
2616
|
+
update_error: this.element.dataset.topicUpdateError || 'Unable to update the topic.',
|
|
2617
|
+
delete_error: this.element.dataset.topicDeleteError || 'Unable to delete the topic.',
|
|
2618
|
+
archive_error: this.element.dataset.topicArchiveError || 'Unable to archive the topic.',
|
|
2619
|
+
restore_error: this.element.dataset.topicRestoreError || 'Unable to restore the topic.'
|
|
2620
|
+
}
|
|
2621
|
+
return translations[key] || key
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Clicking the avatar of an assigned agent releases the assignment. The avatar
|
|
2625
|
+
// sits inside the topic tag, so the click must not also fall through to
|
|
2626
|
+
// selecting that topic.
|
|
2627
|
+
async clearTopicPrimaryAgent(event) {
|
|
2628
|
+
event.preventDefault()
|
|
2629
|
+
event.stopPropagation()
|
|
2630
|
+
|
|
2631
|
+
const topicId = event.currentTarget.dataset.id
|
|
2632
|
+
if (!topicId) return
|
|
2633
|
+
|
|
2634
|
+
const topic = (this.topics || []).find(t => String(t.id) === String(topicId))
|
|
2635
|
+
const agentName = topic?.primary_agent?.name || ''
|
|
2636
|
+
|
|
2637
|
+
const confirmed = await confirmDialog(
|
|
2638
|
+
this._i18n('clear_agent_confirm').replace('%{name}', agentName)
|
|
2639
|
+
)
|
|
2640
|
+
if (!confirmed) return
|
|
2641
|
+
|
|
2642
|
+
await this.setTopicPrimaryAgent(topicId, null)
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// Pass agent === null to release the topic's assignment.
|
|
1003
2646
|
async setTopicPrimaryAgent(topicId, agent) {
|
|
1004
2647
|
if (!this.creativeId) return
|
|
1005
2648
|
|
|
@@ -1010,16 +2653,26 @@ export default class extends Controller {
|
|
|
1010
2653
|
'Content-Type': 'application/json',
|
|
1011
2654
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
1012
2655
|
},
|
|
1013
|
-
body: JSON.stringify({ agent_id: agent.id })
|
|
2656
|
+
body: JSON.stringify({ agent_id: agent ? agent.id : null })
|
|
1014
2657
|
})
|
|
1015
2658
|
|
|
2659
|
+
const data = await response.json().catch(() => ({}))
|
|
2660
|
+
|
|
1016
2661
|
if (!response.ok) {
|
|
1017
|
-
|
|
1018
|
-
|
|
2662
|
+
alertDialog(data.error || this._i18n("set_agent_error"))
|
|
2663
|
+
return
|
|
1019
2664
|
}
|
|
1020
|
-
|
|
2665
|
+
|
|
2666
|
+
// Render the avatar from the response rather than waiting for the
|
|
2667
|
+
// WebSocket broadcast. A dropped broadcast (e.g. the topics channel
|
|
2668
|
+
// subscription was refused) would otherwise leave the avatar
|
|
2669
|
+
// invisible until the next page load. The broadcast still runs and
|
|
2670
|
+
// propagates the change to other connected users; re-applying it
|
|
2671
|
+
// here is a no-op merge.
|
|
2672
|
+
if (data.topic) this.updateTopicInList(data.topic)
|
|
1021
2673
|
} catch (e) {
|
|
1022
2674
|
console.error('Error setting primary agent', e)
|
|
2675
|
+
alertDialog(this._i18n("set_agent_error"))
|
|
1023
2676
|
}
|
|
1024
2677
|
}
|
|
1025
2678
|
|
|
@@ -1040,21 +2693,40 @@ export default class extends Controller {
|
|
|
1040
2693
|
|
|
1041
2694
|
if (response.ok) {
|
|
1042
2695
|
const topic = await response.json()
|
|
2696
|
+
this._topicScrollInterrupted = false
|
|
1043
2697
|
this.currentTopicId = topic.id
|
|
1044
2698
|
await this.flushSaveLastTopic(topic.id)
|
|
1045
2699
|
await this.loadTopics()
|
|
1046
2700
|
this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
|
|
1047
2701
|
} else {
|
|
1048
|
-
|
|
2702
|
+
// Dropping an agent that has no feedback access here is now
|
|
2703
|
+
// refused (it would mute the topic), and that is a user mistake
|
|
2704
|
+
// with a fix — sharing the creative. A console line would leave
|
|
2705
|
+
// the drop looking like it silently did nothing.
|
|
2706
|
+
const data = await response.json().catch(() => ({}))
|
|
2707
|
+
const message = data.errors?.[0] || data.error
|
|
2708
|
+
console.error('Failed to create topic with agent', message)
|
|
2709
|
+
alertDialog(message || this._i18n('create_error'))
|
|
1049
2710
|
}
|
|
1050
2711
|
} catch (e) {
|
|
1051
2712
|
console.error('Error creating topic with agent', e)
|
|
2713
|
+
alertDialog(this._i18n('create_error'))
|
|
1052
2714
|
}
|
|
1053
2715
|
}
|
|
1054
2716
|
|
|
1055
|
-
|
|
2717
|
+
// When topicId is given the avatar doubles as the release control: clicking it
|
|
2718
|
+
// unassigns the agent from the topic. Pass null to render it as a plain badge
|
|
2719
|
+
// (viewers who cannot manage topics).
|
|
2720
|
+
renderAgentAvatar(agent, topicId = null) {
|
|
1056
2721
|
const size = 16
|
|
1057
|
-
|
|
2722
|
+
const releasable = topicId !== null && topicId !== undefined
|
|
2723
|
+
const title = releasable
|
|
2724
|
+
? this._i18n('agent_assigned_title').replace('%{name}', agent.name)
|
|
2725
|
+
: agent.name
|
|
2726
|
+
const releaseAttrs = releasable
|
|
2727
|
+
? ` data-action="click->comments--topics#clearTopicPrimaryAgent" data-id="${this.escapeAttr(String(topicId))}" role="button"`
|
|
2728
|
+
: ''
|
|
2729
|
+
let html = `<span class="avatar-wrapper topic-agent-avatar-wrapper${releasable ? ' topic-agent-avatar-releasable' : ''}"${releaseAttrs} style="width:${size}px;height:${size}px;" title="${this.escapeAttr(title)}">`
|
|
1058
2730
|
html += `<img src="${this.escapeAttr(agent.avatar_url)}" alt="" width="${size}" height="${size}" class="topic-agent-avatar" style="border-radius:50%;vertical-align:middle;">`
|
|
1059
2731
|
if (agent.default_avatar) {
|
|
1060
2732
|
html += `<span class="avatar-initial">${this.escapeAttr(agent.initial)}</span>`
|