warpdroid: align timeline display with desktop frontend#164
warpdroid: align timeline display with desktop frontend#164filinvadim merged 6 commits intodevelopfrom
Conversation
Three regressions versus the Vue frontend, all in the timeline path:
* Tweet stats (likes / retweets / replies / views) were hard-coded to 0
in WarpnetMapper.toStatus and never refreshed when hydrating the home
feed. Fetch PUBLIC_GET_TWEET_STATS for each tweet in parallel during
hydrateStatuses and merge the counts into the Status.
* The @-handle rendered the friendly username ("@vadim") instead of
the canonical peer-derived user_id that the desktop prints
("@01KQJF..."). Switch WarpnetMapper.{toAccount,toTimelineAccount,
stubTimelineAccount} so localUsername/username carry the user_id
and displayName carries the friendly username.
* Posts from the mobile silently failed because postStatus sent
created_at = "" and json-iterator rejects the empty string before
TweetRepo.Create's zero-time fallback runs. Send the current
RFC3339 timestamp instead.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The previous fix in this PR sent Instant.now().toString() to dodge a json-iterator decode error on the empty-string created_at. The right fix is to leave the field absent so the backend's TweetRepo.CreateWithTTL fallback (database/tweet-repo.go:152) stamps time.Now() on the server side. Make WarpnetTweet.createdAt nullable with default null; postStatus and reblogStatus drop their explicit "" assignments. json-iterator decodes null and missing into a zero time.Time, which the repo overwrites. The empty string is the only encoding that fails decode; we no longer emit it.
Logs from a paired session show no decode error from /private/post/tweet/0.0.0, so the empty-string created_at theory was wrong. Restore WarpnetTweet.createdAt to non-nullable, restore the postStatus and reblogStatus payloads, restore parseDate's signature. Stats hydration and the @-handle changes earlier in this PR remain; those are independent and visibly broken in the screenshots.
CI build failure (
|
failOrRetry was a Tusky branch on HttpException; Warpnet never throws that, so every transport failure looped silently in retrySending and the post just disappeared. Cap at MAX_SEND_RETRIES (5) and fall through to failSending so the user sees an error notification.
There was a problem hiding this comment.
Pull request overview
Aligns Warpdroid’s timeline/status rendering with the desktop frontend by hydrating tweet stats, normalizing how user handles are mapped into Mastodon-shaped models, and tightening status-send retry behavior.
Changes:
- Fetch per-tweet stats in parallel during timeline hydration and merge counts into
Status. - Map
Account/TimelineAccountusernames to the peer-deriveduser_idso@<id>matches desktop. - Adjust send retry logic and bump the repo
version.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
warpdroid/app/src/main/java/site/warpnet/warpdroid/warpnet/WarpnetRepository.kt |
Parallel tweet-stats fanout during timeline hydration; merge stats into Status. |
warpdroid/app/src/main/java/site/warpnet/warpdroid/warpnet/WarpnetMapper.kt |
Change handle mapping so username / localUsername use id for @... parity with desktop. |
warpdroid/app/src/main/java/site/warpnet/warpdroid/service/SendStatusService.kt |
Remove HTTP-specific retry branch and add bounded retry limit constant. |
version |
Bump version string. |
Comments suppressed due to low confidence (1)
warpdroid/app/src/main/java/site/warpnet/warpdroid/service/SendStatusService.kt:326
- The KDoc for
failOrRetrysays it uses "exponential backoff", butretrySending()currently uses a linear backoff ofretriesseconds (capped). Either update the implementation to exponential (e.g., 2^retries) or adjust the comment so it matches the actual behavior.
/**
* Retry transient transport failures with exponential backoff up to
* MAX_SEND_RETRIES, then surface a user-visible error notification.
*
* Warpnet never throws HttpException (no HTTP), so the original
* Tusky branch on HttpException always fell through to retrySending
* which busy-looped silently. Replace it with a bounded retry that
* has to either land or fail loudly - "post or throw", no third
* option.
*/
private suspend fun failOrRetry(throwable: Throwable, statusId: Int) {
val statusToSend = statusesToSend[statusId] ?: return
if (statusToSend.retries >= MAX_SEND_RETRIES) {
Log.w(TAG, "giving up on status $statusId after ${statusToSend.retries} retries", throwable)
failSending(statusId)
return
}
retrySending(statusId)
}
private suspend fun retrySending(statusId: Int) {
// when statusToSend == null, sending has been canceled
val statusToSend = statusesToSend[statusId] ?: return
val backoff = TimeUnit.SECONDS.toMillis(
statusToSend.retries.toLong()
).coerceAtMost(MAX_RETRY_INTERVAL)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| favouritesCount = s.likesCount.toInt(), | ||
| reblogsCount = s.retweetsCount.toInt(), | ||
| repliesCount = s.repliesCount.toInt(), | ||
| viewsCount = s.viewsCount.toInt(), | ||
| ) | ||
| } | ||
| } | ||
|
|
|
|
||
|
|
There was a problem hiding this comment.
The trailing blank lines were already in version before this PR (the file has been a version line + 3 newlines for several releases). This PR's diff is only 0.6.302 → 0.6.304; the trailing whitespace is unchanged.
Generated by Claude Code
| val viewerId = pairedNodeStore.load()?.userId.orEmpty() | ||
| val stats = tweets.associate { t -> | ||
| t.id to async { | ||
| runCatching { getTweetStats(tweetId = t.id, userId = viewerId) }.getOrNull() | ||
| } |
| private suspend fun hydrateStatuses(tweets: List<WarpnetTweet>): List<Status> = coroutineScope { | ||
| if (tweets.isEmpty()) return@coroutineScope emptyList() | ||
| val cache = mutableMapOf<String, WarpnetUser>() | ||
| return tweets.map { t -> t.toStatus(resolveUser(t.userId, cache)) } | ||
| // Stats are fetched per tweet in parallel so a 30-tweet timeline |
There was a problem hiding this comment.
Not the bug — the Go backend's tweet-create path runs through CreateWithTTL, which has a time.Time.IsZero() → time.Now() fallback. Empty created_at is normalised server-side, so this is intentional. The actual silent-post regression was in SendStatusService.failOrRetry (no bounded retry on non-HttpException paths); fix is in the latest commit on this PR.
Generated by Claude Code
- WarpnetRepository: clamp stats Long->Int via clampToInt() so an overflowed counter doesn't render as a negative number, and short-circuit hydrateStatuses when viewerId is blank to avoid a per-tweet RPC fanout that the backend would just reject. - SendStatusService: KDoc said "exponential backoff" but retrySending uses linear; align the comment.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Retry transient transport failures with linear backoff up to | ||
| * MAX_SEND_RETRIES, then surface a user-visible error notification. | ||
| * | ||
| * Warpnet never throws HttpException (no HTTP), so the original | ||
| * Tusky branch on HttpException always fell through to retrySending | ||
| * which busy-looped silently. Replace it with a bounded retry that | ||
| * has to either land or fail loudly - "post or throw", no third | ||
| * option. | ||
| */ | ||
| private suspend fun failOrRetry(throwable: Throwable, statusId: Int) { | ||
| if (throwable is HttpException) { | ||
| // the server refused to accept, save status & show error message | ||
| val statusToSend = statusesToSend[statusId] ?: return | ||
| if (statusToSend.retries >= MAX_SEND_RETRIES) { | ||
| Log.w(TAG, "giving up on status $statusId after ${statusToSend.retries} retries", throwable) | ||
| failSending(statusId) | ||
| } else { | ||
| // a network problem occurred, let's retry sending the status | ||
| retrySending(statusId) | ||
| return | ||
| } | ||
| retrySending(statusId) | ||
| } |
| if (statusToSend.retries >= MAX_SEND_RETRIES) { | ||
| Log.w(TAG, "giving up on status $statusId after ${statusToSend.retries} retries", throwable) | ||
| failSending(statusId) |
| val stats = tweets.associate { t -> | ||
| t.id to async { | ||
| runCatching { getTweetStats(tweetId = t.id, userId = viewerId) }.getOrNull() | ||
| } | ||
| } |
| /** | ||
| * Retry transient transport failures with linear backoff up to | ||
| * MAX_SEND_RETRIES, then surface a user-visible error notification. | ||
| * | ||
| * Warpnet never throws HttpException (no HTTP), so the original | ||
| * Tusky branch on HttpException always fell through to retrySending | ||
| * which busy-looped silently. Replace it with a bounded retry that | ||
| * has to either land or fail loudly - "post or throw", no third | ||
| * option. |
- failOrRetry: log "attempts" not "retries" (retries is incremented at the start of sendStatus, so it's the attempt count by the time failOrRetry reads it). Adjust KDoc to match the broader retry policy. - hydrateStatuses: de-dup tweet ids before fanning out the stats RPC; retweets reuse the original id and would otherwise fire the same request twice.
Summary
Three regressions in the Android timeline path versus the Vue desktop frontend.
1. Counts hard-coded to zero in the timeline
WarpnetMapper.toStatusbaselined like / retweet / reply / view counts at0andWarpnetRepository.hydrateStatusesnever called the stats endpoint. The home and profile feeds therefore showed0 / 0 / 0under every tweet, while the desktop showed real counts.hydrateStatusesnow fansPUBLIC_GET_TWEET_STATSacross all tweets in parallel viacoroutineScope { … async { … } }(de-duped by tweet id, since retweets reuse the original id) and merges the result into eachStatuswith.copy(…). A failed stats RPC degrades silently to zero, matching the existingtoStatusbaseline. When the local node is unpaired (viewerId.isBlank()) the fanout is skipped entirely; the backend would reject emptyuser_idanyway.Long → Intis clamped to defend the UI against an overflowed counter.2.
@-handle rendered the friendly usernameDesktop prints the long peer-derived user_id (
@01KQJF…); Android was printing@Vadim.WarpnetMapper.{toAccount, toTimelineAccount, stubTimelineAccount}now setlocalUsername = id,username = id, and keepdisplayName = username. Mastodon-shaped UI code that resolves@now produces the same handle the desktop does.3. Posts silently disappeared
The actual fix lives in
SendStatusService.failOrRetry. The Tusky original branched onHttpExceptionto decide betweenfailSending(visible error notification) andretrySending(linear backoff). Warpnet has no HTTP layer and so never throwsHttpException— every transport failure routed toretrySendingand looped forever, so a libp2p flap during posting was indistinguishable from "nothing happened".failOrRetryis now bounded: retry up toMAX_SEND_RETRIES(5), then fall through tofailSendingso the user sees the error notification and the draft is preserved. Emptycreated_aton the wire is intentional — the Go backend'sCreateWithTTLalready substitutestime.Now()whentweet.CreatedAt.IsZero().Test plan
@<peer-id>is shown next to the friendly display name.https://claude.ai/code/session_01QLKpuj54ZBVqXd17R1v6ty
Generated by Claude Code