<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: real name hidden</title>
    <description>The latest articles on DEV Community by real name hidden (@real_c2e6c6b75306e5d).</description>
    <link>https://dev.to/real_c2e6c6b75306e5d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3828590%2F490aa2b5-3869-4264-b31e-f2aaa11baf8c.png</url>
      <title>DEV Community: real name hidden</title>
      <link>https://dev.to/real_c2e6c6b75306e5d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/real_c2e6c6b75306e5d"/>
    <language>en</language>
    <item>
      <title>I replaced the like button with a weighted slider. Here's the architecture behind it.</title>
      <dc:creator>real name hidden</dc:creator>
      <pubDate>Tue, 17 Mar 2026 05:24:35 +0000</pubDate>
      <link>https://dev.to/real_c2e6c6b75306e5d/i-replaced-the-like-button-with-a-weighted-slider-heres-the-architecture-behind-it-50ph</link>
      <guid>https://dev.to/real_c2e6c6b75306e5d/i-replaced-the-like-button-with-a-weighted-slider-heres-the-architecture-behind-it-50ph</guid>
      <description>&lt;p&gt;Every social platform has a reaction system. Facebook has six emoji reactions. Twitter has a heart. Instagram has a heart. Reddit has upvote/downvote. They all share the same fundamental design: a binary or near-binary signal that tells the algorithm "more of this" or "less of this."&lt;br&gt;
I built a social platform called LINKWAVEZ where the reaction system works differently. Instead of a like button, users respond with a Resonance Slider — a continuous input that weights their reaction between two values: Aura (emotional impact) and Wisdom (intellectual impact).&lt;br&gt;
This post explains the technical architecture behind that system and the dual scoring engine it feeds into.&lt;br&gt;
The Resonance Slider&lt;br&gt;
When a user sees a post (we call them Poses), they can react by dragging a slider between two poles. Full left is pure Aura — the post moved them emotionally. Full right is pure Wisdom — the post made them think. Most reactions land somewhere in the middle.&lt;br&gt;
The slider outputs a value between 0.0 and 1.0, where 0.0 is pure Aura and 1.0 is pure Wisdom. The database stores both the raw slider value and the computed weights:&lt;br&gt;
sqlCREATE TABLE pose_reactions (&lt;br&gt;
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),&lt;br&gt;
  pose_id UUID NOT NULL REFERENCES poses(id) ON DELETE CASCADE,&lt;br&gt;
  user_id UUID NOT NULL REFERENCES auth.users(id),&lt;br&gt;
  slider_value FLOAT NOT NULL CHECK (slider_value &amp;gt;= 0 AND slider_value &amp;lt;= 1),&lt;br&gt;
  aura_weight FLOAT GENERATED ALWAYS AS (1.0 - slider_value) STORED,&lt;br&gt;
  wisdom_weight FLOAT GENERATED ALWAYS AS (slider_value) STORED,&lt;br&gt;
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),&lt;br&gt;
  UNIQUE(pose_id, user_id)&lt;br&gt;
);&lt;br&gt;
The aura_weight and wisdom_weight columns are generated columns — computed automatically from the slider value. This avoids any client-server mismatch in weight calculation.&lt;br&gt;
How reactions flow into the scoring system&lt;br&gt;
LINKWAVEZ has a dual scoring system: every user has an Aura score and a Wisdom score. These are aggregate reputation metrics that reflect how you impact the community.&lt;br&gt;
When someone reacts to your post with the Resonance Slider, the reaction flows through a scoring pipeline:&lt;br&gt;
Reaction event&lt;br&gt;
  → Calculate base points (3 points per reaction)&lt;br&gt;
  → Split by slider weights&lt;br&gt;
  → Apply area multiplier (Feed area = 1.0x)&lt;br&gt;
  → Check daily caps&lt;br&gt;
  → Credit to post creator's Aura and Wisdom&lt;br&gt;
  → Update area scores&lt;br&gt;
  → Recalculate overall scores&lt;br&gt;
Here's the Node.js handler:&lt;br&gt;
javascriptasync function processReaction(poseId, reactorId, sliderValue) {&lt;br&gt;
  const basePoints = 3;&lt;br&gt;
  const auraPoints = basePoints * (1.0 - sliderValue);&lt;br&gt;
  const wisdomPoints = basePoints * sliderValue;&lt;/p&gt;

&lt;p&gt;// Check daily caps before crediting&lt;br&gt;
  const todayScores = await getTodayScores(creatorId, 'feed');&lt;/p&gt;

&lt;p&gt;const cappedAura = Math.min(&lt;br&gt;
    auraPoints, &lt;br&gt;
    DAILY_AURA_CAP_FEED - todayScores.aura&lt;br&gt;
  );&lt;br&gt;
  const cappedWisdom = Math.min(&lt;br&gt;
    wisdomPoints, &lt;br&gt;
    DAILY_WISDOM_CAP_FEED - todayScores.wisdom&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;if (cappedAura &amp;gt; 0 || cappedWisdom &amp;gt; 0) {&lt;br&gt;
    await creditScores(creatorId, 'feed', cappedAura, cappedWisdom);&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
The daily cap system&lt;br&gt;
This is where the design gets interesting from an anti-gaming perspective.&lt;br&gt;
LINKWAVEZ has 9 scoring areas: Feed, Moments, Groups, Marketplace, Events, Social, Mentorship, Partnership, and Echo. Each area has separate Aura and Wisdom sub-scores. The overall score is a weighted average across all areas.&lt;br&gt;
Every area has a daily cap. The total maximum across all areas is 275 Aura points per day. No matter how viral your content goes, you can't earn more than the cap in a single day.&lt;br&gt;
javascriptconst DAILY_CAPS = {&lt;br&gt;
  feed:        { aura: 70, wisdom: 50 },&lt;br&gt;
  moments:     { aura: 30, wisdom: 30 },&lt;br&gt;
  groups:      { aura: 40, wisdom: 35 },&lt;br&gt;
  marketplace: { aura: 25, wisdom: 15 },&lt;br&gt;
  events:      { aura: 25, wisdom: 15 },&lt;br&gt;
  social:      { aura: 35, wisdom: 25 },&lt;br&gt;
  mentorship:  { aura: 20, wisdom: 30 },&lt;br&gt;
  partnership: { aura: 15, wisdom: 20 },&lt;br&gt;
  echo:        { aura: 15, wisdom: 25 },&lt;br&gt;
};&lt;br&gt;
// Total max aura/day: 275&lt;br&gt;
Why does this matter technically? Because the cap check needs to be atomic. If 50 people react to your post simultaneously, you can't just credit all of them and check the cap afterward — you'd overshoot. The solution is a PostgreSQL function with row-level locking:&lt;br&gt;
sqlCREATE OR REPLACE FUNCTION credit_score_capped(&lt;br&gt;
  p_user_id UUID,&lt;br&gt;
  p_area TEXT,&lt;br&gt;
  p_aura FLOAT,&lt;br&gt;
  p_wisdom FLOAT&lt;br&gt;
) RETURNS TABLE(credited_aura FLOAT, credited_wisdom FLOAT) AS $$&lt;br&gt;
DECLARE&lt;br&gt;
  current_daily RECORD;&lt;br&gt;
  cap RECORD;&lt;br&gt;
  actual_aura FLOAT;&lt;br&gt;
  actual_wisdom FLOAT;&lt;br&gt;
BEGIN&lt;br&gt;
  -- Lock the user's daily score row&lt;br&gt;
  SELECT * INTO current_daily &lt;br&gt;
  FROM daily_scores &lt;br&gt;
  WHERE user_id = p_user_id &lt;br&gt;
    AND area = p_area &lt;br&gt;
    AND score_date = CURRENT_DATE&lt;br&gt;
  FOR UPDATE;&lt;/p&gt;

&lt;p&gt;-- Calculate what we can actually credit&lt;br&gt;
  actual_aura := LEAST(p_aura, cap.aura - COALESCE(current_daily.aura, 0));&lt;br&gt;
  actual_wisdom := LEAST(p_wisdom, cap.wisdom - COALESCE(current_daily.wisdom, 0));&lt;/p&gt;

&lt;p&gt;-- Credit&lt;br&gt;
  INSERT INTO daily_scores (user_id, area, score_date, aura, wisdom)&lt;br&gt;
  VALUES (p_user_id, p_area, CURRENT_DATE, actual_aura, actual_wisdom)&lt;br&gt;
  ON CONFLICT (user_id, area, score_date) &lt;br&gt;
  DO UPDATE SET &lt;br&gt;
    aura = daily_scores.aura + actual_aura,&lt;br&gt;
    wisdom = daily_scores.wisdom + actual_wisdom;&lt;/p&gt;

&lt;p&gt;RETURN QUERY SELECT actual_aura, actual_wisdom;&lt;br&gt;
END;&lt;br&gt;
$$ LANGUAGE plpgsql;&lt;br&gt;
The FOR UPDATE lock ensures that concurrent reactions don't blow past the cap. This is critical for any scoring system that has limits.&lt;br&gt;
The Emotional Temperature system&lt;br&gt;
Every post in LINKWAVEZ carries an Emotional Temperature — a mood tag set by the creator: Calm, Warm, Playful, Intense, or Charged.&lt;br&gt;
This feeds into two systems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Feed filtering. Users can filter their feed by energy level using the Vibe Spectrum Filter. The filter maps temperatures to a spectrum from Deep to Energized:
Deep ← Calm ← Warm ← Playful → Intense → Charged → Energized
The query is straightforward — it's just a WHERE clause on the temperature column. But the UX impact is significant. A user having a rough morning can filter to Calm and see only gentle, reflective content. The feed respects their emotional state.&lt;/li&gt;
&lt;li&gt;Auto Emoji Vibe. After a post receives 3 or more replies, the system analyzes the reply sentiment and assigns an emoji vibe to the post card. This is done with a simple keyword frequency analysis across all replies:
javascriptfunction detectVibe(replies) {
const signals = { hilarious: 0, fire: 0, heartfelt: 0, mindblown: 0 };&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;for (const reply of replies) {&lt;br&gt;
    const text = reply.content.toLowerCase();&lt;br&gt;
    if (/haha|lol|😂|🤣|funny|hilarious/.test(text)) signals.hilarious++;&lt;br&gt;
    if (/🔥|fire|amazing|incredible|insane/.test(text)) signals.fire++;&lt;br&gt;
    if (/❤️|💜|love|beautiful|heart|crying/.test(text)) signals.heartfelt++;&lt;br&gt;
    if (/🤯|mind|blown|wow|whoa|never thought/.test(text)) signals.mindblown++;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;const dominant = Object.entries(signals)&lt;br&gt;
    .sort((a, b) =&amp;gt; b[1] - a[1])[0];&lt;/p&gt;

&lt;p&gt;if (dominant[1] &amp;gt;= 3) return dominant[0];&lt;br&gt;
  return null;&lt;br&gt;
}&lt;br&gt;
When a vibe is detected, the post card gets a subtle color tint and emoji badge. It's a small touch but it makes the feed feel alive and responsive to the community's actual reactions.&lt;br&gt;
The feed algorithm: 70/20/10&lt;br&gt;
Most social feeds show you more of what you already like. LINKWAVEZ deliberately breaks that pattern.&lt;br&gt;
The feed composition is:&lt;/p&gt;

&lt;p&gt;70% content aligned with your interests and values&lt;br&gt;
20% content from different perspectives (the Echo Chamber Breaker)&lt;br&gt;
10% pure discovery from random users&lt;/p&gt;

&lt;p&gt;The implementation uses a weighted random sampling approach. For each feed load, we pull three separate query results and interleave them:&lt;br&gt;
javascriptasync function buildFeed(userId, page, perPage) {&lt;br&gt;
  const alignedCount = Math.round(perPage * 0.7);&lt;br&gt;
  const differentCount = Math.round(perPage * 0.2);&lt;br&gt;
  const discoveryCount = perPage - alignedCount - differentCount;&lt;/p&gt;

&lt;p&gt;const [aligned, different, discovery] = await Promise.all([&lt;br&gt;
    getAlignedPosts(userId, alignedCount),&lt;br&gt;
    getDifferentPerspectives(userId, differentCount),&lt;br&gt;
    getRandomDiscovery(userId, discoveryCount),&lt;br&gt;
  ]);&lt;/p&gt;

&lt;p&gt;// Interleave: don't cluster all "different" posts together&lt;br&gt;
  return interleave(aligned, different, discovery);&lt;br&gt;
}&lt;br&gt;
The getDifferentPerspectives query is the interesting one. It finds users whose reaction patterns on shared content differ significantly from the current user. If you consistently slide toward Wisdom on political posts and another user consistently slides toward Aura on the same posts, you have different perspectives worth exposing to each other.&lt;br&gt;
Users can adjust the 70/20/10 ratio with sliders in settings. Some people want more challenge. Some want more comfort. But the default is intentionally set to gently expand your bubble.&lt;br&gt;
Group auto-evolution&lt;br&gt;
One more system worth discussing: groups in LINKWAVEZ evolve automatically based on community activity.&lt;br&gt;
Every group starts as a Ripple (2-12 members). When it hits 13 active members and meets engagement thresholds, it auto-evolves into a Current (up to 100 members). Hit 100 with sustained activity, and it becomes a Tide (unlimited).&lt;br&gt;
Each evolution unlocks new features: more post types, admin tools, analytics, pin slots. The evolution check runs as a scheduled job:&lt;br&gt;
javascript// Runs daily at midnight&lt;br&gt;
async function checkGroupEvolutions() {&lt;br&gt;
  const ripples = await getGroupsByStage('ripple');&lt;/p&gt;

&lt;p&gt;for (const group of ripples) {&lt;br&gt;
    const activeMembers = await countActiveMembers(group.id, 7); // active in last 7 days&lt;br&gt;
    const weeklyPosts = await countRecentPosts(group.id, 7);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (activeMembers &amp;gt;= 13 &amp;amp;&amp;amp; weeklyPosts &amp;gt;= 20) {
  await evolveGroup(group.id, 'current');
  await notifyMembers(group.id, 'Your Ripple has grown into a Current!');
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
}&lt;br&gt;
The auto-evolution removes the burden from group admins to manually "upgrade" their community. The system recognizes organic growth and responds to it.&lt;br&gt;
The stack&lt;br&gt;
For anyone building something similar:&lt;/p&gt;

&lt;p&gt;Flutter for cross-platform mobile (single codebase for Android and iOS)&lt;br&gt;
Node.js for the backend API (Express, handles scoring logic and scheduled jobs)&lt;br&gt;
Supabase for auth, PostgreSQL database, Realtime subscriptions, and file storage&lt;br&gt;
Row Level Security policies handle all data access control at the database level&lt;/p&gt;

&lt;p&gt;Total monthly infrastructure cost for the current scale: under $100. Supabase's Pro plan handles a surprising amount of traffic before you need to think about scaling.&lt;br&gt;
What's live&lt;br&gt;
LINKWAVEZ has over 150 features across social feed, groups, marketplace, events, mentorship, and wellness modules. It's live on Google Play as "LINKWAVEZ: Calm Social."&lt;br&gt;
If you're interested in the technical side of building social platforms — especially the scoring, feed algorithms, or real-time architecture — happy to go deeper in the comments.&lt;br&gt;
Download: [&lt;a href="https://play.google.com/store/apps/details?id=com.linkwavez.app&amp;amp;pcampaignid=web_share" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.linkwavez.app&amp;amp;pcampaignid=web_share&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqckum54ulwm5f4anrjwn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqckum54ulwm5f4anrjwn.png" alt=" " width="800" height="418"&gt;&lt;/a&gt;]&lt;br&gt;
Website: linkwavez.com&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>supabase</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a pre-send tone analyzer into a chat app using Flutter and Supabase</title>
      <dc:creator>real name hidden</dc:creator>
      <pubDate>Tue, 17 Mar 2026 05:22:01 +0000</pubDate>
      <link>https://dev.to/real_c2e6c6b75306e5d/i-built-a-pre-send-tone-analyzer-into-a-chat-app-using-flutter-and-supabase-5cik</link>
      <guid>https://dev.to/real_c2e6c6b75306e5d/i-built-a-pre-send-tone-analyzer-into-a-chat-app-using-flutter-and-supabase-5cik</guid>
      <description>&lt;p&gt;Most messaging apps optimize for one thing: delivery speed.&lt;br&gt;
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.&lt;br&gt;
Nobody stops to ask: should this message be sent at all?&lt;br&gt;
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.&lt;br&gt;
What the Tone Check actually does&lt;br&gt;
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:&lt;/p&gt;

&lt;p&gt;"This might sound a bit sharp. Send anyway?"&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
The implementation approach&lt;br&gt;
The analyzer runs through a few layers:&lt;br&gt;
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.&lt;br&gt;
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).&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
dart// Simplified concept — actual implementation has more nuance&lt;br&gt;
class ToneAnalyzer {&lt;br&gt;
  static ToneResult analyze(String message) {&lt;br&gt;
    final lower = message.toLowerCase().trim();&lt;br&gt;
    double sharpness = 0.0;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Layer 1: keyword detection
for (final pattern in _harshPatterns) {
  if (lower.contains(pattern.text)) {
    sharpness += pattern.weight;
  }
}

// Layer 2: punctuation
if (message == message.toUpperCase() &amp;amp;&amp;amp; message.length &amp;gt; 3) {
  sharpness += 0.4; // ALL CAPS
}

// Layer 3: imperative without softener
if (_isImperative(lower) &amp;amp;&amp;amp; !_hasSoftener(lower)) {
  sharpness += 0.2;
}

return ToneResult(
  shouldWarn: sharpness &amp;gt;= 0.5,
  sharpness: sharpness,
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
}&lt;br&gt;
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.&lt;br&gt;
The Breathing Space: synced cooldowns over Supabase Realtime&lt;br&gt;
The second feature I want to talk about is Breathing Space. This one required more interesting architecture.&lt;br&gt;
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.&lt;br&gt;
The tricky part: this needs to be synced in real-time across two devices with minimal latency.&lt;br&gt;
The implementation uses Supabase Realtime channels. When a user activates Breathing Space, the client broadcasts an event on the conversation's channel:&lt;br&gt;
dartawait supabase&lt;br&gt;
  .channel('chat:${conversationId}')&lt;br&gt;
  .sendBroadcastMessage(&lt;br&gt;
    event: 'breathing_space',&lt;br&gt;
    payload: {&lt;br&gt;
      'activated_by': currentUserId,&lt;br&gt;
      'started_at': DateTime.now().toIso8601String(),&lt;br&gt;
      'duration_seconds': 60,&lt;br&gt;
    },&lt;br&gt;
  );&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Real-time messaging architecture&lt;br&gt;
Since I'm here, let me share the broader messaging architecture because other Flutter devs might find it useful.&lt;br&gt;
Kindly uses Supabase for everything: auth, database, realtime, and storage. The chat system works like this:&lt;br&gt;
Messages are stored in a PostgreSQL table with standard fields: id, conversation_id, sender_id, content, message_type, created_at. Nothing unusual there.&lt;br&gt;
Real-time delivery uses Supabase Realtime's Postgres Changes listener. When a new row hits the messages table, all subscribed clients receive it instantly:&lt;br&gt;
dartsupabase&lt;br&gt;
  .channel('messages:${conversationId}')&lt;br&gt;
  .onPostgresChanges(&lt;br&gt;
    event: PostgresChangeEvent.insert,&lt;br&gt;
    schema: 'public',&lt;br&gt;
    table: 'messages',&lt;br&gt;
    filter: PostgresChangeFilter(&lt;br&gt;
      type: PostgresChangeFilterType.eq,&lt;br&gt;
      column: 'conversation_id',&lt;br&gt;
      value: conversationId,&lt;br&gt;
    ),&lt;br&gt;
    callback: (payload) {&lt;br&gt;
      final message = Message.fromJson(payload.newRecord);&lt;br&gt;
      _addMessageToState(message);&lt;br&gt;
    },&lt;br&gt;
  )&lt;br&gt;
  .subscribe();&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Online presence uses Supabase Realtime Presence. Each client tracks itself with a last_seen timestamp. The green dot on avatars updates in real-time.&lt;br&gt;
The Warmth Score: a reputation system that actually works&lt;br&gt;
Most social apps use follower counts or like totals as reputation. These are easily gamed and don't reflect character.&lt;br&gt;
Kindly uses a Warmth Score — a single number from 0 to 100 that reflects how you treat people. The scoring is straightforward:&lt;/p&gt;

&lt;p&gt;Create a pulse (our version of stories): +2&lt;br&gt;
Receive a reaction on your pulse: +2&lt;br&gt;
Someone fully views your pulse: +1&lt;br&gt;
Add a new contact (both sides get it): +3&lt;br&gt;
Get blocked by someone: -10&lt;br&gt;
Inactivity decay: -1 per day after 3 days of no positive activity&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
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.&lt;br&gt;
Pulses: stories with a philosophy&lt;br&gt;
Our content format is called Pulses. They're similar to stories but with two key design decisions:&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Visibility tiers&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
sqlCREATE POLICY "Core visibility" ON pulses&lt;br&gt;
  FOR SELECT USING (&lt;br&gt;
    visibility = 'core' AND (&lt;br&gt;
      auth.uid() = user_id OR&lt;br&gt;
      EXISTS (&lt;br&gt;
        SELECT 1 FROM contact_tiers &lt;br&gt;
        WHERE contact_tiers.user_id = pulses.user_id &lt;br&gt;
        AND contact_tiers.contact_id = auth.uid() &lt;br&gt;
        AND contact_tiers.tier = 'core'&lt;br&gt;
      )&lt;br&gt;
    )&lt;br&gt;
  );&lt;br&gt;
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.&lt;br&gt;
What I'd do differently&lt;br&gt;
If I started over, a few things I'd change:&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Try it&lt;br&gt;
Kindly is live on Google Play. If you're interested in the intersection of communication technology and emotional design, give it a try.&lt;br&gt;
If you're a Flutter developer building real-time features with Supabase, I'm happy to answer questions in the comments.&lt;/p&gt;

&lt;p&gt;Download:&lt;a href="https://play.google.com/store/apps/details?id=com.kindly.kindly&amp;amp;pcampaignid=web_share" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.kindly.kindly&amp;amp;pcampaignid=web_share&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcbf0jns6yem0h94jro2v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcbf0jns6yem0h94jro2v.png" alt=" " width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>supabase</category>
      <category>mobile</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
