summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPepper <3782201+rohvani@users.noreply.github.com>2026-09-17 11:03:27 -0400
committerGitHub <noreply@github.com>2026-09-17 11:03:27 -0400
commitcc5d3dd3cd653971fff16cf2bc1d380edd5ea7f2 (patch)
treea825cb021b74e45ceacb90213260b625f5234ebb
parent56b34b332727299f2d54d4d6e8dbe5a7cb54cb11 (diff)
parentc9cb3694cce949e7ab4638200646e91814c1cc7b (diff)
Merge pull request #6298 from secondlife/pepper/chat-fixes-2
additional fixes/cleanup for chat modernization project viewer
-rw-r--r--indra/newview/llchatservicehistory.cpp518
-rw-r--r--indra/newview/llchatservicehistory.h21
-rw-r--r--indra/newview/llchatservicehistorycore.cpp386
-rw-r--r--indra/newview/llchatservicehistorycore.h42
-rw-r--r--indra/newview/llfloaterconversationpreview.cpp28
-rw-r--r--indra/newview/llfloaterconversationpreview.h1
-rw-r--r--indra/newview/llfloaterimsession.cpp26
-rw-r--r--indra/newview/llimprocessing.cpp8
-rw-r--r--indra/newview/llimview.cpp223
-rw-r--r--indra/newview/llimview.h15
-rw-r--r--indra/newview/llnotificationhandler.h9
-rw-r--r--indra/newview/llnotificationhandlerutil.cpp27
-rw-r--r--indra/newview/llnotificationofferhandler.cpp6
-rw-r--r--indra/newview/tests/llchatservicehistorycore_test.cpp809
14 files changed, 1584 insertions, 535 deletions
diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp
index b73937b546..ade534cd05 100644
--- a/indra/newview/llchatservicehistory.cpp
+++ b/indra/newview/llchatservicehistory.cpp
@@ -26,6 +26,7 @@
#include "llfloaterconversationpreview.h"
#include "llfloaterreg.h"
#include "llfloaterimsessiontab.h"
+#include "llfloaterimsession.h"
#include "llimview.h"
#include "lllogchat.h"
#include "llmutelist.h"
@@ -78,7 +79,7 @@ const char* const PENDING_NAME = "(name pending)";
const char* const LEGACY_WALL_TIME = "chat_service_legacy_wall_time";
const F64 REQUEST_SPACING = 2.1;
const F64 RETRY_DELAY = 5.0;
-const F64 OUTBOUND_REFRESH_DELAY = 15.0;
+const F64 ACTIVITY_REFRESH_DELAY = 15.0;
const F64 RATE_LIMIT_DELAY = 120.0;
const F64 LIST_INTERVAL = 3600.0;
const F64 NAME_TIMEOUT = 30.0;
@@ -109,11 +110,11 @@ enum ESummary
SUMMARY_VALID
};
-enum EOutboundDiscovery
+enum EActivityDiscovery
{
- OUTBOUND_NONE,
- OUTBOUND_PENDING,
- OUTBOUND_CONFIRMING
+ DISCOVERY_NONE,
+ DISCOVERY_PENDING,
+ DISCOVERY_CONFIRMING
};
struct CapabilityContext
@@ -186,10 +187,10 @@ struct Resident
bool metadata_waiting = false;
bool priority_waiting = false;
- // Outbound bursts share one quiet deadline; unknown conversations also retain
- // one bounded discovery confirmation.
- F64 outbound_refresh_due = 0.0;
- EOutboundDiscovery outbound_discovery = OUTBOUND_NONE;
+ // Incoming and outgoing IM bursts share one quiet deadline; unknown
+ // conversations retain one bounded discovery confirmation.
+ F64 activity_refresh_due = 0.0;
+ EActivityDiscovery activity_discovery = DISCOVERY_NONE;
LLChatServiceHistory::Snapshot snapshot;
};
@@ -270,6 +271,22 @@ bool ownsRuntime(U32 epoch)
return sRuntime.running && sRuntime.epoch == epoch;
}
+bool postPresentation(const LL::WorkQueue::Work& work)
+{
+ // Manager and name-cache coroutines may resume inside a yielding UI operation.
+ // Run presentation on the main-loop coroutine, after that operation unwinds.
+ const U32 epoch = sRuntime.epoch;
+ LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop");
+ return main && main->post([epoch, work]()
+ {
+ // Queued UI work belongs only to the login that scheduled it.
+ if (ownsRuntime(epoch))
+ {
+ work();
+ }
+ });
+}
+
std::string accountPath(const std::string& filename)
{
return gDirUtilp ? gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, filename)
@@ -343,7 +360,17 @@ void publishSnapshot(const LLUUID& id, Resident& resident)
sRuntime.rollout && transcriptConsent() &&
sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending &&
!sRuntime.delete_requested;
- sSnapshotSignal(id, resident.snapshot);
+ postPresentation([id]()
+ {
+ // Deliver current state, not a captured preview that may have been revoked
+ // or replaced while this notification waited for the main loop.
+ auto found = sRuntime.residents.find(id);
+ if (found != sRuntime.residents.end())
+ {
+ const auto snapshot = found->second.snapshot;
+ sSnapshotSignal(id, snapshot);
+ }
+ });
}
void setWorkActive(const LLUUID& id, bool active)
@@ -1151,8 +1178,7 @@ U32 pendingMetadataCount()
bool ensureMetadata(const LLUUID& id, Resident& resident)
{
- // A resolved record is stable for this login. Republishing it from a view load
- // would synchronously re-enter that load through the resident snapshot signal.
+ // A resolved record is stable for this login; view loads need no new publication.
if (resident.metadata.state == META_RESOLVED)
{
return true;
@@ -1234,7 +1260,7 @@ bool networkEligible(const LLUUID& id, const Resident& resident,
}
void expireMetadata();
-void activateDueOutboundRefreshes();
+void activateDueActivityRefreshes();
bool waitForWake(F64 seconds, U32 epoch)
{
@@ -1244,7 +1270,7 @@ bool waitForWake(F64 seconds, U32 epoch)
}
// Coalesce queued wake events, then sleep until either state changes or the
- // nearest pacing, list, metadata, or outbound deadline arrives.
+ // nearest pacing, list, metadata, or activity deadline arrives.
sRuntime.wake_pending = false;
sWake->discard();
llcoro::suspendUntilEventOnWithTimeout(*sWake, static_cast<F32>(llmax(0.01, seconds)),
@@ -1280,7 +1306,7 @@ bool pace(const CapabilityContext& context, U32 epoch,
// Record quiet-expiry priority during a global cooldown without allowing it
// to bypass that cooldown or create a separate request path.
- activateDueOutboundRefreshes();
+ activateDueActivityRefreshes();
const F64 now = F64(LLTimer::getTotalSeconds());
const F64 remaining = sRuntime.network_not_before - now;
if (remaining <= 0.0)
@@ -1294,9 +1320,9 @@ bool pace(const CapabilityContext& context, U32 epoch,
{
wait = llmin(wait, llmax(0.01, pair.second.metadata.deadline - now));
}
- if (pair.second.outbound_refresh_due > now)
+ if (pair.second.activity_refresh_due > now)
{
- wait = llmin(wait, pair.second.outbound_refresh_due - now);
+ wait = llmin(wait, pair.second.activity_refresh_due - now);
}
}
const U32 pending_before = pendingMetadataCount();
@@ -1345,9 +1371,9 @@ void handleRequestFailure(const LLUUID& id, Resident& resident, S32 status)
}
}
-bool processList(const LLSD& body, bool& confirm_outbound_discovery)
+bool processList(const LLSD& body, bool& confirm_activity_discovery)
{
- confirm_outbound_discovery = false;
+ confirm_activity_discovery = false;
std::vector<ListEntry> entries;
if (!validateConversationList(body, sRuntime.agent_id, entries))
{
@@ -1360,21 +1386,21 @@ bool processList(const LLSD& body, bool& confirm_outbound_discovery)
}
// Validate the complete list before preserving priority placeholders and
- // appending background work. The first miss after an outbound hint retains its
+ // appending background work. The first miss after an activity hint retains its
// placeholder for one delayed confirmation; the next valid miss retires it.
for (auto& pair : sRuntime.residents)
{
pair.second.listed = listed.count(pair.first) != 0;
if (!pair.second.listed)
{
- if (pair.second.outbound_discovery == OUTBOUND_PENDING)
+ if (pair.second.activity_discovery == DISCOVERY_PENDING)
{
- pair.second.outbound_discovery = OUTBOUND_CONFIRMING;
- confirm_outbound_discovery = true;
+ pair.second.activity_discovery = DISCOVERY_CONFIRMING;
+ confirm_activity_discovery = true;
continue;
}
- pair.second.outbound_discovery = OUTBOUND_NONE;
+ pair.second.activity_discovery = DISCOVERY_NONE;
sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), pair.first),
sRuntime.queue.end());
if (sRuntime.priority_resident == pair.first)
@@ -1396,7 +1422,7 @@ bool processList(const LLSD& body, bool& confirm_outbound_discovery)
resident.conversation_id = entry.conversation_id;
resident.advertised_token = entry.last_msg_id;
resident.listed = true;
- resident.outbound_discovery = OUTBOUND_NONE;
+ resident.activity_discovery = DISCOVERY_NONE;
if (resident.metadata.state == META_FAILED)
{
resident.metadata.state = META_UNREQUESTED;
@@ -1428,7 +1454,7 @@ void requestList(const CapabilityContext& context, U32 epoch)
}
// Discovery mutates resident scheduling state only after the complete response
- // passes strict validation. A pending outbound hint remains visible across the
+ // passes strict validation. A pending activity hint remains visible across the
// HTTP suspension and is consumed by processList rather than this request latch.
const HttpResult response = request(context.list_url, NULL);
LL_INFOS("ChatServiceHistory")
@@ -1447,14 +1473,14 @@ void requestList(const CapabilityContext& context, U32 epoch)
}
if (!response.body.isUndefined() && response.status >= 200 && response.status < 300)
{
- bool confirm_outbound_discovery = false;
- if (processList(response.body, confirm_outbound_discovery))
+ bool confirm_activity_discovery = false;
+ if (processList(response.body, confirm_activity_discovery))
{
const F64 now = F64(LLTimer::getTotalSeconds());
- sRuntime.list_needed = confirm_outbound_discovery;
+ sRuntime.list_needed = confirm_activity_discovery;
sRuntime.list_retry_used = false;
sRuntime.next_list = now + LIST_INTERVAL;
- if (confirm_outbound_discovery)
+ if (confirm_activity_discovery)
{
sRuntime.network_not_before = llmax(sRuntime.network_not_before,
now + RETRY_DELAY);
@@ -1494,8 +1520,8 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch)
if (!resident.listed || resident.conversation_id.empty())
{
// A failed discovery remains dormant until a fresh open/list/region or
- // due outbound trigger.
- resident.outbound_discovery = OUTBOUND_NONE;
+ // due activity trigger.
+ resident.activity_discovery = DISCOVERY_NONE;
resident.snapshot.head_preview.clear();
setWorkActive(id, false);
clearPriority(id);
@@ -1599,10 +1625,10 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch)
// If the first head request begins just after quiet expiry, it already
// covers that burst. Cursor pages never consume a newer deadline.
if (!current.first_request_started && cursor.empty() &&
- current.outbound_refresh_due > 0.0 &&
- current.outbound_refresh_due <= F64(LLTimer::getTotalSeconds()))
+ current.activity_refresh_due > 0.0 &&
+ current.activity_refresh_due <= F64(LLTimer::getTotalSeconds()))
{
- current.outbound_refresh_due = 0.0;
+ current.activity_refresh_due = 0.0;
}
current.first_request_started = true;
post["conversation_id"] = current.conversation_id;
@@ -1648,9 +1674,9 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch)
break;
}
- // A validated response is the yield boundary for outbound deadlines
+ // A validated response is the yield boundary for activity deadlines
// that matured while this request was in flight.
- activateDueOutboundRefreshes();
+ activateDueActivityRefreshes();
if (!ownsRuntime(epoch))
{
return;
@@ -1717,8 +1743,8 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch)
}
// Archive inspection is also an awaited page boundary; apply any
- // outbound priority that matured while it ran before staging rows.
- activateDueOutboundRefreshes();
+ // activity priority that matured while it ran before staging rows.
+ activateDueActivityRefreshes();
if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id)
{
queueResident(id, false);
@@ -1888,16 +1914,22 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch)
const F64 archived_seconds = after_publish.summary.newest.ticks >= UUID_EPOCH
? static_cast<F64>(after_publish.summary.newest.ticks - UUID_EPOCH) / 10000000.0
: static_cast<F64>(time_corrected());
- const LLAvatarName& name = after_publish.metadata.name;
- LLConversationLog::instance().addServiceConversation(
- LLIMMgr::computeSessionID(IM_NOTHING_SPECIAL, id),
- name.getCompleteName(),
- LLCacheName::buildUsername(name.getUserName()),
- id,
- U64Seconds(LLUnits::Seconds::fromValue(archived_seconds)));
+ const LLAvatarName name = after_publish.metadata.name;
+ postPresentation([id, name, archived_seconds]()
+ {
+ if (!LLChatServiceHistory::historySuppressed())
+ {
+ LLConversationLog::instance().addServiceConversation(
+ LLIMMgr::computeSessionID(IM_NOTHING_SPECIAL, id),
+ name.getCompleteName(),
+ LLCacheName::buildUsername(name.getUserName()),
+ id,
+ U64Seconds(LLUnits::Seconds::fromValue(archived_seconds)));
+ }
+ });
}
- LLLogChat::notifyTranscriptCreated();
+ postPresentation(LLLogChat::notifyTranscriptCreated);
}
else
{
@@ -1907,7 +1939,7 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch)
after_publish.covered_token.clear();
sRuntime.index_dirty = true;
sRuntime.local_content_exists = true;
- LLLogChat::notifyTranscriptCreated();
+ postPresentation(LLLogChat::notifyTranscriptCreated);
publication_failed = true;
}
}
@@ -2021,14 +2053,20 @@ void finishDelete(bool success)
else
{
sRuntime.delete_requested = true;
- LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed");
}
- if (callback)
+ postPresentation([success, callback]()
{
- callback(success);
- }
- LLLogChat::notifyTranscriptCreated();
+ if (!success)
+ {
+ LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed");
+ }
+ if (callback)
+ {
+ callback(success);
+ }
+ LLLogChat::notifyTranscriptCreated();
+ });
}
void runDelete(U32 epoch)
@@ -2072,24 +2110,45 @@ void runDelete(U32 epoch)
publishSnapshot(pair.first, pair.second);
}
- for (auto& pair : LLIMModel::instance().mId2SessionMap)
+ // Await invalidation on the main loop before sweeping files or accepting new
+ // history. A late clear must not discard messages received after deletion.
+ LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop");
+ const bool cleared = main && main->waitForResult([epoch]()
{
- pair.second->clearForHistoryDeletion();
- }
+ if (!ownsRuntime(epoch))
+ {
+ return false;
+ }
- const LLFloaterReg::const_instance_list_t& previews =
- LLFloaterReg::getFloaterList("preview_conversation");
- for (LLFloater* floater : previews)
- {
- if (LLFloaterConversationPreview* preview =
- dynamic_cast<LLFloaterConversationPreview*>(floater))
+ for (auto& pair : LLIMModel::instance().mId2SessionMap)
{
- preview->invalidateHistory();
- preview->closeFloater();
+ pair.second->clearForHistoryDeletion();
}
- }
- LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true);
+ const LLFloaterReg::const_instance_list_t& previews =
+ LLFloaterReg::getFloaterList("preview_conversation");
+ for (LLFloater* floater : previews)
+ {
+ if (LLFloaterConversationPreview* preview =
+ dynamic_cast<LLFloaterConversationPreview*>(floater))
+ {
+ preview->invalidateHistory();
+ preview->closeFloater();
+ }
+ }
+
+ LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true);
+ return true;
+ });
+ if (!ownsRuntime(epoch))
+ {
+ return;
+ }
+ if (!cleared)
+ {
+ finishDelete(false);
+ return;
+ }
// Sweep legacy transcripts first, then service-owned artifacts, through the
// shared filesystem mutation boundaries on the General queue.
@@ -2225,7 +2284,7 @@ bool prepareOneArchive(U32 epoch)
ensureMetadata(id, current);
}
publishSnapshot(id, current);
- LLLogChat::notifyTranscriptCreated();
+ postPresentation(LLLogChat::notifyTranscriptCreated);
}
return prepared;
}
@@ -2344,9 +2403,9 @@ void expireMetadata()
}
}
-void activateDueOutboundRefreshes()
+void activateDueActivityRefreshes()
{
- // Revoked account gates retire pending outbound activity before it can remain
+ // Revoked account gates retire pending activity before it can remain
// a wake source or schedule service work.
if (!sRuntime.rollout || !transcriptConsent() ||
sRuntime.state_safety != STATE_SAFE || sRuntime.cleanup_pending ||
@@ -2354,7 +2413,7 @@ void activateDueOutboundRefreshes()
{
for (auto& pair : sRuntime.residents)
{
- pair.second.outbound_refresh_due = 0.0;
+ pair.second.activity_refresh_due = 0.0;
}
return;
}
@@ -2363,14 +2422,14 @@ void activateDueOutboundRefreshes()
for (auto& pair : sRuntime.residents)
{
Resident& resident = pair.second;
- if (resident.outbound_refresh_due <= 0.0 || resident.outbound_refresh_due > now)
+ if (resident.activity_refresh_due <= 0.0 || resident.activity_refresh_due > now)
{
continue;
}
// Consume the deadline before reusing the existing priority/discovery path
// so an expired value cannot create a rapid manager wake loop.
- resident.outbound_refresh_due = 0.0;
+ resident.activity_refresh_due = 0.0;
LLMuteList* mute = LLMuteList::getInstance();
if (mute && mute->isLoadedFromServer() &&
(mute->isMuted(pair.first) ||
@@ -2383,9 +2442,9 @@ void activateDueOutboundRefreshes()
{
LLChatServiceHistory::prioritizeResident(pair.first, true);
}
- else if (resident.outbound_discovery == OUTBOUND_NONE)
+ else if (resident.activity_discovery == DISCOVERY_NONE)
{
- resident.outbound_discovery = OUTBOUND_PENDING;
+ resident.activity_discovery = DISCOVERY_PENDING;
LLChatServiceHistory::prioritizeResident(pair.first);
}
}
@@ -2393,7 +2452,7 @@ void activateDueOutboundRefreshes()
F64 nearestWait()
{
- // Sleep until the earliest list, pacing, metadata, or outbound quiet deadline;
+ // Sleep until the earliest list, pacing, metadata, or activity quiet deadline;
// explicit wake events interrupt this deadline when state changes sooner.
const F64 now = LLTimer::getTotalSeconds();
F64 deadline = sRuntime.next_list > now ? sRuntime.next_list : now + LIST_INTERVAL;
@@ -2409,9 +2468,9 @@ F64 nearestWait()
}
if (!sRuntime.delete_requested && !sRuntime.cleanup_pending &&
sRuntime.state_safety == STATE_SAFE && sRuntime.rollout && transcriptConsent() &&
- pair.second.outbound_refresh_due > 0.0)
+ pair.second.activity_refresh_due > 0.0)
{
- deadline = llmin(deadline, pair.second.outbound_refresh_due);
+ deadline = llmin(deadline, pair.second.activity_refresh_due);
}
}
return llmax(0.01, deadline - now);
@@ -2452,11 +2511,14 @@ void manager(U32 epoch)
sRuntime.state_safety = state.safety;
sRuntime.deleted_before_ticks = state.boundary;
sRuntime.cleanup_pending = state.cleanup_pending;
- LLLogChat::notifyTranscriptCreated();
- if (state.safety == STATE_SAFE && !state.cleanup_pending)
+ postPresentation([]()
{
- LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true);
- }
+ LLLogChat::notifyTranscriptCreated();
+ if (!LLChatServiceHistory::historySuppressed())
+ {
+ LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true);
+ }
+ });
if (state.safety == STATE_SAFE && state.cleanup_pending)
{
sRuntime.delete_active = true;
@@ -2474,10 +2536,10 @@ void manager(U32 epoch)
continue;
}
- // Expire timers, activate due outbound bursts, and fill metadata slots before
+ // Expire timers, activate due activity bursts, and fill metadata slots before
// choosing the next network occurrence.
expireMetadata();
- activateDueOutboundRefreshes();
+ activateDueActivityRefreshes();
fillMetadataSlots();
const CapabilityContext context = sampleContext();
@@ -2507,8 +2569,8 @@ void manager(U32 epoch)
}
}
- // Discovery precedes resident paging; the queue itself preserves open,
- // inbound, and due outbound priority without creating a second worker.
+ // Discovery precedes resident paging; opens and due activity share the
+ // resident priority queue.
const bool base_network = baseNetworkEligible(context);
LLMuteList* mute = LLMuteList::getInstance();
// Once all external inputs are available, record why synchronization stayed gated.
@@ -2691,75 +2753,6 @@ bool legacyWallEpoch(const std::string& text, F64& epoch)
}
}
-bool sameSenderAndText(const LLSD& left, const LLSD& right)
-{
- if (left[LL_IM_TEXT].asString() != right[LL_IM_TEXT].asString())
- {
- return false;
- }
-
- // Prefer exact resident identity when both sources carry it; otherwise compare
- // their canonical resident usernames.
- const bool left_has_id = left[LL_IM_FROM_ID].isDefined();
- const bool right_has_id = right[LL_IM_FROM_ID].isDefined();
- return left_has_id && right_has_id
- ? left[LL_IM_FROM_ID].asUUID() == right[LL_IM_FROM_ID].asUUID()
- : sameDirectSenderName(left[LL_IM_FROM].asString(),
- right[LL_IM_FROM].asString());
-}
-
-bool sameSenderAndText(const LLSD& legacy, const Row& service)
-{
- if (legacy[LL_IM_TEXT].asString() != service.message)
- {
- return false;
- }
-
- return legacy[LL_IM_FROM_ID].isDefined()
- ? legacy[LL_IM_FROM_ID].asUUID() == service.from_id
- : sameDirectSenderName(legacy[LL_IM_FROM].asString(), service.from_name);
-}
-
-bool sameLegacyMinute(F64 wall_epoch, F64 utc_epoch)
-{
- // Legacy transcript minutes are SLT but do not encode whether UTC-7 or UTC-8
- // applied. Either exact minute may identify the authoritative service row.
- for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 })
- {
- const F64 minute = wall_epoch + offset;
- if (utc_epoch >= minute && utc_epoch < minute + 60.0)
- {
- return true;
- }
- }
- return false;
-}
-
-S64 utcMinute(F64 epoch)
-{
- return static_cast<S64>(epoch) / 60;
-}
-
-bool sameHistoryLiveOccurrence(const LLSD& history, const LLSD& live)
-{
- if (!sameSenderAndText(history, live) ||
- !live["timestamp"].isInteger() || live["timestamp"].asInteger() <= 0)
- {
- return false;
- }
-
- const F64 live_epoch = static_cast<U32>(live["timestamp"].asInteger());
- if (history["chat_service_msg_id"].isString() &&
- history["timestamp"].isInteger() && history["timestamp"].asInteger() > 0)
- {
- const U32 history_epoch = static_cast<U32>(history["timestamp"].asInteger());
- return history_epoch / 60 == static_cast<U32>(live_epoch) / 60;
- }
-
- return history[LEGACY_WALL_TIME].isReal() &&
- sameLegacyMinute(history[LEGACY_WALL_TIME].asReal(), live_epoch);
-}
-
LLSD serviceMessage(const Row& row)
{
LLSD message;
@@ -2781,7 +2774,7 @@ LLSD serviceMessage(const Row& row)
LLChatServiceHistory::HistoryResult readStitched(
const LLUUID& id, const LLUUID& agent_id, const std::string& archive_path,
const std::vector<std::string>& legacy_paths, U32 limit, U32 epoch, U32 serial,
- U64 boundary, bool include_service, std::vector<Row> preview)
+ U64 boundary, bool include_service, const std::vector<Row>& preview)
{
LLChatServiceHistory::HistoryResult result;
result.account_epoch = epoch;
@@ -2813,40 +2806,8 @@ LLChatServiceHistory::HistoryResult readStitched(
LLChatServiceHistoryAccess::loadLegacy(path, legacy, parameters);
}
- // Canonical rows win exact identity over preview rows, then all service rows
- // share one stable TimeUUID order for seam reconciliation and final display.
- std::set<std::string> canonical_ids;
- std::vector<Row> service = archive.display_rows;
- for (const Row& row : service)
- {
- canonical_ids.insert(row.msg_id);
- }
- for (const Row& row : preview)
- {
- if (!canonical_ids.count(row.msg_id))
- {
- service.push_back(row);
- }
- }
- std::sort(service.begin(), service.end(), [](const Row& left, const Row& right)
- {
- return left.key < right.key;
- });
- std::vector<bool> service_placed(service.size(), false);
- std::multimap<S64, size_t> canonical_minutes;
- for (size_t pos = 0; pos < service.size(); ++pos)
- {
- if (canonical_ids.count(service[pos].msg_id))
- {
- canonical_minutes.emplace(
- utcMinute(LLDate(service[pos].created_at).secondsSinceEpoch()), pos);
- }
- }
-
const F64 service_epoch = archive.has_oldest
? static_cast<F64>(archive.oldest.ticks - UUID_EPOCH) / 10000000.0 : 0.0;
- std::vector<std::pair<F64, LLSD>> dated;
- std::list<LLSD> undated;
// Admit only the legacy prefix that may predate the durable service boundary.
// Offset-less SLT timestamps use their earliest UTC interpretation (UTC-7).
@@ -2855,65 +2816,26 @@ LLChatServiceHistory::HistoryResult readStitched(
F64 wall = 0.0;
if (!legacyWallEpoch(message[LL_IM_DATE_TIME].asString(), wall))
{
- undated.push_back(message);
+ result.messages.push_back(message);
}
else if (!archive.has_oldest || legacyWallMayPrecedeService(wall, service_epoch))
{
LLSD stitched = message;
stitched[LEGACY_WALL_TIME] = wall;
-
- // Reconcile exact overlaps only inside the admitted legacy prefix,
- // consuming one occurrence from each source.
- size_t match = service.size();
- for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 })
- {
- const auto range = canonical_minutes.equal_range(utcMinute(wall + offset));
- for (auto candidate = range.first; candidate != range.second; ++candidate)
- {
- const size_t pos = candidate->second;
- if (!service_placed[pos] && sameSenderAndText(message, service[pos]))
- {
- match = pos;
- break;
- }
- }
- if (match != service.size())
- {
- break;
- }
- }
- if (match != service.size())
- {
- stitched = serviceMessage(service[match]);
- service_placed[match] = true;
- }
- dated.emplace_back(wall, stitched);
+ result.messages.push_back(stitched);
}
}
- std::stable_sort(dated.begin(), dated.end(),
- [](const auto& left, const auto& right)
- {
- return left.first < right.first;
- });
-
- for (const auto& item : dated)
+ // Storage reads and preview publications share the same occurrence-aware seam.
+ std::list<LLSD> service;
+ for (const Row& row : archive.display_rows)
{
- result.messages.push_back(item.second);
+ service.push_back(serviceMessage(row));
}
-
- for (const LLSD& message : undated)
- {
- result.messages.push_back(message);
- }
-
- // Append service rows that did not replace their exact legacy occurrence.
- for (size_t pos = 0; pos < service.size(); ++pos)
+ for (const Row& row : preview)
{
- if (!service_placed[pos])
- {
- result.messages.push_back(serviceMessage(service[pos]));
- }
+ service.push_back(serviceMessage(row));
}
+ result.messages = mergeDirectHistory(result.messages, service);
// Apply the consumer limit to the complete seam order, retaining the service tail.
while (limit && result.messages.size() > limit)
@@ -2965,7 +2887,7 @@ void LLChatServiceHistory::start()
{
if (!allowed)
{
- pair.second.outbound_refresh_due = 0.0;
+ pair.second.activity_refresh_due = 0.0;
pair.second.snapshot.head_preview.clear();
pair.second.snapshot.service_work_active = false;
}
@@ -2983,7 +2905,10 @@ void LLChatServiceHistory::start()
.getControl("LogShowHistory")->getSignal()->connect(
[](LLControlVariable*, const LLSD&, const LLSD&)
{
- LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true);
+ postPresentation([]()
+ {
+ LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true);
+ });
});
}
@@ -3147,7 +3072,7 @@ void LLChatServiceHistory::prioritizeResident(const LLUUID& id, bool follow_acti
wakeManager();
}
-void LLChatServiceHistory::noteOutboundDirectMessage(const LLUUID& id)
+void LLChatServiceHistory::noteDirectMessageActivity(const LLUUID& id)
{
if (!sRuntime.running || !sRuntime.rollout || !transcriptConsent() ||
sRuntime.delete_requested || id.isNull() || id == sRuntime.agent_id)
@@ -3155,14 +3080,14 @@ void LLChatServiceHistory::noteOutboundDirectMessage(const LLUUID& id)
return;
}
- // Every send replaces one quiet deadline, while first contact keeps its
- // immediate bounded discovery path.
+ // Each incoming or outgoing IM resets the quiet deadline. First contact
+ // also starts bounded discovery immediately.
Resident& resident = sRuntime.residents[id];
- resident.outbound_refresh_due =
- F64(LLTimer::getTotalSeconds()) + OUTBOUND_REFRESH_DELAY;
- if (!resident.listed && resident.outbound_discovery == OUTBOUND_NONE)
+ resident.activity_refresh_due =
+ F64(LLTimer::getTotalSeconds()) + ACTIVITY_REFRESH_DELAY;
+ if (!resident.listed && resident.activity_discovery == DISCOVERY_NONE)
{
- resident.outbound_discovery = OUTBOUND_PENDING;
+ resident.activity_discovery = DISCOVERY_PENDING;
prioritizeResident(id);
}
else
@@ -3189,79 +3114,72 @@ boost::signals2::connection LLChatServiceHistory::setSnapshotChanged(
return sSnapshotSignal.connect(callback);
}
-std::list<LLSD> LLChatServiceHistory::filterLiveDuplicates(
- const std::list<LLSD>& history, const std::list<LLSD>& live)
+LLChatServiceHistory::Messages LLChatServiceHistory::composeHistory(
+ History& history, const Snapshot& snapshot, U32 limit, const LLUUID& session_id)
{
- std::vector<const LLSD*> live_rows;
- for (const LLSD& message : live)
+ Messages preview;
+ if (snapshot.service_presentation_allowed)
+ {
+ for (const Row& row : snapshot.head_preview)
+ {
+ preview.push_back(serviceMessage(row));
+ }
+ }
+ else
{
- live_rows.push_back(&message);
+ history.clearService();
}
- std::vector<bool> consumed(live_rows.size(), false);
- // Matching is occurrence-aware: repeated identical messages consume repeated
- // rows one-for-one instead of collapsing to one value.
- std::list<LLSD> filtered;
- for (const LLSD& message : history)
+ // Preview has no live rows. An IM session supplies only its append-only deliveries.
+ Messages live;
+ LLFloaterIMSession* floater = nullptr;
+ if (session_id.notNull())
{
- bool duplicate = false;
- for (size_t pos = 0; pos < live_rows.size(); ++pos)
+ floater = LLFloaterIMSession::findInstance(session_id);
+ if (const auto* session = LLIMModel::instance().findIMSession(session_id))
{
- if (!consumed[pos] && sameHistoryLiveOccurrence(message, *live_rows[pos]))
+ for (const LLSD& message : session->mMsgs)
{
- consumed[pos] = true;
- duplicate = true;
- break;
+ if (!message["is_history"].asBoolean())
+ {
+ live.push_back(message);
+ }
}
}
- if (!duplicate)
- {
- filtered.push_back(message);
- }
}
- return filtered;
+ return history.compose(preview, live, limit, floater && floater->isInVisibleChain());
}
-std::list<LLSD> LLChatServiceHistory::mergeHeadPreview(
- const std::list<LLSD>& loaded, const Snapshot& snapshot, U32 limit)
+bool LLChatServiceHistory::replaceHistory(Messages& current, const Messages& history, bool direct)
{
- // Deduplicate transient preview rows against the loaded archive by exact message
- // ID, then apply the consumer's ordinary newest-row limit.
- std::list<LLSD> result = loaded;
- std::set<std::string> ids;
- for (const LLSD& message : result)
+ Messages resolved = history;
+ // Legacy names can acquire a UUID from local caches without starting a lookup.
+ // Leave unknown IDs absent so reconciliation can still use the resident name.
+ for (LLSD& message : resolved)
{
- if (message["chat_service_msg_id"].isString())
+ const LLSD& source = message;
+ if (!source[LL_IM_FROM_ID].isDefined())
{
- ids.insert(message["chat_service_msg_id"].asString());
- }
- }
-
- std::vector<Row> additions;
- for (const Row& row : snapshot.head_preview)
- {
- if (!ids.count(row.msg_id))
- {
- additions.push_back(row);
+ const LLUUID id = LLAvatarNameCache::getInstance()->findIdByName(
+ LLCacheName::buildLegacyName(source[LL_IM_FROM].asString()));
+ if (id.notNull())
+ {
+ message[LL_IM_FROM_ID] = id;
+ }
}
}
+ return LLChatServiceHistoryCore::replaceHistory(current, resolved, direct);
+}
- std::sort(additions.begin(), additions.end(), [](const Row& left, const Row& right)
- {
- return left.key < right.key;
- });
-
- for (const Row& row : additions)
- {
- result.push_back(serviceMessage(row));
- }
-
- while (limit && result.size() > limit)
- {
- result.pop_front();
- }
+LLSD LLChatServiceHistory::prepareLiveMessage(
+ const std::string& text, U32& timestamp, const LLSD& context)
+{
+ return captureLiveMessage(text, timestamp, context, static_cast<U32>(time_corrected()));
+}
- return result;
+void LLChatServiceHistory::recordLiveMessage(LLSD& message, const LLSD& context)
+{
+ LLChatServiceHistoryCore::recordLiveMessage(message, context, static_cast<U32>(time_corrected()));
}
bool LLChatServiceHistory::loadStitchedHistory(
@@ -3339,7 +3257,7 @@ bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callb
return false;
}
- // Latch deletion and retire outbound deadlines so no new request or view read can
+ // Latch deletion and retire activity deadlines so no new request or view read can
// start, and no expired wake source remains before pending privacy state is durable.
sRuntime.delete_click_ticks = ticks;
sRuntime.delete_callback = callback;
@@ -3347,7 +3265,7 @@ bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callb
sRuntime.delete_requested = true;
for (auto& pair : sRuntime.residents)
{
- pair.second.outbound_refresh_due = 0.0;
+ pair.second.activity_refresh_due = 0.0;
}
wakeManager();
diff --git a/indra/newview/llchatservicehistory.h b/indra/newview/llchatservicehistory.h
index 62564d52dd..2385fef798 100644
--- a/indra/newview/llchatservicehistory.h
+++ b/indra/newview/llchatservicehistory.h
@@ -75,21 +75,26 @@ namespace LLChatServiceHistory
bool localHistoryExists();
bool localHistoryExists(const LLUUID& resident_id);
- // Opens, accepted inbound activity, and due outbound bursts share one
- // account-scoped priority queue.
+ // Opens and due direct-message activity share one account-scoped priority queue.
bool isPersistedDirectDialog(EInstantMessage dialog);
void prioritizeResident(const LLUUID& resident_id, bool follow_active_request = false);
- void noteOutboundDirectMessage(const LLUUID& resident_id);
+ // Incoming and outgoing IMs reset one quiet deadline per resident.
+ void noteDirectMessageActivity(const LLUUID& resident_id);
// Views connect first and then query so they cannot miss an active-work transition.
Snapshot getSnapshot(const LLUUID& resident_id);
boost::signals2::connection setSnapshotChanged(const snapshot_callback_t& callback);
- std::list<LLSD> mergeHeadPreview(const std::list<LLSD>& loaded,
- const Snapshot& snapshot, U32 limit);
+ using History = LLChatServiceHistoryCore::History;
+ using Messages = LLChatServiceHistoryCore::Messages;
- // Remove one historical occurrence for each exact same-minute live occurrence.
- std::list<LLSD> filterLiveDuplicates(const std::list<LLSD>& history,
- const std::list<LLSD>& live);
+ // Both views compose from their loaded history; open IMs also retain visible context.
+ Messages composeHistory(History& history, const Snapshot& snapshot, U32 limit,
+ const LLUUID& session_id = LLUUID::null);
+ bool replaceHistory(Messages& current, const Messages& history, bool direct);
+
+ // Core IM delivery carries one opaque context through translation and logging.
+ LLSD prepareLiveMessage(const std::string& text, U32& timestamp, const LLSD& context);
+ void recordLiveMessage(LLSD& message, const LLSD& context);
// The callback runs on the main queue after legacy and service storage are read.
bool loadStitchedHistory(const LLUUID& resident_id, const std::string& legacy_stem,
diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp
index 06fdd8ecf6..fc66b1e165 100644
--- a/indra/newview/llchatservicehistorycore.cpp
+++ b/indra/newview/llchatservicehistorycore.cpp
@@ -1,6 +1,6 @@
/**
* @file llchatservicehistorycore.cpp
- * @brief Strict ChatService wire, TimeUUID, and CSV primitives.
+ * @brief ChatService wire/storage primitives and direct history composition.
*
* $LicenseInfo:firstyear=2026&license=viewerlgpl$
* Second Life Viewer Source Code
@@ -15,6 +15,7 @@
#include "lldate.h"
#include "llfile.h"
#include "llstring.h"
+#include "llsdutil.h"
#include "fsyspath.h"
#if LL_WINDOWS
@@ -27,6 +28,7 @@
#include <cerrno>
#include <cctype>
#include <limits>
+#include <map>
#include <set>
namespace LLChatServiceHistoryCore
@@ -286,15 +288,372 @@ bool persistedDirectDialog(S32 dialog)
bool sameDirectSenderName(const std::string& left, const std::string& right)
{
- if (left == right)
+ // Extract the resident name from complete transcript names before normalizing
+ // legacy and dotted forms; the display-name prefix is presentation only.
+ const std::string left_resident =
+ LLCacheName::buildUsername(LLCacheName::buildLegacyName(left));
+ const std::string right_resident =
+ LLCacheName::buildUsername(LLCacheName::buildLegacyName(right));
+ return !left_resident.empty() && left_resident == right_resident;
+}
+
+bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed)
+{
+ if (!timed["timestamp"].isInteger() || timed["timestamp"].asInteger() <= 0)
{
- return true;
+ return false;
}
- // Compare resident usernames even when the chat service supplies a legacy name.
- const std::string left_resident = LLCacheName::buildUsername(left);
- const std::string right_resident = LLCacheName::buildUsername(right);
- return !left_resident.empty() && left_resident == right_resident;
+ // Service bodies omit offline notices and translations. Plaintext contains
+ // the rendered body, so compare it against the same displayed value.
+ const bool service = history["chat_service_msg_id"].isString();
+ const LLSD& text = service && timed["chat_service_original_text"].isString()
+ ? timed["chat_service_original_text"] : timed["message"];
+ if (history["message"].asString() != text.asString())
+ {
+ return false;
+ }
+
+ // UUIDs are authoritative when available; plaintext otherwise identifies
+ // its sender by the resident username, including complete-name spellings.
+ const bool same_sender = history["from_id"].isDefined() && timed["from_id"].isDefined()
+ ? history["from_id"].asUUID() == timed["from_id"].asUUID()
+ : sameDirectSenderName(history["from"].asString(), timed["from"].asString());
+ if (!same_sender)
+ {
+ return false;
+ }
+
+ const S32 timestamp = timed["timestamp"].asInteger();
+ if (service)
+ {
+ if (!history["timestamp"].isInteger() || history["timestamp"].asInteger() <= 0)
+ {
+ return false;
+ }
+
+ const S32 service_timestamp = history["timestamp"].asInteger();
+ // Receipt and service recording use separate clocks and can finish in
+ // either order. Admit bounded service lead as well as delivery delay.
+ if (timed["chat_service_time_source"].asString() == "receipt")
+ {
+ constexpr S64 MAX_DELIVERY_DELAY = 60;
+ constexpr S64 MAX_SERVICE_LEAD = 5;
+ const S64 delay = static_cast<S64>(timestamp) - service_timestamp;
+ return delay >= -MAX_SERVICE_LEAD && delay <= MAX_DELIVERY_DELAY;
+ }
+
+ // Outgoing local echoes admit clock skew within their UTC minute. Saved
+ // offline send timestamps and unmarked callers retain exact seconds.
+ if (timed["chat_service_time_source"].asString() == "local")
+ {
+ return service_timestamp / 60 == timestamp / 60;
+ }
+
+ return service_timestamp == timestamp;
+ }
+
+ // Offline IMs are logged when delivered, potentially days after being sent.
+ // Legacy SLT minutes admit either UTC-7 or UTC-8 because no offset is stored.
+ if (!history["chat_service_legacy_wall_time"].isReal())
+ {
+ return false;
+ }
+ const S32 logged = timed["chat_service_log_timestamp"].isInteger()
+ ? timed["chat_service_log_timestamp"].asInteger() : timestamp;
+ for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 })
+ {
+ const F64 minute = history["chat_service_legacy_wall_time"].asReal() + offset;
+ if (logged >= minute && logged < minute + 60.0)
+ {
+ return true;
+ }
+ }
+ return false;
+}
+
+std::list<LLSD> filterDirectHistoryDuplicates(
+ const std::list<LLSD>& history, const std::list<LLSD>& live)
+{
+ // Reserve known send times before receipt intervals and local-clock minutes.
+ // Within each kind, match newest deliveries to newest eligible occurrences so
+ // earlier repeated messages retain their historical position.
+ auto priority = [](const LLSD& message)
+ {
+ const std::string source = message["chat_service_time_source"].asString();
+ return source == "receipt" ? 1 : source == "local" ? 2 : 0;
+ };
+ std::vector<const LLSD*> deliveries;
+ for (const LLSD& message : live) deliveries.push_back(&message);
+ std::stable_sort(deliveries.begin(), deliveries.end(), [&](const LLSD* a, const LLSD* b)
+ {
+ return priority(*a) != priority(*b) ? priority(*a) < priority(*b)
+ : (*a)["timestamp"].asInteger() > (*b)["timestamp"].asInteger();
+ });
+
+ // Sorted iterators allow matching in time order while retaining the source order.
+ Messages result = history;
+ std::vector<Messages::const_iterator> service;
+ for (auto row = result.cbegin(); row != result.cend(); ++row)
+ {
+ if ((*row)["chat_service_msg_id"].isString()) service.push_back(row);
+ }
+ std::stable_sort(service.begin(), service.end(), [](auto a, auto b)
+ {
+ return (*a)["timestamp"].asInteger() > (*b)["timestamp"].asInteger();
+ });
+ std::vector<bool> matched(deliveries.size(), false);
+ for (size_t pos = 0; pos < deliveries.size(); ++pos)
+ {
+ const auto match = std::find_if(service.begin(), service.end(), [&](auto row)
+ {
+ return row != result.cend() && sameDirectHistoryOccurrence(*row, *deliveries[pos]);
+ });
+ if (match != service.end())
+ {
+ result.erase(*match);
+ *match = result.cend();
+ matched[pos] = true;
+ }
+ }
+
+ // Plaintext uses the remaining occurrences. Saved/translated deliveries can
+ // also identify a decorated log copy that could not match the raw service body.
+ for (size_t pos = 0; pos < deliveries.size(); ++pos)
+ {
+ const LLSD& delivery = *deliveries[pos];
+ if (matched[pos] && !(delivery["chat_service_original_text"].isString() &&
+ delivery["chat_service_original_text"].asString() != delivery["message"].asString()))
+ {
+ continue;
+ }
+ const auto match = std::find_if(result.begin(), result.end(), [&](const LLSD& row)
+ {
+ return !row["chat_service_msg_id"].isString() && sameDirectHistoryOccurrence(row, delivery);
+ });
+ if (match != result.end()) result.erase(match);
+ }
+ return result;
+}
+
+std::list<LLSD> interleaveDirectHistory(const std::list<LLSD>& history,
+ const std::list<LLSD>& live)
+{
+ std::list<LLSD> result = live;
+ auto position = result.begin();
+
+ // A service head may contain messages sent after live delivery began. Insert
+ // them by UTC time while retaining both sources' order and untimed live anchors.
+ for (const LLSD& message : history)
+ {
+ const S32 timestamp = message["timestamp"].asInteger();
+ while (position != result.end() &&
+ (timestamp <= 0 || (*position)["timestamp"].asInteger() <= 0 ||
+ (*position)["timestamp"].asInteger() >= timestamp))
+ {
+ ++position;
+ }
+ result.insert(position, message);
+ }
+ return result;
+}
+
+Messages mergeDirectHistory(const Messages& loaded, const Messages& incoming)
+{
+ // Each input is a seam already reconciled against its own service IDs.
+ // Reconcile only IDs missing from that input, then union legacy occurrences.
+ struct Seam
+ {
+ Messages legacy;
+ std::map<TimeUuidKey, LLSD> service;
+ explicit Seam(const Messages& messages)
+ {
+ for (const LLSD& row : messages)
+ {
+ TimeUuidKey key;
+ if (parseTimeUuid(row["chat_service_msg_id"].asString(), key))
+ service.emplace(key, row);
+ else
+ legacy.push_back(row);
+ }
+ }
+ } left(loaded), right(incoming);
+
+ auto reconcile = [](Seam& seam, const Seam& other)
+ {
+ // Index minutes so full transcript reads do not compare every pair.
+ std::multimap<S64, const LLSD*> added;
+ for (const auto& entry : other.service)
+ {
+ if (!seam.service.count(entry.first))
+ added.emplace(entry.second["timestamp"].asInteger() / 60, &entry.second);
+ }
+ seam.legacy.remove_if([&](const LLSD& row)
+ {
+ if (!row["chat_service_legacy_wall_time"].isReal()) return false;
+ for (S64 offset : {7 * 3600, 8 * 3600})
+ {
+ const S64 minute = (static_cast<S64>(row["chat_service_legacy_wall_time"].asReal()) + offset) / 60;
+ const auto range = added.equal_range(minute);
+ for (auto candidate = range.first; candidate != range.second; ++candidate)
+ {
+ if (sameDirectHistoryOccurrence(row, *candidate->second))
+ {
+ added.erase(candidate);
+ return true;
+ }
+ }
+ }
+ return false;
+ });
+ };
+ reconcile(left, right);
+ reconcile(right, left);
+
+ // Shared plaintext occurrences count once; distinct repeated lines retain
+ // the greater occurrence count from the two bounded source windows.
+ Messages unmatched = left.legacy;
+ for (const LLSD& row : right.legacy)
+ {
+ const auto match = std::find_if(unmatched.begin(), unmatched.end(),
+ [&](const LLSD& candidate) { return llsd_equals(candidate, row); });
+ if (match == unmatched.end()) left.legacy.push_back(row);
+ else unmatched.erase(match);
+ }
+ left.legacy.sort([](const LLSD& a, const LLSD& b)
+ {
+ const LLSD& at = a["chat_service_legacy_wall_time"];
+ const LLSD& bt = b["chat_service_legacy_wall_time"];
+ return at.isReal() && (!bt.isReal() || at.asReal() < bt.asReal());
+ });
+
+ // Loaded canonical values win preview identity; all service rows use TimeUUID order.
+ left.service.insert(right.service.begin(), right.service.end());
+ for (const auto& entry : left.service) left.legacy.push_back(entry.second);
+ return left.legacy;
+}
+
+void History::setLoaded(const Messages& messages)
+{
+ mLoaded = messages;
+ if (messages.empty())
+ {
+ mVisible.clear();
+ }
+}
+
+void History::clear()
+{
+ mLoaded.clear();
+ mVisible.clear();
+}
+
+void History::clearService()
+{
+ auto service = [](const LLSD& row) { return row["chat_service_msg_id"].isString(); };
+ mLoaded.remove_if(service);
+ mVisible.remove_if(service);
+}
+
+Messages History::compose(const Messages& preview, const Messages& live, U32 limit,
+ bool retain_context)
+{
+ // Retain bounded visible context while open, including plaintext that has not
+ // acquired a service ID. Compose before filtering so live copies cannot evict it.
+ Messages result = mergeDirectHistory(mLoaded, preview);
+ if (retain_context)
+ {
+ result = mergeDirectHistory(result, mVisible);
+ }
+ result = filterDirectHistoryDuplicates(result, live);
+ while (limit && result.size() > limit)
+ {
+ result.pop_front();
+ }
+ mVisible = retain_context ? result : Messages();
+ return result;
+}
+
+bool replaceHistory(Messages& current, const Messages& history, bool direct)
+{
+ Messages live;
+ for (const LLSD& message : current)
+ {
+ if (!message["is_history"].asBoolean())
+ {
+ live.push_back(message);
+ }
+ }
+ Messages historical;
+ for (const LLSD& source : history)
+ {
+ LLSD message = source;
+ message["timestamp"] = source["timestamp"].asInteger();
+ message["is_history"] = true;
+ message["is_region_msg"] = false;
+ historical.push_front(message);
+ }
+ if (direct)
+ {
+ live = interleaveDirectHistory(historical, live);
+ }
+ else
+ {
+ live.insert(live.end(), historical.begin(), historical.end());
+ }
+
+ // Index changes alone do not require replaying inline notification widgets.
+ if (current.size() == live.size() &&
+ std::equal(current.begin(), current.end(), live.begin(), [](LLSD left, LLSD right)
+ {
+ left.erase("index");
+ right.erase("index");
+ return llsd_equals(left, right);
+ }))
+ {
+ return false;
+ }
+ current.swap(live);
+ S32 index = static_cast<S32>(current.size());
+ for (LLSD& message : current)
+ {
+ message["index"] = --index;
+ }
+ return true;
+}
+
+LLSD incomingContext(const LLSD& original_text, bool online)
+{
+ LLSD context;
+ context["chat_service_original_text"] = original_text;
+ context["chat_service_time_source"] = online ? "receipt" : "sent";
+ return context;
+}
+
+LLSD captureLiveMessage(const std::string& text, U32& timestamp, const LLSD& context, U32 now)
+{
+ LLSD captured = context;
+ if (!context["chat_service_original_text"].isString())
+ {
+ captured["chat_service_original_text"] = text;
+ }
+ if (!context["chat_service_time_source"].isString() ||
+ (!timestamp && context["chat_service_time_source"].asString() != "receipt"))
+ {
+ captured["chat_service_time_source"] = timestamp ? "sent" : "local";
+ }
+ if (!timestamp)
+ {
+ timestamp = now;
+ }
+ return captured;
+}
+
+void recordLiveMessage(LLSD& message, const LLSD& context, U32 logged_at)
+{
+ message["chat_service_original_text"] = context["chat_service_original_text"];
+ message["chat_service_time_source"] = context["chat_service_time_source"];
+ message["chat_service_log_timestamp"] = static_cast<S32>(logged_at);
}
bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch)
@@ -441,15 +800,15 @@ bool validateConversationList(const LLSD& value, const LLUUID& agent_id,
continue;
}
- // Direct entries must identify one unique peer and use the deterministic
- // account/resident conversation ID with a valid latest-message token.
+ // Direct entries use the deterministic account/participant conversation ID
+ // with a valid latest-message token and no duplicate participants.
std::string resident_text;
std::string token;
LLUUID resident;
TimeUuidKey token_key;
if (!llsdString(*it, "other_participant_id", resident_text) ||
!llsdString(*it, "last_msg_id", token) ||
- !parseCanonicalUuid(resident_text, resident) || resident.isNull() ||
+ !parseCanonicalUuid(resident_text, resident) ||
resident == agent_id ||
!parseTimeUuid(token, token_key) ||
conversation != directConversationId(agent_id, resident) ||
@@ -459,7 +818,12 @@ bool validateConversationList(const LLSD& value, const LLUUID& agent_id,
return false;
}
- entries.push_back({resident, conversation, token});
+ // The service also lists system traffic with a null participant; only
+ // resident conversations can have a local history archive.
+ if (resident.notNull())
+ {
+ entries.push_back({resident, conversation, token});
+ }
}
return true;
diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h
index 9b4254eca6..ebf47e809e 100644
--- a/indra/newview/llchatservicehistorycore.h
+++ b/indra/newview/llchatservicehistorycore.h
@@ -1,6 +1,6 @@
/**
* @file llchatservicehistorycore.h
- * @brief Strict ChatService wire, TimeUUID, and CSV primitives.
+ * @brief ChatService wire/storage primitives and direct history composition.
*
* $LicenseInfo:firstyear=2026&license=viewerlgpl$
* Second Life Viewer Source Code
@@ -16,6 +16,7 @@
#include <array>
#include <deque>
#include <iosfwd>
+#include <list>
#include <string>
#include <vector>
@@ -110,6 +111,45 @@ namespace LLChatServiceHistoryCore
bool parseCreatedAt(const std::string& text, std::string& normalized);
bool sameDirectSenderName(const std::string& left, const std::string& right);
+ // Compare service rows against original bodies and plaintext against logged bodies/times.
+ bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed);
+
+ // Reserve known send times before receipt intervals and local-clock minute matches.
+ std::list<LLSD> filterDirectHistoryDuplicates(const std::list<LLSD>& history,
+ const std::list<LLSD>& live);
+
+ using Messages = std::list<LLSD>;
+
+ // Union historical seams by service ID and plaintext occurrence, consuming overlaps one-for-one.
+ Messages mergeDirectHistory(const Messages& loaded, const Messages& service);
+
+ // Preview publications start from the loaded value; visible context is retained separately.
+ class History
+ {
+ public:
+ void setLoaded(const Messages& messages);
+ void clear();
+ void clearService();
+ Messages compose(const Messages& preview, const Messages& live, U32 limit,
+ bool retain_context = false);
+
+ private:
+ Messages mLoaded;
+ Messages mVisible;
+ };
+
+ // Preserve live order and notification rows; replace history and reindex only on change.
+ bool replaceHistory(Messages& current, const Messages& history, bool direct);
+
+ // Capture timing before translation and keep its provenance with the original wire body.
+ LLSD incomingContext(const LLSD& original_text, bool online);
+ LLSD captureLiveMessage(const std::string& text, U32& timestamp, const LLSD& context, U32 now);
+ void recordLiveMessage(LLSD& message, const LLSD& context, U32 logged_at);
+
+ // Both inputs are newest-first; interleave timed history without reordering live rows.
+ std::list<LLSD> interleaveDirectHistory(const std::list<LLSD>& history,
+ const std::list<LLSD>& live);
+
// Legacy SLT wall times carry no UTC offset; UTC-7 is their earliest possible
// interpretation at the durable service boundary.
bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch);
diff --git a/indra/newview/llfloaterconversationpreview.cpp b/indra/newview/llfloaterconversationpreview.cpp
index 767bf95b7f..f46d28342f 100644
--- a/indra/newview/llfloaterconversationpreview.cpp
+++ b/indra/newview/llfloaterconversationpreview.cpp
@@ -208,6 +208,7 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key)
mOpened = true;
++mServiceToken;
+ mServiceHistory.clear();
mPageSpinner = getChild<LLSpinCtrl>("history_page_spin");
mPageSpinner->setCommitCallback(
boost::bind(&LLFloaterConversationPreview::onMoreHistoryBtnClick, this));
@@ -347,6 +348,7 @@ void LLFloaterConversationPreview::onClose(bool app_quitting)
// Close is a lifecycle fence for callbacks, not a request to cancel shared service work.
mOpened = false;
++mServiceToken;
+ mServiceHistory.clear();
mServiceLocalLoading = false;
mServiceReloadPending = false;
mHistoryContentConnection.disconnect();
@@ -371,6 +373,7 @@ void LLFloaterConversationPreview::invalidateHistory()
// Advancing the token invalidates every outstanding legacy or stitched completion
// before deletion clears the visible backing lists.
++mServiceToken;
+ mServiceHistory.clear();
mServiceLocalLoading = false;
mServiceReloadPending = false;
@@ -454,8 +457,9 @@ void LLFloaterConversationPreview::onServiceLoaded(
// Merge the bounded transient head only after the durable read matches the
// current snapshot, then preserve the reader's relative page position.
- std::list<LLSD> merged = LLChatServiceHistory::mergeHeadPreview(
- result.messages, snapshot, 10000);
+ mServiceHistory.setLoaded(result.messages);
+ std::list<LLSD> merged = LLChatServiceHistory::composeHistory(
+ mServiceHistory, snapshot, 10000);
setPages(new std::list<LLSD>(merged), mChatHistoryFileName);
if (reload_pending)
@@ -482,20 +486,6 @@ void LLFloaterConversationPreview::onServiceSnapshot(
{
mServiceReloadPending = true;
}
- if (presentation_changed && !snapshot.service_presentation_allowed && mMessages)
- {
- std::list<LLSD>* legacy_only = new std::list<LLSD>();
- for (const LLSD& message : *mMessages)
- {
- if (!message["chat_service_msg_id"].isString())
- {
- legacy_only->push_back(message);
- }
- }
-
- setPages(legacy_only, mChatHistoryFileName);
- }
-
// A CSV-only cold-cache Preview performs one extra read when shared metadata
// supplies the legacy transcript stem.
if (mChatHistoryFileName.empty() && !mServiceNameReloaded &&
@@ -515,10 +505,10 @@ void LLFloaterConversationPreview::onServiceSnapshot(
// The first validated service page may update the open Preview before archive
// preparation and older paging complete.
- if (snapshot.service_presentation_allowed && !snapshot.head_preview.empty())
+ if (presentation_changed || !snapshot.head_preview.empty())
{
- std::list<LLSD> merged = LLChatServiceHistory::mergeHeadPreview(
- mMessages ? *mMessages : std::list<LLSD>(), snapshot, 10000);
+ std::list<LLSD> merged = LLChatServiceHistory::composeHistory(
+ mServiceHistory, snapshot, 10000);
setPages(new std::list<LLSD>(merged), mChatHistoryFileName);
}
diff --git a/indra/newview/llfloaterconversationpreview.h b/indra/newview/llfloaterconversationpreview.h
index 4c03095e94..c36dae564c 100644
--- a/indra/newview/llfloaterconversationpreview.h
+++ b/indra/newview/llfloaterconversationpreview.h
@@ -89,6 +89,7 @@ private:
// Tokens fence asynchronous applies across close, reopen, and reload requests.
U64 mServiceToken;
U32 mServiceAppliedSerial;
+ LLChatServiceHistory::History mServiceHistory;
boost::signals2::connection mHistoryContentConnection;
boost::signals2::connection mServiceSnapshotConnection;
};
diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp
index 64c0354ddd..4b9e63ae5a 100644
--- a/indra/newview/llfloaterimsession.cpp
+++ b/indra/newview/llfloaterimsession.cpp
@@ -843,6 +843,16 @@ void LLFloaterIMSession::updateMessages()
for (; iter != iter_end; ++iter)
{
LLSD msg = *iter;
+ // Consume hidden fallbacks and expired offer markers as well as visible rows.
+ mLastMessageIndex = msg["index"].asInteger();
+
+ // An active inline offer replaces only its explicitly linked text log.
+ // The fallback can be absent or arrive after unrelated chat messages.
+ const LLUUID notification_log_id = msg["notification_log_id"].asUUID();
+ if (notification_log_id.notNull() && LLNotificationsUtil::find(notification_log_id) != NULL)
+ {
+ continue;
+ }
std::string time = msg["time"].asString();
LLUUID from_id = msg["from_id"].asUUID();
@@ -879,7 +889,7 @@ void LLFloaterIMSession::updateMessages()
channel->hideToast(chat.mNotifId);
}
}
- // if notification doesn't exist - try to use next message which should be log entry
+ // Expired offers leave their text fallback to render independently.
else
{
continue;
@@ -893,20 +903,6 @@ void LLFloaterIMSession::updateMessages()
// Add the message to the chat log
appendMessage(chat);
- mLastMessageIndex = msg["index"].asInteger();
-
- // if it is a notification - next message is a notification history log, so skip it
- if (chat.mNotifId.notNull() && LLNotificationsUtil::find(chat.mNotifId) != NULL)
- {
- if (++iter == iter_end)
- {
- break;
- }
- else
- {
- mLastMessageIndex++;
- }
- }
}
}
}
diff --git a/indra/newview/llimprocessing.cpp b/indra/newview/llimprocessing.cpp
index bd5a2cddee..409e213e0e 100644
--- a/indra/newview/llimprocessing.cpp
+++ b/indra/newview/llimprocessing.cpp
@@ -519,6 +519,10 @@ void LLIMProcessing::processNewMessage(LLUUID from_id,
break;
case IM_NOTHING_SPECIAL: // p2p IM
+ // Record delivery metadata so live transport can be distinguished from history retrieval.
+ LL_INFOS("ChatServiceHistory") << "Direct IM received: sender_id=" << from_id
+ << ", offline=" << (offline == IM_OFFLINE)
+ << ", timestamp=" << timestamp << ", bytes=" << message.size() << LL_ENDL;
// Don't show dialog, just do IM
if (!gAgent.isGodlike()
&& gAgent.inPrelude()
@@ -622,6 +626,7 @@ void LLIMProcessing::processNewMessage(LLUUID from_id,
real_name = SYSTEM_FROM;
}
+ // Keep the undecorated body for matching against service history.
gIMMgr->addMessage(session_id,
from_id,
name,
@@ -635,7 +640,8 @@ void LLIMProcessing::processNewMessage(LLUUID from_id,
region_message,
timestamp,
LLUUID::null,
- real_name);
+ real_name,
+ notice_name.empty() ? LLSD(message) : LLSD());
}
else
{
diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp
index e27ad96a63..0bdb114c17 100644
--- a/indra/newview/llimview.cpp
+++ b/indra/newview/llimview.cpp
@@ -90,31 +90,6 @@ const S32 XL8_PADDING = 3; // XL8_START_TAG.size() + XL8_END_TAG.size()
// Tokens are process-unique so a stale completion cannot match a recreated P2P session.
static U64 sChatHistoryLoadToken = 0;
-namespace
-{
-bool sameHistoryRow(LLSD left, LLSD right)
-{
- // Positional indexes are rebuilt with the overlay and do not change presentation.
- left.erase("index");
- right.erase("index");
- return llsd_equals(left, right);
-}
-
-bool sameHistory(const LLIMModel::chat_message_list_t& left,
- const LLIMModel::chat_message_list_t& right)
-{
- return left.size() == right.size() &&
- std::equal(left.begin(), left.end(), right.begin(), sameHistoryRow);
-}
-
-bool isStrictHistoryPrefix(const LLIMModel::chat_message_list_t& prefix,
- const LLIMModel::chat_message_list_t& complete)
-{
- return prefix.size() < complete.size() &&
- std::equal(prefix.begin(), prefix.end(), complete.begin(), sameHistoryRow);
-}
-}
-
/** Timeout of outgoing session initialization (in seconds) */
const static U32 SESSION_INITIALIZATION_TIMEOUT = 30;
@@ -626,39 +601,37 @@ void chatterBoxInvitationCoro(std::string url, LLUUID sessionId, LLIMMgr::EInvit
}
void translateSuccess(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text,
- U64 time_n_flags, std::string originalMsg, std::string expectLang, std::string translation, const std::string detected_language)
+ U64 time_n_flags, LLSD history_context, std::string expectLang, std::string translation, const std::string detected_language)
{
std::string message_txt(utf8_text);
// filter out non-interesting responses
if (!translation.empty()
&& ((detected_language.empty()) || (expectLang != detected_language))
- && (LLStringUtil::compareInsensitive(translation, originalMsg) != 0))
+ && (LLStringUtil::compareInsensitive(translation, utf8_text) != 0))
{ // Note - if this format changes, also fix code in addMessagesFromServerHistory()
message_txt += XL8_START_TAG + LLTranslate::removeNoTranslateTags(translation) + XL8_END_TAG;
}
- // Extract info packed in time_n_flags
bool log2file = (bool)(time_n_flags & (1LL << 32));
bool is_region_msg = (bool)(time_n_flags & (1LL << 33));
U32 time_stamp = (U32)(time_n_flags & 0x00000000ffffffff);
- LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp);
+ LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp, history_context);
}
void translateFailure(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text,
- U64 time_n_flags, int status, const std::string err_msg)
+ U64 time_n_flags, LLSD history_context, int status, const std::string err_msg)
{
std::string message_txt(utf8_text);
std::string msg = LLTrans::getString("TranslationFailed", LLSD().with("[REASON]", err_msg));
LLStringUtil::replaceString(msg, "\n", " "); // we want one-line error messages
message_txt += XL8_START_TAG + msg + XL8_END_TAG;
- // Extract info packed in time_n_flags
bool log2file = (bool)(time_n_flags & (1LL << 32));
bool is_region_msg = (bool)(time_n_flags & (1LL << 33));
U32 time_stamp = (U32)(time_n_flags & 0x00000000ffffffff);
- LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp);
+ LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp, history_context);
}
void chatterBoxHistoryCoro(std::string url, LLUUID sessionId, std::string from, std::string message, U32 timestamp)
@@ -1394,8 +1367,8 @@ void LLIMModel::LLIMSession::loadHistory()
{
mChatHistoryLoadToken = ++sChatHistoryLoadToken;
mChatHistoryLocalLoading = false;
- mChatServiceHistoricalValue.clear();
- replaceHistoricalMessages(mChatServiceHistoricalValue);
+ mChatServiceHistory.clear();
+ replaceHistoricalMessages({});
return;
}
@@ -1403,8 +1376,8 @@ void LLIMModel::LLIMSession::loadHistory()
{
mChatHistoryLoadToken = ++sChatHistoryLoadToken;
mChatHistoryLocalLoading = false;
- mChatServiceHistoricalValue.clear();
- replaceHistoricalMessages(mChatServiceHistoricalValue);
+ mChatServiceHistory.clear();
+ replaceHistoricalMessages({});
return;
}
@@ -1463,9 +1436,9 @@ void LLIMModel::LLIMSession::loadHistory()
// current, then replace the historical overlay in one operation.
session->mChatHistoryArchiveSerial = result.archive_serial;
session->mChatHistoryLocalLoading = false;
- session->mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview(
- result.messages, snapshot, 200);
- session->replaceHistoricalMessages(session->mChatServiceHistoricalValue);
+ session->mChatServiceHistory.setLoaded(result.messages);
+ session->replaceHistoricalMessages(LLChatServiceHistory::composeHistory(
+ session->mChatServiceHistory, snapshot, 200, session_id));
}))
{
// If dispatch infrastructure is unavailable, retain ordinary plaintext
@@ -1476,7 +1449,7 @@ void LLIMModel::LLIMSession::loadHistory()
{
chat_message_list_t legacy;
LLLogChat::loadChatHistory(mHistoryFileName, legacy);
- mChatServiceHistoricalValue = legacy;
+ mChatServiceHistory.setLoaded(legacy);
replaceHistoricalMessages(legacy);
}
}
@@ -1484,86 +1457,12 @@ void LLIMModel::LLIMSession::loadHistory()
void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t& history)
{
- // Separate the append-only live tail from the currently presented historical baseline.
- chat_message_list_t live;
- chat_message_list_t current_historical;
- for (const LLSD& message : mMsgs)
- {
- if (!message["is_history"].asBoolean())
- {
- live.push_back(message);
- }
- else
- {
- current_historical.push_back(message);
- }
- }
-
- // A refresh may contain rows that this open session already
- // displays live. Consume those exact occurrences before rebuilding the overlay.
- const chat_message_list_t filtered = isP2P()
- ? LLChatServiceHistory::filterLiveDuplicates(history, live) : history;
-
- // Normalize stitched LLLogChat fields into the IM model shape and reverse the
- // ascending input into the model's newest-first historical order.
- chat_message_list_t historical;
- for (const LLSD& source : filtered)
+ if (LLChatServiceHistory::replaceHistory(mMsgs, history, isP2P()))
{
- const std::string from = source[LL_IM_FROM].asString();
- LLUUID from_id;
-
- // Plaintext transcripts store sender names but usually omit UUIDs. Recover
- // resident identity from local name caches without scheduling a lookup.
- if (source[LL_IM_FROM_ID].isDefined())
+ if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID))
{
- from_id = source[LL_IM_FROM_ID].asUUID();
+ floater->reloadMessages(false);
}
- else
- {
- const std::string legacy_name = gCacheName->buildLegacyName(from);
- from_id = LLAvatarNameCache::getInstance()->findIdByName(legacy_name);
- }
-
- LLSD message = source;
- message["from"] = from;
- message["from_id"] = from_id;
- message["message"] = source[LL_IM_TEXT];
- message["time"] = source[LL_IM_TIME];
- message["timestamp"] = source["timestamp"].asInteger();
- message["is_history"] = true;
- message["is_region_msg"] = false;
- historical.push_front(message);
- }
-
- const bool consumed_live = filtered.size() < history.size();
- LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID);
- const bool capped_live_rollover =
- floater && floater->isInVisibleChain() &&
- consumed_live &&
- !historical.empty() &&
- history.size() == current_historical.size() &&
- isStrictHistoryPrefix(historical, current_historical);
-
- // Re-publishing the same bounded history, including eviction of its oldest
- // rows after live messages become durable, must not reconstruct a visible transcript.
- if (sameHistory(current_historical, historical) || capped_live_rollover)
- {
- return;
- }
-
- live.insert(live.end(), historical.begin(), historical.end());
- mMsgs.swap(live);
-
- // Rebuild positional indexes only after the complete live/history overlay exists.
- S32 index = static_cast<S32>(mMsgs.size());
- for (LLSD& message : mMsgs)
- {
- message["index"] = --index;
- }
-
- if (floater)
- {
- floater->reloadMessages(false);
}
}
@@ -1573,8 +1472,8 @@ void LLIMModel::LLIMSession::clearHistoricalMessages()
// after presentation has been cleared.
mChatHistoryLoadToken = ++sChatHistoryLoadToken;
mChatHistoryLocalLoading = false;
- mChatServiceHistoricalValue.clear();
- replaceHistoricalMessages(mChatServiceHistoricalValue);
+ mChatServiceHistory.clear();
+ replaceHistoricalMessages({});
}
void LLIMModel::LLIMSession::clearForHistoryDeletion()
@@ -1584,7 +1483,7 @@ void LLIMModel::LLIMSession::clearForHistoryDeletion()
mChatHistoryLocalLoading = false;
mChatHistoryArchiveSerial = 0;
mChatServicePresentationAllowed = false;
- mChatServiceHistoricalValue.clear();
+ mChatServiceHistory.clear();
mLastHistoryCacheDateTime.clear();
mLastHistoryCacheMsgs.clear();
mMsgs.clear();
@@ -1607,33 +1506,15 @@ void LLIMModel::LLIMSession::applyChatServiceSnapshot(
return;
}
- // Revoking service presentation strips only service-tagged historical rows;
- // legacy rows and every live row remain intact.
const bool presentation_changed =
snapshot.service_presentation_allowed != mChatServicePresentationAllowed;
mChatServicePresentationAllowed = snapshot.service_presentation_allowed;
- if (presentation_changed && !snapshot.service_presentation_allowed)
+ // Apply previews and presentation changes through the shared history composer.
+ if (presentation_changed || !snapshot.head_preview.empty())
{
- chat_message_list_t legacy_only;
- for (const LLSD& message : mChatServiceHistoricalValue)
- {
- if (!message["chat_service_msg_id"].isString())
- {
- legacy_only.push_back(message);
- }
- }
-
- mChatServiceHistoricalValue.swap(legacy_only);
- replaceHistoricalMessages(mChatServiceHistoricalValue);
- }
-
- // The bounded first page can update presentation before durable archive publication.
- if (snapshot.service_presentation_allowed && !snapshot.head_preview.empty())
- {
- mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview(
- mChatServiceHistoricalValue, snapshot, 200);
- replaceHistoricalMessages(mChatServiceHistoricalValue);
+ replaceHistoricalMessages(LLChatServiceHistory::composeHistory(
+ mChatServiceHistory, snapshot, 200, mSessionID));
}
// The active read will restart itself if its captured presentation or archive
@@ -2008,8 +1889,7 @@ bool LLIMModel::addToHistory(const LLUUID& session_id,
return false;
}
- // P2P live rows keep their actual arrival/send time so a later asynchronous
- // history refresh can reconcile the same logged occurrence exactly by minute.
+ // Fill missing direct timestamps for display and plaintext reconciliation.
U32 model_timestamp = timestamp;
if (!model_timestamp && session->isP2PSessionType())
{
@@ -2060,27 +1940,34 @@ void LLIMModel::proccessOnlineOfflineNotification(
}
void LLIMModel::addMessage(const LLUUID& session_id, const std::string& from, const LLUUID& from_id,
- const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* = false */ U32 time_stamp /* = 0 */)
+ const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* = false */ U32 time_stamp /* = 0 */,
+ LLSD history_context)
{
+ // Capture direct-message timing before an asynchronous translation can delay it.
+ LLIMSession* session = findIMSession(session_id);
+ if (session && session->isP2P())
+ {
+ history_context = LLChatServiceHistory::prepareLiveMessage(utf8_text, time_stamp, history_context);
+ }
if (gSavedSettings.getBOOL("TranslateChat") && (from != SYSTEM_FROM))
{
const std::string from_lang = ""; // leave empty to trigger autodetect
const std::string to_lang = LLTranslate::getTranslateLanguage();
U64 time_n_flags = ((U64) time_stamp) | (log2file ? (1LL << 32) : 0) | (is_region_msg ? (1LL << 33) : 0); // boost::bind has limited parameters
LLTranslate::translateMessage(from_lang, to_lang, utf8_text,
- boost::bind(&translateSuccess, session_id, from, from_id, utf8_text, time_n_flags, utf8_text, from_lang, _1, _2),
- boost::bind(&translateFailure, session_id, from, from_id, utf8_text, time_n_flags, _1, _2));
+ boost::bind(&translateSuccess, session_id, from, from_id, utf8_text, time_n_flags, history_context, from_lang, _1, _2),
+ boost::bind(&translateFailure, session_id, from, from_id, utf8_text, time_n_flags, history_context, _1, _2));
}
else
{
- processAddingMessage(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp);
+ processAddingMessage(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp, history_context);
}
}
void LLIMModel::processAddingMessage(const LLUUID& session_id, const std::string& from, const LLUUID& from_id,
- const std::string& utf8_text, bool log2file, bool is_region_msg, U32 time_stamp)
+ const std::string& utf8_text, bool log2file, bool is_region_msg, U32 time_stamp, LLSD history_context)
{
- LLIMSession* session = addMessageSilently(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp);
+ LLIMSession* session = addMessageSilently(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp, history_context);
if (!session)
return;
@@ -2107,7 +1994,7 @@ void LLIMModel::processAddingMessage(const LLUUID& session_id, const std::string
LLIMModel::LLIMSession* LLIMModel::addMessageSilently(const LLUUID& session_id, const std::string& from, const LLUUID& from_id,
const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* false */
- U32 timestamp /* = 0 */)
+ U32 timestamp /* = 0 */, LLSD history_context)
{
LLIMSession* session = findIMSession(session_id);
@@ -2123,7 +2010,16 @@ LLIMModel::LLIMSession* LLIMModel::addMessageSilently(const LLUUID& session_id,
from_name = SYSTEM_FROM;
}
+ // Direct silent callers also capture provenance; live delivery always appends.
+ if (session->isP2P())
+ {
+ history_context = LLChatServiceHistory::prepareLiveMessage(utf8_text, timestamp, history_context);
+ }
addToHistory(session_id, from_name, from_id, utf8_text, is_region_msg, timestamp);
+ if (session->isP2P())
+ {
+ LLChatServiceHistory::recordLiveMessage(session->mMsgs.front(), history_context);
+ }
if (log2file)
{
logToFile(getHistoryFileName(session_id), from_name, from_id, utf8_text);
@@ -2344,11 +2240,11 @@ void LLIMModel::sendMessage(const std::string& utf8_text,
if((dialog == IM_NOTHING_SPECIAL) &&
(other_participant_id.notNull()))
{
- // The packet has been sent; direct outbound bursts share one quiet service
- // refresh while first contact also starts bounded discovery immediately.
+ // The packet has been sent; incoming and outgoing IMs share one quiet
+ // service refresh while first contact starts bounded discovery immediately.
if (session && session->isP2PSessionType())
{
- LLChatServiceHistory::noteOutboundDirectMessage(other_participant_id);
+ LLChatServiceHistory::noteDirectMessageActivity(other_participant_id);
}
// Do we have to replace the /me's here?
@@ -3502,7 +3398,8 @@ void LLIMMgr::addMessage(
bool is_region_msg,
U32 timestamp, // May be zero
LLUUID display_id,
- std::string_view display_name)
+ std::string_view display_name,
+ const LLSD& original_text)
{
LLUUID other_participant_id = target_id;
std::string message_display_name = (display_name.empty()) ? from : std::string(display_name);
@@ -3631,14 +3528,20 @@ void LLIMMgr::addMessage(
if (!LLMuteList::getInstance()->isMuted(other_participant_id, LLMute::flagTextChat) && !skip_message)
{
- // Accepted inbound direct rows share the same immediate priority path used by an opener.
- if (LLChatServiceHistory::isPersistedDirectDialog(dialog) &&
+ // Online direct timestamps describe receipt; offline timestamps describe
+ // the saved send. Preserve that distinction through translation.
+ const bool incoming_direct = LLChatServiceHistory::isPersistedDirectDialog(dialog) &&
!is_region_msg && message_display_name != SYSTEM_FROM &&
- other_participant_id.notNull() && other_participant_id != gAgentID)
+ other_participant_id.notNull() && other_participant_id != gAgentID;
+ if (incoming_direct)
{
- LLChatServiceHistory::prioritizeResident(other_participant_id, true);
+ // Incoming activity shares the outgoing quiet deadline before reconciliation.
+ LLChatServiceHistory::noteDirectMessageActivity(other_participant_id);
}
- LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp);
+ LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp,
+ incoming_direct
+ ? LLChatServiceHistoryCore::incomingContext(original_text, !is_offline_msg)
+ : LLSD());
}
// Open conversation floater if offline messages are present
diff --git a/indra/newview/llimview.h b/indra/newview/llimview.h
index 3ea8ebac43..4406cbc019 100644
--- a/indra/newview/llimview.h
+++ b/indra/newview/llimview.h
@@ -155,7 +155,7 @@ public:
chat_message_list_t mMsgs;
// Historical rows are replaced as one overlay so live rows remain untouched.
- chat_message_list_t mChatServiceHistoricalValue;
+ LLChatServiceHistory::History mChatServiceHistory;
// Process-unique tokens and archive serials reject stale asynchronous reads.
U64 mChatHistoryLoadToken = 0;
@@ -252,6 +252,7 @@ public:
* Add a message to an IM Model - the message is saved in a message store associated with a session specified by session_id
* and also saved into a file if log2file is specified.
* It sends new message signal for each added message.
+ * history_context carries ChatService metadata through delivery and translation.
*/
void addMessage(const LLUUID& session_id,
const std::string& from,
@@ -259,7 +260,8 @@ public:
const std::string& utf8_text,
bool log2file = true,
bool is_region_msg = false,
- U32 time_stamp = 0);
+ U32 time_stamp = 0,
+ LLSD history_context = LLSD());
void processAddingMessage(const LLUUID& session_id,
const std::string& from,
@@ -267,13 +269,15 @@ public:
const std::string& utf8_text,
bool log2file,
bool is_region_msg,
- U32 time_stamp);
+ U32 time_stamp,
+ LLSD history_context = LLSD());
/**
* Similar to addMessage(...) above but won't send a signal about a new message added
*/
LLIMModel::LLIMSession* addMessageSilently(const LLUUID& session_id, const std::string& from, const LLUUID& from_id,
- const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, U32 timestamp = 0);
+ const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, U32 timestamp = 0,
+ LLSD history_context = LLSD());
/**
* Add a system message to an IM Model
@@ -396,7 +400,8 @@ public:
bool is_region_msg = false,
U32 timestamp = 0,
LLUUID display_id = LLUUID::null,
- std::string_view display_name = "");
+ std::string_view display_name = "",
+ const LLSD& original_text = LLSD());
void addSystemMessage(const LLUUID& session_id, const std::string& message_name, const LLSD& args);
diff --git a/indra/newview/llnotificationhandler.h b/indra/newview/llnotificationhandler.h
index a259a4b248..0099adca88 100644
--- a/indra/newview/llnotificationhandler.h
+++ b/indra/newview/llnotificationhandler.h
@@ -287,17 +287,20 @@ public:
/**
* Writes notification message to IM session.
+ * notification_id links a text fallback to its inline offer, when present.
*/
static void logToIM(const EInstantMessage& session_type,
const std::string& session_name, const std::string& from_name,
const std::string& message, const LLUUID& session_owner_id,
- const LLUUID& from_id);
+ const LLUUID& from_id, const LLUUID& notification_id = LLUUID::null);
/**
* Writes notification message to IM p2p session.
*/
- static void logToIMP2P(const LLNotificationPtr& notification, bool to_file_only = false);
- static void logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only = false);
+ static void logToIMP2P(const LLNotificationPtr& notification, bool to_file_only = false,
+ const LLUUID& notification_id = LLUUID::null);
+ static void logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only = false,
+ const LLUUID& notification_id = LLUUID::null);
/**
* Writes group notice notification message to IM group session.
diff --git a/indra/newview/llnotificationhandlerutil.cpp b/indra/newview/llnotificationhandlerutil.cpp
index fc973e00ae..935094fdc0 100644
--- a/indra/newview/llnotificationhandlerutil.cpp
+++ b/indra/newview/llnotificationhandlerutil.cpp
@@ -72,7 +72,7 @@ bool LLHandlerUtil::isIMFloaterOpened(const LLNotificationPtr& notification)
void LLHandlerUtil::logToIM(const EInstantMessage& session_type,
const std::string& session_name, const std::string& from_name,
const std::string& message, const LLUUID& session_owner_id,
- const LLUUID& from_id)
+ const LLUUID& from_id, const LLUUID& notification_id)
{
std::string from = from_name;
if (from_name.empty())
@@ -111,6 +111,14 @@ void LLHandlerUtil::logToIM(const EInstantMessage& session_type,
S32 participant_unread = session->mParticipantUnreadMessageCount;
LLIMModel::instance().addMessageSilently(session_id, from, from_id,
message);
+
+ // Link the text fallback to its inline offer before the floater sees it.
+ // Name lookup may complete after other messages have arrived.
+ if (notification_id.notNull())
+ {
+ session->mMsgs.front()["notification_log_id"] = notification_id;
+ }
+
// we shouldn't increment counters when logging, so restore them
session->mNumUnread = unread;
session->mParticipantUnreadMessageCount = participant_unread;
@@ -121,15 +129,17 @@ void LLHandlerUtil::logToIM(const EInstantMessage& session_type,
}
void log_name_callback(const LLAvatarName& av_name, const std::string& from_name,
- const std::string& message, const LLUUID& from_id)
+ const std::string& message, const LLUUID& from_id,
+ const LLUUID& notification_id)
{
LLHandlerUtil::logToIM(IM_NOTHING_SPECIAL, av_name.getUserName(), from_name, message,
- from_id, LLUUID());
+ from_id, LLUUID(), notification_id);
}
// static
-void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only)
+void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only,
+ const LLUUID& notification_id)
{
if (!gCacheName)
{
@@ -145,19 +155,20 @@ void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message
if(to_file_only)
{
- LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, "", message, LLUUID()));
+ LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, "", message, LLUUID(), notification_id));
}
else
{
- LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, INTERACTIVE_SYSTEM_FROM, message, from_id));
+ LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, INTERACTIVE_SYSTEM_FROM, message, from_id, notification_id));
}
}
// static
-void LLHandlerUtil::logToIMP2P(const LLNotificationPtr& notification, bool to_file_only)
+void LLHandlerUtil::logToIMP2P(const LLNotificationPtr& notification, bool to_file_only,
+ const LLUUID& notification_id)
{
LLUUID from_id = notification->getPayload()["from_id"];
- logToIMP2P(from_id, notification->getMessage(), to_file_only);
+ logToIMP2P(from_id, notification->getMessage(), to_file_only, notification_id);
}
// static
diff --git a/indra/newview/llnotificationofferhandler.cpp b/indra/newview/llnotificationofferhandler.cpp
index dfca370b46..aa875ab247 100644
--- a/indra/newview/llnotificationofferhandler.cpp
+++ b/indra/newview/llnotificationofferhandler.cpp
@@ -150,6 +150,8 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification,
{
// log only to file if notif panel can be embedded to IM and IM is opened
bool file_only = add_notif_to_im && LLHandlerUtil::isIMFloaterOpened(notification);
+ // Only a log paired with an inline offer may be hidden during replay.
+ const LLUUID notification_id = add_notif_to_im ? notification->getID() : LLUUID::null;
if ((notification->getName() == "TeleportOffered"
|| notification->getName() == "TeleportOffered_MaturityExceeded"
|| notification->getName() == "TeleportOffered_MaturityBlocked"))
@@ -157,11 +159,11 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification,
boost::regex r("<icon\\s*>\\s*([^<]*)?\\s*</icon\\s*>( - )?",
boost::regex::perl|boost::regex::icase);
std::string stripped_msg = boost::regex_replace(notification->getMessage(), r, "");
- LLHandlerUtil::logToIMP2P(notification->getPayload()["from_id"], stripped_msg,file_only);
+ LLHandlerUtil::logToIMP2P(notification->getPayload()["from_id"], stripped_msg, file_only, notification_id);
}
else
{
- LLHandlerUtil::logToIMP2P(notification, file_only);
+ LLHandlerUtil::logToIMP2P(notification, file_only, notification_id);
}
}
}
diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp
index 52bc16edba..d3d3548728 100644
--- a/indra/newview/tests/llchatservicehistorycore_test.cpp
+++ b/indra/newview/tests/llchatservicehistorycore_test.cpp
@@ -13,6 +13,8 @@
#include "../test/namedtempfile.h"
#include "../llchatservicehistorycore.h"
#include "../llmutelist.h"
+#include <array>
+#include "llsdutil.h"
struct LLMuteListTestAccess
{
@@ -142,7 +144,7 @@ template<> template<> void object_t::test<5>()
template<> template<> void object_t::test<6>()
{
- // Discovery rejects duplicate or null direct peers after accepting one canonical row.
+ // Discovery rejects duplicate peers and ignores the null system participant.
LLSD list = LLSD::emptyArray();
LLSD entry;
entry["conversation_type"] = "direct";
@@ -161,7 +163,8 @@ template<> template<> void object_t::test<6>()
entry["other_participant_id"] = LLUUID::null.asString();
entry["conversation_id"] = directConversationId(AGENT, LLUUID::null);
null_list.append(entry);
- ensure("null resident rejected", !validateConversationList(null_list, AGENT, entries));
+ ensure("null system participant ignored", validateConversationList(null_list, AGENT, entries));
+ ensure("system conversation has no resident history", entries.empty());
}
template<> template<> void object_t::test<7>()
@@ -309,4 +312,806 @@ template<> template<> void object_t::test<14>()
ensure("ambiguous DST row retained",
legacyWallMayPrecedeService(wall, daylight_utc + 30.0 * 60.0));
}
+
+template<> template<> void object_t::test<15>()
+{
+ // Complete transcript names carry the resident identity inside parentheses.
+ ensure("complete name matches legacy resident name",
+ sameDirectSenderName("Bridie (bridie.linden)", "Bridie Linden"));
+ ensure("complete name matches dotted resident name",
+ sameDirectSenderName("Bridie (bridie.linden)", "bridie.linden"));
+ ensure("single-name resident matches Resident suffix",
+ sameDirectSenderName("beanie lin alt (danyanka)", "danyanka Resident"));
+ ensure("resident names compare without case",
+ sameDirectSenderName("BRIDIE.LINDEN", "Bridie Linden"));
+
+ // Display-name changes and collisions must not change the compared identity.
+ ensure("different display names for one resident match",
+ sameDirectSenderName("Old Name (bridie.linden)", "New Name (bridie.linden)"));
+ ensure("same display name for different residents stays distinct",
+ !sameDirectSenderName("Bridie (bridie.linden)", "Bridie (danyanka)"));
+ ensure("display name alone does not identify the resident",
+ !sameDirectSenderName("Bridie", "Bridie (bridie.linden)"));
+ ensure("missing names do not identify a resident", !sameDirectSenderName("", ""));
+}
+
+template<> template<> void object_t::test<16>()
+{
+ LLSD history;
+ history["from_id"] = RESIDENT;
+ history["message"] = "hello 1";
+ history["timestamp"] = 1789005928;
+ history["chat_service_msg_id"] = FIRST;
+
+ // Offline delivery carries its original body independently of the localized notice.
+ LLSD delivered = history;
+ delivered.erase("chat_service_msg_id");
+ delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1";
+ delivered["chat_service_original_text"] = "hello 1";
+ ensure("offline service copy matches delivered original", sameDirectHistoryOccurrence(history, delivered));
+ delivered["message"] = "(Enregistre le mercredi)hello 1";
+ ensure("offline notice localization does not affect identity", sameDirectHistoryOccurrence(history, delivered));
+ delivered["message"] = "hello 1 (bonjour 1)";
+ ensure("translation does not affect identity", sameDirectHistoryOccurrence(history, delivered));
+
+ // Never infer original text by stripping something the sender may have typed.
+ delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1";
+ delivered["chat_service_original_text"] = delivered["message"];
+ ensure("literal saved prefix remains message content", !sameDirectHistoryOccurrence(history, delivered));
+ delivered.erase("chat_service_original_text");
+ ensure("unidentified decorated text is not guessed", !sameDirectHistoryOccurrence(history, delivered));
+ delivered["message"] = "hello 1";
+ ensure("plain live delivery still matches", sameDirectHistoryOccurrence(history, delivered));
+ delivered["from_id"] = AGENT;
+ ensure("another sender stays distinct", !sameDirectHistoryOccurrence(history, delivered));
+ delivered["from_id"] = RESIDENT;
+ delivered["timestamp"] = 1789005988;
+ ensure("another send minute stays distinct", !sameDirectHistoryOccurrence(history, delivered));
+}
+
+template<> template<> void object_t::test<17>()
+{
+ LLSD delivered;
+ delivered["from_id"] = RESIDENT;
+ delivered["from"] = "peppertest Resident";
+ delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1";
+ delivered["chat_service_original_text"] = "hello 1";
+ delivered["timestamp"] = 1789005928;
+ delivered["chat_service_log_timestamp"] = 1789092365;
+
+ // An offline message delivered the next day has a plaintext receipt minute
+ // that differs from its service send minute; both copies identify the same delivery.
+ LLSD legacy;
+ legacy["from"] = "peppertest";
+ legacy["message"] = delivered["message"];
+ const F64 logged_minute = (1789092365 / 60) * 60;
+ legacy["chat_service_legacy_wall_time"] = logged_minute - 7.0 * 3600.0;
+ ensure("plaintext matches the delivery minute", sameDirectHistoryOccurrence(legacy, delivered));
+ legacy["chat_service_legacy_wall_time"] = logged_minute - 8.0 * 3600.0;
+ ensure("plaintext accepts the standard-time SLT offset", sameDirectHistoryOccurrence(legacy, delivered));
+ legacy["message"] = "hello 1";
+ ensure("plaintext matches its logged body exactly", !sameDirectHistoryOccurrence(legacy, delivered));
+ legacy["message"] = delivered["message"];
+ legacy.erase("chat_service_legacy_wall_time");
+ ensure("undated plaintext is not guessed", !sameDirectHistoryOccurrence(legacy, delivered));
+}
+
+template<> template<> void object_t::test<18>()
+{
+ auto row = [](const std::string& text, S32 timestamp)
+ {
+ return LLSD().with("message", text).with("timestamp", timestamp);
+ };
+ const std::list<LLSD> live{
+ row("hello 3", 1789005932), row("hello 2", 1789005930), row("hello 1", 1789005928)
+ };
+ const std::list<LLSD> history{row("hello", 1789005955), row("older", 1789005800)};
+ auto merged = interleaveDirectHistory(history, live);
+ auto it = merged.begin();
+ ensure_equals("fourth message follows the offline deliveries", (*it++)["message"].asString(), "hello");
+ ensure_equals("third delivery keeps its place", (*it++)["message"].asString(), "hello 3");
+ ensure_equals("second delivery keeps its place", (*it++)["message"].asString(), "hello 2");
+ ensure_equals("first delivery keeps its place", (*it++)["message"].asString(), "hello 1");
+ ensure_equals("older history precedes the live conversation", (*it++)["message"].asString(), "older");
+ ensure("all occurrences retained", it == merged.end());
+
+ // Timestamp ties keep the existing live row later; undated history stays at the start.
+ merged = interleaveDirectHistory({row("tie", 1789005930), row("undated", 0)}, live);
+ it = merged.begin();
+ ensure_equals("newest live stays newest", (*it++)["message"].asString(), "hello 3");
+ ensure_equals("live wins timestamp tie", (*it++)["message"].asString(), "hello 2");
+ ensure_equals("equal-time history retains its occurrence", (*it++)["message"].asString(), "tie");
+ ensure_equals("oldest live stays in delivery order", (*it++)["message"].asString(), "hello 1");
+ ensure_equals("undated history remains before live rows", (*it++)["message"].asString(), "undated");
+ ensure("interleaving leaves source lists unchanged", live.size() == 3 && history.size() == 2);
+}
+
+template<> template<> void object_t::test<19>()
+{
+ // A retained service row at :05 is a different occurrence from a new send at
+ // :35, even when its sender, text, and minute are identical.
+ LLSD history;
+ history["from_id"] = RESIDENT;
+ history["message"] = "ok";
+ history["timestamp"] = 1789005905;
+ history["chat_service_msg_id"] = FIRST;
+
+ LLSD delivered = history;
+ delivered.erase("chat_service_msg_id");
+ delivered["timestamp"] = 1789005935;
+ ensure("a later repeat in the same minute stays distinct",
+ !sameDirectHistoryOccurrence(history, delivered));
+ delivered["timestamp"] = 1789005904;
+ ensure("a preceding send in the same minute stays distinct",
+ !sameDirectHistoryOccurrence(history, delivered));
+ delivered["timestamp"] = 1789005905;
+ ensure("the matching service second reconciles",
+ sameDirectHistoryOccurrence(history, delivered));
+
+ // Missing precision provides no evidence for consuming a service occurrence.
+ delivered["timestamp"] = 0;
+ ensure("an untimed delivery stays distinct", !sameDirectHistoryOccurrence(history, delivered));
+}
+
+template<> template<> void object_t::test<20>()
+{
+ LLSD history;
+ history["from_id"] = AGENT;
+ history["message"] = "hello";
+ history["timestamp"] = 1789005905;
+ history["chat_service_msg_id"] = FIRST;
+
+ // Outgoing echoes use a local clock; server persistence may occur in another second.
+ LLSD echo = history;
+ echo.erase("chat_service_msg_id");
+ echo["chat_service_time_source"] = "local";
+ echo["timestamp"] = 1789005904;
+ ensure("local send before persistence reconciles", sameDirectHistoryOccurrence(history, echo));
+ echo["timestamp"] = 1789005906;
+ ensure("local clock ahead of the server reconciles", sameDirectHistoryOccurrence(history, echo));
+
+ // Timestamp tolerance does not relax sender/body identity or extend past the minute.
+ echo["message"] = "hello (bonjour)";
+ echo["chat_service_original_text"] = "hello";
+ ensure("translated local echo reconciles its original body", sameDirectHistoryOccurrence(history, echo));
+ echo["chat_service_original_text"] = "another message";
+ ensure("different local body stays distinct", !sameDirectHistoryOccurrence(history, echo));
+ echo["chat_service_original_text"] = "hello";
+ echo["from_id"] = RESIDENT;
+ ensure("another sender stays distinct for local time", !sameDirectHistoryOccurrence(history, echo));
+ echo["from_id"] = AGENT;
+ echo["timestamp"] = 1789005965;
+ ensure("local time in another minute stays distinct", !sameDirectHistoryOccurrence(history, echo));
+
+ // Supplied timestamps use exact seconds even for the current agent's messages.
+ echo["timestamp"] = 1789005906;
+ echo["chat_service_time_source"] = "sent";
+ ensure("supplied outgoing timestamp requires exact seconds", !sameDirectHistoryOccurrence(history, echo));
+ echo["timestamp"] = 1789005905;
+ ensure("supplied outgoing timestamp matches its service second", sameDirectHistoryOccurrence(history, echo));
+ echo["chat_service_time_source"] = "local";
+ history["timestamp"] = 0;
+ ensure("local provenance does not make an untimed service row match", !sameDirectHistoryOccurrence(history, echo));
+}
+
+// Repeated incoming bodies share a plaintext minute but have distinct supplied seconds.
+LLSD repeatedDelivery(S32 second)
+{
+ LLSD message;
+ message["from_id"] = RESIDENT;
+ message["from"] = "Example Resident";
+ message["message"] = "ok";
+ message["timestamp"] = 1789005900 + second;
+ message["chat_service_log_timestamp"] = message["timestamp"];
+ return message;
+}
+
+LLSD serviceCopy(const LLSD& delivery, const std::string& id)
+{
+ LLSD message = delivery;
+ message["chat_service_msg_id"] = id;
+ return message;
+}
+
+LLSD plaintextCopy(const LLSD& delivery)
+{
+ LLSD message;
+ message["from"] = delivery["from"];
+ message["message"] = delivery["message"];
+ message["chat_service_legacy_wall_time"] =
+ F64(delivery["timestamp"].asInteger() / 60 * 60) - 7.0 * 3600.0;
+ return message;
+}
+
+template<> template<> void object_t::test<21>()
+{
+ const LLSD earlier = repeatedDelivery(5);
+ const LLSD later = repeatedDelivery(35);
+ const LLSD legacy = plaintextCopy(earlier);
+ const LLSD service = serviceCopy(later, FIRST);
+
+ // Partial coverage leaves plaintext before the only service row, which must
+ // reserve the :35 delivery before the plaintext minute can consume it.
+ for (bool history_reversed : {false, true})
+ {
+ for (bool live_reversed : {false, true})
+ {
+ std::list<LLSD> history{legacy, service};
+ std::list<LLSD> live{later, earlier};
+ if (history_reversed) history.reverse();
+ if (live_reversed) live.reverse();
+ ensure("partial service coverage reconciles both deliveries in either order",
+ filterDirectHistoryDuplicates(history, live).empty());
+ }
+ }
+}
+
+template<> template<> void object_t::test<22>()
+{
+ const LLSD earlier = repeatedDelivery(5);
+ const LLSD later = repeatedDelivery(35);
+ const LLSD legacy = plaintextCopy(earlier);
+ LLSD keep_before = legacy;
+ keep_before["message"] = "before";
+ LLSD keep_after = legacy;
+ keep_after["message"] = "after";
+ const std::list<LLSD> history{legacy, keep_before, serviceCopy(later, FIRST), legacy, keep_after};
+ const std::list<LLSD> live{later, earlier};
+
+ // One exact reservation leaves only one occurrence for plaintext. Extra
+ // identical history and unrelated rows remain in their original positions.
+ const auto filtered = filterDirectHistoryDuplicates(history, live);
+ ensure_equals("each live occurrence is consumed once", filtered.size(), size_t(3));
+ auto row = filtered.begin();
+ ensure_equals("first unmatched row keeps its position", (*row++)["message"].asString(), "before");
+ ensure("extra repeated occurrence retains its plaintext source", !row->has("chat_service_msg_id"));
+ ensure_equals("extra repeated occurrence remains", (*row++)["message"].asString(), "ok");
+ ensure_equals("last unmatched row keeps its position", (*row++)["message"].asString(), "after");
+ ensure_equals("history input is unchanged", history.size(), size_t(5));
+ ensure_equals("live input is unchanged", live.size(), size_t(2));
+ ensure_equals("no live rows leaves history intact", filterDirectHistoryDuplicates(history, {}).size(), history.size());
+ ensure("no history leaves an empty result", filterDirectHistoryDuplicates({}, live).empty());
+}
+
+template<> template<> void object_t::test<23>()
+{
+ const LLSD supplied = repeatedDelivery(5);
+ LLSD local = repeatedDelivery(34);
+ local["chat_service_time_source"] = "local";
+ const LLSD service_earlier = serviceCopy(supplied, FIRST);
+ const LLSD service_later = serviceCopy(repeatedDelivery(35), SECOND);
+
+ // A local-minute fallback must not take an exact supplied match's place,
+ // regardless of the order of the service rows or live deliveries.
+ for (bool history_reversed : {false, true})
+ {
+ for (bool live_reversed : {false, true})
+ {
+ std::list<LLSD> history{service_earlier, service_later};
+ std::list<LLSD> live{local, supplied};
+ if (history_reversed) history.reverse();
+ if (live_reversed) live.reverse();
+ ensure("exact service matches precede local-minute fallback",
+ filterDirectHistoryDuplicates(history, live).empty());
+ }
+ }
+}
+
+template<> template<> void object_t::test<24>()
+{
+ LLSD earlier = repeatedDelivery(5);
+ LLSD later = repeatedDelivery(35);
+ const LLSD service_earlier = serviceCopy(earlier, FIRST);
+ const LLSD service_later = serviceCopy(later, SECOND);
+ for (LLSD* delivery : {&earlier, &later})
+ {
+ (*delivery)["chat_service_original_text"] = "ok";
+ (*delivery)["message"] = "ok (d'accord)";
+ }
+ const LLSD legacy = plaintextCopy(earlier);
+ std::list<LLSD> history{legacy, service_later, legacy, service_earlier};
+ const std::list<LLSD> live{later, earlier};
+
+ // Translated or saved delivery can cover both its decorated plaintext and
+ // raw service representation, with separate one-for-one consumption.
+ ensure("decorated plaintext and raw service copies both reconcile",
+ filterDirectHistoryDuplicates(history, live).empty());
+ history.push_front(legacy);
+ history.push_back(service_earlier);
+ const auto filtered = filterDirectHistoryDuplicates(history, live);
+ ensure_equals("extra occurrences in each representation remain", filtered.size(), size_t(2));
+ ensure("extra decorated plaintext retains its source", !filtered.front().has("chat_service_msg_id"));
+ ensure_equals("extra service retains its source", filtered.back()["chat_service_msg_id"].asString(), std::string(FIRST));
+}
+
+template<> template<> void object_t::test<25>()
+{
+ // Service creation can precede online delivery across a second boundary.
+ LLSD history = serviceCopy(repeatedDelivery(0), FIRST);
+ history["message"] = "123";
+ history["datetime"] = "2026-09-10T17:17:00.904000Z";
+ history["timestamp"] = S32(LLDate(history["datetime"].asString()).secondsSinceEpoch());
+ LLSD delivered = history;
+ delivered.erase("chat_service_msg_id");
+ delivered["timestamp"] = 1789060621;
+ delivered["chat_service_time_source"] = "receipt";
+ ensure("online delivery reconciles the preceding service second",
+ filterDirectHistoryDuplicates({history}, {delivered}).empty());
+
+ // Only online delivery admits this delay; offline send times retain their precision.
+ delivered["chat_service_time_source"] = "sent";
+ ensure("offline send time stays exact", !sameDirectHistoryOccurrence(history, delivered));
+ delivered["timestamp"] = 1789060619;
+ ensure("offline send time rejects later service recording", !sameDirectHistoryOccurrence(history, delivered));
+ delivered["chat_service_time_source"] = "receipt";
+ ensure("online receipt can precede service recording", sameDirectHistoryOccurrence(history, delivered));
+ delivered["timestamp"] = 1789060681;
+ ensure("delivery outside the rolling window stays distinct", !sameDirectHistoryOccurrence(history, delivered));
+
+ // Elapsed delay crosses minute boundaries, with exact sender and body checks.
+ history["timestamp"] = 1789060619;
+ delivered["timestamp"] = 1789060620;
+ ensure("delivery delay crosses a minute boundary", sameDirectHistoryOccurrence(history, delivered));
+ delivered["from_id"] = AGENT;
+ ensure("delivery tolerance retains sender identity", !sameDirectHistoryOccurrence(history, delivered));
+ delivered["from_id"] = RESIDENT;
+ delivered["message"] = "1234";
+ ensure("delivery tolerance retains body identity", !sameDirectHistoryOccurrence(history, delivered));
+}
+
+template<> template<> void object_t::test<26>()
+{
+ const LLSD earlier = serviceCopy(repeatedDelivery(0), FIRST);
+ const LLSD later = serviceCopy(repeatedDelivery(1), SECOND);
+ LLSD delivered = repeatedDelivery(1);
+ delivered["chat_service_time_source"] = "receipt";
+
+ // Prefer the most recent eligible occurrence when a receipt can match repeated
+ // sends, preserving the earlier occurrence in history.
+ for (bool reversed : {false, true})
+ {
+ std::list<LLSD> history{earlier, later};
+ if (reversed) history.reverse();
+ const auto filtered = filterDirectHistoryDuplicates(history, {delivered});
+ ensure_equals("one live delivery consumes one service occurrence", filtered.size(), size_t(1));
+ ensure_equals("the earlier repeated send survives", filtered.front()["chat_service_msg_id"].asString(), std::string(FIRST));
+ }
+}
+
+template<> template<> void object_t::test<27>()
+{
+ const LLSD earlier = serviceCopy(repeatedDelivery(0), FIRST);
+ const LLSD later = serviceCopy(repeatedDelivery(1), SECOND);
+ LLSD first_delivery = repeatedDelivery(1);
+ first_delivery["chat_service_time_source"] = "receipt";
+ LLSD second_delivery = first_delivery;
+
+ // Two deliveries in one second remain separate occurrences even when one
+ // service creation crossed the boundary.
+ for (bool reversed : {false, true})
+ {
+ std::list<LLSD> history{earlier, later};
+ if (reversed) history.reverse();
+ ensure("both adjacent service occurrences reconcile one-for-one",
+ filterDirectHistoryDuplicates(history, {first_delivery, second_delivery}).empty());
+ }
+
+ // Delivery matches reserve their occurrence before local clocks and plaintext minutes.
+ LLSD local = repeatedDelivery(0);
+ local["chat_service_time_source"] = "local";
+ const LLSD much_later = serviceCopy(repeatedDelivery(35), SECOND);
+ ensure("delivery matching precedes local-minute matching",
+ filterDirectHistoryDuplicates({earlier, much_later}, {local, first_delivery}).empty());
+ const LLSD legacy = plaintextCopy(first_delivery);
+ ensure("delivery matching precedes plaintext matching",
+ filterDirectHistoryDuplicates({legacy, earlier}, {first_delivery, repeatedDelivery(35)}).empty());
+}
+
+template<> template<> void object_t::test<28>()
+{
+ // Service lead and delivery delay use elapsed seconds across minute, hour,
+ // and day boundaries instead of comparing calendar minutes.
+ for (const char* created : {"2026-09-10T12:34:01Z", "2026-09-10T12:59:59Z",
+ "2026-09-10T13:00:01Z", "2026-09-10T23:59:59Z",
+ "2026-09-11T00:00:01Z"})
+ {
+ LLSD history = serviceCopy(repeatedDelivery(0), FIRST);
+ const S32 timestamp = S32(LLDate(created).secondsSinceEpoch());
+ history["timestamp"] = timestamp;
+ LLSD delivered = repeatedDelivery(0);
+ delivered["chat_service_time_source"] = "receipt";
+ for (S32 delay : {-5, -4, -2, -1, 0, 2, 5, 30, 60})
+ {
+ delivered["timestamp"] = timestamp + delay;
+ ensure("receipt inside the rolling window reconciles",
+ filterDirectHistoryDuplicates({history}, {delivered}).empty());
+ }
+ for (S32 delay : {-6, 61})
+ {
+ delivered["timestamp"] = timestamp + delay;
+ ensure_equals("receipt outside the rolling window remains",
+ filterDirectHistoryDuplicates({history}, {delivered}).size(), size_t(1));
+ }
+ }
+}
+
+template<> template<> void object_t::test<29>()
+{
+ // Both sides can contain repeated bodies with different delays or service
+ // lead. Newest-first pairing keeps a newer receipt from consuming an older
+ // receipt's only candidate, even when their timestamps match exactly.
+ for (const auto times : {std::array<S32, 4>{0, 15, 5, 30}, {0, 15, 30, 75},
+ {0, 10, 10, 65}, {65, 70, 60, 65}, {0, 6, -4, 2}})
+ {
+ const LLSD first = serviceCopy(repeatedDelivery(times[0]), FIRST);
+ const LLSD second = serviceCopy(repeatedDelivery(times[1]), SECOND);
+ LLSD first_delivery = repeatedDelivery(times[2]);
+ LLSD second_delivery = repeatedDelivery(times[3]);
+ first_delivery["chat_service_time_source"] = "receipt";
+ second_delivery["chat_service_time_source"] = "receipt";
+ for (bool history_reversed : {false, true})
+ {
+ for (bool live_reversed : {false, true})
+ {
+ std::list<LLSD> history{first, second};
+ std::list<LLSD> live{first_delivery, second_delivery};
+ if (history_reversed) history.reverse();
+ if (live_reversed) live.reverse();
+ ensure("delayed repeats reconcile independently of source order",
+ filterDirectHistoryDuplicates(history, live).empty());
+ }
+ }
+ }
+}
+
+// Stable service IDs let the view retain context without guessing plaintext identity.
+LLSD contextRow(U8 ordinal)
+{
+ LLUUID id(FIRST);
+ id.mData[3] = ordinal;
+ LLSD row = serviceCopy(repeatedDelivery(ordinal * 10), id.asString().c_str());
+ row["message"] = std::to_string(ordinal);
+ return row;
+}
+
+LLSD liveCopy(LLSD row)
+{
+ row.erase("chat_service_msg_id");
+ row["is_history"] = false;
+ return row;
+}
+
+template<> template<> void object_t::test<30>()
+{
+ const LLSD first = serviceCopy(repeatedDelivery(5), FIRST);
+ const LLSD second = serviceCopy(repeatedDelivery(35), SECOND);
+ const LLSD legacy = plaintextCopy(first);
+ History history;
+ history.setLoaded(mergeDirectHistory({legacy, legacy, legacy}, {first}));
+
+ // Reapplying a preview must not consume the next identical plaintext occurrence.
+ const Messages expected{legacy, first, second};
+ for (int publication = 0; publication < 3; ++publication)
+ {
+ const auto result = history.compose({second, first}, {}, 10);
+ ensure_equals("preview keeps all three occurrences", result.size(), expected.size());
+ ensure("preview is idempotent and chronological",
+ std::equal(result.begin(), result.end(), expected.begin(),
+ [](const LLSD& a, const LLSD& b) { return llsd_equals(a, b); }));
+ }
+
+ // Canonical ID identity wins over a stale preview, and limits follow reconciliation.
+ LLSD stale = first;
+ stale["message"] = "stale preview";
+ const auto limited = history.compose({second, stale, second}, {}, 2);
+ ensure_equals("duplicate IDs do not evict other history", limited.size(), size_t(2));
+ ensure_equals("canonical value wins", limited.front()["message"].asString(), std::string("ok"));
+ ensure_equals("service tail stays in TimeUUID order", limited.back()["chat_service_msg_id"].asString(), std::string(SECOND));
+}
+
+template<> template<> void object_t::test<31>()
+{
+ const Messages initial{contextRow(1), contextRow(2), contextRow(3)};
+ const Messages bounded{contextRow(2), contextRow(3), contextRow(4)};
+ const LLSD delivered = liveCopy(contextRow(4));
+ History history;
+ history.setLoaded(initial);
+ history.compose({}, {}, 3, true);
+ history.setLoaded(bounded);
+ auto result = history.compose({}, {delivered}, 3, true);
+ ensure_equals("live copy does not evict older visible context", result.size(), size_t(3));
+ ensure_equals("oldest context survives rollover", result.front()["message"].asString(), std::string("1"));
+
+ // A late delivery may replace any retained row, including the newest one.
+ for (U8 ordinal : {2, 3})
+ {
+ history.clear();
+ history.setLoaded(initial);
+ history.compose({}, {}, 3, true);
+ history.setLoaded(bounded);
+ history.compose({}, {delivered}, 3, true);
+ for (int publication = 0; publication < 3; ++publication)
+ {
+ result = history.compose({}, {liveCopy(contextRow(ordinal)), delivered}, 3, true);
+ ensure_equals("late live delivery replaces one retained occurrence", result.size(), size_t(2));
+ ensure_equals("unrelated context remains", result.front()["message"].asString(), std::string("1"));
+ }
+ }
+
+ history.clear();
+ history.setLoaded(initial);
+ history.compose({}, {}, 3, true);
+ history.setLoaded({contextRow(4), contextRow(5), contextRow(6)});
+ const Messages live{liveCopy(contextRow(6)), liveCopy(contextRow(5)), delivered};
+ ensure_equals("an all-live source retains open history", history.compose({}, live, 3, true).size(), size_t(3));
+ ensure_equals("repeated all-live publication is stable", history.compose({}, live, 3, true).size(), size_t(3));
+ ensure("hidden view releases old context", history.compose({}, live, 3).empty());
+ history.setLoaded(initial);
+ history.compose({}, {}, 3, true);
+ history.setLoaded({});
+ ensure("empty load clears retained context", history.compose({}, {}, 3, true).empty());
+
+ // Revoking service consent leaves plaintext, while deletion clears both sources.
+ const LLSD legacy = plaintextCopy(repeatedDelivery(5));
+ history.setLoaded({legacy, contextRow(1)});
+ history.compose({}, {}, 3, true);
+ history.clearService();
+ ensure_equals("service revocation keeps legacy", history.compose({}, {}, 3, true).size(), size_t(1));
+ history.clear();
+ ensure("clear removes every historical source", history.compose({}, {}, 3, true).empty());
+}
+
+template<> template<> void object_t::test<32>()
+{
+ LLSD offer;
+ offer["notification_id"] = AGENT;
+ offer["timestamp"] = 0;
+ offer["is_history"] = false;
+ LLSD first = liveCopy(contextRow(4));
+ LLSD second = first;
+ first["message"] = "test";
+ second["message"] = "test";
+ second["timestamp"] = first["timestamp"].asInteger() + 1;
+ Messages current{second, first, offer};
+ ensure("initial history changes the model", replaceHistory(current, {contextRow(1)}, true));
+ ensure_equals("two live tests and the offer survive", current.size(), size_t(4));
+ auto row = current.begin();
+ ensure_equals("latest test stays live", (*row++)["message"].asString(), std::string("test"));
+ ensure_equals("first test stays live", (*row++)["message"].asString(), std::string("test"));
+ ensure_equals("inline offer identity survives", (*row)["notification_id"].asUUID(), AGENT);
+ ensure("identical publication avoids replay", !replaceHistory(current, {contextRow(1)}, true));
+ ensure("clearing history changes the model", replaceHistory(current, {}, true));
+ ensure_equals("clearing history retains both live sends and offer", current.size(), size_t(3));
+ S32 index = 3;
+ for (const LLSD& message : current)
+ {
+ ensure_equals("indexes remain contiguous", message["index"].asInteger(), --index);
+ }
+}
+
+template<> template<> void object_t::test<33>()
+{
+ constexpr U32 received = 1789060621;
+ for (bool supplied : {false, true})
+ {
+ U32 timestamp = supplied ? received : 0;
+ LLSD captured = captureLiveMessage("123", timestamp, incomingContext("123", true), received);
+ ensure_equals("supplied and synthesized receipt times agree", timestamp, received);
+ captured = captureLiveMessage("123 (translated)", timestamp, captured, received + 90);
+ LLSD row = repeatedDelivery(0);
+ row["timestamp"] = static_cast<S32>(timestamp);
+ row["message"] = "123 (translated)";
+ recordLiveMessage(row, captured, received + 90);
+ ensure_equals("translation preserves original body", row["chat_service_original_text"].asString(), std::string("123"));
+ ensure_equals("translation preserves receipt provenance", row["chat_service_time_source"].asString(), std::string("receipt"));
+ ensure_equals("logging time follows translation completion", row["chat_service_log_timestamp"].asInteger(), S32(received + 90));
+ LLSD service = serviceCopy(repeatedDelivery(0), FIRST);
+ service["message"] = "123";
+ service["timestamp"] = S32(received - 1);
+ ensure("translation delay does not affect service matching", sameDirectHistoryOccurrence(service, row));
+ service["timestamp"] = S32(received + 1);
+ ensure("later service recording matches the captured receipt", sameDirectHistoryOccurrence(service, row));
+ }
+
+ U32 timestamp = received - 86400;
+ LLSD offline = captureLiveMessage("(Saved yesterday)123", timestamp, incomingContext("123", false), received);
+ ensure_equals("offline delivery keeps the send time", timestamp, received - 86400);
+ ensure_equals("offline time is authoritative", offline["chat_service_time_source"].asString(), std::string("sent"));
+ timestamp = 0;
+ offline = captureLiveMessage("(Saved)123", timestamp, incomingContext("123", false), received);
+ ensure_equals("missing send time cannot become authoritative", offline["chat_service_time_source"].asString(), std::string("local"));
+ timestamp = 0;
+ LLSD echo = captureLiveMessage("123", timestamp, LLSD(), received);
+ ensure_equals("outgoing echo captures its local clock", echo["chat_service_time_source"].asString(), std::string("local"));
+ ensure_equals("outgoing echo captures before translation", timestamp, received);
+}
+
+template<> template<> void object_t::test<34>()
+{
+ const LLSD old = contextRow(1);
+ const LLSD legacy = plaintextCopy(old);
+ const LLSD live = liveCopy(contextRow(4));
+ History history;
+ history.setLoaded({legacy, contextRow(2), contextRow(3)});
+ history.compose({}, {}, 3, true);
+ history.setLoaded({contextRow(2), contextRow(3), contextRow(4)});
+ for (int publication = 0; publication < 3; ++publication)
+ {
+ const auto result = history.compose({}, {live}, 3, true);
+ ensure_equals("capped service rollover retains older plaintext", result.size(), size_t(3));
+ ensure("retained plaintext keeps its source", !result.front().has("chat_service_msg_id"));
+ }
+ history.setLoaded({old, contextRow(2), contextRow(3)});
+ auto result = history.compose({}, {live}, 3, true);
+ ensure_equals("new archive row replaces retained plaintext", result.size(), size_t(3));
+ ensure_equals("replaced plaintext acquires the canonical ID", result.front()["chat_service_msg_id"].asString(), old["chat_service_msg_id"].asString());
+
+ // Each window may already have consumed a different member of repeated plaintext.
+ const LLSD first = serviceCopy(repeatedDelivery(5), FIRST);
+ const LLSD second = serviceCopy(repeatedDelivery(35), SECOND);
+ const LLSD repeat = plaintextCopy(first);
+ const Messages left = mergeDirectHistory({repeat, repeat, repeat}, {first});
+ const Messages right = mergeDirectHistory({repeat, repeat, repeat}, {second});
+ result = mergeDirectHistory(left, right);
+ ensure_equals("overlapping windows preserve three original occurrences", result.size(), size_t(3));
+ ensure_equals("repeated seam publication is stable", mergeDirectHistory(result, right).size(), size_t(3));
+ ensure_equals("source window order preserves counts", mergeDirectHistory(right, left).size(), size_t(3));
+}
+
+template<> template<> void object_t::test<35>()
+{
+ // Recreating a session leaves only the latest delivery live. An offline repeat
+ // within its receipt window must retain its earlier place in the transcript.
+ LLSD earlier = contextRow(1);
+ earlier["message"] = "hello";
+ earlier["timestamp"] = 1789092403;
+ LLSD between = contextRow(2);
+ between["message"] = "6";
+ between["timestamp"] = 1789092414;
+ LLSD later = contextRow(3);
+ later["message"] = "hello";
+ later["timestamp"] = 1789092446;
+ const Messages archive{earlier, between, later};
+
+ for (S32 delay : {-5, -1, 0, 2, 15})
+ {
+ History history;
+ history.setLoaded({earlier, between});
+ LLSD delivery = liveCopy(later);
+ delivery["chat_service_time_source"] = "receipt";
+ delivery["timestamp"] = later["timestamp"].asInteger() + delay;
+ Messages current{delivery};
+
+ // The archive read can complete before the latest service head arrives.
+ replaceHistory(current, history.compose({}, {delivery}, 10, true), true);
+ for (int publication = 0; publication < 3; ++publication)
+ {
+ const auto composed = history.compose(archive, {delivery}, 10, true);
+ ensure_equals("one delivery replaces only one historical occurrence", composed.size(), size_t(2));
+ ensure_equals("the earlier hello retains its identity and position",
+ composed.front()["chat_service_msg_id"].asString(), earlier["chat_service_msg_id"].asString());
+ const bool changed = replaceHistory(current, composed, true);
+ ensure_equals("unchanged publications do not replay the transcript", changed, publication == 0);
+ ensure_equals("both hellos and the intervening message remain", current.size(), size_t(3));
+ }
+
+ // Reopening with all rows historical preserves the same message order.
+ Messages reopened;
+ replaceHistory(reopened, archive, true);
+ auto expected = reopened.begin();
+ for (const LLSD& row : current)
+ {
+ ensure_equals("reopening preserves message order", row["message"].asString(), (*expected++)["message"].asString());
+ }
+ }
+}
+
+template<> template<> void object_t::test<36>()
+{
+ // System traffic may share a discovery response with ordinary resident history.
+ LLSD peer;
+ peer["conversation_type"] = "direct";
+ peer["conversation_id"] = directConversationId(AGENT, RESIDENT);
+ peer["other_participant_id"] = RESIDENT.asString();
+ peer["last_msg_id"] = SECOND;
+ LLSD system = peer;
+ system["conversation_id"] = directConversationId(AGENT, LLUUID::null);
+ system["other_participant_id"] = LLUUID::null.asString();
+
+ for (bool system_first : {true, false})
+ {
+ LLSD list = LLSD::emptyArray();
+ list.append(system_first ? system : peer);
+ list.append(system_first ? peer : system);
+ std::vector<ListEntry> entries;
+ ensure("system entry does not block resident discovery",
+ validateConversationList(list, AGENT, entries));
+ ensure_equals("only the resident is scheduled", entries.size(), size_t(1));
+ ensure_equals("resident identity preserved", entries.front().resident_id, RESIDENT);
+ ensure_equals("conversation identity preserved", entries.front().conversation_id,
+ peer["conversation_id"].asString());
+ ensure_equals("history token preserved", entries.front().last_msg_id, std::string(SECOND));
+ }
+}
+
+template<> template<> void object_t::test<37>()
+{
+ // Missing online wire timestamps are captured before the corresponding
+ // service rows are recorded. The two representations describe one reply.
+ Messages archive;
+ Messages live;
+ U8 ordinal = 0;
+ for (const char* body : {"30", "thnk you"})
+ {
+ const U32 received = ordinal ? 1789489070 : 1789489012;
+ LLSD service = contextRow(++ordinal);
+ service["message"] = body;
+ service["timestamp"] = static_cast<S32>(received + 1);
+ archive.push_back(service);
+
+ U32 timestamp = 0;
+ const LLSD context = captureLiveMessage(body, timestamp, incomingContext(body, true), received);
+ LLSD delivery = liveCopy(service);
+ delivery["timestamp"] = static_cast<S32>(timestamp);
+ recordLiveMessage(delivery, context, received);
+ live.push_front(delivery);
+ }
+
+ // Preview and durable reads can arrive in either order. Repeated publication
+ // must keep both live replies without adding historical copies or replaying them.
+ for (bool loaded_first : {false, true})
+ {
+ History history;
+ if (loaded_first) history.setLoaded(archive);
+ Messages current = live;
+ for (int publication = 0; publication < 3; ++publication)
+ {
+ const auto composed = history.compose(archive, live, 200, true);
+ ensure("recording after receipt does not add history copies", composed.empty());
+ ensure("unchanged live replies do not need replay", !replaceHistory(current, composed, true));
+ ensure_equals("both replies remain visible once", current.size(), size_t(2));
+ history.setLoaded(archive);
+ }
+
+ const auto durable = history.compose({}, live, 200, true);
+ ensure("durable read reconciles after preview expires", durable.empty());
+ ensure("durable publication does not replay replies", !replaceHistory(current, durable, true));
+ ensure_equals("newest reply retains delivery order", current.front()["message"].asString(), std::string("thnk you"));
+ ensure_equals("earliest reply retains delivery order", current.back()["message"].asString(), std::string("30"));
+ ensure("newest reply stays live", !current.front()["is_history"].asBoolean());
+ ensure("earliest reply stays live", !current.back()["is_history"].asBoolean());
+ }
+}
+
+template<> template<> void object_t::test<38>()
+{
+ // With only the first delivery live, a later identical service message is a
+ // separate occurrence. Preserve its ID and the intervening message's position.
+ LLSD earlier = contextRow(1);
+ earlier["message"] = "hello";
+ LLSD between = contextRow(2);
+ between["message"] = "6";
+ between["timestamp"] = earlier["timestamp"].asInteger() + 15;
+ LLSD later = contextRow(3);
+ later["message"] = "hello";
+ later["timestamp"] = earlier["timestamp"].asInteger() + 30;
+ LLSD delivery = liveCopy(earlier);
+ delivery["chat_service_time_source"] = "receipt";
+ History history;
+ history.setLoaded({earlier, between, later});
+
+ const auto composed = history.compose({}, {delivery}, 200, true);
+ ensure_equals("one receipt consumes one occurrence", composed.size(), size_t(2));
+ ensure_equals("later repeat keeps its service ID", composed.back()["chat_service_msg_id"].asString(),
+ later["chat_service_msg_id"].asString());
+ Messages current{delivery};
+ ensure("unseen service messages enter the transcript", replaceHistory(current, composed, true));
+ ensure_equals("three real occurrences remain", current.size(), size_t(3));
+ auto row = current.begin();
+ ensure_equals("later repeat follows intervening message", (*row++)["chat_service_msg_id"].asString(),
+ later["chat_service_msg_id"].asString());
+ ensure_equals("intervening message keeps its position", (*row++)["message"].asString(), std::string("6"));
+ ensure("earliest occurrence keeps its live delivery", !(*row)["is_history"].asBoolean());
+}
+
}