Tweet composition is deceptively complex: character limits with weighted characters, media attachments, poll creation, draft autosave, and thread composition all need careful UX design.
Character Counting (Twitter-Style)
Twitter counts URLs as 23 characters regardless of actual length, and weighted characters for non-Latin scripts:
class TweetCharacterCounter {
companion object {
const val MAX_CHARS = 280
const val URL_LENGTH = 23 // t.co shortlink length
private val URL_REGEX = Regex("https?://\\S+")
}
fun countWeighted(text: String): Int {
var count = 0
val textWithoutUrls = URL_REGEX.replace(text) { _ ->
count += URL_LENGTH
"" // remove URL from text before counting
}
// Count remaining characters
textWithoutUrls.forEach { char ->
count += if (char.code > 0xFFFF) 2 else 1 // surrogate pairs = 2
}
return count
}
fun remaining(text: String): Int = MAX_CHARS - countWeighted(text)
fun isValid(text: String): Boolean = remaining(text) >= 0 && text.isNotBlank()
}
Composition State
data class ComposeState(
val text: String = "",
val mediaAttachments: List<MediaAttachment> = emptyList(),
val replyTo: Tweet? = null,
val poll: Poll? = null,
val scheduledAt: Long? = null,
val threadItems: List<String> = emptyList() // for threads
) {
val charCount: Int get() = TweetCharacterCounter().countWeighted(text)
val charsRemaining: Int get() = 280 - charCount
val isValid: Boolean get() = text.isNotBlank() && charsRemaining >= 0 &&
mediaAttachments.size <= 4 &&
!(mediaAttachments.any { it.type == MediaType.VIDEO } && mediaAttachments.size > 1)
val canAddMedia: Boolean get() = poll == null &&
mediaAttachments.size < 4 &&
!mediaAttachments.any { it.type == MediaType.VIDEO }
val canAddPoll: Boolean get() = mediaAttachments.isEmpty() && poll == null
}
Draft Autosave
@HiltViewModel
class ComposeViewModel @Inject constructor(
private val draftRepository: DraftRepository,
private val tweetApi: TweetApi
) : ViewModel() {
private val _state = MutableStateFlow(ComposeState())
val state: StateFlow<ComposeState> = _state
private var autosaveJob: Job? = null
fun onTextChanged(text: String) {
_state.value = _state.value.copy(text = text)
scheduleAutosave()
}
private fun scheduleAutosave() {
autosaveJob?.cancel()
autosaveJob = viewModelScope.launch {
delay(2000) // save after 2s of inactivity
draftRepository.saveDraft(_state.value)
}
}
suspend fun submit(): Result<Tweet> {
val state = _state.value
if (!state.isValid) return Result.failure(IllegalStateException("Invalid tweet"))
// Upload media first if any
val uploadedUrls = state.mediaAttachments.map { attachment ->
tweetApi.uploadMedia(attachment.localFile)
}
return runCatching {
tweetApi.postTweet(
text = state.text,
mediaIds = uploadedUrls,
replyToId = state.replyTo?.id
)
}.also { result ->
if (result.isSuccess) draftRepository.clearDraft()
}
}
fun addMedia(uri: Uri) {
if (!_state.value.canAddMedia) return
val attachment = MediaAttachment(
localUri = uri,
localFile = uri.toFile(context),
type = uri.detectMediaType(context)
)
_state.value = _state.value.copy(
mediaAttachments = _state.value.mediaAttachments + attachment
)
}
}
Character Counter UI
@Composable
fun CharacterCountIndicator(charsRemaining: Int) {
val (color, progress) = when {
charsRemaining < 0 -> Color.Red to 1f
charsRemaining <= 20 -> Color(0xFFFF9800) to (1f - charsRemaining / 280f)
else -> Color.Gray to (1f - charsRemaining / 280f)
}
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = progress,
color = color,
trackColor = Color.Gray.copy(alpha = 0.2f),
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
if (charsRemaining <= 20) {
Text(
charsRemaining.toString(),
style = MaterialTheme.typography.labelSmall,
color = color,
fontSize = 10.sp
)
}
}
}
Media Grid
@Composable
fun MediaAttachmentGrid(
attachments: List<MediaAttachment>,
onRemove: (MediaAttachment) -> Unit
) {
when (attachments.size) {
0 -> {}
1 -> SingleMediaItem(attachments[0], onRemove)
2 -> Row(Modifier.aspectRatio(16f/9)) {
attachments.forEach { TwoUpMediaItem(it, onRemove, Modifier.weight(1f)) }
}
else -> {
// 3 or 4 in a grid
Column {
Row { attachments.take(2).forEach { GridMediaItem(it, onRemove, Modifier.weight(1f)) } }
Row { attachments.drop(2).forEach { GridMediaItem(it, onRemove, Modifier.weight(1f)) } }
}
}
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| URL = 23 chars | Replace URL with placeholder before counting |
| Autosave on inactivity | 2s debounce; clear draft on successful submit |
| Media validation | Max 4 images OR 1 video; no media with poll |
| Char counter UI | Gray > 20 remaining; amber ≤ 20; red < 0 |
| Submit disabled | isValid = false → button disabled; don't let user wonder why submit fails |
| Thread composition | Each "tweet" in a thread is a separate ComposeState; linked by replyToId |