diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 00000000..d6726972 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/soup_campaign_channel.rb b/app/channels/soup_campaign_channel.rb new file mode 100644 index 00000000..5036a185 --- /dev/null +++ b/app/channels/soup_campaign_channel.rb @@ -0,0 +1,120 @@ +# Realtime collaboration channel for Soup campaign editing. +# Handles Yjs CRDT sync messages and presence (who's editing, cursor positions). +# Each connected client sends a unique tab_id so we can deduplicate same-user multi-tab sessions. +class SoupCampaignChannel < ApplicationCable::Channel + PRESENCE_KEY_PREFIX = "soup_campaign_presence:" + PRESENCE_TTL = 30 # seconds + + def subscribed + campaign = SoupCampaign.find_by(id: params[:campaign_id]) + return reject unless campaign && current_user&.admin? + + @campaign = campaign + @tab_id = params[:tab_id] + + stream_from stream_name + broadcast_presence_update + end + + def unsubscribed + broadcast_presence_leave + clear_my_presence + end + + # Client sends a Yjs awareness update (cursor/selection) — relay to all peers, no persistence needed + def awareness(data) + ActionCable.server.broadcast(stream_name, { + type: "awareness", + update: data["update"], + tab_id: @tab_id + }) + end + + # Client sends an incremental Yjs update — relay to peers only (fast path) + def sync(data) + # Relay to all other subscribers immediately + ActionCable.server.broadcast(stream_name, { + type: "sync", + update: data["update"], + tab_id: @tab_id + }) + end + + # Client sends a debounced autosave with FULL Yjs state and field values + def autosave(data) + update_bytes = Base64.strict_decode64(data["update"]) + + @campaign.with_lock do + # Overwrite the entire state vector since the client sent the full document + @campaign.update_columns(yjs_state: update_bytes) + flush_fields(data["fields"]) if data["fields"] + end + end + + # Client sends presence update (cursor field, selection, etc.) + def presence(data) + store_my_presence(data) + ActionCable.server.broadcast(stream_name, { + type: "presence", + user: presence_user_payload, + tab_id: @tab_id, + data: data + }) + end + + private + + def stream_name + "soup_campaign:#{@campaign.id}" + end + + def flush_fields(fields) + permitted = %w[name body footer unsubscribe_label image_url] + updates = fields.slice(*permitted) + @campaign.update_columns(updates) if updates.any? + end + + def presence_user_payload + { + id: current_user.id, + display_name: current_user.display_name, + avatar: current_user.avatar, + tab_id: @tab_id, + color: user_color + } + end + + def user_color + # Deterministic hue from user id so the same person always gets the same color + hue = (current_user.id * 47) % 360 + "hsl(#{hue}, 70%, 55%)" + end + + def broadcast_presence_update + store_my_presence({}) + ActionCable.server.broadcast(stream_name, { + type: "presence_join", + user: presence_user_payload + }) + end + + def broadcast_presence_leave + ActionCable.server.broadcast(stream_name, { + type: "presence_leave", + tab_id: @tab_id + }) + end + + def presence_redis_key + "#{PRESENCE_KEY_PREFIX}#{@campaign.id}:#{current_user.id}:#{@tab_id}" + end + + def store_my_presence(data) + # Use Rails cache (backed by Redis in production) with TTL for auto-cleanup + Rails.cache.write(presence_redis_key, { user: presence_user_payload, data: data }, expires_in: PRESENCE_TTL) + end + + def clear_my_presence + Rails.cache.delete(presence_redis_key) + end +end diff --git a/app/controllers/admin/soup_campaigns_controller.rb b/app/controllers/admin/soup_campaigns_controller.rb new file mode 100644 index 00000000..924305db --- /dev/null +++ b/app/controllers/admin/soup_campaigns_controller.rb @@ -0,0 +1,234 @@ +class Admin::SoupCampaignsController < Admin::ApplicationController + before_action :require_admin! # Soup campaigns are admin-only + + skip_after_action :verify_authorized, only: %i[index] # index uses policy_scope; create uses skip_authorization since it redirects immediately + skip_after_action :verify_policy_scoped, only: %i[show new create update destroy send_campaign test_send cancel toggle_unsubscribe] # non-index actions use authorize + + def index + campaigns = policy_scope(SoupCampaign).recent.includes(:created_by) + + render inertia: "admin/soup_campaigns/index", props: { + campaigns: campaigns.map { |c| serialize_campaign(c) } + } + end + + def show + campaign = SoupCampaign.find(params[:id]) + authorize campaign + + if campaign.draft? + # Show projected audience (who would receive the campaign) before sending + projected_scope = User.verified.kept.where.not(slack_id: nil).order(:display_name) + pagy, projected_users = pagy(projected_scope, limit: 50, page: params[:rp], page_param: :rp) + recipients = projected_users.map do |u| + { id: u.id, slack_id: u.slack_id, display_name: u.display_name, status: "projected", + sent_at: nil, error_message: nil } + end + recipients_pagy = pagy_props(pagy) + else + pagy, recipient_records = pagy( + campaign.soup_campaign_recipients.order(created_at: :asc), + limit: 50, page: params[:rp], page_param: :rp + ) + recipients = recipient_records.map { |r| serialize_recipient(r) } + recipients_pagy = pagy_props(pagy) + end + + render inertia: "admin/soup_campaigns/show", props: { + campaign: serialize_campaign(campaign), + recipients:, + recipients_pagy:, + stats: campaign.recipient_stats, + progress: campaign.progress_percent + } + end + + def new + # Auto-create a blank draft and open the collaborative editor directly + campaign = SoupCampaign.new( + name: "Untitled campaign", + body: "", + footer: "", + unsubscribe_label: SoupCampaign::DEFAULT_UNSUBSCRIBE_LABEL, + created_by: current_user + ) + authorize campaign + + campaign.save! + redirect_to edit_admin_soup_campaign_path(campaign) + end + + def create + # Unused — new action auto-creates and redirects to edit + skip_authorization + head :not_found + end + + def update + campaign = SoupCampaign.find(params[:id]) + authorize campaign + + if campaign.update(campaign_params) + redirect_to admin_soup_campaign_path(campaign), notice: "Campaign updated." + else + render inertia: "admin/soup_campaigns/edit", props: { + campaign: serialize_campaign(campaign), + yjs_state: campaign.yjs_state.present? ? Base64.strict_encode64(campaign.yjs_state) : nil, + errors: campaign.errors.as_json + } + end + end + + def edit + campaign = SoupCampaign.find(params[:id]) + authorize campaign, :update? + + render inertia: "admin/soup_campaigns/edit", props: { + campaign: serialize_campaign(campaign), + yjs_state: campaign.yjs_state.present? ? Base64.strict_encode64(campaign.yjs_state) : nil, + current_user_presence: { + id: current_user.id, + display_name: current_user.display_name, + avatar: current_user.avatar + } + } + end + + def destroy + campaign = SoupCampaign.find(params[:id]) + authorize campaign + + campaign.destroy! + redirect_to admin_soup_campaigns_path, notice: "Campaign deleted." + end + + def send_campaign + campaign = SoupCampaign.find(params[:id]) + authorize campaign + + unless campaign.draft? + redirect_to admin_soup_campaign_path(campaign), alert: "Campaign has already been sent or is currently sending." + return + end + + SendSoupCampaignJob.perform_later(campaign.id) + redirect_to admin_soup_campaign_path(campaign), notice: "Campaign is now sending!" + end + + def test_send + campaign = SoupCampaign.find(params[:id]) + authorize campaign + + slack_id = params[:slack_id].to_s.strip + return render json: { error: "slack_id is required" }, status: :unprocessable_entity if slack_id.blank? + + unsubscribe_url = soup_campaign_unsubscribe_url( + token: "test-token", + host: ENV.fetch("APP_HOST", "fallout.hackclub.com") + ) + + client = Slack::Web::Client.new(token: ENV.fetch("SLACK_BOT_TOKEN", nil)) + client.chat_postMessage( + channel: slack_id, + text: "[TEST] #{campaign.body}", + username: "Soup", + icon_url: "https://avatars.slack-edge.com/2026-03-03/10620134255189_994e10cd91f0fc88ad9c_512.jpg", + blocks: build_test_blocks(campaign, unsubscribe_url).to_json + ) + + render json: { ok: true } + rescue Slack::Web::Api::Errors::SlackError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def cancel + campaign = SoupCampaign.find(params[:id]) + authorize campaign + + campaign.update!(status: :cancelled) + redirect_to admin_soup_campaign_path(campaign), notice: "Campaign cancelled." + end + + def toggle_unsubscribe + campaign = SoupCampaign.find(params[:id]) + authorize campaign, :update? # Admin-only; reuse update? permission + + recipient = campaign.soup_campaign_recipients.find(params[:recipient_id]) + + if recipient.unsubscribed? + # Re-subscribe: move back to sent if the campaign was already sent, otherwise pending + new_status = campaign.sent? ? :sent : :pending + recipient.update!(status: new_status) + else + recipient.update!(status: :unsubscribed) + end + + render json: { ok: true, status: recipient.status } + rescue ActiveRecord::RecordNotFound + render json: { error: "Recipient not found" }, status: :not_found + end + + private + + def campaign_params + params.expect(soup_campaign: [ :name, :body, :footer, :unsubscribe_label, :image_url ]) + end + + def build_test_blocks(campaign, unsubscribe_url) + blocks = [] + + blocks << { type: "section", text: { type: "mrkdwn", text: "[TEST] #{campaign.body}" } } + + if campaign.footer.present? + blocks << { type: "section", text: { type: "mrkdwn", text: campaign.footer } } + end + + if campaign.image_url.present? + blocks << { type: "image", image_url: campaign.image_url, alt_text: campaign.name } + end + + blocks << { type: "divider" } + + blocks << { + type: "context", + elements: [ + { type: "mrkdwn", text: "#{campaign.unsubscribe_label} · <#{unsubscribe_url}|Unsubscribe>" } + ] + } + + blocks + end + + def serialize_campaign(campaign) + { + id: campaign.id, + name: campaign.name, + body: campaign.body, + footer: campaign.footer, + unsubscribe_label: campaign.unsubscribe_label, + image_url: campaign.image_url, + status: campaign.status, + sent_at: campaign.sent_at&.iso8601, + scheduled_at: campaign.scheduled_at&.iso8601, + created_at: campaign.created_at.iso8601, + created_by: { + id: campaign.created_by_id, + display_name: campaign.created_by&.display_name, + avatar: campaign.created_by&.avatar + }, + stats: campaign.recipient_stats, + progress: campaign.progress_percent + } + end + + def serialize_recipient(recipient) + { + id: recipient.id, + slack_id: recipient.slack_id, + display_name: recipient.display_name, + status: recipient.status, + sent_at: recipient.sent_at&.iso8601, + error_message: recipient.error_message + } + end +end diff --git a/app/controllers/soup_campaign_unsubscribes_controller.rb b/app/controllers/soup_campaign_unsubscribes_controller.rb new file mode 100644 index 00000000..05bad319 --- /dev/null +++ b/app/controllers/soup_campaign_unsubscribes_controller.rb @@ -0,0 +1,38 @@ +# Public unsubscribe endpoint — no authentication required, token-scoped. +# Allows any recipient to opt out of future Soup campaigns. +class SoupCampaignUnsubscribesController < ApplicationController + allow_unauthenticated_access only: %i[show create] # Token is the auth mechanism + skip_onboarding_redirect only: %i[show create] # Unsubscribe must work without an account + skip_after_action :verify_authorized, only: %i[show create] # No Pundit subject; token-gated + skip_after_action :verify_policy_scoped, only: %i[show create] # No collection query + + def show + recipient = SoupCampaignRecipient.find_by(unsubscribe_token: params[:token]) + + if recipient.nil? + render inertia: "soup_campaign_unsubscribe/invalid" + return + end + + render inertia: "soup_campaign_unsubscribe/show", props: { + campaign_name: recipient.soup_campaign.name, + already_unsubscribed: recipient.unsubscribed?, + token: params[:token] + } + end + + def create + recipient = SoupCampaignRecipient.find_by(unsubscribe_token: params[:token]) + + if recipient.nil? + render inertia: "soup_campaign_unsubscribe/invalid" + return + end + + recipient.update!(status: :unsubscribed) unless recipient.unsubscribed? + + render inertia: "soup_campaign_unsubscribe/confirmed", props: { + campaign_name: recipient.soup_campaign.name + } + end +end diff --git a/app/frontend/components/admin/AdminSidebar.tsx b/app/frontend/components/admin/AdminSidebar.tsx index d345ad15..a64a447d 100644 --- a/app/frontend/components/admin/AdminSidebar.tsx +++ b/app/frontend/components/admin/AdminSidebar.tsx @@ -23,6 +23,7 @@ import { Store, ShoppingCart, Fish, + Soup, } from 'lucide-react' interface AdminStats { @@ -127,6 +128,13 @@ function buildNavSections(): { items: NavItem[] }[] { statKey: null, requirePermission: 'is_admin', }, + { + label: 'Soup Campaigns', + href: '/admin/soup_campaigns', + icon: Soup, + statKey: null, + requirePermission: 'is_admin', + }, ], }, { diff --git a/app/frontend/layouts/AdminLayout.tsx b/app/frontend/layouts/AdminLayout.tsx index 53cd155c..daa20cdd 100644 --- a/app/frontend/layouts/AdminLayout.tsx +++ b/app/frontend/layouts/AdminLayout.tsx @@ -7,7 +7,7 @@ import AdminSidebar from '@/components/admin/AdminSidebar' import type { SharedProps } from '@/types' import '@/styles/admin.css' -export default function AdminLayout({ children }: { children: ReactNode }) { +export default function AdminLayout({ children, flush }: { children: ReactNode; flush?: boolean }) { const { auth } = usePage().props useEffect(() => { @@ -28,7 +28,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
-
{children}
+
{children}
) diff --git a/app/frontend/pages/admin/soup_campaigns/CollaborativeEditor.tsx b/app/frontend/pages/admin/soup_campaigns/CollaborativeEditor.tsx new file mode 100644 index 00000000..926c742d --- /dev/null +++ b/app/frontend/pages/admin/soup_campaigns/CollaborativeEditor.tsx @@ -0,0 +1,858 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import type { ReactNode } from 'react' +import { Link, router } from '@inertiajs/react' +import * as Y from 'yjs' +import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate } from 'y-protocols/awareness' +import { createConsumer } from '@rails/actioncable' +import { EditorView, minimalSetup } from 'codemirror' +import { EditorState } from '@codemirror/state' +import { placeholder as cmPlaceholder } from '@codemirror/view' +import { yCollab, yRemoteSelectionsTheme } from 'y-codemirror.next' +import AdminLayout from '@/layouts/AdminLayout' +import { Badge } from '@/components/admin/ui/badge' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/admin/ui/alert-dialog' +import { ArrowLeftIcon, TrashIcon, SendIcon, WifiIcon, WifiOffIcon } from 'lucide-react' + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface Campaign { + id: number + name: string + body: string + footer: string + unsubscribe_label: string + image_url: string + status: string +} + +interface PresenceUser { + id: number + display_name: string + avatar: string | null + tab_id: string + color: string +} + +interface CurrentUserPresence { + id: number + display_name: string + avatar: string | null +} + +interface Props { + campaign: Campaign + current_user_presence: CurrentUserPresence + yjs_state: string | null +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const SOUP_AVATAR = 'https://avatars.slack-edge.com/2026-03-03/10620134255189_994e10cd91f0fc88ad9c_512.jpg' +const DEFAULT_UNSUBSCRIBE_LABEL = 'Important program related announcement | Unsubscribe' +const PREVIEW_NAME = 'Alex' +const AUTOSAVE_DEBOUNCE_MS = 800 +const FIELDS = ['name', 'body', 'footer', 'unsubscribe_label', 'image_url'] as const +type Field = (typeof FIELDS)[number] + +// ── Slack preview renderer ──────────────────────────────────────────────────── + +function renderSlackMarkdown(text: string): string { + // Escape HTML first + const escaped = text.replace(/&/g, '&').replace(//g, '>') + + // Process line by line to handle block elements (lists, blockquotes) + const lines = escaped.split('\n') + const output: string[] = [] + let inList = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const bulletMatch = line.match(/^([•\-\*])\s+(.*)$/) + const numberMatch = line.match(/^(\d+)\.\s+(.*)$/) + const quoteMatch = line.match(/^>\s?(.*)$/) + + if (bulletMatch || numberMatch) { + if (!inList) { + output.push('') + inList = false + } + if (quoteMatch) { + output.push( + `
${inlineSlack(quoteMatch[1])}
`, + ) + } else if (line === '') { + output.push('
') + } else { + output.push(`${inlineSlack(line)}
`) + } + } + } + + if (inList) output.push('') + + return output.join('') +} + +function inlineSlack(text: string): string { + return text + .replace(/\*([^*]+)\*/g, '$1') + .replace(/_([^_]+)_/g, '$1') + .replace(/~([^~]+)~/g, '$1') + .replace( + /`([^`]+)`/g, + '$1', + ) + .replace(/<(https?:\/\/[^|&]+)\|([^&]+)>/g, '$2') + .replace(/<(https?:\/\/[^&]+)>/g, '$1') +} + +// ── Presence avatars ────────────────────────────────────────────────────────── + +function PresenceAvatars({ users }: { users: PresenceUser[] }) { + // Deduplicate: one avatar per user id (multiple tabs → show once, with tab count) + const byUser = users.reduce>((acc, u) => { + acc[u.id] = acc[u.id] ?? [] + acc[u.id].push(u) + return acc + }, {}) + + const unique = Object.values(byUser) + + if (unique.length === 0) return null + + return ( +
+ Editing now +
+ {unique.slice(0, 5).map((tabs) => { + const u = tabs[0] + const tabCount = tabs.length + return ( +
1 ? ` (${tabCount} tabs)` : ''}`} + > + {u.avatar ? ( + {u.display_name} + ) : ( +
+ {u.display_name[0]} +
+ )} + {tabCount > 1 && ( + + {tabCount} + + )} + {/* Tooltip */} +
+ {u.display_name} + {tabCount > 1 && ({tabCount} tabs)} +
+
+ ) + })} + {unique.length > 5 && ( +
+ +{unique.length - 5} +
+ )} +
+
+ ) +} + +// ── Save status indicator ───────────────────────────────────────────────────── + +type SaveStatus = 'saved' | 'saving' | 'unsaved' | 'offline' + +function SaveIndicator({ status }: { status: SaveStatus }) { + const configs = { + saved: { dot: 'bg-green-500', label: 'Saved', pulse: false }, + saving: { dot: 'bg-amber-400', label: 'Saving…', pulse: true }, + unsaved: { dot: 'bg-amber-400', label: 'Unsaved changes', pulse: false }, + offline: { dot: 'bg-red-500', label: 'Disconnected', pulse: false }, + } + const { dot, label, pulse } = configs[status] + + return ( +
+ + {label} +
+ ) +} + +// ── Collaborative CodeMirror editor ────────────────────────────────────────── + +function CollabEditor({ + yText, + awareness, + placeholderText, + minHeight, + mono = false, + singleLine = false, + onFocus, + onBlur, +}: { + yText: Y.Text + awareness: Awareness + placeholderText?: string + minHeight?: string + mono?: boolean + singleLine?: boolean + onFocus?: () => void + onBlur?: () => void +}) { + const containerRef = useRef(null) + + useEffect(() => { + if (!containerRef.current) return + + const view = new EditorView({ + state: EditorState.create({ + doc: yText.toString(), + extensions: [ + minimalSetup, + yCollab(yText, awareness), + yRemoteSelectionsTheme, + placeholderText ? cmPlaceholder(placeholderText) : [], + singleLine + ? EditorState.transactionFilter.of((tr) => (tr.newDoc.lines > 1 ? [] : tr)) + : EditorView.lineWrapping, + EditorView.theme({ + '&': { + fontFamily: mono + ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + : 'var(--font-sans, ui-sans-serif, system-ui, sans-serif)', + fontSize: '0.875rem', + background: 'transparent', + border: '1px solid var(--input)', + borderRadius: '0.5rem', + color: 'var(--foreground)', + }, + '.cm-editor': { + fontFamily: mono + ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + : 'var(--font-sans, ui-sans-serif, system-ui, sans-serif)', + overflow: 'visible !important', + }, + '&.cm-focused': { + outline: 'none', + borderColor: 'var(--ring)', + boxShadow: '0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent)', + }, + '.cm-scroller': { + overflowX: singleLine ? 'clip' : 'hidden', + overflowY: singleLine ? 'hidden' : 'auto', + lineHeight: '1.5', + minHeight: singleLine ? 'auto' : (minHeight ?? '8rem'), + fontFamily: 'inherit', + }, + '.cm-content': { + padding: singleLine ? '0.5rem 0.75rem' : '1.5rem 0.625rem 0.5rem', + minHeight: singleLine ? 'auto' : (minHeight ?? '8rem'), + fontFamily: 'inherit', + }, + '.cm-line': { padding: '0', fontFamily: 'inherit' }, + '.cm-gutters': { display: 'none' }, + '.cm-activeLine': { background: 'transparent' }, + '.cm-selectionBackground': { background: 'var(--input) !important' }, + '&.cm-focused .cm-selectionBackground': { + background: 'color-mix(in oklch, var(--ring) 30%, transparent) !important', + }, + '.cm-cursor': { borderLeftColor: 'var(--foreground)' }, + '.cm-placeholder': { color: 'var(--muted-foreground)', fontStyle: 'normal' }, + /* Google Docs style cursors */ + '.cm-ySelectionInfo': { + fontFamily: 'var(--font-sans, ui-sans-serif, system-ui, sans-serif) !important', + fontSize: '0.7rem !important', + fontWeight: '600 !important', + padding: '2px 6px !important', + borderRadius: '4px 4px 4px 0 !important', + lineHeight: '1.2 !important', + color: '#fff !important', + opacity: '1 !important', + border: 'none !important', + top: '-1.5em !important', + left: '-1px !important', + whiteSpace: 'nowrap !important', + zIndex: '101 !important', + animation: 'yjs-cursor-fade 2.5s ease-in-out forwards !important', + }, + '.cm-ySelectionCaret': { + position: 'relative !important', + }, + '.cm-ySelectionCaret:hover > .cm-ySelectionInfo': { + animation: 'none !important', + opacity: '1 !important', + }, + '@keyframes yjs-cursor-fade': { + '0%': { opacity: 1 }, + '70%': { opacity: 1 }, + '100%': { opacity: 0 }, + }, + }), + EditorView.domEventHandlers({ + focus: () => { + if (onFocus) onFocus() + }, + blur: () => { + if (onBlur) onBlur() + }, + }), + ], + }), + parent: containerRef.current, + }) + + return () => view.destroy() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [yText, awareness]) + + return
+} + +// ── Collaborative input ─────────────────────────────────────────────────────── + +// ── Slack preview ───────────────────────────────────────────────────────────── + +function SlackPreview({ fields }: { fields: Record }) { + const { body, footer, unsubscribe_label, image_url } = fields + const label = unsubscribe_label.trim() || DEFAULT_UNSUBSCRIBE_LABEL + const now = new Date() + const timeStr = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) + + return ( +
+
+ Soup +
+
+ Soup + {timeStr} + + APP + +
+ {body.trim() ? ( +
+ ) : ( +

Your message will appear here…

+ )} + {footer.trim() && ( +
+ )} + {image_url.trim() && ( + { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> + )} +
+ +
+
+
+ ) +} + +// ── Main collaborative editor ───────────────────────────────────────────────── + +export default function SoupCampaignCollaborativeEditor({ campaign, current_user_presence, yjs_state }: Props) { + const ydocRef = useRef(null) + const awarenessRef = useRef(null) + const channelRef = useRef['subscriptions']['create']> | null>(null) + const tabId = useRef(`${current_user_presence.id}-${Math.random().toString(36).slice(2)}`) + const autosaveTimer = useRef | null>(null) + const isSyncedRef = useRef(false) + const connectedRef = useRef(null) + + const [connected, setConnected] = useState(null) + const [saveStatus, setSaveStatus] = useState('saved') + const [peers, setPeers] = useState([]) + const [activeField, setActiveField] = useState(null) + + // Live preview state driven by Yjs + const [previewFields, setPreviewFields] = useState>({ + name: campaign.name, + body: campaign.body, + footer: campaign.footer ?? '', + unsubscribe_label: campaign.unsubscribe_label, + image_url: campaign.image_url ?? '', + }) + + // ── Init Yjs doc + Awareness ───────────────────────────────────────────────── + + if (!ydocRef.current) { + const doc = new Y.Doc() + if (yjs_state) { + try { + // Apply persisted Yjs state synchronously — no ActionCable race conditions + const binary = atob(yjs_state) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + Y.applyUpdate(doc, bytes) + + // Recover from older corrupted states (missing initial seed dependencies) + if (!doc.getText('name').length && !doc.getText('body').length) { + console.warn('Document empty after applying yjs_state, seeding from DB fields') + doc.getText('name').insert(0, campaign.name) + doc.getText('body').insert(0, campaign.body) + doc.getText('footer').insert(0, campaign.footer ?? '') + doc.getText('unsubscribe_label').insert(0, campaign.unsubscribe_label) + doc.getText('image_url').insert(0, campaign.image_url ?? '') + } + } catch (e) { + console.error('Failed to parse yjs_state, falling back to DB fields:', e) + doc.getText('name').insert(0, campaign.name) + doc.getText('body').insert(0, campaign.body) + doc.getText('footer').insert(0, campaign.footer ?? '') + doc.getText('unsubscribe_label').insert(0, campaign.unsubscribe_label) + doc.getText('image_url').insert(0, campaign.image_url ?? '') + } + isSyncedRef.current = true + } else { + // Brand new campaign with no saved Yjs state — seed from DB field values + doc.getText('name').insert(0, campaign.name) + doc.getText('body').insert(0, campaign.body) + doc.getText('footer').insert(0, campaign.footer ?? '') + doc.getText('unsubscribe_label').insert(0, campaign.unsubscribe_label) + doc.getText('image_url').insert(0, campaign.image_url ?? '') + isSyncedRef.current = true + } + ydocRef.current = doc + } + + if (!awarenessRef.current) { + const awareness = new Awareness(ydocRef.current) + // Set this user's info so y-codemirror.next can render their cursor label + const hue = (current_user_presence.id * 47) % 360 + awareness.setLocalStateField('user', { + name: current_user_presence.display_name, + color: `hsl(${hue}, 70%, 55%)`, + colorLight: `hsl(${hue}, 70%, 80%)`, + }) + awarenessRef.current = awareness + } + + const ydoc = ydocRef.current + const awareness = awarenessRef.current + + // Update live preview whenever any Yjs text changes + useEffect(() => { + const handlers: Array<[Y.Text, () => void]> = FIELDS.map((f) => { + const yText = ydoc.getText(f) + const handler = () => setPreviewFields((prev) => ({ ...prev, [f]: yText.toString() })) + yText.observe(handler) + return [yText, handler] + }) + return () => handlers.forEach(([yText, handler]) => yText.unobserve(handler)) + }, [ydoc]) + + // ── Autosave ──────────────────────────────────────────────────────────────── + + // ── Real-time sync + autosave ──────────────────────────────────────────────── + + const scheduleAutosave = useCallback(() => { + setSaveStatus('unsaved') + if (autosaveTimer.current) clearTimeout(autosaveTimer.current) + autosaveTimer.current = setTimeout(() => { + if (!channelRef.current || !connectedRef.current) return + setSaveStatus('saving') + const fields: Record = {} + FIELDS.forEach((f) => { + fields[f] = ydoc.getText(f).toString() + }) + const update = Y.encodeStateAsUpdate(ydoc) + let binary = '' + for (let i = 0; i < update.byteLength; i += 1024) { + binary += String.fromCharCode.apply(null, update.subarray(i, i + 1024) as any) + } + channelRef.current.perform('autosave', { + update: btoa(binary), + fields, + }) + setSaveStatus('saved') + }, AUTOSAVE_DEBOUNCE_MS) + }, [ydoc]) + + useEffect(() => { + const handler = (update: Uint8Array, origin: any) => { + // Don't echo updates received from the websocket back to the websocket + if (origin === 'websocket') { + // Still schedule an autosave so we persist remote edits if we're the last tab open + scheduleAutosave() + return + } + + // Broadcast incremental update immediately for real-time collaboration + if (channelRef.current && connectedRef.current) { + let binary = '' + for (let i = 0; i < update.byteLength; i += 1024) { + binary += String.fromCharCode.apply(null, update.subarray(i, i + 1024) as any) + } + channelRef.current.perform('sync', { + update: btoa(binary), + }) + } + // Debounced save with full field values for persistence + scheduleAutosave() + } + ydoc.on('update', handler) + return () => ydoc.off('update', handler) + }, [ydoc, scheduleAutosave]) + + // ── ActionCable subscription ──────────────────────────────────────────────── + + useEffect(() => { + const consumer = createConsumer() + + const channel = consumer.subscriptions.create( + { channel: 'SoupCampaignChannel', campaign_id: campaign.id, tab_id: tabId.current }, + { + connected() { + connectedRef.current = true + setConnected(true) + setSaveStatus('saved') + }, + + disconnected() { + connectedRef.current = false + setConnected(false) + setSaveStatus('offline') + }, + + received(data: Record) { + switch (data.type) { + case 'sync_step1_reply': { + // Already applied synchronously from the yjs_state prop — skip + break + } + + case 'sync': { + if ((data.tab_id as string) === tabId.current) break + const binary = atob(data.update as string) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + Y.applyUpdate(ydoc, bytes, 'websocket') + break + } + + case 'awareness': { + if ((data.tab_id as string) === tabId.current) break + const binary = atob(data.update as string) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + applyAwarenessUpdate(awareness, bytes, null) + break + } + + case 'presence_join': + case 'presence': { + const user = data.user as PresenceUser + if (user.tab_id === tabId.current) break + setPeers((prev) => { + const filtered = prev.filter((p) => p.tab_id !== user.tab_id) + return [...filtered, user] + }) + break + } + + case 'presence_leave': { + const tid = data.tab_id as string + setPeers((prev) => prev.filter((p) => p.tab_id !== tid)) + break + } + } + }, + }, + ) + + channelRef.current = channel + + // Broadcast local awareness changes (cursor, selection) to peers + const awarenessHandler = ({ + added, + updated, + removed, + }: { + added: number[] + updated: number[] + removed: number[] + }) => { + const changedClients = [...added, ...updated, ...removed] + const update = encodeAwarenessUpdate(awareness, changedClients) + let binary = '' + for (let i = 0; i < update.byteLength; i += 1024) { + binary += String.fromCharCode.apply(null, update.subarray(i, i + 1024) as any) + } + channel.perform('awareness', { update: btoa(binary) }) + } + awareness.on('update', awarenessHandler) + + return () => { + awareness.off('update', awarenessHandler) + channel.unsubscribe() + consumer.disconnect() + } + }, [campaign.id, ydoc, awareness]) + + // ── Field focus → presence broadcast ─────────────────────────────────────── + + function handleFieldFocus(field: Field) { + setActiveField(field) + channelRef.current?.perform('presence', { field }) + } + + function handleFieldBlur() { + setActiveField(null) + channelRef.current?.perform('presence', { field: null }) + } + + // ── Delete campaign ───────────────────────────────────────────────────────── + + function handleDelete() { + router.delete(`/admin/soup_campaigns/${campaign.id}`, { + onSuccess: () => router.visit('/admin/soup_campaigns'), + }) + } + + const isDraft = campaign.status === 'draft' + + return ( +
+ {/* Top bar */} +
+
+ {/* Left: back + campaign name */} +
+ + + +
+ {/* Editable name inline */} + handleFieldFocus('name')} + onBlur={handleFieldBlur} + /> +
+ + {/* Center: presence */} +
+ +
+ + {/* Right: status + actions */} +
+
+ {connected === false ? ( + + ) : ( + + )} + +
+ + {isDraft && ( + + + + + + + Delete campaign? + + This will permanently delete this draft. It cannot be undone. + + + + Cancel + + Delete + + + + + )} + + + + Review & send + +
+
+
+ + {/* Editor + preview split */} +
+ {/* Left: editor */} +
+ {/* Sync status banner */} + {connected === false && ( +
+ + Connection lost — changes will sync when reconnected +
+ )} + + {/* Body */} +
+
+ +
+ {[ + ['*text*', 'bold'], + ['_text_', 'italic'], + ['`code`', 'code'], + ['{name}', 'first name'], + ].map(([sym, desc]) => ( + + {sym}{' '} + {desc} + + ))} +
+
+ handleFieldFocus('body')} + onBlur={handleFieldBlur} + /> +
+ + {/* Footer */} +
+ + handleFieldFocus('footer')} + onBlur={handleFieldBlur} + /> +
+ + {/* Image */} +
+ + handleFieldFocus('image_url')} + onBlur={handleFieldBlur} + /> +
+ + {/* Unsubscribe label */} +
+ +

+ Appears in the footer of every message next to the unsubscribe link. +

+ handleFieldFocus('unsubscribe_label')} + onBlur={handleFieldBlur} + /> +
+
+ + {/* Right: sticky live preview */} +
+
+
+

Live preview

+

+ {'{name}'} → {PREVIEW_NAME} +

+
+ + {activeField && ( +

+ Editing {activeField.replace('_', ' ')} +

+ )} +
+
+
+
+ ) +} + +SoupCampaignCollaborativeEditor.layout = (page: ReactNode) => {page} diff --git a/app/frontend/pages/admin/soup_campaigns/SoupCampaignForm.tsx b/app/frontend/pages/admin/soup_campaigns/SoupCampaignForm.tsx new file mode 100644 index 00000000..f4e66d90 --- /dev/null +++ b/app/frontend/pages/admin/soup_campaigns/SoupCampaignForm.tsx @@ -0,0 +1,296 @@ +import { useState } from 'react' +import { router } from '@inertiajs/react' +import { Button } from '@/components/admin/ui/button' +import { Input } from '@/components/admin/ui/input' +import { Textarea } from '@/components/admin/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/admin/ui/card' +import { Badge } from '@/components/admin/ui/badge' + +const DEFAULT_UNSUBSCRIBE_LABEL = 'Important program related announcement | Unsubscribe' +const PREVIEW_NAME = 'Alex' + +interface Campaign { + id?: number + name: string + body: string + footer: string + unsubscribe_label: string + image_url: string + status?: string +} + +interface Props { + campaign?: Campaign + errors?: Record +} + +const SOUP_AVATAR = 'https://avatars.slack-edge.com/2026-03-03/10620134255189_994e10cd91f0fc88ad9c_512.jpg' + +// Very lightweight Slack mrkdwn → HTML renderer for preview +function renderSlackMarkdown(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\*([^*\n]+)\*/g, '$1') + .replace(/_([^_\n]+)_/g, '$1') + .replace(/~([^~\n]+)~/g, '$1') + .replace(/`([^`\n]+)`/g, '$1') + .replace(/<(https?:\/\/[^|&]+)\|([^&]+)>/g, '$2') + .replace(/<(https?:\/\/[^&]+)>/g, '$1') + .replace(/<#[A-Z0-9]+\|([^&]+)>/g, '#$1') + .replace( + /<@([A-Z0-9]+)>/g, + '@$1', + ) + .replace(/\n/g, '
') +} + +function interpolate(text: string): string { + return text.replace(/\{name\}/g, PREVIEW_NAME) +} + +function SlackPreview({ + body, + footer, + unsubscribeLabel, + imageUrl, +}: { + body: string + footer: string + unsubscribeLabel: string + imageUrl: string +}) { + const label = unsubscribeLabel.trim() || DEFAULT_UNSUBSCRIBE_LABEL + const now = new Date() + const timeStr = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) + + return ( +
+
+ Soup +
+
+ Soup + {timeStr} + + APP + +
+ + {/* Body */} + {body.trim() ? ( +
+ ) : ( +

Your message will appear here…

+ )} + + {/* Footer section block */} + {footer.trim() && ( +
+ )} + + {/* Image block */} + {imageUrl.trim() && ( + { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> + )} + + {/* Divider */} +
+ + {/* Context block — small gray footer */} + +
+
+
+ ) +} + +const FORMATTING_TIPS = [ + { symbol: '*text*', desc: 'bold' }, + { symbol: '_text_', desc: 'italic' }, + { symbol: '~text~', desc: 'strikethrough' }, + { symbol: '`code`', desc: 'inline code' }, + { symbol: '', desc: 'link' }, + { symbol: '<#C037157AL30|fallout>', desc: 'channel' }, +] + +export default function SoupCampaignForm({ campaign, errors }: Props) { + const isEdit = Boolean(campaign?.id) + const [name, setName] = useState(campaign?.name ?? '') + const [body, setBody] = useState(campaign?.body ?? '') + const [footer, setFooter] = useState(campaign?.footer ?? '') + const [unsubscribeLabel, setUnsubscribeLabel] = useState(campaign?.unsubscribe_label ?? DEFAULT_UNSUBSCRIBE_LABEL) + const [imageUrl, setImageUrl] = useState(campaign?.image_url ?? '') + const [submitting, setSubmitting] = useState(false) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSubmitting(true) + + const data = { soup_campaign: { name, body, footer, unsubscribe_label: unsubscribeLabel, image_url: imageUrl } } + const opts = { onFinish: () => setSubmitting(false) } + + if (isEdit) { + router.patch(`/admin/soup_campaigns/${campaign!.id}`, data, opts) + } else { + router.post('/admin/soup_campaigns', data, opts) + } + } + + function fieldError(field: string) { + return errors?.[field]?.[0] + } + + return ( +
+
+ {/* Left: editor */} +
+ + + Campaign details + + +
+ + setName(e.target.value)} + placeholder="e.g. April update, summit invite…" + required + /> + {fieldError('name') &&

{fieldError('name')}

} +
+
+
+ + + + Message body + + +