Skip to content

feat(feed): implement real-time GraphQL feed with subscriptions#401

Open
renaudmathieu wants to merge 1 commit intomainfrom
chore/newsfeed
Open

feat(feed): implement real-time GraphQL feed with subscriptions#401
renaudmathieu wants to merge 1 commit intomainfrom
chore/newsfeed

Conversation

@renaudmathieu
Copy link
Collaborator

  • Replace MockFeedRepository with FeedGraphQLRepository using Apollo.
  • Add GraphQL queries and subscriptions for real-time feed updates.
  • Update domain model to include FeedItem.Message and MessageType.
  • Implement MessageCard UI component with relative time formatting.
  • Configure Apollo WebSocketNetworkTransport and custom Instant scalar adapter.
  • Refactor DataModule to use singleOf for dependency injection.
  • Update schema and mappers to support the new feed message types.

Checklist

  • Builds without errors (./gradlew assembleDebug)
  • Tested on device/emulator
  • No new warnings introduced

- Replace `MockFeedRepository` with `FeedGraphQLRepository` using Apollo.
- Add GraphQL queries and subscriptions for real-time feed updates.
- Update domain model to include `FeedItem.Message` and `MessageType`.
- Implement `MessageCard` UI component with relative time formatting.
- Configure Apollo `WebSocketNetworkTransport` and custom `Instant` scalar adapter.
- Refactor `DataModule` to use `singleOf` for dependency injection.
- Update schema and mappers to support the new feed message types.

val KotlinxInstantAdapter = object : Adapter<Instant> {
override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): Instant {
val str = reader.nextString() ?: throw IllegalStateException("Expected non-null Instant string")

Check warning

Code scanning / detekt

Use check() or error() instead of throwing an IllegalStateException. Warning

Use check() or error() instead of throwing an IllegalStateException.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a real-time feed backed by Apollo GraphQL, replacing the mock feed source and extending the domain/UI layers to support a new message-based feed item type.

Changes:

  • Added GraphQL query + subscription for feed messages, plus Apollo WebSocket transport and an Instant scalar adapter.
  • Extended the feed domain model with FeedItem.Message and MessageType, and mapped GraphQL feed messages into domain items.
  • Updated feed UI to render message items via a new MessageCard component and removed the unused “Read More” footer/strings.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/MessageCard.kt New UI card for FeedItem.Message, including relative time display.
shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/FeedScreen.kt Renders FeedItem.Message using MessageCard.
shared/ui/src/commonMain/kotlin/com/androidmakers/ui/feed/ArticleCards.kt Removes unused article footer/avatar/read-more UI.
shared/ui/src/commonMain/composeResources/values/strings.xml Removes unused feed strings; minor formatting change.
shared/ui/src/commonMain/composeResources/values-fr/strings.xml Removes unused French “Read More” string.
shared/domain/src/commonMain/kotlin/fr/androidmakers/domain/model/FeedItem.kt Adds FeedItem.Message + MessageType; simplifies Article model.
shared/di/src/commonMain/kotlin/fr/androidmakers/di/DataModule.kt Switches DI to bind FeedRepository to FeedGraphQLRepository using singleOf.
shared/data/src/commonMain/kotlin/fr/androidmakers/store/mock/MockFeedRepository.kt Updates mock data to use FeedItem.Message (though no longer wired as default).
shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/KotlinxInstantAdapter.kt Adds Apollo custom scalar adapter for kotlinx.datetime.Instant.
shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedMappers.kt Maps GraphQL FeedMessageDetails to domain feed items.
shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/FeedGraphQLRepository.kt New repository merging initial query results with subscription updates.
shared/data/src/commonMain/kotlin/fr/androidmakers/store/graphql/ApolloClient.kt Configures Apollo WebSocket transport for subscriptions.
shared/data/src/commonMain/graphql/schema.graphqls Adds feed query, types, and subscription to the schema.
shared/data/src/commonMain/graphql/feed.graphql Adds feed query/subscription operations + fragment.
shared/data/build.gradle.kts Registers GraphQLInstant scalar mapping to the new adapter.
.gitignore Ignores firebase-debug.log.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +63 to +67
return when {
durationMinutes < 1 -> "just now"
durationMinutes < 60 -> "${durationMinutes}m ago"
durationMinutes < 1440 -> "${durationMinutes / 60}h ago"
else -> "${durationMinutes / 1440}d ago"
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relative-time strings here are hardcoded in English ("just now", "m ago", etc.). Please move these to compose resources (ideally using plurals/format strings) so the feed UI remains localizable.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +41
val unique = newData.filter { it.id !in existingIds }
Result.success(existing + unique)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because cacheAndNetwork() emits multiple snapshots (cache then network), accumulating with existing + unique means items with the same ID are never updated when newer server data arrives. Consider treating query emissions as authoritative snapshots (replace/update by ID) rather than only appending unseen IDs.

Suggested change
val unique = newData.filter { it.id !in existingIds }
Result.success(existing + unique)
val newById = newData.associateBy { it.id }
// Update existing items with newer versions when IDs match
val updatedExisting = existing.map { item ->
newById[item.id] ?: item
}
// Append only truly new items that did not exist before
val brandNew = newData.filter { it.id !in existingIds }
Result.success(updatedExisting + brandNew)

Copilot uses AI. Check for mistakes.
<string name="feed">Feed</string>
<string name="feed_read_more">Read More</string>
<string name="feed_category_venue">VENUE</string>
<string name="feed_category_venue">VENUE</string>
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation on this line is inconsistent with the surrounding XML (missing the usual leading spaces). If the project runs XML formatting/lint checks, this can create noisy diffs or even fail formatting checks; please align it with the rest of the file’s indentation.

Suggested change
<string name="feed_category_venue">VENUE</string>
<string name="feed_category_venue">VENUE</string>

Copilot uses AI. Check for mistakes.
val existing = acc.getOrNull().orEmpty()
val existingIds = existing.map { it.id }.toSet()
val unique = newData.filter { it.id !in existingIds }
Result.success(existing + unique)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New items are appended to the end of the list (existing + unique). For a real-time feed, subscription items are typically newest and should be inserted at the top (or the whole list should be re-sorted by createdAt) to avoid surprising ordering in the UI.

Suggested change
Result.success(existing + unique)
Result.success(unique + existing)

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +29
.mapNotNull { response ->
response.data?.let { data ->
Result.success(listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem()))
}
}
.catch { /* subscription error — silently stop, initial data still shown */ }
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mapping drops any subscription events that don’t have data and ignores GraphQL errors/response.exception, so failures won’t be visible to callers. Please convert the subscription responses into Result consistently (similar to toResultFlow() used elsewhere) so errors can be propagated or handled explicitly.

Suggested change
.mapNotNull { response ->
response.data?.let { data ->
Result.success(listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem()))
}
}
.catch { /* subscription error — silently stop, initial data still shown */ }
.map { response ->
val exception = response.exception
val errors = response.errors
val data = response.data
when {
exception != null -> {
Result.failure<List<FeedItem>>(exception)
}
errors != null && errors.isNotEmpty() -> {
val message = errors.joinToString(separator = "; ") { it.message }
Result.failure<List<FeedItem>>(RuntimeException("GraphQL subscription error(s): $message"))
}
data != null -> {
Result.success(
listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem())
)
}
else -> {
Result.failure<List<FeedItem>>(
IllegalStateException("Subscription response has no data, errors or exception")
)
}
}
}
.catch { throwable ->
emit(Result.failure<List<FeedItem>>(throwable))
}

Copilot uses AI. Check for mistakes.
Result.success(listOf(data.feedMessageAdded.feedMessageDetails.toFeedItem()))
}
}
.catch { /* subscription error — silently stop, initial data still shown */ }
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing subscription exceptions here makes real-time failures silent and hard to diagnose. Please at least log/report the exception, or emit a Result.failure so the UI can reflect connectivity issues appropriately.

Suggested change
.catch { /* subscription error — silently stop, initial data still shown */ }
.catch { emit(Result.failure(it)) }

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +58
private fun MessageType.label(): String = when (this) {
MessageType.INFO -> "INFO"
MessageType.ALERT -> "ALERT"
MessageType.ANNOUNCEMENT -> "ANNOUNCEMENT"
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message type labels are hardcoded ("INFO"/"ALERT"/"ANNOUNCEMENT"). Since the app ships with localized string resources (including values-fr), these user-visible labels should be sourced from compose resources so they can be translated consistently.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants