diff options
| author | Pepper <3782201+rohvani@users.noreply.github.com> | 2026-09-01 19:00:25 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 19:00:25 -0400 |
| commit | 56b34b332727299f2d54d4d6e8dbe5a7cb54cb11 (patch) | |
| tree | f9ea9e683dc3951a49a0773084d84ffa0dd9383e | |
| parent | 66ff4c75bc2d594a41dfe22d85139311567b8e97 (diff) | |
| parent | cfbbdcb6e874d73ef9429293d3afff58e1d6c216 (diff) | |
Merge pull request #6208 from secondlife/pepper/chat-fixes
various fixes for chat modernization project viewer
26 files changed, 693 insertions, 44 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/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/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 <string> 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..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; @@ -147,6 +151,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 +231,7 @@ protected: bool mMinimized; U32 mFSAASamples; bool mForceRebuild; + bool mIsConstructing = true; S32 mDragOverrideCursor; 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/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/llchathistory.cpp b/indra/newview/llchathistory.cpp index 110fa77ef9..8db072b445 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; } @@ -1523,14 +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<LLIMToastNotifyPanel *>(&panel); const std::string& notification_name = panel.getNotificationName(); - if (notification_name == "OfferFriendship" + 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/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index 39dc3d6271..b73937b546 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" @@ -1429,6 +1431,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 +1614,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 +1860,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 || @@ -1868,6 +1879,24 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) ++after_publish.archive_serial; sRuntime.index_dirty = true; sRuntime.local_content_exists = true; + + // 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 + ? 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))); + } + LLLogChat::notifyTranscriptCreated(); } else @@ -2481,6 +2510,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) @@ -2655,13 +2698,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) @@ -2673,7 +2717,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) @@ -2804,9 +2848,8 @@ LLChatServiceHistory::HistoryResult readStitched( std::vector<std::pair<F64, LLSD>> dated; std::list<LLSD> 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. + // 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; @@ -2814,14 +2857,13 @@ LLChatServiceHistory::HistoryResult readStitched( { undated.push_back(message); } - else if (!archive.has_oldest || wall + 7.0 * 3600.0 < service_epoch) + 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 }) { @@ -2873,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 a8506c687a..06fdd8ecf6 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,24 @@ 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 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 d9ac647a57..9b4254eca6 100644 --- a/indra/newview/llchatservicehistorycore.h +++ b/indra/newview/llchatservicehistorycore.h @@ -108,6 +108,11 @@ 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); + + // 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, diff --git a/indra/newview/llconversationlog.cpp b/indra/newview/llconversationlog.cpp index e5adedd2ba..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); } @@ -388,6 +393,53 @@ 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 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; + } + + // 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..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; } @@ -126,6 +127,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 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, + 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 +208,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/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 11116c4cc5..64c0354ddd 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -923,6 +923,8 @@ 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; updateMessages(); diff --git a/indra/newview/llimprocessing.cpp b/indra/newview/llimprocessing.cpp index 9ba939d35b..bd5a2cddee 100644 --- a/indra/newview/llimprocessing.cpp +++ b/indra/newview/llimprocessing.cpp @@ -1453,7 +1453,13 @@ 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()) + 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; break; 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(); } 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<LLMuteList> 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/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<LLIMToastNotifyPanel*>(panelp.get())) + if (panel.isDead() || panel.getID() != p->getID()) { - panelp->updateNotification(); + continue; } - else + + if (LLIMToastNotifyPanel* im_panel = dynamic_cast<LLIMToastNotifyPanel*>(&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<LLToastNotifyPanel, LLUUID, LLInstanceTrackerReplaceOnCollision>(notification->getID()) +, LLInstanceTracker<LLToastNotifyPanel>() , 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<LLToastNotifyPanel, LLUUID, LLInstanceTrackerReplaceOnCollision> +class LLToastNotifyPanel: public LLCheckBoxToastPanel, public LLInstanceTracker<LLToastNotifyPanel> { public: /** diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 8730b14614..de40e3f913 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) { @@ -6048,10 +6054,21 @@ void LLViewerWindow::checkSettings() mStatesDirty = false; } - // We want to update the resolution AFTER the states getting refreshed not before. if (mResDirty) { - reshape(getWindowWidthRaw(), getWindowHeightRaw()); + // Deferred resolution update after states have been refreshed. + LLCoordWindow 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); diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 3a160a95c2..52bc16edba 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 { @@ -211,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)); } @@ -245,4 +270,43 @@ 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()); +} + +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")); +} + +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)); +} } diff --git a/indra/newview/tests/llconversationlog_test.cpp b/indra/newview/tests/llconversationlog_test.cpp new file mode 100644 index 0000000000..aa53c312e7 --- /dev/null +++ b/indra/newview/tests/llconversationlog_test.cpp @@ -0,0 +1,268 @@ +/** + * @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; + } + + 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 +{ +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<conversation_log> 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; + 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(); + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + archived_time); + + // Repeated archive publication must not duplicate the Conversation Log row. + log.addServiceConversation( + session_id, + "Bridie Linden", + "bridie.linden", + resident_id, + 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")); + ensure_equals("remote transcript stem retained", discovered->getHistoryFileName(), + std::string("bridie.linden")); +} + +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); + + // 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()); +} +} |
