collavre 0.22.0 → 0.23.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.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # Shared base for the voice-companion mobile API. Inherits Doorkeeper
8
+ # bearer auth from Api::V1::BaseController.
9
+ #
10
+ # ★ The API base controller authenticates the token but does NOT run the
11
+ # host app's locale around_action, so I18n would otherwise serve the
12
+ # process-default locale. Every persisted/spoken string here must match
13
+ # the speaker, so we wrap every action in I18n.with_locale (app-supplied
14
+ # `locale` param wins, else the user's stored locale).
15
+ class BaseController < Collavre::Api::V1::BaseController
16
+ around_action :with_request_locale
17
+
18
+ private
19
+
20
+ def with_request_locale(&block)
21
+ I18n.with_locale(requested_locale, &block)
22
+ end
23
+
24
+ def requested_locale
25
+ normalize_locale(params[:locale].presence || current_user&.locale)
26
+ end
27
+
28
+ def normalize_locale(value)
29
+ return I18n.default_locale if value.blank?
30
+
31
+ base = value.to_s.tr("-", "_").split("_").first.to_sym
32
+ I18n.available_locales.include?(base) ? base : I18n.default_locale
33
+ end
34
+
35
+ def summarizer
36
+ @summarizer ||= Collavre::Mobile::EventSummarizer.new(locale: I18n.locale)
37
+ end
38
+
39
+ # User-owned AI agents (Claude Channel sessions, etc.).
40
+ def agent_ids
41
+ @agent_ids ||= Collavre::User.ai_agents.where(created_by_id: current_user.id).pluck(:id)
42
+ end
43
+
44
+ # Undecided permission prompts the token holder is the approver for.
45
+ def pending_approvals
46
+ Collavre::Comment.where(approver_id: current_user.id, action_executed_at: nil)
47
+ .where.not(action: nil)
48
+ .order(:created_at)
49
+ .limit(50)
50
+ .select(&:claude_channel_permission?)
51
+ end
52
+
53
+ def reply(key)
54
+ I18n.t("collavre.mobile.reply.#{key}")
55
+ end
56
+
57
+ def label_for_topic(topic_id)
58
+ topic = topic_id && Collavre::Topic.find_by(id: topic_id)
59
+ topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
60
+ end
61
+
62
+ # List/display title for an event: "Creative#Topic" so the app can show
63
+ # which thread a message belongs to (spec: 제목을 크리에이티브#토픽 형태로).
64
+ def title_for_topic(topic_id)
65
+ topic = topic_id && Collavre::Topic.find_by(id: topic_id)
66
+ name = topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
67
+ creative = topic&.creative&.effective_origin || topic&.creative
68
+ creative&.description.present? ? "#{creative.description}##{name}" : name
69
+ end
70
+
71
+ def render_speak(reply_key_or_text, action: {}, speak: true, status: :ok)
72
+ text = reply_key_or_text.is_a?(Symbol) ? reply(reply_key_or_text) : reply_key_or_text
73
+ render json: { reply: text, speak: speak, action: action }, status: status
74
+ end
75
+
76
+ # Gate + decide + broadcast a Claude Channel permission. Routes the
77
+ # voice path through the same approval_status gate the web path uses
78
+ # (Comments::ApprovalActions). Without this the caller could decide a
79
+ # prompt they merely own the authoring agent for but are not the
80
+ # designated approver of (authorized_comment? lets agent-owned events
81
+ # through). Returns a symbol: :ok | :unauthorized | :already_decided.
82
+ def decide_permission(comment, behavior)
83
+ return :unauthorized unless comment.can_be_approved_by?(current_user)
84
+
85
+ comment.decide_claude_channel_permission!(behavior, by: current_user)
86
+ comment.broadcast_claude_channel_permission_decision(behavior)
87
+ :ok
88
+ rescue Collavre::Comment::ClaudeChannelPermission::AlreadyDecided
89
+ :already_decided
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # FCM registration for the Android companion. Push is a follow-up (MVP
8
+ # uses foreground polling), but registering the device now keeps the
9
+ # token current so push can light up without a client change.
10
+ class DevicesController < BaseController
11
+ def create
12
+ device = Collavre::Device.find_by(fcm_token: device_params[:fcm_token]) ||
13
+ current_user.devices.find_or_initialize_by(client_id: device_params[:client_id])
14
+
15
+ device.assign_attributes(device_params)
16
+ device.user = current_user
17
+ device.device_type = :android if device.device_type.blank?
18
+ device.save!
19
+ head :no_content
20
+ end
21
+
22
+ private
23
+
24
+ def device_params
25
+ params.require(:device).permit(:client_id, :device_type, :app_id, :app_version, :fcm_token)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # A plain mic press (no message selected) routes here: the utterance
8
+ # starts a new piece of work in the user's Inbox#Main, exactly like
9
+ # typing in the inbox chat — dispatch then matches an agent via
10
+ # routing_expression. Replies to a SPECIFIC message go through
11
+ # agent_events#respond instead (they carry the target event id).
12
+ class VoiceCommandsController < BaseController
13
+ def create
14
+ text = params[:text].to_s.strip
15
+ return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
16
+
17
+ inbox = Collavre::Creative.inbox_for(current_user)
18
+ comment = inbox.comments.create!(content: text, user: current_user)
19
+ render_speak(:sent, action: { type: "message_sent", comment_id: comment.id })
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -67,7 +67,9 @@ module Collavre
67
67
  allowed_creative_ids: @allowed_creative_ids,
68
68
  progress_map: @progress_map
69
69
  )
70
- render json: { creatives: @creatives_tree_json }
70
+ payload = { creatives: @creatives_tree_json }
71
+ payload[:pagination] = index_result.pagination if index_result.pagination
72
+ render json: payload
71
73
  end
72
74
  end
73
75
  end
@@ -155,6 +157,10 @@ module Collavre
155
157
  render json: {
156
158
  id: @creative.id,
157
159
  description: @creative.effective_description,
160
+ # Embedded variant for read-only display (e.g. slide view): turns
161
+ # bare YouTube links into preview iframes. `description` stays the
162
+ # raw editable form the inline editor round-trips.
163
+ description_embedded_html: view_context.embed_youtube_iframe(@creative.effective_description),
158
164
  description_raw_html: @creative.description,
159
165
  origin_id: @creative.origin_id,
160
166
  parent_id: @creative.parent_id,
@@ -165,6 +171,7 @@ module Collavre
165
171
  has_children: children_count > 0,
166
172
  data: sanitized_data,
167
173
  content_type: effective.data&.dig("content_type"),
174
+ markdown_editor: effective.data&.dig("editor"),
168
175
  markdown_source: can_edit ? effective.data&.dig("markdown_source") : nil,
169
176
  trigger_loop: trigger_loop_data,
170
177
  is_trigger_task: parent_trigger_enabled,
@@ -210,6 +217,7 @@ module Collavre
210
217
  render json: {
211
218
  id: @creative.id,
212
219
  content_type: @creative.data&.dig("content_type"),
220
+ markdown_editor: @creative.data&.dig("editor"),
213
221
  markdown_source: @creative.data&.dig("markdown_source")
214
222
  }
215
223
  else
@@ -281,7 +289,8 @@ module Collavre
281
289
  progress: base.progress,
282
290
  progress_html: view_context.render_creative_progress(base),
283
291
  has_children: base.children.exists?,
284
- content_type: base.data&.dig("content_type")
292
+ content_type: base.data&.dig("content_type"),
293
+ markdown_editor: base.data&.dig("editor")
285
294
  }
286
295
  # Expose the post-rewrite markdown source so the client can sync its
287
296
  # textarea after the server replaces inline data: URIs with blob paths.
@@ -394,9 +403,11 @@ module Collavre
394
403
  return
395
404
  end
396
405
  # Reserved markdown fields are not editable via metadata; preserve current values so a stale
397
- # YAML payload from the metadata popup can't overwrite a concurrent markdown edit.
406
+ # YAML payload from the metadata popup (or an API client that omits them) can't overwrite a
407
+ # concurrent markdown edit. "editor" must be reserved too: dropping it would erase the "rich"
408
+ # authoring flag and make a Lexical-authored creative reopen in the advanced textarea.
398
409
  current_data = creative.data || {}
399
- %w[markdown_source content_type].each do |key|
410
+ %w[markdown_source content_type editor].each do |key|
400
411
  if current_data.key?(key)
401
412
  new_data[key] = current_data[key]
402
413
  else
@@ -543,7 +554,7 @@ module Collavre
543
554
  end
544
555
 
545
556
  def creative_params
546
- params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input)
557
+ params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input, :markdown_editor)
547
558
  end
548
559
 
549
560
  def any_filter_active?
@@ -14,12 +14,21 @@ module Collavre
14
14
  return head :unprocessable_entity
15
15
  end
16
16
 
17
- was_delegated = task.status == "delegated"
17
+ # These statuses count against the topic slot (occupying_topic_slot) yet no
18
+ # live worker will run AiAgentJob's ensure-block drain for them:
19
+ # - delegated / pending_approval already returned from the job holding the
20
+ # slot (should_release = false) — awaiting an MCP reply / approval.
21
+ # - pending may be a waiter that dequeue_next_for_topic promoted
22
+ # queued -> pending before its job starts; once cancelled, that job
23
+ # early-returns at the top of #perform and never reaches the ensure drain.
24
+ # So free the agent slot and drain the topic queue here — otherwise
25
+ # cancelling the blocker leaves agent capacity and the next waiter stuck
26
+ # until stuck recovery. release!/dequeue are idempotent (dequeue is bounded
27
+ # by topic_at_capacity?), so a racing live worker that also drains is harmless.
28
+ held_slot_without_worker = %w[pending delegated pending_approval].include?(task.status)
18
29
  task.update!(status: "cancelled")
19
30
 
20
- # Delegated tasks have no active worker to run the ensure-block release,
21
- # so free the agent slot and drain the topic queue here.
22
- if was_delegated && task.agent
31
+ if held_slot_without_worker && task.agent
23
32
  Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
24
33
  Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
25
34
  end
@@ -128,6 +128,7 @@ module Collavre
128
128
 
129
129
  def move
130
130
  topic = @creative.topics.find(params[:id])
131
+ source_creative = @creative
131
132
  target_creative = Creative.find(params[:target_creative_id]).effective_origin
132
133
 
133
134
  unless target_creative.has_permission?(Current.user, :write) || target_creative.user == Current.user
@@ -147,7 +148,13 @@ module Collavre
147
148
  broadcast_topic_event("deleted", topic_id: topic.id)
148
149
  broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
149
150
 
150
- render json: { success: true, topic: topic.slice(:id, :name), target_creative_id: target_creative.id }
151
+ render json: {
152
+ success: true,
153
+ topic: topic.slice(:id, :name),
154
+ target_creative_id: target_creative.id,
155
+ target_creative_name: target_creative.creative_snippet,
156
+ missing_members: missing_members_for_move(source_creative, target_creative)
157
+ }
151
158
  end
152
159
 
153
160
  def archive
@@ -204,6 +211,47 @@ module Collavre
204
211
  @creative = Creative.find(params[:creative_id]).effective_origin
205
212
  end
206
213
 
214
+ # When a topic moves to a different creative, members who had access on the
215
+ # source creative may lose visibility on the target. Returns the human
216
+ # members who are effectively present on the source (owner + feedback-or-
217
+ # higher shares) but have no resolvable access on the target, so the client
218
+ # can offer to re-add them with the same permission. Only returned when the
219
+ # current user can actually grant shares on the target (admin/owner);
220
+ # otherwise the suggestion would be unusable.
221
+ def missing_members_for_move(source_creative, target_creative)
222
+ return [] unless target_creative.has_permission?(Current.user, :admin) || target_creative.user == Current.user
223
+
224
+ # Any user with a resolvable share on the target chain (including inherited
225
+ # shares and explicit no_access blocks) already has — or is intentionally
226
+ # denied — access, so they are never "missing".
227
+ excluded_user_ids = target_creative.all_shared_users(:no_access).map(&:user_id).compact.to_set
228
+ excluded_user_ids << target_creative.user_id
229
+ excluded_user_ids << Current.user&.id
230
+
231
+ # Candidate => grant permission. Source owner had full control, so we
232
+ # suggest admin; shared members keep their own (closest) permission.
233
+ candidates = {}
234
+ owner = source_creative.user
235
+ if owner && !owner.ai_user? && excluded_user_ids.exclude?(owner.id)
236
+ candidates[owner.id] = { user: owner, permission: "admin" }
237
+ end
238
+
239
+ source_creative.all_shared_users(:feedback).each do |share|
240
+ user = share.user
241
+ next if user.nil? || user.ai_user?
242
+ next if excluded_user_ids.include?(user.id)
243
+
244
+ candidates[user.id] ||= { user: user, permission: share.permission }
245
+ end
246
+
247
+ candidates.values.map do |candidate|
248
+ {
249
+ user: view_context.user_json(candidate[:user], email: true),
250
+ permission: candidate[:permission]
251
+ }
252
+ end
253
+ end
254
+
207
255
  def topic_params
208
256
  params.require(:topic).permit(:name)
209
257
  end
@@ -0,0 +1,39 @@
1
+ module Collavre
2
+ # Session-authenticated endpoint backing the inline typo-correction UI. The
3
+ # frontend also gates by device/location, but we re-check here (fail closed)
4
+ # before spending an LLM call, and echo the user's auto-apply threshold so the
5
+ # client never has to hardcode it.
6
+ class TypoCorrectionsController < ApplicationController
7
+ # The client debounce is only a hint — a scripted/compromised page can loop
8
+ # this endpoint with arbitrary text, and every accepted call spends an LLM
9
+ # request. Bound both the call rate and the per-call size server-side. The
10
+ # chat composer is short text; anything larger is not a typo-correction case.
11
+ MAX_TEXT_LENGTH = 4_000
12
+ RATE_LIMIT = 120 # requests
13
+ RATE_PERIOD = 1.minute # ...per user per minute
14
+
15
+ def create
16
+ user = Current.user
17
+ device = params[:device].to_s
18
+ location = params[:location].presence || "chat"
19
+ text = params[:text].to_s
20
+
21
+ unless user.typo_correction_active_for?(device: device, location: location)
22
+ return render json: { edits: [], threshold: user.typo_correction_threshold }
23
+ end
24
+
25
+ # Oversized input: skip the LLM entirely (return no edits, not an error —
26
+ # this is a background affordance, not a user-facing failure).
27
+ if text.length > MAX_TEXT_LENGTH
28
+ return render json: { edits: [], threshold: user.typo_correction_threshold }
29
+ end
30
+
31
+ # Raises RateLimitExceeded -> 429 (DynamicRateLimiting), which the client
32
+ # treats as "no suggestions this round".
33
+ check_rate_limit!(key: "typo_correction:#{user.id}", limit: RATE_LIMIT, period: RATE_PERIOD)
34
+
35
+ edits = TypoCorrector.new.correct(text)
36
+ render json: { edits: edits, threshold: user.typo_correction_threshold }
37
+ end
38
+ end
39
+ end
@@ -58,6 +58,14 @@ module Collavre
58
58
  end
59
59
  end
60
60
 
61
+ def typo_correction
62
+ @user = Collavre::User.find(params[:id])
63
+
64
+ unless @user == Current.user || Current.user.system_admin?
65
+ redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
66
+ end
67
+ end
68
+
61
69
  def update_password
62
70
  @user = Collavre::User.find(params[:id])
63
71
  if @user.authenticate(params[:user][:current_password])
@@ -89,7 +97,14 @@ module Collavre
89
97
  :notifications_enabled,
90
98
  :calendar_id,
91
99
  :timezone,
92
- :locale
100
+ :locale,
101
+ :typo_correction_enabled,
102
+ :typo_correction_threshold,
103
+ :typo_correction_on_soft_keyboard,
104
+ :typo_correction_on_voice,
105
+ :typo_correction_on_physical_keyboard,
106
+ :typo_correction_in_chat,
107
+ :typo_correction_in_editor
93
108
  ).tap do |p|
94
109
  p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
95
110
  end
@@ -37,7 +37,34 @@ module Collavre
37
37
  Collavre::Contact.ensure(user: invitation.inviter, contact_user: @user)
38
38
  invitation.creative.create_linked_creative_for_user(@user)
39
39
  end
40
- Collavre::EmailVerificationMailer.verify(@user).deliver_later
40
+ # Deployments with no outbound mail (e.g. the local desktop app, which
41
+ # has no SMTP/SES) would otherwise leave the first user unable to verify
42
+ # and permanently locked out of login. Such envs opt into auto-verify.
43
+ # NB: compare to `true` explicitly — an unset config.x.* key reads back
44
+ # as a truthy empty OrderedOptions, so a bare `if` would fire everywhere.
45
+ if Rails.application.config.x.auto_verify_email == true
46
+ @user.update_column(:email_verified_at, Time.current)
47
+ else
48
+ Collavre::EmailVerificationMailer.verify(@user).deliver_later
49
+ end
50
+ # Envs that ship no seeded admin (e.g. desktop) bootstrap one here: the
51
+ # first registered user becomes system_admin. Guarded by "no admin yet"
52
+ # so only the very first signup is elevated, and by a loopback origin so
53
+ # that in documented open mode (LAN/Tailscale binding) a remote client
54
+ # cannot race the local owner for the admin account. `== true` for the
55
+ # same OrderedOptions reason as above.
56
+ if Rails.application.config.x.bootstrap_first_admin == true && request_from_loopback?
57
+ # Promote atomically rather than exists?-then-update: a separate check
58
+ # and write is a TOCTOU race where two concurrent first signups both
59
+ # read "no admin yet" and both elevate. A single UPDATE ... WHERE NOT
60
+ # EXISTS lets the DB evaluate the guard and write under one lock, so
61
+ # exactly one wins. This path only runs on desktop (the only env that
62
+ # sets bootstrap_first_admin), which is always SQLite — and SQLite
63
+ # serializes writers, so the loser's re-evaluated NOT EXISTS sees the
64
+ # admin and updates nothing.
65
+ admin_exists = Collavre::User.where(system_admin: true).arel.exists
66
+ Collavre::User.where(id: @user.id).where(admin_exists.not).update_all(system_admin: true)
67
+ end
41
68
  session.delete(:return_to_after_authenticating)
42
69
  redirect_to new_session_path, notice: I18n.t("collavre.users.new.success_sign_up")
43
70
  else
@@ -56,6 +83,19 @@ module Collavre
56
83
 
57
84
  private
58
85
 
86
+ # Whether the request originates from the local machine. Uses remote_addr
87
+ # (the socket peer) rather than remote_ip: remote_ip trusts X-Forwarded-For
88
+ # from private-range proxies, so a LAN attacker at e.g. 192.168.x could spoof
89
+ # `XFF: 127.0.0.1` and forge a loopback origin. The socket peer cannot be
90
+ # spoofed by headers.
91
+ def request_from_loopback?
92
+ addr = IPAddr.new(request.remote_addr.to_s)
93
+ addr = addr.native if addr.respond_to?(:ipv4_mapped?) && addr.ipv4_mapped?
94
+ addr.loopback?
95
+ rescue IPAddr::InvalidAddressError
96
+ false
97
+ end
98
+
59
99
  def user_params
60
100
  params.require(:user).permit(:email, :password, :password_confirmation, :name)
61
101
  end
@@ -14,6 +14,7 @@ module Collavre
14
14
  collavre/org_chart
15
15
  collavre/popup
16
16
  collavre/comments_popup
17
+ collavre/tables
17
18
  collavre/code_highlight
18
19
  collavre/comment_versions
19
20
  collavre/mention_menu
@@ -5,6 +5,7 @@
5
5
  import "./modules/creatives"
6
6
  import "./modules/creative_row_swipe"
7
7
  import "./modules/mention_menu"
8
+ import "./modules/typo_corrector"
8
9
  import "./modules/command_menu"
9
10
  import "./modules/modal_dialog"
10
11
  import "./modules/export_to_markdown"
@@ -19,6 +20,7 @@ import "./components/creative_tree_row"
19
20
  // Import and re-export lib utilities
20
21
  import "./lib/apply_lexical_styles"
21
22
  import "./lib/turbo_stream_actions"
23
+ import "./lib/turbo_confirm"
22
24
 
23
25
  // Export controller registration
24
26
  export { registerControllers } from "./controllers"
@@ -51,9 +51,15 @@ export default function ImageResizer({ src, altText, width, height, nodeKey }) {
51
51
  editor.registerCommand(
52
52
  CLICK_COMMAND,
53
53
  (event) => {
54
- // If click is outside our image, deselect
54
+ // If click is outside our image, deselect — but only via clearSelection.
55
+ // setSelected(false) is destructive here: when the click lands in text
56
+ // (a RangeSelection / the caret), the hook synthesizes a fresh empty
57
+ // NodeSelection and replaces the caret with it, so the editor loses its
58
+ // caret and becomes uneditable the instant you click to edit while an
59
+ // image is present. clearSelection only acts on an existing
60
+ // NodeSelection, so a RangeSelection (caret) is preserved.
55
61
  if (containerRef.current && !containerRef.current.contains(event.target)) {
56
- setSelected(false)
62
+ clearSelection()
57
63
  }
58
64
  return false
59
65
  },
@@ -90,7 +96,7 @@ export default function ImageResizer({ src, altText, width, height, nodeKey }) {
90
96
  COMMAND_PRIORITY_LOW
91
97
  )
92
98
  )
93
- }, [editor, isSelected, nodeKey, setSelected])
99
+ }, [editor, isSelected, nodeKey, clearSelection])
94
100
 
95
101
  // Resize logic
96
102
  const onPointerDown = useCallback(