Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: CI

on:
push:
branches: ['**']
pull_request:

# A newer push to the same branch makes the in-flight run obsolete.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
contracts:
name: Contracts (forge)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
submodules: recursive # lib/forge-std

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1

- run: forge --version

- name: Build
run: forge build --sizes

# Runs fully local: no test creates a fork, and foundry.toml deliberately
# leaves `eth_rpc_url` unset so the default profile doesn't force one.
- name: Test
run: forge test -vvv

frontend:
name: Frontend (vitest)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v5

- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install
run: npm ci

# `npm run build` also type-checks, but running tsc first gives a clearer
# failure when the break is types rather than bundling.
- name: Type-check
run: npx tsc -b --force

- name: Test
run: npm test

- name: Build
run: npm run build
8 changes: 7 additions & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.28"
eth_rpc_url = "gnosis"

# NOTE: deliberately no `eth_rpc_url` here. Setting it makes `forge test` fork
# from that endpoint for every run, so the whole suite went over the network and
# the fuzz/invariant tests got 429-ed by the public Gnosis RPC — despite the
# Makefile documenting `test-unit` as "no fork". Targets that do need an
# endpoint pass `--rpc-url` / `--fork-url` explicitly (see `make test-fork`),
# and the aliases below still resolve for those.

[rpc_endpoints]
gnosis = "https://rpc.gnosischain.com"
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/Conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ export default function Conversation() {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [messages.length])

// Viewing the conversation is what marks it read. Idempotent: the store only
// returns rows that actually flipped, so re-renders don't re-send receipts.
useEffect(() => {
if (!handles || !peer) return
handles.markConversationRead(peer).catch(() => {})
}, [handles, peer, messages.length])

const onPaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.files)
if (items.length === 0) return
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/components/GroupConversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export default function GroupConversation() {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [messages.length])

// Viewing the group marks it read; the store only returns rows that actually
// flipped, so this stays idempotent across re-renders.
useEffect(() => {
if (!handles || !groupId) return
handles.markGroupRead(groupId).catch(() => {})
}, [handles, groupId, messages.length])

const onPaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.files)
if (items.length === 0) return
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@ import { useState } from 'react'
import { Link, NavLink, useLocation } from 'react-router'
import { useConversations } from '../hooks/useConversations'
import { useGroups } from '../hooks/useGroups'
import { useGroupConversations } from '../hooks/useGroupConversations'
import { previewOf } from '../lib/messages-store'
import { useMessenger } from '../contexts/MessengerContext'
import CreateGroupModal from './CreateGroupModal'
import EnsName from './EnsName'

/** Unread pill shown on chat-list rows. */
function UnreadBadge({ count }: { count: number }) {
if (count <= 0) return null
return (
<span className="flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-[#ff7a00] text-black text-[11px] font-semibold flex items-center justify-center">
{count > 99 ? '99+' : count}
</span>
)
}

function formatRelative(ts: number): string {
const diff = Date.now() - ts
const m = Math.floor(diff / 60_000)
Expand All @@ -20,6 +31,8 @@ function formatRelative(ts: number): string {
export default function Sidebar() {
const conversations = useConversations()
const groups = useGroups()
const groupSummaries = useGroupConversations()
const unreadByGroup = new Map(groupSummaries.map(g => [g.groupId.toLowerCase(), g.unread]))
const location = useLocation()
const { ready } = useMessenger()
const [showCreateGroup, setShowCreateGroup] = useState(false)
Expand Down Expand Up @@ -70,6 +83,7 @@ export default function Sidebar() {
{g.members.length} member{g.members.length === 1 ? '' : 's'}
</div>
</div>
<UnreadBadge count={unreadByGroup.get(g.id.toLowerCase()) ?? 0} />
</Link>
</li>
)
Expand Down Expand Up @@ -105,9 +119,12 @@ export default function Sidebar() {
{formatRelative(c.lastMessage.ts)}
</div>
</div>
<div className="text-xs text-[#a39690] truncate">
{c.lastMessage.direction === 'out' ? 'You: ' : ''}
{previewOf(c.lastMessage)}
<div className="flex items-center gap-2">
<div className="flex-1 text-xs text-[#a39690] truncate">
{c.lastMessage.direction === 'out' ? 'You: ' : ''}
{previewOf(c.lastMessage)}
</div>
<UnreadBadge count={c.unread} />
</div>
</div>
</Link>
Expand Down
Loading