From 77c4811fa637426d791ecaa00d9e1d4c900e25df Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:00 -0400 Subject: Cover cached mute-list ChatService synchronization Keep authoritative mute-list source classification in one testable predicate so full, empty, and server-validated cache responses remain eligible while fallback cache stays gated. Log compact gate, list, history, and archive publication outcomes so device-specific synchronization failures can be diagnosed without recording message content. --- indra/newview/llchatservicehistory.cpp | 23 +++++++++++++++ indra/newview/llmutelist.h | 12 +++++++- .../tests/llchatservicehistorycore_test.cpp | 34 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index 39dc3d6271..d8f0a9f87d 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -1429,6 +1429,8 @@ void requestList(const CapabilityContext& context, U32 epoch) // passes strict validation. A pending outbound 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") + << "Conversation-list response: status=" << response.status << LL_ENDL; if (!ownsRuntime(epoch) || sampleContext() != context || sRuntime.delete_requested || !baseNetworkEligible(context)) { @@ -1610,6 +1612,9 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) } const HttpResult response = request(context.history_url, &post); + LL_INFOS("ChatServiceHistory") + << "History response: resident_id=" << id << ", status=" << response.status + << ", cursor=" << (cursor.empty() ? "head" : "continuation") << LL_ENDL; Page page; found = sRuntime.residents.find(id); if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) @@ -1853,6 +1858,10 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) { return publishRows(path, staged, append, resulting); }); + LL_INFOS("ChatServiceHistory") + << "Archive publication: resident_id=" << id << ", rows=" << staged.size() + << ", mode=" << (append ? "append" : "replace") + << ", success=" << changed << LL_ENDL; found = sRuntime.residents.find(id); if (!ownsRuntime(epoch) || sRuntime.account_dir != account_dir || @@ -2481,6 +2490,20 @@ void manager(U32 epoch) // Discovery precedes resident paging; the queue itself preserves open, // inbound, and due outbound priority without creating a second worker. const bool base_network = baseNetworkEligible(context); + LLMuteList* mute = LLMuteList::getInstance(); + // Once all external inputs are available, record why synchronization stayed gated. + if (!base_network && context.complete() && mute && mute->isLoaded()) + { + LL_INFOS_ONCE("ChatServiceHistory") + << "Network sync blocked: rollout=" << sRuntime.rollout + << ", consent=" << transcriptConsent() + << ", state_safe=" << (sRuntime.state_safety == STATE_SAFE) + << ", cleanup_pending=" << sRuntime.cleanup_pending + << ", delete_requested=" << sRuntime.delete_requested + << ", mute_source=" << mute->getLoadSourceName() + << ", mute_authoritative=" << mute->isLoadedFromServer() + << LL_ENDL; + } if (base_network) { if (F64(LLTimer::getTotalSeconds()) >= sRuntime.next_list) diff --git a/indra/newview/llmutelist.h b/indra/newview/llmutelist.h index aff23c72d1..8aeafa61ce 100644 --- a/indra/newview/llmutelist.h +++ b/indra/newview/llmutelist.h @@ -93,6 +93,14 @@ class LLMuteList : public LLSingleton MLS_SERVER_CACHE, MLS_FALLBACK_CACHE, }; + + // UseCachedMuteList is an authoritative server response even though its bytes + // come from the viewer's CRC-matched local cache. + static bool isServerAuthoritativeSource(EMuteListSource source) + { + return source == MLS_SERVER || source == MLS_SERVER_EMPTY || source == MLS_SERVER_CACHE; + } + public: // reasons for auto-unmuting a resident enum EAutoReason @@ -130,9 +138,10 @@ public: bool isLoaded() const { return mLoadState == ML_LOADED; } // Loaded, but not necessarily from server. bool isFailed() const { return mLoadState == ML_FAILED; } // Unable to load any mute list. Server did not reply. // Loaded from an authoritative server response, including when the server directs us to use our cached copy. - bool isLoadedFromServer() const { return isLoaded() && (mLoadSource == MLS_SERVER || mLoadSource == MLS_SERVER_EMPTY || mLoadSource == MLS_SERVER_CACHE); } + bool isLoadedFromServer() const { return isLoaded() && isServerAuthoritativeSource(mLoadSource); } // Loaded without an authoritative server response. Would be nice to upgrade to a server load from here if possible. bool isLoadedDegraded() const { return isLoaded() && !isLoadedFromServer(); } + const char* getLoadSourceName() const { return sourceToString(mLoadSource); } // Advance the load state machine, trying cache fallback if necessary. // Return value indicates mute list consumption readiness. @@ -212,6 +221,7 @@ private: boost::signals2::connection mRegionChangedCallback; friend class LLDispatchEmptyMuteList; + friend struct LLMuteListTestAccess; }; class LLMuteListObserver diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 3a160a95c2..12c5fedb21 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -8,9 +8,31 @@ * $/LicenseInfo$ */ #include "linden_common.h" +#include "llsingleton.h" #include "../test/lltut.h" #include "../test/namedtempfile.h" #include "../llchatservicehistorycore.h" +#include "../llmutelist.h" + +struct LLMuteListTestAccess +{ + static bool server() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_SERVER); + } + static bool serverEmpty() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_SERVER_EMPTY); + } + static bool serverCache() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_SERVER_CACHE); + } + static bool fallbackCache() + { + return LLMuteList::isServerAuthoritativeSource(LLMuteList::MLS_FALLBACK_CACHE); + } +}; namespace tut { @@ -245,4 +267,16 @@ template<> template<> void object_t::test<11>() file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); ensure_equals("corrupt distinguished", scan.state, ARCHIVE_CORRUPT); } + +template<> template<> void object_t::test<12>() +{ + // A full server response on the first login and a server-validated cache on + // the next login must satisfy the same ChatService network prerequisite. + ensure("full server response is authoritative", LLMuteListTestAccess::server()); + ensure("empty server response is authoritative", LLMuteListTestAccess::serverEmpty()); + ensure("warm-cache relog remains authoritative", LLMuteListTestAccess::serverCache()); + + // A timeout fallback has not been validated by the server and remains closed. + ensure("fallback cache remains degraded", !LLMuteListTestAccess::fallbackCache()); +} } -- cgit v1.3 From 467c3116760966c61edbff2e8079609f22635c14 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:50:26 -0400 Subject: Normalize IM sender names during history reconciliation Compare fallback sender identities as canonical resident usernames when either stitched source lacks a UUID. This lets legacy First Last ChatService names match viewer first.last names, preventing the first outbound live IM from retaining a duplicate history row. Cover legacy-to-resident matching and distinct-resident rejection in the LLChatServiceHistoryCore tests. Validated with the Release secondlife-bin build and the LLChatServiceHistoryCore test suite (13 tests). --- indra/newview/llchatservicehistory.cpp | 9 +++++---- indra/newview/llchatservicehistorycore.cpp | 14 ++++++++++++++ indra/newview/llchatservicehistorycore.h | 1 + indra/newview/tests/llchatservicehistorycore_test.cpp | 9 +++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index d8f0a9f87d..d0c82f79df 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -2678,13 +2678,14 @@ bool sameSenderAndText(const LLSD& left, const LLSD& right) return false; } - // Prefer exact resident identity when both sources carry it; otherwise require - // the transcript names to agree exactly. + // 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() - : left[LL_IM_FROM].asString() == right[LL_IM_FROM].asString(); + : sameDirectSenderName(left[LL_IM_FROM].asString(), + right[LL_IM_FROM].asString()); } bool sameSenderAndText(const LLSD& legacy, const Row& service) @@ -2696,7 +2697,7 @@ bool sameSenderAndText(const LLSD& legacy, const Row& service) return legacy[LL_IM_FROM_ID].isDefined() ? legacy[LL_IM_FROM_ID].asUUID() == service.from_id - : legacy[LL_IM_FROM].asString() == service.from_name; + : sameDirectSenderName(legacy[LL_IM_FROM].asString(), service.from_name); } bool sameLegacyMinute(F64 wall_epoch, F64 utc_epoch) diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index a8506c687a..3478bb2f11 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -11,6 +11,7 @@ #include "llchatservicehistorycore.h" +#include "llcachename.h" #include "lldate.h" #include "llfile.h" #include "llstring.h" @@ -283,6 +284,19 @@ bool persistedDirectDialog(S32 dialog) } } +bool sameDirectSenderName(const std::string& left, const std::string& right) +{ + if (left == right) + { + return true; + } + + // 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; +} + bool parseCreatedAt(const std::string& text, std::string& normalized) { // Validate the complete wire shape before LLDate performs calendar conversion. diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h index d9ac647a57..86c6b0131e 100644 --- a/indra/newview/llchatservicehistorycore.h +++ b/indra/newview/llchatservicehistorycore.h @@ -108,6 +108,7 @@ namespace LLChatServiceHistoryCore bool parseTimeUuid(const std::string& text, TimeUuidKey& key); bool persistedDirectDialog(S32 dialog); bool parseCreatedAt(const std::string& text, std::string& normalized); + bool sameDirectSenderName(const std::string& left, const std::string& right); // Both wire validators are all-or-nothing and clear their output before parsing. bool validateConversationList(const LLSD& value, const LLUUID& agent_id, diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 12c5fedb21..e5f542d3f7 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -279,4 +279,13 @@ template<> template<> void object_t::test<12>() // A timeout fallback has not been validated by the server and remains closed. ensure("fallback cache remains degraded", !LLMuteListTestAccess::fallbackCache()); } + +template<> template<> void object_t::test<13>() +{ + // Chat service legacy names and viewer resident names identify the same direct sender. + ensure("legacy and resident names match", + sameDirectSenderName("Bridie Linden", "bridie.linden")); + ensure("different residents stay distinct", + !sameDirectSenderName("bridie.linden", "beanie.tester")); +} } -- cgit v1.3 From 20d6cc9911d4392196b6c267af3d70eb73a1317f Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:20:58 -0400 Subject: Publish ChatService history in Conversation Log Create P2P conversation metadata when the first durable service archive is published, so history downloaded on a new device is discoverable without a live IM session. Preserve Conversation Log consent, deduplicate existing rows, and persist through the existing observer contract. Add focused coverage for cross-device discovery, duplicate publication, retained metadata, and disabled logging. Validated with the Release secondlife-bin build, the LLConversationLog test suite (2 tests), and the LLChatServiceHistoryCore test suite (13 tests). --- indra/newview/CMakeLists.txt | 1 + indra/newview/llchatservicehistory.cpp | 21 +++ indra/newview/llconversationlog.cpp | 27 ++++ indra/newview/llconversationlog.h | 11 ++ indra/newview/tests/llconversationlog_test.cpp | 171 +++++++++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 indra/newview/tests/llconversationlog_test.cpp diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 0fa3735536..79dab2dd09 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -2410,6 +2410,7 @@ if (LL_TESTS) SET(viewer_TEST_SOURCE_FILES llagentaccess.cpp llchatservicehistorycore.cpp + llconversationlog.cpp lldateutil.cpp # llmediadataclient.cpp lllogininstance.cpp diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index d0c82f79df..985b012a21 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -13,6 +13,8 @@ #include "llagent.h" #include "llavatarnamecache.h" +#include "llcachename.h" +#include "llconversationlog.h" #include "llcorehttputil.h" #include "llcoros.h" #include "lldate.h" @@ -1877,6 +1879,25 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) ++after_publish.archive_serial; sRuntime.index_dirty = true; sRuntime.local_content_exists = true; + + // The first durable service archive makes a cross-device conversation + // discoverable without creating a live IM session. Later appends leave + // explicit Conversation Log dismissals intact. + if (!append && after_publish.summary.has_rows && + after_publish.metadata.state == META_RESOLVED) + { + const F64 archived_seconds = after_publish.summary.newest.ticks >= UUID_EPOCH + ? static_cast(after_publish.summary.newest.ticks - UUID_EPOCH) / 10000000.0 + : static_cast(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))); + } + LLLogChat::notifyTranscriptCreated(); } else diff --git a/indra/newview/llconversationlog.cpp b/indra/newview/llconversationlog.cpp index e5adedd2ba..1fbf3297dd 100644 --- a/indra/newview/llconversationlog.cpp +++ b/indra/newview/llconversationlog.cpp @@ -388,6 +388,33 @@ void LLConversationLog::sessionAdded(const LLUUID& session_id, const std::string logConversation(session_id, has_offline_msg); } +void LLConversationLog::addServiceConversation(const LLUUID& session_id, + const std::string& conversation_name, + const std::string& history_filename, + const LLUUID& participant_id, + const U64Seconds& archived_time) +{ + // Service publication is idempotent and follows the Conversation Log privacy setting. + if (!mLoggingEnabled || getConversation(session_id)) + { + return; + } + + // A durable archive supplies the P2P metadata normally obtained from a live session. + ConversationParams params; + params.time(archived_time) + .conversation_type(LLIMModel::LLIMSession::P2P_SESSION) + .has_offline_ims(false) + .conversation_name(conversation_name) + .participant_id(participant_id) + .session_id(session_id) + .history_filename(history_filename); + + mConversations.emplace_back(params); + notifyObservers(); + cache(); +} + void LLConversationLog::cache() { if (gSavedPerAccountSettings.getS32("KeepConversationLogTranscripts") > 0) diff --git a/indra/newview/llconversationlog.h b/indra/newview/llconversationlog.h index 957d90d55e..1616bef41d 100644 --- a/indra/newview/llconversationlog.h +++ b/indra/newview/llconversationlog.h @@ -126,6 +126,14 @@ public: // LLIMSessionObserver triggers virtual void sessionAdded(const LLUUID& session_id, const std::string& name, const LLUUID& other_participant_id, bool has_offline_msg) override; + + // Publish metadata for durable service history that has no live IM session. + void addServiceConversation(const LLUUID& session_id, + const std::string& conversation_name, + const std::string& history_filename, + const LLUUID& participant_id, + const U64Seconds& archived_time); + virtual void sessionActivated(const LLUUID& session_id, const std::string& name, const LLUUID& other_participant_id) override {}; // Stub virtual void sessionRemoved(const LLUUID& session_id) override{} // Stub virtual void sessionVoiceOrIMStarted(const LLUUID& session_id) override{}; // Stub @@ -199,6 +207,9 @@ private: boost::signals2::connection mAvatarNameCacheConnection; bool mLoggingEnabled; + + // Test-only peer: model enabled/empty startup without filesystem or observer setup. + friend struct LLConversationLogTestAccess; }; class LLConversationLogObserver diff --git a/indra/newview/tests/llconversationlog_test.cpp b/indra/newview/tests/llconversationlog_test.cpp new file mode 100644 index 0000000000..b3e7df1d81 --- /dev/null +++ b/indra/newview/tests/llconversationlog_test.cpp @@ -0,0 +1,171 @@ +/** + * @file llconversationlog_test.cpp + * @brief Conversation Log discovery tests. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "../llviewerprecompiledheaders.h" +#include "llsingleton.h" +#include "llcontrol.h" +#include "../test/lltut.h" +#include "../llconversationlog.h" + +LLControlGroup gSavedSettings("test settings"); +LLControlGroup gSavedPerAccountSettings("test per-account settings"); +LLUUID gAgentID("00000000-0000-0000-0000-000000000001"); + +// The fixture needs only the no-session branches of the viewer services reached +// by LLConversationLog; these doubles keep that boundary isolated. +LLIMModel::LLIMModel() +{ +} + +LLIMModel::LLIMSession* LLIMModel::findIMSession(const LLUUID&) const +{ + return NULL; +} + +bool LLIMModel::LLIMSession::isOutgoingAdHoc() const +{ + return false; +} + +LLUUID LLIMModel::LLIMSession::generateOutgoingAdHocHash() const +{ + return mSessionID; +} + +LLIMMgr::LLIMMgr() +{ +} + +void LLIMMgr::addSessionObserver(LLIMSessionObserver*) +{ +} + +void LLIMMgr::removeSessionObserver(LLIMSessionObserver*) +{ +} + +LLAvatarTracker LLAvatarTracker::sInstance; + +LLAvatarTracker::LLAvatarTracker() +: mTrackingData(NULL), + mTrackedAgentValid(false), + mModifyMask(0), + mIsNotifyObservers(false) +{ +} + +LLAvatarTracker::~LLAvatarTracker() +{ +} + +void LLAvatarTracker::addObserver(LLFriendObserver*) +{ +} + +void LLAvatarTracker::removeObserver(LLFriendObserver*) +{ +} + +void LLLogChat::renameLogFile(const std::string&, const std::string&) +{ +} + +LLFloaterIMSession* LLFloaterIMSession::findInstance(const LLUUID&) +{ + return NULL; +} + +boost::signals2::connection LLFloaterIMSession::setIMFloaterShowedCallback( + const floater_showed_signal_t::slot_type&) +{ + return boost::signals2::connection(); +} + +// Keep private access to fixture state; production initialization binds global services. +struct LLConversationLogTestAccess +{ + static void reset(bool logging_enabled) + { + LLConversationLog& log = LLConversationLog::instance(); + log.mConversations.clear(); + log.mLoggingEnabled = logging_enabled; + } +}; + +namespace tut +{ +struct conversation_log +{ + conversation_log() + { + // A different device starts without locally created conversation metadata. + LLConversationLogTestAccess::reset(true); + } + + ~conversation_log() + { + LLConversationLogTestAccess::reset(false); + } +}; + +typedef test_group group_t; +typedef group_t::object object_t; +group_t group("LLConversationLog"); + +template<> template<> void object_t::test<1>() +{ + const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); + const LLUUID session_id = gAgentID ^ resident_id; + + // A durable service archive has resident metadata but no live IM session. + LLConversationLog& log = LLConversationLog::instance(); + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + U64Seconds(LLUnits::Seconds::fromValue(1787756400.0))); + + // Repeated archive publication must not duplicate the Conversation Log row. + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + U64Seconds(LLUnits::Seconds::fromValue(1787756400.0))); + + const LLConversation* discovered = log.getConversation(session_id); + ensure("remote direct conversation is discoverable", discovered != NULL); + ensure_equals("remote conversation is unique", log.getConversations().size(), size_t(1)); + ensure("remote conversation is P2P", + discovered->getConversationType() == LLIMModel::LLIMSession::P2P_SESSION); + ensure_equals("remote participant retained", discovered->getParticipantID(), resident_id); + ensure_equals("remote name retained", discovered->getConversationName(), + std::string("Bridie Linden")); + ensure_equals("remote transcript stem retained", discovered->getHistoryFileName(), + std::string("bridie.linden")); +} + +template<> template<> void object_t::test<2>() +{ + LLConversationLogTestAccess::reset(false); + + // Service history must not create metadata when Conversation Log privacy is disabled. + const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); + LLConversationLog& log = LLConversationLog::instance(); + log.addServiceConversation( + gAgentID ^ resident_id, + "Bridie Linden", + "bridie.linden", + resident_id, + U64Seconds(LLUnits::Seconds::fromValue(1787756400.0))); + + ensure("disabled Conversation Log remains empty", log.isLogEmpty()); +} +} -- cgit v1.3 From 99099633b3fcf4bf5c9c24f5c592bfa5a3607ec1 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:32:15 -0400 Subject: Preserve IM offers across history refreshes Defer IM transcript replay until the frame after clearing chat history, allowing deferred inline notification panels to finish destruction before replacements are embedded. Keep message updates paused during that interval so existing live rows and all notification-backed offers are rebuilt through the normal generic path. Validated with the Release secondlife-bin build. --- indra/newview/llfloaterimsession.cpp | 18 +++++++++++++++++- indra/newview/llfloaterimsession.h | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 11116c4cc5..5a7c03184f 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -73,6 +73,8 @@ floater_showed_signal_t LLFloaterIMSession::sIMFloaterShowedSignal; LLFloaterIMSession::LLFloaterIMSession(const LLUUID& session_id) : LLFloaterIMSessionTab(session_id), mLastMessageIndex(-1), + mHistoryReplayPending(false), + mHistoryReplayFrame(0), mDialog(IM_NOTHING_SPECIAL), mTypingStart(), mShouldSendTypingState(false), @@ -829,6 +831,11 @@ void LLFloaterIMSession::sessionInitReplyReceived(const LLUUID& im_session_id) void LLFloaterIMSession::updateMessages() { + if (mHistoryReplayPending) + { + return; + } + std::list messages; // we shouldn't reset unread message counters if IM floater doesn't have focus @@ -925,7 +932,8 @@ void LLFloaterIMSession::reloadMessages(bool clean_messages/* = false*/) mChatHistory->clear(); mLastMessageIndex = -1; - updateMessages(); + mHistoryReplayPending = true; + mHistoryReplayFrame = LLFrameTimer::getFrameCount(); mInputEditor->setFont(LLViewerChat::getChatFont()); } @@ -1124,6 +1132,14 @@ void LLFloaterIMSession::processSessionUpdate(const LLSD& session_update) // virtual void LLFloaterIMSession::draw() { + // Replay after the next mortician pass has destroyed the old inline panels. + if (mHistoryReplayPending && + mHistoryReplayFrame != LLFrameTimer::getFrameCount()) + { + mHistoryReplayPending = false; + updateMessages(); + } + // The floater presents both model-owned local reads and account-scoped service // work without scheduling either source from draw(). const bool loading = diff --git a/indra/newview/llfloaterimsession.h b/indra/newview/llfloaterimsession.h index 01147f1311..bc5716c3ea 100644 --- a/indra/newview/llfloaterimsession.h +++ b/indra/newview/llfloaterimsession.h @@ -173,6 +173,8 @@ private: static void confirmLeaveCallCallback(const LLSD& notification, const LLSD& response); S32 mLastMessageIndex; + bool mHistoryReplayPending; + U32 mHistoryReplayFrame; EInstantMessage mDialog; LLUUID mOtherParticipantUUID; -- cgit v1.3 From 33fd7dd09bf5054a2310a507c3782684c870c3ed Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:03:34 -0400 Subject: Replay IM transcripts without blank frames Detach chat-owned inline views before clearing and rebuild the transcript synchronously so an empty editor never reaches draw. Track notification panels as concurrent instances while deferred deletion overlaps old and replacement projections. Route offer updates to the live IM panel and ignore retiring friendship panels during deduplication. Validated with the RelWithDebInfo secondlife-bin build and the existing LLInstanceTracker test suite. --- indra/newview/llchathistory.cpp | 14 +++++++++++++- indra/newview/llfloaterimsession.cpp | 20 +++---------------- indra/newview/llfloaterimsession.h | 2 -- indra/newview/llimprocessing.cpp | 5 ++++- indra/newview/llnotificationofferhandler.cpp | 29 +++++++++++++++++----------- indra/newview/lltoastnotifypanel.cpp | 2 +- indra/newview/lltoastnotifypanel.h | 4 +++- 7 files changed, 42 insertions(+), 34 deletions(-) diff --git a/indra/newview/llchathistory.cpp b/indra/newview/llchathistory.cpp index 110fa77ef9..3af23d2af3 100644 --- a/indra/newview/llchathistory.cpp +++ b/indra/newview/llchathistory.cpp @@ -1272,6 +1272,16 @@ void LLChatHistory::onClickMoreText() void LLChatHistory::clear() { mLastFromName.clear(); + + // Inline views die on the mortician pass. Detach this transcript's current + // views now so a synchronous replay cannot draw old and replacement panels together. + const LLView* document_view = mEditor->getDocumentView(); + const LLView::child_list_t document_children = *document_view->getChildList(); + for (LLView* child : document_children) + { + mEditor->removeDocumentChild(child); + } + mEditor->clear(); mLastFromID = LLUUID::null; } @@ -1530,7 +1540,9 @@ void LLChatHistory::appendMessage(const LLChat& chat, const LLSD &args, const LL { LLIMToastNotifyPanel * imtoastp = dynamic_cast(&panel); const std::string& notification_name = panel.getNotificationName(); - if (notification_name == "OfferFriendship" + // A synchronous transcript replay replaces panels already queued for deletion. + if (!panel.isDead() + && notification_name == "OfferFriendship" && panel.isControlPanelEnabled() && imtoastp) { diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 5a7c03184f..64c0354ddd 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -73,8 +73,6 @@ floater_showed_signal_t LLFloaterIMSession::sIMFloaterShowedSignal; LLFloaterIMSession::LLFloaterIMSession(const LLUUID& session_id) : LLFloaterIMSessionTab(session_id), mLastMessageIndex(-1), - mHistoryReplayPending(false), - mHistoryReplayFrame(0), mDialog(IM_NOTHING_SPECIAL), mTypingStart(), mShouldSendTypingState(false), @@ -831,11 +829,6 @@ void LLFloaterIMSession::sessionInitReplyReceived(const LLUUID& im_session_id) void LLFloaterIMSession::updateMessages() { - if (mHistoryReplayPending) - { - return; - } - std::list messages; // we shouldn't reset unread message counters if IM floater doesn't have focus @@ -930,10 +923,11 @@ void LLFloaterIMSession::reloadMessages(bool clean_messages/* = false*/) } } + // Clear and replay in one turn so the transcript never presents an empty frame. + // LLChatHistory detaches old inline widgets before replacements are registered. mChatHistory->clear(); mLastMessageIndex = -1; - mHistoryReplayPending = true; - mHistoryReplayFrame = LLFrameTimer::getFrameCount(); + updateMessages(); mInputEditor->setFont(LLViewerChat::getChatFont()); } @@ -1132,14 +1126,6 @@ void LLFloaterIMSession::processSessionUpdate(const LLSD& session_update) // virtual void LLFloaterIMSession::draw() { - // Replay after the next mortician pass has destroyed the old inline panels. - if (mHistoryReplayPending && - mHistoryReplayFrame != LLFrameTimer::getFrameCount()) - { - mHistoryReplayPending = false; - updateMessages(); - } - // The floater presents both model-owned local reads and account-scoped service // work without scheduling either source from draw(). const bool loading = diff --git a/indra/newview/llfloaterimsession.h b/indra/newview/llfloaterimsession.h index bc5716c3ea..01147f1311 100644 --- a/indra/newview/llfloaterimsession.h +++ b/indra/newview/llfloaterimsession.h @@ -173,8 +173,6 @@ private: static void confirmLeaveCallCallback(const LLSD& notification, const LLSD& response); S32 mLastMessageIndex; - bool mHistoryReplayPending; - U32 mHistoryReplayFrame; EInstantMessage mDialog; LLUUID mOtherParticipantUUID; diff --git a/indra/newview/llimprocessing.cpp b/indra/newview/llimprocessing.cpp index 9ba939d35b..eee44b7ae0 100644 --- a/indra/newview/llimprocessing.cpp +++ b/indra/newview/llimprocessing.cpp @@ -1453,7 +1453,10 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, for (auto& panel : LLToastNotifyPanel::instance_snapshot()) { const std::string& notification_name = panel.getNotificationName(); - if (notification_name == "OfferFriendship" && panel.isControlPanelEnabled()) + // Panels pending deferred deletion no longer suppress a live offer. + if (!panel.isDead() + && notification_name == "OfferFriendship" + && panel.isControlPanelEnabled()) { add_notification = false; break; diff --git a/indra/newview/llnotificationofferhandler.cpp b/indra/newview/llnotificationofferhandler.cpp index 27ef28f32a..dfca370b46 100644 --- a/indra/newview/llnotificationofferhandler.cpp +++ b/indra/newview/llnotificationofferhandler.cpp @@ -171,22 +171,29 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification, /*virtual*/ void LLOfferHandler::onChange(LLNotificationPtr p) { - auto panelp = LLToastNotifyPanel::getInstance(p->getID()); - if (panelp) + bool has_toast_panel = false; + + // Deferred panel deletion can briefly overlap old and replacement views. + // Prefer the live IM projection for this notification; otherwise close its toast. + for (LLToastNotifyPanel& panel : LLToastNotifyPanel::instance_snapshot()) { - // - // HACK: if we're dealing with a notification embedded in IM, update it - // otherwise remove its toast - // - if (dynamic_cast(panelp.get())) + if (panel.isDead() || panel.getID() != p->getID()) { - panelp->updateNotification(); + continue; } - else + + if (LLIMToastNotifyPanel* im_panel = dynamic_cast(&panel)) { - // if notification has changed, hide it - mChannel.get()->removeToastByNotificationID(p->getID()); + im_panel->updateNotification(); + return; } + + has_toast_panel = true; + } + + if (has_toast_panel) + { + mChannel.get()->removeToastByNotificationID(p->getID()); } } diff --git a/indra/newview/lltoastnotifypanel.cpp b/indra/newview/lltoastnotifypanel.cpp index e905a879cb..c2bb880e2c 100644 --- a/indra/newview/lltoastnotifypanel.cpp +++ b/indra/newview/lltoastnotifypanel.cpp @@ -58,7 +58,7 @@ LLToastNotifyPanel::button_click_signal_t LLToastNotifyPanel::sButtonClickSignal LLToastNotifyPanel::LLToastNotifyPanel(const LLNotificationPtr& notification, const LLRect& rect, bool show_images) : LLCheckBoxToastPanel(notification) -, LLInstanceTracker(notification->getID()) +, LLInstanceTracker() , mTextBox(NULL), mButtonWidth(BUTTON_WIDTH) { diff --git a/indra/newview/lltoastnotifypanel.h b/indra/newview/lltoastnotifypanel.h index 71b9eaf8f5..31df823e27 100644 --- a/indra/newview/lltoastnotifypanel.h +++ b/indra/newview/lltoastnotifypanel.h @@ -44,10 +44,12 @@ class LLNotificationForm; * * Replaces class LLNotifyBox. * + * Toast and IM projections can overlap until deferred view deletion completes. + * * @deprecated this class will be removed after all toast panel types are * implemented in separate classes. */ -class LLToastNotifyPanel: public LLCheckBoxToastPanel, public LLInstanceTracker +class LLToastNotifyPanel: public LLCheckBoxToastPanel, public LLInstanceTracker { public: /** -- cgit v1.3 From cae8a81d8f0e0fd3a74ff5afbc79048701c86ebd Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:04:52 -0400 Subject: Avoid redundant IM transcript reconstruction Compare normalized historical overlays before replacing the visible transcript, and retain the current bounded history when durable live rows only roll the oldest entries out of the 200-message window. Let an active history read observe newer archive generations and restart itself instead of scheduling overlapping reads from snapshot callbacks. Validated with the RelWithDebInfo secondlife-bin build and the LLChatServiceHistoryCore test suite. --- indra/newview/llimview.cpp | 58 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index a4383b2f0d..e27ad96a63 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -38,6 +38,7 @@ #include "llrect.h" #include "llerror.h" #include "llbutton.h" +#include "llsdutil.h" #include "llsdutil_math.h" #include "llstring.h" #include "lltextutil.h" @@ -89,6 +90,31 @@ 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; @@ -1458,17 +1484,22 @@ void LLIMModel::LLIMSession::loadHistory() void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t& history) { - // Preserve every live row in its current order, replacing only rows tagged historical. + // 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); + } } - // An authoritative refresh may contain rows that this open session already + // 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; @@ -1504,6 +1535,22 @@ void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t 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); @@ -1514,7 +1561,7 @@ void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t message["index"] = --index; } - if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) + if (floater) { floater->reloadMessages(false); } @@ -1589,7 +1636,10 @@ void LLIMModel::LLIMSession::applyChatServiceSnapshot( replaceHistoricalMessages(mChatServiceHistoricalValue); } - if (presentation_changed || snapshot.archive_serial != mChatHistoryArchiveSerial) + // The active read will restart itself if its captured presentation or archive + // generation is stale, so status snapshots do not need to supersede it. + if (!mChatHistoryLocalLoading && + (presentation_changed || snapshot.archive_serial != mChatHistoryArchiveSerial)) { loadHistory(); } -- cgit v1.3 From 229c4204b51ebb29c76006fd536bd48cacfce318 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:34:46 -0400 Subject: Keep mobile conversations current in Conversation Log Publish Conversation Log metadata after both replace and append ChatService archive changes. Upsert existing P2P rows only from newer archive timestamps so mobile conversations become discoverable and dates refresh before a live IM session opens, while preserving retained metadata and consent. Notify and persist only real timestamp advances, leaving equal or older history as a no-op. Add coverage for exact archive times, monotonic updates, observer behavior, metadata retention, deduplication, and disabled logging. Validated with the Release secondlife-bin build, the LLConversationLog test suite (3 tests), and the LLChatServiceHistoryCore test suite (13 tests). --- indra/newview/llchatservicehistory.cpp | 7 +- indra/newview/llconversationlog.cpp | 31 +++++++- indra/newview/llconversationlog.h | 5 +- indra/newview/tests/llconversationlog_test.cpp | 101 ++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index 985b012a21..41b8d63a5a 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -1880,10 +1880,9 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) sRuntime.index_dirty = true; sRuntime.local_content_exists = true; - // The first durable service archive makes a cross-device conversation - // discoverable without creating a live IM session. Later appends leave - // explicit Conversation Log dismissals intact. - if (!append && after_publish.summary.has_rows && + // Every durable archive change can create or advance Conversation Log + // metadata without creating a live IM session. + if (after_publish.summary.has_rows && after_publish.metadata.state == META_RESOLVED) { const F64 archived_seconds = after_publish.summary.newest.ticks >= UUID_EPOCH diff --git a/indra/newview/llconversationlog.cpp b/indra/newview/llconversationlog.cpp index 1fbf3297dd..5a97e8b625 100644 --- a/indra/newview/llconversationlog.cpp +++ b/indra/newview/llconversationlog.cpp @@ -99,7 +99,12 @@ LLConversation::~LLConversation() void LLConversation::updateTimestamp() { - mTime = (U64Seconds)time_corrected(); + updateTimestamp((U64Seconds)time_corrected()); +} + +void LLConversation::updateTimestamp(const U64Seconds& utc_time) +{ + mTime = utc_time; mTimestamp = createTimestamp(mTime); } @@ -394,9 +399,29 @@ void LLConversationLog::addServiceConversation(const LLUUID& session_id, const LLUUID& participant_id, const U64Seconds& archived_time) { - // Service publication is idempotent and follows the Conversation Log privacy setting. - if (!mLoggingEnabled || getConversation(session_id)) + // Service publication follows the Conversation Log privacy setting. + if (!mLoggingEnabled) + { + return; + } + + // Durable service activity advances one matching row but never regresses its + // timestamp or replaces retained metadata. Notify and cache only when its + // visible timestamp changes. + for (LLConversation& conversation : mConversations) { + if (conversation.getSessionID() != session_id) + { + continue; + } + + if (archived_time > conversation.getTime()) + { + conversation.updateTimestamp(archived_time); + notifyParticularConversationObservers( + session_id, LLConversationLogObserver::CHANGED_TIME); + cache(); + } return; } diff --git a/indra/newview/llconversationlog.h b/indra/newview/llconversationlog.h index 1616bef41d..77a1562cbd 100644 --- a/indra/newview/llconversationlog.h +++ b/indra/newview/llconversationlog.h @@ -64,9 +64,10 @@ public: bool isOlderThan(U32Days days) const; /* - * updates last interaction time + * Updates last interaction time to now or an explicit UTC archive time. */ void updateTimestamp(); + void updateTimestamp(const U64Seconds& utc_time); void updateHistoryFileName(const std::string &new_name) { mHistoryFileName = new_name; } @@ -127,7 +128,7 @@ public: // LLIMSessionObserver triggers virtual void sessionAdded(const LLUUID& session_id, const std::string& name, const LLUUID& other_participant_id, bool has_offline_msg) override; - // Publish metadata for durable service history that has no live IM session. + // Publish or advance metadata for durable service history that has no live IM session. void addServiceConversation(const LLUUID& session_id, const std::string& conversation_name, const std::string& history_filename, diff --git a/indra/newview/tests/llconversationlog_test.cpp b/indra/newview/tests/llconversationlog_test.cpp index b3e7df1d81..aa53c312e7 100644 --- a/indra/newview/tests/llconversationlog_test.cpp +++ b/indra/newview/tests/llconversationlog_test.cpp @@ -96,6 +96,40 @@ struct LLConversationLogTestAccess log.mConversations.clear(); log.mLoggingEnabled = logging_enabled; } + + static void setOfflineMessages(const LLUUID& session_id, bool has_offline_messages) + { + LLConversationLog& log = LLConversationLog::instance(); + for (LLConversation& conversation : log.mConversations) + { + if (conversation.getSessionID() == session_id) + { + conversation.setOfflineMessages(has_offline_messages); + return; + } + } + } +}; + +// Capture both observer contracts so timestamp updates stay targeted to one row. +struct ConversationLogObserver : public LLConversationLogObserver +{ + void changed() override + { + ++mRefreshes; + } + + void changed(const LLUUID& session_id, U32 mask) override + { + ++mParticularChanges; + mSessionID = session_id; + mMask = mask; + } + + S32 mRefreshes = 0; + S32 mParticularChanges = 0; + LLUUID mSessionID; + U32 mMask = 0; }; namespace tut @@ -122,6 +156,7 @@ template<> template<> void object_t::test<1>() { const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); const LLUUID session_id = gAgentID ^ resident_id; + const U64Seconds archived_time(LLUnits::Seconds::fromValue(1787756400.0)); // A durable service archive has resident metadata but no live IM session. LLConversationLog& log = LLConversationLog::instance(); @@ -130,7 +165,7 @@ template<> template<> void object_t::test<1>() "Bridie Linden", "bridie.linden", resident_id, - U64Seconds(LLUnits::Seconds::fromValue(1787756400.0))); + archived_time); // Repeated archive publication must not duplicate the Conversation Log row. log.addServiceConversation( @@ -138,13 +173,14 @@ template<> template<> void object_t::test<1>() "Bridie Linden", "bridie.linden", resident_id, - U64Seconds(LLUnits::Seconds::fromValue(1787756400.0))); + archived_time); const LLConversation* discovered = log.getConversation(session_id); ensure("remote direct conversation is discoverable", discovered != NULL); ensure_equals("remote conversation is unique", log.getConversations().size(), size_t(1)); ensure("remote conversation is P2P", discovered->getConversationType() == LLIMModel::LLIMSession::P2P_SESSION); + ensure("remote archive time retained", discovered->getTime() == archived_time); ensure_equals("remote participant retained", discovered->getParticipantID(), resident_id); ensure_equals("remote name retained", discovered->getConversationName(), std::string("Bridie Linden")); @@ -153,6 +189,67 @@ template<> template<> void object_t::test<1>() } template<> template<> void object_t::test<2>() +{ + const LLUUID resident_id("00000000-0000-0000-0000-000000000002"); + const LLUUID changed_resident_id("00000000-0000-0000-0000-000000000003"); + const LLUUID session_id = gAgentID ^ resident_id; + const U64Seconds original_time(LLUnits::Seconds::fromValue(1787756400.0)); + const U64Seconds newer_time(LLUnits::Seconds::fromValue(1787929200.0)); + const U64Seconds older_time(LLUnits::Seconds::fromValue(1787666400.0)); + + LLConversationLog& log = LLConversationLog::instance(); + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + original_time); + LLConversationLogTestAccess::setOfflineMessages(session_id, true); + + // A newer archive advances only the timestamp. Equal and older publications + // remain silent so stale service data cannot regress or duplicate the row. + ConversationLogObserver observer; + log.addObserver(&observer); + log.addServiceConversation( + session_id, + "Changed Name", + "changed.filename", + changed_resident_id, + newer_time); + log.addServiceConversation( + session_id, + "Changed Again", + "changed.again", + changed_resident_id, + newer_time); + log.addServiceConversation( + session_id, + "Older Name", + "older.filename", + changed_resident_id, + older_time); + log.removeObserver(&observer); + + const LLConversation* updated = log.getConversation(session_id); + ensure("updated conversation remains present", updated != NULL); + ensure_equals("updated conversation remains unique", log.getConversations().size(), size_t(1)); + ensure("newer archive time applied", updated->getTime() == newer_time); + ensure_equals("formatted archive time refreshed", updated->getTimestamp(), + LLConversation::createTimestamp(newer_time)); + ensure_equals("existing participant preserved", updated->getParticipantID(), resident_id); + ensure_equals("existing name preserved", updated->getConversationName(), + std::string("Bridie Linden")); + ensure_equals("existing transcript stem preserved", updated->getHistoryFileName(), + std::string("bridie.linden")); + ensure("existing offline state preserved", updated->hasOfflineMessages()); + ensure_equals("timestamp update avoids full refresh", observer.mRefreshes, S32(0)); + ensure_equals("only newer time notifies", observer.mParticularChanges, S32(1)); + ensure_equals("timestamp update identifies session", observer.mSessionID, session_id); + ensure_equals("timestamp update identifies change", observer.mMask, + U32(LLConversationLogObserver::CHANGED_TIME)); +} + +template<> template<> void object_t::test<3>() { LLConversationLogTestAccess::reset(false); -- cgit v1.3 From b938e466e005c0a59d24d842ff3c23d18f87a7d4 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:05:49 -0400 Subject: Preserve dated legacy history ordering Match exact dated legacy and ChatService overlaps before applying the durable archive seam, so canonical replacements retain their legacy positions ahead of undated rows. Continue dropping unmatched post-boundary plaintext and append only remaining service rows after the undated seam. Validated with the RelWithDebInfo secondlife-bin build and the LLChatServiceHistoryCore test suite (13 tests). --- indra/newview/llchatservicehistory.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index 41b8d63a5a..b140259de1 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -2848,9 +2848,9 @@ LLChatServiceHistory::HistoryResult readStitched( std::vector> dated; std::list undated; - // Only the durable archive's oldest key controls the legacy/service seam. Keep - // ambiguous rows conservatively and require dated legacy rows to precede the - // service boundary by the existing seven-hour tolerance. + // Exact canonical overlaps keep their legacy position. For unmatched rows, the + // durable archive's oldest key controls the seam; retain undated rows and dated + // rows whose earliest possible SLT interpretation predates that boundary. for (const LLSD& message : legacy) { F64 wall = 0.0; @@ -2858,7 +2858,7 @@ LLChatServiceHistory::HistoryResult readStitched( { undated.push_back(message); } - else if (!archive.has_oldest || wall + 7.0 * 3600.0 < service_epoch) + else { LLSD stitched = message; stitched[LEGACY_WALL_TIME] = wall; @@ -2889,7 +2889,11 @@ LLChatServiceHistory::HistoryResult readStitched( stitched = serviceMessage(service[match]); service_placed[match] = true; } - dated.emplace_back(wall, stitched); + if (match != service.size() || !archive.has_oldest || + wall + 7.0 * 3600.0 < service_epoch) + { + dated.emplace_back(wall, stitched); + } } } std::stable_sort(dated.begin(), dated.end(), -- cgit v1.3 From 3f9646753032f53c78074eefa4176361013410be Mon Sep 17 00:00:00 2001 From: Andrey Kleshchev <117672381+akleshchev@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:59:44 +0300 Subject: p#700 Add a postponed size update on viewDidMoveToWindow --- indra/llwindow/llopenglview-objc.mm | 46 ++++++++++++++++++++++++++++++++---- indra/llwindow/llwindowcallbacks.cpp | 4 ++++ indra/llwindow/llwindowcallbacks.h | 1 + indra/llwindow/llwindowmacosx-objc.h | 2 ++ indra/llwindow/llwindowmacosx.cpp | 17 +++++++++++-- indra/llwindow/llwindowmacosx.h | 5 ++++ indra/newview/llviewerwindow.cpp | 19 ++++++++++++++- indra/newview/llviewerwindow.h | 1 + 8 files changed, 88 insertions(+), 7 deletions(-) diff --git a/indra/llwindow/llopenglview-objc.mm b/indra/llwindow/llopenglview-objc.mm index 403103991e..0786449286 100644 --- a/indra/llwindow/llopenglview-objc.mm +++ b/indra/llwindow/llopenglview-objc.mm @@ -137,6 +137,18 @@ attributedStringInfo getSegments(NSAttributedString *str) - (void)viewDidMoveToWindow { + // viewDidMoveToWindow is called both when the view is attached to a window + // and when it is detached (window == nil). + [NSObject cancelPreviousPerformRequestsWithTarget:self + selector:@selector(performDeferredResolutionUpdate) + object:nil]; + + if (![self window]) + { + [[NSNotificationCenter defaultCenter] removeObserver:self]; + return; + } + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowResized:) name:NSWindowDidResizeNotification object:[self window]]; @@ -157,11 +169,37 @@ attributedStringInfo getSegments(NSAttributedString *str) object:[self window]]; - NSRect wnd_rect = [[self window] frame]; - NSRect dev_rect = [self convertRectToBacking:wnd_rect]; - if (!NSEqualSizes(wnd_rect.size,dev_rect.size)) + if (windowCallbacksReady()) + { + NSRect wnd_rect = [[self window] frame]; + NSRect dev_rect = [self convertRectToBacking:wnd_rect]; + if (!NSEqualSizes(wnd_rect.size,dev_rect.size)) + { + // If window size actually changes, update size immediately + callResize(dev_rect.size.width, dev_rect.size.height); + } + else + { + // Defer size update to let the window settle on its final display + // before checkSettings() reads the live backing size. + callRequestResolutionUpdate(); + } + } + else + { + // Defer so that mCallbacks is the real callbacks object, + // not the null_callbacks used during LLWindowMacOSX construction. + // afterDelay will fire on the next loop iteration. + NSLog(@"viewDidMoveToWindow triggered, but callbacks not ready, postponing resolution update", nil); + [self performSelector:@selector(performDeferredResolutionUpdate) withObject:nil afterDelay:0]; + } +} + +- (void)performDeferredResolutionUpdate +{ + if ([self window]) { - callResize(dev_rect.size.width, dev_rect.size.height); + callRequestResolutionUpdate(); } } diff --git a/indra/llwindow/llwindowcallbacks.cpp b/indra/llwindow/llwindowcallbacks.cpp index f997aebb9d..b16d0ffce8 100644 --- a/indra/llwindow/llwindowcallbacks.cpp +++ b/indra/llwindow/llwindowcallbacks.cpp @@ -156,6 +156,10 @@ void LLWindowCallbacks::handleResize(LLWindow *window, const S32 width, const S3 { } +void LLWindowCallbacks::handleRequestResolutionUpdate(LLWindow* window) +{ +} + void LLWindowCallbacks::handleFocus(LLWindow *window) { LL_WARNS("COCOA") << "Called handleFocus proto" << LL_ENDL; diff --git a/indra/llwindow/llwindowcallbacks.h b/indra/llwindow/llwindowcallbacks.h index cd54ccd7c9..1fcf2974b5 100644 --- a/indra/llwindow/llwindowcallbacks.h +++ b/indra/llwindow/llwindowcallbacks.h @@ -63,6 +63,7 @@ public: virtual void handleScrollWheel(LLWindow *window, S32 clicks); virtual void handleScrollHWheel(LLWindow *window, S32 clicks); virtual void handleResize(LLWindow *window, S32 width, S32 height); + virtual void handleRequestResolutionUpdate(LLWindow* window); virtual void handleFocus(LLWindow *window); virtual void handleFocusLost(LLWindow *window); virtual void handleMenuSelect(LLWindow *window, S32 menu_item); diff --git a/indra/llwindow/llwindowmacosx-objc.h b/indra/llwindow/llwindowmacosx-objc.h index ec9afb1844..a9698bc0e1 100644 --- a/indra/llwindow/llwindowmacosx-objc.h +++ b/indra/llwindow/llwindowmacosx-objc.h @@ -140,6 +140,7 @@ void callLeftMouseDown(float *pos, unsigned int mask); void callLeftMouseUp(float *pos, unsigned int mask); void callDoubleClick(float *pos, unsigned int mask); void callResize(unsigned int width, unsigned int height); +void callRequestResolutionUpdate(); void callMouseMoved(float *pos, unsigned int mask); void callMouseDragged(float *pos, unsigned int mask); void callScrollMoved(float deltaX, float deltaY); @@ -157,6 +158,7 @@ void callFocusLost(); void callModifier(unsigned int mask); void callQuitHandler(); void commitCurrentPreedit(GLViewRef glView); +bool windowCallbacksReady(); #include void callHandleDragEntered(std::string url); diff --git a/indra/llwindow/llwindowmacosx.cpp b/indra/llwindow/llwindowmacosx.cpp index 7cb681aefe..7310e5b2fa 100644 --- a/indra/llwindow/llwindowmacosx.cpp +++ b/indra/llwindow/llwindowmacosx.cpp @@ -165,6 +165,7 @@ LLWindowMacOSX::LLWindowMacOSX(LLWindowCallbacks* callbacks, // Route them to a dummy callback structure until the end of constructor. LLWindowCallbacks null_callbacks; mCallbacks = &null_callbacks; + mIsConstructing = true; // Voodoo for calling cocoa from carbon (see llwindowmacosx-objc.mm). setupCocoa(); @@ -236,9 +237,8 @@ LLWindowMacOSX::LLWindowMacOSX(LLWindowCallbacks* callbacks, } mCallbacks = callbacks; + mIsConstructing = false; stop_glerror(); - - } // These functions are used as wrappers for our internal event handling callbacks. @@ -412,6 +412,14 @@ void callResize(unsigned int width, unsigned int height) } } +void callRequestResolutionUpdate() +{ + if (gWindowImplementation && gWindowImplementation->getCallbacks()) + { + gWindowImplementation->getCallbacks()->handleRequestResolutionUpdate(gWindowImplementation); + } +} + void callMouseMoved(float *pos, MASK mask) { if (!gWindowImplementation) @@ -705,6 +713,11 @@ void getPreeditLocation(float *location, unsigned int length) } } +bool windowCallbacksReady() +{ + return gWindowImplementation && !gWindowImplementation->isConstructing(); +} + void LLWindowMacOSX::updateMouseDeltas(float* deltas) { if (mCursorDecoupled) diff --git a/indra/llwindow/llwindowmacosx.h b/indra/llwindow/llwindowmacosx.h index 9534eadf8e..cc6490bb62 100644 --- a/indra/llwindow/llwindowmacosx.h +++ b/indra/llwindow/llwindowmacosx.h @@ -147,6 +147,10 @@ public: // enable or disable multithreaded GL static void setUseMultGL(bool use_mult_gl); + // During construction null_callbacks are used, + // this is a way to determine when callbacks are ready. + bool isConstructing() const { return mIsConstructing; } + protected: LLWindowMacOSX(LLWindowCallbacks* callbacks, const std::string& title, const std::string& name, int x, int y, int width, int height, U32 flags, @@ -223,6 +227,7 @@ protected: bool mMinimized; U32 mFSAASamples; bool mForceRebuild; + bool mIsConstructing = true; S32 mDragOverrideCursor; diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 8730b14614..6659f39d79 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -1580,6 +1580,12 @@ void LLViewerWindow::handleResize(LLWindow *window, S32 width, S32 height) mResDirty = true; } +void LLViewerWindow::handleRequestResolutionUpdate(LLWindow* window) +{ + requestResolutionUpdate(); + LL_DEBUGS("Window") << "handleRequestResolutionUpdate: mResDirty set" << LL_ENDL; +} + // The top-level window has gained focus (e.g. via ALT-TAB) void LLViewerWindow::handleFocus(LLWindow *window) { @@ -6051,7 +6057,18 @@ void LLViewerWindow::checkSettings() // We want to update the resolution AFTER the states getting refreshed not before. if (mResDirty) { - reshape(getWindowWidthRaw(), getWindowHeightRaw()); + LLCoordScreen window_size; + if (mWindow->getSize(&window_size)) + { + reshape(window_size.mX, window_size.mY); + } + else + { + S32 width = getWindowWidthRaw(); + S32 height = getWindowHeightRaw(); + LL_WARNS() << "Failed to get window size, using raw window size " << width << "x" << height << " instead" << LL_ENDL; + reshape(width, height); + } mResDirty = false; } } diff --git a/indra/newview/llviewerwindow.h b/indra/newview/llviewerwindow.h index 06d49df56d..e09e8fee14 100644 --- a/indra/newview/llviewerwindow.h +++ b/indra/newview/llviewerwindow.h @@ -221,6 +221,7 @@ public: void handleMouseDragged(LLWindow *window, LLCoordGL pos, MASK mask); /*virtual*/ void handleMouseLeave(LLWindow *window); /*virtual*/ void handleResize(LLWindow *window, S32 x, S32 y); + /*virtual*/ void handleRequestResolutionUpdate(LLWindow* window); /*virtual*/ void handleFocus(LLWindow *window); /*virtual*/ void handleFocusLost(LLWindow *window); /*virtual*/ bool handleActivate(LLWindow *window, bool activated); -- cgit v1.3 From ce1b7583c99eb8155fd6b1621b3976fb31cac913 Mon Sep 17 00:00:00 2001 From: Andrey Kleshchev <117672381+akleshchev@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:59:44 +0300 Subject: p#700 Fix issue with tittle bar on Windows --- indra/llwindow/llwindow.h | 5 +++++ indra/llwindow/llwindowmacosx.h | 4 ++++ indra/llwindow/llwindowwin32.h | 4 ++++ indra/newview/llviewerwindow.cpp | 4 ++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/indra/llwindow/llwindow.h b/indra/llwindow/llwindow.h index 327323b018..3f06bf1a50 100644 --- a/indra/llwindow/llwindow.h +++ b/indra/llwindow/llwindow.h @@ -78,7 +78,12 @@ public: virtual void restore() = 0; bool getFullscreen() { return mFullscreen; }; virtual bool getPosition(LLCoordScreen *position) = 0; + + // Returns the platform-defined window size in physical (backing) pixels. + // On macOS: backing content view size (excludes title bar). + // On Windows: outer window frame size (includes title bar and borders). virtual bool getSize(LLCoordScreen *size) = 0; + virtual bool getSize(LLCoordWindow *size) = 0; virtual bool setPosition(LLCoordScreen position) = 0; bool setSize(LLCoordScreen size); diff --git a/indra/llwindow/llwindowmacosx.h b/indra/llwindow/llwindowmacosx.h index cc6490bb62..a9475c84cc 100644 --- a/indra/llwindow/llwindowmacosx.h +++ b/indra/llwindow/llwindowmacosx.h @@ -50,7 +50,11 @@ public: void restore() override; bool getFullscreen(); bool getPosition(LLCoordScreen *position) override; + + // Content view size in backing pixels (excludes title bar). + // Note: on Windows, getSize(LLCoordScreen) returns the outer window frame size (includes title bar and borders). bool getSize(LLCoordScreen *size) override; + bool getSize(LLCoordWindow *size) override; bool setPosition(LLCoordScreen position) override; bool setSizeImpl(LLCoordScreen size) override; diff --git a/indra/llwindow/llwindowwin32.h b/indra/llwindow/llwindowwin32.h index 0b104c6da8..9ccd0a3ee2 100644 --- a/indra/llwindow/llwindowwin32.h +++ b/indra/llwindow/llwindowwin32.h @@ -62,7 +62,11 @@ public: void restore() override; bool getFullscreen(); bool getPosition(LLCoordScreen *position) override; + + // Outer window frame size in pixels (includes title bar and borders). + // Note OS Specific behavior: On macOS excludes title bar. bool getSize(LLCoordScreen *size) override; + bool getSize(LLCoordWindow *size) override; bool setPosition(LLCoordScreen position) override; bool setSizeImpl(LLCoordScreen size) override; diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 6659f39d79..de40e3f913 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -6054,10 +6054,10 @@ void LLViewerWindow::checkSettings() mStatesDirty = false; } - // We want to update the resolution AFTER the states getting refreshed not before. if (mResDirty) { - LLCoordScreen window_size; + // Deferred resolution update after states have been refreshed. + LLCoordWindow window_size; if (mWindow->getSize(&window_size)) { reshape(window_size.mX, window_size.mY); -- cgit v1.3 From 38635da94eb5e7f2356305ffd38567199e65528f Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:18:17 -0400 Subject: Scope friendship offer deduplication by sender Treat an existing actionable friendship panel as a duplicate only when its notification belongs to the same resident. This preserves same-sender suppression and dead-panel replay behavior while allowing simultaneous offline requests from different residents to render in their own sessions. Validated with the RelWithDebInfo secondlife-bin build. --- indra/newview/llchathistory.cpp | 8 +++++--- indra/newview/llimprocessing.cpp | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/indra/newview/llchathistory.cpp b/indra/newview/llchathistory.cpp index 3af23d2af3..8db072b445 100644 --- a/indra/newview/llchathistory.cpp +++ b/indra/newview/llchathistory.cpp @@ -1533,16 +1533,18 @@ void LLChatHistory::appendMessage(const LLChat& chat, const LLSD &args, const LL bool create_toast = true; if (notification->getName() == "OfferFriendship") { - // We don't want multiple friendship offers to appear, this code checks if there are previous offers - // by iterating though all panels. - // Note: it might be better to simply add a "pending offer" flag somewhere + // Keep one actionable offer per sender without hiding requests from other residents. + const LLUUID from_id = notification->getPayload()["from_id"].asUUID(); for (auto& panel : LLToastNotifyPanel::instance_snapshot()) { LLIMToastNotifyPanel * imtoastp = dynamic_cast(&panel); const std::string& notification_name = panel.getNotificationName(); + LLNotificationPtr panel_notification = LLNotificationsUtil::find(panel.getID()); // A synchronous transcript replay replaces panels already queued for deletion. if (!panel.isDead() && notification_name == "OfferFriendship" + && panel_notification + && panel_notification->getPayload()["from_id"].asUUID() == from_id && panel.isControlPanelEnabled() && imtoastp) { diff --git a/indra/newview/llimprocessing.cpp b/indra/newview/llimprocessing.cpp index eee44b7ae0..bd5a2cddee 100644 --- a/indra/newview/llimprocessing.cpp +++ b/indra/newview/llimprocessing.cpp @@ -1453,9 +1453,12 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, for (auto& panel : LLToastNotifyPanel::instance_snapshot()) { const std::string& notification_name = panel.getNotificationName(); - // Panels pending deferred deletion no longer suppress a live offer. + LLNotificationPtr panel_notification = LLNotificationsUtil::find(panel.getID()); + // Only a live actionable offer from this sender makes the packet a duplicate. if (!panel.isDead() && notification_name == "OfferFriendship" + && panel_notification + && panel_notification->getPayload()["from_id"].asUUID() == from_id && panel.isControlPanelEnabled()) { add_notification = false; -- cgit v1.3 From cfbbdcb6e874d73ef9429293d3afff58e1d6c216 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:13:34 -0400 Subject: Restore canonical ChatService tail ordering Apply the durable archive boundary before reconciling dated legacy overlaps so post-boundary messages stay in canonical service order and survive consumer caps. Name the offset-less SLT conversion at the seam and cover capped archive summaries and UTC-7/UTC-8 boundary cases. Validated with the RelWithDebInfo secondlife-bin build, the LLChatServiceHistoryCore suite (14 tests), the LLConversationLog suite (3 tests), and all 19 newview CTest targets. --- indra/newview/llchatservicehistory.cpp | 19 +++++++------------ indra/newview/llchatservicehistorycore.cpp | 5 +++++ indra/newview/llchatservicehistorycore.h | 4 ++++ .../newview/tests/llchatservicehistorycore_test.cpp | 21 +++++++++++++++++++++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index b140259de1..b73937b546 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -2848,9 +2848,8 @@ LLChatServiceHistory::HistoryResult readStitched( std::vector> dated; std::list undated; - // Exact canonical overlaps keep their legacy position. For unmatched rows, the - // durable archive's oldest key controls the seam; retain undated rows and dated - // rows whose earliest possible SLT interpretation predates that boundary. + // Admit only the legacy prefix that may predate the durable service boundary. + // Offset-less SLT timestamps use their earliest UTC interpretation (UTC-7). for (const LLSD& message : legacy) { F64 wall = 0.0; @@ -2858,14 +2857,13 @@ LLChatServiceHistory::HistoryResult readStitched( { undated.push_back(message); } - else + else if (!archive.has_oldest || legacyWallMayPrecedeService(wall, service_epoch)) { LLSD stitched = message; stitched[LEGACY_WALL_TIME] = wall; - // Replace an exact legacy occurrence in place with its canonical row. - // This keeps local ordering around same-minute system messages while - // consuming only one occurrence from each source. + // 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 }) { @@ -2889,11 +2887,7 @@ LLChatServiceHistory::HistoryResult readStitched( stitched = serviceMessage(service[match]); service_placed[match] = true; } - if (match != service.size() || !archive.has_oldest || - wall + 7.0 * 3600.0 < service_epoch) - { - dated.emplace_back(wall, stitched); - } + dated.emplace_back(wall, stitched); } } std::stable_sort(dated.begin(), dated.end(), @@ -2921,6 +2915,7 @@ LLChatServiceHistory::HistoryResult readStitched( } } + // Apply the consumer limit to the complete seam order, retaining the service tail. while (limit && result.messages.size() > limit) { result.messages.pop_front(); diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index 3478bb2f11..06fdd8ecf6 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -297,6 +297,11 @@ bool sameDirectSenderName(const std::string& left, const std::string& right) return !left_resident.empty() && left_resident == right_resident; } +bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch) +{ + return wall_epoch + 7.0 * 3600.0 < service_epoch; +} + bool parseCreatedAt(const std::string& text, std::string& normalized) { // Validate the complete wire shape before LLDate performs calendar conversion. diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h index 86c6b0131e..9b4254eca6 100644 --- a/indra/newview/llchatservicehistorycore.h +++ b/indra/newview/llchatservicehistorycore.h @@ -110,6 +110,10 @@ namespace LLChatServiceHistoryCore bool parseCreatedAt(const std::string& text, std::string& normalized); bool sameDirectSenderName(const std::string& left, const std::string& right); + // 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); + // Both wire validators are all-or-nothing and clear their output before parsing. bool validateConversationList(const LLSD& value, const LLUUID& agent_id, std::vector& entries); diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index e5f542d3f7..52bc16edba 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -233,9 +233,12 @@ template<> template<> void object_t::test<9>() { NamedTempFile file("chatservice", validArchive()); ArchiveScan scan; + TimeUuidKey oldest; + parseTimeUuid(FIRST, oldest); ensure("valid archive", scanArchive(file.getPath().string(), AGENT, RESIDENT, 0, 1, scan)); ensure_equals("two summarized", scan.row_count, U32(2)); + ensure("display cap preserves durable oldest", scan.oldest == oldest); ensure_equals("newest display cap", scan.display_rows.size(), size_t(1)); ensure_equals("newest retained", scan.display_rows.front().msg_id, std::string(SECOND)); } @@ -288,4 +291,22 @@ template<> template<> void object_t::test<13>() ensure("different residents stay distinct", !sameDirectSenderName("bridie.linden", "beanie.tester")); } + +template<> template<> void object_t::test<14>() +{ + const F64 wall = 1000.0; + const F64 daylight_utc = wall + 7.0 * 3600.0; + + // The earlier UTC-7 interpretation controls conservative seam admission. + ensure("possible pre-boundary row retained", + legacyWallMayPrecedeService(wall, daylight_utc + 1.0)); + ensure("row at boundary excluded", + !legacyWallMayPrecedeService(wall, daylight_utc)); + ensure("post-boundary row excluded", + !legacyWallMayPrecedeService(wall, daylight_utc - 1.0)); + + // A boundary between the UTC-7 and UTC-8 interpretations remains ambiguous. + ensure("ambiguous DST row retained", + legacyWallMayPrecedeService(wall, daylight_utc + 30.0 * 60.0)); +} } -- cgit v1.3