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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user