feat(thread): iOS-parity depth connectors, reply order, sticky reply bar + publish gating (port wisp #567)

- Replace stacked vertical lines with single L-shaped connector per non-root
  reply: 12dp indent step capped at depth 5, 8dp corner radius, 1dp stroke,
  suppresses PostCard full-width divider so no line extends left of the curve
- Sort thread children purely by created_at oldest-first; removes own-replies-
  first bubbling to match iOS ThreadViewModel.buildNestedReplies
- Add ThreadReplyBar sticky bottom composable: 0.5dp divider + surfaceVariant
  pill with "Reply…" placeholder and orange edit icon; tap opens compose with
  the focal event as reply parent
- Gate Publish button on non-blank text or at least one uploaded attachment
This commit is contained in:
The Daniel
2026-06-12 13:35:24 -04:00
parent f3f8c0b199
commit 669cc87e35
3 changed files with 97 additions and 24 deletions
@@ -1219,7 +1219,7 @@ fun ComposeScreen(
resolvedEmojis = resolvedEmojis
)
},
enabled = !publishing && !isMiningBusy,
enabled = !publishing && !isMiningBusy && (content.text.isNotBlank() || uploadedUrls.isNotEmpty()),
modifier = Modifier.fillMaxWidth()
) {
Text(
@@ -20,8 +20,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -29,6 +31,8 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.res.stringResource
import com.darkwisp.app.R
import androidx.compose.material3.TopAppBar
@@ -49,6 +53,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -208,6 +215,7 @@ fun ThreadScreen(
Toast.makeText(zapDisabledContext, zapDisabledMessage, Toast.LENGTH_SHORT).show()
}
val focalEvent = flatThread.firstOrNull()?.first
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
@@ -222,6 +230,12 @@ fun ThreadScreen(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
bottomBar = {
ThreadReplyBar(
enabled = focalEvent != null,
onClick = { focalEvent?.let { onReply(it) } }
)
}
) { padding ->
if (isLoading && flatThread.isEmpty()) {
@@ -278,23 +292,40 @@ fun ThreadScreen(
val userZapPollVote = remember(pollVoteVersion, event.id) {
if (event.kind == 6969) eventRepo.getUserZapPollVote(event.id) else null
}
val indentDp = 12
val clampedDepth = min(depth, 8)
val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)
val indentStepDp = 12.dp
val clampedDepth = min(depth, 5)
val cornerRadiusDp = 8.dp
val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
val showConnector = depth > 0
Box(
modifier = Modifier
.fillMaxWidth()
.drawBehind {
val indentPx = indentDp.dp.toPx()
for (level in 0 until clampedDepth) {
val x = level * indentPx + indentPx / 2f
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = 1.5.dp.toPx()
)
}
if (!showConnector) return@drawBehind
val lineX = (clampedDepth * indentStepDp.toPx()) - 8.dp.toPx() + 1.dp.toPx()
val r = cornerRadiusDp.toPx()
val strokePx = 1.dp.toPx()
drawLine(
color = lineColor,
start = Offset(lineX, 0f),
end = Offset(lineX, size.height - r),
strokeWidth = strokePx
)
drawArc(
color = lineColor,
startAngle = 90f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(lineX, size.height - 2f * r),
size = Size(2f * r, 2f * r),
style = Stroke(width = strokePx, cap = StrokeCap.Round)
)
drawLine(
color = lineColor,
start = Offset(lineX + r, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokePx
)
}
) {
if (isGalleryEvent(event)) {
@@ -340,7 +371,8 @@ fun ThreadScreen(
nip05Repo = nip05Repo,
onQuotedNoteClick = onQuotedNoteClick,
noteActions = noteActions,
modifier = Modifier.padding(start = (clampedDepth * indentDp).dp)
showDivider = !showConnector,
modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp)
)
} else {
PostCard(
@@ -398,7 +430,8 @@ fun ThreadScreen(
zapPollTotalSats = zapPollTotalSats,
userZapPollVote = userZapPollVote,
onZapPollVote = { idx -> onZapPollVote(event.id, idx) },
modifier = Modifier.padding(start = (clampedDepth * indentDp).dp)
showDivider = !showConnector,
modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp)
)
}
}
@@ -578,3 +611,50 @@ private fun SpamToggle(count: Int, expanded: Boolean, onToggle: () -> Unit) {
}
}
@Composable
private fun ThreadReplyBar(
enabled: Boolean,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
thickness = 0.5.dp
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(18.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Text(
text = "Reply…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Outlined.Edit,
contentDescription = "Reply",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
@@ -466,16 +466,9 @@ class ThreadViewModel : ViewModel() {
scoreAuthorsAsync(pubkeysToScore)
}
val myPubkey = currentUserPubkey
for (children in parentToChildren.values) {
children.sortWith(Comparator { a, b ->
val aIsOwn = myPubkey != null && a.pubkey == myPubkey
val bIsOwn = myPubkey != null && b.pubkey == myPubkey
if (aIsOwn != bIsOwn) {
if (aIsOwn) -1 else 1
} else {
a.created_at.compareTo(b.created_at)
}
a.created_at.compareTo(b.created_at)
})
}