multiple chats support

This commit is contained in:
kaichaosun 2026-02-06 00:18:37 +08:00
parent 1aefd7eb0f
commit 537d48d5fd
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
9 changed files with 434 additions and 486 deletions

1
.gitignore vendored
View File

@ -29,4 +29,5 @@ target
# Temporary data folder # Temporary data folder
tmp tmp
chat-cli-data

267
Cargo.lock generated
View File

@ -30,12 +30,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.10.0"
@ -127,13 +121,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"crossterm 0.29.0", "crossterm 0.29.0",
"dirs",
"logos-chat", "logos-chat",
"notify",
"ratatui", "ratatui",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
] ]
[[package]] [[package]]
@ -199,7 +190,7 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
"crossterm_winapi", "crossterm_winapi",
"mio", "mio",
"parking_lot", "parking_lot",
@ -215,7 +206,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
"crossterm_winapi", "crossterm_winapi",
"derive_more 2.1.1", "derive_more 2.1.1",
"document-features", "document-features",
@ -376,27 +367,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.12" version = "0.2.12"
@ -541,15 +511,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -674,26 +635,6 @@ dependencies = [
"rustversion", "rustversion",
] ]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.10.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@ -749,42 +690,12 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"libc",
]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.33.0" version = "0.33.0"
@ -891,33 +802,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.10.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.10.0",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -952,12 +836,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@ -1125,7 +1003,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
"cassowary", "cassowary",
"compact_str", "compact_str",
"crossterm 0.28.1", "crossterm 0.28.1",
@ -1146,18 +1024,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
] ]
[[package]] [[package]]
@ -1166,7 +1033,7 @@ version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@ -1189,7 +1056,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
@ -1202,7 +1069,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.11.0", "linux-raw-sys 0.11.0",
@ -1253,15 +1120,6 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -1642,16 +1500,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@ -1683,15 +1531,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
@ -1710,16 +1549,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets 0.52.6", "windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
] ]
[[package]] [[package]]
@ -1737,31 +1567,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc",
"windows_i686_gnu 0.52.6", "windows_i686_gnu",
"windows_i686_gnullvm 0.52.6", "windows_i686_gnullvm",
"windows_i686_msvc 0.52.6", "windows_i686_msvc",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
] ]
[[package]] [[package]]
@ -1770,96 +1583,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.14" version = "0.7.14"

View File

@ -14,6 +14,3 @@ crossterm = "0.29"
anyhow = "1.0" anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
notify = "8.0"
tempfile = "3"
dirs = "6.0"

View File

@ -40,21 +40,22 @@ cargo run -p chat-cli -- bob
| `/help` | Show available commands | | `/help` | Show available commands |
| `/intro` | Generate and display your introduction bundle | | `/intro` | Generate and display your introduction bundle |
| `/connect <user> <bundle>` | Connect to a user using their introduction bundle | | `/connect <user> <bundle>` | Connect to a user using their introduction bundle |
| `/peers` | List available peers |
| `/status` | Show connection status and your address | | `/status` | Show connection status and your address |
| `/clear` | Clear message history | | `/clear` | Clear message history |
| `/quit` or `Esc` | Exit the application | | `/quit` or `Esc` or `Ctrl+C` | Exit the application |
### Sending Messages ### Sending Messages
Simply type your message and press Enter. Messages are automatically encrypted and delivered via the file-based transport. Simply type your message and press Enter. Messages are automatically encrypted and delivered via file-based transport.
## How It Works ## How It Works
### File-Based Transport ### File-Based Transport
Since this is a local demo without a real network, messages are passed between users via files: Messages are passed between users via files in a shared directory:
1. Each user has an "inbox" directory at `~/.local/share/chat-cli/transport/<username>/` 1. Each user has an "inbox" directory at `chat-cli-data/transport/<username>/`
2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox 2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox
3. Bob's client watches for new files and processes incoming messages 3. Bob's client watches for new files and processes incoming messages
4. Files are deleted after processing 4. Files are deleted after processing
@ -62,7 +63,7 @@ Since this is a local demo without a real network, messages are passed between u
### Storage ### Storage
User data (identity keys, chat state) is stored in SQLite databases at: User data (identity keys, chat state) is stored in SQLite databases at:
- `~/.local/share/chat-cli/data/<username>.db` - `chat-cli-data/<username>.db`
### Encryption ### Encryption

View File

@ -1,21 +1,40 @@
//! Chat application logic. //! Chat application logic.
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use logos_chat::{ChatManager, Introduction, StorageConfig}; use logos_chat::{ChatManager, Introduction, StorageConfig};
use serde::{Deserialize, Serialize};
use crate::transport::FileTransport; use crate::transport::FileTransport;
/// A chat message for display. /// A chat message for display.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct DisplayMessage { pub struct DisplayMessage {
pub from_self: bool, pub from_self: bool,
pub content: String, pub content: String,
pub timestamp: u64, pub timestamp: u64,
} }
/// Metadata for a chat session (persisted).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatSession {
pub chat_id: String,
pub remote_user: String,
pub messages: Vec<DisplayMessage>,
}
/// App state that gets persisted.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AppState {
/// Map from remote username to chat session.
pub sessions: HashMap<String, ChatSession>,
/// Currently active chat (remote username).
pub active_chat: Option<String>,
}
/// The chat application state. /// The chat application state.
pub struct ChatApp { pub struct ChatApp {
/// The logos-chat manager. /// The logos-chat manager.
@ -24,50 +43,103 @@ pub struct ChatApp {
pub transport: FileTransport, pub transport: FileTransport,
/// Our introduction bundle (to share with others). /// Our introduction bundle (to share with others).
pub intro_bundle: Option<Introduction>, pub intro_bundle: Option<Introduction>,
/// Current chat ID (if in a conversation). /// Persisted app state.
pub current_chat_id: Option<String>, pub state: AppState,
/// Remote user name (for transport). /// Global messages (shown when no active chat).
pub remote_user: Option<String>, pub global_messages: Vec<DisplayMessage>,
/// Messages to display.
pub messages: Vec<DisplayMessage>,
/// Input buffer. /// Input buffer.
pub input: String, pub input: String,
/// Status message. /// Status message.
pub status: String, pub status: String,
/// Our user name. /// Our user name.
pub user_name: String, pub user_name: String,
/// Path to state file.
state_path: PathBuf,
/// Data directory.
data_dir: PathBuf,
} }
impl ChatApp { impl ChatApp {
/// Create a new chat application. /// Create a new chat application.
pub fn new(user_name: &str, data_dir: &PathBuf, transport_dir: &PathBuf) -> Result<Self> { pub fn new(user_name: &str, data_dir: &PathBuf) -> Result<Self> {
// Create database path // Create database path
let db_path = data_dir.join(format!("{}.db", user_name)); let db_path = data_dir.join(format!("{}.db", user_name));
std::fs::create_dir_all(data_dir)?; std::fs::create_dir_all(data_dir)?;
// Open or create the chat manager with file-based storage // Open or create the chat manager with file-based storage
let manager = ChatManager::open(StorageConfig::File( let manager = ChatManager::open(StorageConfig::File(db_path.to_string_lossy().to_string()))
db_path.to_string_lossy().to_string(), .context("Failed to open ChatManager")?;
))
.context("Failed to open ChatManager")?;
// Create file transport // Create file-based transport
let transport = FileTransport::new(user_name, transport_dir) let transport =
.context("Failed to create transport")?; FileTransport::new(user_name, data_dir).context("Failed to create file transport")?;
// Load persisted state
let state_path = data_dir.join(format!("{}_state.json", user_name));
let state = Self::load_state(&state_path);
// Count existing chats
let chat_count = state.sessions.len();
let status = if chat_count > 0 {
format!(
"Welcome back, {}! {} chat(s) loaded. Type /help for commands.",
user_name, chat_count
)
} else {
format!("Welcome, {}! Type /help for commands.", user_name)
};
Ok(Self { Ok(Self {
manager, manager,
transport, transport,
intro_bundle: None, intro_bundle: None,
current_chat_id: None, state,
remote_user: None, global_messages: Vec::new(),
messages: Vec::new(),
input: String::new(), input: String::new(),
status: format!("Welcome, {}! Type /help for commands.", user_name), status,
user_name: user_name.to_string(), user_name: user_name.to_string(),
state_path,
data_dir: data_dir.clone(),
}) })
} }
/// Load state from file.
fn load_state(path: &PathBuf) -> AppState {
if path.exists() {
if let Ok(contents) = fs::read_to_string(path) {
if let Ok(state) = serde_json::from_str(&contents) {
return state;
}
}
}
AppState::default()
}
/// Save state to file.
fn save_state(&self) -> Result<()> {
let json = serde_json::to_string_pretty(&self.state)?;
fs::write(&self.state_path, json)?;
Ok(())
}
/// Get the current chat session (if any).
pub fn current_session(&self) -> Option<&ChatSession> {
self.state
.active_chat
.as_ref()
.and_then(|name| self.state.sessions.get(name))
}
/// Get the current messages to display.
pub fn messages(&self) -> Vec<&DisplayMessage> {
if let Some(session) = self.current_session() {
session.messages.iter().collect()
} else {
// Show global messages when no active chat
self.global_messages.iter().collect()
}
}
/// Create and display our introduction bundle. /// Create and display our introduction bundle.
pub fn create_intro(&mut self) -> Result<String> { pub fn create_intro(&mut self) -> Result<String> {
let intro = self.manager.create_intro_bundle()?; let intro = self.manager.create_intro_bundle()?;
@ -80,96 +152,165 @@ impl ChatApp {
/// Connect to another user using their introduction bundle. /// Connect to another user using their introduction bundle.
pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> { pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> {
// Check if we already have a chat with this user
if self.state.sessions.contains_key(remote_user) {
return Err(anyhow::anyhow!(
"Already have a chat with {}. Use /switch {} to switch to it.",
remote_user,
remote_user
));
}
let intro = Introduction::try_from(bundle_str.as_bytes()) let intro = Introduction::try_from(bundle_str.as_bytes())
.map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?; .map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?;
let (chat_id, envelopes) = self.manager.start_private_chat(&intro, "👋 Hello!")?; let (chat_id, envelopes) = self.manager.start_private_chat(&intro, "👋 Hello!")?;
self.current_chat_id = Some(chat_id.clone());
self.remote_user = Some(remote_user.to_string());
// Send the envelopes via file transport // Send the envelopes via file transport
for envelope in envelopes { for envelope in envelopes {
self.transport.send(remote_user, envelope.data)?; self.transport.send(remote_user, envelope.data)?;
} }
self.messages.push(DisplayMessage { // Create new session
let mut session = ChatSession {
chat_id: chat_id.clone(),
remote_user: remote_user.to_string(),
messages: Vec::new(),
};
session.messages.push(DisplayMessage {
from_self: true, from_self: true,
content: "👋 Hello!".to_string(), content: "👋 Hello!".to_string(),
timestamp: now(), timestamp: now(),
}); });
self.status = format!("Connected to {}! Chat ID: {}", remote_user, &chat_id[..8]); self.state.sessions.insert(remote_user.to_string(), session);
self.state.active_chat = Some(remote_user.to_string());
self.save_state()?;
self.status = format!("Connected to {}!", remote_user);
Ok(()) Ok(())
} }
/// Switch to a different chat.
pub fn switch_chat(&mut self, remote_user: &str) -> Result<()> {
if self.state.sessions.contains_key(remote_user) {
self.state.active_chat = Some(remote_user.to_string());
self.save_state()?;
self.status = format!("Switched to chat with {}", remote_user);
Ok(())
} else {
Err(anyhow::anyhow!(
"No chat with {}. Use /chats to list available chats.",
remote_user
))
}
}
/// Send a message in the current chat. /// Send a message in the current chat.
pub fn send_message(&mut self, content: &str) -> Result<()> { pub fn send_message(&mut self, content: &str) -> Result<()> {
let chat_id = self.current_chat_id.as_ref() let active = self
.ok_or_else(|| anyhow::anyhow!("No active chat"))?; .state
let remote_user = self.remote_user.as_ref() .active_chat
.ok_or_else(|| anyhow::anyhow!("No remote user"))?; .clone()
.ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?;
let envelopes = self.manager.send_message(chat_id, content.as_bytes())?; let session = self
.state
.sessions
.get(&active)
.ok_or_else(|| anyhow::anyhow!("Chat session not found"))?;
let chat_id = session.chat_id.clone();
let remote_user = session.remote_user.clone();
let envelopes = self.manager.send_message(&chat_id, content.as_bytes())?;
for envelope in envelopes { for envelope in envelopes {
self.transport.send(remote_user, envelope.data)?; self.transport.send(&remote_user, envelope.data)?;
} }
self.messages.push(DisplayMessage { // Update messages
from_self: true, if let Some(session) = self.state.sessions.get_mut(&active) {
content: content.to_string(), session.messages.push(DisplayMessage {
timestamp: now(), from_self: true,
}); content: content.to_string(),
timestamp: now(),
});
}
self.save_state()?;
Ok(()) Ok(())
} }
/// Process incoming messages from transport. /// Process incoming messages from transport.
pub fn process_incoming(&mut self) -> Result<()> { pub fn process_incoming(&mut self) -> Result<()> {
// Check for new messages
while let Some(envelope) = self.transport.try_recv() { while let Some(envelope) = self.transport.try_recv() {
self.handle_incoming_envelope(&envelope)?; self.handle_incoming_envelope(&envelope)?;
} }
Ok(()) Ok(())
} }
/// Process existing messages on startup.
pub fn process_existing(&mut self) -> Result<()> {
let messages = self.transport.process_existing_messages();
for envelope in messages {
self.handle_incoming_envelope(&envelope)?;
}
Ok(())
}
/// Handle an incoming envelope. /// Handle an incoming envelope.
fn handle_incoming_envelope(&mut self, envelope: &crate::transport::FileEnvelope) -> Result<()> { fn handle_incoming_envelope(
&mut self,
envelope: &crate::transport::MessageEnvelope,
) -> Result<()> {
match self.manager.handle_incoming(&envelope.data) { match self.manager.handle_incoming(&envelope.data) {
Ok(content) => { Ok(content) => {
// Update chat state if this is a new chat let from_user = &envelope.from;
if self.current_chat_id.is_none() { let chat_id = content.conversation_id.clone();
self.current_chat_id = Some(content.conversation_id.clone());
self.remote_user = Some(envelope.from.clone()); // Find or create session for this user
self.status = format!("New chat from {}!", envelope.from); if !self.state.sessions.contains_key(from_user) {
// New chat from someone
let session = ChatSession {
chat_id: chat_id.clone(),
remote_user: from_user.clone(),
messages: Vec::new(),
};
self.state.sessions.insert(from_user.clone(), session);
self.state.active_chat = Some(from_user.clone());
self.status = format!("New chat from {}!", from_user);
} }
let message = String::from_utf8_lossy(&content.data).to_string(); let message = String::from_utf8_lossy(&content.data).to_string();
if !message.is_empty() { if !message.is_empty() {
self.messages.push(DisplayMessage { if let Some(session) = self.state.sessions.get_mut(from_user) {
from_self: false, session.messages.push(DisplayMessage {
content: message, from_self: false,
timestamp: envelope.timestamp, content: message,
}); timestamp: envelope.timestamp,
});
}
} }
self.save_state()?;
} }
Err(e) => { Err(e) => {
self.status = format!("Error handling message: {}", e); self.status = format!("Error: {}", e);
} }
} }
Ok(()) Ok(())
} }
/// Add a system message to the current chat (for display only).
fn add_system_message(&mut self, content: &str) {
let msg = DisplayMessage {
from_self: true,
content: content.to_string(),
timestamp: now(),
};
if let Some(active) = &self.state.active_chat.clone() {
if let Some(session) = self.state.sessions.get_mut(active) {
session.messages.push(msg);
return;
}
}
// No active chat - add to global messages
self.global_messages.push(msg);
}
/// Handle a command (starts with /). /// Handle a command (starts with /).
pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> { pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
@ -178,18 +319,22 @@ impl ChatApp {
match command { match command {
"/help" => { "/help" => {
Ok(Some( self.add_system_message("── Commands ──");
"Commands:\n\ self.add_system_message("/intro - Show your introduction bundle");
/intro - Show your introduction bundle\n\ self.add_system_message("/connect <user> <bundle> - Connect to a user");
/connect <user> <bundle> - Connect to a user\n\ self.add_system_message("/chats - List all chats");
/status - Show connection status\n\ self.add_system_message("/switch <user> - Switch to chat with user");
/clear - Clear messages\n\ self.add_system_message("/status - Show connection status");
/quit - Exit".to_string() self.add_system_message("/clear - Clear current chat messages");
)) self.add_system_message("/quit or Esc or Ctrl+C - Exit");
Ok(Some("Help displayed".to_string()))
} }
"/intro" => { "/intro" => {
let bundle = self.create_intro()?; let bundle = self.create_intro()?;
Ok(Some(format!("Your bundle:\n{}", bundle))) self.add_system_message("── Your Introduction Bundle ──");
self.add_system_message(&bundle);
self.add_system_message("Share this bundle with others to connect!");
Ok(Some("Bundle created".to_string()))
} }
"/connect" => { "/connect" => {
let connect_parts: Vec<&str> = args.splitn(2, ' ').collect(); let connect_parts: Vec<&str> = args.splitn(2, ' ').collect();
@ -201,29 +346,70 @@ impl ChatApp {
self.connect(remote_user, bundle)?; self.connect(remote_user, bundle)?;
Ok(Some(format!("Connected to {}", remote_user))) Ok(Some(format!("Connected to {}", remote_user)))
} }
"/chats" => {
let sessions: Vec<_> = self.state.sessions.keys().cloned().collect();
if sessions.is_empty() {
Ok(Some("No chats yet. Use /connect to start one.".to_string()))
} else {
self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len()));
for name in &sessions {
let marker = if Some(name) == self.state.active_chat.as_ref() {
" (active)"
} else {
""
};
self.add_system_message(&format!("{}{}", name, marker));
}
Ok(Some(format!("{} chat(s)", sessions.len())))
}
}
"/switch" => {
if args.is_empty() {
return Ok(Some("Usage: /switch <username>".to_string()));
}
self.switch_chat(args)?;
Ok(Some(format!("Switched to {}", args)))
}
"/peers" => {
let peers = self.transport.list_peers();
if peers.is_empty() {
Ok(Some(
"No peers found. Start another chat-cli instance.".to_string(),
))
} else {
self.add_system_message(&format!("── Peers ({}) ──", peers.len()));
for peer in &peers {
self.add_system_message(&format!("{}", peer));
}
Ok(Some(format!("{} peer(s)", peers.len())))
}
}
"/status" => { "/status" => {
let status = match &self.current_chat_id { let chats = self.state.sessions.len();
Some(id) => format!( let active = self.state.active_chat.as_deref().unwrap_or("none");
"Chat ID: {}\nRemote: {}\nAddress: {}", let status = format!(
&id[..8.min(id.len())], "User: {}\nAddress: {}\nChats: {}\nActive: {}",
self.remote_user.as_deref().unwrap_or("none"), self.user_name,
self.manager.local_address() self.manager.local_address(),
), chats,
None => format!( active
"No active chat\nAddress: {}", );
self.manager.local_address()
),
};
Ok(Some(status)) Ok(Some(status))
} }
"/clear" => { "/clear" => {
self.messages.clear(); if let Some(active) = &self.state.active_chat {
if let Some(session) = self.state.sessions.get_mut(active) {
session.messages.clear();
self.save_state()?;
}
}
Ok(Some("Messages cleared".to_string())) Ok(Some("Messages cleared".to_string()))
} }
"/quit" => { "/quit" => Ok(None),
Ok(None) // Signal to quit _ => Ok(Some(format!(
} "Unknown command: {}. Type /help for commands.",
_ => Ok(Some(format!("Unknown command: {}", command))), command
))),
} }
} }
} }

View File

@ -1,7 +1,7 @@
//! Chat CLI - A terminal chat application using logos-chat. //! Chat CLI - A terminal chat application using logos-chat.
//! //!
//! This application demonstrates how to use the logos-chat library //! This application demonstrates how to use the logos-chat library
//! with a file-based transport for local simulation. //! with file-based transport for local communication.
//! //!
//! # Usage //! # Usage
//! //!
@ -32,6 +32,16 @@ use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
/// Get the data directory (in project folder).
fn get_data_dir() -> PathBuf {
// Use the directory where the binary is or current working directory
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.parent()
.unwrap_or(&PathBuf::from("."))
.join("chat-cli-data")
}
fn main() -> Result<()> { fn main() -> Result<()> {
// Parse arguments // Parse arguments
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
@ -45,26 +55,15 @@ fn main() -> Result<()> {
let user_name = &args[1]; let user_name = &args[1];
// Setup directories // Setup data directory in project folder
let base_dir = dirs::data_local_dir() let data_dir = get_data_dir();
.unwrap_or_else(|| PathBuf::from("."))
.join("chat-cli");
let data_dir = base_dir.join("data");
let transport_dir = base_dir.join("transport");
std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?; std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?;
std::fs::create_dir_all(&transport_dir).context("Failed to create transport directory")?;
println!("Starting chat as '{}'...", user_name); println!("Starting chat as '{}'...", user_name);
println!("Data dir: {:?}", data_dir); println!("Data dir: {:?}", data_dir);
println!("Transport dir: {:?}", transport_dir);
// Create app // Create app
let mut app = app::ChatApp::new(user_name, &data_dir, &transport_dir) let mut app = app::ChatApp::new(user_name, &data_dir).context("Failed to create chat app")?;
.context("Failed to create chat app")?;
// Process any existing messages
app.process_existing()?;
// Initialize terminal UI // Initialize terminal UI
let mut terminal = ui::init().context("Failed to initialize terminal")?; let mut terminal = ui::init().context("Failed to initialize terminal")?;

View File

@ -1,126 +1,134 @@
//! File-based transport for local chat simulation. //! File-based transport for local chat communication.
//! //!
//! Each user has an inbox directory where other users drop messages. //! Messages are passed between users via files in a shared directory.
//! Messages are JSON files with envelope data.
use std::fs::{self, File}; use std::collections::HashSet;
use std::io::{Read, Write}; use std::fs;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A message envelope for file-based transport. /// A message envelope for transport.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEnvelope { pub struct MessageEnvelope {
pub from: String, pub from: String,
pub data: Vec<u8>, pub data: Vec<u8>,
pub timestamp: u64, pub timestamp: u64,
} }
/// File-based transport for simulating message passing. /// File-based transport for local communication.
pub struct FileTransport { pub struct FileTransport {
/// Our user name (used for inbox directory). /// Our user name.
user_name: String, user_name: String,
/// Base directory for all inboxes. /// Base directory for transport files.
base_dir: PathBuf, base_dir: PathBuf,
/// Channel for receiving incoming messages. /// Our inbox directory.
incoming_rx: Receiver<FileEnvelope>, inbox_dir: PathBuf,
/// Watcher handle (kept alive). /// Set of processed message files (to avoid reprocessing).
_watcher: RecommendedWatcher, processed: HashSet<String>,
} }
impl FileTransport { impl FileTransport {
/// Create a new file transport. /// Create a new file transport.
/// pub fn new(user_name: &str, data_dir: &PathBuf) -> Result<Self> {
/// `user_name` is used to create an inbox directory. let base_dir = data_dir.join("transport");
/// `base_dir` is the shared directory where all user inboxes live.
pub fn new(user_name: &str, base_dir: &Path) -> Result<Self> {
let inbox_dir = base_dir.join(user_name); let inbox_dir = base_dir.join(user_name);
// Create our inbox directory
fs::create_dir_all(&inbox_dir) fs::create_dir_all(&inbox_dir)
.with_context(|| format!("Failed to create inbox dir: {:?}", inbox_dir))?; .context("Failed to create inbox directory")?;
let (tx, rx) = mpsc::channel();
let watcher = Self::start_watcher(&inbox_dir, tx)?;
Ok(Self { Ok(Self {
user_name: user_name.to_string(), user_name: user_name.to_string(),
base_dir: base_dir.to_path_buf(), base_dir,
incoming_rx: rx, inbox_dir,
_watcher: watcher, processed: HashSet::new(),
}) })
} }
/// Start watching the inbox directory for new messages. /// Send a message to a specific user.
fn start_watcher(inbox_dir: &Path, tx: Sender<FileEnvelope>) -> Result<RecommendedWatcher> {
let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
if matches!(event.kind, EventKind::Create(_)) {
for path in event.paths {
if path.extension().map(|e| e == "json").unwrap_or(false) {
// Small delay to ensure file is fully written
thread::sleep(Duration::from_millis(50));
if let Ok(envelope) = Self::read_message(&path) {
let _ = tx.send(envelope);
// Delete the message after reading
let _ = fs::remove_file(&path);
}
}
}
}
}
},
Config::default().with_poll_interval(Duration::from_millis(100)),
)?;
watcher.watch(inbox_dir, RecursiveMode::NonRecursive)?;
Ok(watcher)
}
/// Read a message from a file.
fn read_message(path: &Path) -> Result<FileEnvelope> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let envelope: FileEnvelope = serde_json::from_str(&contents)?;
Ok(envelope)
}
/// Send a message to another user's inbox.
pub fn send(&self, to_user: &str, data: Vec<u8>) -> Result<()> { pub fn send(&self, to_user: &str, data: Vec<u8>) -> Result<()> {
let to_inbox = self.base_dir.join(to_user); let target_dir = self.base_dir.join(to_user);
fs::create_dir_all(&to_inbox)?;
// Create target inbox if it doesn't exist
fs::create_dir_all(&target_dir)
.context("Failed to create target inbox")?;
let timestamp = std::time::SystemTime::now() let envelope = MessageEnvelope {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let envelope = FileEnvelope {
from: self.user_name.clone(), from: self.user_name.clone(),
data, data,
timestamp, timestamp: now(),
}; };
let filename = format!("{}_{}.json", self.user_name, timestamp); // Write message to a unique file
let path = to_inbox.join(filename); let filename = format!("{}_{}.json", self.user_name, now());
let filepath = target_dir.join(&filename);
let json = serde_json::to_string_pretty(&envelope)?; let json = serde_json::to_string_pretty(&envelope)?;
let mut file = File::create(&path)?; fs::write(&filepath, json)
file.write_all(json.as_bytes())?; .context("Failed to write message file")?;
file.sync_all()?;
Ok(()) Ok(())
} }
/// Try to receive an incoming message (non-blocking). /// Try to receive an incoming message (non-blocking).
pub fn try_recv(&self) -> Option<FileEnvelope> { pub fn try_recv(&mut self) -> Option<MessageEnvelope> {
self.incoming_rx.try_recv().ok() // List files in our inbox
let entries = match fs::read_dir(&self.inbox_dir) {
Ok(e) => e,
Err(_) => return None,
};
for entry in entries.flatten() {
let path = entry.path();
// Skip non-json files
if path.extension().map(|e| e != "json").unwrap_or(true) {
continue;
}
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
// Skip already processed files
if self.processed.contains(&filename) {
continue;
}
// Try to read and parse the message
if let Ok(contents) = fs::read_to_string(&path) {
if let Ok(envelope) = serde_json::from_str::<MessageEnvelope>(&contents) {
// Mark as processed and delete
self.processed.insert(filename);
let _ = fs::remove_file(&path);
return Some(envelope);
}
}
}
None
}
/// List available peers (users with inbox directories).
pub fn list_peers(&self) -> Vec<String> {
let mut peers = Vec::new();
if let Ok(entries) = fs::read_dir(&self.base_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
if name != self.user_name {
peers.push(name.to_string());
}
}
}
}
}
peers
} }
/// Get our user name. /// Get our user name.
@ -128,26 +136,11 @@ impl FileTransport {
pub fn user_name(&self) -> &str { pub fn user_name(&self) -> &str {
&self.user_name &self.user_name
} }
}
/// Process any existing messages in inbox on startup.
pub fn process_existing_messages(&self) -> Vec<FileEnvelope> { fn now() -> u64 {
let inbox_dir = self.base_dir.join(&self.user_name); std::time::SystemTime::now()
let mut messages = Vec::new(); .duration_since(std::time::UNIX_EPOCH)
.unwrap()
if let Ok(entries) = fs::read_dir(&inbox_dir) { .as_millis() as u64
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(envelope) = Self::read_message(&path) {
messages.push(envelope);
let _ = fs::remove_file(&path);
}
}
}
}
// Sort by timestamp
messages.sort_by_key(|m| m.timestamp);
messages
}
} }

View File

@ -54,9 +54,9 @@ pub fn draw(frame: &mut Frame, app: &ChatApp) {
} }
fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) { fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) {
let title = match &app.remote_user { let title = match app.current_session() {
Some(remote) => format!(" 💬 Chat: {}{} ", app.user_name, remote), Some(session) => format!(" 💬 Chat: {}{} ", app.user_name, session.remote_user),
None => format!(" 💬 {} (no active chat) ", app.user_name), None => format!(" 💬 {} (no active chat - use /connect or /chats) ", app.user_name),
}; };
let header = Paragraph::new(title) let header = Paragraph::new(title)
@ -67,35 +67,37 @@ fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) {
} }
fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) { fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
let remote_name = app
.current_session()
.map(|s| s.remote_user.as_str())
.unwrap_or("Them");
let messages: Vec<ListItem> = app let messages: Vec<ListItem> = app
.messages .messages()
.iter() .iter()
.map(|msg| { .map(|msg| {
let (prefix, style) = if msg.from_self { let (prefix, style) = if msg.from_self {
("You: ", Style::default().fg(Color::Green)) ("You", Style::default().fg(Color::Green))
} else { } else {
let name = app.remote_user.as_deref().unwrap_or("Them"); (remote_name, Style::default().fg(Color::Yellow))
(name, Style::default().fg(Color::Yellow))
}; };
let content = if msg.from_self { let content = Line::from(vec![
Line::from(vec![ Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)),
Span::styled(prefix, style.add_modifier(Modifier::BOLD)), Span::raw(&msg.content),
Span::raw(&msg.content), ]);
])
} else {
Line::from(vec![
Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)),
Span::raw(&msg.content),
])
};
ListItem::new(content) ListItem::new(content)
}) })
.collect(); .collect();
let title = match app.current_session() {
Some(session) => format!(" Messages with {} ", session.remote_user),
None => " Messages ".to_string(),
};
let messages_widget = List::new(messages) let messages_widget = List::new(messages)
.block(Block::default().title(" Messages ").borders(Borders::ALL)); .block(Block::default().title(title).borders(Borders::ALL));
frame.render_widget(messages_widget, area); frame.render_widget(messages_widget, area);
} }
@ -134,6 +136,10 @@ pub fn handle_events(app: &mut ChatApp) -> io::Result<bool> {
match key.code { match key.code {
KeyCode::Esc => return Ok(false), KeyCode::Esc => return Ok(false),
// Handle Ctrl+C
KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
return Ok(false);
}
KeyCode::Enter => { KeyCode::Enter => {
if !app.input.is_empty() { if !app.input.is_empty() {
let input = std::mem::take(&mut app.input); let input = std::mem::take(&mut app.input);
@ -151,7 +157,7 @@ pub fn handle_events(app: &mut ChatApp) -> io::Result<bool> {
app.status = format!("Error: {}", e); app.status = format!("Error: {}", e);
} }
} }
} else if app.current_chat_id.is_some() { } else if app.current_session().is_some() {
if let Err(e) = app.send_message(&input) { if let Err(e) = app.send_message(&input) {
app.status = format!("Send error: {}", e); app.status = format!("Send error: {}", e);
} }

View File

@ -39,7 +39,7 @@ impl TryFrom<&[u8]> for Introduction {
let str_value = String::from_utf8_lossy(value); let str_value = String::from_utf8_lossy(value);
let parts: Vec<&str> = str_value.splitn(3, ':').collect(); let parts: Vec<&str> = str_value.splitn(3, ':').collect();
if parts[0] != "Bundle" { if parts.len() < 3 || parts[0] != "Bundle" {
return Err(ChatError::BadBundleValue( return Err(ChatError::BadBundleValue(
"not recognized as an introduction bundle".into(), "not recognized as an introduction bundle".into(),
)); ));
@ -51,7 +51,7 @@ impl TryFrom<&[u8]> for Introduction {
.map_err(|_| ChatError::InvalidKeyLength)?; .map_err(|_| ChatError::InvalidKeyLength)?;
let installation_key = PublicKey::from(installation_bytes); let installation_key = PublicKey::from(installation_bytes);
let ephemeral_bytes: [u8; 32] = hex::decode(parts[1]) let ephemeral_bytes: [u8; 32] = hex::decode(parts[2])
.map_err(|_| ChatError::BadParsing("ephemeral_key"))? .map_err(|_| ChatError::BadParsing("ephemeral_key"))?
.try_into() .try_into()
.map_err(|_| ChatError::InvalidKeyLength)?; .map_err(|_| ChatError::InvalidKeyLength)?;