Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 66 additions & 5 deletions app/javascript/controllers/chat_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -40,28 +42,87 @@ 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) {
const storageKey = `yours-input-${this.universeTimeValue || 'current'}`
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() {
Expand Down
9 changes: 9 additions & 0 deletions app/models/resonance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/views/application/account.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@

<div style="margin: 3rem 0; padding-top: 2rem; border-top: 1px solid var(--border);">
<h2>Begin again</h2>
<p>Want to clear in-universe memory completely? (This won't affect your subscription or your in-universe day counter.)</p>
<p>Want to clear in-universe memory completely? (This will not reset the day counter, and will not cancel your subscription.)</p>
<p style="margin-top: 1rem; color: var(--warning);">This action cannot be undone.</p>

<%= button_to "Begin Again",
Expand Down
2 changes: 1 addition & 1 deletion app/views/application/chat.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div data-controller="chat" data-chat-narrative-value="<%= @narrative.to_json %>" data-chat-universe-time-value="<%= current_resonance.universe_time %>" style="display: flex; flex-direction: column; gap: 1rem;">
<div data-controller="chat" data-chat-narrative-value="<%= @narrative.to_json %>" data-chat-universe-time-value="<%= current_resonance.universe_time %>" data-chat-saved-textarea-value="<%= h(current_resonance.textarea || "") %>" style="display: flex; flex-direction: column; gap: 1rem;">
<div id="chat-log" data-chat-target="log" style="display: flex; flex-direction: column; gap: 1rem;">
<!-- Messages will appear here -->
</div>
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# Service routes
post "stream", to: "application#stream"
post "integrate", to: "application#integrate"
put "textarea", to: "application#save_textarea"
post "subscription", to: "application#create_subscription"
delete "subscription", to: "application#destroy_subscription"
post "reset", to: "application#reset"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddEncryptedTextareaToResonances < ActiveRecord::Migration[8.0]
def change
add_column :resonances, :encrypted_textarea, :text
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions spec/models/resonance_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,64 @@
expect(resonance).to be_valid
end
end

describe "#textarea" do
let(:google_id) { "google-user-123" }

it "stores and retrieves textarea content" do
resonance = Resonance.find_or_create_by_google_id(google_id)
resonance.textarea = "thinking about something..."
resonance.save!

reloaded = Resonance.find_by_google_id(google_id)
expect(reloaded.textarea).to eq("thinking about something...")
end

it "encrypts the textarea content" do
resonance = Resonance.find_or_create_by_google_id(google_id)
resonance.textarea = "secret thought"
resonance.save!

expect(resonance.encrypted_textarea).to be_present
expect(resonance.encrypted_textarea).not_to include("secret thought")
end

it "persists across day transitions" do
resonance = Resonance.find_or_create_by_google_id(google_id)
resonance.textarea = "carrying this forward"
resonance.universe_day = 1
resonance.narrative_accumulation_by_day = [
{ role: "user", content: [ { type: "text", text: "Day 1 message" } ] }
]
resonance.save!

# Simulate day transition (like integrate action does)
resonance.universe_day = 2
resonance.narrative_accumulation_by_day = []
resonance.integration_harmonic_by_night = "some harmonic"
resonance.save!

# Textarea should persist unchanged
reloaded = Resonance.find_by_google_id(google_id)
expect(reloaded.textarea).to eq("carrying this forward")
expect(reloaded.universe_day).to eq(2)
end

it "returns nil when not set" do
resonance = Resonance.find_or_create_by_google_id(google_id)
expect(resonance.textarea).to be_nil
end

it "can be explicitly cleared" do
resonance = Resonance.find_or_create_by_google_id(google_id)
resonance.textarea = "something"
resonance.save!

resonance.textarea = nil
resonance.save!

reloaded = Resonance.find_by_google_id(google_id)
expect(reloaded.textarea).to be_nil
end
end
end