Skip to main content

Co-Reading Refactoring Proposal v3

Executive Summary

This proposal outlines a refactoring of the co-reading feature to leverage the mature solo story reader infrastructure. The current implementation duplicates navigation logic and maintains parallel state management systems. The proposed solution treats co-reading as "synchronized solo reading" where both owner and followers use the same StoryReaderViewModel for navigation, with a thin synchronization layer on top.

Problem Statement

Current Architecture Issues

  1. Duplicate Navigation Systems

    • CoReadingSessionActor implements its own navigation logic parallel to StoryReaderViewModel
    • State is tracked redundantly across multiple components
    • Navigation validation and beat loading happen in multiple places
  2. Unclear Responsibilities

    • CoReadingViewModel and StoryReaderViewModel have overlapping concerns
    • The boundary between local navigation and remote synchronization is blurred
    • Actions are artificially split between "local" and "remote" variants
  3. Maintenance Burden

    • Bug fixes and features must be implemented twice
    • Testing requires covering duplicate functionality
    • Mental model is unnecessarily complex

Proposed Architecture

Core Principle

Co-reading = Solo Reading + State Synchronization

Both owner and followers use the exact same StoryReaderViewModel infrastructure. The only difference is how navigation is initiated:

  • Owners: Navigate via user interaction, changes are synchronized to followers
  • Followers: Navigate via sync events, UI interactions are blocked

Component Responsibilities

1. StoryReaderViewModel (Extended)

  • Navigation engine for both solo and co-reading
  • Handles all beat loading, page transitions, content management
  • Provides two navigation entry points:
    • changePage(): User-initiated navigation (calls modeDelegate)
    • navigateToLocation(): Programmatic navigation (bypasses modeDelegate)
  • Publishes navigation events for synchronization
  • Self-contained beat loading in navigateToLocation()

2. CoReadingSessionActor (Simplified)

  • Pure state synchronizer with initial setup responsibilities
  • Maintains minimal shared state (selected story, current location, participants)
  • Converts between local actions and network events
  • Determines concrete initial story location on selection
  • Loads necessary beats to resolve actual page IDs
  • No navigation logic or content management

3. CoReadingViewModel (Coordinator)

  • Thin coordination layer
  • For owners: Captures navigation events from StoryReaderViewModel and syncs
  • For followers: Receives location updates and calls navigateToLocation()
  • Manages participant list and co-reading UI state
  • Establishes StoryReaderViewModel binding at appropriate time

4. CoReadingMode (UI Customization)

  • Permission control and UI provider
  • Blocks navigation for followers at UI level
  • Provides video feeds and call controls
  • No navigation or state logic

Detailed Design

State Management

// Minimal shared state with loading tracking
struct CoReadingState: Codable, Equatable {
var stories: Set<Story> = []
var selectedStoryId: UUID?
var currentLocation: StoryLocation? // Always fully resolved
var participants: Set<UserID> = []
var isFullScreenMode: Bool = false

// Loading state tracking
var storySelectionInProgressForStoryId: UUID?
}

// Focused action set
enum CoReadingAction: Equatable {
// Story management
case selectStory(Story)
case returnToLibrary

// Navigation
case updateLocation(StoryLocation)

// Participants
case updateParticipants(Set<UserID>)

// Loading states
case storySelectionStarted(storyId: UUID)
case storySelectionCompleted
}

StoryReaderViewModel Extensions

extension StoryReaderViewModel {
// For programmatic navigation (followers)
// Self-contained: loads beats if needed
func navigateToLocation(_ location: StoryLocation, animated: Bool = true) async {
// Direct navigation without modeDelegate involvement

// 1. Ensure beat is loaded using own loadingStateManager
if !contentManager.storyBeatManager.isBeatLoaded(location.beatId) {
await loadingStateManager.executeWithRetry(
description: "Loading content...",
beatId: location.beatId
) { [weak self] in
_ = try await self?.contentManager.storyBeatManager.loadBeat(location.beatId)
}
}

// 2. Update navigator position directly
navigator.updatePosition(location, direction: .forward)

// 3. Execute UI navigation
await pageController?.executeNavigation(
to: location,
direction: .forward,
animated: animated,
reason: .sync
)
}

// Publisher for navigation events
var navigationPublisher: AnyPublisher<StoryLocation, Never> {
$currentLocation
.compactMap { $0 }
.removeDuplicates()
.eraseToAnyPublisher()
}
}

// Add sync reason
enum NavigationReason {
case swipe
case tap
case startStory
case restartStory
case choice
case sync // New: for co-reading synchronization
}

Initial Location Determination

// In CoReadingSessionActor
private func handleSelectStory(_ story: Story) async -> CoReadingEffect {
// Mark selection as in progress
state.storySelectionInProgressForStoryId = story.id
state.selectedStoryId = story.id
state.stories.insert(story)

// Load necessary beats to determine initial location
do {
// Load the start beat
let startBeat = try await beatRepository.fetchBeat(
storyId: story.id.uuidString,
beatId: story.startBeatId
)

guard let firstPage = startBeat.pages.first else {
throw CoReadingError.emptyBeat(beatId: story.startBeatId)
}

// If first page is a title page with nextBeatId, may need to load that too
var actualFirstPageId = firstPage.id
var actualFirstBeatId = story.startBeatId

if let titlePage = firstPage as? PajamaScriptTitlePage,
let nextBeatId = titlePage.nextBeatId {
// Optionally load next beat to ensure smooth start
// This is a policy decision - could also defer
}

// Set the concrete, navigable location
let initialLocation = StoryLocation(
beatId: actualFirstBeatId,
pageId: actualFirstPageId,
lineIndex: 0
)

state.currentLocation = initialLocation
state.isFullScreenMode = true
state.storySelectionInProgressForStoryId = nil

} catch {
state.storySelectionInProgressForStoryId = nil
return .error(error)
}

return .none
}

Owner Navigation Flow

1. User taps/swipes
2. StoryReaderViewModel.changePage() called
3. modeDelegate consulted (allows navigation)
4. Navigation completes, location published
5. CoReadingViewModel captures via navigationPublisher
6. Sends updateLocation to CoReadingSessionActor
7. Actor syncs to remote participants

Follower Navigation Flow

1. Remote sync event arrives
2. CoReadingSessionActor updates currentLocation
3. CoReadingViewModel observes state change
4. Calls StoryReaderViewModel.navigateToLocation()
5. Direct navigation without modeDelegate
6. UI updates to show new location

UI-Level Navigation Control

// In StoryPageController
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController
) -> UIViewController? {
// Check if navigation is allowed
guard modeDelegate.isOwner else {
return nil // Disable swipe for followers
}
return handleSwipe(.backward)
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController
) -> UIViewController? {
guard modeDelegate.isOwner else {
return nil // Disable swipe for followers
}
return handleSwipe(.forward)
}

// In page views (e.g., TextPageView)
.onTapGesture {
guard viewModel.modeDelegate.isOwner else { return }
Task {
await viewModel.userTappedToAdvance()
}
}

CoReadingViewModel Implementation

@MainActor
final class CoReadingViewModel: ObservableObject, ExperienceViewModel {
@Published private(set) var state: CoReadingState = .initial
private let sessionActor: CoReadingSessionActor
private weak var storyReaderViewModel: StoryReaderViewModel?
private var navigationSubscription: AnyCancellable?

func bindToStoryReader(_ reader: StoryReaderViewModel) {
// Prevent multiple bindings
guard storyReaderViewModel == nil else {
AppLogger.warn("StoryReaderViewModel already bound")
return
}

self.storyReaderViewModel = reader

if isOwner {
// Capture navigation events for synchronization
navigationSubscription = reader.navigationPublisher
.sink { [weak self] location in
Task {
await self?.sessionActor.processUserAction(.updateLocation(location))
}
}
}

// Subscribe to state changes
Task {
for await newState in await sessionActor.stateStream() {
let oldState = self.state
self.state = newState
await handleStateChange(from: oldState, to: newState)
}
}
}

private func handleStateChange(from old: CoReadingState, to new: CoReadingState) async {
// Handle story selection completion for owner
if isOwner && old.storySelectionInProgressForStoryId != nil
&& new.storySelectionInProgressForStoryId == nil
&& new.currentLocation != nil {
// Owner needs to sync to actor-determined location
await storyReaderViewModel?.navigateToLocation(new.currentLocation!)
}

// For followers: apply navigation changes
if !isOwner && old.currentLocation != new.currentLocation,
let location = new.currentLocation {
await storyReaderViewModel?.navigateToLocation(location)
}
}
}

View Integration

// In UnifiedStoryReaderView
struct UnifiedStoryReaderView: View {
@StateObject var storyReaderViewModel: StoryReaderViewModel

var body: some View {
ReaderUIPageViewController(...)
.onAppear {
if let coReadingVM = modeDelegate.coReadingViewModel {
coReadingVM.bindToStoryReader(storyReaderViewModel)
}
}
}
}

Implementation Plan

Phase 1: Core Infrastructure (2 days)

  1. Extend StoryReaderViewModel

    • Add navigateToLocation() method with self-contained beat loading
    • Add navigationPublisher
    • Add .sync navigation reason
  2. Simplify State Types

    • Implement new CoReadingState with loading tracking
    • Implement new CoReadingAction enum
    • Remove redundant navigation state

Phase 2: Simplify Actor (3 days)

  1. Implement Initial Location Resolution

    • Add beat loading logic to story selection
    • Ensure concrete locations are always set
    • Handle edge cases (empty beats, missing pages)
  2. Remove Navigation Logic

    • Delete all navigation-related methods
    • Focus on state synchronization
    • Clean event processing

Phase 3: UI Navigation Control (2 days)

  1. Block Follower Navigation

    • Update StoryPageController datasource methods
    • Update all page views to check ownership
    • Add visual feedback for disabled navigation
  2. Ensure Consistent Behavior

    • Test all navigation paths
    • Verify owner can navigate freely
    • Confirm followers cannot navigate

Phase 4: Coordination Layer (2 days)

  1. Update CoReadingViewModel

    • Implement bindToStoryReader() with proper timing
    • Add navigation capture for owners
    • Add location application for followers
    • Handle owner's initial sync to actor state
  2. Update CoReadingMode

    • Remove navigation handling methods
    • Ensure permission checks work correctly

Phase 5: Testing & Polish (2 days)

  1. Test Scenarios

    • Owner navigation → follower sync
    • Story selection → both at same location
    • Beat loading for both parties
    • Follower cannot navigate
    • Network disconnection handling
    • Owner's initial location sync
  2. Edge Cases

    • Rapid navigation by owner
    • Follower beat loading failures
    • Participant join mid-story
    • Story with empty beats
    • Selection of story with complex navigation

Error Handling Strategy

  1. Beat Loading Failures During Selection

    • Actor reverts selection state
    • UI shows error with retry option
    • Selection can be retried
  2. Navigation Sync Failures

    • Each client handles its own failures
    • Followers show loading state during catch-up
    • Timeout with user-friendly message
  3. Race Conditions

    • Actor queues state changes
    • Navigation events are sequenced
    • Last writer wins for conflicts

Performance Considerations

  1. Optimistic Beat Loading

    • Preload next beat during navigation
    • Cache recently used beats
    • Progressive loading for large stories
  2. Efficient State Updates

    • Only sync meaningful changes
    • Batch rapid navigation events
    • Debounce location updates

Success Criteria

  1. Single Navigation Implementation: All navigation logic lives in StoryReaderViewModel
  2. Clear Separation: Synchronization clearly separated from navigation
  3. Code Reduction: 40%+ reduction in co-reading specific code
  4. Bug Parity: Fixes to solo reader automatically apply to co-reading
  5. Performance: No degradation in responsiveness
  6. Consistency: Owner and followers always see the same content

Timeline

  • Total: 11 days
  • Buffer: 2 days for unexpected issues
  • Final Timeline: 13 days

This approach provides a clean separation of concerns where co-reading truly becomes solo reading with a synchronization layer, maximizing code reuse and maintainability while ensuring consistency across all participants.