DEV Community

Cover image for DotShare v3.1 — Toast Engine, Race Condition Fix, and Surviving Reddit's S3 Upload Pipeline
freerave
freerave

Posted on

DotShare v3.1 — Toast Engine, Race Condition Fix, and Surviving Reddit's S3 Upload Pipeline

Full technical breakdown of v3.1 'The Polish Pass' — a custom WebView toast system, global loading states, per-platform character limits, and the two bugs that took the longest to kill.

v3.0 added the platforms. v3.1 made them feel like they belonged.

"The Polish Pass" is the kind of release that's invisible when it works and infuriating when it doesn't. No new platforms. No headline features. Just: notifications that don't hijack your screen, buttons that tell you something is happening, character counters that actually enforce limits — and two bugs that were quietly breaking things since the beginning.

This is the full technical breakdown.


What's in v3.1

  • Custom WebView toast notification engine (replaces VS Code popups)
  • Global loading states on all async actions
  • Per-platform character limit validation with visual feedback
  • Glassmorphism UI pass
  • The Disappearing Preview race condition — fixed
  • Reddit's S3 native image upload pipeline — fixed
  • Reddit field name synchronization between frontend and backend
  • Unused variable cleanup for ESLint compliance

1. The Toast Notification Engine

Before v3.1, every status update used vscode.window.showInformationMessage() — a popup in the VS Code notification center that requires manual dismissal and breaks your flow. For an extension designed to keep you in the editor, it was wrong.

The replacement is a custom toast system that lives entirely inside the WebView.

DotShare toast notifications showing success, error, warning, and info states with animated progress bars

HTML Container

<!-- media/webview/platform-post.html -->
<div id="toast-container" aria-live="polite" aria-atomic="false"></div>
Enter fullscreen mode Exit fullscreen mode

One container, fixed to the bottom-right of the WebView. Toasts mount and remove themselves.

The Toast Function

// media/webview/app.ts

type ToastType = 'success' | 'error' | 'warning' | 'info';

const TOAST_ICONS: Record<ToastType, string> = {
  success: '',
  error:   '',
  warning: '',
  info:    '',
};

function escHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function toast(
  msg: string,
  type: ToastType = 'info',
  ms: number = 5000
): void {
  const container = document.getElementById('toast-container');
  if (!container) return;

  const el = document.createElement('div');
  el.className = `toast toast-${type}`;
  el.setAttribute('role', 'alert');
  el.style.setProperty('--toast-duration', `${ms}ms`);

  el.innerHTML = `
    <span class="toast-icon">${TOAST_ICONS[type]}</span>
    <span class="toast-message">${escHtml(msg)}</span>
    <div class="toast-progress"></div>
  `;

  container.appendChild(el);

  // Trigger enter animation on next paint
  requestAnimationFrame(() => el.classList.add('toast-visible'));

  if (ms > 0) {
    setTimeout(() => {
      el.classList.remove('toast-visible');
      el.classList.add('toast-exit');
      el.addEventListener('transitionend', () => el.remove(), { once: true });
    }, ms);
  }
}
Enter fullscreen mode Exit fullscreen mode

CSS

/* media/webview/style.css */

#toast-container {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  display: flex;
  flex-direction: column;
  gap: 8px;
  z-index: 9999;
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 16px;
  border-radius: 8px;
  font-size: 13px;
  min-width: 260px;
  max-width: 380px;
  position: relative;
  overflow: hidden;
  pointer-events: all;
  opacity: 0;
  transform: translateX(20px);
  transition: opacity 0.2s ease, transform 0.2s ease;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

.toast-visible { opacity: 1; transform: translateX(0); }
.toast-exit    { opacity: 0; transform: translateX(20px); }

.toast-success { background: rgba(29,158,117,0.15); border: 1px solid rgba(29,158,117,0.4); color: #4ade80; }
.toast-error   { background: rgba(239,68,68,0.15);  border: 1px solid rgba(239,68,68,0.4);  color: #f87171; }
.toast-warning { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4); color: #fbbf24; }
.toast-info    { background: rgba(59,130,246,0.15); border: 1px solid rgba(59,130,246,0.4); color: #60a5fa; }

.toast-progress {
  position: absolute;
  bottom: 0; left: 0;
  height: 2px;
  width: 100%;
  background: currentColor;
  opacity: 0.4;
  animation: toast-shrink var(--toast-duration, 5000ms) linear forwards;
}

@keyframes toast-shrink {
  from { width: 100%; }
  to   { width: 0%; }
}
Enter fullscreen mode Exit fullscreen mode

2. Global Loading States

Every async button now disables itself and shows a spinner during the operation. Prevents double-submissions. Makes it obvious something is happening.

// media/webview/app.ts

function setLoading(btn: HTMLButtonElement, loading: boolean, label?: string): void {
  if (loading) {
    btn.dataset.originalText = btn.textContent ?? '';
    btn.textContent = label ?? '⏳ Working…';
    btn.disabled = true;
    btn.classList.add('btn-loading');
  } else {
    btn.textContent = btn.dataset.originalText ?? 'Share';
    btn.disabled = false;
    btn.classList.remove('btn-loading');
  }
}

// On click — enter loading state
btnShare?.addEventListener('click', () => {
  if (!btnShare) return;
  setLoading(btnShare, true, '⏳ Sharing…');
  send('share', {
    platform:       activeCommandPlatform,
    post:           textarea?.value.trim(),
    mediaFilePaths: activeMediaPaths,
  });
});

// On complete — exit loading state
case 'shareComplete':
  if (btnShare) setLoading(btnShare, false);
  resetAllComposers();
  toast('Shared successfully!', 'success');
  break;

// On error — also exit loading so user can retry
case 'status':
  if (msg.type === 'error' && btnShare) {
    setLoading(btnShare, false);
  }
  toast(String(msg.status ?? ''), msg.type ?? 'info');
  break;
Enter fullscreen mode Exit fullscreen mode

3. Character Limit Validation

MAX_CHARS existed before v3.1 but was never wired to the UI. This release connects it to both the counter display and the share button state.

Character counter at 240/280 for X showing warning color, and over-limit state with red counter and disabled share button

// media/webview/app.ts

const MAX_CHARS: Record<string, number> = {
  x:        280,
  bluesky:  300,
  linkedin: 3000,
  telegram: 4096,
  facebook: 63206,
  discord:  2000,
  reddit:   40000,
  devto:    100000,
  medium:   100000,
};

function updateCharCounter(): void {
  if (!textarea || !counter) return;

  const len      = textarea.value.length;
  const platform = activeCommandPlatform ?? '';
  const max      = MAX_CHARS[platform] ?? null;

  counter.textContent = max ? `${len} / ${max}` : String(len);
  counter.className   = 'compose-counter';

  if (max) {
    if (len > max)                       counter.classList.add('counter-error');
    else if (len > Math.floor(max * 0.8)) counter.classList.add('counter-warn');
  }
}

function updateShareBtn(): void {
  if (!btnShare) return;
  const empty   = !textarea?.value.trim().length;
  const platform = activeCommandPlatform ?? '';
  const max     = MAX_CHARS[platform] ?? null;
  const over    = max ? (textarea?.value.length ?? 0) > max : false;
  btnShare.disabled = empty || over;
}

textarea?.addEventListener('input', () => {
  updateCharCounter();
  updateShareBtn();
});
Enter fullscreen mode Exit fullscreen mode
.compose-counter               { color: var(--vscode-descriptionForeground); font-size: 12px; }
.compose-counter.counter-warn  { color: #f59e0b; }
.compose-counter.counter-error { color: #ef4444; font-weight: 600; }
Enter fullscreen mode Exit fullscreen mode

4. The Glassmorphism UI Pass

Cards and modals now use backdrop-filter: blur with semi-transparent fills. Depth without visual weight.

.composer-card {
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 12px;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  transition: border-color 0.2s ease;
}

.composer-card:hover {
  border-color: rgba(255, 255, 255, 0.14);
}

.modal-content {
  background: rgba(30, 30, 40, 0.92);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 14px;
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  animation: modal-enter 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes modal-enter {
  from { opacity: 0; transform: scale(0.95) translateY(8px); }
  to   { opacity: 1; transform: scale(1)    translateY(0);   }
}
Enter fullscreen mode Exit fullscreen mode

5. The Disappearing Preview Race Condition

This was the most subtle bug. And it was hiding in plain sight.

The Symptom

Attach a file → preview appears → preview vanishes 500ms later. Every time.

The Root Cause

When a file uploaded successfully, the backend sent:

{ command: 'status', type: 'success', status: 'File uploaded successfully' }
Enter fullscreen mode Exit fullscreen mode

The WebView message handler treated every success the same way:

// ❌ BEFORE — nuked everything on any success message
case 'status':
  if (msg.type === 'success') {
    resetAllComposers(); // cleared activeMediaPaths + removed preview DOM
  }
  toast(msg.status, msg.type);
  break;
Enter fullscreen mode Exit fullscreen mode

resetAllComposers() was designed for one thing: clean up after a post is fully shared. But it was firing on file uploads, auto-saves, and every other intermediate success too.

The Fix

// ✅ AFTER — terminal vs intermediate distinction
case 'status': {
  toast(String(msg.status ?? ''), msg.type ?? 'info');

  if (msg.type === 'success') {
    const text = String(msg.status ?? '').toLowerCase();

    const isTerminal     = text.includes('shared')    ||
                           text.includes('published') ||
                           text.includes('complete');

    const isIntermediate = text.includes('upload')    ||
                           text.includes('saved')     ||
                           text.includes('processing');

    if (isTerminal && !isIntermediate) {
      resetAllComposers();
    }
  }
  break;
}
Enter fullscreen mode Exit fullscreen mode

The rule going forward: resetAllComposers() is terminal-only. Intermediate feedback goes to toast() and targeted DOM updates — never a full reset.


6. Reddit's S3 Upload Pipeline

This was the hardest fix in v3.1. Reddit's native image upload doesn't use a standard multipart POST endpoint. It routes through Amazon S3, and the sequence has enough specific requirements that every step has its own failure mode.

The Full Flow

POST /api/media/asset.json  →  receive S3 upload URL + signed fields
POST to S3 URL (FormData)   →  upload the file bytes
POST /api/submit            →  create the Reddit post with asset URL
Enter fullscreen mode Exit fullscreen mode

Step 1 — Get S3 Credentials

// src/platforms/reddit.ts

async function getS3UploadUrl(
  accessToken: string,
  filePath: string,
  mimeType: string
): Promise<{ uploadUrl: string; fields: Record<string, string>; assetUrl: string }> {
  const res = await axios.post(
    'https://oauth.reddit.com/api/media/asset.json',
    new URLSearchParams({
      filepath: path.basename(filePath),
      mimetype: mimeType,
    }),
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/x-www-form-urlencoded',
        'User-Agent': 'DotShare/3.1',
      },
    }
  );

  const uploadUrl = `https:${res.data.args.action}`;

  // Reddit returns fields as [{name, value}] array — convert to object
  const fields: Record<string, string> = {};
  for (const f of res.data.args.fields) {
    fields[f.name] = f.value;
  }

  return {
    uploadUrl,
    fields,
    assetUrl: `https://i.redd.it/${res.data.asset.asset_id}`,
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Upload to S3

Two traps here. Field order in FormData matters — S3 requires all fields before the file. And S3 requires Content-Length explicitly — axios doesn't set it automatically for FormData in Node.js.

async function uploadToS3(
  uploadUrl: string,
  fields: Record<string, string>,
  filePath: string,
  mimeType: string
): Promise<void> {
  const fileBuffer = await fs.promises.readFile(filePath);
  const formData   = new FormData();

  // All fields MUST come before the file — S3 policy
  for (const [key, value] of Object.entries(fields)) {
    formData.append(key, value);
  }
  formData.append(
    'file',
    new Blob([fileBuffer], { type: mimeType }),
    path.basename(filePath)
  );

  await axios.post(uploadUrl, formData, {
    headers: { 'Content-Length': fileBuffer.byteLength.toString() },
    maxBodyLength: Infinity,
    maxContentLength: Infinity,
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Submit the Post

async function submitRedditImagePost(
  accessToken: string,
  subreddit: string,
  title: string,
  assetUrl: string
): Promise<string> {
  const res = await axios.post(
    'https://oauth.reddit.com/api/submit',
    new URLSearchParams({
      sr:       subreddit,
      kind:     'image',
      title:    title,
      url:      assetUrl,  // use asset URL, NOT the CDN URL
      resubmit: 'true',
      api_type: 'json',
    }),
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/x-www-form-urlencoded',
        'User-Agent': 'DotShare/3.1',
      },
    }
  );

  const postUrl = res.data?.json?.data?.url;
  if (!postUrl) {
    const errors = res.data?.json?.errors;
    throw new Error(
      errors?.length
        ? `Reddit: ${errors[0][1]}`
        : 'Reddit: no post URL in response'
    );
  }

  return postUrl;
}
Enter fullscreen mode Exit fullscreen mode

The three traps summarized:

  1. FormData field order — fields before file or S3 returns 403
  2. Missing Content-Length — AWS returns 400, message says "malformed body"
  3. CDN URL vs asset URL — i.redd.it/ID is unpopulated at submit time, use the S3 action URL

7. Reddit Field Name Sync

Found this while fixing the S3 pipeline. The WebView was sending field names the backend didn't recognize — and the post text wasn't being sent at all.

// ❌ BEFORE
send('shareToReddit', {
  subreddit: val('redditSubreddit'), // ❌ handler expects 'redditSubreddit'
  title:     val('redditTitle'),     // ❌ handler expects 'redditTitle'
  flair:     ...,                    // ❌ handler expects 'redditFlairId'
  postType:  ...,                    // ❌ handler expects 'redditPostType'
  // ❌ 'post' (the text) missing entirely
});

// ✅ AFTER
send('shareToReddit', {
  post:            textarea?.value.trim() ?? '',
  redditSubreddit: val('redditSubreddit'),
  redditTitle:     val('redditTitle'),
  redditFlairId:   get<HTMLSelectElement>('redditFlair')?.value ?? '',
  redditPostType:  document.querySelector<HTMLInputElement>(
                     'input[name="redditPostType"]:checked'
                   )?.value ?? 'self',
  redditSpoiler:   get<HTMLInputElement>('redditSpoiler')?.checked ?? false,
});
Enter fullscreen mode Exit fullscreen mode

8. Unused Variable Cleanup

// ❌ BEFORE
const btnShare    = get<HTMLButtonElement>('btn-share');
const btnGenerate = get<HTMLButtonElement>('btn-generate-ai');
const btnSchedule = get<HTMLButtonElement>('btn-schedule'); // never used → ESLint error
const btnMedia    = get<HTMLButtonElement>('btn-attach-media');

// ✅ AFTER
const btnShare    = get<HTMLButtonElement>('btn-share');
const btnGenerate = get<HTMLButtonElement>('btn-generate-ai');
const btnMedia    = get<HTMLButtonElement>('btn-attach-media');
Enter fullscreen mode Exit fullscreen mode

Scheduling stays in the HTML as a disabled "Coming Soon" button — listener is commented out pending v3.2.


v3.1 Summary

Before After
Status feedback VS Code popups Inline toast engine
File preview Disappears on upload Stable ✅
Character limits Display only Enforced + visual feedback
Reddit images Broken (S3 errors) Full pipeline working ✅
Reddit fields Mismatched Synchronized ✅
ESLint 1 unused var warning 0 warnings ✅

See It in Action

LinkedIn posting demo:

Telegram posting demo:

Bluesky posting demo:


Install DotShare

VS Code Marketplace

Open VSX (VSCodium / Gitpod

GitHub — star, fork, contribute


v3.2 "The Media Expansion" — up to 4 images per post, per-thumbnail previews, JIT compression, secure WebView URIs — breakdown coming next.

Top comments (0)