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.
HTML Container
<!-- media/webview/platform-post.html -->
<div id="toast-container" aria-live="polite" aria-atomic="false"></div>
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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);
}
}
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%; }
}
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;
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.
// 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();
});
.compose-counter { color: var(--vscode-descriptionForeground); font-size: 12px; }
.compose-counter.counter-warn { color: #f59e0b; }
.compose-counter.counter-error { color: #ef4444; font-weight: 600; }
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); }
}
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' }
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;
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;
}
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
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}`,
};
}
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,
});
}
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;
}
The three traps summarized:
- FormData field order — fields before file or S3 returns 403
- Missing
Content-Length— AWS returns 400, message says "malformed body" - CDN URL vs asset URL —
i.redd.it/IDis 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,
});
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');
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
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)