summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPepper <3782201+rohvani@users.noreply.github.com>2026-09-01 19:00:25 -0400
committerGitHub <noreply@github.com>2026-09-01 19:00:25 -0400
commit56b34b332727299f2d54d4d6e8dbe5a7cb54cb11 (patch)
treef9ea9e683dc3951a49a0773084d84ffa0dd9383e
parent66ff4c75bc2d594a41dfe22d85139311567b8e97 (diff)
parentcfbbdcb6e874d73ef9429293d3afff58e1d6c216 (diff)
Merge pull request #6208 from secondlife/pepper/chat-fixes
various fixes for chat modernization project viewer
-rw-r--r--indra/llwindow/llopenglview-objc.mm46
-rw-r--r--indra/llwindow/llwindow.h5
-rw-r--r--indra/llwindow/llwindowcallbacks.cpp4
-rw-r--r--indra/llwindow/llwindowcallbacks.h1
-rw-r--r--indra/llwindow/llwindowmacosx-objc.h2
-rw-r--r--indra/llwindow/llwindowmacosx.cpp17
-rw-r--r--indra/llwindow/llwindowmacosx.h9
-rw-r--r--indra/llwindow/llwindowwin32.h4
-rw-r--r--indra/newview/CMakeLists.txt1
-rw-r--r--indra/newview/llchathistory.cpp22
-rw-r--r--indra/newview/llchatservicehistory.cpp65
-rw-r--r--indra/newview/llchatservicehistorycore.cpp19
-rw-r--r--indra/newview/llchatservicehistorycore.h5
-rw-r--r--indra/newview/llconversationlog.cpp54
-rw-r--r--indra/newview/llconversationlog.h14
-rw-r--r--indra/newview/llfloaterimsession.cpp2
-rw-r--r--indra/newview/llimprocessing.cpp8
-rw-r--r--indra/newview/llimview.cpp58
-rw-r--r--indra/newview/llmutelist.h12
-rw-r--r--indra/newview/llnotificationofferhandler.cpp29
-rw-r--r--indra/newview/lltoastnotifypanel.cpp2
-rw-r--r--indra/newview/lltoastnotifypanel.h4
-rw-r--r--indra/newview/llviewerwindow.cpp21
-rw-r--r--indra/newview/llviewerwindow.h1
-rw-r--r--indra/newview/tests/llchatservicehistorycore_test.cpp64
-rw-r--r--indra/newview/tests/llconversationlog_test.cpp268
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());
+}
+}