Most messaging apps optimize for one thing: delivery speed.
Message typed. Message sent. Message delivered. Done. The entire pipeline is built around getting bytes from point A to point B as fast as possible.
Nobody stops to ask: should this message be sent at all?
I've been building a messaging app called Kindly for the past couple of years. It's a Flutter app backed by Supabase, and the feature that gets the most reaction from other developers isn't the real-time chat system or the media handling. It's a tiny feature I almost didn't build: the Tone Check.
What the Tone Check actually does
Before a message leaves your device, Kindly runs a lightweight keyword analysis on the text. If the message contains patterns that commonly read as harsh, dismissive, or aggressive in text form, a small non-blocking banner appears:
"This might sound a bit sharp. Send anyway?"
The user can dismiss it instantly and send. There's no gate. No censorship. Just a one-second pause that lets the sender re-read their own words with fresh eyes.
Here's why this matters technically: text-based communication strips away every non-verbal signal that humans rely on. No facial expressions. No vocal tone. No body language. Studies show that the receiver interprets the emotional tone of a text message differently from the sender roughly 50% of the time. Half of all text messages land with a different emotional weight than intended.
The Tone Check doesn't use a heavy ML model. It's a local keyword and pattern matcher that runs entirely on-device. No API calls. No latency. No privacy concerns from sending message content to a server.
The implementation approach
The analyzer runs through a few layers:
Layer 1: Direct keyword flags. Words and phrases that almost always read as aggressive in text: "whatever," "fine," "I don't care," "do what you want." These aren't inherently rude in speech, but in text they carry weight.
Layer 2: Punctuation patterns. ALL CAPS detection. Excessive exclamation marks. Periods at the end of short messages (research shows a single period after "Ok." reads as passive-aggressive to younger users).
Layer 3: Absence of softeners. Messages that are commands without courtesy words. "Send me the file" vs "Could you send me the file?" The analyzer flags when a message is imperative without softening language.
The entire thing runs in under 5 milliseconds on a mid-range Android device. It's synchronous — the send button checks the result before dispatching. If the check passes, the message goes out with zero perceived delay.
dart// Simplified concept — actual implementation has more nuance
class ToneAnalyzer {
static ToneResult analyze(String message) {
final lower = message.toLowerCase().trim();
double sharpness = 0.0;
// Layer 1: keyword detection
for (final pattern in _harshPatterns) {
if (lower.contains(pattern.text)) {
sharpness += pattern.weight;
}
}
// Layer 2: punctuation
if (message == message.toUpperCase() && message.length > 3) {
sharpness += 0.4; // ALL CAPS
}
// Layer 3: imperative without softener
if (_isImperative(lower) && !_hasSoftener(lower)) {
sharpness += 0.2;
}
return ToneResult(
shouldWarn: sharpness >= 0.5,
sharpness: sharpness,
);
}
}
The threshold is configurable per chat. Some people want it sensitive. Some want it only for obvious aggression. The setting persists locally — no server roundtrip needed.
The Breathing Space: synced cooldowns over Supabase Realtime
The second feature I want to talk about is Breathing Space. This one required more interesting architecture.
When a conversation gets heated, either participant can activate a 60-second cooldown. Both users see a breathing animation overlay simultaneously. Neither can send messages during the cooldown. After 60 seconds, the conversation resumes.
The tricky part: this needs to be synced in real-time across two devices with minimal latency.
The implementation uses Supabase Realtime channels. When a user activates Breathing Space, the client broadcasts an event on the conversation's channel:
dartawait supabase
.channel('chat:${conversationId}')
.sendBroadcastMessage(
event: 'breathing_space',
payload: {
'activated_by': currentUserId,
'started_at': DateTime.now().toIso8601String(),
'duration_seconds': 60,
},
);
The receiving client listens for this event and immediately shows the overlay. Both clients run independent 60-second timers. There's a 5-minute cooldown between activations to prevent abuse.
The interesting edge case: what if one client has a slightly different clock? We use the started_at timestamp from the activating client and compute the remaining time on receive. If the receiving client gets the event 2 seconds late, it shows 58 seconds remaining. Close enough for a breathing exercise.
Real-time messaging architecture
Since I'm here, let me share the broader messaging architecture because other Flutter devs might find it useful.
Kindly uses Supabase for everything: auth, database, realtime, and storage. The chat system works like this:
Messages are stored in a PostgreSQL table with standard fields: id, conversation_id, sender_id, content, message_type, created_at. Nothing unusual there.
Real-time delivery uses Supabase Realtime's Postgres Changes listener. When a new row hits the messages table, all subscribed clients receive it instantly:
dartsupabase
.channel('messages:${conversationId}')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages',
filter: PostgresChangeFilter(
type: PostgresChangeFilterType.eq,
column: 'conversation_id',
value: conversationId,
),
callback: (payload) {
final message = Message.fromJson(payload.newRecord);
_addMessageToState(message);
},
)
.subscribe();
Read receipts use a separate broadcast channel. When a user opens a conversation, the client broadcasts a read event with the last message ID. The other client updates the UI accordingly. This avoids writing to the database on every read — we batch-update read status every 30 seconds.
Typing indicators are pure broadcast — no database persistence at all. One client broadcasts typing: true, the other shows the indicator. After 3 seconds of no typing broadcast, the indicator disappears.
Online presence uses Supabase Realtime Presence. Each client tracks itself with a last_seen timestamp. The green dot on avatars updates in real-time.
The Warmth Score: a reputation system that actually works
Most social apps use follower counts or like totals as reputation. These are easily gamed and don't reflect character.
Kindly uses a Warmth Score — a single number from 0 to 100 that reflects how you treat people. The scoring is straightforward:
Create a pulse (our version of stories): +2
Receive a reaction on your pulse: +2
Someone fully views your pulse: +1
Add a new contact (both sides get it): +3
Get blocked by someone: -10
Inactivity decay: -1 per day after 3 days of no positive activity
The score updates in real-time via a database trigger. When the underlying events happen, a PostgreSQL function recalculates the score and the client's Realtime subscription picks up the change.
What makes it interesting is the decay mechanic. If you stop engaging positively, your score drops. Not as punishment — but as a signal that this person hasn't been active recently. It creates a gentle incentive to show up and be kind, rather than a pressure to post content.
Pulses: stories with a philosophy
Our content format is called Pulses. They're similar to stories but with two key design decisions:
Expiry control. The creator chooses how long their pulse lives: 1 hour, 6 hours, 12 hours, 24 hours, or 7 days. This isn't just a product feature — it required implementing a scheduled cleanup job in Supabase. We use a pg_cron extension to run a deletion query every hour that removes expired pulses and their associated media from storage.
Mindful Timer. Before you can react to someone's pulse, you must view it for a minimum duration based on content length. A text pulse requires 3 seconds of viewing. A photo pulse requires 2 seconds. The timer runs client-side, and the react button stays disabled until the timer completes.
This one design decision eliminated low-effort engagement. When reacting requires actually consuming the content first, the reactions that do come through carry real weight.
Visibility tiers
Every pulse has a visibility setting: Everyone, Contacts, Close, Core, or Only Me. The interesting technical bit is how this maps to Row Level Security in Supabase.
Each visibility tier maps to a different RLS policy that checks the relationship between the viewer and the pulse creator. The "Core" tier, for example, checks a separate contact_tiers table where the creator has explicitly marked specific contacts as Core.
sqlCREATE POLICY "Core visibility" ON pulses
FOR SELECT USING (
visibility = 'core' AND (
auth.uid() = user_id OR
EXISTS (
SELECT 1 FROM contact_tiers
WHERE contact_tiers.user_id = pulses.user_id
AND contact_tiers.contact_id = auth.uid()
AND contact_tiers.tier = 'core'
)
)
);
This means the database itself enforces who can see what. The client doesn't need to filter — it just queries and RLS handles the rest.
What I'd do differently
If I started over, a few things I'd change:
State management. I used Riverpod, which works great, but the learning curve for new contributors is steep. For a messaging app specifically, a simpler approach with ChangeNotifier and streams might have been easier to reason about.
Offline support. I should have built offline-first from day one. Adding it retroactively to a real-time app is painful. If you're building a chat app, use a local SQLite database as the source of truth and sync with the server in the background.
Media handling. Uploading images and videos to Supabase Storage works fine, but I wish I'd implemented progressive upload and thumbnail generation earlier. Users on slow Malaysian mobile networks notice when a 5MB photo takes 8 seconds to send.
Try it
Kindly is live on Google Play. If you're interested in the intersection of communication technology and emotional design, give it a try.
If you're a Flutter developer building real-time features with Supabase, I'm happy to answer questions in the comments.
Download:https://play.google.com/store/apps/details?id=com.kindly.kindly&pcampaignid=web_share

Top comments (0)