OFFLINE SYNC PLAN - nself-org/nchat GitHub Wiki
Version: 0.9.1 Date: February 3, 2026 Status: Implementation Plan Tasks: TODO.md Tasks 118-120 (Phase 17: Offline & Sync)
This document outlines the comprehensive implementation plan for completing offline support and synchronization capabilities in nself-chat. The project already has a robust foundation (v0.8.0) with ~8,000 lines of production code covering IndexedDB storage, network detection, conflict resolution, and React integration. This plan focuses on completing the remaining gaps to achieve 100% production readiness per TODO.md requirements.
| Component | Status | Completion |
|---|---|---|
| IndexedDB Storage | Complete | 100% |
| Network Detection | Complete | 100% |
| Message Queue | Complete | 100% |
| Conflict Resolution | Partial | 75% |
| Settings Sync | Partial | 40% |
| File Upload Queue | Partial | 30% |
| Backend Integration | Not Started | 0% |
| Service Worker | Basic | 60% |
- Backend Integration - Sync methods have placeholder implementations
- File Upload Queue - Basic structure exists, chunked/resumable uploads missing
- Settings Sync - settingsStorage exists but no sync logic
- Conflict Resolution UI - Components exist but not integrated
- Service Worker - Basic caching, no Background Sync API integration
- E2E Testing - Offline scenario tests not implemented
The existing implementation in /src/lib/offline/ provides:
-
offline-queue.ts- Message queuing with priority -
sync-queue.ts- Queue processing with retry logic -
offline-storage.ts- IndexedDB persistence -
useOfflineQueue.ts- React hook integration
File: /src/lib/sync/processors.ts (NEW)
/**
* Production-ready queue processors for all action types
*/
import { getSyncQueue, type SyncQueueItem } from '@/lib/offline'
import { messageStorage, channelStorage } from '@/lib/offline/offline-storage'
import { getApolloClient } from '@/lib/apollo-client'
import {
SEND_MESSAGE_MUTATION,
EDIT_MESSAGE_MUTATION,
DELETE_MESSAGE_MUTATION,
ADD_REACTION_MUTATION,
REMOVE_REACTION_MUTATION,
} from '@/graphql/mutations'
export interface ProcessorConfig {
graphqlEndpoint: string
authToken: () => string | null
onError?: (error: Error, item: SyncQueueItem) => void
onSuccess?: (item: SyncQueueItem, result: any) => void
}
export function registerAllProcessors(config: ProcessorConfig): void {
const queue = getSyncQueue()
const client = getApolloClient()
// Message Send Processor
queue.registerProcessor('message', async (item: SyncQueueItem) => {
if (item.operation === 'create') {
const { channelId, content, tempId, attachments, replyToId } = item.data
const result = await client.mutate({
mutation: SEND_MESSAGE_MUTATION,
variables: { channelId, content, replyToId, attachments },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
// Replace temp message with server message
await messageStorage.remove(tempId)
await messageStorage.save({
...result.data.insertMessage,
isPending: false,
})
config.onSuccess?.(item, result.data.insertMessage)
return result.data.insertMessage
}
if (item.operation === 'update') {
const { messageId, content } = item.data
const result = await client.mutate({
mutation: EDIT_MESSAGE_MUTATION,
variables: { messageId, content },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
// Update local cache
const existing = await messageStorage.get(messageId)
if (existing) {
await messageStorage.save({
...existing,
content,
updatedAt: new Date(),
})
}
return result.data.updateMessage
}
if (item.operation === 'delete') {
const { messageId } = item.data
await client.mutate({
mutation: DELETE_MESSAGE_MUTATION,
variables: { messageId },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
// Remove from local cache
await messageStorage.remove(messageId)
return { deleted: true, messageId }
}
})
// Reaction Processor
queue.registerProcessor('reaction', async (item: SyncQueueItem) => {
const { messageId, emoji, userId } = item.data
if (item.operation === 'create') {
await client.mutate({
mutation: ADD_REACTION_MUTATION,
variables: { messageId, emoji },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
}
if (item.operation === 'delete') {
await client.mutate({
mutation: REMOVE_REACTION_MUTATION,
variables: { messageId, emoji },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
}
return { success: true }
})
// Read Receipt Processor
queue.registerProcessor('read_receipt', async (item: SyncQueueItem) => {
const { channelId, messageId, timestamp } = item.data
await client.mutate({
mutation: MARK_READ_MUTATION,
variables: { channelId, messageId, timestamp },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
return { success: true }
})
// Typing Indicator Processor (low priority, no retry)
queue.registerProcessor('typing', async (item: SyncQueueItem) => {
const { channelId, isTyping } = item.data
// Send via WebSocket instead of GraphQL for real-time
const socket = getSocketConnection()
socket.emit('typing', { channelId, isTyping })
return { success: true }
})
// Presence Update Processor
queue.registerProcessor('presence', async (item: SyncQueueItem) => {
const { status, lastSeenAt } = item.data
await client.mutate({
mutation: UPDATE_PRESENCE_MUTATION,
variables: { status, lastSeenAt },
context: { headers: { Authorization: `Bearer ${config.authToken()}` } },
})
return { success: true }
})
}File: /src/lib/offline/retry-strategies.ts (NEW)
/**
* Advanced retry strategies for different failure scenarios
*/
export type FailureType =
| 'network'
| 'auth'
| 'rate_limit'
| 'server_error'
| 'validation'
| 'conflict'
export interface RetryDecision {
shouldRetry: boolean
delay: number
maxRetries: number
backoffFactor: number
}
export function getRetryStrategy(
error: Error,
statusCode?: number,
attempt: number = 0
): RetryDecision {
const failureType = classifyError(error, statusCode)
switch (failureType) {
case 'network':
// Network errors - aggressive retry with exponential backoff
return {
shouldRetry: attempt < 10,
delay: Math.min(1000 * Math.pow(2, attempt), 300000), // Max 5 min
maxRetries: 10,
backoffFactor: 2,
}
case 'rate_limit':
// Rate limited - respect Retry-After header or use long delay
const retryAfter = extractRetryAfter(error)
return {
shouldRetry: attempt < 5,
delay: retryAfter || Math.min(60000 * (attempt + 1), 300000),
maxRetries: 5,
backoffFactor: 1,
}
case 'auth':
// Auth errors - don't retry, need user action
return {
shouldRetry: false,
delay: 0,
maxRetries: 0,
backoffFactor: 0,
}
case 'validation':
// Validation errors - don't retry, data is bad
return {
shouldRetry: false,
delay: 0,
maxRetries: 0,
backoffFactor: 0,
}
case 'server_error':
// Server errors - retry with longer delays
return {
shouldRetry: attempt < 5,
delay: Math.min(5000 * Math.pow(2, attempt), 120000),
maxRetries: 5,
backoffFactor: 2,
}
case 'conflict':
// Conflicts - retry after resolution
return {
shouldRetry: true,
delay: 1000,
maxRetries: 3,
backoffFactor: 1,
}
default:
return {
shouldRetry: attempt < 3,
delay: 5000,
maxRetries: 3,
backoffFactor: 2,
}
}
}
function classifyError(error: Error, statusCode?: number): FailureType {
if (!navigator.onLine || error.message.includes('fetch')) {
return 'network'
}
switch (statusCode) {
case 401:
case 403:
return 'auth'
case 400:
case 422:
return 'validation'
case 409:
return 'conflict'
case 429:
return 'rate_limit'
case 500:
case 502:
case 503:
case 504:
return 'server_error'
default:
return 'network'
}
}
function extractRetryAfter(error: Error): number | null {
// Extract Retry-After header from error if available
const match = error.message.match(/retry-after:\s*(\d+)/i)
return match ? parseInt(match[1], 10) * 1000 : null
}File: /src/lib/offline/queue-persistence.ts (NEW)
/**
* Enhanced queue persistence with integrity checks
*/
import { queueStorage } from './offline-storage'
import type { QueuedAction } from './offline-types'
export interface QueueSnapshot {
version: number
timestamp: Date
items: QueuedAction[]
checksum: string
}
export class QueuePersistence {
private readonly SNAPSHOT_KEY = 'nchat-queue-snapshot'
private readonly INTEGRITY_CHECK_INTERVAL = 60000 // 1 minute
/**
* Create a snapshot of the current queue for recovery
*/
async createSnapshot(): Promise<QueueSnapshot> {
const items = await queueStorage.getAll()
const snapshot: QueueSnapshot = {
version: 1,
timestamp: new Date(),
items,
checksum: this.calculateChecksum(items),
}
// Store in localStorage as backup
try {
localStorage.setItem(this.SNAPSHOT_KEY, JSON.stringify(snapshot))
} catch (e) {
console.warn('[QueuePersistence] LocalStorage backup failed:', e)
}
return snapshot
}
/**
* Verify queue integrity and repair if needed
*/
async verifyAndRepair(): Promise<{
verified: boolean
repaired: boolean
itemsRecovered: number
}> {
const items = await queueStorage.getAll()
const currentChecksum = this.calculateChecksum(items)
// Try to load backup snapshot
const snapshotJson = localStorage.getItem(this.SNAPSHOT_KEY)
if (!snapshotJson) {
return { verified: true, repaired: false, itemsRecovered: 0 }
}
const snapshot: QueueSnapshot = JSON.parse(snapshotJson)
// Check if IndexedDB is missing items from snapshot
const snapshotIds = new Set(snapshot.items.map((i) => i.id))
const currentIds = new Set(items.map((i) => i.id))
const missingItems = snapshot.items.filter(
(item) => !currentIds.has(item.id) && item.status === 'pending'
)
if (missingItems.length > 0) {
// Recover missing items
for (const item of missingItems) {
await queueStorage.add(item)
}
console.log(`[QueuePersistence] Recovered ${missingItems.length} items`)
return {
verified: false,
repaired: true,
itemsRecovered: missingItems.length,
}
}
return { verified: true, repaired: false, itemsRecovered: 0 }
}
/**
* Calculate checksum for integrity verification
*/
private calculateChecksum(items: QueuedAction[]): string {
const data = items
.map((i) => `${i.id}:${i.status}:${i.retryCount}`)
.sort()
.join('|')
// Simple hash for integrity check
let hash = 0
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return hash.toString(16)
}
/**
* Start periodic integrity checks
*/
startIntegrityMonitoring(): () => void {
const interval = setInterval(async () => {
await this.verifyAndRepair()
await this.createSnapshot()
}, this.INTEGRITY_CHECK_INTERVAL)
return () => clearInterval(interval)
}
}
export const queuePersistence = new QueuePersistence()File: /src/components/chat/message-status-indicator.tsx (NEW)
'use client';
import { Check, CheckCheck, Clock, AlertCircle, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export type MessageStatus =
| 'pending' // Queued locally
| 'sending' // Currently sending
| 'sent' // Sent to server
| 'delivered' // Delivered to recipient(s)
| 'read' // Read by recipient(s)
| 'failed'; // Failed to send
interface MessageStatusIndicatorProps {
status: MessageStatus;
error?: string;
retryCount?: number;
className?: string;
onRetry?: () => void;
}
export function MessageStatusIndicator({
status,
error,
retryCount,
className,
onRetry,
}: MessageStatusIndicatorProps) {
const getStatusIcon = () => {
switch (status) {
case 'pending':
return <Clock className="h-3 w-3 text-muted-foreground" />;
case 'sending':
return <RefreshCw className="h-3 w-3 text-muted-foreground animate-spin" />;
case 'sent':
return <Check className="h-3 w-3 text-muted-foreground" />;
case 'delivered':
return <CheckCheck className="h-3 w-3 text-muted-foreground" />;
case 'read':
return <CheckCheck className="h-3 w-3 text-primary" />;
case 'failed':
return <AlertCircle className="h-3 w-3 text-destructive" />;
}
};
const getTooltipContent = () => {
switch (status) {
case 'pending':
return 'Waiting to send';
case 'sending':
return retryCount && retryCount > 0
? `Retrying (attempt ${retryCount + 1})...`
: 'Sending...';
case 'sent':
return 'Sent';
case 'delivered':
return 'Delivered';
case 'read':
return 'Read';
case 'failed':
return error || 'Failed to send';
}
};
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1',
status === 'failed' && onRetry && 'cursor-pointer hover:opacity-80',
className
)}
onClick={status === 'failed' && onRetry ? onRetry : undefined}
>
{getStatusIcon()}
{status === 'failed' && onRetry && (
<span className="text-xs text-destructive">Tap to retry</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{getTooltipContent()}</p>
</TooltipContent>
</Tooltip>
);
}The existing /src/lib/offline/conflict-resolver.ts provides:
- ConflictResolver class with multiple strategies
- Last-write-wins, local-wins, remote-wins, merge
- TombstoneStore for deletion tracking
- Message merge logic
File: /src/lib/offline/version-vector.ts (NEW)
/**
* Version vectors for tracking distributed state
* Enables accurate conflict detection in multi-device scenarios
*/
export interface VersionVector {
[nodeId: string]: number
}
export interface VectorClock {
vector: VersionVector
nodeId: string
}
export class VersionVectorManager {
private nodeId: string
private vector: VersionVector = {}
constructor(nodeId?: string) {
this.nodeId = nodeId || this.generateNodeId()
this.vector[this.nodeId] = 0
}
/**
* Increment local version
*/
increment(): VersionVector {
this.vector[this.nodeId] = (this.vector[this.nodeId] || 0) + 1
return { ...this.vector }
}
/**
* Merge with another vector (max of each component)
*/
merge(other: VersionVector): VersionVector {
const merged = { ...this.vector }
for (const [nodeId, version] of Object.entries(other)) {
merged[nodeId] = Math.max(merged[nodeId] || 0, version)
}
this.vector = merged
return merged
}
/**
* Compare two vectors to determine causal relationship
*/
compare(a: VersionVector, b: VersionVector): 'before' | 'after' | 'concurrent' | 'equal' {
let aBeforeB = false
let bBeforeA = false
const allNodes = new Set([...Object.keys(a), ...Object.keys(b)])
for (const nodeId of allNodes) {
const aVersion = a[nodeId] || 0
const bVersion = b[nodeId] || 0
if (aVersion < bVersion) aBeforeB = true
if (bVersion < aVersion) bBeforeA = true
}
if (aBeforeB && !bBeforeA) return 'before'
if (bBeforeA && !aBeforeB) return 'after'
if (!aBeforeB && !bBeforeA) return 'equal'
return 'concurrent'
}
/**
* Check if vector a happened before vector b
*/
happenedBefore(a: VersionVector, b: VersionVector): boolean {
return this.compare(a, b) === 'before'
}
/**
* Detect if two vectors are concurrent (conflict)
*/
isConcurrent(a: VersionVector, b: VersionVector): boolean {
return this.compare(a, b) === 'concurrent'
}
/**
* Get current vector
*/
getVector(): VersionVector {
return { ...this.vector }
}
/**
* Generate unique node ID for this device
*/
private generateNodeId(): string {
// Try to get persistent device ID
let deviceId = localStorage.getItem('nchat-device-id')
if (!deviceId) {
deviceId = `device-${Date.now()}-${Math.random().toString(36).slice(2)}`
localStorage.setItem('nchat-device-id', deviceId)
}
return deviceId
}
/**
* Serialize for storage/transmission
*/
serialize(): string {
return JSON.stringify({
nodeId: this.nodeId,
vector: this.vector,
})
}
/**
* Deserialize from storage/transmission
*/
static deserialize(data: string): VersionVectorManager {
const { nodeId, vector } = JSON.parse(data)
const manager = new VersionVectorManager(nodeId)
manager.vector = vector
return manager
}
}
// Singleton for the current device
let versionManager: VersionVectorManager | null = null
export function getVersionManager(): VersionVectorManager {
if (!versionManager) {
// Try to restore from storage
const stored = localStorage.getItem('nchat-version-vector')
if (stored) {
versionManager = VersionVectorManager.deserialize(stored)
} else {
versionManager = new VersionVectorManager()
}
}
return versionManager
}
export function persistVersionVector(): void {
if (versionManager) {
localStorage.setItem('nchat-version-vector', versionManager.serialize())
}
}File: /src/lib/offline/merge-strategies.ts (NEW)
/**
* Advanced merge strategies for different data types
*/
import type { CachedMessage, CachedChannel, CachedUser } from './offline-types'
export interface MergeResult<T> {
success: boolean
merged: T | null
conflicts: string[]
}
/**
* Three-way merge for messages
*/
export function mergeMessages(
base: CachedMessage | null,
local: CachedMessage,
remote: CachedMessage
): MergeResult<CachedMessage> {
const conflicts: string[] = []
// If no base (new message), use timestamp
if (!base) {
const useLocal = new Date(local.createdAt) > new Date(remote.createdAt)
return {
success: true,
merged: useLocal ? local : remote,
conflicts: [],
}
}
// Check content changes
const localContentChanged = local.content !== base.content
const remoteContentChanged = remote.content !== base.content
let mergedContent: string
if (!localContentChanged && !remoteContentChanged) {
mergedContent = base.content
} else if (localContentChanged && !remoteContentChanged) {
mergedContent = local.content
} else if (!localContentChanged && remoteContentChanged) {
mergedContent = remote.content
} else {
// Both changed - try text merge
const textMerge = mergeText(base.content, local.content, remote.content)
if (textMerge.hasConflict) {
conflicts.push('Content was edited on multiple devices')
// Use most recent
mergedContent =
new Date(local.updatedAt || local.createdAt) >
new Date(remote.updatedAt || remote.createdAt)
? local.content
: remote.content
} else {
mergedContent = textMerge.result
}
}
// Merge reactions (union)
const mergedReactions = mergeReactions(base.reactions, local.reactions, remote.reactions)
// Merge attachments (union)
const mergedAttachments = mergeAttachments(
base.attachments,
local.attachments,
remote.attachments
)
const merged: CachedMessage = {
...remote,
content: mergedContent,
reactions: mergedReactions,
attachments: mergedAttachments,
updatedAt: new Date(),
}
return {
success: conflicts.length === 0,
merged,
conflicts,
}
}
/**
* Simple text merge (line-based)
*/
function mergeText(
base: string,
local: string,
remote: string
): { result: string; hasConflict: boolean } {
// If one side is unchanged, use the other
if (base === local) return { result: remote, hasConflict: false }
if (base === remote) return { result: local, hasConflict: false }
// Simple heuristic: if both added text at different positions, concatenate
const localAdded = local.replace(base, '').trim()
const remoteAdded = remote.replace(base, '').trim()
if (localAdded && remoteAdded && local.includes(base) && remote.includes(base)) {
// Both are additions - append both
return {
result: `${base}\n${localAdded}\n${remoteAdded}`,
hasConflict: false,
}
}
// Can't auto-merge
return { result: '', hasConflict: true }
}
/**
* Merge reaction arrays (CRDT set union)
*/
function mergeReactions(
base: CachedMessage['reactions'],
local: CachedMessage['reactions'],
remote: CachedMessage['reactions']
): CachedMessage['reactions'] {
const merged = new Map<string, CachedMessage['reactions'][0]>()
// Add all base reactions
for (const r of base) {
merged.set(r.emoji, { ...r })
}
// Merge local (additions/changes)
for (const r of local) {
const existing = merged.get(r.emoji)
if (existing) {
const userIds = new Set([...existing.userIds, ...r.userIds])
merged.set(r.emoji, {
...r,
userIds: Array.from(userIds),
count: userIds.size,
})
} else {
merged.set(r.emoji, r)
}
}
// Merge remote (additions/changes)
for (const r of remote) {
const existing = merged.get(r.emoji)
if (existing) {
const userIds = new Set([...existing.userIds, ...r.userIds])
merged.set(r.emoji, {
...r,
userIds: Array.from(userIds),
count: userIds.size,
})
} else {
merged.set(r.emoji, r)
}
}
// Handle removals (if user removed, don't include them)
// This is simplified - full CRDT would track add/remove sets
return Array.from(merged.values())
}
/**
* Merge attachment arrays (union by ID)
*/
function mergeAttachments(
base: CachedMessage['attachments'],
local: CachedMessage['attachments'],
remote: CachedMessage['attachments']
): CachedMessage['attachments'] {
const merged = new Map<string, CachedMessage['attachments'][0]>()
for (const a of [...base, ...local, ...remote]) {
merged.set(a.id, a)
}
return Array.from(merged.values())
}
/**
* Merge user settings
*/
export function mergeUserSettings<T extends Record<string, any>>(
base: T | null,
local: T,
remote: T
): MergeResult<T> {
if (!base) {
// No base - use most recent by comparing timestamps if available
return {
success: true,
merged: { ...local, ...remote },
conflicts: [],
}
}
const merged = { ...base } as T
const conflicts: string[] = []
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)])
for (const key of allKeys) {
const baseVal = base[key]
const localVal = local[key]
const remoteVal = remote[key]
const localChanged = JSON.stringify(localVal) !== JSON.stringify(baseVal)
const remoteChanged = JSON.stringify(remoteVal) !== JSON.stringify(baseVal)
if (!localChanged && !remoteChanged) {
merged[key as keyof T] = baseVal
} else if (localChanged && !remoteChanged) {
merged[key as keyof T] = localVal
} else if (!localChanged && remoteChanged) {
merged[key as keyof T] = remoteVal
} else {
// Both changed - last write wins for settings
// Could be enhanced to check timestamps per field
merged[key as keyof T] = remoteVal // Prefer server
conflicts.push(`Setting "${key}" was changed on multiple devices`)
}
}
return {
success: conflicts.length === 0,
merged,
conflicts,
}
}File: /src/components/sync/conflict-resolution-dialog.tsx (NEW)
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { Conflict, ResolutionStrategy } from '@/lib/offline';
interface ConflictResolutionDialogProps {
conflict: Conflict;
open: boolean;
onResolve: (choice: 'local' | 'remote' | 'merge', mergedValue?: any) => void;
onCancel: () => void;
}
export function ConflictResolutionDialog({
conflict,
open,
onResolve,
onCancel,
}: ConflictResolutionDialogProps) {
const [choice, setChoice] = useState<'local' | 'remote' | 'merge'>('remote');
const [mergedContent, setMergedContent] = useState('');
const formatTimestamp = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(date));
};
const getConflictDescription = () => {
switch (conflict.type) {
case 'concurrent_edit':
return 'This item was edited on multiple devices at the same time.';
case 'delete_edit':
return 'This item was deleted on one device but edited on another.';
case 'version_mismatch':
return 'The version on the server is different from your local version.';
default:
return 'A conflict was detected during sync.';
}
};
const handleResolve = () => {
if (choice === 'merge') {
// For messages, allow editing merged content
const merged = conflict.itemType === 'message'
? { ...conflict.remote, content: mergedContent }
: conflict.remote;
onResolve('merge', merged);
} else {
onResolve(choice);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Resolve Conflict</DialogTitle>
<DialogDescription>{getConflictDescription()}</DialogDescription>
</DialogHeader>
<Tabs defaultValue="compare" className="w-full">
<TabsList>
<TabsTrigger value="compare">Compare</TabsTrigger>
{conflict.itemType === 'message' && (
<TabsTrigger value="merge">Merge</TabsTrigger>
)}
</TabsList>
<TabsContent value="compare">
<div className="grid grid-cols-2 gap-4 mt-4">
{/* Local Version */}
<div
className={cn(
'p-4 rounded-lg border',
choice === 'local' && 'border-primary ring-2 ring-primary'
)}
>
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium">Your Version</h4>
<span className="text-xs text-muted-foreground">
{formatTimestamp(conflict.localTimestamp)}
</span>
</div>
<ScrollArea className="h-40">
<pre className="text-sm whitespace-pre-wrap">
{JSON.stringify(conflict.local, null, 2)}
</pre>
</ScrollArea>
<Button
variant={choice === 'local' ? 'default' : 'outline'}
className="w-full mt-2"
onClick={() => setChoice('local')}
>
Use This Version
</Button>
</div>
{/* Remote Version */}
<div
className={cn(
'p-4 rounded-lg border',
choice === 'remote' && 'border-primary ring-2 ring-primary'
)}
>
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium">Server Version</h4>
<span className="text-xs text-muted-foreground">
{formatTimestamp(conflict.remoteTimestamp)}
</span>
</div>
<ScrollArea className="h-40">
<pre className="text-sm whitespace-pre-wrap">
{JSON.stringify(conflict.remote, null, 2)}
</pre>
</ScrollArea>
<Button
variant={choice === 'remote' ? 'default' : 'outline'}
className="w-full mt-2"
onClick={() => setChoice('remote')}
>
Use This Version
</Button>
</div>
</div>
</TabsContent>
{conflict.itemType === 'message' && (
<TabsContent value="merge">
<div className="mt-4">
<Label>Edit merged content:</Label>
<textarea
className="w-full h-40 mt-2 p-2 border rounded-md font-mono text-sm"
value={mergedContent || (conflict.local as any)?.content || ''}
onChange={(e) => {
setMergedContent(e.target.value);
setChoice('merge');
}}
placeholder="Edit the merged content..."
/>
<Button
variant={choice === 'merge' ? 'default' : 'outline'}
className="w-full mt-2"
onClick={() => setChoice('merge')}
>
Use Merged Version
</Button>
</div>
</TabsContent>
)}
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleResolve}>
Apply Resolution
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}-
settingsStoragein/src/lib/offline/offline-storage.ts- basic key-value storage -
AppConfigContextuses localStorage + API sync
File: /src/lib/sync/settings-sync.ts (NEW)
/**
* Settings synchronization service
* Handles sync of user preferences across devices
*/
import { settingsStorage } from '@/lib/offline/offline-storage'
import { getSyncQueue } from '@/lib/offline/sync-queue'
import { getConflictResolver } from '@/lib/offline/conflict-resolver'
import { mergeUserSettings } from '@/lib/offline/merge-strategies'
// =============================================================================
// Types
// =============================================================================
export interface UserPreferences {
// Notification settings
notifications: {
enabled: boolean
sound: boolean
desktop: boolean
mentions: boolean
directMessages: boolean
channelMessages: boolean
quietHoursEnabled: boolean
quietHoursStart: string // "22:00"
quietHoursEnd: string // "08:00"
}
// Theme settings
theme: {
colorScheme: 'light' | 'dark' | 'system'
preset: string
customColors?: Record<string, string>
fontSize: 'small' | 'medium' | 'large'
compactMode: boolean
}
// Chat settings
chat: {
enterToSend: boolean
showTimestamps: boolean
timestampFormat: '12h' | '24h'
showReadReceipts: boolean
showTypingIndicators: boolean
messagePreview: boolean
linkPreviews: boolean
emojiSuggestions: boolean
spellCheck: boolean
}
// Privacy settings
privacy: {
showOnlineStatus: boolean
showLastSeen: boolean
showReadReceipts: boolean
allowDirectMessages: 'everyone' | 'contacts' | 'none'
}
// Accessibility settings
accessibility: {
reduceMotion: boolean
highContrast: boolean
screenReaderMode: boolean
keyboardShortcuts: boolean
}
// Device-specific (not synced)
device?: {
pushToken?: string
deviceId?: string
lastActiveAt?: Date
}
// Metadata
_version: number
_updatedAt: Date
_deviceId: string
}
export interface SettingsSyncResult {
success: boolean
hadConflicts: boolean
resolvedPreferences: UserPreferences
syncedAt: Date
}
// =============================================================================
// Default Preferences
// =============================================================================
export const defaultPreferences: Omit<UserPreferences, '_version' | '_updatedAt' | '_deviceId'> = {
notifications: {
enabled: true,
sound: true,
desktop: true,
mentions: true,
directMessages: true,
channelMessages: true,
quietHoursEnabled: false,
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
},
theme: {
colorScheme: 'system',
preset: 'nself',
fontSize: 'medium',
compactMode: false,
},
chat: {
enterToSend: true,
showTimestamps: true,
timestampFormat: '12h',
showReadReceipts: true,
showTypingIndicators: true,
messagePreview: true,
linkPreviews: true,
emojiSuggestions: true,
spellCheck: true,
},
privacy: {
showOnlineStatus: true,
showLastSeen: true,
showReadReceipts: true,
allowDirectMessages: 'everyone',
},
accessibility: {
reduceMotion: false,
highContrast: false,
screenReaderMode: false,
keyboardShortcuts: true,
},
}
// =============================================================================
// Settings Sync Service
// =============================================================================
export class SettingsSyncService {
private static readonly SETTINGS_KEY = 'user-preferences'
private static readonly SYNC_DEBOUNCE = 2000 // 2 seconds
private syncTimer: ReturnType<typeof setTimeout> | null = null
private pendingChanges: Partial<UserPreferences> = {}
/**
* Get current preferences (local)
*/
async getPreferences(): Promise<UserPreferences> {
const stored = await settingsStorage.get<UserPreferences>(SettingsSyncService.SETTINGS_KEY)
if (stored) {
return stored
}
// Initialize with defaults
const deviceId = this.getDeviceId()
const initial: UserPreferences = {
...defaultPreferences,
_version: 1,
_updatedAt: new Date(),
_deviceId: deviceId,
}
await settingsStorage.set(SettingsSyncService.SETTINGS_KEY, initial)
return initial
}
/**
* Update preferences (debounced sync)
*/
async updatePreferences(updates: Partial<UserPreferences>): Promise<UserPreferences> {
const current = await this.getPreferences()
// Deep merge updates
const updated: UserPreferences = {
...current,
notifications: { ...current.notifications, ...updates.notifications },
theme: { ...current.theme, ...updates.theme },
chat: { ...current.chat, ...updates.chat },
privacy: { ...current.privacy, ...updates.privacy },
accessibility: { ...current.accessibility, ...updates.accessibility },
_version: current._version + 1,
_updatedAt: new Date(),
_deviceId: this.getDeviceId(),
}
// Save locally immediately
await settingsStorage.set(SettingsSyncService.SETTINGS_KEY, updated)
// Queue for sync (debounced)
this.queueSync(updated)
return updated
}
/**
* Queue sync with debouncing
*/
private queueSync(preferences: UserPreferences): void {
if (this.syncTimer) {
clearTimeout(this.syncTimer)
}
this.syncTimer = setTimeout(async () => {
await this.syncToServer(preferences)
}, SettingsSyncService.SYNC_DEBOUNCE)
}
/**
* Sync to server
*/
async syncToServer(preferences: UserPreferences): Promise<void> {
const queue = getSyncQueue()
await queue.add(
'settings',
'update',
{
preferences: this.stripDeviceSpecific(preferences),
},
{
priority: 5, // Medium priority
deduplicate: true,
dedupeKey: 'settings-sync',
}
)
}
/**
* Fetch and merge from server
*/
async syncFromServer(): Promise<SettingsSyncResult> {
const local = await this.getPreferences()
try {
const response = await fetch('/api/user/preferences')
if (!response.ok) {
throw new Error('Failed to fetch preferences')
}
const remote: UserPreferences = await response.json()
// Check for conflicts
if (this.hasConflict(local, remote)) {
const resolver = getConflictResolver()
const basePrefs = await this.getBasePreferences()
const mergeResult = mergeUserSettings(basePrefs, local, remote)
if (!mergeResult.success) {
// Has unresolved conflicts - use conflict resolver
const conflict = {
id: 'user-preferences',
type: 'concurrent_edit' as const,
itemType: 'settings',
local,
remote,
localTimestamp: new Date(local._updatedAt),
remoteTimestamp: new Date(remote._updatedAt),
}
const resolution = await resolver.autoResolve(conflict)
if (resolution.resolved) {
await settingsStorage.set(SettingsSyncService.SETTINGS_KEY, resolution.result)
return {
success: true,
hadConflicts: true,
resolvedPreferences: resolution.result,
syncedAt: new Date(),
}
}
}
// Use merged result
const resolved = {
...mergeResult.merged!,
_version: Math.max(local._version, remote._version) + 1,
_updatedAt: new Date(),
_deviceId: this.getDeviceId(),
}
await settingsStorage.set(SettingsSyncService.SETTINGS_KEY, resolved)
await this.setBasePreferences(resolved)
return {
success: true,
hadConflicts: mergeResult.conflicts.length > 0,
resolvedPreferences: resolved,
syncedAt: new Date(),
}
}
// No conflict - use server version if newer
if (new Date(remote._updatedAt) > new Date(local._updatedAt)) {
const updated = {
...remote,
device: local.device, // Keep device-specific settings
_deviceId: this.getDeviceId(),
}
await settingsStorage.set(SettingsSyncService.SETTINGS_KEY, updated)
await this.setBasePreferences(updated)
return {
success: true,
hadConflicts: false,
resolvedPreferences: updated,
syncedAt: new Date(),
}
}
// Local is newer - sync to server
await this.syncToServer(local)
return {
success: true,
hadConflicts: false,
resolvedPreferences: local,
syncedAt: new Date(),
}
} catch (error) {
console.error('[SettingsSync] Sync failed:', error)
return {
success: false,
hadConflicts: false,
resolvedPreferences: local,
syncedAt: new Date(),
}
}
}
/**
* Check if there's a conflict between local and remote
*/
private hasConflict(local: UserPreferences, remote: UserPreferences): boolean {
// Different devices edited since last sync
return (
local._deviceId !== remote._deviceId &&
Math.abs(new Date(local._updatedAt).getTime() - new Date(remote._updatedAt).getTime()) < 60000 // Within 1 minute
)
}
/**
* Strip device-specific fields for sync
*/
private stripDeviceSpecific(prefs: UserPreferences): Omit<UserPreferences, 'device'> {
const { device, ...syncable } = prefs
return syncable
}
/**
* Get/set base preferences for three-way merge
*/
private async getBasePreferences(): Promise<UserPreferences | null> {
return settingsStorage.get<UserPreferences>('user-preferences-base')
}
private async setBasePreferences(prefs: UserPreferences): Promise<void> {
await settingsStorage.set('user-preferences-base', prefs)
}
/**
* Get unique device ID
*/
private getDeviceId(): string {
let id = localStorage.getItem('nchat-device-id')
if (!id) {
id = `device-${Date.now()}-${Math.random().toString(36).slice(2)}`
localStorage.setItem('nchat-device-id', id)
}
return id
}
}
// Singleton instance
let settingsSyncInstance: SettingsSyncService | null = null
export function getSettingsSyncService(): SettingsSyncService {
if (!settingsSyncInstance) {
settingsSyncInstance = new SettingsSyncService()
}
return settingsSyncInstance
}File: /src/hooks/use-user-preferences.ts (NEW)
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
getSettingsSyncService,
type UserPreferences,
defaultPreferences,
} from '@/lib/sync/settings-sync'
import { useOfflineStatus } from './use-offline'
export interface UseUserPreferencesReturn {
preferences: UserPreferences
isLoading: boolean
isSyncing: boolean
error: string | null
// Setters for each section
updateNotifications: (updates: Partial<UserPreferences['notifications']>) => Promise<void>
updateTheme: (updates: Partial<UserPreferences['theme']>) => Promise<void>
updateChat: (updates: Partial<UserPreferences['chat']>) => Promise<void>
updatePrivacy: (updates: Partial<UserPreferences['privacy']>) => Promise<void>
updateAccessibility: (updates: Partial<UserPreferences['accessibility']>) => Promise<void>
// Sync controls
syncNow: () => Promise<void>
resetToDefaults: () => Promise<void>
}
export function useUserPreferences(): UseUserPreferencesReturn {
const [preferences, setPreferences] = useState<UserPreferences>({
...defaultPreferences,
_version: 0,
_updatedAt: new Date(),
_deviceId: '',
})
const [isLoading, setIsLoading] = useState(true)
const [isSyncing, setIsSyncing] = useState(false)
const [error, setError] = useState<string | null>(null)
const isOnline = useOfflineStatus()
const service = getSettingsSyncService()
// Load preferences on mount
useEffect(() => {
loadPreferences()
}, [])
// Sync when coming online
useEffect(() => {
if (isOnline && !isLoading) {
syncNow()
}
}, [isOnline, isLoading])
const loadPreferences = async () => {
try {
setIsLoading(true)
const prefs = await service.getPreferences()
setPreferences(prefs)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load preferences')
} finally {
setIsLoading(false)
}
}
const updatePreferences = async (updates: Partial<UserPreferences>) => {
try {
const updated = await service.updatePreferences(updates)
setPreferences(updated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update preferences')
throw err
}
}
const updateNotifications = useCallback(
async (updates: Partial<UserPreferences['notifications']>) => {
await updatePreferences({ notifications: updates } as any)
},
[]
)
const updateTheme = useCallback(async (updates: Partial<UserPreferences['theme']>) => {
await updatePreferences({ theme: updates } as any)
}, [])
const updateChat = useCallback(async (updates: Partial<UserPreferences['chat']>) => {
await updatePreferences({ chat: updates } as any)
}, [])
const updatePrivacy = useCallback(async (updates: Partial<UserPreferences['privacy']>) => {
await updatePreferences({ privacy: updates } as any)
}, [])
const updateAccessibility = useCallback(
async (updates: Partial<UserPreferences['accessibility']>) => {
await updatePreferences({ accessibility: updates } as any)
},
[]
)
const syncNow = useCallback(async () => {
if (!isOnline) {
console.log('[useUserPreferences] Skipping sync - offline')
return
}
try {
setIsSyncing(true)
setError(null)
const result = await service.syncFromServer()
if (result.success) {
setPreferences(result.resolvedPreferences)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Sync failed')
} finally {
setIsSyncing(false)
}
}, [isOnline, service])
const resetToDefaults = useCallback(async () => {
const reset = {
...defaultPreferences,
_version: preferences._version + 1,
_updatedAt: new Date(),
_deviceId: preferences._deviceId,
}
await updatePreferences(reset)
}, [preferences])
return {
preferences,
isLoading,
isSyncing,
error,
updateNotifications,
updateTheme,
updateChat,
updatePrivacy,
updateAccessibility,
syncNow,
resetToDefaults,
}
}File: /src/lib/offline/chunked-upload.ts (NEW)
/**
* Chunked file upload with resume support
*/
export interface ChunkInfo {
index: number
start: number
end: number
size: number
uploaded: boolean
checksum?: string
}
export interface UploadProgress {
fileId: string
fileName: string
totalSize: number
uploadedSize: number
percent: number
chunksTotal: number
chunksUploaded: number
status: 'pending' | 'uploading' | 'paused' | 'completed' | 'failed'
error?: string
}
export interface ChunkedUploadConfig {
chunkSize: number // Default: 1MB
maxConcurrent: number // Default: 3
retryAttempts: number // Default: 3
retryDelay: number // Default: 1000ms
}
const DEFAULT_CONFIG: ChunkedUploadConfig = {
chunkSize: 1024 * 1024, // 1MB
maxConcurrent: 3,
retryAttempts: 3,
retryDelay: 1000,
}
export class ChunkedUploadManager {
private config: ChunkedUploadConfig
private uploads: Map<string, UploadState> = new Map()
private listeners: Set<(progress: UploadProgress) => void> = new Set()
constructor(config: Partial<ChunkedUploadConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }
}
/**
* Start or resume a file upload
*/
async upload(
file: File,
options: {
fileId?: string
channelId?: string
messageId?: string
metadata?: Record<string, any>
} = {}
): Promise<string> {
const fileId = options.fileId || this.generateFileId()
// Check for existing upload
let state = this.uploads.get(fileId)
if (!state) {
// Initialize new upload
state = {
fileId,
file,
chunks: this.createChunks(file),
uploadedChunks: new Set(),
status: 'pending',
startedAt: new Date(),
...options,
}
this.uploads.set(fileId, state)
// Persist state for resume
await this.persistState(state)
}
// Start upload
await this.processUpload(state)
return fileId
}
/**
* Create chunk definitions for a file
*/
private createChunks(file: File): ChunkInfo[] {
const chunks: ChunkInfo[] = []
const totalChunks = Math.ceil(file.size / this.config.chunkSize)
for (let i = 0; i < totalChunks; i++) {
const start = i * this.config.chunkSize
const end = Math.min(start + this.config.chunkSize, file.size)
chunks.push({
index: i,
start,
end,
size: end - start,
uploaded: false,
})
}
return chunks
}
/**
* Process upload with concurrent chunk uploads
*/
private async processUpload(state: UploadState): Promise<void> {
state.status = 'uploading'
this.notifyProgress(state)
// Get pending chunks
const pendingChunks = state.chunks.filter((c) => !state.uploadedChunks.has(c.index))
// Upload in batches
while (pendingChunks.length > 0) {
const batch = pendingChunks.splice(0, this.config.maxConcurrent)
const results = await Promise.allSettled(batch.map((chunk) => this.uploadChunk(state, chunk)))
for (let i = 0; i < results.length; i++) {
const result = results[i]
const chunk = batch[i]
if (result.status === 'fulfilled') {
state.uploadedChunks.add(chunk.index)
chunk.uploaded = true
} else {
console.error(`[ChunkedUpload] Chunk ${chunk.index} failed:`, result.reason)
// Will retry on next loop or fail
}
}
// Persist progress
await this.persistState(state)
this.notifyProgress(state)
// Check if paused or cancelled
if (state.status === 'paused') {
return
}
}
// All chunks uploaded - finalize
await this.finalizeUpload(state)
}
/**
* Upload a single chunk
*/
private async uploadChunk(state: UploadState, chunk: ChunkInfo): Promise<void> {
const blob = state.file.slice(chunk.start, chunk.end)
const formData = new FormData()
formData.append('file', blob)
formData.append('fileId', state.fileId)
formData.append('chunkIndex', String(chunk.index))
formData.append('totalChunks', String(state.chunks.length))
formData.append('fileName', state.file.name)
formData.append('fileSize', String(state.file.size))
formData.append('mimeType', state.file.type)
let attempts = 0
let lastError: Error | null = null
while (attempts < this.config.retryAttempts) {
try {
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`)
}
return
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
attempts++
if (attempts < this.config.retryAttempts) {
await this.sleep(this.config.retryDelay * attempts)
}
}
}
throw lastError || new Error('Upload failed after retries')
}
/**
* Finalize upload after all chunks are uploaded
*/
private async finalizeUpload(state: UploadState): Promise<void> {
const response = await fetch('/api/upload/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId: state.fileId,
fileName: state.file.name,
fileSize: state.file.size,
mimeType: state.file.type,
totalChunks: state.chunks.length,
channelId: state.channelId,
messageId: state.messageId,
metadata: state.metadata,
}),
})
if (!response.ok) {
throw new Error('Failed to finalize upload')
}
state.status = 'completed'
this.notifyProgress(state)
// Clean up persisted state
await this.clearPersistedState(state.fileId)
}
/**
* Pause an upload
*/
pause(fileId: string): void {
const state = this.uploads.get(fileId)
if (state) {
state.status = 'paused'
this.notifyProgress(state)
}
}
/**
* Resume a paused upload
*/
async resume(fileId: string): Promise<void> {
const state = this.uploads.get(fileId)
if (state && state.status === 'paused') {
await this.processUpload(state)
}
}
/**
* Cancel an upload
*/
async cancel(fileId: string): Promise<void> {
const state = this.uploads.get(fileId)
if (state) {
state.status = 'failed'
this.notifyProgress(state)
// Request server cleanup
await fetch(`/api/upload/cancel/${fileId}`, { method: 'DELETE' })
this.uploads.delete(fileId)
await this.clearPersistedState(fileId)
}
}
/**
* Get upload progress
*/
getProgress(fileId: string): UploadProgress | null {
const state = this.uploads.get(fileId)
if (!state) return null
return this.createProgressObject(state)
}
/**
* Subscribe to progress updates
*/
onProgress(listener: (progress: UploadProgress) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
/**
* Restore interrupted uploads
*/
async restoreUploads(): Promise<string[]> {
const restored: string[] = []
// Load persisted states from IndexedDB
const states = await this.loadPersistedStates()
for (const state of states) {
// Verify file is still available (will fail for web uploads)
if (state.file) {
this.uploads.set(state.fileId, state)
restored.push(state.fileId)
} else {
// Clean up orphaned state
await this.clearPersistedState(state.fileId)
}
}
return restored
}
// Helper methods
private generateFileId(): string {
return `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
private createProgressObject(state: UploadState): UploadProgress {
const uploadedSize = Array.from(state.uploadedChunks).reduce(
(sum, idx) => sum + state.chunks[idx].size,
0
)
return {
fileId: state.fileId,
fileName: state.file.name,
totalSize: state.file.size,
uploadedSize,
percent: Math.round((uploadedSize / state.file.size) * 100),
chunksTotal: state.chunks.length,
chunksUploaded: state.uploadedChunks.size,
status: state.status,
error: state.error,
}
}
private notifyProgress(state: UploadState): void {
const progress = this.createProgressObject(state)
this.listeners.forEach((listener) => listener(progress))
}
// Persistence methods (implementation depends on IndexedDB)
private async persistState(state: UploadState): Promise<void> {
// Store in IndexedDB for resume across sessions
// Implementation uses offline-storage.ts
}
private async loadPersistedStates(): Promise<UploadState[]> {
// Load from IndexedDB
return []
}
private async clearPersistedState(fileId: string): Promise<void> {
// Remove from IndexedDB
}
}
interface UploadState {
fileId: string
file: File
chunks: ChunkInfo[]
uploadedChunks: Set<number>
status: 'pending' | 'uploading' | 'paused' | 'completed' | 'failed'
startedAt: Date
channelId?: string
messageId?: string
metadata?: Record<string, any>
error?: string
}
// Singleton instance
let uploadManager: ChunkedUploadManager | null = null
export function getUploadManager(): ChunkedUploadManager {
if (!uploadManager) {
uploadManager = new ChunkedUploadManager()
}
return uploadManager
}File: /public/sw.js (UPDATE)
/**
* Enhanced Service Worker with Background Sync API
*/
const CACHE_VERSION = 'v1.1.0'
const CACHE_NAMES = {
STATIC: `nchat-static-${CACHE_VERSION}`,
DYNAMIC: `nchat-dynamic-${CACHE_VERSION}`,
IMAGES: `nchat-images-${CACHE_VERSION}`,
API: `nchat-api-${CACHE_VERSION}`,
OFFLINE: `nchat-offline-${CACHE_VERSION}`,
}
// Background sync tags
const SYNC_TAGS = {
MESSAGES: 'sync-messages',
SETTINGS: 'sync-settings',
UPLOADS: 'sync-uploads',
}
// Install event
self.addEventListener('install', (event) => {
console.log('[SW] Installing v1.1.0...')
event.waitUntil(
caches.open(CACHE_NAMES.STATIC).then((cache) => {
return cache.addAll([
'/',
'/offline.html',
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
])
})
)
self.skipWaiting()
})
// Activate event
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...')
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key.startsWith('nchat-') && !Object.values(CACHE_NAMES).includes(key))
.map((key) => caches.delete(key))
)
})
)
return self.clients.claim()
})
// Fetch event
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
const url = new URL(event.request.url)
if (!url.protocol.startsWith('http')) return
event.respondWith(handleFetch(event.request))
})
// Background Sync event
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag)
if (event.tag === SYNC_TAGS.MESSAGES) {
event.waitUntil(syncMessages())
} else if (event.tag === SYNC_TAGS.SETTINGS) {
event.waitUntil(syncSettings())
} else if (event.tag === SYNC_TAGS.UPLOADS) {
event.waitUntil(syncUploads())
}
})
// Periodic Background Sync (if supported)
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'sync-all') {
event.waitUntil(performFullSync())
}
})
// Push notification event
self.addEventListener('push', (event) => {
if (!event.data) return
const data = event.data.json()
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
tag: data.tag || 'nchat-notification',
data: data.data,
actions: data.actions || [],
requireInteraction: data.requireInteraction || false,
}
event.waitUntil(self.registration.showNotification(data.title, options))
})
// Notification click event
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const data = event.notification.data
let targetUrl = '/'
if (data?.channelId) {
targetUrl = `/chat/${data.channelId}`
if (data.messageId) {
targetUrl += `?message=${data.messageId}`
}
}
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
// Try to focus existing window
for (const client of clientList) {
if (client.url.includes(targetUrl) && 'focus' in client) {
return client.focus()
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(targetUrl)
}
})
)
})
// Fetch handlers
async function handleFetch(request) {
const url = new URL(request.url)
// Images - cache first
if (url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i)) {
return cacheFirst(request, CACHE_NAMES.IMAGES)
}
// API - network first with offline fallback
if (url.pathname.startsWith('/api/')) {
return networkFirstWithOfflineFallback(request, CACHE_NAMES.API)
}
// Navigation - network first with offline page
if (request.mode === 'navigate') {
return networkFirstWithOfflinePage(request)
}
// Default - stale while revalidate
return staleWhileRevalidate(request, CACHE_NAMES.DYNAMIC)
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
if (cached) return cached
try {
const response = await fetch(request)
if (response.ok) cache.put(request, response.clone())
return response
} catch (e) {
if (cached) return cached
throw e
}
}
async function networkFirstWithOfflineFallback(request, cacheName) {
try {
const response = await fetch(request)
const cache = await caches.open(cacheName)
if (response.ok) cache.put(request, response.clone())
return response
} catch (e) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
if (cached) return cached
// Return offline response for API calls
return new Response(JSON.stringify({ error: 'Offline', offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
}
}
async function networkFirstWithOfflinePage(request) {
try {
const response = await fetch(request)
return response
} catch (e) {
const cache = await caches.open(CACHE_NAMES.STATIC)
return cache.match('/offline.html')
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
const fetchPromise = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone())
return response
})
return cached || fetchPromise
}
// Sync functions
async function syncMessages() {
console.log('[SW] Syncing messages...')
try {
// Get all clients to communicate with
const clients = await self.clients.matchAll()
// Notify clients to process queue
for (const client of clients) {
client.postMessage({
type: 'SYNC_MESSAGES',
timestamp: Date.now(),
})
}
return true
} catch (error) {
console.error('[SW] Message sync failed:', error)
throw error // Will retry
}
}
async function syncSettings() {
console.log('[SW] Syncing settings...')
try {
const clients = await self.clients.matchAll()
for (const client of clients) {
client.postMessage({
type: 'SYNC_SETTINGS',
timestamp: Date.now(),
})
}
return true
} catch (error) {
console.error('[SW] Settings sync failed:', error)
throw error
}
}
async function syncUploads() {
console.log('[SW] Syncing uploads...')
try {
const clients = await self.clients.matchAll()
for (const client of clients) {
client.postMessage({
type: 'SYNC_UPLOADS',
timestamp: Date.now(),
})
}
return true
} catch (error) {
console.error('[SW] Upload sync failed:', error)
throw error
}
}
async function performFullSync() {
await Promise.all([syncMessages(), syncSettings(), syncUploads()])
}
console.log('[SW] Service worker loaded v1.1.0')File: /e2e/offline/offline-messaging.spec.ts (NEW)
import { test, expect } from '@playwright/test'
test.describe('Offline Messaging', () => {
test.beforeEach(async ({ page, context }) => {
// Login and navigate to chat
await page.goto('/chat')
await expect(page.locator('[data-testid="message-list"]')).toBeVisible()
})
test('should queue messages when offline', async ({ page, context }) => {
// Go offline
await context.setOffline(true)
// Verify offline indicator appears
await expect(page.locator('[data-testid="offline-indicator"]')).toBeVisible()
// Send a message
await page.fill('[data-testid="message-input"]', 'Test offline message')
await page.click('[data-testid="send-button"]')
// Verify message appears with pending indicator
const message = page.locator('[data-testid="message"]').last()
await expect(message).toContainText('Test offline message')
await expect(message.locator('[data-testid="status-pending"]')).toBeVisible()
// Verify queue count
await expect(page.locator('[data-testid="pending-count"]')).toContainText('1')
})
test('should sync messages when back online', async ({ page, context }) => {
// Go offline
await context.setOffline(true)
// Send multiple messages
for (let i = 1; i <= 3; i++) {
await page.fill('[data-testid="message-input"]', `Offline message ${i}`)
await page.click('[data-testid="send-button"]')
}
// Verify all are queued
await expect(page.locator('[data-testid="pending-count"]')).toContainText('3')
// Go back online
await context.setOffline(false)
// Wait for sync
await page.waitForSelector('[data-testid="sync-progress"]')
await page.waitForSelector('[data-testid="sync-complete"]', { timeout: 10000 })
// Verify all messages synced
await expect(page.locator('[data-testid="pending-count"]')).toContainText('0')
await expect(page.locator('[data-testid="status-sent"]')).toHaveCount(3)
})
test('should handle sync conflicts', async ({ page, context, browser }) => {
// Open second browser context (another device)
const secondContext = await browser.newContext()
const secondPage = await secondContext.newPage()
await secondPage.goto('/chat')
// Edit same message on both devices
const messageId = 'test-message-id'
// Go offline on first device
await context.setOffline(true)
// Edit on first device
await page.click(`[data-message-id="${messageId}"] [data-testid="edit-button"]`)
await page.fill('[data-testid="edit-input"]', 'Edit from device 1')
await page.click('[data-testid="save-edit"]')
// Edit on second device (online)
await secondPage.click(`[data-message-id="${messageId}"] [data-testid="edit-button"]`)
await secondPage.fill('[data-testid="edit-input"]', 'Edit from device 2')
await secondPage.click('[data-testid="save-edit"]')
// Go back online on first device
await context.setOffline(false)
// Should trigger conflict resolution
await expect(page.locator('[data-testid="conflict-dialog"]')).toBeVisible()
// Resolve conflict
await page.click('[data-testid="use-local-version"]')
await page.click('[data-testid="resolve-conflict"]')
// Cleanup
await secondContext.close()
})
test('should persist queue across page reload', async ({ page, context }) => {
// Go offline
await context.setOffline(true)
// Send a message
await page.fill('[data-testid="message-input"]', 'Persistent message')
await page.click('[data-testid="send-button"]')
// Reload page (still offline)
await page.reload()
// Verify message is still in queue
await expect(page.locator('[data-testid="pending-count"]')).toContainText('1')
await expect(page.locator('[data-testid="message"]').last()).toContainText('Persistent message')
})
})File: /src/lib/offline/__tests__/conflict-resolver.test.ts (UPDATE)
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ConflictResolver, TombstoneStore, getConflictResolver } from '../conflict-resolver'
import { mergeMessages } from '../merge-strategies'
import type { CachedMessage } from '../offline-types'
describe('ConflictResolver', () => {
let resolver: ConflictResolver
beforeEach(() => {
resolver = new ConflictResolver()
})
describe('last-write-wins', () => {
it('should choose local when local is newer', async () => {
const conflict = {
id: 'msg-1',
type: 'concurrent_edit' as const,
itemType: 'message',
local: { content: 'local content' },
remote: { content: 'remote content' },
localTimestamp: new Date('2026-02-03T12:00:00'),
remoteTimestamp: new Date('2026-02-03T11:00:00'),
}
const result = await resolver.resolve(conflict, 'last_write_wins')
expect(result.resolved).toBe(true)
expect(result.result).toEqual({ content: 'local content' })
})
it('should choose remote when remote is newer', async () => {
const conflict = {
id: 'msg-1',
type: 'concurrent_edit' as const,
itemType: 'message',
local: { content: 'local content' },
remote: { content: 'remote content' },
localTimestamp: new Date('2026-02-03T11:00:00'),
remoteTimestamp: new Date('2026-02-03T12:00:00'),
}
const result = await resolver.resolve(conflict, 'last_write_wins')
expect(result.resolved).toBe(true)
expect(result.result).toEqual({ content: 'remote content' })
})
})
describe('merge strategy', () => {
it('should merge reactions from both versions', () => {
const base: CachedMessage = {
id: 'msg-1',
channelId: 'ch-1',
content: 'Hello',
senderId: 'user-1',
senderName: 'User 1',
createdAt: new Date(),
reactions: [{ emoji: '👍', count: 1, userIds: ['user-1'], hasReacted: false }],
attachments: [],
}
const local: CachedMessage = {
...base,
reactions: [
{ emoji: '👍', count: 1, userIds: ['user-1'], hasReacted: false },
{ emoji: '❤️', count: 1, userIds: ['user-2'], hasReacted: true },
],
}
const remote: CachedMessage = {
...base,
reactions: [
{ emoji: '👍', count: 2, userIds: ['user-1', 'user-3'], hasReacted: false },
{ emoji: '😂', count: 1, userIds: ['user-4'], hasReacted: false },
],
}
const result = mergeMessages(base, local, remote)
expect(result.success).toBe(true)
expect(result.merged?.reactions).toHaveLength(3)
const thumbsUp = result.merged?.reactions.find((r) => r.emoji === '👍')
expect(thumbsUp?.userIds).toContain('user-1')
expect(thumbsUp?.userIds).toContain('user-2')
expect(thumbsUp?.userIds).toContain('user-3')
})
it('should report conflict when content differs', () => {
const base: CachedMessage = {
id: 'msg-1',
channelId: 'ch-1',
content: 'Original',
senderId: 'user-1',
senderName: 'User 1',
createdAt: new Date(),
reactions: [],
attachments: [],
}
const local = { ...base, content: 'Local edit' }
const remote = { ...base, content: 'Remote edit' }
const result = mergeMessages(base, local, remote)
expect(result.conflicts).toContain('Content was edited on multiple devices')
})
})
describe('user prompt', () => {
it('should call user callback when strategy is user_prompt', async () => {
const userChoice = vi.fn().mockResolvedValue({ content: 'user chosen' })
resolver.setUserChoiceCallback(userChoice)
const conflict = {
id: 'msg-1',
type: 'concurrent_edit' as const,
itemType: 'message',
local: { content: 'local' },
remote: { content: 'remote' },
localTimestamp: new Date(),
remoteTimestamp: new Date(),
}
const result = await resolver.resolve(conflict, 'user_prompt')
expect(userChoice).toHaveBeenCalledWith(conflict)
expect(result.resolved).toBe(true)
expect(result.result).toEqual({ content: 'user chosen' })
})
})
})
describe('TombstoneStore', () => {
let store: TombstoneStore
beforeEach(() => {
store = new TombstoneStore()
})
it('should track deleted items', () => {
store.add({
id: 'msg-1',
itemType: 'message',
deletedAt: new Date(),
deletedBy: 'user-1',
})
expect(store.isDeleted('msg-1')).toBe(true)
expect(store.isDeleted('msg-2')).toBe(false)
})
it('should cleanup old tombstones', () => {
const oldDate = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000) // 40 days ago
const newDate = new Date()
store.add({ id: 'old-msg', itemType: 'message', deletedAt: oldDate, deletedBy: 'user-1' })
store.add({ id: 'new-msg', itemType: 'message', deletedAt: newDate, deletedBy: 'user-1' })
const removed = store.cleanup(30 * 24 * 60 * 60 * 1000) // 30 day retention
expect(removed).toBe(1)
expect(store.isDeleted('old-msg')).toBe(false)
expect(store.isDeleted('new-msg')).toBe(true)
})
})| Task | Priority | Effort | Dependencies |
|---|---|---|---|
| Complete queue processors | High | 3d | GraphQL mutations |
| Implement sync methods | High | 2d | Backend API |
| Wire up real-time events | Medium | 2d | WebSocket setup |
| Add retry strategies | Medium | 1d | - |
| Task | Priority | Effort | Dependencies |
|---|---|---|---|
| Version vectors | Medium | 2d | - |
| Enhanced merge | Medium | 2d | - |
| Conflict UI | High | 3d | Dialog components |
| Integration testing | High | 2d | - |
| Task | Priority | Effort | Dependencies |
|---|---|---|---|
| Settings sync service | High | 2d | - |
| User preferences hook | High | 1d | - |
| Settings API endpoints | Medium | 1d | Backend API |
| Cross-device testing | Medium | 1d | - |
| Task | Priority | Effort | Dependencies |
|---|---|---|---|
| Chunked upload manager | High | 3d | - |
| Resume support | High | 2d | IndexedDB |
| Upload queue integration | Medium | 1d | - |
| Progress UI | Medium | 1d | - |
| Task | Priority | Effort | Dependencies |
|---|---|---|---|
| Enhanced SW | Medium | 2d | - |
| Background Sync | Medium | 1d | - |
| E2E tests | High | 3d | Test infrastructure |
| Performance testing | Medium | 1d | - |
- 100% of queued messages sync successfully when online
- 0% data loss during offline-to-online transitions
- Conflicts detected and resolved within 5 seconds
- Settings sync across all user devices within 30 seconds
- Queue operations < 10ms
- IndexedDB reads < 5ms
- Full sync < 5 seconds for 1000 messages
- Memory usage < 50MB for cached data
- 99.9% sync success rate
- 0 orphaned queue items after 24 hours
- Automatic recovery from all error states
- Graceful degradation when storage quota exceeded
/src/lib/sync/
processors.ts # Queue processors
settings-sync.ts # Settings sync service
/src/lib/offline/
retry-strategies.ts # Enhanced retry logic
queue-persistence.ts # Queue persistence guarantees
version-vector.ts # CRDT version vectors
merge-strategies.ts # Advanced merge logic
chunked-upload.ts # Chunked file uploads
/src/hooks/
use-user-preferences.ts # Preferences hook
/src/components/sync/
conflict-resolution-dialog.tsx # Conflict UI
/src/components/chat/
message-status-indicator.tsx # Message status
/e2e/offline/
offline-messaging.spec.ts # E2E tests
/public/sw.js # Enhanced service worker
/src/lib/offline/conflict-resolver.ts # Additional tests
/src/lib/offline/sync-manager.ts # Backend integration
/src/app/settings/page.tsx # Settings sync UI
- Existing Implementation:
/src/lib/offline/ - Current Documentation:
/docs/OFFLINE-SYNC-IMPLEMENTATION.md - Integration Guide:
/docs/OFFLINE-INTEGRATION-GUIDE.md - TODO.md Tasks: 118-120 (Phase 17)
- CLAUDE.md: Project architecture and patterns
Document Status: Complete Implementation Plan Next Steps: Begin Phase 1 implementation with queue processors