mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-07 03:10:12 +00:00
First network activity: pull the server's draft snapshot for the active team
and apply it additively. No absence-based deletion and no POST/DELETE workers
yet (later sub-steps) — a Draft is never removed for being missing from the
response, and nothing is applied when the GET fails.
- actions/remote/draft.ts: fetchDraftsForTeam (GET via ClientDrafts, forceLogout
+ non-PII error, fail-closed) and reconcileTeamDrafts — adopt legacy drafts
first, normalize each server draft (BoR durations read from config, fail
closed), restrict to confirmed-membership + DM/GM channels, and apply the
incoming matrix per key via mutateDraftAndOutbox:
* DELETE outbox -> fingerprint match preserves the delete, differ abandons it
and adopts server content;
* pending upsert -> local wins, merge server props + record serverUpdateAt;
* waiting/blocked_upload -> apply remote portable fields, preserve local files;
* blocked/unsyncable_empty -> preserve;
* no outbox -> create, or update a clean draft only when content differs.
Missing channel skips one key without aborting others; a missing root never
blocks a reply draft.
- DraftSyncManager: single-flight, epoch-guarded reconcile with coalescing;
baseline recorded only on epoch-valid success; still no draining/POST/DELETE.
Checks: npm run tsc clean; eslint --quiet clean; 125 Jest tests pass (remote
draft, manager, draft actions/queries/sync).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
7.3 KiB
TypeScript
203 lines
7.3 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {PostTypes} from '@constants/post';
|
|
|
|
// DraftLike is the minimal set of fields the pure sync helpers read from a Draft.
|
|
// A WatermelonDB DraftModel instance satisfies this shape, as does a plain object
|
|
// built from one, which keeps these functions free of any database dependency.
|
|
type DraftLike = {
|
|
channelId: string;
|
|
rootId: string;
|
|
message: string;
|
|
type: PostTypesUserCreatable | null;
|
|
props: DraftProps | null;
|
|
fileIds: string[];
|
|
metadata?: PostMetadata;
|
|
};
|
|
|
|
// NormalizedDraft is the persistence-ready shape produced from a server DraftApi.
|
|
// It uses the DraftModel property names so a later phase can assign the values
|
|
// directly onto a prepared record. It intentionally never carries a record id or
|
|
// a local update_at; the caller stamps update_at when content actually changes.
|
|
export type NormalizedDraft = {
|
|
channelId: string;
|
|
rootId: string;
|
|
message: string;
|
|
serverUpdateAt: number;
|
|
type: PostTypesUserCreatable | null;
|
|
props: DraftProps;
|
|
fileIds: string[];
|
|
files: FileInfo[];
|
|
metadata?: PostMetadata;
|
|
};
|
|
|
|
type BoRDurations = {
|
|
borDurationSeconds: number;
|
|
borMaximumTimeToLiveSeconds: number;
|
|
};
|
|
|
|
/**
|
|
* reconstructDraftBoRConfig: pure, fail-closed reconstruction of a draft's burn-on-read
|
|
* config. The duration values live in server config (not in draft props) so the caller
|
|
* passes them in. Returns null when the draft is not a burn-on-read draft, and also null
|
|
* (fail closed) when it is a burn-on-read draft but valid positive durations are not
|
|
* available — never fabricate durations.
|
|
*/
|
|
export const reconstructDraftBoRConfig = (
|
|
serverType: string | null | undefined,
|
|
durations: BoRDurations | undefined,
|
|
): PostBoRConfig | null => {
|
|
if (serverType !== PostTypes.BURN_ON_READ) {
|
|
return null;
|
|
}
|
|
|
|
if (!durations || durations.borDurationSeconds <= 0 || durations.borMaximumTimeToLiveSeconds <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
enabled: true,
|
|
borDurationSeconds: durations.borDurationSeconds,
|
|
borMaximumTimeToLiveSeconds: durations.borMaximumTimeToLiveSeconds,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* stableStringify: deterministic JSON serialization with object keys sorted at every level.
|
|
* Two structurally-equal objects always produce the same string regardless of key insertion
|
|
* order, which is what makes the fingerprint below order-independent.
|
|
*/
|
|
const stableStringify = (value: unknown): string => {
|
|
if (value === null || typeof value !== 'object') {
|
|
return JSON.stringify(value) ?? 'null';
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableStringify).join(',')}]`;
|
|
}
|
|
|
|
const obj = value as Record<string, unknown>;
|
|
const keys = Object.keys(obj).sort();
|
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
|
};
|
|
|
|
/**
|
|
* cyrb53: a small, fast, deterministic non-cryptographic 53-bit string hash. It is NOT
|
|
* reversible and NOT collision-proof, but collisions between two different draft contents are
|
|
* astronomically unlikely for our purposes. Returned as a hex string.
|
|
*/
|
|
const cyrb53 = (str: string, seed = 0): string => {
|
|
let h1 = 0xdeadbeef ^ seed;
|
|
let h2 = 0x41c6ce57 ^ seed;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const ch = str.charCodeAt(i);
|
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
}
|
|
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
|
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
|
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
const hash = (4294967296 * (2097151 & h2)) + (h1 >>> 0);
|
|
return hash.toString(16);
|
|
};
|
|
|
|
/**
|
|
* draftContentFingerprint: produce a stable, deterministic, non-reversible hash of a draft's
|
|
* canonical server-visible content (message, type, props, sorted fileIds, priority). Same content
|
|
* always yields the same hash; different content practically always yields a different hash. The
|
|
* raw content is NEVER returned, only the hash — this lets the outbox distinguish a stale replica
|
|
* echo of deleted content from genuinely new content without persisting the content itself.
|
|
*/
|
|
export const draftContentFingerprint = (input: {
|
|
message: string;
|
|
type: string | null;
|
|
props: DraftProps | null;
|
|
fileIds: string[];
|
|
priority?: PostPriority;
|
|
}): string => {
|
|
const canonical = {
|
|
message: input.message,
|
|
type: input.type ?? '',
|
|
props: input.props ?? {},
|
|
fileIds: [...input.fileIds].sort(),
|
|
priority: input.priority ?? null,
|
|
};
|
|
|
|
return cyrb53(stableStringify(canonical));
|
|
};
|
|
|
|
/**
|
|
* isSyncableDraft: a draft is only syncable when it has message content. Empty-message
|
|
* drafts (attachment-only, priority-only, burn-on-read-only, type/props-only) are never
|
|
* synced because the server treats an empty-message upsert as a delete.
|
|
*/
|
|
export const isSyncableDraft = (draft: DraftLike): boolean => {
|
|
return draft.message.length > 0;
|
|
};
|
|
|
|
/**
|
|
* buildDraftUpsertRequest: build the server POST body from a local draft. Sends ONLY the
|
|
* server-owned fields; local-only file fields (localPath, clientId, bytesRead, failed,
|
|
* upload progress) and local-only draft fields (record id, server_update_at) are never
|
|
* serialized. Returns null for a non-syncable (empty message) draft so callers cannot
|
|
* accidentally POST an empty draft that the server would interpret as a delete.
|
|
*/
|
|
export const buildDraftUpsertRequest = (draft: DraftLike): DraftUpsertRequest | null => {
|
|
if (!isSyncableDraft(draft)) {
|
|
return null;
|
|
}
|
|
|
|
const request: DraftUpsertRequest = {
|
|
channel_id: draft.channelId,
|
|
root_id: draft.rootId,
|
|
message: draft.message,
|
|
type: draft.type ?? '',
|
|
props: draft.props ?? {},
|
|
file_ids: draft.fileIds ?? [],
|
|
};
|
|
|
|
const priority = draft.metadata?.priority;
|
|
if (priority) {
|
|
request.priority = priority;
|
|
}
|
|
|
|
return request;
|
|
};
|
|
|
|
/**
|
|
* normalizeServerDraft: map a server DraftApi into a persistence-ready NormalizedDraft.
|
|
* Server update_at is captured as server_update_at (an observation hint) and never as the
|
|
* local update_at. file_ids are copied exactly, even when metadata.files is unavailable, so
|
|
* attachment IDs are never lost and local paths are never invented. Priority and burn-on-read
|
|
* config are reconstructed into metadata; burn-on-read fails closed when durations are missing.
|
|
*/
|
|
export const normalizeServerDraft = (
|
|
serverDraft: DraftApi,
|
|
durations?: BoRDurations,
|
|
): NormalizedDraft => {
|
|
const metadata: PostMetadata = {};
|
|
|
|
if (serverDraft.priority) {
|
|
metadata.priority = serverDraft.priority;
|
|
}
|
|
|
|
const borConfig = reconstructDraftBoRConfig(serverDraft.type, durations);
|
|
if (borConfig) {
|
|
metadata.borConfig = borConfig;
|
|
}
|
|
|
|
return {
|
|
channelId: serverDraft.channel_id,
|
|
rootId: serverDraft.root_id,
|
|
message: serverDraft.message,
|
|
serverUpdateAt: serverDraft.update_at,
|
|
type: serverDraft.type === PostTypes.BURN_ON_READ ? PostTypes.BURN_ON_READ : '',
|
|
props: serverDraft.props ?? {},
|
|
fileIds: serverDraft.file_ids ?? [],
|
|
files: serverDraft.metadata?.files ?? [],
|
|
metadata: Object.keys(metadata).length ? metadata : undefined,
|
|
};
|
|
};
|