Skip to content

feat: chat reactions#8018

Open
biotech77 wants to merge 187 commits intodevfrom
feat/chat-reactions
Open

feat: chat reactions#8018
biotech77 wants to merge 187 commits intodevfrom
feat/chat-reactions

Conversation

@biotech77
Copy link
Copy Markdown
Contributor

@biotech77 biotech77 commented Apr 2, 2026

Pull Request Description

Summary

Adds a full emoji reaction system to the chat, supporting two modes:

  • Situational reactions — click the reaction button to broadcast an animated emoji burst visible to nearby players (screen-space + world-space particles above avatars)
  • Message reactions — toggle emoji reactions on individual chat messages, persisted in chat history

The system uses GPU-instanced particle rendering, spring-force physics for world-space particles, network debouncing over LiveKit, and an MVP presenter layer for the UI.

Architecture

ChatReactions/
├── Configs/ ScriptableObject configs (atlas, UI/world lane tuning, debounce timing, tooltip positioning)
├── Core/ Orchestration layer
│ ├── ChatReactionsFactory Composition root — wires all dependencies
│ ├── SituationalReactionFacade Main API: trigger → broadcast + spawn
│ ├── SituationalSimulationLoop Per-frame tick: physics, network, rendering
│ ├── SituationalReactionDebouncer Batches outgoing reactions to reduce network traffic
│ ├── RemoteReactionReceiver Staggered dequeue for visual cascade of incoming reactions
│ ├── StreamReactionsChatCommand /streamreactions debug command — continuous test stream
│ ├── StreamReactionsEmitter Drives the stream emission loop with rate limiting
│ ├── FakeReactionsChatCommand /fakereaction debug command — single test reaction
│ ├── TokenBucketRateLimiter Rate-limits outgoing stream reactions
│ ├── ChatReactionRecentsService Tracks recently used emoji for selector bar
│ └── EmojiCodepointHelper Emoji ↔ index resolution utilities
├── Networking/ Message bus + routing
│ ├── IReactionMessageBus / MultiplayerReactionMessageBus LiveKit integration
│ ├── ReactionRouter Dispatches by type (situational vs message)
│ ├── ReactionNetworkBroadcaster Debounced network flush
│ ├── ReactionWireEncoding Packs emoji index + removal flag into single int
│ ├── ReactionChannelRouting Channel selection logic for reaction payloads
│ └── ChatMessageReactionService Toggle/persist reactions on chat messages
├── Presenters/ MVP presenters
│ ├── ChatReactionsPresenter Top-level: button + selector bars + emoji panel coordination
│ ├── ChatReactionsSelectorPresenter Emoji shortcuts (recents + defaults)
│ ├── ChatReactionButtonPresenter Individual reaction button logic
│ ├── SituationalReactionPresenter Bridges situational trigger to UI
│ ├── EmojiPanelReactionBridge Bridges full emoji panel for selection
│ └── ReactionPanelPositioner Positions selector relative to message anchors
├── Simulation/ Particle physics + storage
│ ├── DenseParticleStore Dense array with lazy compaction (allocation-free ticks)
│ ├── ParticleIntegrator Velocity integration, drag, gravity, expiration
│ ├── UI/ Screen-space: flight paths, zigzag steering, spawn resolver, stream emitter
│ └── World/ World-space: avatar anchor tracking, spring forces, frustum culling
├── Rendering/ GPU-instanced particle rendering
│ ├── ChatReactionsParticleRenderer Batches ≤1023 particles/draw, screen↔world conversion
│ ├── ChatReactionsAtlasHelper Per-emoji UV lookup from atlas config
│ └── ChatReactionMaterialFactory Creates/caches instanced materials for particle draws
├── Views/ Thin view components (button, selector bar, item, hover effect, situational view)
├── Debug/ Debug panel, event bus, mock message bus, stats window
├── Editor/ Custom inspectors, atlas preview, debug prefs
└── Tests/ 15 test files, 118 test cases

Data Flow

Local reaction:
Button click → ChatReactionsPresenter → SituationalReactionFacade
├→ ChatReactionUISimulation.SpawnBurst() (screen-space particles)
├→ LocalPlayerWorldReactor.TriggerBurst() (world-space above local avatar)
└→ ReactionNetworkBroadcaster.Enqueue() (debounced LiveKit send)

Remote reaction:
LiveKit message → ReactionRouter
├→ [Situational] RemoteReactionReceiver → stagger → world spawn above sender's avatar
└→ [Message] ChatMessageReactionService → toggle reaction in chat history

Shape
Design

Frame debugger
image

Debug config
image

What does this PR change?

Adds chat reactions feature

Test Instructions

Test Steps

Additional Testing Notes

  • Note any edge cases to verify
  • Mention specific areas that need careful testing
  • List known limitations or potential issues

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

Code Review Reference

Please review our Code Review Standards before submitting.

biotech77 added 30 commits March 5, 2026 17:48
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 3, 2026

Claude finished @biotech77's task in 3m 38s —— View job


PR Review: feat/chat-reactions

Tasks

  • Read PR context and understand scope
  • Review key source files
  • Analyze security, bugs, and performance issues
  • Post inline comments for specific issues
  • Summarize findings

Summary

4 blocking issues found — the PR should not merge until these are resolved.


Issues

1. Security — wallet address spoofable (Critical)

File: MultiplayerReactionMessageBus.cs:173-175

OnChatReactionReceived sets walletId from Payload.Address when present. This field is self-reported by the sender — any client can forge it to impersonate another user's wallet and add/remove reactions on their behalf. Only receivedMessage.FromWalletId is transport-authenticated. Inline comment posted.

2. Data corruption — reaction file unreadable after first write (Critical)

File: ChatHistoryStorage.cs:721-725

AppendReactionToFile opens a fresh CreateEncryptionStreamWriter (new IV + header) for every reaction, then FileMode.Appends to the same file. ReadReactionsFromFileAsync opens a single decryption stream over the resulting multi-header concatenation. AES cannot transparently stitch these — only the first segment decrypts; all subsequent reactions are silently corrupted or throw. Messages work correctly by keeping the stream open via OpenChannelFileForWriting / CloseChannelFile; the reaction path must do the same. Inline comment posted.

3. Debug code runs in production (Bug)

File: MessageReactionsView.cs:117

messageConfig.DebugRandomizeReactionCounts and Random.Range(1, 100) execute on every reaction-item update in production builds. Must be removed or wrapped in #if UNITY_EDITOR. Inline comment posted.

4. GPU regression — SetVectorArray uploads full 1023-entry arrays every flush (Performance)

File: ChatReactionsParticleRenderer.cs:168-170

MaterialPropertyBlock.SetVectorArray has no count parameter. Both posSize and packed (each 1023 × Vector4) are uploaded to the GPU on every Flush call regardless of actual particle count — 2 × 1023 × 16 ≈ 32 KB per batch, even for a single particle. This aligns with the consistent ~16% GPU overhead seen across all three performance test runs (average GPU: +2.6 ms, 1% worst: +2.6 ms on the most recent run). A practical mitigation is to maintain tiered pre-allocated arrays (e.g. 16 / 64 / 256 / 1023 entries) and select the smallest tier that covers count before calling SetVectorArray. Inline comment posted.


REVIEW_RESULT: FAIL ❌

@AnsisMalins
Copy link
Copy Markdown
Contributor

PR #8018, run #23943128828

Builds: Windows change, Windows baseline, macOS change, macOS baseline

Intel Core i3

Metric Change Baseline Delta Improvement
Samples 2560 2667
CPU average 35.1 ms 33.7 ms 1.4 ms -4.0% 🔴
CPU 1% worst 72.7 ms 55.9 ms 16.8 ms -30.0% 🔴
CPU 0.1% worst 263.9 ms 117.1 ms 146.8 ms -125.3% 🔴
GPU average 19.0 ms 16.3 ms 2.6 ms -16.2% 🔴
GPU 1% worst 24.1 ms 23.2 ms 0.9 ms -3.9% 🔴
GPU 0.1% worst 26.0 ms 28.0 ms -2.0 ms 7.0% 🟢

@AnsisMalins
Copy link
Copy Markdown
Contributor

PR #8018, run #23944583101

Builds: Windows change, Windows baseline, macOS change, macOS baseline

Intel Core i3

Metric Change Baseline Delta Improvement
Samples 2560 2667
CPU average 35.1 ms 33.7 ms 1.4 ms -4.2% 🔴
CPU 1% worst 69.3 ms 58.8 ms 10.5 ms -17.9% 🔴
CPU 0.1% worst 227.5 ms 154.0 ms 73.5 ms -47.7% 🔴
GPU average 19.4 ms 16.1 ms 3.3 ms -20.1% 🔴
GPU 1% worst 24.4 ms 21.7 ms 2.7 ms -12.5% 🔴
GPU 0.1% worst 28.2 ms 24.7 ms 3.4 ms -13.9% 🔴

@AnsisMalins
Copy link
Copy Markdown
Contributor

PR #8018, run #23950517683

Builds: Windows change, Windows baseline, macOS change, macOS baseline

Intel Core i3

Metric Change Baseline Delta Improvement
Samples 2626 2668
CPU average 34.2 ms 33.7 ms 0.5 ms -1.5%
CPU 1% worst 62.1 ms 60.8 ms 1.3 ms -2.1%
CPU 0.1% worst 110.8 ms 155.3 ms -44.5 ms 28.7% 🟢
GPU average 17.5 ms 15.6 ms 1.9 ms -12.1% 🔴
GPU 1% worst 23.9 ms 21.2 ms 2.7 ms -12.7% 🔴
GPU 0.1% worst 35.5 ms 22.8 ms 12.7 ms -55.9% 🔴

@AnsisMalins
Copy link
Copy Markdown
Contributor

PR #8018, run #23956101373

Builds: Windows change, Windows baseline, macOS change, macOS baseline

Intel Core i3

Metric Change Baseline Delta Improvement
Samples 2593 2687
CPU average 34.7 ms 33.4 ms 1.2 ms -3.6% 🔴
CPU 1% worst 59.8 ms 41.9 ms 17.8 ms -42.5% 🔴
CPU 0.1% worst 109.9 ms 68.6 ms 41.3 ms -60.3% 🔴
GPU average 18.6 ms 15.8 ms 2.8 ms -17.4% 🔴
GPU 1% worst 23.6 ms 21.3 ms 2.3 ms -10.6% 🔴
GPU 0.1% worst 24.7 ms 22.9 ms 1.8 ms -7.7% 🔴

@AnsisMalins
Copy link
Copy Markdown
Contributor

PR #8018, run #23966339094

Builds: Windows change, Windows baseline, macOS change, macOS baseline

Intel Core i3

Metric Change Baseline Delta Improvement
Samples 2609 2663
CPU average 34.4 ms 33.7 ms 0.7 ms -2.1%
CPU 1% worst 62.7 ms 61.4 ms 1.3 ms -2.1%
CPU 0.1% worst 148.7 ms 180.8 ms -32.1 ms 17.8% 🟢
GPU average 18.7 ms 15.7 ms 3.0 ms -19.1% 🔴
GPU 1% worst 22.9 ms 21.5 ms 1.4 ms -6.7% 🔴
GPU 0.1% worst 24.1 ms 24.7 ms -0.7 ms 2.8%

@biotech77 biotech77 marked this pull request as ready for review April 6, 2026 11:10
@biotech77 biotech77 requested review from a team as code owners April 6, 2026 11:10
Copy link
Copy Markdown
Collaborator

@mikhail-dcl mikhail-dcl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the first part only, will continue later

reactionsPresenter,
messageReactionService,
tooltipPresenter,
msgId => currentChannelService.CurrentChannel?.GetReactions(msgId));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you pass currentChannelService directly instead of wrapping it in a delegate?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you really need this interface?

reactionsPresenter.ShowForMessage(
anchor,
chatEntryView.IsSentByOwnUser,
atlasIndex => messageReactionService.ToggleReaction(messageId, atlasIndex),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These delegates could be wired only once instead of passing and allocate them on every time. The only thing you need a capture for is messageId that you could save in the reactionsPresenter

{
if (pendingReactionMessageId == null) return;

pendingReactionChatEntry?.messageBubbleElement.reactionButtonHoverView?.SetClicked(false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels erroneous to intervene into the internals of the view element and, especially, control it from multiple places:

Image

I would recommend splitting responsibilities somehow so from the code perspective it will be clearly visible which view/presenter is responsible for which part of the view elements and they do not overlap.

I am also concerned that now it's not part of any state, that makes things more obscure

scrollToBottomPresenter.RequestScrollAction += OnRequestScrollAction;
chatHistory.MessageAdded += OnMessageAddedToChatHistory;

if (currentChannelService.CurrentChannel != null)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can't be null according to the nullable notation


private ChatReactionsAtlasConfig? atlasConfig;
private string? ownWalletAddress;
private ChatReactionsMessageConfig? messageConfig;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used

public class ChatReactionsAtlasConfig : ScriptableObject
{
[field: Note("The emoji sprite sheet texture. Must have Read/Write enabled in import settings.")]
[field: SerializeField] public Texture2D Atlas { get; private set; }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How this atlas is generated? Manually?

return result;
}

public Rect GetUVRect(int tileIndex)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to handle UV stuff manually? Isn't it provided from anything like TMP_Sprite Asset?

/// Views are reparented under a hidden root when returned and
/// reparented under a chat entry when acquired.
/// </summary>
public class MessageReactionsViewPool
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it used?

private int emojiIndex;
private bool hiding;

public event Action<int>? Clicked;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's better to use ViewEventBus than popping events through several layers?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

force-build Used to trigger a build on draft PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants