diff options
| author | Pepper <3782201+rohvani@users.noreply.github.com> | 2026-09-17 11:03:27 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-17 11:03:27 -0400 |
| commit | cc5d3dd3cd653971fff16cf2bc1d380edd5ea7f2 (patch) | |
| tree | a825cb021b74e45ceacb90213260b625f5234ebb | |
| parent | 56b34b332727299f2d54d4d6e8dbe5a7cb54cb11 (diff) | |
| parent | c9cb3694cce949e7ab4638200646e91814c1cc7b (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.cpp | 518 | ||||
| -rw-r--r-- | indra/newview/llchatservicehistory.h | 21 | ||||
| -rw-r--r-- | indra/newview/llchatservicehistorycore.cpp | 386 | ||||
| -rw-r--r-- | indra/newview/llchatservicehistorycore.h | 42 | ||||
| -rw-r--r-- | indra/newview/llfloaterconversationpreview.cpp | 28 | ||||
| -rw-r--r-- | indra/newview/llfloaterconversationpreview.h | 1 | ||||
| -rw-r--r-- | indra/newview/llfloaterimsession.cpp | 26 | ||||
| -rw-r--r-- | indra/newview/llimprocessing.cpp | 8 | ||||
| -rw-r--r-- | indra/newview/llimview.cpp | 223 | ||||
| -rw-r--r-- | indra/newview/llimview.h | 15 | ||||
| -rw-r--r-- | indra/newview/llnotificationhandler.h | 9 | ||||
| -rw-r--r-- | indra/newview/llnotificationhandlerutil.cpp | 27 | ||||
| -rw-r--r-- | indra/newview/llnotificationofferhandler.cpp | 6 | ||||
| -rw-r--r-- | indra/newview/tests/llchatservicehistorycore_test.cpp | 809 |
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()); +} + } |
