chats 0.1.1
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 +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- metadata +204 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
es:
|
|
2
|
+
chats:
|
|
3
|
+
inbox:
|
|
4
|
+
title: "Mensajes"
|
|
5
|
+
search_placeholder: "Buscar conversaciones"
|
|
6
|
+
empty_title: "Aún no tienes conversaciones"
|
|
7
|
+
empty_hint: "Cuando empieces una conversación, aparecerá aquí."
|
|
8
|
+
no_results_title: "Sin resultados"
|
|
9
|
+
no_results_hint: "No hay nada que coincida con «%{query}»."
|
|
10
|
+
no_messages: "Sin mensajes todavía"
|
|
11
|
+
you_prefix: "Tú:"
|
|
12
|
+
thread:
|
|
13
|
+
back: "Atrás"
|
|
14
|
+
options: "Opciones de la conversación"
|
|
15
|
+
mute: "Silenciar"
|
|
16
|
+
unmute: "Activar notificaciones"
|
|
17
|
+
leave: "Salir del grupo"
|
|
18
|
+
loading: "Cargando…"
|
|
19
|
+
sent: "Enviado"
|
|
20
|
+
seen: "Visto"
|
|
21
|
+
today: "Hoy"
|
|
22
|
+
yesterday: "Ayer"
|
|
23
|
+
typing_suffix: "está escribiendo…"
|
|
24
|
+
new_messages: "Mensajes nuevos"
|
|
25
|
+
conversation:
|
|
26
|
+
empty_title: "Conversación"
|
|
27
|
+
message:
|
|
28
|
+
deleted: "Mensaje eliminado"
|
|
29
|
+
edited: "editado"
|
|
30
|
+
copy: "Copiar"
|
|
31
|
+
copied: "¡Copiado!"
|
|
32
|
+
attachment: "Foto"
|
|
33
|
+
close_attachment: "Cerrar foto"
|
|
34
|
+
edit: "Editar"
|
|
35
|
+
delete: "Eliminar"
|
|
36
|
+
delete_confirm: "¿Eliminar este mensaje para todos?"
|
|
37
|
+
cancel: "Cancelar"
|
|
38
|
+
save: "Guardar"
|
|
39
|
+
react: "Reaccionar"
|
|
40
|
+
toggle_reaction: "Reaccionar con %{emoji}"
|
|
41
|
+
composer:
|
|
42
|
+
editing: "Editar mensaje"
|
|
43
|
+
cancel_edit: "Cancelar edición"
|
|
44
|
+
placeholder: "Escribe un mensaje…"
|
|
45
|
+
send: "Enviar"
|
|
46
|
+
attach: "Adjuntar imágenes"
|
|
47
|
+
buttons:
|
|
48
|
+
chat: "Mensaje"
|
|
49
|
+
flashes:
|
|
50
|
+
blocked: "No puedes enviar mensajes a esta persona."
|
|
51
|
+
not_allowed: "No puedes iniciar esta conversación."
|
|
52
|
+
left: "Has salido de «%{title}»."
|
|
53
|
+
muted: "Conversación silenciada."
|
|
54
|
+
unmuted: "Notificaciones activadas."
|
|
55
|
+
|
|
56
|
+
activerecord:
|
|
57
|
+
errors:
|
|
58
|
+
models:
|
|
59
|
+
chats/conversation:
|
|
60
|
+
attributes:
|
|
61
|
+
kind:
|
|
62
|
+
groups_disabled: "las conversaciones de grupo están desactivadas"
|
|
63
|
+
chats/participant:
|
|
64
|
+
attributes:
|
|
65
|
+
base:
|
|
66
|
+
group_full: "este grupo está lleno (máximo %{count} miembros)"
|
|
67
|
+
chats/message:
|
|
68
|
+
attributes:
|
|
69
|
+
base:
|
|
70
|
+
blocked: "No puedes enviar mensajes a esta persona."
|
|
71
|
+
sender:
|
|
72
|
+
blank: "es obligatorio"
|
|
73
|
+
not_a_participant: "no participa en esta conversación"
|
|
74
|
+
body:
|
|
75
|
+
blank: "no puede estar vacío"
|
|
76
|
+
too_long: "es demasiado largo (máximo %{count} caracteres)"
|
|
77
|
+
files:
|
|
78
|
+
not_allowed: "los adjuntos están desactivados"
|
|
79
|
+
too_many: "demasiados adjuntos (máximo %{count})"
|
|
80
|
+
must_be_images: "solo se permiten imágenes"
|
|
81
|
+
too_big: "los archivos deben pesar menos de %{count} MB"
|
|
82
|
+
chats/reaction:
|
|
83
|
+
attributes:
|
|
84
|
+
base:
|
|
85
|
+
reactions_disabled: "las reacciones están desactivadas"
|
|
86
|
+
reactor:
|
|
87
|
+
not_a_participant: "no participa en esta conversación"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Chats::Engine.routes.draw do
|
|
4
|
+
# `path: ""` keeps URLs short under the host's mount point: with
|
|
5
|
+
# `mount Chats::Engine => "/messages"` the inbox is /messages, a thread is
|
|
6
|
+
# /messages/:id, sending is POST /messages/:conversation_id/messages.
|
|
7
|
+
resources :conversations, path: "", only: %i[index show create] do
|
|
8
|
+
member do
|
|
9
|
+
post :read # advance the viewer's read horizon (mark as read)
|
|
10
|
+
post :typing # ephemeral "X is typing…" ping (see Chats::Broadcasts.typing)
|
|
11
|
+
post :leave # leave a group (direct threads can't be left — block instead)
|
|
12
|
+
post :mute
|
|
13
|
+
post :unmute
|
|
14
|
+
get :refresh # stale-thread catch-up after sleep/disconnect (?since=ms)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
resources :messages, only: %i[show create update destroy] do
|
|
18
|
+
# Tap-to-toggle, so `create` both adds and removes (see Reaction.toggle!).
|
|
19
|
+
resources :reactions, only: :create
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
root to: "conversations#index"
|
|
24
|
+
end
|
data/docs/PRD.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# `chats` — PRD (draft v0.2)
|
|
2
|
+
|
|
3
|
+
**A drop-in, real-time messaging engine for any Rails 8+ app.**
|
|
4
|
+
Direct messages, group chats, reactions, attachments, read receipts — Hotwire-native, polymorphic, and wired into the rest of our gem ecosystem (`moderation`/`banbanban`, `goodmail`, future `notifications`/push) so a developer gets messaging **and** its Trust & Safety + notifications story by configuring once.
|
|
5
|
+
|
|
6
|
+
> **Status: SHIPPED as v0.1.0 (2026-06-09).** This PRD is kept as the original design document; the README and the code are the source of truth now. What shipped vs. this draft:
|
|
7
|
+
>
|
|
8
|
+
> - **Built gem-first** (not CarHey-first): by build time the moderation system had already been extracted as the `moderate` gem, so the proven-shape argument for incubating in-app no longer applied. The gem was built standalone with its own dummy-app suite and integrated into CarHey in the same change (Gemfile `github: "rameerez/chats"`).
|
|
9
|
+
> - **Namespace drift**: everywhere this doc says `Moderation::*` / `moderates_content` / `filter_policy`, the real extracted API is `Moderate::*`, `has_reportable_content`, `moderates`, `config.filter`, `Moderate.blocked_ids_for`. The interop shipped duck-typed (no hard dependency, §7's resolution), via `config.blocked_messager_ids` + plain contract methods on `Chats::Message`/`Chats::Conversation`.
|
|
10
|
+
> - **No `Chats::MessageReceipt` table**: read state shipped as a per-participant horizon (`last_read_at`), which delivers unread counts + "Seen" with zero per-message writes (the Campfire model). Receipts can be added later without breaking API — see `Chats::Participant`'s doc.
|
|
11
|
+
> - **Typing indicators** shipped as a Turbo Stream custom action over the existing `Turbo::StreamsChannel` (a debounced POST + broadcast), not a bespoke `Chats::ConversationChannel` — no Action Cable identification requirements on the host.
|
|
12
|
+
> - **Noticed** remains uninstalled in CarHey; the interim notifier proc schedules a debounced goodmail job exactly as §8's caveat anticipated. The `c.notifier` seam is Noticed-ready.
|
|
13
|
+
> - Open question §12 (cardinality) resolved: per-pair-**per-subject** when `about:` is passed, plain per-pair otherwise — the host picks. CarHey threads per listing.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. Vision & positioning
|
|
18
|
+
|
|
19
|
+
A gem you add to a Rails app to get **Instagram/X-DM-class** user-to-user messaging without building it again. The happy path is one generator + one `acts_as_messager` line + a mounted engine; the result is a working, real-time, polished inbox. Power users override views, policies, and adapters.
|
|
20
|
+
|
|
21
|
+
It is **not** a chatbot/LLM framework, not a Slack-clone with workspaces, and not a support-ticketing tool. It is peer-to-peer (and group) human messaging.
|
|
22
|
+
|
|
23
|
+
**Why it exists:** every consumer app eventually needs DMs, and everyone rebuilds the same Conversation/Message/Participant/Receipt model, the same Action Cable + Turbo plumbing, and the same "report this message / block this user / filter this text" surface. We already built the moderation half for CarHey; `chats` is the messaging half, and the two snap together.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 2. Design principles
|
|
28
|
+
|
|
29
|
+
1. **Hotwire-native, zero custom JS by default.** Turbo Streams for live message append/update/delete, Turbo Frames for lazy panes, Stimulus only for the few genuinely-interactive bits (composer autosize, typing throttle, scroll-to-bottom). No SPA, no build step (importmap-friendly).
|
|
30
|
+
2. **Mountable engine, `isolate_namespace Chats`.** All models/controllers/tables namespaced (`Chats::Conversation`, `chats_messages`). Host route names stay the host's choice.
|
|
31
|
+
3. **Polymorphic from day one.** A participant is not hard-typed to `User`. Any model that `acts_as_messager` can converse — `User`, `Organization`, a support `Agent`, a bot. This is the same inversion discipline the moderation gem uses (configurable class names + polymorphic targets).
|
|
32
|
+
4. **Good defaults, easy override.** Ships working controllers + views; every view is overridable by copying into the host; every policy is a config hook.
|
|
33
|
+
5. **Decoupled, adapter-driven ecosystem interop.** `chats` does not depend on CarHey. It depends on small, documented adapter interfaces for moderation, notifications, and auth — defaulting to no-ops so it runs standalone, and snapping onto `moderation`/`goodmail` when present.
|
|
34
|
+
6. **Compliance by composition.** Messages are UGC. Rather than re-implement report/block/filter, `chats` content plugs into the `moderation` gem, so adding `chats` to a compliant app keeps it compliant (DSA + Apple 1.2 + Google Play) instead of reopening those gaps.
|
|
35
|
+
7. **Delightful DX.** Consistent with the ecosystem: a `Chats.configure do |c| … end` block, `acts_as_*` macros, adapter objects, and an install generator — so `railsfast` + `goodmail` + `moderation` + `chats` feel like one coherent toolkit.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 3. Architecture & data model
|
|
40
|
+
|
|
41
|
+
### Engine
|
|
42
|
+
`Chats::Engine < ::Rails::Engine` with `isolate_namespace Chats`. Ships migrations, models, controllers, views, Stimulus controllers, Action Cable channels, and generators.
|
|
43
|
+
|
|
44
|
+
### Core models (namespaced, UUID PKs to match the ecosystem)
|
|
45
|
+
- **`Chats::Conversation`** — `kind` (`direct` | `group`), `title` (groups), `last_message_at` (denormalized for inbox ordering), optional polymorphic `subject` (so a conversation can be *about* a host record — a ride, an order, a listing — which is how CarHey attaches a chat to a trip).
|
|
46
|
+
- **`Chats::Participant`** — polymorphic `messager` (`messager_type`/`messager_id`), `conversation_id`, `role` (`member` | `admin` | `owner` for groups), `last_read_at`, `muted_at`, `left_at`. Unique on `[conversation_id, messager_type, messager_id]`.
|
|
47
|
+
- **`Chats::Message`** — `conversation_id`, polymorphic `sender` (the messager), `body` (text), `reply_to_id` (self-ref for threads/quotes), `edited_at`, `deleted_at` (soft delete / "deleted for everyone"), `metadata` jsonb. Has many attachments (ActiveStorage) and reactions.
|
|
48
|
+
- **`Chats::MessageReceipt`** — per-recipient delivery/read state (`message_id`, `participant_id`, `delivered_at`, `read_at`). Powers read receipts + unread counts. (For large groups, receipts are opt-in / aggregated — see §6.)
|
|
49
|
+
- **`Chats::Reaction`** — `message_id`, polymorphic `reactor`, `emoji`. Unique on `[message_id, reactor, emoji]`.
|
|
50
|
+
- Attachments via **ActiveStorage** `has_many_attached :files` on `Message` (configurable: disabled, images-only, any).
|
|
51
|
+
|
|
52
|
+
### Host integration: `acts_as_messager`
|
|
53
|
+
```ruby
|
|
54
|
+
class User < ApplicationRecord
|
|
55
|
+
acts_as_messager # injects has_many :chat_participations, :chat_messages, helpers
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
The macro is the single host contract on the actor side. A model can also `acts_as_chat_subject` if conversations attach to it (e.g. `Ride`).
|
|
59
|
+
|
|
60
|
+
### Tables (sketch)
|
|
61
|
+
`chats_conversations`, `chats_participants`, `chats_messages`, `chats_message_receipts`, `chats_reactions`. All UUID, all polymorphic where noted, indexed for the two hot queries: **a messager's inbox** (`participants` by messager + `conversations.last_message_at`) and **a conversation's message page** (`messages` by `conversation_id, created_at`).
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 4. Real-time (Hotwire)
|
|
66
|
+
|
|
67
|
+
- **Turbo Streams over Action Cable.** `Chats::Conversation` broadcasts `append`/`replace`/`remove` to a per-conversation stream; participants subscribe via `turbo_stream_from conversation`. New message → append to the thread + update each inbox row's preview/unread badge.
|
|
68
|
+
- **`Chats::ConversationChannel`** (Action Cable) for presence + typing indicators (ephemeral, not persisted) and for authorizing the stream subscription (a non-participant must not subscribe).
|
|
69
|
+
- **Stimulus** controllers (namespaced `chats--*`): `composer` (autosize, Enter-to-send, typing-throttle ping), `scroll` (stick-to-bottom, infinite-scroll older pages via Turbo Frame pagination), `receipts` (mark-read on viewport intersection).
|
|
70
|
+
- **Read state**: when a participant views a conversation, mark messages read (debounced) → broadcast a lightweight receipt update so the sender sees "Seen".
|
|
71
|
+
- Degrades gracefully: with cable down, it's still a working request/response inbox (Turbo Drive navigation); realtime is an enhancement, not a requirement.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 5. Generators & onboarding
|
|
76
|
+
|
|
77
|
+
- `rails g chats:install` → writes `config/initializers/chats.rb`, copies migrations, mounts the engine (`mount Chats::Engine => "/chats"`), adds `acts_as_messager` guidance.
|
|
78
|
+
- `rails chats:install:migrations` → idempotent migration copy.
|
|
79
|
+
- `rails g chats:views [scope]` → eject overridable views into the host.
|
|
80
|
+
- `rails g chats:stimulus` / component generator → eject the Stimulus controllers / view components for deep customization.
|
|
81
|
+
|
|
82
|
+
One-command setup; explicit initializer; no magic.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 6. Configurable features
|
|
87
|
+
|
|
88
|
+
`Chats.configure do |c| … end`:
|
|
89
|
+
- `c.messager_class = "User"` (default) — the primary actor; polymorphic participants still allow others.
|
|
90
|
+
- Feature flags (all default sensible): `c.groups = true`, `c.attachments = :images` (`false`/`:images`/`:any`), `c.reactions = true`, `c.read_receipts = true`, `c.typing_indicators = true`, `c.editing = true`, `c.deletion = :soft`, `c.search = true`, `c.threads = false`.
|
|
91
|
+
- `c.messages_per_page = 30`, `c.max_message_length`, `c.max_group_size`.
|
|
92
|
+
- `c.read_receipts_max_group_size` — disable per-recipient receipts above N (perf).
|
|
93
|
+
- **Policies** (the authorization seam): `c.can_message = ->(from:, to:) { … }` (default: true unless blocked — see moderation), `c.can_create_group`, `c.can_join`. Defaults are permissive but trivially overridable; CarHey will scope DMs to a confirmed ride relationship initially.
|
|
94
|
+
- **Adapters** (the ecosystem seam — §7/§8): `c.moderation`, `c.notifiers`, `c.authorizer`, `c.current_messager`.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 7. Moderation interop (the critical seam)
|
|
99
|
+
|
|
100
|
+
`chats` content is UGC; it must be reportable, blockable, and filterable. Instead of re-implementing T&S, `chats` plugs into the **`moderation`** gem (the system CarHey is extracting from PR #28). The interop is via small, documented contracts so `chats` works standalone (no-op adapter) and lights up when `moderation` is present.
|
|
101
|
+
|
|
102
|
+
### 7.1 Reportable messages & conversations
|
|
103
|
+
`Chats::Message` (and `Chats::Conversation`) `include Moderation::Reportable` and implement the concern contract already proven in CarHey:
|
|
104
|
+
```ruby
|
|
105
|
+
class Chats::Message < ApplicationRecord
|
|
106
|
+
include Moderation::Reportable
|
|
107
|
+
reportable_fields :body
|
|
108
|
+
def reported_owner = sender
|
|
109
|
+
def moderation_label = "Message #{id}"
|
|
110
|
+
def moderation_snapshot_text(field) = body if field.to_s == "body"
|
|
111
|
+
def remove_reported_field!(field) = (update!(body: nil, deleted_at: Time.current); true) if field.to_s == "body"
|
|
112
|
+
def report_visible_to?(viewer, field:) = participant?(viewer) # only people in the convo can report it
|
|
113
|
+
# moderation_subject_url / return_path / admin_path take a routes object (already the concern's shape)
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
The host registers these in `Moderation.config.reportable_class_names`. **No CarHey coupling** — this is exactly the polymorphic adapter the moderation concern was designed for. A "Denunciar mensaje" affordance reuses moderation's `report_link` helper.
|
|
117
|
+
|
|
118
|
+
### 7.2 Block enforcement (blocked users can't DM)
|
|
119
|
+
`chats`' default `can_message` policy and inbox/visibility queries consult a **block adapter** that defaults to `Moderation::Block.user_ids_related_to(user)` (the bidirectional SSOT query). A blocked pair: cannot start a new conversation, cannot send into an existing one, and don't surface to each other. Wire-once: `c.moderation.blocked_ids_for = ->(user) { Moderation::Block.user_ids_related_to(user) }`. When `moderation` isn't installed, the default is "nobody blocked".
|
|
120
|
+
|
|
121
|
+
### 7.3 Content filtering with configurable modes
|
|
122
|
+
Filtering is configurable **per class/field** via a registry on the moderation gem (keyed by `"Class#field"`, ancestor-aware), with three modes:
|
|
123
|
+
- **`:off`** — no check.
|
|
124
|
+
- **`:block`** — reject at write time. Stays an ActiveModel **validator** (it legitimately stops the save).
|
|
125
|
+
- **`:flag`** — the write **succeeds**, and a pending review item is created **out-of-band**.
|
|
126
|
+
|
|
127
|
+
Wired once in `config/initializers/moderation.rb`:
|
|
128
|
+
```ruby
|
|
129
|
+
Moderation.configure do |c|
|
|
130
|
+
c.filter_policy "Chats::Message", :body, mode: :flag
|
|
131
|
+
c.filter_policy "Chats::Message", :files, mode: :flag, adapter: Chats::AttachmentReviewAdapter
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Two design rules are now proven in CarHey:
|
|
136
|
+
1. **`:flag` does not live in a validator.** Validators remain side-effect-free. `:flag` runs from `Moderation::ContentFilterable` after commit, so system-generated review rows survive only after the host row exists and can be safely consumed by human/admin or ML workers.
|
|
137
|
+
2. **The flag is a separate `Moderation::Flag` table, not `Moderation::Report`.** `Report` models a human notice/report with contact fields, message, good-faith confirmation, DSA taxonomy, decision, and appeal window. A system flag has no notifier. Current CarHey shape: polymorphic `flaggable`, optional `owner`, `field`, `source` (`text_filter`, `image_filter`, `external_classifier`, `manual`), `mode` (`flag`, `block`), `status` (`pending`, `actioned`, `dismissed`), `excerpt`, `categories`, `scores`, and `context`. A shared `pending` scope is the SSOT both a Madmin queue and future ML consumers read.
|
|
138
|
+
|
|
139
|
+
**Adapter interface** (one method, score-carrying so a wordlist and an AI classifier are interchangeable):
|
|
140
|
+
```ruby
|
|
141
|
+
adapter.classify(value) # => { allowed:, categories:, scores:, source:, metadata: }
|
|
142
|
+
```
|
|
143
|
+
The moderation core wraps adapter hashes in `Moderation::FilterResult`. Built-in wordlist behavior is CarHey's `TextFilter` (NFKD normalization, leetspeak folding, spacing/accent resistance, Spanish/English YAML blocklist), score `1.0` per hit. Optional `:openai`/`:ruby_llm` adapters can return real `0..1` scores, and should run from a background job in `:flag` mode for expensive media/ML checks. Report intake only re-runs a block-mode adapter synchronously when the adapter explicitly exposes `synchronous? == true`; external/network adapters should leave that false and rely on `Moderation::Flag` evidence instead. The existing `moderate` gem remains a possible upstream home for this adapter interface, but not a hard dependency: today it is English-oriented and too narrow for CarHey/Spanish compliance needs.
|
|
144
|
+
|
|
145
|
+
`chats` consumes all of this with the real **two-part** shape: on the model, `include Moderation::ContentFilterable` **+ `moderates_content :body`** (declares the scanned fields — drives the `validate` for `:block` and the `after_commit` for `:flag`); in the initializer, one `c.filter_policy "Chats::Message", :body, mode: :flag` line (sets the mode). A `filter_policy` line **without** `moderates_content` silently does nothing. Flagging is `Moderation::Flag.flag!` called from `ContentFilterable` (there is **no** `Moderation.flag` facade, and the standalone `ObjectionableContentValidator` is dead code — the concern owns `:block` too). For `:files` (attachments), override `moderation_field_value` / `moderation_field_changed_for_commit?` since the default `public_send(:files)` returns an ActiveStorage proxy, not classifiable content (mirror CarHey's host-supplied `ImageReviewAdapter`). The filter-mode + `Flag` machinery is a **moderation-gem change to land first** (independently useful for CarHey avatars today).
|
|
146
|
+
|
|
147
|
+
### 7.4 Compliance inheritance
|
|
148
|
+
Because messages flow through moderation, adding `chats` to a DSA/Apple/Google-compliant app **keeps it compliant only if the host enables the required policies**:
|
|
149
|
+
- `Chats::Message` and `Chats::Conversation` are registered reportable classes.
|
|
150
|
+
- Message body and attachment policies are configured (`:block`, `:flag`, or explicit `:off`).
|
|
151
|
+
- Blocking is enforced before conversation creation and before every send.
|
|
152
|
+
- Every message/conversation UI exposes report and block affordances.
|
|
153
|
+
- Admins can see `Moderation::Report`, `Moderation::Flag`, and `Moderation::Appeal` queues.
|
|
154
|
+
- Report decisions are delivered via moderation: the **DSA legal email** (receipt / decision / statement-of-reasons) goes through moderate's direct, synchronous goodmail seam (its boolean return gates the `*_notified_at` DSA timestamps — Noticed's fire-and-forget can't honor that), while **affected-user surfaces** (in-app feed + push) are layered via a host Noticed notifier. This is moderation's responsibility, not "the chats bus."
|
|
155
|
+
|
|
156
|
+
This is a launch gate for the CarHey chats PR, not optional polish. A chat surface is high-velocity UGC; shipping it without these hooks would regress App Store Guideline 1.2, Google Play UGC, and DSA notice-and-action.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 8. Notifications: integrate with Noticed (the host's adopted orchestrator)
|
|
161
|
+
|
|
162
|
+
**The host has chosen [Noticed](https://github.com/excid3/noticed) v3 as the notification orchestrator + in-app feed, and `action_push_native` for push** (see CarHey's `docs/notifications_architecture_prd.md`). `chats` does **not** build its own fan-out bus — Noticed already owns multi-subscriber fan-out, the per-recipient in-app feed (`Noticed::Notification` records), per-user preferences (`if/unless`), and the channel adapters (email/action_cable/action_push_native/custom). A second bus inside `chats` would double-wire goodmail and strand chats events away from push/feed/telegram. (Do **not** route chats through the `moderate` gem's `Moderation.notify` PORO either — that bus exists only to gate moderate's synchronous DSA legal email; it is not the cross-channel orchestrator.)
|
|
163
|
+
|
|
164
|
+
The shape:
|
|
165
|
+
- **`chats` is a Noticed event *source*.** On notification-worthy domain moments, `chats` fires a host **Noticed Notifier** through a single no-op-default `c.notifier` adapter proc. In CarHey that proc is `->(event, **p) { NewMessageNotifier.with(message: p[:message]).deliver }`. The gem takes **no hard dependency on Noticed** (the default proc is a no-op, so `chats` runs standalone).
|
|
166
|
+
- **Recipients live in the host Notifier**, not in `chats`. `chats` passes the domain object (the message/conversation); the Notifier's `recipients -> { params[:message].conversation.participants.excluding(params[:message].sender) }` computes who gets it.
|
|
167
|
+
- **The 5 channels are host-owned Noticed delivery methods**, wired once: in-app feed (Noticed core + a `TurboStream` delivery method for the live bell), the **chats DM surface** (a custom `DeliveryMethods::Chats`), **email via goodmail** (Noticed's built-in `:email` pointing at a normal `ApplicationMailer` that uses `goodmail_mail` — **goodmail needs zero changes**, and it is **not** `Goodmail::Base`), **push via `action_push_native`**, and **admin Telegram via `telegrama`** as a `bulk_deliver_by`.
|
|
168
|
+
- **The in-app feed is Noticed's, not chats'.** `chats` owns only the chat/DM surface (channel 2). The bell/feed is `current_user.notifications` (`Noticed::Notification`).
|
|
169
|
+
- **Debounced email is a Noticed config**, not a chats-owned digest job: `config.wait = 10.minutes` + `config.unless = -> { read? }` gives the classic "email only if still unread."
|
|
170
|
+
|
|
171
|
+
> **Status caveat:** Noticed + `action_push_native` are **not yet installed** in CarHey. Until they are, the interim CarHey wiring may call goodmail directly. The *target* is the Noticed integration above; `chats`'s hooks are designed so that milestone wires Noticed **once** with no gem changes. Two push gotchas to honor when it lands (from the action_push_native source): the `:action_push_native` delivery method calls `with_apple`/`with_google`/`with_data` unconditionally — **all three must be set** (at least `-> { {} }`); and `before_enqueue` is a real Noticed v3 callback that may `throw(:abort)`, while `config.if`/`config.unless` remain the clearer preference gates.
|
|
172
|
+
|
|
173
|
+
### 8.1 Chats domain moments → Noticed notifiers (not a private bus)
|
|
174
|
+
|
|
175
|
+
These are the moments at which `chats` fires a host Notifier — they are **not** events on a chats-owned bus:
|
|
176
|
+
|
|
177
|
+
| Domain moment | Host Notifier | Channels |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| message created | `NewMessageNotifier` | feed + chats DM + push + debounced email |
|
|
180
|
+
| @mention | `MentionNotifier` (optional) | feed + push + email |
|
|
181
|
+
| added to conversation | `ParticipantAddedNotifier` (optional) | feed + push |
|
|
182
|
+
|
|
183
|
+
Non-notification domain changes (edited / deleted / reacted) need **no** notification framework — handle locally, or emit `ActiveSupport::Notifications` if instrumentation is wanted. **Do not emit a `message_flagged` event** — flagging is owned by `Moderation::Flag` / `ContentFilterable`; re-emitting it would double-count. Each Notifier's payload carries the domain object + actor + a stable idempotency key (Noticed `params` + dedup).
|
|
184
|
+
|
|
185
|
+
### 8.2 `chats` is both a Noticed *source* and a Noticed *target*
|
|
186
|
+
|
|
187
|
+
These are different directions and both are true (this resolves the ambiguity flagged in the host PRD §11):
|
|
188
|
+
- **Source:** a user message fires `NewMessageNotifier` (above) → participants get bell + push + email.
|
|
189
|
+
- **Target:** other notifiers post a *system* message **into** a conversation via the custom `DeliveryMethods::Chats`, calling a stable chats API — `Chats::Conversation#post_system_message!(body:)` (a `Chats::SystemSender` participant), e.g. "Your ride was cancelled" dropped into the ride's chat. The host delivery method must match chats' real signature — messages have a **conversation + participants**, not a per-message `recipient:`, so the illustrative `Chats::Message.create!(recipient:)` in the host PRD §5.5 is wrong; chats exposes `post_system_message!` instead.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 9. Security, privacy, performance
|
|
194
|
+
|
|
195
|
+
- **Authorization everywhere**: channel subscriptions, message reads, and mutations all gate on participation + the `authorizer` adapter. A non-participant can't read or stream a conversation.
|
|
196
|
+
- **Privacy**: soft-delete semantics ("delete for me" vs "delete for everyone"); optional message encryption-at-rest (ActiveRecord Encryption) behind a flag; PII-aware (don't leak presence to blocked users).
|
|
197
|
+
- **Abuse/rate limits**: per-sender send rate limit (Rails 8 `rate_limit`); attachment type/size limits; optional attachment scanning hook.
|
|
198
|
+
- **Compliance rate limits**: reporting/appeal forms use anti-abuse checks owned by moderation; message sends have separate high-throughput per-sender limits so attackers cannot create unlimited UGC before moderation catches up.
|
|
199
|
+
- **Scale**: denormalized `last_message_at`, receipt opt-out for large groups, cursor pagination, `find_each`-friendly broadcasts, counter-cached unread.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 10. Cross-gem DX conventions (the ecosystem)
|
|
204
|
+
|
|
205
|
+
To make this "the best Ruby gem ecosystem," `chats` follows and reinforces shared conventions with `railsfast`, `goodmail`, `moderation`, `organizations`, `pricing_plans`:
|
|
206
|
+
- **One `X.configure do |c| … end` block per gem**, required keys validated at boot.
|
|
207
|
+
- **`acts_as_*` host macros** for model integration (`acts_as_messager`, `acts_as_reportable`/`Moderation::Reportable`, etc.).
|
|
208
|
+
- **Adapter objects + procs** for cross-gem seams (notifier, moderation, audit), defaulting to no-ops so each gem runs standalone.
|
|
209
|
+
- **Configurable class-name strings + polymorphic targets** (never hard constants) so no gem depends on a host's `User`.
|
|
210
|
+
- **Install generators** writing a templated initializer + idempotent migrations + engine mount.
|
|
211
|
+
- **Goodmail for all transactional mail**; **moderation for all UGC T&S**; **a shared event/notifier contract** so notifications fan out uniformly.
|
|
212
|
+
|
|
213
|
+
Target experience: `bundle add chats moderation goodmail`, run three installers, add `acts_as_messager` + `include Moderation::Reportable`, set a couple of adapter procs in initializers — and you have real-time DMs with reporting, blocking, filtering, email + push notifications, and DSA/store compliance, with every view overridable.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 11. Build plan
|
|
218
|
+
|
|
219
|
+
**Phase 0 — in CarHey (validate the shape against a real app):**
|
|
220
|
+
1. Replace the interim coordination model (the `Ride::JoinRequest#message` free-text + the `/messages` "Próximamente" placeholder) with real `Chats` conversations attached to a ride (`acts_as_chat_subject` on `Ride`).
|
|
221
|
+
2. `Chats::Message include Moderation::Reportable`; register it in `Moderation.config.reportable_class_names`; reuse `report_link` + the block enforcement.
|
|
222
|
+
3. Wire `Chats.config.notifier` → goodmail (`ChatsGoodmailer`) and `Chats.config.moderation` → the moderation adapters.
|
|
223
|
+
4. Scope `can_message` to confirmed ride relationships (CarHey policy); prove the polymorphic/adapter seams under a real workload + tests.
|
|
224
|
+
5. Launch gate: no chat PR merges unless report/block/filter/flag/appeal paths are green for `Chats::Message`, attachments, and conversations.
|
|
225
|
+
|
|
226
|
+
**Phase 1 — extract to the gem:** `bundle gem chats` (engine), move the namespaced models/controllers/views/channels/generators, add `messager_class` string + `constantize`, ship default `en` locale + overridable views, write the install generator, and replace CarHey's direct usage with the gem.
|
|
227
|
+
|
|
228
|
+
**Phase 2 — ecosystem polish:** extract the shared event dispatcher if moderation + chats both prove the same API; ship optional `goodmail`, `moderation`, `noticed`, and push adapters; document one canonical RailsFast setup.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 11.1 Acceptance criteria for CarHey v1
|
|
233
|
+
|
|
234
|
+
- A driver/passenger with an accepted ride relationship can open a conversation from the ride screen.
|
|
235
|
+
- New messages appear in real time through Turbo Streams and still work without WebSockets.
|
|
236
|
+
- A participant can report a message, block the sender, and stop receiving future messages from that sender.
|
|
237
|
+
- `Chats::Message#body` goes through moderation filter policy; attachments create `Moderation::Flag` rows in `:flag` mode.
|
|
238
|
+
- Madmin shows reports, flags, and appeals for chat content without CarHey-specific case statements.
|
|
239
|
+
- Goodmail sends a debounced unread-message digest through the shared notifier array.
|
|
240
|
+
- Full CarHey test suite covers send/read/receipt/report/block/filter/appeal happy paths and authz negatives.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 12. Open questions
|
|
245
|
+
- Conversation ↔ subject cardinality (one chat per ride? per pair-per-ride?).
|
|
246
|
+
- Group membership model for very large groups (receipts, fan-out) — start capped.
|
|
247
|
+
- E2E encryption: out of scope v1 (at-rest only); revisit.
|
|
248
|
+
- Search backend: start with Postgres `ILIKE`/trigram; pluggable adapter for later.
|
|
249
|
+
- Exact dependency direction: does `chats` depend on `moderation` directly, or only on a `moderation`-shaped adapter interface? → **adapter interface** (keeps `chats` usable without `moderation`), with a first-class `moderation` adapter shipped.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## 13. Non-goals (v1)
|
|
254
|
+
Bots/LLM agents, workspaces/tenancy, voice/video, message scheduling, public channels/communities, federation. Deferred, not designed against.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Campfire review — what we adopted, what we skipped, and why
|
|
2
|
+
|
|
3
|
+
A deep read of Basecamp's open-source Campfire
|
|
4
|
+
([basecamp/once-campfire](https://github.com/basecamp/once-campfire), DHH's
|
|
5
|
+
production chat app) against this gem, done while the gem was still
|
|
6
|
+
unreleased and fully malleable. Every pattern below got a deliberate
|
|
7
|
+
verdict; "skip" always carries the reason, so future-us can re-litigate
|
|
8
|
+
with the same facts.
|
|
9
|
+
|
|
10
|
+
## Adopted
|
|
11
|
+
|
|
12
|
+
| Campfire pattern | Where it landed here | Notes |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| **Stale-room refresh** (`Rooms::RefreshesController` + `refresh_room_controller.js`): on tab-visible-after-sleep or cable reconnect, fetch `?since=` and append new / replace updated | `ConversationsController#refresh`, `Message.created_since/.updated_since`, thread controller `refreshThread()` | The single biggest reliability pattern in their codebase — mobile WebViews reap WebSockets constantly. We improved the trigger: instead of their dedicated `HeartbeatChannel`, we observe the `connected` attribute turbo-rails already toggles on `<turbo-cable-stream-source>`. Zero new channels. We also added a deep-backlog escape hatch: > 1 page missed answers with a Turbo 8 `refresh` stream action (full morph) instead of splicing arbitrary history. |
|
|
15
|
+
| **DOM cap** (`message_paginator.js` `maxMessages: 300`) | Thread controller `trimExcessMessages()` (300 + 20 leeway) | Only trims while the viewer is parked at the bottom. Our pagination is a server-rendered lazy-frame chain (theirs is JS-driven), so trimming also re-plants the keyset anchor frame (`rebuildPaginationAnchor()`) — trimmed history stays reachable on scroll-up with no gaps. |
|
|
16
|
+
| **Out-of-order arrival handling** (their `messages_controller.js` re-sorts on insert) | Thread controller `ensureChronological()` | Broadcast appends from concurrent host job workers can land out of order. ISO8601 lexicographic compare; equal timestamps keep arrival order. |
|
|
17
|
+
| **First-unread anchoring** (they page around the first unread; membership unread marker) | The «new messages» divider: `@first_unread_id` computed in `#show` *before* `read!` advances the horizon | We render a divider rather than re-anchoring the page — the thread still opens at the bottom (coordination chats are short; jumping deep into history on open would feel broken at our scale). Backlogs deeper than a page pin the divider to the top of the page. |
|
|
18
|
+
| **Read state as a side-channel that other surfaces consume** (their unread membership drives sidebar + Web Push badge) | `:conversation_read` notifier event from `Participant#read!` | Hosts use it to keep external notification centers truthful (mark a chat's bell rows read the instant the thread is read). Fired only when the read actually consumed unread content. |
|
|
19
|
+
|
|
20
|
+
## Already had (independently converged, kept ours)
|
|
21
|
+
|
|
22
|
+
| Pattern | Theirs | Ours |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Unread without a receipts table | `membership.unread_at` flag, set on disconnect | Per-participant `last_read_at` **horizon** — strictly more expressive (drives unread counts *and* "Seen" receipts from one column) |
|
|
25
|
+
| Direct-conversation identity | `Rooms::Direct.find_or_create_for(users)` set-comparison (their own FIXME flags it as O(rooms) slow) | Deterministic `direct_key` + unique index + `create_or_find_by!` — race-safe at the DB, O(1) lookup |
|
|
26
|
+
| Client-side own/other + day separators + message grouping | `message_formatter.js` (`--me`, threading window, first-of-day) | `chats--thread` classify + separators + grouping — same philosophy (broadcast once, personalize client-side), already shipped |
|
|
27
|
+
| Typing TTL + stop-on-message | `typing_tracker.js` 5s TTL | Same shape (stale cutoff + `hideTypingFor` on message arrival), ours rides a Turbo Stream custom action instead of a bespoke channel |
|
|
28
|
+
| Keyset pagination | `created_at` cursors (`page_before/page_after`) | `(created_at, id)` compound cursor — same idea, tiebreaker included |
|
|
29
|
+
| Composer file UX | picked-file previews via `URL.createObjectURL` | Same, shipped in the composer controller |
|
|
30
|
+
| Search-input hygiene | strips non-word chars before FTS | `sanitize_sql_like` on the inbox LIKE search |
|
|
31
|
+
|
|
32
|
+
## Considered and skipped (with reasons)
|
|
33
|
+
|
|
34
|
+
| Pattern | Why skipped |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| **Involvement enum** (`invisible/nothing/mentions/everything` per membership) | The mentions level only earns its keep with @mention support, which we don't have (plain-text coordination chats). Without mentions the enum collapses to exactly our `muted_at`. Revisit alongside mentions, together. |
|
|
37
|
+
| **Optimistic client messages** (client-generated id, pending template, server echo reconciliation) | Real complexity (failure rollback for rate-limits/validation, template duplication) for latency our sync form-submit→stream path already keeps low. Our dedup-by-`dom_id` append already absorbs the double-delivery half of the problem. Revisit if send latency ever feels bad on real networks. |
|
|
38
|
+
| **Presentation-div edit broadcasts** (replace `[message, :presentation]`, not the whole bubble) | Our Stimulus target callbacks re-normalize a replaced bubble anyway (classify/receipts/grouping re-run), so the only residual win is not closing an open popover during someone *else's* edit — marginal vs. restructuring the partial in the gem **and** every host's ejected copy. |
|
|
39
|
+
| **Web Push w/ VAPID + service worker + thread pool** | Hosts own push (this gem is host-agnostic; our reference host pushes through Noticed + action_push_native). Their `WebPush::Pool` is the right reference if a host ever needs self-hosted web push. |
|
|
40
|
+
| **ActionText/Trix rich text + OpenGraph unfurls + mentions-as-attachments** | Deliberate scope line: plain-text coordination chat. ActionText would drag in Trix, sanitization surface, and attachment semantics that fight our moderation contract (flag/snapshot plain columns). |
|
|
41
|
+
| **FTS message search** (SQLite FTS5 virtual table) | DB-specific (we're adapter-agnostic), and message-content search cuts against the privacy posture our reference host wants. Inbox search (participants/title/subject) covers the recall need. |
|
|
42
|
+
| **Bots + webhooks** (bot users, webhook reply parsing) | No host demand yet. The seam already exists (`post_system_message!` + the messager abstraction); a bot is just a messager. |
|
|
43
|
+
| **Room types as STI** (`Rooms::Open/Closed/Direct`) | Our `kind` enum (direct/group) + policy procs cover the same ground without STI's autoload/migration sharp edges in an engine. "Open" rooms (auto-grant to everyone) are an account-wide chat concept, not a host-policy one. |
|
|
44
|
+
| **Connection-TTL presence** (`connected_at`, 60s TTL, gate push on disconnected) | Needs a presence channel we deliberately don't have. Hosts get the same outcome cheaper at the delivery layer: suppress push when the recipient's participant has already read the message by delivery time (see the reference host's `push_suppressed_for?`), plus `:conversation_read` keeps badges truthful. |
|
|
45
|
+
| **Sounds / `/play` commands** | Not a desktop social chat. |
|
|
46
|
+
| **`maintain_scroll` stream attribute + scroll promise chain** | Our containment (stick-to-bottom + prepend restoration in the frame chain) already covers the cases we have; their generalized scroll manager solves a problem (interleaved arbitrary stream mutations) we don't generate. |
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.1.0"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "appraisal"
|
|
10
|
+
gem "web-console"
|
|
11
|
+
gem "rubocop", "~> 1.0", require: false
|
|
12
|
+
gem "rubocop-minitest", "~> 0.35", require: false
|
|
13
|
+
gem "rubocop-performance", "~> 1.0", require: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
group :test do
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "mocha", "~> 2.0"
|
|
20
|
+
gem "simplecov", require: false
|
|
21
|
+
gem "actioncable"
|
|
22
|
+
gem "actionmailer"
|
|
23
|
+
gem "activejob"
|
|
24
|
+
gem "activestorage"
|
|
25
|
+
gem "mysql2"
|
|
26
|
+
gem "pg"
|
|
27
|
+
gem "sqlite3"
|
|
28
|
+
gem "bootsnap", require: false
|
|
29
|
+
gem "importmap-rails"
|
|
30
|
+
gem "propshaft"
|
|
31
|
+
gem "puma"
|
|
32
|
+
gem "stimulus-rails"
|
|
33
|
+
gem "rdoc", ">= 7.0"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 7.2.0"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "appraisal"
|
|
10
|
+
gem "web-console"
|
|
11
|
+
gem "rubocop", "~> 1.0", require: false
|
|
12
|
+
gem "rubocop-minitest", "~> 0.35", require: false
|
|
13
|
+
gem "rubocop-performance", "~> 1.0", require: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
group :test do
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "mocha", "~> 2.0"
|
|
20
|
+
gem "simplecov", require: false
|
|
21
|
+
gem "actioncable"
|
|
22
|
+
gem "actionmailer"
|
|
23
|
+
gem "activejob"
|
|
24
|
+
gem "activestorage"
|
|
25
|
+
gem "mysql2"
|
|
26
|
+
gem "pg"
|
|
27
|
+
gem "sqlite3"
|
|
28
|
+
gem "bootsnap", require: false
|
|
29
|
+
gem "importmap-rails"
|
|
30
|
+
gem "propshaft"
|
|
31
|
+
gem "puma"
|
|
32
|
+
gem "stimulus-rails"
|
|
33
|
+
gem "rdoc", ">= 7.0"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rake", "~> 13.0"
|
|
6
|
+
gem "rails", "~> 8.1.0"
|
|
7
|
+
|
|
8
|
+
group :development do
|
|
9
|
+
gem "appraisal"
|
|
10
|
+
gem "web-console"
|
|
11
|
+
gem "rubocop", "~> 1.0", require: false
|
|
12
|
+
gem "rubocop-minitest", "~> 0.35", require: false
|
|
13
|
+
gem "rubocop-performance", "~> 1.0", require: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
group :test do
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "mocha", "~> 2.0"
|
|
20
|
+
gem "simplecov", require: false
|
|
21
|
+
gem "actioncable"
|
|
22
|
+
gem "actionmailer"
|
|
23
|
+
gem "activejob"
|
|
24
|
+
gem "activestorage"
|
|
25
|
+
gem "mysql2"
|
|
26
|
+
gem "pg"
|
|
27
|
+
gem "sqlite3"
|
|
28
|
+
gem "bootsnap", require: false
|
|
29
|
+
gem "importmap-rails"
|
|
30
|
+
gem "propshaft"
|
|
31
|
+
gem "puma"
|
|
32
|
+
gem "stimulus-rails"
|
|
33
|
+
gem "rdoc", ">= 7.0"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
gemspec path: "../"
|