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
-
Duplicate Navigation Systems
CoReadingSessionActorimplements its own navigation logic parallel toStoryReaderViewModel- State is tracked redundantly across multiple components
- Navigation validation and beat loading happen in multiple places
-
Unclear Responsibilities
CoReadingViewModelandStoryReaderViewModelhave overlapping concerns- The boundary between local navigation and remote synchronization is blurred
- Actions are artificially split between "local" and "remote" variants
-
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
}
Navigation Flow
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)
-
Extend StoryReaderViewModel
- Add
navigateToLocation()method with self-contained beat loading - Add
navigationPublisher - Add
.syncnavigation reason
- Add
-
Simplify State Types
- Implement new
CoReadingStatewith loading tracking - Implement new
CoReadingActionenum - Remove redundant navigation state
- Implement new
Phase 2: Simplify Actor (3 days)
-
Implement Initial Location Resolution
- Add beat loading logic to story selection
- Ensure concrete locations are always set
- Handle edge cases (empty beats, missing pages)
-
Remove Navigation Logic
- Delete all navigation-related methods
- Focus on state synchronization
- Clean event processing
Phase 3: UI Navigation Control (2 days)
-
Block Follower Navigation
- Update
StoryPageControllerdatasource methods - Update all page views to check ownership
- Add visual feedback for disabled navigation
- Update
-
Ensure Consistent Behavior
- Test all navigation paths
- Verify owner can navigate freely
- Confirm followers cannot navigate
Phase 4: Coordination Layer (2 days)
-
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
- Implement
-
Update CoReadingMode
- Remove navigation handling methods
- Ensure permission checks work correctly
Phase 5: Testing & Polish (2 days)
-
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
-
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
-
Beat Loading Failures During Selection
- Actor reverts selection state
- UI shows error with retry option
- Selection can be retried
-
Navigation Sync Failures
- Each client handles its own failures
- Followers show loading state during catch-up
- Timeout with user-friendly message
-
Race Conditions
- Actor queues state changes
- Navigation events are sequenced
- Last writer wins for conflicts
Performance Considerations
-
Optimistic Beat Loading
- Preload next beat during navigation
- Cache recently used beats
- Progressive loading for large stories
-
Efficient State Updates
- Only sync meaningful changes
- Batch rapid navigation events
- Debounce location updates
Success Criteria
- Single Navigation Implementation: All navigation logic lives in
StoryReaderViewModel - Clear Separation: Synchronization clearly separated from navigation
- Code Reduction: 40%+ reduction in co-reading specific code
- Bug Parity: Fixes to solo reader automatically apply to co-reading
- Performance: No degradation in responsiveness
- 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.