Compare commits

...

46 Commits

Author SHA1 Message Date
glyph bd5e789919 Merge pull request 'Fixes and improvements courtesy of Daan (black-puppydog)' (#6) from black_puppydog_fixes into main
Reviewed-on: #6
2022-10-02 16:43:02 +00:00
glyph b4281db1f2 add message of thanks to daan 2022-10-02 17:41:15 +01:00
glyph 465919792f don't treat already-not-following as error 2022-10-02 17:37:22 +01:00
glyph 61f0883841 fix use of incorrect macro 2022-10-02 17:32:52 +01:00
glyph 504ca08076 fix premature use of 2022-10-02 17:31:44 +01:00
glyph 6f1536a09d add instruction to import bincode dependency 2022-10-02 17:28:24 +01:00
glyph c42af06f02 add instruction to import serde 2022-10-02 17:25:46 +01:00
glyph f8009163dd add missing Path import 2022-10-02 17:20:34 +01:00
glyph 473e9ac225 fix semicolon 2022-10-02 17:19:50 +01:00
glyph 3946f2f7dc add instructions to include sled and xdg dependencies 2022-10-02 17:17:00 +01:00
glyph 59947f6166 add missing RelationshipQuery import 2022-10-02 17:11:14 +01:00
glyph 66a5f8b58d add instruction to declare utils module in main.rs 2022-10-02 17:08:50 +01:00
glyph 5c0d4726c0 remove duplicate route mounting 2022-10-02 17:03:14 +01:00
glyph 27b2a32572 missing character in use statement dependency 2022-10-02 17:01:38 +01:00
glyph 12fb6cd9fa Merge pull request 'Ignore dead code warnings' (#5) from ignore_dead_code_warning into main
Reviewed-on: #5
2022-10-02 15:59:07 +00:00
glyph 4f93096c2b Merge pull request 'Include README as Rust doc comment' (#4) from doc_incl_string into main
Reviewed-on: #4
2022-10-02 15:57:40 +00:00
glyph d7d4f0e6b3 ignore dead code warnings 2022-10-02 16:54:02 +01:00
glyph 9e86212404 include readme as rust lang doc comment 2022-10-02 16:48:17 +01:00
glyph fb3bae27b7 updat index links 2022-09-09 11:05:00 +01:00
glyph 7548962ff6 fix indentation and make minor wording improvements 2022-09-09 11:04:50 +01:00
glyph 0548c6e42a add clarification about post deletion 2022-09-09 10:56:40 +01:00
glyph 771eb40452 remove dependency comment from manifest 2022-09-09 10:50:57 +01:00
glyph 5e5a48ae65 update manifest and lockfile 2022-09-09 10:49:14 +01:00
glyph 0a714ec5c4 add part 10 draft 2022-09-09 10:48:57 +01:00
glyph 1ad255325b add part 9 draft 2022-09-09 10:48:45 +01:00
glyph 4bb354f239 small addition 2022-09-09 10:48:30 +01:00
glyph 767f07ea24 Merge pull request 'Add the eighth installment: Post List and Post Content' (#3) from part_8 into main
Reviewed-on: #3
2022-09-08 13:07:13 +00:00
glyph d48d449bba add note about running the application 2022-09-08 14:05:53 +01:00
glyph 596ddcf839 fix typos 2022-09-08 14:03:50 +01:00
glyph eb24df9ce6 add part 8 draft tutorial 2022-09-08 13:54:36 +01:00
glyph 8593e17294 Merge pull request 'Add the seventh installment: Latest Posts and Names' (#2) from part_7 into main
Reviewed-on: #2
2022-09-07 16:28:03 +00:00
glyph 3ae90af617 comment the unreleased installments in the manifest 2022-09-07 17:24:48 +01:00
glyph aff1e5777c add missing code and fix indentation and typos 2022-09-07 17:22:56 +01:00
glyph 97693bf6d9 remove part 6 (renamed) 2022-09-07 16:59:20 +01:00
glyph d006aa9b46 add part 7 draft tutorial 2022-09-07 16:59:00 +01:00
glyph a820ed231c change the name of part 6 2022-09-07 16:58:09 +01:00
glyph 40826cfd1c Merge pull request 'Add the sixth installment: Update UI and Display Peers List' (#1) from part_6 into main
Reviewed-on: #1
2022-09-07 09:00:06 +00:00
glyph 6ab158008c comment the unreleased installments in the manifest 2022-09-07 09:59:05 +01:00
glyph 6f648bde54 fix indentation 2022-09-07 09:57:22 +01:00
glyph 6318236a50 fix typos and indentation 2022-09-07 09:56:04 +01:00
glyph 4afc45e5fd add part 6 draft tutorial 2022-09-07 09:44:11 +01:00
glyph 36e6270d61 merge part 5 draft and part 4 updates 2022-09-05 14:03:17 +01:00
glyph aba48580d0 add call for contributions 2022-09-05 10:18:01 +01:00
glyph 0e868b43eb update manifest and gitignore to expose all installments, update link to part 2 in readme 2022-09-05 10:11:57 +01:00
glyph 54874c61d8 Revert "hide parts 2, 3 and 4"
This reverts commit ce693cadc3.
2022-09-05 10:09:00 +01:00
glyph ce693cadc3 hide parts 2, 3 and 4 2022-08-26 10:26:30 +01:00
98 changed files with 5854 additions and 106 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
notes
target

227
Cargo.lock generated
View File

@ -39,18 +39,18 @@ dependencies = [
[[package]]
name = "aho-corasick"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
@ -101,9 +101,9 @@ dependencies = [
[[package]]
name = "async-global-executor"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5262ed948da60dd8956c6c5aca4d4163593dddb7b32d73267c93dab7b2e98940"
checksum = "0da5b41ee986eed3f524c380e6d64965aea573882a8907682ad100f7859305ca"
dependencies = [
"async-channel",
"async-executor",
@ -111,16 +111,16 @@ dependencies = [
"async-lock",
"blocking",
"futures-lite",
"num_cpus",
"once_cell",
]
[[package]]
name = "async-io"
version = "1.7.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e18f61464ae81cde0a23e713ae8fd299580c54d697a35820cfd0625b8b0e07"
checksum = "83e21f3a490c72b3b0cf44962180e60045de2925d8dff97918f7ee43c8f637c7"
dependencies = [
"autocfg",
"concurrent-queue",
"futures-lite",
"libc",
@ -145,11 +145,12 @@ dependencies = [
[[package]]
name = "async-process"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf2c06e30a24e8c78a3987d07f0930edf76ef35e027e7bdb063fccafdad1f60c"
checksum = "02111fd8655a613c25069ea89fc8d9bb89331fa77486eb3bc059ee757cfa481c"
dependencies = [
"async-io",
"autocfg",
"blocking",
"cfg-if 1.0.0",
"event-listener",
@ -314,9 +315,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "block-buffer"
version = "0.10.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e"
dependencies = [
"generic-array",
]
@ -346,9 +347,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.10.0"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3"
checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d"
[[package]]
name = "byteorder"
@ -461,7 +462,7 @@ dependencies = [
"rand",
"sha2",
"subtle",
"time 0.3.13",
"time 0.3.14",
"version_check",
]
@ -473,9 +474,9 @@ checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
[[package]]
name = "cpufeatures"
version = "0.2.2"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320"
dependencies = [
"libc",
]
@ -624,9 +625,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797"
[[package]]
name = "encoding_rs"
@ -731,9 +732,9 @@ checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"
[[package]]
name = "futures"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c"
dependencies = [
"futures-channel",
"futures-core",
@ -746,9 +747,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
checksum = "30bdd20c28fadd505d0fd6712cdfcb0d4b5648baf45faef7f852afb2399bb050"
dependencies = [
"futures-core",
"futures-sink",
@ -756,15 +757,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf"
[[package]]
name = "futures-executor"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab"
dependencies = [
"futures-core",
"futures-task",
@ -773,9 +774,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68"
[[package]]
name = "futures-lite"
@ -794,9 +795,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17"
dependencies = [
"proc-macro2",
"quote",
@ -805,21 +806,21 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
checksum = "21b20ba5a92e727ba30e72834706623d94ac93a725410b6a6b6fbc1b07f7ba56"
[[package]]
name = "futures-task"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1"
[[package]]
name = "futures-util"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90"
dependencies = [
"futures-channel",
"futures-core",
@ -977,9 +978,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.13"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57"
checksum = "5ca32592cf21ac7ccab1825cd87f6c9b3d9022c44d086172ed0966bec8af30be"
dependencies = [
"bytes",
"fnv",
@ -1057,9 +1058,9 @@ dependencies = [
[[package]]
name = "httparse"
version = "1.7.1"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "httpdate"
@ -1099,13 +1100,14 @@ dependencies = [
[[package]]
name = "iana-time-zone"
version = "0.1.46"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2bfd338099682614d3ee3fe0cd72e0b6a41ca6a87f6a74a3bd593c91650501"
checksum = "4c495f162af0bf17656d0014a0eded5f3cd2f365fdd204548c2869db89359dc7"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"js-sys",
"once_cell",
"wasm-bindgen",
"winapi 0.3.9",
]
@ -1277,9 +1279,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.129"
version = "0.2.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64de3cc433455c14174d42e554d4027ee631c4d046d43e3ecc6efc4636cdc7a7"
checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5"
[[package]]
name = "libsodium-sys"
@ -1295,9 +1297,9 @@ dependencies = [
[[package]]
name = "lock_api"
version = "0.4.7"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53"
checksum = "9f80bf5aacaf25cbfc8210d1cfb718f2bf3b11c4c54e5afe36c236853a8ec390"
dependencies = [
"autocfg",
"scopeguard",
@ -1511,9 +1513,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.13.0"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1"
checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0"
[[package]]
name = "opaque-debug"
@ -1652,6 +1654,78 @@ dependencies = [
"xdg",
]
[[package]]
name = "part_6_ui_layout"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_7_latest_posts"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_8_ui_posts"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_9_read_delete"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "pear"
version = "0.2.3"
@ -1784,10 +1858,11 @@ checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]]
name = "polling"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685404d509889fade3e86fe3a5803bca2ec09b0c0778d5ada6ec8bf7a8de5259"
checksum = "899b00b9c8ab553c743b3e11e87c5c7d423b2a2de229ba95b24a756344748011"
dependencies = [
"autocfg",
"cfg-if 1.0.0",
"libc",
"log",
@ -1978,7 +2053,7 @@ dependencies = [
"serde",
"state",
"tempfile",
"time 0.3.13",
"time 0.3.14",
"tokio",
"tokio-stream",
"tokio-util",
@ -2038,7 +2113,7 @@ dependencies = [
"smallvec",
"stable-pattern",
"state",
"time 0.3.13",
"time 0.3.14",
"tokio",
"uncased",
]
@ -2078,18 +2153,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "serde"
version = "1.0.143"
version = "1.0.144"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e8e5d5b70924f74ff5c6d64d9a5acd91422117c60f48c4e07855238a254553"
checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.143"
version = "1.0.144"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d8e8de557aee63c26b85b947f5e59b690d0454c753f3adeb5cd7835ab88391"
checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00"
dependencies = [
"proc-macro2",
"quote",
@ -2098,9 +2173,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.83"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38dd04e3c8279e75b31ef29dbdceebfe5ad89f4d0937213c53f7d49d01b3d5a7"
checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44"
dependencies = [
"indexmap",
"itoa",
@ -2121,9 +2196,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.10.2"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676"
checksum = "cf9db03534dff993187064c4e0c05a5708d2a9728ace9a8959b77bedf415dac5"
dependencies = [
"cfg-if 1.0.0",
"cpufeatures",
@ -2206,9 +2281,9 @@ checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1"
[[package]]
name = "socket2"
version = "0.4.4"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0"
checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd"
dependencies = [
"libc",
"winapi 0.3.9",
@ -2293,18 +2368,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.32"
version = "1.0.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994"
checksum = "8c1b05ca9d106ba7d2e31a9dab4a64e7be2cce415321966ea3132c49a656e252"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.32"
version = "1.0.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21"
checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487"
dependencies = [
"proc-macro2",
"quote",
@ -2333,9 +2408,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.13"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db76ff9fa4b1458b3c7f077f3ff9887394058460d21e634355b273aaf11eea45"
checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b"
dependencies = [
"itoa",
"libc",
@ -2351,9 +2426,9 @@ checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792"
[[package]]
name = "tokio"
version = "1.20.1"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a8325f63a7d4774dd041e363b2409ed1c5cbbd0f867795e661df066b2b0a581"
checksum = "89797afd69d206ccd11fb0ea560a44bbb87731d020670e79416d442919257d42"
dependencies = [
"autocfg",
"bytes",
@ -2496,18 +2571,18 @@ checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "ubyte"
version = "0.10.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a58e29f263341a29bb79e14ad7fda5f63b1c7e48929bad4c685d7876b1d04e94"
checksum = "c81f0dae7d286ad0d9366d7679a77934cfc3cf3a8d67e82669794412b2368fe6"
dependencies = [
"serde",
]
[[package]]
name = "ucd-trie"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89570599c4fe5585de2b388aab47e99f7fa4e9238a1399f707a02e356058141c"
checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81"
[[package]]
name = "uncased"

View File

@ -5,5 +5,9 @@ members = [
"part_2_subscribe_form",
"part_3_database_follows",
"part_4_posts_streams",
"part_5_task_loop"
"part_5_task_loop",
"part_6_ui_layout",
"part_7_latest_posts",
"part_8_ui_posts",
"part_9_read_delete"
]

View File

@ -9,14 +9,15 @@ Author: [@glyph](https://mycelial.technology/)
## Installments
- [Part 1: Sbot and Web Server](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_1_sbot_rocket)
- Part 2: Subscription Form and Key Validation
- Part 3: Database and Follows
- Part 4: Posts and Message Streams
- Part 5: Task Loop
- Part 6: Display Peers and Update UI
- Part 7: Fetch Latest Posts
- Part 8: Read, Unread and Delete Posts
- Part 9: Extension Ideas and Conclusion
- [Part 2: Subscription Form and Key Validation](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_2_subscribe_form)
- [Part 3: Database and Follows](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_3_database_follows)
- [Part 4: Posts and Message Streams](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_4_posts_streams)
- [Part 5: Task Loop](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_5_task_loop)
- [Part 6: UI Layout and Peers List](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_6_ui_layout)
- [Part 7: Latest Posts and Names](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_7_latest_posts)
- [Part 8: Post List and Post Content](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_8_ui_posts)
- [Part 9: Read, Unread and Delete](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_9_read_delete)
- [Part 10: Extension Ideas and Conclusion](https://git.coopcloud.tech/glyph/lykin_tutorial/src/branch/main/part_10_extension_ideas)
## Links
@ -28,6 +29,8 @@ Author: [@glyph](https://mycelial.technology/)
I am grateful to the Butts who voted to fund this work, all contributors to the [SSBC](https://opencollective.com/secure-scuttlebutt-consortium) and Erick Lavoie (`@elavoie` / `@IgYpd+tCtXnlE2tYX/8rR2AGt+P8svC98WH3MdYAa8Y=.ed25519`) in particular - both for partially funding this work and for developing and overseeing the community grant process.
A big thank you to Daan Wynen (aka. [black-puppydog](https://github.com/black-puppydog)) for providing feedback and fixes for code examples in this tutorial series.
## Contribute
If you wish to support my work on the Rust Scuttlebutt ecosystem, please consider contributing to my [Liberapay account](https://liberapay.com/glyph/).

View File

@ -0,0 +1,49 @@
# lykin tutorial
## Part 10: Extension Ideas and Conclusion
### Introduction
The application we've written is very basic and still quite rough around the edges. No doubt, there are things you would have done differently if you had written such an application. Rather than being a conclusion, I hope the end of this tutorial series will be the beginning of further development of lykin in many different directions. Please feel free to fork the repo and shape the application as you wish! Here I share a few ideas for improvements and extensions.
### Improvements
- Error handling and reporting
- Replace all instances of `unwrap()` with robust error handling to ensure the application never panics
- Report useful information via the web interface when errors occur
- For example, when a connection attempt with the sbot fails
- Interface interactivity
- Write JSON endpoints to serve peer and post data
- Use JavaScript written with unobstructive principles to update the DOM without having to reload the entire page
### Extensions
- Batch selection of posts
- Delete or change read / unread status of many posts at once
- Bookmarks
- Bookmark a post(s) for later reading
- View only the bookmarked posts for a selected peer
- Interactions
- React to a post (publish a `like` or other reaction)
- Reply to a post (publish a message referencing the root post)
- Threads
- Display thread replies in addition to root posts
- Formatting
- Render the markdown of post text as HTML
- Display images where blob references are included in the post text
### Conclusion
I hope I've given you a few ideas for further development and that you feel excited and equipped to continue playing with this application. Make it your own! Modify the CSS, add new features, remove what you don't like...have fun.
If you do end up taking this futher, I'd love to hear about it! Please send me an email on `glyph@mycelial.technology` or on Scuttlebutt (`@HEqy940T6uB+T+d9Jaa58aNfRzLx9eRWqkZljBmnkmk=.ed25519`). You could also post an issue on the `lykin` git repo.
Please also contact me with any feedback you may have about this tutorial series.
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -238,3 +238,7 @@ That's all for the first part of this tutorial series. We installed and configur
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
use std::env;
use golgi::{sbot::Keystore, Sbot};

View File

@ -105,7 +105,7 @@ use rocket_dyn_templates::Template;
async fn rocket() -> _ {
rocket::build()
.attach(Template::fairing())
.mount("/", routes![home, subscribe_form, unsubscribe_form])
.mount("/", routes![home])
}
```
@ -168,7 +168,7 @@ Then add the subscription route handlers to the existing code in `src/routes.rs`
```rust
use log::info;
use rocket::{form::Form, get, post, response::Redirect, uri, FromFor}
use rocket::{form::Form, get, post, response::Redirect, uri, FromForm}
#[derive(FromForm)]
pub struct PeerForm {
@ -244,6 +244,12 @@ pub fn validate_public_key(public_key: &str) -> Result<(), String> {
}
```
The `utils` module needs to registered in `main.rs`. Without this addition, the module will not be compiled and the `validate_public_key` function will not be available to the rest of our program. Add this line at the top of `src/main.rs`:
```rust
mod utils;
```
Now the validation function can be called from our subscribe / unsubscribe route handlers, allowing us to ensure the provided public key is valid before using it to make further RPC calls to the sbot:
`src/routes.rs`
@ -339,6 +345,8 @@ In order to do this using the `golgi` RPC library, we have to construct a `Relat
`src/sbot.rs`
```rust
use golgi::api::friends::RelationshipQuery;
pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
@ -434,3 +442,7 @@ In the next installment we'll add a key-value store and learn how to follow and
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
mod routes;
mod sbot;
mod utils;

View File

@ -3,8 +3,6 @@ name = "part_3_database_follows"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
bincode = "1.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }

View File

@ -35,6 +35,8 @@ We're going to use [sled](https://sled.rs/) in order to store the data used by o
`src/db.rs`
```rust
use std::path::Path;
use sled::{Db, Tree};
#[derive(Clone)]
@ -62,12 +64,19 @@ impl Database {
}
```
The initialisation method requires a `Path` in order to open / create the database. We can use the [xdg](https://crates.io/crates/xdg) crate to generate a path using the XDG Base Directory specification. Open `src/main.rs` and add the following code:
The initialisation method requires a `Path` in order to open / create the database. We can use the [xdg](https://crates.io/crates/xdg) crate to generate a path using the XDG Base Directory specification. Add the dependencies for `sled` and `xdg` to `Cargo.toml`:
```toml
sled = "0.34"
xdg = "2.4.1"
```
Now open `src/main.rs` and add the following code:
```rust
mod db;
use xdg::BaseDirectories.
use xdg::BaseDirectories;
use crate::{db::Database, routes::*};
@ -109,6 +118,14 @@ pub struct Peer {
}
```
Before our new code will compile we need to add `serde` to our manifest file. Serde is used to *ser*ialize and *de*serialize data (like our `Peer` struct defined above).
`Cargo.toml`
```toml
serde = "1"
```
In addition to the datastructure itself, we'll implement a couple of methods to be able to create and modify instances of the `struct`.
`src/db.rs`
@ -171,6 +188,12 @@ impl Database {
You'll notice in the above code snippet that we're serialising the peer data as bincode before inserting it. The sled database we're using expects values in the form of a byte vector; bincode thus provides a neat way of storing complex datastructures (such as our `Peer` `struct`).
Add the `bincode` dependency to `Cargo.toml` and then test that everything compiles correctly:
```toml
bincode = "1.3"
```
That's enough database code for the moment. Now we can return to our Scuttlebutt-related code and complete the peer subscription flows.
### Follow / Unfollow a Peer
@ -197,33 +220,33 @@ At this point we have the capability to check whether we follow a peer, to add a
```rust
// Update this match block in `subscribe_form`
match sbot::is_following(&whoami, remote_peer).await {
match sbot::is_following(&whoami, &peer.public_key).await {
Ok(status) if status.as_str() == "false" => {
// If we are not following the peer, call the `follow_peer` method.
match sbot::follow_peer(remote_peer).await {
Ok(_) => info!("Followed peer {}", &remote_peer),
Err(e) => warn!("Failed to follow peer {}: {}", &remote_peer, e),
match sbot::follow_peer(&peer.public_key).await {
Ok(_) => info!("Followed peer {}", &peer.public_key),
Err(e) => warn!("Failed to follow peer {}: {}", &peer.public_key, e),
}
}
Ok(status) if status.as_str() == "true" => {
info!(
"Already following peer {}. No further action taken",
&remote_peer
&peer.public_key
)
}
_ => (),
}
// Update this match block in `unsubscribe_form`
match sbot::is_following(&whoami, remote_peer).await {
match sbot::is_following(&whoami, &peer.public_key).await {
Ok(status) if status.as_str() == "true" => {
// If we are following the peer, call the `unfollow_peer` method.
info!("Unfollowing peer {}", &remote_peer);
match sbot::unfollow_peer(remote_peer).await {
info!("Unfollowing peer {}", &peer.public_key);
match sbot::unfollow_peer(&peer.public_key).await {
Ok(_) => {
info!("Unfollowed peer {}", &remote_peer);
info!("Unfollowed peer {}", &peer.public_key);
}
Err(e) => warn!("Failed to unfollow peer {}: {}", &remote_peer, e),
Err(e) => warn!("Failed to unfollow peer {}: {}", &peer.public_key, e),
}
}
_ => (),
@ -269,7 +292,7 @@ pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
Ok(())
}
Err(e) => {
let err_msg = warn!("Failed to follow peer {}: {}", &remote_peer, e);
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
@ -300,6 +323,14 @@ pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
pub async fn unfollow_if_following(remote_peer: &str) {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => {
info!(
"Not currently following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {
@ -517,3 +548,7 @@ In the next installment we'll deal primarily with Scuttlebutt messages - learnin
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;

View File

@ -103,6 +103,13 @@ pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => {
info!(
"Not currently following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {

View File

@ -3,8 +3,6 @@ name = "part_4_posts_streams"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = "1.10"
bincode = "1.3"

View File

@ -335,3 +335,7 @@ In the next tutorial installment we'll write an asynchronous task loop to run ba
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -54,6 +54,7 @@ pub struct Post {
impl Post {
// Create a new instance of the Post struct. A default value of `false` is
// set for `read`.
#[allow(dead_code)]
pub fn new(
key: String,
text: String,
@ -135,6 +136,7 @@ impl Database {
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
#[allow(dead_code)]
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
@ -146,6 +148,7 @@ impl Database {
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
#[allow(dead_code)]
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
let mut post_batch = Batch::default();

View File

@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;

View File

@ -132,6 +132,7 @@ pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
/// Return a stream of messages authored by the given public key.
///
/// This returns all messages regardless of type.
#[allow(dead_code)]
pub async fn get_message_stream(
public_key: &str,
sequence_number: u64,
@ -162,6 +163,7 @@ pub async fn get_name(public_key: &str) -> Result<String, String> {
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
#[allow(dead_code)]
pub async fn get_root_posts(
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
) -> (u64, Vec<Post>) {

View File

@ -3,8 +3,6 @@ name = "part_5_task_loop"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = "1.10"
bincode = "1.3"

View File

@ -155,6 +155,7 @@ impl Database {
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
#[allow(dead_code)]
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);

View File

@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;

View File

@ -43,6 +43,7 @@ async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
pub enum Task {
Cancel,
FetchAllPosts(String),
#[allow(dead_code)]
FetchLatestName(String),
}

View File

@ -0,0 +1,18 @@
[package]
name = "part_6_ui_layout"
version = "0.1.0"
edition = "2021"
[dependencies]
async-std = "1.10"
bincode = "1.3"
chrono = "0.4"
futures = "0.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
log = "0.4"
rocket = "0.5.0-rc.1"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
serde = "1"
serde_json = "1"
sled = "0.34"
xdg = "2.4.1"

312
part_6_ui_layout/README.md Normal file
View File

@ -0,0 +1,312 @@
# lykin tutorial
## Part 6: UI Layout and Peers List
### Introduction
Up to this point in the series we've been primarily focused on backend development; we've created a webserver, setup a key-value store, written functions for interacting with an sbot instance and made a task loop to run background processes. It's time to focus on the UI of our application.
Today we'll write Tera HTML templates and create the layout of our user interface using CSS. We will then begin to populate the templates with data from our key-value store, such as the list of peers we're subscribed to. This is an exciting phase in the development of our application. Let's begin!
### Outline
Here's what we'll tackle in this sixth part of the series:
- Define layout shape
- Download stylesheet and icons
- Mount the fileserver
- Create layout in base template
- Create templates
- Navigation bar
- Peer list
- Post list
- Post content
- Populate peer list with data
### Define Layout Shape
Before getting started with code, it might be helpful to know the shape of the layout we'll be building in this installment.
The layout is composed of a topbar for navigation, a peers column on the left and a column of posts and post content on the right. Here's a diagram to illustrate the basic shape:
```text
┌───────────────────────────────────────────────────┐
│ Navigation │
├──────────────┬────────────────────────────────────┤
│ Peer List │ Post List │
│ │ │
│ │ │
│ ├────────────────────────────────────┤
│ │ Post Content │
│ │ │
│ │ │
│ │ │
│ │ │
└──────────────┴────────────────────────────────────┘
```
### Download Icons and Stylesheet
We are going to use CSS grid to create the layout of our user interface. I am not going to deal with CSS in-detail in this tutorial so you may want to refer to [A Complete Guide to Grid](https://css-tricks.com/snippets/css/complete-guide-grid/), authored by Chris House on CSS-Tricks, to fill any gaps in your understanding. We will simply download the stylesheet and icons so that we can focus on the rest of the application.
We begin by creating a `static` directory in the root directory of our application. Next, we create subdirectories named `css` and `icons` inside the static directory. Like so:
```text
.
├── static
│ ├── css
│ └── icons
```
Now we can download the assets from the [lykin repo](https://git.coopcloud.tech/glyph/lykin):
```bash
# Ensure you are calling these commands from the root directory.
# You can download the files manually if you do not have wget.
# ...
# Download the CSS stylesheet:
wget -O static/css/lykin.css https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/css/lykin.css
# Move into the icons subdirectory:
cd static/icons
# Download the icons:
wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/delete_post.png
wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/download.png
wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/read_post.png
wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/unread_post.png
# Move back to the root directory:
cd ../..
```
**Note:** The icons we're using were created by [Kiranshastry](https://www.flaticon.com/authors/kiranshastry) and can be found on Flaticon.
### Mount the Fileserver
In order to be able to serve the CSS and icons, we need to mount a fileserver to our Rocket application and provide the path to the assets:
`src/main.rs`
```rust
use rocket::fs::{FileServer, relative};
#[launch]
async fn rocket() -> _ {
// ...
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount("/", routes![home, subscribe_form, unsubscribe_form])
// Mount the fileserver and set a relative path with `static` as root.
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}
```
### Create Layout in Base Template
Now that the assets and fileserver are in place, we can turn our attention to the templates. Let's begin by modifying the base HTML template we wrote previously. In it, we're going to create a grid container and include (`import`) the templates representing each section of the layout. We will then create the templates for each section of the layout.
`templates/base.html.tera`
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>
```
### Create Navigation Bar Template
With the base layout in place, we can begin to populate the constituent templates. The navigation / topbar consists of a row of four icons followed by a form for subscribing to and unsubscribing from peers. Clicking on each icon will eventually perform an action: download the latest posts, mark a post as 'read', mark a post as 'unread' and delete a post. We'll create the routes and handlers for those actions later in the series. For now, it's enough to have the icons without any associated actions.
You're invited to take a peek at the stylesheet (`lykin.css`) if you're curious about any of the classes used in the template, such as `disabled` or `flex-container`.
`templates/topbar.html.tera`
```html
<div class="nav">
<div class="flex-container">
<a class="disabled icon" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
<input type="text" id="public_key" name="public_key" maxlength=53>
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>
```
The `{% ... %}` syntax in the template code is Tera syntax (inspired by Jinja2 and Django templates). Consult the [documentation](https://tera.netlify.app/docs/) if you wish to know more. We will add similar control-flow syntax later in the tutorial series to selectively set the `href` tags of the anchor elements and to enable or disable the navigation elements.
### Create Peer List Template
This one couldn't be much simpler. We define a `div` element for our list of peers and populate an unordered list. We first try to display the name of the peer and fallback to the public key if the `name` string is empty. Each peer in this template corresponds with an instance of the `Peer` struct defined in our `src/db.rs` file, hence the `name` and `public_key` fields.
`templates/peer_list.html.tera`
```html
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</li>
{%- endfor %}
</ul>
</div>
```
### Create Post List Template
Now we'll write another simple `for` loop to display a list of posts. Eventually we'll update this template to display the subject of each post authored by the selected peer. Clicking on a peer in the peer list will serve as the trigger to update the selected peer variable, allowing us to define whose posts we should be displaying in this list.
`templates/post_list.html.tera`
```html
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
Subject placeholder
{%- endfor %}
</ul>
{% endif %}
</div>
```
### Create Post Content Template
Finally, we'll write the template to display the content of a selected post.
`templates/post_content.html.tera`
```html
<div class="content">
{% if post %}
{{ post.text }}
{% endif %}
</div>
```
### Populate Peer List with Data
If we run our application at this point and visit `localhost:8000` in a browser we receive a `500: Internal Server Error`. The output in the Rocket application logs points to the problem:
```text
>> Error rendering Tera template 'base'.
>> Failed to render 'base'
>> Variable `peers` not found in context while rendering 'peer_list'
>> Template 'base' failed to render.
>> Outcome: Failure
```
The `peer_list.html.tera` template expects a `peers` variable which has not been provided. In other words, the template has not been provided with the context it requires to render. What we need to do is revisit our `home` route handler and provide the context by querying our key-value store for a list of peers.
`src/routes.rs`
```rust
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
// Retrieve the list of peers to whom we subscribe.
let peers = db.get_peers();
// Render the template with `peers` and `flash`.
Template::render("base", context! { peers: peers, flash: flash })
}
```
Great, the template will now be hydrated with the data it expects. There's just one more problem: the `db.get_peers()` method doesn't exist yet. Let's write it now:
`src/db.rs`
```rust
impl Database {
// ...
// Get a list of all peers in the peer tree. The byte value for each
// peer entry is deserialized from bincode into an instance of the Peer
// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
// Define an empty vector to store the list of peers.
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
// Add a peer to the peers vector.
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
}
```
The above method is very similar to the `get_peer` method we define previously. However, instead of retrieving a specific peer from the peer database tree, we iterate over all key-value pairs in the tree and push the deserialized value to a vector.
Run the application, visit `localhost:8000` in your browser and you should see a beautiful, colourful layout! After ensuring your instance of go-sbot is running, try to subscribe and unsubscribe to some peers to test things out. Feel free to play around with the styles in `static/css/lykin.css` if you wish to change the colours or other aspects of the design.
### Conclusion
In this installment we took strides in improving the visual aspect of our application. We defined a layout using CSS and HTML templates, added a fileserver to serve assets, updated our `home` route handler to provide the required context data to our templates and added a `get_peers()` method to the database.
Our application has come a long way. We can now subscribe and unsubscribe to the root posts of our peers and display a list of subscribed peers in a neat user interface.
In the next installment we will return to the database and Scuttlebutt-related code in our application, adding the ability to retrieve only the latest posts for each of our peers from the sbot. This will give us an efficient way of keeping our application up to date with the latest happenings in the Scuttleverse. In doing so, we will add a means of tracking the latest sequence number of each of the peers we subscribe to.
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

206
part_6_ui_layout/src/db.rs Normal file
View File

@ -0,0 +1,206 @@
use std::path::Path;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sled::{Batch, Db, IVec, Result, Tree};
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
}
impl Peer {
/// Create a new instance of the Peer struct using the given public
/// key. A default value is set for name.
pub fn new(public_key: &str) -> Peer {
Peer {
public_key: public_key.to_string(),
name: "".to_string(),
}
}
/// Modify the name field of an instance of the Peer struct, leaving
/// the other values unchanged.
pub fn set_name(self, name: &str) -> Peer {
Self {
name: name.to_string(),
..self
}
}
}
/// The text and metadata of a Scuttlebutt root post.
#[derive(Debug, Deserialize, Serialize)]
pub struct Post {
/// The key of the post-type message, also known as a message reference.
pub key: String,
/// The text of the post (may be formatted as markdown).
pub text: String,
/// The date the post was published (e.g. 17 May 2021).
pub date: String,
/// The sequence number of the post-type message.
pub sequence: u64,
/// The read state of the post; true if read, false if unread.
pub read: bool,
/// The timestamp representing the date the post was published.
pub timestamp: i64,
/// The subject of the post, represented as the first 53 characters of
/// the post text.
pub subject: Option<String>,
}
impl Post {
// Create a new instance of the Post struct. A default value of `false` is
// set for `read`.
pub fn new(
key: String,
text: String,
date: String,
sequence: u64,
timestamp: i64,
subject: Option<String>,
) -> Post {
Post {
key,
text,
date,
sequence,
timestamp,
subject,
read: false,
}
}
}
/// An instance of the key-value database and relevant trees.
#[allow(dead_code)]
#[derive(Clone)]
pub struct Database {
/// The sled database instance.
db: Db,
/// A database tree containing Peer struct instances for all the peers
/// we are subscribed to.
peer_tree: Tree,
/// A database tree containing Post struct instances for all of the posts
/// we have downloaded from the peer to whom we subscribe.
pub post_tree: Tree,
}
impl Database {
/// Initialise the database by opening the database file, loading the
/// peers tree and returning an instantiated Database struct.
pub fn init(path: &Path) -> Self {
// Open the database at the given path.
// The database will be created if it does not yet exist.
// This code will panic if an IO error is encountered.
info!("Initialising sled database");
let db = sled::open(path).expect("Failed to open database");
debug!("Opening 'peers' database tree");
let peer_tree = db
.open_tree("peers")
.expect("Failed to open 'peers' database tree");
debug!("Opening 'posts' database tree");
let post_tree = db
.open_tree("posts")
.expect("Failed to open 'posts' database tree");
Database {
db,
peer_tree,
post_tree,
}
}
/// Add a peer to the database by inserting the public key into the peer
/// tree.
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
debug!("Serializing peer data for {} to bincode", &peer.public_key);
let peer_bytes = bincode::serialize(&peer).unwrap();
debug!(
"Inserting peer {} into 'peers' database tree",
&peer.public_key
);
self.peer_tree.insert(&peer.public_key, peer_bytes)
}
/// Get a single peer from the peer tree, defined by the given public key.
/// The byte value for the matching entry, if found, is deserialized from
/// bincode into an instance of the Peer struct.
pub fn get_peer(&self, public_key: &str) -> Result<Option<Peer>> {
debug!(
"Retrieving peer data for {} from 'peers' database tree",
&public_key
);
let peer = self
.peer_tree
.get(public_key.as_bytes())
.unwrap()
.map(|peer| {
debug!("Deserializing peer data for {} from bincode", &public_key);
bincode::deserialize(&peer).unwrap()
});
Ok(peer)
}
/// Get a list of all peers in the peer tree. The byte value for each
/// peer entry is deserialized from bincode into an instance of the Peer
/// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
/// Remove a peer from the database, as represented by the given public
/// key.
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
debug!("Removing peer {} from 'peers' database tree", &public_key);
self.peer_tree.remove(&public_key).map(|_| ())
}
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
#[allow(dead_code)]
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
self.post_tree.insert(post_key.as_bytes(), post_bytes)
}
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
let mut post_batch = Batch::default();
for post in posts {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
post_batch.insert(post_key.as_bytes(), post_bytes)
}
debug!("Applying batch insertion into 'posts' database tree");
self.post_tree.apply_batch(post_batch)
}
}

View File

@ -0,0 +1,50 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;
mod task_loop;
mod utils;
use async_std::channel;
use log::info;
use rocket::{
fairing::AdHoc,
fs::{relative, FileServer},
launch, routes,
};
use rocket_dyn_templates::Template;
use xdg::BaseDirectories;
use crate::{db::Database, routes::*, task_loop::Task};
#[launch]
async fn rocket() -> _ {
// Create the key-value database.
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
let db_path = xdg_dirs
.place_config_file("database")
.expect("cannot create database directory");
let db = Database::init(&db_path);
let db_clone = db.clone();
// Create a message passing channel.
let (tx, rx) = channel::unbounded();
let tx_clone = tx.clone();
// Spawn the task loop, passing in the receiver half of the channel.
info!("Spawning task loop");
task_loop::spawn(db_clone, rx).await;
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount("/", routes![home, subscribe_form, unsubscribe_form])
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}

View File

@ -0,0 +1,119 @@
use async_std::channel::Sender;
use log::{info, warn};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri, FromForm, State,
};
use rocket_dyn_templates::{context, Template};
use crate::{
db::{Database, Peer},
sbot,
task_loop::Task,
utils,
};
#[derive(FromForm)]
pub struct PeerForm {
pub public_key: String,
}
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
Template::render("base", context! { peers: peers, flash: flash })
}
#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(
db: &State<Database>,
tx: &State<Sender<Task>>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
// Retrieve the name of the peer to which we are subscribing.
let peer_name = match sbot::get_name(&peer.public_key).await {
Ok(name) => name,
Err(e) => {
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
// Return an empty string if an error occurs.
String::from("")
}
};
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
match sbot::follow_if_not_following(&peer.public_key).await {
Ok(_) => {
// Add the peer to the database.
if db.add_peer(peer_info).is_ok() {
info!("Added {} to 'peers' database tree", &peer.public_key);
let peer_id = peer.public_key.to_string();
// Fetch all root posts authored by the peer we're subscribing
// to. Posts will be added to the key-value database.
if let Err(e) = tx.send(Task::FetchAllPosts(peer_id)).await {
warn!("Task loop error: {}", e)
}
} else {
let err_msg = format!(
"Failed to add peer {} to 'peers' database tree",
&peer.public_key
);
warn!("{}", err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(
db: &State<Database>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
match sbot::unfollow_if_following(&peer.public_key).await {
Ok(_) => {
// Remove the peer from the database.
if db.remove_peer(&peer.public_key).is_ok() {
info!(
"Removed peer {} from 'peers' database tree",
&peer.public_key
);
} else {
warn!(
"Failed to remove peer {} from 'peers' database tree",
&peer.public_key
);
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}

View File

@ -0,0 +1,213 @@
use std::env;
use async_std::stream::StreamExt;
use chrono::NaiveDateTime;
use golgi::{
api::{friends::RelationshipQuery, history_stream::CreateHistoryStream},
messages::{SsbMessageContentType, SsbMessageKVT},
sbot::Keystore,
GolgiError, Sbot,
};
use log::{info, warn};
use serde_json::value::Value;
use crate::db::Post;
/// Initialise a connection to a Scuttlebutt server.
pub async fn init_sbot() -> Result<Sbot, String> {
let go_sbot_port = env::var("GO_SBOT_PORT").unwrap_or_else(|_| "8021".to_string());
let keystore = Keystore::GoSbot;
let ip_port = Some(format!("127.0.0.1:{}", go_sbot_port));
let net_id = None;
Sbot::init(keystore, ip_port, net_id)
.await
.map_err(|e| e.to_string())
}
/// Return the public key of the local sbot instance.
pub async fn whoami() -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.whoami().await.map_err(|e| e.to_string())
}
/// Check follow status.
///
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
let query = RelationshipQuery {
source: public_key_a.to_string(),
dest: public_key_b.to_string(),
};
sbot.friends_is_following(query)
.await
.map_err(|e| e.to_string())
}
/// Follow a peer.
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.follow(public_key).await.map_err(|e| e.to_string())
}
/// Unfollow a peer.
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
}
/// Return the name (self-identifier) for the peer associated with the given
/// public key.
///
/// The public key of the peer will be returned if a name is not found.
pub async fn get_name(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.get_name(public_key).await.map_err(|e| e.to_string())
}
/// Check the follow status of a remote peer and follow them if not already
/// following.
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
Ok(_) => {
info!("Followed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
},
Ok(status) if status.as_str() == "true" => {
info!(
"Already following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Check the follow status of a remote peer and unfollow them if already
/// following.
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {
Ok(_) => {
info!("Unfollowed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
}
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Return a stream of messages authored by the given public key.
///
/// This returns all messages regardless of type.
pub async fn get_message_stream(
public_key: &str,
sequence_number: u64,
) -> impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>> {
let mut sbot = init_sbot().await.unwrap();
let history_stream_args = CreateHistoryStream::new(public_key.to_string())
.keys_values(true, true)
.after_seq(sequence_number);
sbot.create_history_stream(history_stream_args)
.await
.unwrap()
}
/// Filter a stream of messages and return a vector of root posts.
///
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
pub async fn get_root_posts(
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
) -> (u64, Vec<Post>) {
let mut latest_sequence = 0;
let mut posts = Vec::new();
futures::pin_mut!(history_stream);
while let Some(res) = history_stream.next().await {
match res {
Ok(msg) => {
if msg.value.is_message_type(SsbMessageContentType::Post) {
let content = msg.value.content.to_owned();
if let Value::Object(content_map) = content {
if !content_map.contains_key("root") {
latest_sequence = msg.value.sequence;
let text = match content_map.get_key_value("text") {
Some(value) => value.1.to_string(),
None => String::from(""),
};
let timestamp = msg.value.timestamp.round() as i64 / 1000;
let datetime = NaiveDateTime::from_timestamp(timestamp, 0);
let date = datetime.format("%d %b %Y").to_string();
let subject = text.get(0..52).map(|s| s.to_string());
let post = Post::new(
msg.key.to_owned(),
text,
date,
msg.value.sequence,
timestamp,
subject,
);
posts.push(post)
}
}
}
}
Err(err) => {
// Print the `GolgiError` of this element to `stderr`.
warn!("err: {:?}", err);
}
}
}
(latest_sequence, posts)
}

View File

@ -0,0 +1,77 @@
use async_std::{channel::Receiver, task};
use log::{info, warn};
use crate::{sbot, Database};
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (_latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
}
/// Request the name of the peer represented by the given public key (ID)
/// and update the existing entry in the database.
async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
match sbot::get_name(&peer_id).await {
Ok(name) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
let updated_peer = peer.set_name(&name);
match db.add_peer(updated_peer) {
Ok(_) => info!("Updated name for peer: {}", &peer_id),
Err(e) => {
warn!("Failed to update name for peer: {}: {}", &peer_id, e)
}
}
}
}
Err(e) => warn!("Failed to fetch name for {}: {}", &peer_id, e),
}
}
pub enum Task {
Cancel,
FetchAllPosts(String),
#[allow(dead_code)]
FetchLatestName(String),
}
/// Spawn an asynchronous loop which receives tasks over an unbounded channel
/// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch all messages authored by the given peer, filter
// the root posts and insert them into the posts tree of the
// database.
Task::FetchAllPosts(peer_id) => {
info!("Fetching all posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, 0).await;
}
// Fetch the latest name for the given peer and update the
// peer entry in the peers tree of the database.
Task::FetchLatestName(peer_id) => {
info!("Fetching latest name for peer: {}", peer_id);
fetch_name_and_update_db(&db, peer_id).await;
}
// Break out of the task loop.
Task::Cancel => {
info!("Exiting task loop...");
break;
}
}
}
});
}

View File

@ -0,0 +1,32 @@
//! Public key validation.
/// Ensure that the given public key is a valid ed25519 key.
///
/// Return an error string if the key is invalid.
pub fn validate_public_key(public_key: &str) -> Result<(), String> {
// Ensure the ID starts with the correct sigil link.
if !public_key.starts_with('@') {
return Err("expected '@' sigil as first character".to_string());
}
// Find the dot index denoting the start of the algorithm definition tag.
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => return Err("no dot index was found".to_string()),
};
// Check the hashing algorithm (must end with ".ed25519").
if !&public_key.ends_with(".ed25519") {
return Err("hashing algorithm must be ed25519".to_string());
}
// Obtain the base64 portion (substring) of the public key.
let base64_str = &public_key[1..dot_index];
// Ensure the length of the base64 encoded ed25519 public key is correct.
if base64_str.len() != 44 {
return Err("base64 data length is incorrect".to_string());
}
Ok(())
}

View File

@ -0,0 +1,153 @@
.content {
background-color: lightyellow;
border: 5px solid #ffd700;
border-radius: 1rem;
grid-area: content;
padding: 1.5rem;
overflow-y: scroll;
word-wrap: anywhere;
}
.container {
height: 100%;
width: 100%;
margin: 0;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.flash-message {
margin-left: auto;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
color: red;
}
.flex-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flex-container > input {
margin: 0.3rem;
}
.grid-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr 3fr;
grid-template-areas: 'nav' 'peers' 'posts' 'content';
grid-gap: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.2rem;
overflow: hidden;
height: 85vh;
}
@media only screen and (min-width: 600px) {
.grid-container {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 3fr 4fr;
grid-template-areas:
'nav nav nav nav nav'
'peers posts posts posts posts'
'peers content content content content';
}
}
.icon {
margin-left: 1rem;
}
.nav {
background-color: lightgreen;
border: 5px solid #19a974;
border-radius: 1rem;
grid-area: nav;
padding: 1rem;
}
.peers {
background-color: lightblue;
border: 5px solid #357edd;
border-radius: 1rem;
grid-area: peers;
text-align: left;
}
.peers > ul {
padding-left: 1rem;
}
.peers > ul > li > a {
justify-content: space-between;
}
.peers > ul > li > a > p {
margin: 0;
font-weight: bold;
padding-right: 1rem;
}
.post > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts {
background-color: bisque;
border: 5px solid #ff6300;
border-radius: 1rem;
grid-area: posts;
overflow-y: scroll;
}
.posts > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts > ul > li > a {
justify-content: space-between;
}
.posts > ul > li > a > p {
margin: 0;
}
.selected {
background-color: #f9c587;
}
a {
text-decoration: none;
color: black;
}
code {
word-wrap: anywhere;
}
form {
margin-left: auto;
margin-right: 0.5rem;
}
h1 {
margin-left: 1rem;
}
img {
width: 3rem;
}
li {
list-style: none;
font-size: 12px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<a href="https://www.flaticon.com/free-icons/download" title="download icons">Download icons created by Kiranshastry - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</li>
{%- endfor %}
</ul>
</div>

View File

@ -0,0 +1,6 @@
<div class="content">
{% if post %}
{{ post.text }}
{% endif %}
</div>

View File

@ -0,0 +1,10 @@
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
Subject placeholder
{%- endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,25 @@
<div class="nav">
<div class="flex-container">
<a class="disabled icon" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
<input type="text" id="public_key" name="public_key" maxlength=53>
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,18 @@
[package]
name = "part_7_latest_posts"
version = "0.1.0"
edition = "2021"
[dependencies]
async-std = "1.10"
bincode = "1.3"
chrono = "0.4"
futures = "0.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
log = "0.4"
rocket = "0.5.0-rc.1"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
serde = "1"
serde_json = "1"
sled = "0.34"
xdg = "2.4.1"

View File

@ -0,0 +1,229 @@
# lykin tutorial
## Part 7: Latest Posts and Names
### Introduction
In the last tutorial installment we updated the user interface of our application and added the ability to display a list of peer subscriptions. Today we'll turn our attention to staying up-to-date with the latest posts authored by the peers we subscribe to. In doing so, we'll add the ability to keep track of the latest sequence number for each peer we follow - as well as syncing only the latest posts for each peer.
This installment will be a short one, since much of the groundwork has already been done in previous installments.
### Outline
- Update the database to store the latest sequence number
- Update the sequence number when fetching posts
- Add a task to fetch the latest posts
- Add a route handler to invoke the `FetchLatestPosts` task
- Update the navigation template
### Update the Database to Store the Latest Sequence Number
The main objective of this tutorial installment is to be able to request only the latest messages for each peer we subscribe to from the sbot. In order to do so, we need to know the sequence number of the most recently published message already in our key-value store. With that information, we can say to the sbot: "please give me all messages for peer X with sequence number greater than Y".
We're going to add a `latest_sequence` field to the `Peer` struct in our database code, as well as a method for updating that value:
`src/db.rs`
```rust
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
pub latest_sequence: u64,
}
impl Peer {
pub fn new(public_key: &str) -> Peer {
Peer {
public_key: public_key.to_string(),
name: "".to_string(),
// Set the value of latest_sequence to 0.
latest_sequence: 0,
}
}
// ...
// Modify the latest_sequence field of an instance of the Peer struct,
// leaving the other values unchanged.
pub fn set_latest_sequence(self, latest_sequence: u64) -> Peer {
Self {
latest_sequence,
..self
}
}
}
```
### Update the Sequence Number When Fetching Posts
Now that we have a way to store and update the latest sequence number for each peer in our database, we need to update our post-fetching function in the task loop accordingly.
`src/task_loop.rs`
```rust
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
// Update the value of the latest sequence number for
// the peer (this is stored in the database).
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
db.add_peer(peer.set_latest_sequence(latest_sequence))
.unwrap();
}
}
```
### Add a Task to Fetch the Latest Posts
We already have a `FetchAllPosts` variant of the `Task` enum in our task loop. Let's add a `FetchLatestPosts` variant, along with a match statement and sbot-related function call:
`src/task_loop.rs`
```rust
pub enum Task {
Cancel,
FetchAllPosts(String),
FetchLatestPosts(String),
FetchLatestName(String),
}
// Spawn an asynchronous loop which receives tasks over an unbounded channel
// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch only the latest messages authored by the given peer,
// ie. messages with sequence numbers greater than those
// which are already stored in the database.
//
// Retrieve the root posts from those messages and insert them
// into the posts tree of the database.
Task::FetchLatestPosts(peer_id) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
info!("Fetching latest posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, peer.latest_sequence).await;
}
}
// ...
}
}
}
}
```
You'll notice that the same function (`fetch_posts_and_update_db()`) is called by both the `FetchAllPosts` and `FetchLatestPosts` tasks; the difference is the value passed in for the third parameter: `after_sequence`. When fetching all posts we pass in a value of 0, while the value of `peer.latest_sequence` is passed when fetching only the latest posts. This relatively simple addition to our code has provided a very efficient means of syncing the latest posts from our local go-sbot instance to the key-value database.
### Add a Route Handler to Invoke the FetchLatestPosts Task
Now we can begin exposing a means for the user to invoke the `FetchLatestPosts` task. This will be done by clicking an icon on the navigation bar of the web interface. Once clicked, a GET request will be sent to `/posts/download_latest`. Let's write the route handler to accept the request and invoke the task for each peer we're subscribed to.
`src/routes.rs`
```rust
#[get("/posts/download_latest")]
pub async fn download_latest_posts(db: &State<Database>, tx: &State<Sender<Task>>) -> Redirect {
// Iterate through the list of peers in the key-value database.
// These are all the peers we're subscribed to via lykin.
for peer in db.get_peers() {
// Fetch the latest root posts authored by each peer we're
// subscribed to. Posts will be added to the key-value database.
if let Err(e) = tx
.send(Task::FetchLatestPosts(peer.public_key.clone()))
.await
{
warn!("Task loop error: {}", e)
}
// Fetch the latest name for each peer we're subscribed to and update
// the database.
if let Err(e) = tx.send(Task::FetchLatestName(peer.public_key)).await {
warn!("Task loop error: {}", e)
}
}
Redirect::to(uri!(home))
}
```
You'll notice in the code above that we also invoke the `FetchLatestName` task for each peer. This ensures that our application stays up-to-date with the ways our peers have chosen to name themselves.
Now we need to mount the `download_latest_posts` route to our Rocket application:
`src/main.rs`
```rust
#[launch]
async fn rocket() -> _ {
// ...
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
// Here we add the route we just wrote.
download_latest_posts
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}
```
### Update the Navigation Template
We need to remove the `disabled` and `icon` classes from the 'Download latest posts' anchor element and add an `href` tag. Once this change has been made, clicking on the download icon will fetch the latest posts for all the peers we're subscribed to.
`templates/topbar.html.tera`
```html
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
<!-- ... -->
</div>
</div>
```
### Conclusion
That marks the conclusion of a relatively short installment in which we added the ability to keep our key-value database up-to-date with the latest posts and name assignments published by the peers we subscribe to. We updated the database to be able to track the latest sequence number for each peer and added a task to fetch all posts with a sequence number greater than that which is stored. We then added a route handler to invoke the task for each peer and wired it up to the download icon in the navigation bar of our UI.
In the next installment we'll write more route handlers and update our templates in order to show a list of posts each peer has made. We'll also add the ability to display the content of each post. We are on the cusp of realising the fruits of our labour!
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -0,0 +1,217 @@
use std::path::Path;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sled::{Batch, Db, IVec, Result, Tree};
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
pub latest_sequence: u64,
}
impl Peer {
/// Create a new instance of the Peer struct using the given public
/// key. A default value is set for name.
pub fn new(public_key: &str) -> Peer {
Peer {
public_key: public_key.to_string(),
name: "".to_string(),
latest_sequence: 0,
}
}
/// Modify the name field of an instance of the Peer struct, leaving
/// the other values unchanged.
pub fn set_name(self, name: &str) -> Peer {
Self {
name: name.to_string(),
..self
}
}
/// Modify the latest_sequence field of an instance of the Peer struct,
/// leaving the other values unchanged.
pub fn set_latest_sequence(self, latest_sequence: u64) -> Peer {
Self {
latest_sequence,
..self
}
}
}
/// The text and metadata of a Scuttlebutt root post.
#[derive(Debug, Deserialize, Serialize)]
pub struct Post {
/// The key of the post-type message, also known as a message reference.
pub key: String,
/// The text of the post (may be formatted as markdown).
pub text: String,
/// The date the post was published (e.g. 17 May 2021).
pub date: String,
/// The sequence number of the post-type message.
pub sequence: u64,
/// The read state of the post; true if read, false if unread.
pub read: bool,
/// The timestamp representing the date the post was published.
pub timestamp: i64,
/// The subject of the post, represented as the first 53 characters of
/// the post text.
pub subject: Option<String>,
}
impl Post {
// Create a new instance of the Post struct. A default value of `false` is
// set for `read`.
pub fn new(
key: String,
text: String,
date: String,
sequence: u64,
timestamp: i64,
subject: Option<String>,
) -> Post {
Post {
key,
text,
date,
sequence,
timestamp,
subject,
read: false,
}
}
}
/// An instance of the key-value database and relevant trees.
#[allow(dead_code)]
#[derive(Clone)]
pub struct Database {
/// The sled database instance.
db: Db,
/// A database tree containing Peer struct instances for all the peers
/// we are subscribed to.
peer_tree: Tree,
/// A database tree containing Post struct instances for all of the posts
/// we have downloaded from the peer to whom we subscribe.
pub post_tree: Tree,
}
impl Database {
/// Initialise the database by opening the database file, loading the
/// peers tree and returning an instantiated Database struct.
pub fn init(path: &Path) -> Self {
// Open the database at the given path.
// The database will be created if it does not yet exist.
// This code will panic if an IO error is encountered.
info!("Initialising sled database");
let db = sled::open(path).expect("Failed to open database");
debug!("Opening 'peers' database tree");
let peer_tree = db
.open_tree("peers")
.expect("Failed to open 'peers' database tree");
debug!("Opening 'posts' database tree");
let post_tree = db
.open_tree("posts")
.expect("Failed to open 'posts' database tree");
Database {
db,
peer_tree,
post_tree,
}
}
/// Add a peer to the database by inserting the public key into the peer
/// tree.
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
debug!("Serializing peer data for {} to bincode", &peer.public_key);
let peer_bytes = bincode::serialize(&peer).unwrap();
debug!(
"Inserting peer {} into 'peers' database tree",
&peer.public_key
);
self.peer_tree.insert(&peer.public_key, peer_bytes)
}
/// Get a single peer from the peer tree, defined by the given public key.
/// The byte value for the matching entry, if found, is deserialized from
/// bincode into an instance of the Peer struct.
pub fn get_peer(&self, public_key: &str) -> Result<Option<Peer>> {
debug!(
"Retrieving peer data for {} from 'peers' database tree",
&public_key
);
let peer = self
.peer_tree
.get(public_key.as_bytes())
.unwrap()
.map(|peer| {
debug!("Deserializing peer data for {} from bincode", &public_key);
bincode::deserialize(&peer).unwrap()
});
Ok(peer)
}
/// Get a list of all peers in the peer tree. The byte value for each
/// peer entry is deserialized from bincode into an instance of the Peer
/// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
/// Remove a peer from the database, as represented by the given public
/// key.
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
debug!("Removing peer {} from 'peers' database tree", &public_key);
self.peer_tree.remove(&public_key).map(|_| ())
}
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
#[allow(dead_code)]
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
self.post_tree.insert(post_key.as_bytes(), post_bytes)
}
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
let mut post_batch = Batch::default();
for post in posts {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
post_batch.insert(post_key.as_bytes(), post_bytes)
}
debug!("Applying batch insertion into 'posts' database tree");
self.post_tree.apply_batch(post_batch)
}
}

View File

@ -0,0 +1,58 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;
mod task_loop;
mod utils;
use async_std::channel;
use log::info;
use rocket::{
fairing::AdHoc,
fs::{relative, FileServer},
launch, routes,
};
use rocket_dyn_templates::Template;
use xdg::BaseDirectories;
use crate::{db::Database, routes::*, task_loop::Task};
#[launch]
async fn rocket() -> _ {
// Create the key-value database.
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
let db_path = xdg_dirs
.place_config_file("database")
.expect("cannot create database directory");
let db = Database::init(&db_path);
let db_clone = db.clone();
// Create a message passing channel.
let (tx, rx) = channel::unbounded();
let tx_clone = tx.clone();
// Spawn the task loop, passing in the receiver half of the channel.
info!("Spawning task loop");
task_loop::spawn(db_clone, rx).await;
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}

View File

@ -0,0 +1,141 @@
use async_std::channel::Sender;
use log::{info, warn};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri, FromForm, State,
};
use rocket_dyn_templates::{context, Template};
use crate::{
db::{Database, Peer},
sbot,
task_loop::Task,
utils,
};
#[derive(FromForm)]
pub struct PeerForm {
pub public_key: String,
}
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
Template::render("base", context! { peers: peers, flash: flash })
}
#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(
db: &State<Database>,
tx: &State<Sender<Task>>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
// Retrieve the name of the peer to which we are subscribing.
let peer_name = match sbot::get_name(&peer.public_key).await {
Ok(name) => name,
Err(e) => {
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
// Return an empty string if an error occurs.
String::from("")
}
};
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
match sbot::follow_if_not_following(&peer.public_key).await {
Ok(_) => {
// Add the peer to the database.
if db.add_peer(peer_info).is_ok() {
info!("Added {} to 'peers' database tree", &peer.public_key);
let peer_id = peer.public_key.to_string();
// Fetch all root posts authored by the peer we're subscribing
// to. Posts will be added to the key-value database.
if let Err(e) = tx.send(Task::FetchAllPosts(peer_id)).await {
warn!("Task loop error: {}", e)
}
} else {
let err_msg = format!(
"Failed to add peer {} to 'peers' database tree",
&peer.public_key
);
warn!("{}", err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(
db: &State<Database>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
match sbot::unfollow_if_following(&peer.public_key).await {
Ok(_) => {
// Remove the peer from the database.
if db.remove_peer(&peer.public_key).is_ok() {
info!(
"Removed peer {} from 'peers' database tree",
&peer.public_key
);
} else {
warn!(
"Failed to remove peer {} from 'peers' database tree",
&peer.public_key
);
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[get("/posts/download_latest")]
pub async fn download_latest_posts(db: &State<Database>, tx: &State<Sender<Task>>) -> Redirect {
for peer in db.get_peers() {
// Fetch the latest root posts authored by each peer we're
// subscribed to. Posts will be added to the key-value database.
if let Err(e) = tx
.send(Task::FetchLatestPosts(peer.public_key.clone()))
.await
{
warn!("Task loop error: {}", e)
}
// Fetch the latest name for each peer we're subscribed to and update
// the database.
if let Err(e) = tx.send(Task::FetchLatestName(peer.public_key)).await {
warn!("Task loop error: {}", e)
}
}
Redirect::to(uri!(home))
}

View File

@ -0,0 +1,213 @@
use std::env;
use async_std::stream::StreamExt;
use chrono::NaiveDateTime;
use golgi::{
api::{friends::RelationshipQuery, history_stream::CreateHistoryStream},
messages::{SsbMessageContentType, SsbMessageKVT},
sbot::Keystore,
GolgiError, Sbot,
};
use log::{info, warn};
use serde_json::value::Value;
use crate::db::Post;
/// Initialise a connection to a Scuttlebutt server.
pub async fn init_sbot() -> Result<Sbot, String> {
let go_sbot_port = env::var("GO_SBOT_PORT").unwrap_or_else(|_| "8021".to_string());
let keystore = Keystore::GoSbot;
let ip_port = Some(format!("127.0.0.1:{}", go_sbot_port));
let net_id = None;
Sbot::init(keystore, ip_port, net_id)
.await
.map_err(|e| e.to_string())
}
/// Return the public key of the local sbot instance.
pub async fn whoami() -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.whoami().await.map_err(|e| e.to_string())
}
/// Check follow status.
///
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
let query = RelationshipQuery {
source: public_key_a.to_string(),
dest: public_key_b.to_string(),
};
sbot.friends_is_following(query)
.await
.map_err(|e| e.to_string())
}
/// Follow a peer.
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.follow(public_key).await.map_err(|e| e.to_string())
}
/// Unfollow a peer.
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
}
/// Return the name (self-identifier) for the peer associated with the given
/// public key.
///
/// The public key of the peer will be returned if a name is not found.
pub async fn get_name(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.get_name(public_key).await.map_err(|e| e.to_string())
}
/// Check the follow status of a remote peer and follow them if not already
/// following.
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
Ok(_) => {
info!("Followed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
},
Ok(status) if status.as_str() == "true" => {
info!(
"Already following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Check the follow status of a remote peer and unfollow them if already
/// following.
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {
Ok(_) => {
info!("Unfollowed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
}
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Return a stream of messages authored by the given public key.
///
/// This returns all messages regardless of type.
pub async fn get_message_stream(
public_key: &str,
sequence_number: u64,
) -> impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>> {
let mut sbot = init_sbot().await.unwrap();
let history_stream_args = CreateHistoryStream::new(public_key.to_string())
.keys_values(true, true)
.after_seq(sequence_number);
sbot.create_history_stream(history_stream_args)
.await
.unwrap()
}
/// Filter a stream of messages and return a vector of root posts.
///
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
pub async fn get_root_posts(
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
) -> (u64, Vec<Post>) {
let mut latest_sequence = 0;
let mut posts = Vec::new();
futures::pin_mut!(history_stream);
while let Some(res) = history_stream.next().await {
match res {
Ok(msg) => {
if msg.value.is_message_type(SsbMessageContentType::Post) {
let content = msg.value.content.to_owned();
if let Value::Object(content_map) = content {
if !content_map.contains_key("root") {
latest_sequence = msg.value.sequence;
let text = match content_map.get_key_value("text") {
Some(value) => value.1.to_string(),
None => String::from(""),
};
let timestamp = msg.value.timestamp.round() as i64 / 1000;
let datetime = NaiveDateTime::from_timestamp(timestamp, 0);
let date = datetime.format("%d %b %Y").to_string();
let subject = text.get(0..52).map(|s| s.to_string());
let post = Post::new(
msg.key.to_owned(),
text,
date,
msg.value.sequence,
timestamp,
subject,
);
posts.push(post)
}
}
}
}
Err(err) => {
// Print the `GolgiError` of this element to `stderr`.
warn!("err: {:?}", err);
}
}
}
(latest_sequence, posts)
}

View File

@ -0,0 +1,96 @@
use async_std::{channel::Receiver, task};
use log::{info, warn};
use crate::{sbot, Database};
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
// Update the value of the latest sequence number for
// the peer (this is stored in the database).
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
db.add_peer(peer.set_latest_sequence(latest_sequence))
.unwrap();
}
}
/// Request the name of the peer represented by the given public key (ID)
/// and update the existing entry in the database.
async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
match sbot::get_name(&peer_id).await {
Ok(name) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
let updated_peer = peer.set_name(&name);
match db.add_peer(updated_peer) {
Ok(_) => info!("Updated name for peer: {}", &peer_id),
Err(e) => {
warn!("Failed to update name for peer: {}: {}", &peer_id, e)
}
}
}
}
Err(e) => warn!("Failed to fetch name for {}: {}", &peer_id, e),
}
}
pub enum Task {
Cancel,
FetchAllPosts(String),
FetchLatestPosts(String),
FetchLatestName(String),
}
/// Spawn an asynchronous loop which receives tasks over an unbounded channel
/// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch all messages authored by the given peer, filter
// the root posts and insert them into the posts tree of the
// database.
Task::FetchAllPosts(peer_id) => {
info!("Fetching all posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, 0).await;
}
// Fetch only the latest messages authored by the given peer,
// ie. messages with sequence numbers greater than those
// which are already stored in the database.
//
// Retrieve the root posts from those messages and insert them
// into the posts tree of the database.
Task::FetchLatestPosts(peer_id) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
info!("Fetching latest posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, peer.latest_sequence).await;
}
}
// Fetch the latest name for the given peer and update the
// peer entry in the peers tree of the database.
Task::FetchLatestName(peer_id) => {
info!("Fetching latest name for peer: {}", peer_id);
fetch_name_and_update_db(&db, peer_id).await;
}
// Break out of the task loop.
Task::Cancel => {
info!("Exiting task loop...");
break;
}
}
}
});
}

View File

@ -0,0 +1,32 @@
//! Public key validation.
/// Ensure that the given public key is a valid ed25519 key.
///
/// Return an error string if the key is invalid.
pub fn validate_public_key(public_key: &str) -> Result<(), String> {
// Ensure the ID starts with the correct sigil link.
if !public_key.starts_with('@') {
return Err("expected '@' sigil as first character".to_string());
}
// Find the dot index denoting the start of the algorithm definition tag.
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => return Err("no dot index was found".to_string()),
};
// Check the hashing algorithm (must end with ".ed25519").
if !&public_key.ends_with(".ed25519") {
return Err("hashing algorithm must be ed25519".to_string());
}
// Obtain the base64 portion (substring) of the public key.
let base64_str = &public_key[1..dot_index];
// Ensure the length of the base64 encoded ed25519 public key is correct.
if base64_str.len() != 44 {
return Err("base64 data length is incorrect".to_string());
}
Ok(())
}

View File

@ -0,0 +1,153 @@
.content {
background-color: lightyellow;
border: 5px solid #ffd700;
border-radius: 1rem;
grid-area: content;
padding: 1.5rem;
overflow-y: scroll;
word-wrap: anywhere;
}
.container {
height: 100%;
width: 100%;
margin: 0;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.flash-message {
margin-left: auto;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
color: red;
}
.flex-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flex-container > input {
margin: 0.3rem;
}
.grid-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr 3fr;
grid-template-areas: 'nav' 'peers' 'posts' 'content';
grid-gap: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.2rem;
overflow: hidden;
height: 85vh;
}
@media only screen and (min-width: 600px) {
.grid-container {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 3fr 4fr;
grid-template-areas:
'nav nav nav nav nav'
'peers posts posts posts posts'
'peers content content content content';
}
}
.icon {
margin-left: 1rem;
}
.nav {
background-color: lightgreen;
border: 5px solid #19a974;
border-radius: 1rem;
grid-area: nav;
padding: 1rem;
}
.peers {
background-color: lightblue;
border: 5px solid #357edd;
border-radius: 1rem;
grid-area: peers;
text-align: left;
}
.peers > ul {
padding-left: 1rem;
}
.peers > ul > li > a {
justify-content: space-between;
}
.peers > ul > li > a > p {
margin: 0;
font-weight: bold;
padding-right: 1rem;
}
.post > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts {
background-color: bisque;
border: 5px solid #ff6300;
border-radius: 1rem;
grid-area: posts;
overflow-y: scroll;
}
.posts > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts > ul > li > a {
justify-content: space-between;
}
.posts > ul > li > a > p {
margin: 0;
}
.selected {
background-color: #f9c587;
}
a {
text-decoration: none;
color: black;
}
code {
word-wrap: anywhere;
}
form {
margin-left: auto;
margin-right: 0.5rem;
}
h1 {
margin-left: 1rem;
}
img {
width: 3rem;
}
li {
list-style: none;
font-size: 12px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<a href="https://www.flaticon.com/free-icons/download" title="download icons">Download icons created by Kiranshastry - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</li>
{%- endfor %}
</ul>
</div>

View File

@ -0,0 +1,6 @@
<div class="content">
{% if post %}
{{ post.text }}
{% endif %}
</div>

View File

@ -0,0 +1,10 @@
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
Subject placeholder
{%- endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,28 @@
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
<input type="text" id="public_key" name="public_key" maxlength=53>
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,18 @@
[package]
name = "part_8_ui_posts"
version = "0.1.0"
edition = "2021"
[dependencies]
async-std = "1.10"
bincode = "1.3"
chrono = "0.4"
futures = "0.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
log = "0.4"
rocket = "0.5.0-rc.1"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
serde = "1"
serde_json = "1"
sled = "0.34"
xdg = "2.4.1"

283
part_8_ui_posts/README.md Normal file
View File

@ -0,0 +1,283 @@
# lykin tutorial
## Part 8: Post List and Post Content
### Introduction
In the last tutorial installment we added the ability to sync the latest posts and names for each peer we subscribe to. The goal of this installment is to update the web interface to display a list of posts when a peer is selected from the peer list and to display the text of a post when one is selected. In order to achieve this, we'll need to add methods to the key-value database to retrieve a post or a batch of posts. We'll also need to add a number of endpoints to our webserver and update the control-flow logic in our templates.
### Outline
- Get a post from the database
- Get a batch of posts from the database
- Add a posts route handler
- Add a post route handler
- Mount the post route handlers
- Update templates
- Peer list
- Post list
- Post content
### Get a Post From the Database
We previously wrote database methods for adding a single post and a batch of posts to the key-value store. Now we need to write methods for retrieving that data. Let's start with a method to retrieve a single post. The method will take a public key and a message ID (aka. message reference or sigil link) as parameters, concatenate those values to create a `post_key` and then attempt to get the value of that key from the database.
`src/db.rs`
```rust
impl Database {
// ...
// Get a single post from the post tree, authored by the given public key
// and defined by the given message ID. The byte value for the matching
// entry, if found, is deserialized from bincode into an instance of the
// Post struct.
pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result<Option<Post>> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!(
"Retrieving post data for {} from 'posts' database tree",
&post_key
);
let post = self
.post_tree
.get(post_key.as_bytes())
.unwrap()
.map(|post| {
debug!("Deserializing post data for {} from bincode", &post_key);
bincode::deserialize(&post).unwrap()
});
Ok(post)
}
}
```
### Get a Batch of Posts From the Database
The corresponding method for retrieving a batch of posts is very similar. We pass in the public key of the desired peer as a parameter and retrieve all posts with a key beginning with that public key (notice `scan_prefix()` in the code below). Once we've populated a vector with all the posts by the given public key, we sort the list according to the timestamp of each post. This will allow us to easily display the posts in descending chronological order in the web interface.
`src/routes.rs`
```rust
impl Database {
// ...
// Get a list of all posts in the post tree authored by the given public
// key and sort them by timestamp in descending order. The byte value for
// each matching entry is deserialized from bincode into an instance of
// the Post struct.
pub fn get_posts(&self, public_key: &str) -> Result<Vec<Post>> {
debug!("Retrieving data for all posts in the 'posts' database tree");
let mut posts = Vec::new();
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
posts.push(bincode::deserialize(&post.1).unwrap())
});
posts.sort_by(|a: &Post, b: &Post| b.timestamp.cmp(&a.timestamp));
Ok(posts)
}
}
```
### Add a Posts Route Handler
Imagine interacting with the lykin interface for a moment: we load the application and see a list of peers down the left-hand side; these are the peers we subscribe to. When we click on the name of one of the peers in the list, we want to see a list of posts authored by that peer - each one with a subject line and date. Then, when we click on one of the posts in the list, we want to see the content of that post.
Let's write an endpoint that will take a public key and render the user interface with a list of posts:
`src/routes.rs`
```rust
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
// Fetch the list of peers we subscribe to.
let peers = db.get_peers();
// Fetch the posts for the given peer from the key-value database.
let posts = db.get_posts(public_key).unwrap();
// Define context data to be rendered in the template.
let context = context! {
peers: &peers,
// This variable allows us to track which peer is currently selected
// from within the template. We'll use this variable to render the
// name of the selected peer in bold.
selected_peer: &public_key,
posts: &posts
};
Template::render("base", context)
}
```
There's not much to the code above: get the peers, get the posts, generate a template context from the data, render the template and return it to the caller.
### Add a Post Route Handler
Now we want to add an endpoint that will return a template populated with a list of peers, a list of posts _and_ the content of a specific post. This is the route handler that will be called when we click on a post in the post list.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post,
post_is_selected: &true
};
Template::render("base", context)
}
```
The code above is almost identical to the code in the `posts` route handler, with the exception of the `msg_id` parameter, the `get_post` database call and the addition of `post` and `selected_post` to the template context. As with `selected_peer`, `selected_post` gives us a means of bolding the text of the selected post in the list of posts. If this is at all confusing, things should become clearer as we update the templates. Let's turn to that task now.
### Mount the Post Route Handlers
Let's register the `post` and `posts` route handlers by mounting them to our Rocket instance.
`src/main.rs`
```rust
#[launch]
async fn rocket() -> _ {
// ...
info!("Launching web server");
rocket::build()
.manage(db)
.manage(tx)
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(Template::fairing())
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}
```
### Update Peer List Template
We need to update the peer list template so that each name in the list is wrapped in an anchor element with an `href` tag value of `/posts/<public_key>`. The `selected_peer` context variable will come in handy here: we can use it to render the name of a peer as bold text if it is the currently selected peer.
`templates/peer_list.html.tera`
```html
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</code>
</a>
</li>
{%- endfor %}
</ul>
</div>
```
Notice the `href` tag value above: `/posts/{{ peer.public_key | urlencode_strict }}`. `urlencode_strict` is a Tera filter that encodes all non-alphanumeric characters in a string including forward slashes (see [the docs](https://tera.netlify.app/docs/#urlencode-strict)).
We also check if the `selected_peer` context variable exists. If it does, and if it matches the value of the peer's public key, we render the name in bold text.
One other small improvement introduced here is selective rendering of the peer name. It's possible that our local key-value database may not contain a name for a peer we've subscribed to (for instance, if that peer is outside of our hops range or we simply haven't replicated any data for it yet). In the case that the peer's name is not known, we simply render the public key instead.
### Update Post List Template
When we wrote the initial post list template we simply printed `Subject placeholder` for each post in the list. Let's update that to display the subject and date for each post.
`templates/post_list.html.tera`
```html
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container" href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>
```
Here we see the `selected_post` context variable in action, in much the same way as the `selected_peer` variable was utilised in the peer list template. The `selected` class is applied to the selected post; this changes the background colour of the element to make it stand out from the rest of the posts.
The `href` tag value of each post in the list is constructed using the `selected_peer` and `post.key` values, both of which are strictly URL-encoded using a Tera filter. Then comes the code to display the post subject, if it exists, along with the post date. If `post.subject` is `None` then we display the `post.text` instead. This would occur if the post text contains less than 52 characters (the length defined for the subject text). Finally, the `post.date` is displayed as the last element in the list item.
### Update Post Content Template
Before wrapping up this installment of the series, we're going to make one small change to the post content template to remove the inverted commas which wrap the text of each post in our database. We'll also call the `trim` Tera filter to remove any leading and trailing whitespace characters:
`templates/post_content.html.tera`
```html
<div class="content">
{% if post %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') | trim }}
{% endif %}
</div>
```
Now you can run the application with `cargo run` and test it out! Remember, you may need to wipe the key-value database if you encounter any `500` errors when navigating to the web interface in your browser.
### Conclusion
In this installment we brought our user-interface to life by added the ability to list posts and display post content. We wrote methods to retrieve posts from the key-value database and added route handlers to render post lists and post content. We also updated the HTML templates of our application to render the post-related data.
Most of the core logic of our application is now complete! In the next installment we'll add the ability to mark individual posts as read or unread and will display the total number of unread posts for each peer. We'll also add a means of deleting individual posts; all via the web interface.
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

264
part_8_ui_posts/src/db.rs Normal file
View File

@ -0,0 +1,264 @@
use std::path::Path;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sled::{Batch, Db, IVec, Result, Tree};
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
pub latest_sequence: u64,
}
impl Peer {
/// Create a new instance of the Peer struct using the given public
/// key. A default value is set for name.
pub fn new(public_key: &str) -> Peer {
Peer {
public_key: public_key.to_string(),
name: "".to_string(),
latest_sequence: 0,
}
}
/// Modify the name field of an instance of the Peer struct, leaving
/// the other values unchanged.
pub fn set_name(self, name: &str) -> Peer {
Self {
name: name.to_string(),
..self
}
}
/// Modify the latest_sequence field of an instance of the Peer struct,
/// leaving the other values unchanged.
pub fn set_latest_sequence(self, latest_sequence: u64) -> Peer {
Self {
latest_sequence,
..self
}
}
}
/// The text and metadata of a Scuttlebutt root post.
#[derive(Debug, Deserialize, Serialize)]
pub struct Post {
/// The key of the post-type message, also known as a message reference.
pub key: String,
/// The text of the post (may be formatted as markdown).
pub text: String,
/// The date the post was published (e.g. 17 May 2021).
pub date: String,
/// The sequence number of the post-type message.
pub sequence: u64,
/// The read state of the post; true if read, false if unread.
pub read: bool,
/// The timestamp representing the date the post was published.
pub timestamp: i64,
/// The subject of the post, represented as the first 53 characters of
/// the post text.
pub subject: Option<String>,
}
impl Post {
// Create a new instance of the Post struct. A default value of `false` is
// set for `read`.
pub fn new(
key: String,
text: String,
date: String,
sequence: u64,
timestamp: i64,
subject: Option<String>,
) -> Post {
Post {
key,
text,
date,
sequence,
timestamp,
subject,
read: false,
}
}
}
/// An instance of the key-value database and relevant trees.
#[allow(dead_code)]
#[derive(Clone)]
pub struct Database {
/// The sled database instance.
db: Db,
/// A database tree containing Peer struct instances for all the peers
/// we are subscribed to.
peer_tree: Tree,
/// A database tree containing Post struct instances for all of the posts
/// we have downloaded from the peer to whom we subscribe.
pub post_tree: Tree,
}
impl Database {
/// Initialise the database by opening the database file, loading the
/// peers tree and returning an instantiated Database struct.
pub fn init(path: &Path) -> Self {
// Open the database at the given path.
// The database will be created if it does not yet exist.
// This code will panic if an IO error is encountered.
info!("Initialising sled database");
let db = sled::open(path).expect("Failed to open database");
debug!("Opening 'peers' database tree");
let peer_tree = db
.open_tree("peers")
.expect("Failed to open 'peers' database tree");
debug!("Opening 'posts' database tree");
let post_tree = db
.open_tree("posts")
.expect("Failed to open 'posts' database tree");
Database {
db,
peer_tree,
post_tree,
}
}
/// Add a peer to the database by inserting the public key into the peer
/// tree.
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
debug!("Serializing peer data for {} to bincode", &peer.public_key);
let peer_bytes = bincode::serialize(&peer).unwrap();
debug!(
"Inserting peer {} into 'peers' database tree",
&peer.public_key
);
self.peer_tree.insert(&peer.public_key, peer_bytes)
}
/// Get a single peer from the peer tree, defined by the given public key.
/// The byte value for the matching entry, if found, is deserialized from
/// bincode into an instance of the Peer struct.
pub fn get_peer(&self, public_key: &str) -> Result<Option<Peer>> {
debug!(
"Retrieving peer data for {} from 'peers' database tree",
&public_key
);
let peer = self
.peer_tree
.get(public_key.as_bytes())
.unwrap()
.map(|peer| {
debug!("Deserializing peer data for {} from bincode", &public_key);
bincode::deserialize(&peer).unwrap()
});
Ok(peer)
}
/// Get a list of all peers in the peer tree. The byte value for each
/// peer entry is deserialized from bincode into an instance of the Peer
/// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
/// Remove a peer from the database, as represented by the given public
/// key.
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
debug!("Removing peer {} from 'peers' database tree", &public_key);
self.peer_tree.remove(&public_key).map(|_| ())
}
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
#[allow(dead_code)]
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
self.post_tree.insert(post_key.as_bytes(), post_bytes)
}
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
let mut post_batch = Batch::default();
for post in posts {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
post_batch.insert(post_key.as_bytes(), post_bytes)
}
debug!("Applying batch insertion into 'posts' database tree");
self.post_tree.apply_batch(post_batch)
}
/// Get a list of all posts in the post tree authored by the given public
/// key and sort them by timestamp in descending order. The byte value for
/// each matching entry is deserialized from bincode into an instance of
/// the Post struct.
pub fn get_posts(&self, public_key: &str) -> Result<Vec<Post>> {
debug!("Retrieving data for all posts in the 'posts' database tree");
let mut posts = Vec::new();
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
posts.push(bincode::deserialize(&post.1).unwrap())
});
posts.sort_by(|a: &Post, b: &Post| b.timestamp.cmp(&a.timestamp));
Ok(posts)
}
/// Get a single post from the post tree, authored by the given public key
/// and defined by the given message ID. The byte value for the matching
/// entry, if found, is deserialized from bincode into an instance of the
/// Post struct.
pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result<Option<Post>> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!(
"Retrieving post data for {} from 'posts' database tree",
&post_key
);
let post = self
.post_tree
.get(post_key.as_bytes())
.unwrap()
.map(|post| {
debug!("Deserializing post data for {} from bincode", &post_key);
bincode::deserialize(&post).unwrap()
});
Ok(post)
}
}

View File

@ -0,0 +1,60 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;
mod task_loop;
mod utils;
use async_std::channel;
use log::info;
use rocket::{
fairing::AdHoc,
fs::{relative, FileServer},
launch, routes,
};
use rocket_dyn_templates::Template;
use xdg::BaseDirectories;
use crate::{db::Database, routes::*, task_loop::Task};
#[launch]
async fn rocket() -> _ {
// Create the key-value database.
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
let db_path = xdg_dirs
.place_config_file("database")
.expect("cannot create database directory");
let db = Database::init(&db_path);
let db_clone = db.clone();
// Create a message passing channel.
let (tx, rx) = channel::unbounded();
let tx_clone = tx.clone();
// Spawn the task loop, passing in the receiver half of the channel.
info!("Spawning task loop");
task_loop::spawn(db_clone, rx).await;
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}

View File

@ -0,0 +1,180 @@
use async_std::channel::Sender;
use log::{info, warn};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri, FromForm, State,
};
use rocket_dyn_templates::{context, Template};
use crate::{
db::{Database, Peer},
sbot,
task_loop::Task,
utils,
};
#[derive(FromForm)]
pub struct PeerForm {
pub public_key: String,
}
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
Template::render("base", context! { peers: peers, flash: flash })
}
#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(
db: &State<Database>,
tx: &State<Sender<Task>>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
// Retrieve the name of the peer to which we are subscribing.
let peer_name = match sbot::get_name(&peer.public_key).await {
Ok(name) => name,
Err(e) => {
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
// Return an empty string if an error occurs.
String::from("")
}
};
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
match sbot::follow_if_not_following(&peer.public_key).await {
Ok(_) => {
// Add the peer to the database.
if db.add_peer(peer_info).is_ok() {
info!("Added {} to 'peers' database tree", &peer.public_key);
let peer_id = peer.public_key.to_string();
// Fetch all root posts authored by the peer we're subscribing
// to. Posts will be added to the key-value database.
if let Err(e) = tx.send(Task::FetchAllPosts(peer_id)).await {
warn!("Task loop error: {}", e)
}
} else {
let err_msg = format!(
"Failed to add peer {} to 'peers' database tree",
&peer.public_key
);
warn!("{}", err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(
db: &State<Database>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
match sbot::unfollow_if_following(&peer.public_key).await {
Ok(_) => {
// Remove the peer from the database.
if db.remove_peer(&peer.public_key).is_ok() {
info!(
"Removed peer {} from 'peers' database tree",
&peer.public_key
);
} else {
warn!(
"Failed to remove peer {} from 'peers' database tree",
&peer.public_key
);
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[get("/posts/download_latest")]
pub async fn download_latest_posts(db: &State<Database>, tx: &State<Sender<Task>>) -> Redirect {
for peer in db.get_peers() {
// Fetch the latest root posts authored by each peer we're
// subscribed to. Posts will be added to the key-value database.
if let Err(e) = tx
.send(Task::FetchLatestPosts(peer.public_key.clone()))
.await
{
warn!("Task loop error: {}", e)
}
// Fetch the latest name for each peer we're subscribed to and update
// the database.
if let Err(e) = tx.send(Task::FetchLatestName(peer.public_key)).await {
warn!("Task loop error: {}", e)
}
}
Redirect::to(uri!(home))
}
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
// Fetch the list of peers we subscribe to.
let peers = db.get_peers();
// Fetch the posts for the given peer from the key-value database.
let posts = db.get_posts(public_key).unwrap();
// Define context data to be rendered in the template.
let context = context! {
peers: &peers,
// This variable allows us to track which peer is currently selected
// from within the template. We'll use this variable to render the
// name of the selected peer in bold.
selected_peer: &public_key,
posts: &posts
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post,
post_is_selected: &true
};
Template::render("base", context)
}

213
part_8_ui_posts/src/sbot.rs Normal file
View File

@ -0,0 +1,213 @@
use std::env;
use async_std::stream::StreamExt;
use chrono::NaiveDateTime;
use golgi::{
api::{friends::RelationshipQuery, history_stream::CreateHistoryStream},
messages::{SsbMessageContentType, SsbMessageKVT},
sbot::Keystore,
GolgiError, Sbot,
};
use log::{info, warn};
use serde_json::value::Value;
use crate::db::Post;
/// Initialise a connection to a Scuttlebutt server.
pub async fn init_sbot() -> Result<Sbot, String> {
let go_sbot_port = env::var("GO_SBOT_PORT").unwrap_or_else(|_| "8021".to_string());
let keystore = Keystore::GoSbot;
let ip_port = Some(format!("127.0.0.1:{}", go_sbot_port));
let net_id = None;
Sbot::init(keystore, ip_port, net_id)
.await
.map_err(|e| e.to_string())
}
/// Return the public key of the local sbot instance.
pub async fn whoami() -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.whoami().await.map_err(|e| e.to_string())
}
/// Check follow status.
///
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
let query = RelationshipQuery {
source: public_key_a.to_string(),
dest: public_key_b.to_string(),
};
sbot.friends_is_following(query)
.await
.map_err(|e| e.to_string())
}
/// Follow a peer.
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.follow(public_key).await.map_err(|e| e.to_string())
}
/// Unfollow a peer.
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
}
/// Return the name (self-identifier) for the peer associated with the given
/// public key.
///
/// The public key of the peer will be returned if a name is not found.
pub async fn get_name(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.get_name(public_key).await.map_err(|e| e.to_string())
}
/// Check the follow status of a remote peer and follow them if not already
/// following.
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
Ok(_) => {
info!("Followed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
},
Ok(status) if status.as_str() == "true" => {
info!(
"Already following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Check the follow status of a remote peer and unfollow them if already
/// following.
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {
Ok(_) => {
info!("Unfollowed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
}
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Return a stream of messages authored by the given public key.
///
/// This returns all messages regardless of type.
pub async fn get_message_stream(
public_key: &str,
sequence_number: u64,
) -> impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>> {
let mut sbot = init_sbot().await.unwrap();
let history_stream_args = CreateHistoryStream::new(public_key.to_string())
.keys_values(true, true)
.after_seq(sequence_number);
sbot.create_history_stream(history_stream_args)
.await
.unwrap()
}
/// Filter a stream of messages and return a vector of root posts.
///
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
pub async fn get_root_posts(
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
) -> (u64, Vec<Post>) {
let mut latest_sequence = 0;
let mut posts = Vec::new();
futures::pin_mut!(history_stream);
while let Some(res) = history_stream.next().await {
match res {
Ok(msg) => {
if msg.value.is_message_type(SsbMessageContentType::Post) {
let content = msg.value.content.to_owned();
if let Value::Object(content_map) = content {
if !content_map.contains_key("root") {
latest_sequence = msg.value.sequence;
let text = match content_map.get_key_value("text") {
Some(value) => value.1.to_string(),
None => String::from(""),
};
let timestamp = msg.value.timestamp.round() as i64 / 1000;
let datetime = NaiveDateTime::from_timestamp(timestamp, 0);
let date = datetime.format("%d %b %Y").to_string();
let subject = text.get(0..52).map(|s| s.to_string());
let post = Post::new(
msg.key.to_owned(),
text,
date,
msg.value.sequence,
timestamp,
subject,
);
posts.push(post)
}
}
}
}
Err(err) => {
// Print the `GolgiError` of this element to `stderr`.
warn!("err: {:?}", err);
}
}
}
(latest_sequence, posts)
}

View File

@ -0,0 +1,96 @@
use async_std::{channel::Receiver, task};
use log::{info, warn};
use crate::{sbot, Database};
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
// Update the value of the latest sequence number for
// the peer (this is stored in the database).
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
db.add_peer(peer.set_latest_sequence(latest_sequence))
.unwrap();
}
}
/// Request the name of the peer represented by the given public key (ID)
/// and update the existing entry in the database.
async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
match sbot::get_name(&peer_id).await {
Ok(name) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
let updated_peer = peer.set_name(&name);
match db.add_peer(updated_peer) {
Ok(_) => info!("Updated name for peer: {}", &peer_id),
Err(e) => {
warn!("Failed to update name for peer: {}: {}", &peer_id, e)
}
}
}
}
Err(e) => warn!("Failed to fetch name for {}: {}", &peer_id, e),
}
}
pub enum Task {
Cancel,
FetchAllPosts(String),
FetchLatestPosts(String),
FetchLatestName(String),
}
/// Spawn an asynchronous loop which receives tasks over an unbounded channel
/// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch all messages authored by the given peer, filter
// the root posts and insert them into the posts tree of the
// database.
Task::FetchAllPosts(peer_id) => {
info!("Fetching all posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, 0).await;
}
// Fetch only the latest messages authored by the given peer,
// ie. messages with sequence numbers greater than those
// which are already stored in the database.
//
// Retrieve the root posts from those messages and insert them
// into the posts tree of the database.
Task::FetchLatestPosts(peer_id) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
info!("Fetching latest posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, peer.latest_sequence).await;
}
}
// Fetch the latest name for the given peer and update the
// peer entry in the peers tree of the database.
Task::FetchLatestName(peer_id) => {
info!("Fetching latest name for peer: {}", peer_id);
fetch_name_and_update_db(&db, peer_id).await;
}
// Break out of the task loop.
Task::Cancel => {
info!("Exiting task loop...");
break;
}
}
}
});
}

View File

@ -0,0 +1,32 @@
//! Public key validation.
/// Ensure that the given public key is a valid ed25519 key.
///
/// Return an error string if the key is invalid.
pub fn validate_public_key(public_key: &str) -> Result<(), String> {
// Ensure the ID starts with the correct sigil link.
if !public_key.starts_with('@') {
return Err("expected '@' sigil as first character".to_string());
}
// Find the dot index denoting the start of the algorithm definition tag.
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => return Err("no dot index was found".to_string()),
};
// Check the hashing algorithm (must end with ".ed25519").
if !&public_key.ends_with(".ed25519") {
return Err("hashing algorithm must be ed25519".to_string());
}
// Obtain the base64 portion (substring) of the public key.
let base64_str = &public_key[1..dot_index];
// Ensure the length of the base64 encoded ed25519 public key is correct.
if base64_str.len() != 44 {
return Err("base64 data length is incorrect".to_string());
}
Ok(())
}

View File

@ -0,0 +1,153 @@
.content {
background-color: lightyellow;
border: 5px solid #ffd700;
border-radius: 1rem;
grid-area: content;
padding: 1.5rem;
overflow-y: scroll;
word-wrap: anywhere;
}
.container {
height: 100%;
width: 100%;
margin: 0;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.flash-message {
margin-left: auto;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
color: red;
}
.flex-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flex-container > input {
margin: 0.3rem;
}
.grid-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr 3fr;
grid-template-areas: 'nav' 'peers' 'posts' 'content';
grid-gap: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.2rem;
overflow: hidden;
height: 85vh;
}
@media only screen and (min-width: 600px) {
.grid-container {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 3fr 4fr;
grid-template-areas:
'nav nav nav nav nav'
'peers posts posts posts posts'
'peers content content content content';
}
}
.icon {
margin-left: 1rem;
}
.nav {
background-color: lightgreen;
border: 5px solid #19a974;
border-radius: 1rem;
grid-area: nav;
padding: 1rem;
}
.peers {
background-color: lightblue;
border: 5px solid #357edd;
border-radius: 1rem;
grid-area: peers;
text-align: left;
}
.peers > ul {
padding-left: 1rem;
}
.peers > ul > li > a {
justify-content: space-between;
}
.peers > ul > li > a > p {
margin: 0;
font-weight: bold;
padding-right: 1rem;
}
.post > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts {
background-color: bisque;
border: 5px solid #ff6300;
border-radius: 1rem;
grid-area: posts;
overflow-y: scroll;
}
.posts > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts > ul > li > a {
justify-content: space-between;
}
.posts > ul > li > a > p {
margin: 0;
}
.selected {
background-color: #f9c587;
}
a {
text-decoration: none;
color: black;
}
code {
word-wrap: anywhere;
}
form {
margin-left: auto;
margin-right: 0.5rem;
}
h1 {
margin-left: 1rem;
}
img {
width: 3rem;
}
li {
list-style: none;
font-size: 12px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<a href="https://www.flaticon.com/free-icons/download" title="download icons">Download icons created by Kiranshastry - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</code>
</a>
</li>
{%- endfor %}
</ul>
</div>

View File

@ -0,0 +1,5 @@
<div class="content">
{% if post %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') | trim }}
{% endif %}
</div>

View File

@ -0,0 +1,20 @@
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container" href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,28 @@
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
<input type="text" id="public_key" name="public_key" maxlength=53>
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,18 @@
[package]
name = "part_9_read_delete"
version = "0.1.0"
edition = "2021"
[dependencies]
async-std = "1.10"
bincode = "1.3"
chrono = "0.4"
futures = "0.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
log = "0.4"
rocket = "0.5.0-rc.1"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
serde = "1"
serde_json = "1"
sled = "0.34"
xdg = "2.4.1"

View File

@ -0,0 +1,410 @@
# lykin tutorial
## Part 9: Read, Unread and Delete
### Introduction
In the last installment we implemented the functionality necessary to display posts in the web interface of our application, bringing it close to completion. Today we're going to add the finishing touches. We'll count and display the number of unread posts for each peer we subscribe to and add the ability to mark a post as read or unread. To finish things off, we'll allow the user to delete individual posts via the web interface. This installment will touch the database, route handlers and templates. Let's get started!
### Outline
- Count unread posts
- Display unread post count
- Mark a post as read
- Mark a post as unread
- Remove a post from the database
- Update navigation template
### Count Unread Posts
We'll begin by implementing a database method that takes a public key as input and returns the total number of unread posts authored by that peer in our key-value store. The logic is fairly simple: retrieve all posts by the peer, iterate through them and increment a counter every time an unread post is encountered.
`src/db.rs`
```rust
impl Database {
// ...
// Sum the total number of unread posts for the peer represented by the
// given public key.
pub fn get_unread_post_count(&self, public_key: &str) -> u16 {
debug!(
"Counting total number of unread posts for peer {}",
&public_key
);
let mut unread_post_counter = 0;
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
let deserialized_post: Post = bincode::deserialize(&post.1).unwrap();
if !deserialized_post.read {
unread_post_counter += 1
}
});
unread_post_counter
}
}
```
### Display Unread Post Count
Now we can update the peer list of our web application to show the number of unread posts next to the name of each peer. This behaviour is similar to what you might see in an email client, where the number of unread messages is displayed alongside the name of each folder in your mailbox.
First we need to update the `home`, `posts` and `post` route handlers of our application to retrieve the unread post counts and pass them into the template as context variables.
`src/routes.rs`
```rust
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
// Count the total unread posts for the given peer.
let unread_count = db.get_unread_post_count(&peer.public_key);
// Push a tuple of the peer data and peer unread post count
// to the `peers_unread` vector.
peers_unread.push((peer, unread_count.to_string()));
}
Template::render("base", context! { peers: &peers_unread, flash: flash })
}
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
let context = context! {
selected_peer: &public_key,
peers: &peers_unread,
posts: &posts
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers_unread,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post
};
Template::render("base", context)
}
```
You'll notice that the main change in the code above, when compared to the code from previous installments, is the `peers_unread` vector and populating loop:
```rust
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
```
The other difference is that we now pass `context! { peers: &peers_unread, ... }` instead of `context! { peers: &peers, ... }`.
Now we need to update the peers list template to utilise the newly-provided unread post count data.
`templates/peer_list.html.tera`
```html
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.0.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.0.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.0.name %}
{{ peer.0.name }}
{% else %}
{{ peer.0.public_key }}
{% endif %}
</code>
{% if peer.1 != "0" %}<p>{{ peer.1 }}</p>{% endif %}
</a>
</li>
{%- endfor %}
</ul>
</div>
```
Since the `peers` context variable is a tuple of `(peer, unread_post_count)`, we use tuple indexing when referencing the values (ie. `peer.0` for the `peer` data and `peer.1` for the `unread_post_count` data).
Run the application (`cargo run`) and you should now see the unread post count displayed next to the name of each peer in the peers list.
### Mark a Post as Read
Much like an email client, we want to be able to mark individual posts as either `read` or `unread`. We already have icons in place in our `topbar.html.tera` template with which to perform these actions. Now we need to write a route handler to mark a particular post as read.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>/read")]
pub async fn mark_post_read(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Retrieve the post from the database using the public key and msg_id
// from the URL.
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
// Mark the post as read.
post.read = true;
// Reinsert the modified post into the database.
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
```
### Mark a Post as Unread
We can now write the equivalent route handler for marking a post as unread.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>/unread")]
pub async fn mark_post_unread(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
post.read = false;
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
```
We still need to mount these routes to our Rocket application in `src/main.rs` and update the logic in our navigation template to wrap the `read` and `unread` icons in anchor elements with the correct URLs. We'll take care of that once we've written the backend code to remove a post from the database.
### Remove a Post From the Database
We already have a `remove_peer()` method in our database which is used when we unsubscribe from a peer. Now we'll write the equivalent method for a post.
Remember that we are deleting the post from our key-value store, _not_ the sbot database! The message will remain in the log kept by the sbot.
`src/db.rs`
```rust
impl Database {
// ...
// Remove a single post from the post tree, authored by the given public
// key and defined by the given message ID.
pub fn remove_post(&self, public_key: &str, msg_id: &str) -> Result<()> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!("Removing post {} from 'posts' database tree", &post_key);
// .remove() would ordinarily return the value of the deleted entry
// as an Option, returning None if the post_key was not found.
// We don't care about the value of the deleted entry so we simply
// map the Option to ().
self.post_tree.remove(post_key.as_bytes()).map(|_| ())
}
}
```
Now we need to write a route handler to respond to a delete request. Much like the route handlers for marking a post as read and unread, we include the public key of the peer and the message ID of the post in the URL. We then use those values when invoking the `remove_post()` method.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>/delete")]
pub async fn delete_post(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Delete the post from the database.
match db.remove_post(public_key, msg_id) {
Ok(_) => info!(
"Removed post {} by {} from 'posts' database tree",
msg_id, public_key
),
Err(e) => warn!(
"Failed to remove post {} by {} from 'posts' database tree: {}",
msg_id, public_key, e
),
}
Redirect::to(uri!(posts(public_key)))
}
```
The three routes we've created so far can now be mounted to the Rocket application.
`src/main.rs`
```rust
#[launch]
async fn rocket() -> _ {
// ...
rocket::build()
.manage(db)
.manage(tx)
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts,
mark_post_read,
mark_post_unread,
delete_post
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(Template::fairing())
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}
```
### Update Navigation Template
With the backend functionality in place, we can now update the navigation template to ensure the correct URLs are set for the 'mark as read, 'mark as unread' and 'delete post' elements. We will only enable those elements when a post is selected, signalling to the user when action is possible.
`templates/topbar.html.tera`
```html
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
{% if post_is_selected %}
{% set selected_peer_encoded = selected_peer | urlencode_strict %}
{% if post.read %}
{% set mark_unread_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/unread" %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a href={{ mark_unread_url }} class="icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% else %}
{% set mark_read_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/read" %}
<a href={{ mark_read_url }} class="icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% endif %}
{% set delete_post_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/delete" %}
<a href={{ delete_post_url }} class="icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% else %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% endif %}
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
{% if selected_peer %}
<input type="text" id="public_key" name="public_key" maxlength=53 value={{ selected_peer }}>
{% else %}
<input type="text" id="public_key" name="public_key" maxlength=53>
{% endif %}
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>
```
Now we have one more template-related change to make. We need to check the read / unread value of each post in the post list and render the text in bold if it is unread.
`templates/post_list.html.tera`
```html
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container"{% if not post.read %} style="font-weight: bold;"{% endif %} href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>
```
Notice the `{% if not post.read %}` syntax in the code above; that is where we selectively bold the line item for unread posts.
Everything should now be in place. Run the application (`cargo run`) and see how the user interface changes as you mark posts as read / unread and delete posts.
### Conclusion
We did it! We wrote a Scuttlebutt client application in Rust. Congratulations on making it this far in the tutorial! I really hope you've learned something through this experience and that you're feeling inspired to write your own applications or modify this one.
In the next installment of the series I'll share some ideas for improving and extending the application.
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

View File

@ -0,0 +1,303 @@
use std::path::Path;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sled::{Batch, Db, IVec, Result, Tree};
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
pub latest_sequence: u64,
}
impl Peer {
/// Create a new instance of the Peer struct using the given public
/// key. A default value is set for name.
pub fn new(public_key: &str) -> Peer {
Peer {
public_key: public_key.to_string(),
name: "".to_string(),
latest_sequence: 0,
}
}
/// Modify the name field of an instance of the Peer struct, leaving
/// the other values unchanged.
pub fn set_name(self, name: &str) -> Peer {
Self {
name: name.to_string(),
..self
}
}
/// Modify the latest_sequence field of an instance of the Peer struct,
/// leaving the other values unchanged.
pub fn set_latest_sequence(self, latest_sequence: u64) -> Peer {
Self {
latest_sequence,
..self
}
}
}
/// The text and metadata of a Scuttlebutt root post.
#[derive(Debug, Deserialize, Serialize)]
pub struct Post {
/// The key of the post-type message, also known as a message reference.
pub key: String,
/// The text of the post (may be formatted as markdown).
pub text: String,
/// The date the post was published (e.g. 17 May 2021).
pub date: String,
/// The sequence number of the post-type message.
pub sequence: u64,
/// The read state of the post; true if read, false if unread.
pub read: bool,
/// The timestamp representing the date the post was published.
pub timestamp: i64,
/// The subject of the post, represented as the first 53 characters of
/// the post text.
pub subject: Option<String>,
}
impl Post {
// Create a new instance of the Post struct. A default value of `false` is
// set for `read`.
pub fn new(
key: String,
text: String,
date: String,
sequence: u64,
timestamp: i64,
subject: Option<String>,
) -> Post {
Post {
key,
text,
date,
sequence,
timestamp,
subject,
read: false,
}
}
}
/// An instance of the key-value database and relevant trees.
#[allow(dead_code)]
#[derive(Clone)]
pub struct Database {
/// The sled database instance.
db: Db,
/// A database tree containing Peer struct instances for all the peers
/// we are subscribed to.
peer_tree: Tree,
/// A database tree containing Post struct instances for all of the posts
/// we have downloaded from the peer to whom we subscribe.
pub post_tree: Tree,
}
impl Database {
/// Initialise the database by opening the database file, loading the
/// peers tree and returning an instantiated Database struct.
pub fn init(path: &Path) -> Self {
// Open the database at the given path.
// The database will be created if it does not yet exist.
// This code will panic if an IO error is encountered.
info!("Initialising sled database");
let db = sled::open(path).expect("Failed to open database");
debug!("Opening 'peers' database tree");
let peer_tree = db
.open_tree("peers")
.expect("Failed to open 'peers' database tree");
debug!("Opening 'posts' database tree");
let post_tree = db
.open_tree("posts")
.expect("Failed to open 'posts' database tree");
Database {
db,
peer_tree,
post_tree,
}
}
/// Add a peer to the database by inserting the public key into the peer
/// tree.
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
debug!("Serializing peer data for {} to bincode", &peer.public_key);
let peer_bytes = bincode::serialize(&peer).unwrap();
debug!(
"Inserting peer {} into 'peers' database tree",
&peer.public_key
);
self.peer_tree.insert(&peer.public_key, peer_bytes)
}
/// Get a single peer from the peer tree, defined by the given public key.
/// The byte value for the matching entry, if found, is deserialized from
/// bincode into an instance of the Peer struct.
pub fn get_peer(&self, public_key: &str) -> Result<Option<Peer>> {
debug!(
"Retrieving peer data for {} from 'peers' database tree",
&public_key
);
let peer = self
.peer_tree
.get(public_key.as_bytes())
.unwrap()
.map(|peer| {
debug!("Deserializing peer data for {} from bincode", &public_key);
bincode::deserialize(&peer).unwrap()
});
Ok(peer)
}
/// Get a list of all peers in the peer tree. The byte value for each
/// peer entry is deserialized from bincode into an instance of the Peer
/// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
/// Remove a peer from the database, as represented by the given public
/// key.
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
debug!("Removing peer {} from 'peers' database tree", &public_key);
self.peer_tree.remove(&public_key).map(|_| ())
}
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
self.post_tree.insert(post_key.as_bytes(), post_bytes)
}
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
let mut post_batch = Batch::default();
for post in posts {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
post_batch.insert(post_key.as_bytes(), post_bytes)
}
debug!("Applying batch insertion into 'posts' database tree");
self.post_tree.apply_batch(post_batch)
}
/// Get a list of all posts in the post tree authored by the given public
/// key and sort them by timestamp in descending order. The byte value for
/// each matching entry is deserialized from bincode into an instance of
/// the Post struct.
pub fn get_posts(&self, public_key: &str) -> Result<Vec<Post>> {
debug!("Retrieving data for all posts in the 'posts' database tree");
let mut posts = Vec::new();
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
posts.push(bincode::deserialize(&post.1).unwrap())
});
posts.sort_by(|a: &Post, b: &Post| b.timestamp.cmp(&a.timestamp));
Ok(posts)
}
/// Get a single post from the post tree, authored by the given public key
/// and defined by the given message ID. The byte value for the matching
/// entry, if found, is deserialized from bincode into an instance of the
/// Post struct.
pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result<Option<Post>> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!(
"Retrieving post data for {} from 'posts' database tree",
&post_key
);
let post = self
.post_tree
.get(post_key.as_bytes())
.unwrap()
.map(|post| {
debug!("Deserializing post data for {} from bincode", &post_key);
bincode::deserialize(&post).unwrap()
});
Ok(post)
}
/// Remove a single post from the post tree, authored by the given public
/// key and defined by the given message ID.
pub fn remove_post(&self, public_key: &str, msg_id: &str) -> Result<()> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!("Removing post {} from 'posts' database tree", &post_key);
// .remove() would ordinarily return the value of the deleted entry
// as an Option, returning None if the post_key was not found.
// We don't care about the value of the deleted entry so we simply
// map the Option to ().
self.post_tree.remove(post_key.as_bytes()).map(|_| ())
}
/// Sum the total number of unread posts for the peer represented by the
/// given public key.
pub fn get_unread_post_count(&self, public_key: &str) -> u16 {
debug!(
"Counting total number of unread posts for peer {}",
&public_key
);
let mut unread_post_counter = 0;
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
let deserialized_post: Post = bincode::deserialize(&post.1).unwrap();
if !deserialized_post.read {
unread_post_counter += 1
}
});
unread_post_counter
}
}

View File

@ -0,0 +1,63 @@
#![doc = include_str!("../README.md")]
mod db;
mod routes;
mod sbot;
mod task_loop;
mod utils;
use async_std::channel;
use log::info;
use rocket::{
fairing::AdHoc,
fs::{relative, FileServer},
launch, routes,
};
use rocket_dyn_templates::Template;
use xdg::BaseDirectories;
use crate::{db::Database, routes::*, task_loop::Task};
#[launch]
async fn rocket() -> _ {
// Create the key-value database.
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
let db_path = xdg_dirs
.place_config_file("database")
.expect("cannot create database directory");
let db = Database::init(&db_path);
let db_clone = db.clone();
// Create a message passing channel.
let (tx, rx) = channel::unbounded();
let tx_clone = tx.clone();
// Spawn the task loop, passing in the receiver half of the channel.
info!("Spawning task loop");
task_loop::spawn(db_clone, rx).await;
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts,
mark_post_read,
mark_post_unread,
delete_post
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}

View File

@ -0,0 +1,242 @@
use async_std::channel::Sender;
use log::{info, warn};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri, FromForm, State,
};
use rocket_dyn_templates::{context, Template};
use crate::{
db::{Database, Peer},
sbot,
task_loop::Task,
utils,
};
#[derive(FromForm)]
pub struct PeerForm {
pub public_key: String,
}
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
Template::render("base", context! { peers: &peers_unread, flash: flash })
}
#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(
db: &State<Database>,
tx: &State<Sender<Task>>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
// Retrieve the name of the peer to which we are subscribing.
let peer_name = match sbot::get_name(&peer.public_key).await {
Ok(name) => name,
Err(e) => {
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
// Return an empty string if an error occurs.
String::from("")
}
};
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
match sbot::follow_if_not_following(&peer.public_key).await {
Ok(_) => {
// Add the peer to the database.
if db.add_peer(peer_info).is_ok() {
info!("Added {} to 'peers' database tree", &peer.public_key);
let peer_id = peer.public_key.to_string();
// Fetch all root posts authored by the peer we're subscribing
// to. Posts will be added to the key-value database.
if let Err(e) = tx.send(Task::FetchAllPosts(peer_id)).await {
warn!("Task loop error: {}", e)
}
} else {
let err_msg = format!(
"Failed to add peer {} to 'peers' database tree",
&peer.public_key
);
warn!("{}", err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(
db: &State<Database>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
match sbot::unfollow_if_following(&peer.public_key).await {
Ok(_) => {
// Remove the peer from the database.
if db.remove_peer(&peer.public_key).is_ok() {
info!(
"Removed peer {} from 'peers' database tree",
&peer.public_key
);
} else {
warn!(
"Failed to remove peer {} from 'peers' database tree",
&peer.public_key
);
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[get("/posts/download_latest")]
pub async fn download_latest_posts(db: &State<Database>, tx: &State<Sender<Task>>) -> Redirect {
for peer in db.get_peers() {
// Fetch the latest root posts authored by each peer we're
// subscribed to. Posts will be added to the key-value database.
if let Err(e) = tx
.send(Task::FetchLatestPosts(peer.public_key.clone()))
.await
{
warn!("Task loop error: {}", e)
}
// Fetch the latest name for each peer we're subscribed to and update
// the database.
if let Err(e) = tx.send(Task::FetchLatestName(peer.public_key)).await {
warn!("Task loop error: {}", e)
}
}
Redirect::to(uri!(home))
}
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
// Define context data to be rendered in the template.
let context = context! {
selected_peer: &public_key,
peers: &peers_unread,
posts: &posts
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let mut peers_unread = Vec::new();
for peer in peers {
let unread_count = db.get_unread_post_count(&peer.public_key);
peers_unread.push((peer, unread_count.to_string()));
}
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers_unread,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post,
post_is_selected: &true
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>/read")]
pub async fn mark_post_read(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Retrieve the post from the database using the public key and msg_id
// from the URL.
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
// Mark the post as read.
post.read = true;
// Reinsert the modified post into the database.
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
#[get("/posts/<public_key>/<msg_id>/unread")]
pub async fn mark_post_unread(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
if let Ok(Some(mut post)) = db.get_post(public_key, msg_id) {
post.read = false;
db.add_post(public_key, post).unwrap();
} else {
warn!(
"Failed to find post {} authored by {} in 'posts' database tree",
msg_id, public_key
)
}
Redirect::to(uri!(post(public_key, msg_id)))
}
#[get("/posts/<public_key>/<msg_id>/delete")]
pub async fn delete_post(db: &State<Database>, public_key: &str, msg_id: &str) -> Redirect {
// Delete the post from the database.
match db.remove_post(public_key, msg_id) {
Ok(_) => info!(
"Removed post {} by {} from 'posts' database tree",
msg_id, public_key
),
Err(e) => warn!(
"Failed to remove post {} by {} from 'posts' database tree: {}",
msg_id, public_key, e
),
}
Redirect::to(uri!(posts(public_key)))
}

View File

@ -0,0 +1,213 @@
use std::env;
use async_std::stream::StreamExt;
use chrono::NaiveDateTime;
use golgi::{
api::{friends::RelationshipQuery, history_stream::CreateHistoryStream},
messages::{SsbMessageContentType, SsbMessageKVT},
sbot::Keystore,
GolgiError, Sbot,
};
use log::{info, warn};
use serde_json::value::Value;
use crate::db::Post;
/// Initialise a connection to a Scuttlebutt server.
pub async fn init_sbot() -> Result<Sbot, String> {
let go_sbot_port = env::var("GO_SBOT_PORT").unwrap_or_else(|_| "8021".to_string());
let keystore = Keystore::GoSbot;
let ip_port = Some(format!("127.0.0.1:{}", go_sbot_port));
let net_id = None;
Sbot::init(keystore, ip_port, net_id)
.await
.map_err(|e| e.to_string())
}
/// Return the public key of the local sbot instance.
pub async fn whoami() -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.whoami().await.map_err(|e| e.to_string())
}
/// Check follow status.
///
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
let query = RelationshipQuery {
source: public_key_a.to_string(),
dest: public_key_b.to_string(),
};
sbot.friends_is_following(query)
.await
.map_err(|e| e.to_string())
}
/// Follow a peer.
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.follow(public_key).await.map_err(|e| e.to_string())
}
/// Unfollow a peer.
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
}
/// Return the name (self-identifier) for the peer associated with the given
/// public key.
///
/// The public key of the peer will be returned if a name is not found.
pub async fn get_name(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.get_name(public_key).await.map_err(|e| e.to_string())
}
/// Check the follow status of a remote peer and follow them if not already
/// following.
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
Ok(_) => {
info!("Followed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
},
Ok(status) if status.as_str() == "true" => {
info!(
"Already following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Check the follow status of a remote peer and unfollow them if already
/// following.
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {
Ok(_) => {
info!("Unfollowed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
}
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Return a stream of messages authored by the given public key.
///
/// This returns all messages regardless of type.
pub async fn get_message_stream(
public_key: &str,
sequence_number: u64,
) -> impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>> {
let mut sbot = init_sbot().await.unwrap();
let history_stream_args = CreateHistoryStream::new(public_key.to_string())
.keys_values(true, true)
.after_seq(sequence_number);
sbot.create_history_stream(history_stream_args)
.await
.unwrap()
}
/// Filter a stream of messages and return a vector of root posts.
///
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
pub async fn get_root_posts(
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
) -> (u64, Vec<Post>) {
let mut latest_sequence = 0;
let mut posts = Vec::new();
futures::pin_mut!(history_stream);
while let Some(res) = history_stream.next().await {
match res {
Ok(msg) => {
if msg.value.is_message_type(SsbMessageContentType::Post) {
let content = msg.value.content.to_owned();
if let Value::Object(content_map) = content {
if !content_map.contains_key("root") {
latest_sequence = msg.value.sequence;
let text = match content_map.get_key_value("text") {
Some(value) => value.1.to_string(),
None => String::from(""),
};
let timestamp = msg.value.timestamp.round() as i64 / 1000;
let datetime = NaiveDateTime::from_timestamp(timestamp, 0);
let date = datetime.format("%d %b %Y").to_string();
let subject = text.get(0..52).map(|s| s.to_string());
let post = Post::new(
msg.key.to_owned(),
text,
date,
msg.value.sequence,
timestamp,
subject,
);
posts.push(post)
}
}
}
}
Err(err) => {
// Print the `GolgiError` of this element to `stderr`.
warn!("err: {:?}", err);
}
}
}
(latest_sequence, posts)
}

View File

@ -0,0 +1,96 @@
use async_std::{channel::Receiver, task};
use log::{info, warn};
use crate::{sbot, Database};
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
// Update the value of the latest sequence number for
// the peer (this is stored in the database).
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
db.add_peer(peer.set_latest_sequence(latest_sequence))
.unwrap();
}
}
/// Request the name of the peer represented by the given public key (ID)
/// and update the existing entry in the database.
async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
match sbot::get_name(&peer_id).await {
Ok(name) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
let updated_peer = peer.set_name(&name);
match db.add_peer(updated_peer) {
Ok(_) => info!("Updated name for peer: {}", &peer_id),
Err(e) => {
warn!("Failed to update name for peer: {}: {}", &peer_id, e)
}
}
}
}
Err(e) => warn!("Failed to fetch name for {}: {}", &peer_id, e),
}
}
pub enum Task {
Cancel,
FetchAllPosts(String),
FetchLatestPosts(String),
FetchLatestName(String),
}
/// Spawn an asynchronous loop which receives tasks over an unbounded channel
/// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch all messages authored by the given peer, filter
// the root posts and insert them into the posts tree of the
// database.
Task::FetchAllPosts(peer_id) => {
info!("Fetching all posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, 0).await;
}
// Fetch only the latest messages authored by the given peer,
// ie. messages with sequence numbers greater than those
// which are already stored in the database.
//
// Retrieve the root posts from those messages and insert them
// into the posts tree of the database.
Task::FetchLatestPosts(peer_id) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
info!("Fetching latest posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, peer.latest_sequence).await;
}
}
// Fetch the latest name for the given peer and update the
// peer entry in the peers tree of the database.
Task::FetchLatestName(peer_id) => {
info!("Fetching latest name for peer: {}", peer_id);
fetch_name_and_update_db(&db, peer_id).await;
}
// Break out of the task loop.
Task::Cancel => {
info!("Exiting task loop...");
break;
}
}
}
});
}

View File

@ -0,0 +1,32 @@
//! Public key validation.
/// Ensure that the given public key is a valid ed25519 key.
///
/// Return an error string if the key is invalid.
pub fn validate_public_key(public_key: &str) -> Result<(), String> {
// Ensure the ID starts with the correct sigil link.
if !public_key.starts_with('@') {
return Err("expected '@' sigil as first character".to_string());
}
// Find the dot index denoting the start of the algorithm definition tag.
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => return Err("no dot index was found".to_string()),
};
// Check the hashing algorithm (must end with ".ed25519").
if !&public_key.ends_with(".ed25519") {
return Err("hashing algorithm must be ed25519".to_string());
}
// Obtain the base64 portion (substring) of the public key.
let base64_str = &public_key[1..dot_index];
// Ensure the length of the base64 encoded ed25519 public key is correct.
if base64_str.len() != 44 {
return Err("base64 data length is incorrect".to_string());
}
Ok(())
}

View File

@ -0,0 +1,153 @@
.content {
background-color: lightyellow;
border: 5px solid #ffd700;
border-radius: 1rem;
grid-area: content;
padding: 1.5rem;
overflow-y: scroll;
word-wrap: anywhere;
}
.container {
height: 100%;
width: 100%;
margin: 0;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.flash-message {
margin-left: auto;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
color: red;
}
.flex-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flex-container > input {
margin: 0.3rem;
}
.grid-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr 3fr;
grid-template-areas: 'nav' 'peers' 'posts' 'content';
grid-gap: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.2rem;
overflow: hidden;
height: 85vh;
}
@media only screen and (min-width: 600px) {
.grid-container {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 3fr 4fr;
grid-template-areas:
'nav nav nav nav nav'
'peers posts posts posts posts'
'peers content content content content';
}
}
.icon {
margin-left: 1rem;
}
.nav {
background-color: lightgreen;
border: 5px solid #19a974;
border-radius: 1rem;
grid-area: nav;
padding: 1rem;
}
.peers {
background-color: lightblue;
border: 5px solid #357edd;
border-radius: 1rem;
grid-area: peers;
text-align: left;
}
.peers > ul {
padding-left: 1rem;
}
.peers > ul > li > a {
justify-content: space-between;
}
.peers > ul > li > a > p {
margin: 0;
font-weight: bold;
padding-right: 1rem;
}
.post > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts {
background-color: bisque;
border: 5px solid #ff6300;
border-radius: 1rem;
grid-area: posts;
overflow-y: scroll;
}
.posts > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts > ul > li > a {
justify-content: space-between;
}
.posts > ul > li > a > p {
margin: 0;
}
.selected {
background-color: #f9c587;
}
a {
text-decoration: none;
color: black;
}
code {
word-wrap: anywhere;
}
form {
margin-left: auto;
margin-right: 0.5rem;
}
h1 {
margin-left: 1rem;
}
img {
width: 3rem;
}
li {
list-style: none;
font-size: 12px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<a href="https://www.flaticon.com/free-icons/download" title="download icons">Download icons created by Kiranshastry - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>

View File

@ -0,0 +1,18 @@
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.0.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.0.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.0.name %}
{{ peer.0.name }}
{% else %}
{{ peer.0.public_key }}
{% endif %}
</code>
{% if peer.1 != "0" %}<p>{{ peer.1 }}</p>{% endif %}
</a>
</li>
{%- endfor %}
</ul>
</div>

View File

@ -0,0 +1,5 @@
<div class="content">
{% if post %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') | trim }}
{% endif %}
</div>

View File

@ -0,0 +1,20 @@
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container"{% if not post.read %} style="font-weight: bold;"{% endif %} href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,55 @@
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
{% if post_is_selected %}
{% set selected_peer_encoded = selected_peer | urlencode_strict %}
{% set selected_post_encoded = selected_post | urlencode_strict %}
{% if post.read %}
{% set mark_unread_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/unread" %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a href={{ mark_unread_url }} class="icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% else %}
{% set mark_read_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/read" %}
<a href={{ mark_read_url }} class="icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
{% endif %}
{% set delete_post_url = "/posts/" ~ selected_peer_encoded ~ "/" ~ selected_post_encoded ~ "/delete" %}
<a href={{ delete_post_url }} class="icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% else %}
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
{% endif %}
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
{% if selected_peer %}
<input type="text" id="public_key" name="public_key" maxlength=53 value={{ selected_peer }}>
{% else %}
<input type="text" id="public_key" name="public_key" maxlength=53>
{% endif %}
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>