Chatter Publisher with Autosave Implementation¶
Table of Contents¶
- Overview
- Architecture
- Implementation Details
- Component Details
- Testing Strategy
- Deployment Instructions
- Lessons Learned
Overview¶
This implementation provides a custom Lightning Web Component that replaces the standard Chatter publisher with autosave capabilities to prevent loss of drafted messages when composing updates to activity groups.
The Problem¶
Members were losing composed Chatter posts when:
- Browser windows needed updates or crashed
- Users navigated away from the page accidentally
- Sessions timed out requiring re-login
- Browsers were closed without posting
The standard Salesforce Chatter publisher in Experience Cloud provides no draft saving functionality, resulting in frustration and wasted time, especially for longer or more thoughtful messages to activity groups.
The Solution¶
A custom Lightning Web Component (chatterPublisherWithAutosave) that provides:
- Automatic Draft Saving - Saves to browser localStorage after typing stops (configurable delay, default 3 seconds)
- Post and Poll Tabs - Post (rich text) and Poll (question + 2–10 choices) with separate drafts per tab
- Rich Text Support - Full formatting preservation for posts (bold, italic, lists, links, etc.)
- Draft Restoration - Prompts users to restore saved post or poll drafts when returning to pages
- Navigation Warnings - Warns users before leaving with unsaved changes
- Visual Feedback - Shows "Draft saved" briefly; message disappears after a short delay (configurable, default 3 seconds)
- Draft Expiration - Auto-expires drafts older than 7 days
- Per-Group Drafts - Each Chatter group maintains its own separate post and poll drafts
Goals¶
- Prevent loss of composed messages across all user scenarios
- Support rich text formatting in drafts
- Provide seamless user experience with minimal friction
- Work reliably across browser sessions and crashes
- Ensure comprehensive test coverage for Apex controllers
Architecture¶
Component Overview¶
Chatter Autosave System
│
├── chatterPublisherWithAutosave (Lightning Web Component)
│ ├── chatterPublisherWithAutosave.js (Component logic)
│ ├── chatterPublisherWithAutosave.html (Template)
│ ├── chatterPublisherWithAutosave.css (Styling)
│ └── chatterPublisherWithAutosave.js-meta.xml (Metadata)
│
├── ChatterPublisherController.cls (Apex Controller)
│ └── ChatterPublisherControllerTest.cls (Test Class)
│
└── Configuration
└── Experience Builder (Component Placement)
Key Design Decisions¶
Lightning Web Component Over Aura: We chose LWC because:
- Modern framework with better performance
- Native support for localStorage and browser APIs
- Better developer experience and tooling
lightning-input-rich-textcomponent provides robust formatting- Easier to maintain and extend
localStorage for Draft Persistence: We chose browser localStorage over server-side storage because:
- Instant save with no network latency
- Works offline and survives browser restarts
- No additional database objects or storage limits needed
- Simpler implementation and faster user experience
- Drafts automatically scoped to device/browser (privacy benefit)
Debounced Autosave: Draft saving uses a configurable debounce (see AUTOSAVE_DELAY_MS in the LWC, default 3 seconds) to:
- Avoid excessive localStorage writes
- Balance responsiveness with performance
- Match user expectations from other editing tools
Rich Text HTML Storage: We store full HTML rather than plain text to:
- Preserve all formatting applied by users
- Support restoration of complex content
- Allow for future enhancement of formatting options
Implementation Details¶
localStorage Key Structure¶
Drafts are stored with keys in the format:
- Post drafts:
chatterDraft_<groupId> - Poll drafts:
chatterPollDraft_<groupId>
Each Chatter group maintains its own separate post and poll drafts, allowing users to have in-progress posts and polls for multiple groups simultaneously.
Draft Data Structure¶
Post draft:
{
"content": "<p>Draft message content with HTML</p>",
"timestamp": "2026-02-08T10:30:00.000Z",
"groupId": "0F9xxxxxxxxxx"
}
Poll draft:
{
"type": "poll",
"question": "Poll question text",
"choices": ["Choice 1", "Choice 2", "Choice 3"],
"timestamp": "2026-02-08T10:30:00.000Z",
"groupId": "0F9xxxxxxxxxx"
}
Autosave Flow¶
Post tab:
- User types in rich text editor (or edits in Poll tab: question or choices)
- Component detects change via
onchangeevent - Debounce timer starts (duration from
AUTOSAVE_DELAY_MS, default 3 seconds) - If user stops typing, timer fires and saves to localStorage
- Visual indicator shows "Draft saved" briefly, then disappears after
DRAFT_SAVED_MESSAGE_DURATION_MS(default 3 seconds) - On page load, component checks for existing draft (post or poll)
- If found and not expired, shows restoration modal
- User can restore or discard draft
Poll tab: Same debounce and save flow; poll drafts use chatterPollDraft_<groupId> and include question and choices.
Navigation Warning Flow¶
- User starts typing (sets
hasUnsavedChanges = true) - On successful save to localStorage, flag cleared
beforeunloadevent listener checks flag- If unsaved changes exist, browser shows native confirmation dialog
- User can choose to stay or leave page
Draft Expiration¶
- Drafts expire after 7 days
- Cleanup runs on component initialization
- Expired drafts are automatically removed from localStorage
- Prevents storage bloat from abandoned drafts
Component Details¶
ChatterPublisherWithAutosave (LWC)¶
Purpose: Provides custom Chatter publisher with autosave functionality
Key constants (in component JS):
AUTOSAVE_DELAY_MS- Debounce delay before saving draft (default 3000 ms)DRAFT_SAVED_MESSAGE_DURATION_MS- How long "Draft saved" stays visible (default 3000 ms)DRAFT_EXPIRATION_DAYS- Drafts older than this are expired (7)
Key Properties:
@api groupId; // Can be set explicitly or auto-detected from page
_internalGroupId; // Auto-detected from CurrentPageReference
effectiveGroupId; // Computed property that returns groupId || _internalGroupId
activeTab; // "post" | "poll"
Key Methods:
handleTextChange(event);
Responds to text changes in the rich text editor (Post tab):
- Updates
draftContentwith new value - Sets
hasUnsavedChangesflag - Clears existing debounce timeout
- Sets new timeout for autosave (duration from
AUTOSAVE_DELAY_MS)
saveDraftToLocalStorage();
Saves current post draft to browser localStorage:
- Validates content is not empty
- Creates draft object with content, timestamp, groupId
- Stores in localStorage with prefixed key
- Shows "Draft saved" briefly (cleared after
DRAFT_SAVED_MESSAGE_DURATION_MS) - Clears
hasUnsavedChangesflag
Poll drafts use schedulePollDraftSave() and savePollDraftToLocalStorage() with the same debounce and temporary message behavior.
loadDraftFromLocalStorage();
Loads existing draft from localStorage (post and/or poll):
- Retrieves draft by groupId key (post:
chatterDraft_, poll:chatterPollDraft_) - Checks if draft is expired (>7 days)
- If expired, deletes and returns
- If valid, shows restoration modal with relative timestamp (restore or discard)
handlePost();
Posts content to Chatter via Apex:
- Validates content is not empty
- Calls
ChatterPublisherController.postToChatter - On success, clears draft and shows success toast
- Reloads page to show new post in feed
- On error, shows error toast and keeps draft
beforeUnloadHandler(event);
Warns user before leaving with unsaved changes:
- Checks
hasUnsavedChangesflag and content not empty - If true, prevents default navigation
- Sets
returnValueto trigger browser confirmation dialog
Helper Methods:
cleanupExpiredDrafts()- Removes expired post and poll drafts (checks both key prefixes)getDraftKey()/getPollDraftKey()- Return localStorage keys for current groupisContentNotEmpty()- Strips HTML and checks for actual textisDraftExpired()- Checks if draft is older than 7 daysgetRelativeTime()- Formats timestamp as "just now", "X minutes ago", etc.showToast()- Displays lightning toast messages
ChatterPublisherController (Apex)¶
Purpose: Handles posting messages to Chatter groups via ConnectApi
Key Methods:
@AuraEnabled
public static String postToChatter(String groupId, String content)
Posts a text message to a Chatter group:
- Validates groupId and content are provided
- Verifies Chatter group exists and user has access
- Converts rich text HTML to plain text
- Builds ConnectApi.FeedItemInput
- Posts via
ConnectApi.ChatterFeeds.postFeedElement - Returns feed item ID on success
@AuraEnabled
public static String postPollToChatter(String groupId, String question, List<String> choices)
Posts a poll to a Chatter group:
- Validates groupId, question, and at least 2 non-empty choices (max 10)
- Verifies Chatter group exists and user has access
- Builds ConnectApi.FeedItemInput with body (question) and PollCapabilityInput (choices)
- Posts via
ConnectApi.ChatterFeeds.postFeedElement - Returns feed element ID on success (or mock ID in test context)
private static Id getNetworkId()
Gets the network ID for community context:
- Tries
Network.getNetworkId()first - Falls back to querying for "Spokane Mountaineers" network
- Returns network ID or null
private static String stripHtmlTags(String html)
Converts rich text HTML to plain text:
- Converts
<br>and</p>tags to newlines - Converts list items to bullet points
- Removes all other HTML tags
- Decodes HTML entities (&, <, etc.)
- Cleans up multiple consecutive newlines
Testing Strategy¶
Test Coverage Goals¶
ChatterPublisherController: Achieved 100% code coverage
Test Scenarios Covered:
-
Successful post and poll posting:
- Plain text content
- Rich text with formatting
- Content with HTML entities
- Content with line breaks
- Content with lists
- Long content
- Content with special characters
- Poll: valid question and 2–10 choices; blank choices filtered out
-
Error Handling:
- Null group ID
- Empty group ID
- Null content
- Empty content
- Invalid/non-existent group ID
- Poll: null/blank groupId, blank question, too few choices, too many choices
-
Edge Cases:
- Group ID validation
- HTML to text conversion
- Entity decoding
- List formatting
Test Data Strategy¶
Tests use:
- Dynamic Chatter group creation with unique names (timestamp suffix)
- Query for existing Public Chatter groups when possible
- Proper use of
Test.startTest()andTest.stopTest() - Comprehensive assertions for positive and negative cases
- Mock HTML content representing real-world usage
Running Tests¶
# Run all tests for the controller
sf apex run test --tests ChatterPublisherControllerTest --target-org staging --code-coverage --result-format human
# Check code coverage
sf apex get test --code-coverage --target-org staging
Deployment Instructions¶
Prerequisites¶
- Salesforce CLI installed
- Access to staging and production orgs
- Git repository cloned locally
- Feature branch created:
feature/chatter-autosave
Quick Deploy (New Components Only)¶
To deploy only the Chatter autosave components and run only their tests (faster than a full project deploy):
sf project deploy start \
--target-org staging \
--source-dir force-app/main/default/classes/ChatterPublisherController.cls \
--source-dir force-app/main/default/classes/ChatterPublisherControllerTest.cls \
--source-dir force-app/main/default/lwc/chatterPublisherWithAutosave \
--test-level RunSpecifiedTests \
--tests ChatterPublisherControllerTest \
--wait 10
- --source-dir (repeated): Deploys only these three items (each Apex class includes its
.cls-meta.xml). - --test-level RunSpecifiedTests: Only
ChatterPublisherControllerTestruns. - --wait 10: Wait up to 10 minutes for the deployment to finish.
Use the same command for production by changing --target-org (e.g. --target-org production).
Step 1: Deploy to Staging (Full Project)¶
# Deploy the component and Apex classes to staging
sf project deploy start --target-org staging
# Monitor deployment
sf project deploy report --target-org staging
Step 2: Run Apex Tests¶
# Run tests and verify >75% coverage
sf apex run test --tests ChatterPublisherControllerTest \
--target-org staging \
--code-coverage \
--result-format human \
--wait 10
# Get detailed coverage report
sf apex get test --code-coverage --target-org staging
Expected output:
- All test methods passing
- Code coverage >75% for ChatterPublisherController
- No test failures or errors
Step 3: Add Component to Experience Cloud¶
-
Open Experience Builder:
- Navigate to Digital Experiences > All Sites
- Find "Spokane Mountaineers" site
- Click "Builder"
-
Navigate to Group Detail Page:
- In Experience Builder, go to any Chatter group page
- Or configure via Settings > Theme > Group Pages template
-
Add Component:
- In left sidebar, find "Custom Components"
- Drag "Chatter Publisher with Autosave" onto the page
- Position it above the standard Chatter feed
- Leave "Chatter Group ID" property blank (auto-detects from page)
-
Publish Changes:
- Click "Publish" button
- Confirm publication to make changes live
Step 4: Test in Staging¶
Test Scenarios:
-
Draft Saving:
- Navigate to a Chatter group page in staging
- Start typing a message with formatting (Post tab) or question/choices (Poll tab)
- Wait a few seconds (autosave delay, default 3 seconds)
- Verify "Draft saved" appears briefly, then disappears
- Check browser console:
localStorage.getItem('chatterDraft_<groupId>')orchatterPollDraft_<groupId>for poll
-
Draft Restoration:
- With a saved post or poll draft, refresh the page
- Verify restoration modal appears (post or poll wording)
- Click "Restore Draft"
- Confirm content is restored (post formatting or poll question/choices intact)
-
Navigation Warning:
- Start typing without waiting for autosave
- Try to navigate away or close tab
- Verify browser shows confirmation dialog
-
Posting:
- Compose a message (Post) or poll (Poll tab), then click "Share"
- Verify post or poll appears in Chatter feed
- Confirm draft is cleared from localStorage
- "Draft saved" message is temporary and clears on its own after the configured duration
-
Draft Expiration:
- Manually create expired draft in console:
const groupId = "0F9..."; // Current group ID const expiredDraft = { content: "<p>Old draft</p>", timestamp: new Date( Date.now() - 8 * 24 * 60 * 60 * 1000 ).toISOString(), // 8 days ago groupId: groupId }; localStorage.setItem( `chatterDraft_${groupId}`, JSON.stringify(expiredDraft) ); - Refresh page
- Verify no restoration modal appears
- Check localStorage to confirm draft was removed
- Manually create expired draft in console:
Step 5: Deploy to Production¶
After successful staging testing:
# Switch to production org
sf config set target-org production
# Deploy
sf project deploy start --target-org production
# Run tests in production
sf apex run test --tests ChatterPublisherControllerTest \
--target-org production \
--code-coverage \
--result-format human \
--wait 10
Step 6: Rollout Strategy¶
Phased Approach:
- Deploy component to one test group (e.g., Ecomm) first
- Monitor for 1-2 days
- Roll out to all activity group pages
- Communicate to members about new autosave feature
Rollback Plan:
If issues arise:
- Remove component from Experience Builder (immediate)
- Members revert to standard Chatter publisher
- No data loss (drafts remain in localStorage)
- Fix issues and redeploy
Lessons Learned¶
What Worked Well¶
localStorage for Persistence: Using browser localStorage provided instant saves with no network latency and survived browser crashes perfectly. The approach proved more reliable than we initially expected, with no storage quota issues even with rich HTML content.
Debounced Autosave: The configurable debounce (e.g. 3 seconds) strikes a balance between responsiveness and performance. Users see a brief "Draft saved" confirmation that disappears after a short delay, avoiding clutter.
Rich Text Component: The lightning-input-rich-text component provided robust formatting out of the box. The toolbar was familiar to users from other editing tools, reducing the learning curve.
ESLint Enforcement: Pre-commit hooks caught several issues early:
- Invalid
@apiproperty reassignment - Missing return statements
- Async operation restrictions
- innerHTML security concerns
These catches prevented bugs from reaching production.
Comprehensive Test Coverage: Achieving 100% coverage for the Apex controller gave us confidence to deploy. Edge cases like null values, empty strings, and invalid group IDs were all covered.
Challenges Overcome¶
LWC Platform Restrictions: Hit several ESLint rules specific to LWC:
@lwc/lwc/no-api-reassignments- Can't reassign@apiproperties- Solution: Created
_internalGroupIdprivate property andeffectiveGroupIdgetter
- Solution: Created
@lwc/lwc/no-async-operation- Can't usesetTimeoutwithout eslint-disable- Solution: Added inline eslint-disable comment with justification
@lwc/lwc/no-inner-html- Can't useinnerHTMLfor security- Solution: Added eslint-disable where needed for HTML parsing in helper method
ConnectApi HTML Limitations: The Connect API doesn't support rich HTML posting directly. Initial implementation tried to post HTML but Chatter stripped formatting.
- Solution: Built
stripHtmlTagsmethod to intelligently convert HTML to plain text while preserving structure (line breaks, lists)
beforeunload Event Handling: The browser's beforeunload event requires returning a value, but ESLint flagged missing return in else branch.
- Solution: Added explicit
return undefinedfor consistency
Draft Restoration UX: Initial implementation restored drafts automatically, which was jarring if users didn't remember they had a draft.
- Solution: Added modal dialog with "Restore" vs "Discard" options, showing timestamp so users can decide
Multiple Chatter Groups: Needed to handle users composing drafts for multiple groups simultaneously.
- Solution: Keyed drafts by groupId, allowing independent drafts per group
Future Considerations¶
Server-Side Draft Backup: Consider adding optional server-side draft storage for:
- Cross-device access (draft on desktop, restore on mobile)
- Draft sharing/collaboration
- Audit trail of draft history
- Recovery if localStorage is cleared
This could be implemented as an enhancement without changing the current localStorage-first approach.
Rich Text Posting: Explore options for preserving rich text formatting in Chatter posts:
- ConnectApi rich text segments (may support some formatting)
- Markdown conversion for structured content
- Image paste support
@Mention and #Hashtag Preservation: Current implementation doesn't preserve Chatter-specific syntax. Future enhancement could:
- Parse @mentions from HTML
- Convert to ConnectApi.MentionSegmentInput
- Parse #hashtags and convert to ConnectApi.HashtagSegmentInput
Draft Versioning: For long-form posts, users might appreciate:
- Multiple draft versions
- Undo/redo functionality
- Draft comparison view
Analytics Integration: Track usage metrics:
- How often drafts are saved
- How often drafts are restored vs discarded
- Average draft lifespan
- Most common draft content lengths
Mobile Optimization: While the component works on mobile, consider:
- Touch-optimized toolbar
- Simplified formatting options for small screens
- Better modal sizing for mobile viewports
Accessibility Improvements:
- ARIA labels for screen readers
- Keyboard shortcuts for common actions
- Focus management in restoration modal
- High contrast mode support
Related Documentation¶
- Activity Group Event Notifications - Related Chatter automation
- Setup Staging Sandbox - Staging environment setup
- Salesforce Development Console Cheatsheet - Debugging tools
Code References¶
Lightning Web Component:
force-app/main/default/lwc/chatterPublisherWithAutosave/chatterPublisherWithAutosave.jsforce-app/main/default/lwc/chatterPublisherWithAutosave/chatterPublisherWithAutosave.htmlforce-app/main/default/lwc/chatterPublisherWithAutosave/chatterPublisherWithAutosave.cssforce-app/main/default/lwc/chatterPublisherWithAutosave/chatterPublisherWithAutosave.js-meta.xml
Apex Classes:
force-app/main/default/classes/ChatterPublisherController.clsforce-app/main/default/classes/ChatterPublisherController.cls-meta.xmlforce-app/main/default/classes/ChatterPublisherControllerTest.clsforce-app/main/default/classes/ChatterPublisherControllerTest.cls-meta.xml
Configuration:
force-app/main/default/lwc/.eslintrc.json- ESLint configuration for LWC