diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f6a0f80..d4ead2d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -178,6 +178,38 @@ def integrate redirect_to root_path, notice: "Day completed." end + # POST /save_textarea + def save_textarea + return render json: { error: "Not authenticated" }, status: 401 unless current_resonance + return render json: { error: "Active subscription required" }, status: 403 unless current_resonance.active_subscription? + + # Check for cross-device continuity divergence + client_universe_time = params[:universe_time] + server_universe_time = current_resonance.universe_time + + if client_universe_time && client_universe_time != server_universe_time + # Parse and compare "day:count" format + client_day, client_count = client_universe_time.split(":").map(&:to_i) + server_day, server_count = server_universe_time.split(":").map(&:to_i) + + # If client is behind server, they're working with stale state + if client_day < server_day || (client_day == server_day && client_count < server_count) + render json: { + error: "continuity_divergence", + message: "This space moved forward elsewhere.", + server_universe_time: server_universe_time + }, status: 409 + return + end + end + + # Save textarea content + current_resonance.textarea = params[:textarea] + current_resonance.save! + + render json: { status: "saved", universe_time: current_resonance.universe_time } + end + # POST /subscription def create_subscription return redirect_to root_path, alert: "Please sign in" unless current_resonance diff --git a/app/javascript/controllers/chat_controller.js b/app/javascript/controllers/chat_controller.js index 9981427..6fa7a10 100644 --- a/app/javascript/controllers/chat_controller.js +++ b/app/javascript/controllers/chat_controller.js @@ -4,12 +4,14 @@ export default class extends Controller { static targets = ["log", "input"] static values = { narrative: Array, - universeTime: String + universeTime: String, + savedTextarea: String } connect() { this.loadExistingMessages() this.loadSavedInput() + this.saveDebounceTimeout = null } loadExistingMessages() { @@ -40,8 +42,13 @@ export default class extends Controller { textarea.style.height = "auto" textarea.style.height = textarea.scrollHeight + "px" - // Save input to localStorage + // Save input to localStorage immediately this.saveInputToStorage(textarea.value) + + // Debounced save to server (unless this is a programmatic event from loadSavedInput) + if (!event.skipServerSave) { + this.debouncedSaveToServer(textarea.value) + } } saveInputToStorage(value) { @@ -49,19 +56,73 @@ export default class extends Controller { localStorage.setItem(storageKey, value) } + debouncedSaveToServer(value) { + // Clear existing timeout + if (this.saveDebounceTimeout) { + clearTimeout(this.saveDebounceTimeout) + } + + // Set new timeout to save after 1.5 seconds of inactivity + this.saveDebounceTimeout = setTimeout(() => { + this.saveToServer(value) + }, 1500) + } + + async saveToServer(value) { + try { + const response = await fetch("/textarea", { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ + textarea: value, + universe_time: this.universeTimeValue + }) + }) + + if (response.status === 409) { + // Continuity divergence - this space moved forward elsewhere + const data = await response.json() + console.warn("Textarea save blocked: continuity divergence", data) + // Could show a subtle notice here if desired + } else if (!response.ok) { + console.error("Failed to save textarea:", response.status) + } + } catch (error) { + console.error("Error saving textarea:", error) + // Fail silently - localStorage still has it + } + } + loadSavedInput() { + // Prefer server-saved value over localStorage + const serverSaved = this.savedTextareaValue const storageKey = `yours-input-${this.universeTimeValue || 'current'}` - const savedInput = localStorage.getItem(storageKey) + const localSaved = localStorage.getItem(storageKey) + + // Use whichever is longer (assumes the longer one is more recent) + // In practice, server value is canonical for cross-device sync + const savedInput = (serverSaved && serverSaved.length >= (localSaved || "").length) + ? serverSaved + : localSaved + if (savedInput) { this.inputTarget.value = savedInput - // Trigger input event to auto-expand - this.inputTarget.dispatchEvent(new Event('input')) + // Trigger input event to auto-expand (but don't re-trigger server save) + const event = new Event('input') + event.skipServerSave = true + this.inputTarget.dispatchEvent(event) } } clearSavedInput() { const storageKey = `yours-input-${this.universeTimeValue || 'current'}` localStorage.removeItem(storageKey) + + // Also clear on server + this.saveToServer("") } send() { diff --git a/app/models/resonance.rb b/app/models/resonance.rb index b4b441c..e58f5c7 100644 --- a/app/models/resonance.rb +++ b/app/models/resonance.rb @@ -111,6 +111,15 @@ def universe_day=(value) self.encrypted_universe_day = value.nil? ? nil : encrypt_field(value.to_s) end + # Textarea contents + def textarea + decrypt_field(encrypted_textarea) + end + + def textarea=(value) + self.encrypted_textarea = encrypt_field(value) + end + # Universe time as "day:message_count" (e.g., "3:14" for day 3, 14 messages) # This serves as a monotonically increasing guard against cross-device state clobbering def universe_time diff --git a/app/views/application/account.html.erb b/app/views/application/account.html.erb index 416b328..dda8813 100644 --- a/app/views/application/account.html.erb +++ b/app/views/application/account.html.erb @@ -61,7 +61,7 @@
Want to clear in-universe memory completely? (This won't affect your subscription or your in-universe day counter.)
+Want to clear in-universe memory completely? (This will not reset the day counter, and will not cancel your subscription.)
This action cannot be undone.
<%= button_to "Begin Again", diff --git a/app/views/application/chat.html.erb b/app/views/application/chat.html.erb index ec3a575..f3afa7b 100644 --- a/app/views/application/chat.html.erb +++ b/app/views/application/chat.html.erb @@ -1,4 +1,4 @@ -