androidengineers.Book a session

Case Study: Design Twitter/X + Interview Framework

Tweet Composition & Media

article20 minMedium

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

PatternRule
URL = 23 charsReplace URL with placeholder before counting
Autosave on inactivity2s debounce; clear draft on successful submit
Media validationMax 4 images OR 1 video; no media with poll
Char counter UIGray > 20 remaining; amber ≤ 20; red < 0
Submit disabledisValid = false → button disabled; don't let user wonder why submit fails
Thread compositionEach "tweet" in a thread is a separate ComposeState; linked by replyToId

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Tweet Composition & Media | Android System Design | Android Engineers