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
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// selectTopic persists the selection through the topics API; stub it so the
|
|
8
|
+
// tests stay offline and the debounce timer has nothing to flush.
|
|
9
|
+
jest.unstable_mockModule('../../../lib/api/topics', () => ({
|
|
10
|
+
fetchNextTopicName: jest.fn(),
|
|
11
|
+
createTopicWithComments: jest.fn(),
|
|
12
|
+
saveLastTopic: jest.fn().mockResolvedValue(undefined),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
16
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
17
|
+
|
|
18
|
+
const TOPICS = [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }]
|
|
19
|
+
const ARCHIVED = [{ id: 3, name: 'Zeta' }, { id: 4, name: 'Omega' }]
|
|
20
|
+
|
|
21
|
+
describe('TopicsController archived topic messages', () => {
|
|
22
|
+
let application
|
|
23
|
+
let controller
|
|
24
|
+
let changeEvents
|
|
25
|
+
|
|
26
|
+
const render = () => controller.renderTopics(controller.topics, controller.canManageTopics)
|
|
27
|
+
|
|
28
|
+
// Stimulus binds actions on freshly inserted nodes via a MutationObserver, so
|
|
29
|
+
// a click dispatched in the same tick as the render would hit nothing.
|
|
30
|
+
const renderAndBind = async () => {
|
|
31
|
+
render()
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
document.body.innerHTML = `
|
|
37
|
+
<div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
|
|
38
|
+
<div data-comments--topics-target="list"></div>
|
|
39
|
+
</div>
|
|
40
|
+
`
|
|
41
|
+
// jsdom has no layout, so scrollIntoView is undefined on elements.
|
|
42
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
43
|
+
document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
|
|
44
|
+
|
|
45
|
+
application = Application.start()
|
|
46
|
+
application.register('comments--topics', TopicsController)
|
|
47
|
+
|
|
48
|
+
changeEvents = []
|
|
49
|
+
document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
52
|
+
controller = application.getControllerForElementAndIdentifier(
|
|
53
|
+
document.getElementById('topics'), 'comments--topics'
|
|
54
|
+
)
|
|
55
|
+
controller.creativeIdValue = '42'
|
|
56
|
+
controller.topics = TOPICS
|
|
57
|
+
controller.archivedTopics = ARCHIVED
|
|
58
|
+
controller.mainTopicId = '1'
|
|
59
|
+
controller.canManageTopics = true
|
|
60
|
+
controller.showingArchived = false
|
|
61
|
+
controller.serverLastTopicId = ''
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
document.body.innerHTML = ''
|
|
67
|
+
document.head.innerHTML = ''
|
|
68
|
+
application.stop()
|
|
69
|
+
jest.clearAllMocks()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('selecting an archived topic', () => {
|
|
73
|
+
test('renders unread counts on active and archived topic chips', () => {
|
|
74
|
+
controller.topics = [{ id: 1, name: 'Main', unread_count: 2 }, { id: 2, name: 'Alpha', unread_count: 0 }]
|
|
75
|
+
controller.archivedTopics = [{ id: 3, name: 'Zeta', unread_count: 4 }]
|
|
76
|
+
controller.showingArchived = true
|
|
77
|
+
|
|
78
|
+
render()
|
|
79
|
+
|
|
80
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="1"] .topic-unread-badge').textContent).toBe('2')
|
|
81
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="2"] .topic-unread-badge')).toBeNull()
|
|
82
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"] .topic-unread-badge').textContent).toBe('4')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('archived chips carry the select action so clicking one opens it', () => {
|
|
86
|
+
controller.showingArchived = true
|
|
87
|
+
render()
|
|
88
|
+
|
|
89
|
+
const chip = controller.listTarget.querySelector('.topic-archived[data-id="3"]')
|
|
90
|
+
expect(chip.dataset.action).toContain('click->comments--topics#select')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('clicking an archived chip dispatches change with its topic id', async () => {
|
|
94
|
+
controller.showingArchived = true
|
|
95
|
+
await renderAndBind()
|
|
96
|
+
|
|
97
|
+
controller.listTarget.querySelector('.topic-archived[data-id="3"]').click()
|
|
98
|
+
|
|
99
|
+
expect(changeEvents.at(-1).topicId).toBe('3')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('the selected archived chip renders as active', () => {
|
|
103
|
+
controller.showingArchived = true
|
|
104
|
+
controller.serverLastTopicId = '3'
|
|
105
|
+
render()
|
|
106
|
+
|
|
107
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
|
|
108
|
+
.toContain('active')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('clicking Restore unarchives without also selecting the topic', async () => {
|
|
112
|
+
const fetchMock = jest.fn().mockResolvedValue({ ok: true })
|
|
113
|
+
global.fetch = fetchMock
|
|
114
|
+
controller.loadTopics = jest.fn()
|
|
115
|
+
controller.showingArchived = true
|
|
116
|
+
await renderAndBind()
|
|
117
|
+
|
|
118
|
+
controller.listTarget.querySelector('.unarchive-topic-btn[data-id="3"]').click()
|
|
119
|
+
|
|
120
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
121
|
+
'/creatives/42/topics/3/unarchive',
|
|
122
|
+
expect.objectContaining({ method: 'PATCH' })
|
|
123
|
+
)
|
|
124
|
+
expect(changeEvents).toHaveLength(0)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('restoreSelection', () => {
|
|
129
|
+
test('keeps an archived topic selected instead of falling back to Main', () => {
|
|
130
|
+
controller.serverLastTopicId = '3'
|
|
131
|
+
render()
|
|
132
|
+
|
|
133
|
+
controller.restoreSelection()
|
|
134
|
+
|
|
135
|
+
expect(changeEvents.at(-1).topicId).toBe('3')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('expands the archived section so the selected topic is visible', () => {
|
|
139
|
+
controller.serverLastTopicId = '3'
|
|
140
|
+
render()
|
|
141
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
|
|
142
|
+
|
|
143
|
+
controller.restoreSelection()
|
|
144
|
+
|
|
145
|
+
expect(controller.showingArchived).toBe(true)
|
|
146
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).not.toBeNull()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('keeps a live topic selected without expanding the archived section', () => {
|
|
150
|
+
controller.serverLastTopicId = '2'
|
|
151
|
+
render()
|
|
152
|
+
|
|
153
|
+
controller.restoreSelection()
|
|
154
|
+
|
|
155
|
+
expect(changeEvents.at(-1).topicId).toBe('2')
|
|
156
|
+
expect(controller.showingArchived).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('falls back to Main for a topic id that no longer exists', () => {
|
|
160
|
+
controller.serverLastTopicId = '999'
|
|
161
|
+
render()
|
|
162
|
+
|
|
163
|
+
controller.restoreSelection()
|
|
164
|
+
|
|
165
|
+
expect(changeEvents.at(-1).topicId).toBe('1')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('falls back to Main when there are no archived topics at all', () => {
|
|
169
|
+
controller.archivedTopics = []
|
|
170
|
+
controller.serverLastTopicId = '3'
|
|
171
|
+
render()
|
|
172
|
+
|
|
173
|
+
controller.restoreSelection()
|
|
174
|
+
|
|
175
|
+
expect(changeEvents.at(-1).topicId).toBe('1')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('revealing the archived section', () => {
|
|
180
|
+
test('selecting an archived topic from the topic-list popup expands the section', () => {
|
|
181
|
+
render()
|
|
182
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
|
|
183
|
+
|
|
184
|
+
// What openTopicListPopup hands the popup as its pick callback.
|
|
185
|
+
controller.selectTopic('3')
|
|
186
|
+
|
|
187
|
+
expect(controller.showingArchived).toBe(true)
|
|
188
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
|
|
189
|
+
.toContain('active')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('selecting a live topic leaves the archived section collapsed', () => {
|
|
193
|
+
render()
|
|
194
|
+
|
|
195
|
+
controller.selectTopic('2')
|
|
196
|
+
|
|
197
|
+
expect(controller.showingArchived).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('collapsing the section stays collapsed while an archived topic is open', () => {
|
|
201
|
+
controller.serverLastTopicId = '3'
|
|
202
|
+
controller.showingArchived = true
|
|
203
|
+
render()
|
|
204
|
+
|
|
205
|
+
controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
|
|
206
|
+
|
|
207
|
+
expect(controller.showingArchived).toBe(false)
|
|
208
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('toggling the section does not reload the message list', () => {
|
|
212
|
+
controller.showingArchived = true
|
|
213
|
+
render()
|
|
214
|
+
|
|
215
|
+
controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
|
|
216
|
+
|
|
217
|
+
expect(changeEvents).toHaveLength(0)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('after the user collapses the section on the topic they are in', () => {
|
|
221
|
+
const collapse = () => controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
|
|
222
|
+
const rename = () => controller.updateTopicInList({ id: 2, name: 'Alpha renamed' })
|
|
223
|
+
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
controller.serverLastTopicId = '3'
|
|
226
|
+
controller.showingArchived = true
|
|
227
|
+
render()
|
|
228
|
+
collapse()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('an unrelated rename does not reopen it', () => {
|
|
232
|
+
rename()
|
|
233
|
+
|
|
234
|
+
expect(controller.showingArchived).toBe(false)
|
|
235
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('a reorder does not reopen it', () => {
|
|
239
|
+
controller.reorderTopicsFromServer([2, 1])
|
|
240
|
+
|
|
241
|
+
expect(controller.showingArchived).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('the hidden topic stays selected', () => {
|
|
245
|
+
rename()
|
|
246
|
+
|
|
247
|
+
expect(String(controller.currentTopicId)).toBe('3')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('deliberately reselecting the topic reopens the section', () => {
|
|
251
|
+
controller.selectTopic('3')
|
|
252
|
+
|
|
253
|
+
expect(controller.showingArchived).toBe(true)
|
|
254
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
|
|
255
|
+
.toContain('active')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a rename after that reselection leaves the section open', () => {
|
|
259
|
+
controller.selectTopic('3')
|
|
260
|
+
|
|
261
|
+
rename()
|
|
262
|
+
|
|
263
|
+
expect(controller.showingArchived).toBe(true)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('re-expanding by hand also drops the suppression', () => {
|
|
267
|
+
collapse()
|
|
268
|
+
|
|
269
|
+
rename()
|
|
270
|
+
|
|
271
|
+
expect(controller.showingArchived).toBe(true)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('collapsing while a live topic is open still reveals a restored archived one', () => {
|
|
276
|
+
controller.serverLastTopicId = '2'
|
|
277
|
+
controller.showingArchived = true
|
|
278
|
+
render()
|
|
279
|
+
|
|
280
|
+
controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
|
|
281
|
+
controller.serverLastTopicId = '3'
|
|
282
|
+
controller.restoreSelection()
|
|
283
|
+
|
|
284
|
+
expect(controller.showingArchived).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('new message badges', () => {
|
|
289
|
+
const newMessage = (topicId) => controller.handleNewMessage({ detail: { topicId } })
|
|
290
|
+
|
|
291
|
+
test('badges the archived toggle when the section is collapsed', () => {
|
|
292
|
+
render()
|
|
293
|
+
|
|
294
|
+
newMessage('3')
|
|
295
|
+
|
|
296
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
297
|
+
.toContain('has-new-messages')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('badges both the chip and the toggle when the section is expanded', () => {
|
|
301
|
+
controller.showingArchived = true
|
|
302
|
+
render()
|
|
303
|
+
|
|
304
|
+
newMessage('3')
|
|
305
|
+
|
|
306
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
|
|
307
|
+
.toContain('has-new-messages')
|
|
308
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
309
|
+
.toContain('has-new-messages')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('the toggle badge survives a re-render', () => {
|
|
313
|
+
render()
|
|
314
|
+
newMessage('3')
|
|
315
|
+
|
|
316
|
+
render()
|
|
317
|
+
|
|
318
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
319
|
+
.toContain('has-new-messages')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('a live topic does not badge the archived toggle', () => {
|
|
323
|
+
render()
|
|
324
|
+
|
|
325
|
+
newMessage('2')
|
|
326
|
+
|
|
327
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
328
|
+
.not.toContain('has-new-messages')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('the toggle badge stays while another archived topic is still unread', () => {
|
|
332
|
+
render()
|
|
333
|
+
newMessage('3')
|
|
334
|
+
newMessage('4')
|
|
335
|
+
|
|
336
|
+
controller.selectTopic('3')
|
|
337
|
+
|
|
338
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
339
|
+
.toContain('has-new-messages')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('the toggle badge clears once every archived topic has been read', () => {
|
|
343
|
+
render()
|
|
344
|
+
newMessage('3')
|
|
345
|
+
newMessage('4')
|
|
346
|
+
|
|
347
|
+
controller.selectTopic('3')
|
|
348
|
+
controller.selectTopic('4')
|
|
349
|
+
|
|
350
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
351
|
+
.not.toContain('has-new-messages')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('ignores a message for the topic already being viewed', () => {
|
|
355
|
+
controller.serverLastTopicId = '3'
|
|
356
|
+
render()
|
|
357
|
+
|
|
358
|
+
newMessage('3')
|
|
359
|
+
|
|
360
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
361
|
+
.not.toContain('has-new-messages')
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// The toggle badge is derived from the unread set's size, so any id left in it
|
|
366
|
+
// that no longer belongs to an archived topic lights the toggle with no chip
|
|
367
|
+
// to click and clear it.
|
|
368
|
+
describe('stale unread state', () => {
|
|
369
|
+
const newMessage = (topicId) => controller.handleNewMessage({ detail: { topicId } })
|
|
370
|
+
|
|
371
|
+
// popup_controller._navigateToEntry reuses open()/openForCreative() on this
|
|
372
|
+
// same instance, so no onPopupClosed runs between two creatives.
|
|
373
|
+
const switchCreative = async (creativeId) => {
|
|
374
|
+
controller.subscribe = jest.fn()
|
|
375
|
+
controller.loadTopics = jest.fn().mockResolvedValue(undefined)
|
|
376
|
+
await controller.onPopupOpened({ creativeId })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
test('switching creatives drops the previous creative unread marks', async () => {
|
|
380
|
+
render()
|
|
381
|
+
newMessage('3')
|
|
382
|
+
expect(controller.archivedWithNewMessages.size).toBe(1)
|
|
383
|
+
|
|
384
|
+
await switchCreative('99')
|
|
385
|
+
|
|
386
|
+
expect(controller.archivedWithNewMessages.size).toBe(0)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// popup_controller.handleCreativeClick re-opens the *same* creative when a
|
|
390
|
+
// docked chat receives a workspace-sync event carrying a highlightId. These
|
|
391
|
+
// marks are transient — loadTopics() cannot rebuild them — so following a
|
|
392
|
+
// comment deep link must not wipe the other archived topics' unread state.
|
|
393
|
+
test('re-opening the same creative keeps its unread marks', async () => {
|
|
394
|
+
render()
|
|
395
|
+
newMessage('3')
|
|
396
|
+
newMessage('4')
|
|
397
|
+
|
|
398
|
+
await switchCreative('42')
|
|
399
|
+
|
|
400
|
+
expect([...controller.archivedWithNewMessages].sort()).toEqual(['3', '4'])
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test('the toggle stays badged after re-opening the same creative', async () => {
|
|
404
|
+
render()
|
|
405
|
+
newMessage('3')
|
|
406
|
+
|
|
407
|
+
await switchCreative('42')
|
|
408
|
+
render()
|
|
409
|
+
|
|
410
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
411
|
+
.toContain('has-new-messages')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
test("the new creative's archived toggle renders unbadged after a switch", async () => {
|
|
415
|
+
render()
|
|
416
|
+
newMessage('3')
|
|
417
|
+
|
|
418
|
+
await switchCreative('99')
|
|
419
|
+
controller.topics = [{ id: 10, name: 'Main' }]
|
|
420
|
+
controller.archivedTopics = [{ id: 11, name: 'Other' }]
|
|
421
|
+
render()
|
|
422
|
+
|
|
423
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
424
|
+
.not.toContain('has-new-messages')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('loadTopics drops unread marks for topics that are no longer archived', async () => {
|
|
428
|
+
render()
|
|
429
|
+
newMessage('3')
|
|
430
|
+
newMessage('4')
|
|
431
|
+
|
|
432
|
+
// Topic 3 was restored to the live strip; only 4 is still archived.
|
|
433
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
434
|
+
ok: true,
|
|
435
|
+
status: 200,
|
|
436
|
+
json: async () => ({
|
|
437
|
+
topics: [{ id: 1, name: 'Main' }, { id: 3, name: 'Zeta' }],
|
|
438
|
+
archived_topics: [{ id: 4, name: 'Omega' }],
|
|
439
|
+
can_manage: true,
|
|
440
|
+
main_topic_id: 1,
|
|
441
|
+
}),
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
await controller.loadTopics()
|
|
445
|
+
|
|
446
|
+
expect([...controller.archivedWithNewMessages]).toEqual(['4'])
|
|
447
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
448
|
+
.toContain('has-new-messages')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('the toggle badge clears when the only unread topic is unarchived', () => {
|
|
452
|
+
render()
|
|
453
|
+
newMessage('3')
|
|
454
|
+
|
|
455
|
+
controller.archivedTopics = [{ id: 4, name: 'Omega' }]
|
|
456
|
+
controller.pruneArchivedBadges()
|
|
457
|
+
render()
|
|
458
|
+
|
|
459
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
460
|
+
.not.toContain('has-new-messages')
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
// Archiving the topic in view switches to All Messages, but the preference
|
|
465
|
+
// save is debounced 500ms — so the topics reload it triggers (and the one the
|
|
466
|
+
// "archived" broadcast triggers) still reads the archived topic as
|
|
467
|
+
// last_topic_id. restoreSelection accepts archived ids now, so nothing else
|
|
468
|
+
// stops it from putting the user back where they just left.
|
|
469
|
+
describe('archiving the topic in view', () => {
|
|
470
|
+
// Route by URL: the archive PATCH and the topics reload both go through
|
|
471
|
+
// the same global fetch. lastTopicId is what the server preference still
|
|
472
|
+
// reports at reload time.
|
|
473
|
+
const stubFetch = (lastTopicId) => {
|
|
474
|
+
global.fetch = jest.fn((url) => {
|
|
475
|
+
if (String(url).includes('/archive')) return Promise.resolve({ ok: true })
|
|
476
|
+
return Promise.resolve({
|
|
477
|
+
ok: true,
|
|
478
|
+
status: 200,
|
|
479
|
+
json: async () => ({
|
|
480
|
+
topics: [{ id: 1, name: 'Main' }],
|
|
481
|
+
archived_topics: [{ id: 2, name: 'Alpha' }, ...ARCHIVED],
|
|
482
|
+
can_manage: true,
|
|
483
|
+
main_topic_id: 1,
|
|
484
|
+
last_topic_id: lastTopicId,
|
|
485
|
+
}),
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// archiveTopic fires loadTopics without awaiting it.
|
|
491
|
+
const archive = async (topicId) => {
|
|
492
|
+
await controller.archiveTopic({
|
|
493
|
+
stopPropagation: jest.fn(),
|
|
494
|
+
currentTarget: { dataset: { id: topicId } },
|
|
495
|
+
})
|
|
496
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
test('does not reselect the topic the reload still reports as last_topic_id', async () => {
|
|
500
|
+
controller.serverLastTopicId = '2'
|
|
501
|
+
render()
|
|
502
|
+
stubFetch(2)
|
|
503
|
+
|
|
504
|
+
await archive('2')
|
|
505
|
+
|
|
506
|
+
expect(changeEvents.at(-1).topicId).toBe('1')
|
|
507
|
+
expect(controller.currentTopicId).toBe('1')
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('allows All Messages to scroll into view after archiving the selected topic', async () => {
|
|
511
|
+
controller.serverLastTopicId = '2'
|
|
512
|
+
controller._topicScrollInterrupted = true
|
|
513
|
+
const interruptionStatesAtLoad = []
|
|
514
|
+
controller.loadTopics = jest.fn(() => {
|
|
515
|
+
interruptionStatesAtLoad.push(controller._topicScrollInterrupted)
|
|
516
|
+
})
|
|
517
|
+
global.fetch = jest.fn().mockResolvedValue({ ok: true })
|
|
518
|
+
|
|
519
|
+
await controller.archiveTopic({
|
|
520
|
+
stopPropagation: jest.fn(),
|
|
521
|
+
currentTarget: { dataset: { id: '2' } },
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
expect(interruptionStatesAtLoad).toEqual([false])
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test('preserves the user scroll lock after archiving an inactive topic', async () => {
|
|
528
|
+
controller.serverLastTopicId = '1'
|
|
529
|
+
controller._topicScrollInterrupted = true
|
|
530
|
+
const interruptionStatesAtLoad = []
|
|
531
|
+
controller.loadTopics = jest.fn(() => {
|
|
532
|
+
interruptionStatesAtLoad.push(controller._topicScrollInterrupted)
|
|
533
|
+
})
|
|
534
|
+
global.fetch = jest.fn().mockResolvedValue({ ok: true })
|
|
535
|
+
|
|
536
|
+
await controller.archiveTopic({
|
|
537
|
+
stopPropagation: jest.fn(),
|
|
538
|
+
currentTarget: { dataset: { id: '2' } },
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
expect(interruptionStatesAtLoad).toEqual([true])
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test('leaves the archived section collapsed instead of revealing the archived topic', async () => {
|
|
545
|
+
controller.serverLastTopicId = '2'
|
|
546
|
+
render()
|
|
547
|
+
stubFetch(2)
|
|
548
|
+
|
|
549
|
+
await archive('2')
|
|
550
|
+
|
|
551
|
+
expect(controller.showingArchived).toBe(false)
|
|
552
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="2"]')).toBeNull()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
test('a deep-link override does not keep reporting the archived topic as current', async () => {
|
|
556
|
+
controller.setOverrideTopicId('2')
|
|
557
|
+
render()
|
|
558
|
+
stubFetch(2)
|
|
559
|
+
|
|
560
|
+
await archive('2')
|
|
561
|
+
|
|
562
|
+
expect(controller.overrideTopicId).toBeUndefined()
|
|
563
|
+
expect(controller.currentTopicId).toBe('1')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('the user can still go back into the topic they just archived', async () => {
|
|
567
|
+
controller.serverLastTopicId = '2'
|
|
568
|
+
render()
|
|
569
|
+
stubFetch(2)
|
|
570
|
+
await archive('2')
|
|
571
|
+
|
|
572
|
+
controller.selectTopic('2')
|
|
573
|
+
controller.restoreSelection()
|
|
574
|
+
|
|
575
|
+
expect(changeEvents.at(-1).topicId).toBe('2')
|
|
576
|
+
expect(controller.showingArchived).toBe(true)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test('the guard lifts once the server preference stops naming the topic', async () => {
|
|
580
|
+
controller.serverLastTopicId = '2'
|
|
581
|
+
render()
|
|
582
|
+
stubFetch(2)
|
|
583
|
+
await archive('2')
|
|
584
|
+
|
|
585
|
+
stubFetch(null)
|
|
586
|
+
await controller.loadTopics()
|
|
587
|
+
// Reselected elsewhere — another tab, or a deep link.
|
|
588
|
+
stubFetch(2)
|
|
589
|
+
await controller.loadTopics()
|
|
590
|
+
|
|
591
|
+
expect(changeEvents.at(-1).topicId).toBe('2')
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// ?topic_id= outranks the saved preference in the currentTopicId getter and
|
|
595
|
+
// survives every reload, so it has to be cleared too — otherwise the guard
|
|
596
|
+
// is the only thing holding the selection back, and it lifts the moment the
|
|
597
|
+
// preference save lands.
|
|
598
|
+
describe('when the URL names the archived topic', () => {
|
|
599
|
+
beforeEach(() => {
|
|
600
|
+
window.history.replaceState({}, '', '/?topic_id=2')
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
afterEach(() => {
|
|
604
|
+
window.history.replaceState({}, '', '/')
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
test('drops the query parameter', async () => {
|
|
608
|
+
render()
|
|
609
|
+
stubFetch(2)
|
|
610
|
+
|
|
611
|
+
await archive('2')
|
|
612
|
+
|
|
613
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
|
|
614
|
+
expect(controller.currentTopicId).toBe('1')
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test('a later reload does not put the user back into it', async () => {
|
|
618
|
+
render()
|
|
619
|
+
stubFetch(2)
|
|
620
|
+
await archive('2')
|
|
621
|
+
|
|
622
|
+
// The preference save has landed by now, so the guard's old lift
|
|
623
|
+
// condition would fire here.
|
|
624
|
+
stubFetch(null)
|
|
625
|
+
await controller.loadTopics()
|
|
626
|
+
|
|
627
|
+
expect(changeEvents.at(-1).topicId).toBe('1')
|
|
628
|
+
expect(controller.showingArchived).toBe(false)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('leaves a query parameter naming a different topic alone', async () => {
|
|
632
|
+
controller.topics = [...TOPICS, { id: 5, name: 'Beta' }]
|
|
633
|
+
render()
|
|
634
|
+
stubFetch(2)
|
|
635
|
+
|
|
636
|
+
await archive('5')
|
|
637
|
+
|
|
638
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('2')
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test('archiving a topic that is not in view leaves the selection alone', async () => {
|
|
643
|
+
controller.topics = [...TOPICS, { id: 5, name: 'Beta' }]
|
|
644
|
+
controller.serverLastTopicId = '2'
|
|
645
|
+
render()
|
|
646
|
+
global.fetch = jest.fn((url) => {
|
|
647
|
+
if (String(url).includes('/archive')) return Promise.resolve({ ok: true })
|
|
648
|
+
return Promise.resolve({
|
|
649
|
+
ok: true,
|
|
650
|
+
status: 200,
|
|
651
|
+
json: async () => ({
|
|
652
|
+
topics: TOPICS,
|
|
653
|
+
archived_topics: [{ id: 5, name: 'Beta' }, ...ARCHIVED],
|
|
654
|
+
can_manage: true,
|
|
655
|
+
main_topic_id: 1,
|
|
656
|
+
last_topic_id: 2,
|
|
657
|
+
}),
|
|
658
|
+
})
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
await archive('5')
|
|
662
|
+
|
|
663
|
+
expect(changeEvents.at(-1).topicId).toBe('2')
|
|
664
|
+
})
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
// The "deleted" broadcast is the one removal path that runs without a
|
|
668
|
+
// loadTopics(), so it is the only thing that can prune the archived cache
|
|
669
|
+
// when an admin deletes an archived topic from another client or the API.
|
|
670
|
+
describe('a deleted broadcast for an archived topic', () => {
|
|
671
|
+
const deleteBroadcast = (topicId) =>
|
|
672
|
+
controller.handleTopicMessage({ action: 'deleted', topic_id: topicId })
|
|
673
|
+
|
|
674
|
+
test('drops it from the archived cache', () => {
|
|
675
|
+
deleteBroadcast(3)
|
|
676
|
+
|
|
677
|
+
expect(controller.archivedTopics.map((t) => t.id)).toEqual([4])
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
test('removes its chip so it can no longer be opened', () => {
|
|
681
|
+
controller.showingArchived = true
|
|
682
|
+
render()
|
|
683
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).not.toBeNull()
|
|
684
|
+
|
|
685
|
+
deleteBroadcast(3)
|
|
686
|
+
|
|
687
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
|
|
688
|
+
expect(controller.listTarget.querySelector('.topic-archived[data-id="4"]')).not.toBeNull()
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
test('moves the viewer out of it instead of leaving them on a dead conversation', () => {
|
|
692
|
+
controller.serverLastTopicId = '3'
|
|
693
|
+
|
|
694
|
+
deleteBroadcast(3)
|
|
695
|
+
|
|
696
|
+
expect(changeEvents.at(-1).topicId).toBe('1')
|
|
697
|
+
expect(controller.serverLastTopicId).toBe('1')
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test('clears the toggle badge it was holding', () => {
|
|
701
|
+
render()
|
|
702
|
+
controller.handleNewMessage({ detail: { topicId: '3' } })
|
|
703
|
+
expect(controller.archivedWithNewMessages.has('3')).toBe(true)
|
|
704
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
705
|
+
.toContain('has-new-messages')
|
|
706
|
+
|
|
707
|
+
deleteBroadcast(3)
|
|
708
|
+
|
|
709
|
+
expect(controller.archivedWithNewMessages.has('3')).toBe(false)
|
|
710
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
711
|
+
.not.toContain('has-new-messages')
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
// Another archived topic still unread keeps the toggle lit — the badge
|
|
715
|
+
// aggregates the whole section.
|
|
716
|
+
test('leaves the toggle lit when another archived topic is still unread', () => {
|
|
717
|
+
render()
|
|
718
|
+
controller.handleNewMessage({ detail: { topicId: '3' } })
|
|
719
|
+
controller.handleNewMessage({ detail: { topicId: '4' } })
|
|
720
|
+
|
|
721
|
+
deleteBroadcast(3)
|
|
722
|
+
|
|
723
|
+
expect([...controller.archivedWithNewMessages]).toEqual(['4'])
|
|
724
|
+
expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
|
|
725
|
+
.toContain('has-new-messages')
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
test('still removes a live topic', () => {
|
|
729
|
+
deleteBroadcast(2)
|
|
730
|
+
|
|
731
|
+
expect(controller.topics.map((t) => t.id)).toEqual([1])
|
|
732
|
+
expect(controller.archivedTopics.map((t) => t.id)).toEqual([3, 4])
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test('does nothing for an id in neither list', () => {
|
|
736
|
+
deleteBroadcast(99)
|
|
737
|
+
|
|
738
|
+
expect(controller.topics.map((t) => t.id)).toEqual([1, 2])
|
|
739
|
+
expect(controller.archivedTopics.map((t) => t.id)).toEqual([3, 4])
|
|
740
|
+
expect(changeEvents).toEqual([])
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
// Assigning "" only moves the preference, and both deep-link sources outrank
|
|
744
|
+
// it in the currentTopicId getter, so the deleted id would keep answering
|
|
745
|
+
// for every later restoreSelection().
|
|
746
|
+
describe('while a deep link still names it', () => {
|
|
747
|
+
test('clears an overrideTopicId pointing at it', () => {
|
|
748
|
+
controller.setOverrideTopicId('3')
|
|
749
|
+
|
|
750
|
+
deleteBroadcast(3)
|
|
751
|
+
|
|
752
|
+
expect(controller.currentTopicId).toBe('1')
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
test('drops a ?topic_id= naming it', () => {
|
|
756
|
+
window.history.replaceState({}, '', '/?topic_id=3')
|
|
757
|
+
|
|
758
|
+
deleteBroadcast(3)
|
|
759
|
+
|
|
760
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
|
|
761
|
+
expect(controller.currentTopicId).toBe('1')
|
|
762
|
+
|
|
763
|
+
window.history.replaceState({}, '', '/')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
// The point of clearing them: a re-render must not throw away the topic
|
|
767
|
+
// the user picked after the deletion.
|
|
768
|
+
test('a later re-render keeps the topic the user chose instead', () => {
|
|
769
|
+
controller.setOverrideTopicId('3')
|
|
770
|
+
deleteBroadcast(3)
|
|
771
|
+
|
|
772
|
+
controller.selectTopic('2')
|
|
773
|
+
controller.renderTopics(controller.topics, controller.canManageTopics)
|
|
774
|
+
controller.restoreSelection()
|
|
775
|
+
|
|
776
|
+
expect(controller.currentTopicId).toBe('2')
|
|
777
|
+
expect(changeEvents.at(-1).topicId).toBe('2')
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
test('leaves a deep link pointing at a different topic alone', () => {
|
|
781
|
+
window.history.replaceState({}, '', '/?topic_id=4')
|
|
782
|
+
|
|
783
|
+
deleteBroadcast(3)
|
|
784
|
+
|
|
785
|
+
expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('4')
|
|
786
|
+
|
|
787
|
+
window.history.replaceState({}, '', '/')
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
// list_controller asks isArchivedTopic for every incoming stream, and the
|
|
793
|
+
// reload that follows an archive/unarchive only refreshes the cache when its
|
|
794
|
+
// fetch resolves. Between the two, membership has to already be right.
|
|
795
|
+
describe('archive membership before the reload lands', () => {
|
|
796
|
+
// Hold the topics reload open so every assertion runs inside the window.
|
|
797
|
+
const stubPendingReload = () => {
|
|
798
|
+
global.fetch = jest.fn(() => new Promise(() => {}))
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const broadcast = (action, topic) =>
|
|
802
|
+
controller.handleTopicMessage({ action, topic })
|
|
803
|
+
|
|
804
|
+
test('an archived broadcast marks the topic archived immediately', () => {
|
|
805
|
+
stubPendingReload()
|
|
806
|
+
|
|
807
|
+
broadcast('archived', { id: 2, name: 'Alpha' })
|
|
808
|
+
|
|
809
|
+
expect(controller.isArchivedTopic('2')).toBe(true)
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
test('an unarchived broadcast unmarks it immediately', () => {
|
|
813
|
+
stubPendingReload()
|
|
814
|
+
|
|
815
|
+
broadcast('unarchived', { id: 3, name: 'Zeta' })
|
|
816
|
+
|
|
817
|
+
expect(controller.isArchivedTopic('3')).toBe(false)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
test('an unarchived broadcast clears the unread badge it can no longer show', () => {
|
|
821
|
+
stubPendingReload()
|
|
822
|
+
controller.archivedWithNewMessages.add('3')
|
|
823
|
+
|
|
824
|
+
broadcast('unarchived', { id: 3, name: 'Zeta' })
|
|
825
|
+
|
|
826
|
+
expect(controller.archivedWithNewMessages.has('3')).toBe(false)
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
test('an unarchived broadcast keeps another archived topic unread', () => {
|
|
830
|
+
stubPendingReload()
|
|
831
|
+
controller.archivedWithNewMessages.add('3')
|
|
832
|
+
controller.archivedWithNewMessages.add('4')
|
|
833
|
+
|
|
834
|
+
broadcast('unarchived', { id: 3, name: 'Zeta' })
|
|
835
|
+
|
|
836
|
+
expect(controller.archivedWithNewMessages.has('4')).toBe(true)
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
// loadTopics() empties this.topics on its way out, so the archived cache is
|
|
840
|
+
// the only one still readable in this window — and the only one routing
|
|
841
|
+
// consults.
|
|
842
|
+
test('a broadcast with no topic payload leaves the archived cache alone', () => {
|
|
843
|
+
stubPendingReload()
|
|
844
|
+
|
|
845
|
+
controller.handleTopicMessage({ action: 'archived' })
|
|
846
|
+
|
|
847
|
+
expect(controller.archivedTopics.map((t) => t.id)).toEqual([3, 4])
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
// The actor's own reload has the same window as every receiver's.
|
|
851
|
+
test('archiving locally marks the topic archived immediately', async () => {
|
|
852
|
+
global.fetch = jest.fn((url) => {
|
|
853
|
+
if (String(url).includes('/archive')) return Promise.resolve({ ok: true })
|
|
854
|
+
return new Promise(() => {})
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
await controller.archiveTopic({
|
|
858
|
+
stopPropagation: jest.fn(),
|
|
859
|
+
currentTarget: { dataset: { id: '2' } },
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
expect(controller.isArchivedTopic('2')).toBe(true)
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
test('restoring locally unmarks the topic immediately', async () => {
|
|
866
|
+
global.fetch = jest.fn((url) => {
|
|
867
|
+
if (String(url).includes('/unarchive')) return Promise.resolve({ ok: true })
|
|
868
|
+
return new Promise(() => {})
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
await controller.unarchiveTopic({
|
|
872
|
+
stopPropagation: jest.fn(),
|
|
873
|
+
currentTarget: { dataset: { id: '3' } },
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
expect(controller.isArchivedTopic('3')).toBe(false)
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
})
|