Compare commits
	
		
			952 Commits
		
	
	
		
			c9f997d121
			...
			v0.2025.10
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4052e3235f | |||
| 13302ad1c7 | |||
| 0f8687e473 | |||
| 9399ccd684 | |||
| b3604039fa | |||
| 732089da2c | |||
| b3e7e4b196 | |||
| 09a0cfd349 | |||
| 5bf7346321 | |||
| dd558c57e0 | |||
| 5647196924 | |||
| 49b1834bc6 | |||
| d111647ea8 | |||
| d7580dab9b | |||
| 28db8a8d5f | |||
| e64d5617e7 | |||
| acd114650a | |||
| 7a47ffaa61 | |||
| 42f7f66f35 | |||
| b2b4ffeeae | |||
| 26de1f7daa | |||
| 07605933dc | |||
| 4bdc7ec616 | |||
| 8ca64550e5 | |||
| 25dbac804c | |||
| 6ab3fd168b | |||
| 94858e2371 | |||
| 6d13502e94 | |||
| 77001e595c | |||
| 6fad20ffa3 | |||
| 00fb6c9839 | |||
| 97fcf72d63 | |||
| 5d8d02515d | |||
| 859fe1feb0 | |||
| 8f61d83f41 | |||
| 6423b3e479 | |||
| 2bc8cec8a2 | |||
| b49a6cd685 | |||
| 2885380f40 | |||
| 2ec3b6a249 | |||
| 3ef795452d | |||
| 479d87c8b8 | |||
| a56077dcc7 | |||
| d3f4587c3b | |||
| 623705b7a1 | |||
| 8f87f4751d | |||
| 2ac6dfde9d | |||
| 81ade7a400 | |||
| 63f7ff9f27 | |||
| 8a0fa17a79 | |||
| 0ead5ed967 | |||
| 53261a6fbc | |||
| c60ff86a4d | |||
| 83a0b017c5 | |||
| 3746622a11 | |||
| ccd50cf59f | |||
| 93680eb43d | |||
| 3ae4b7086a | |||
| 446b1f8600 | |||
| 00fd208a2c | |||
| e574d03716 | |||
| c1f3116c9d | |||
| 3aec7e6c14 | |||
| 9f0020dec8 | |||
| 6e78ad9729 | |||
| 44d84a9b2a | |||
| ac7809415c | |||
| 675cecaa20 | |||
| 5d179cc088 | |||
| d905618590 | |||
| 3fd9bc0b18 | |||
| 39abee7f73 | |||
| b770619111 | |||
| 1c44857da4 | |||
| bca4440867 | |||
| 4855543961 | |||
| cb3d6a98b9 | |||
| ada67a13d3 | |||
| f4c928f26e | |||
| 91fd515d39 | |||
| be6e841d3d | |||
| af6afa6903 | |||
| 6ab5d2a28d | |||
| 4be033f288 | |||
| c550f92003 | |||
| ed836b3ee0 | |||
| ac7a43abf4 | |||
| 49f19fce91 | |||
| b2197eb8e9 | |||
| 5fbc2cae1c | |||
| 730abb49ce | |||
| edccab054a | |||
| e8210c6fdd | |||
| 55d69d7c13 | |||
| 50fb18d4ff | |||
| 77b1ea1fc8 | |||
| 61501a9b64 | |||
| c1507adac5 | |||
| 45fb9eda1c | |||
| 982a61f4bf | |||
| 18e5b41663 | |||
| 910c39cbd0 | |||
| 9952dfd49d | |||
| 00f75d5382 | |||
| d78828554b | |||
| b84b561109 | |||
| a618815500 | |||
| e1f3dc6ae4 | |||
| f378db6c6f | |||
| 7cec0f7d61 | |||
| f902d0374c | |||
| b5f0a0c4f7 | |||
| 00623cea09 | |||
| ed4f1d6f2c | |||
| 73f4a3407f | |||
| 6f11318e84 | |||
| e88ee91f0e | |||
| 3f8daf257c | |||
| dc387acadc | |||
| 68aa41ab96 | |||
| 85b23437b3 | |||
| c59fba817d | |||
| c3415ab75c | |||
| f1d0151d71 | |||
| 3c5c1756d1 | |||
| 6a6b65d1b3 | |||
| 81bd54dbe6 | |||
| 6a1bb0d3bc | |||
| 705e8b553f | |||
| e4729b22f2 | |||
| 662112551a | |||
| 38fe88aab8 | |||
| 578c51faa0 | |||
| a3ccc73b81 | |||
| 7312f4d43a | |||
| 8b546c7e02 | |||
| c0b6ff2e64 | |||
| 638b7cc1e5 | |||
| 05e54e1be0 | |||
| 4c3299ead0 | |||
| 1ef56b35ad | |||
| 061e79c295 | |||
| 5edfe732b1 | |||
| a8f9b67f71 | |||
| de7fbf1eb7 | |||
| a51a3d7e43 | |||
| 433b3b1003 | |||
| 6703c5b584 | |||
| 5f729efabe | |||
| b2085b3f28 | |||
| 2f893494b0 | |||
| e26af21f63 | |||
| 7e1d738f8d | |||
| 199448e11e | |||
| fdaabab807 | |||
| ca4560c5c9 | |||
| 2478f3064d | |||
| e9b8b43e7c | |||
| 951155f1b6 | |||
| 1b678175ef | |||
| 8eb1f40eec | |||
| 235887b3bf | |||
| 0b3d66dd48 | |||
| beb9ef3754 | |||
| 9f6a480736 | |||
| b3bac2927d | |||
| ef389f2ba2 | |||
| ef21dc6ae8 | |||
| 6e55b6b49e | |||
| db115ef1bd | |||
| 678838dbd5 | |||
| 586f87625d | |||
| 1542370f9b | |||
| 1f7d5968c7 | |||
| 39e51f7790 | |||
| 052663efbe | |||
| 8f84ff2611 | |||
| 37e1c5d97b | |||
| cef526bcf3 | |||
| 6af36cafa9 | |||
| fca859d93d | |||
| 2178300d8d | |||
| 636bdcce6b | |||
| 94b7703ca9 | |||
| a391dd1316 | |||
| b6ba5211b7 | |||
| 8e8e130045 | |||
| 1f40bc1a0f | |||
| 5437212222 | |||
| a8ab845cd2 | |||
| 8cee6dc98b | |||
| 70c2b73414 | |||
| 98013c4422 | |||
| e9e22b762d | |||
| 620db19936 | |||
| 94a79dd62c | |||
| b56c3efde0 | |||
| 066827f8f1 | |||
| c3b65d9cd8 | |||
| a15b916b06 | |||
| 31d0a5c233 | |||
| 140179e80a | |||
| 53cba2d7e4 | |||
| e54312d3b8 | |||
| cadc27b7b5 | |||
| 388b829ec1 | |||
| 67861f0f33 | |||
| a1f1eb34d5 | |||
| 2a6789063e | |||
| cbf1273a55 | |||
| 8143a23ced | |||
| 3c17810747 | |||
| bea7a2e9ed | |||
| 2f0a2ac6b0 | |||
| 18908b6b56 | |||
| b135a210cc | |||
| 3a2a829940 | |||
| e56dd2dd2d | |||
| 3f41a48bc7 | |||
| 65ed53281a | |||
| 1121557a2e | |||
| d4a7b86ee7 | |||
| 626c18b04e | |||
| bfa97ed7c7 | |||
| deae4d5367 | |||
| 899605c860 | |||
| dc9a279991 | |||
| 2a53892581 | |||
| 6bef0eb764 | |||
| 462b40640c | |||
| 72e1b2025c | |||
| fc7c4b1257 | |||
| 6c22c59056 | |||
| 94c2b1184f | |||
| 45231d703d | |||
| 7882fcbe8f | |||
| 3bbc8c4d35 | |||
| 8ae10dc80b | |||
| 9b11c2c629 | |||
| e2a231fb4a | |||
| 8a9502d1f2 | |||
| 534438df63 | |||
| 45a4feec96 | |||
| aa7a32395e | |||
| ab9f57f044 | |||
| 4040d6aa08 | |||
| 1c96f5c35e | |||
| 4d3e42812d | |||
| f7b3711d4f | |||
| 2408e076ff | |||
| 6f71ffb477 | |||
| 214433f36a | |||
| 309b22732e | |||
| 6fe7687b2a | |||
| a8cbf757ff | |||
| 4a4bedfe2b | |||
| 051291f725 | |||
| d2b338095f | |||
| 899827a8f2 | |||
| 5fcbe3d6a9 | |||
| a0a40e6cb2 | |||
| bb1190e3f8 | |||
| 0a3baed1da | |||
| 4931c489ed | |||
| 996f9abaa2 | |||
| 08c097e176 | |||
| daa861a98b | |||
| a25d08fd76 | |||
| 392d31cc53 | |||
| 92926fa8df | |||
| 61ae9ae465 | |||
| 89622697d5 | |||
| 17694f5646 | |||
| 5a1303149f | |||
| 8a0e190a86 | |||
| 0d7dfd8c9e | |||
| f979ff7050 | |||
| e3fcdea362 | |||
| 476fec2757 | |||
| 53c215399b | |||
| 2c330802da | |||
| 851d7046ea | |||
| c0019d7246 | |||
| 7688e4d3a8 | |||
| ef58749ce3 | |||
| 35941a7ddc | |||
| 1f2664e5a8 | |||
| 35656a5c34 | |||
| 799f22e989 | |||
| e226a37251 | |||
| 8e3bc9d700 | |||
| 58c3e6c2ab | |||
| 0dc148bfea | |||
| 3eff1b08a9 | |||
| 02d789471f | |||
| d367d47c4d | |||
| c93b8fc045 | |||
| eb9377e21d | |||
| a1764eee42 | |||
| 86ef74e20d | |||
| 4de53b9926 | |||
| 99a195a3fd | |||
| f1ced31f69 | |||
| b3cedf2baa | |||
| 3bf19fabda | |||
| cf81ebe8ad | |||
| 278b5566a1 | |||
| e8c1390f09 | |||
| 3c04abda45 | |||
| 2597f99ccf | |||
| 9d3a07c1cf | |||
| bdfd8925b5 | |||
| 1a4d1985f4 | |||
| 6273f3ea53 | |||
| 5bdc6fa471 | |||
| 3ba41291db | |||
| 0867811952 | |||
| 8d961cd805 | |||
| 97cea7b40b | |||
| 4106834db8 | |||
| a4a8f7cab2 | |||
| 9e209ee800 | |||
| ddfa84f040 | |||
| 6b3a6ec7c1 | |||
| 4d037c02bf | |||
| deaeab10d8 | |||
| 2a5375b1e7 | |||
| e7a03e3283 | |||
| efb3a12dcc | |||
| 3830d695d7 | |||
| f36620927b | |||
| 5423cbd628 | |||
| abde709e54 | |||
| 27f2d319ab | |||
| 66234b14bc | |||
| 6a9167e565 | |||
| 3c60f8ca06 | |||
| c26bf5c112 | |||
| 41cbde934a | |||
| 946941d95e | |||
| 50f0104239 | |||
| 40fa7edadf | |||
| d6926569c6 | |||
| a8bba324ca | |||
| 5bba5776b3 | |||
| 8104f6f228 | |||
| 3f4738e593 | |||
| 1516e17f5d | |||
| 676d2702b7 | |||
| 5d39548964 | |||
| 67d458bd38 | |||
| d9684c7d62 | |||
| 5a818d2119 | |||
| 6f96d4ce65 | |||
| f72395756a | |||
| 38d746b310 | |||
| f7270987ea | |||
| 6f565c0f0a | |||
| 7f252e79b6 | |||
| ba2bb17638 | |||
| bc7c658293 | |||
| 4d84e69bb5 | |||
| 03fac74908 | |||
| 5252ff1ecf | |||
| 20100d3fd4 | |||
| dd3b2656ad | |||
| 657f25e22b | |||
| 8be354fc49 | |||
| e574758340 | |||
| 40cf519492 | |||
| 0e4fda54e9 | |||
| 868f91e1ef | |||
| b9000c154f | |||
| 894c72a82f | |||
| c128cfc25c | |||
| 36c88b463c | |||
| 8a66e74074 | |||
| ea60b165da | |||
| 1011e0026b | |||
| 9462521287 | |||
| 576022c41a | |||
| 70c38b7ea8 | |||
| 36370f2dea | |||
| db9bf7f7bd | |||
| fa7aef0c37 | |||
| b135ea17f6 | |||
| 4b1643bc47 | |||
| 240a8ce9c7 | |||
| 8928e8722b | |||
| d692734e55 | |||
| 50197198b4 | |||
| 1ee1107c93 | |||
| cf90533b6c | |||
| f0211f621e | |||
| d9693af89b | |||
| 13722232fb | |||
| 0bcb033349 | |||
| e92c439724 | |||
| 7f34b585d3 | |||
| d7e9fd918a | |||
| 9899c0c5e2 | |||
| c50de0b0f0 | |||
| bb7d2d7ae0 | |||
| 862d172ca8 | |||
| 3671051d0e | |||
| 223e20cbbc | |||
| 9af4312561 | |||
| 934e40240e | |||
| edb1980387 | |||
| bb7b04013f | |||
| 26a3007268 | |||
| 5de2b09596 | |||
| 3660577a23 | |||
| 98b4c7cf04 | |||
| 427a7b8d25 | |||
| 67b84830cd | |||
| 973cd53266 | |||
| 1afdbe6932 | |||
| 942f582329 | |||
| 951a80389a | |||
| b7ecfc9925 | |||
| 59e389d793 | |||
| 2ec047cc00 | |||
| ee33f54745 | |||
| 7a79534ca8 | |||
| a74a9fc821 | |||
| e2c388b9db | |||
| 0f573ce09e | |||
| bc70e41b7c | |||
| f500e14aa3 | |||
| 8b47938238 | |||
| 8912212d8e | |||
| 6a346bf940 | |||
| b5bdae4611 | |||
| 6590da5793 | |||
| eb2b426ec7 | |||
| 4864a0411f | |||
| 68590cae33 | |||
| abf2bbaec2 | |||
| 0da7e2722f | |||
| 60d4b06057 | |||
| 4c3df34950 | |||
| 7737e60b52 | |||
| 71e816bc13 | |||
| c74f90ef04 | |||
| 26cb7e5a17 | |||
| 2bad6672d8 | |||
| 71c4011526 | |||
| 5e81078f59 | |||
| 31b78e74df | |||
| 2ff689aab0 | |||
| 0a6f0ed3f7 | |||
| bfec46673d | |||
| 3a16614c72 | |||
| 9c9efb845c | |||
| 6192f1b94d | |||
| ce451b2449 | |||
| 8534e16469 | |||
| f6cc6f2eae | |||
| 0904425221 | |||
| a729886522 | |||
| e5dfedc7d1 | |||
| f02423d084 | |||
| 8f4b6e83eb | |||
| 3029919553 | |||
| ac67db0591 | |||
| 1e08838f5b | |||
| d814f7ee77 | |||
| 7edfb9d386 | |||
| 6928d6caba | |||
| 1a626875cf | |||
| 6eb3b64334 | |||
| 09f3595e93 | |||
| 11e89622d4 | |||
| 0fa8acc264 | |||
| afc7c64ed8 | |||
| c794c1b885 | |||
| 6247529799 | |||
| ad3eedc1fb | |||
| 1cfac3cae6 | |||
| 478bcd5d13 | |||
| 6932da69b3 | |||
| 857f47bf55 | |||
| 373d742751 | |||
| 575622c522 | |||
| a3e86ccb1d | |||
| e491798ff1 | |||
| 15df4ac236 | |||
| 017a74c4e4 | |||
| 6e5b1127f3 | |||
| 95f4f88949 | |||
| 92858882d7 | |||
| 4cba95ec8c | |||
| bbb2f89d85 | |||
| 88213038b2 | |||
| cc18b41e76 | |||
| f16127a238 | |||
| 2a6ecfaede | |||
| cf4a09bf03 | |||
| 20e60262fd | |||
| 9e3928c976 | |||
| 95d8768545 | |||
| 8679d09040 | |||
| 4cc5c6acb3 | |||
| 6f9b548b1a | |||
| fbff3386a9 | |||
| e5899fca58 | |||
| dddec489b9 | |||
| b4049eaeaa | |||
| b3fd724b5a | |||
| aca25be86a | |||
| 0f8cbdac57 | |||
| 630219d667 | |||
| fef268e434 | |||
| e18dcf3a48 | |||
| e019320146 | |||
| 65b31e14f9 | |||
| 9cb5cbcde9 | |||
| 25914ff5a7 | |||
| c4af799279 | |||
| 3f8f0e14f4 | |||
| 5414b30e7f | |||
| 7aee897c1b | |||
| 4060f9cc11 | |||
| 5b526cbf5b | |||
| f5065ff42b | |||
| 86b5546f5f | |||
| 43ae2a293b | |||
| b8ddbd4255 | |||
| 87cdba1db8 | |||
| df83187e33 | |||
| eccdbf29ab | |||
| 28d181f8bc | |||
| d386daf2ff | |||
| 0c1e116c1e | |||
| bb0ed67827 | |||
| b111d06851 | |||
| 79388845ea | |||
| 99faef2e77 | |||
| 21fffbfe10 | |||
| 64fb9f0c2a | |||
| a42e0bef2c | |||
| 45a09006e1 | |||
| 240484be4c | |||
| 22f4d115e3 | |||
| 32920e0e5d | |||
| f03a5918d1 | |||
| dd1870b52a | |||
| f0c1a8f98f | |||
| 0c181d7e7c | |||
| e3dc0e833a | |||
| 6de875edea | |||
| 986a55173f | |||
| 59c8cabf02 | |||
| a33b9fab07 | |||
| f8a725e1e7 | |||
| b3ab3af01b | |||
| 79f9463e56 | |||
| 4257b2ed51 | |||
| 6488ab60ec | |||
| 18bd3dfcf9 | |||
| ec114e160d | |||
| de033af18f | |||
| e971c6fcb7 | |||
| d3c465391c | |||
| f1fa19593d | |||
| 60b6f9c73e | |||
| 55b95ddecb | |||
| 0800a251b2 | |||
| 82cf7a80eb | |||
| 379f3d12eb | |||
| e52972d4d4 | |||
| 1a0ca4dec2 | |||
| 0bac9d8d5a | |||
| a9608363c5 | |||
| f1a2c5ae8e | |||
| 192a81ede7 | |||
| 916aa5abbd | |||
| d4a5cc6eee | |||
| d19605cc8d | |||
| 8d529327a4 | |||
| 697e2f2071 | |||
| f7fb112f21 | |||
| b2c0211190 | |||
| c59e0ea6e5 | |||
| 6c2fc1444d | |||
| 94d969fe46 | |||
| a7bc3d301c | |||
| 18bab849f7 | |||
| 04878fcc30 | |||
| ffe1299548 | |||
| 64bdbf5725 | |||
| b82deb557f | |||
| d529a48a11 | |||
| b711e4e6bd | |||
| 186eecfbff | |||
| d766c33f59 | |||
| 2c9257f1a8 | |||
| 71ff604f90 | |||
| 83e00763ea | |||
| 5b647e0937 | |||
| e0444510f4 | |||
| 82e876a892 | |||
| c728e05032 | |||
| c7a8ce7060 | |||
| 762b4339fe | |||
| f824b8988e | |||
| 6280d6d167 | |||
| 4f18e744b4 | |||
| 01d8f720e8 | |||
| 18cf058af3 | |||
| e2406df367 | |||
| 1fd669bdb3 | |||
| f6add12c80 | |||
| 0f643bfe39 | |||
| 15be498e4b | |||
| fba465dd62 | |||
| 19dbe354e7 | |||
| fca5d37b7e | |||
| aa04ad2dc2 | |||
| 7ef4d814ef | |||
| 3f3deb665c | |||
| 97fc22ce57 | |||
| 616f3ad76d | |||
| faca63946c | |||
| 57bae341a2 | |||
| fd09a766d2 | |||
| 11564a5292 | |||
| ac12e350bf | |||
| 2def15337d | |||
| 3e3d58a4a9 | |||
| 5ce4f55228 | |||
| 21788fc7b0 | |||
| a1c4382fde | |||
| 364e95698e | |||
| 6eec142499 | |||
| a8f6b3a39a | |||
| 250933bf41 | |||
| 56c77c781a | |||
| f7602b39a1 | |||
| 8c86092356 | |||
| db0a4bff77 | |||
| e198ff9cb1 | |||
| b8eaa5cf97 | |||
| 0d597721bf | |||
| 003e0caada | |||
| 053637cfb4 | |||
| 8178213f1a | |||
| b4222a41de | |||
| f28e409ea5 | |||
| 287c6c06e1 | |||
| 8216bdb4b3 | |||
| aa15da50ab | |||
| 02759c6f83 | |||
| 6b0c49752c | |||
| 2e4f792fc3 | |||
| 17eba059f0 | |||
| e59a00922b | |||
| 872201c886 | |||
| 3352098284 | |||
| d0bbd7f24f | |||
| 7f87714b58 | |||
| 5594bee618 | |||
| c469ef23e6 | |||
| f6e74f2526 | |||
| 10b6e9c537 | |||
| 3f27af30b7 | |||
| 23db09f9b7 | |||
| d1b7681efc | |||
| 61ad405ad8 | |||
| aff98110e0 | |||
| 2f36db9142 | |||
| aa86ee1066 | |||
| dbbcce8165 | |||
| 1ed066ef0f | |||
| 763f7d45d8 | |||
| 2328f3afb5 | |||
| 2223245861 | |||
| 36226b01cd | |||
| da31f9cadd | |||
| 9da4857066 | |||
| 75c71135ba | |||
| 0cb5025a16 | |||
| 44d9f69434 | |||
| 3f343b283b | |||
| 03a28fc3c5 | |||
| 3513619221 | |||
| 0c9f5769d3 | |||
| 587a666ab6 | |||
| f26deea508 | |||
| b8e19040b5 | |||
| 7d9e0f4080 | |||
| 16ce7fbc7b | |||
| 639fce376a | |||
| 3cdbac5c22 | |||
| 3dcafdf403 | |||
| cd2fe9f8d9 | |||
| fd40596ce7 | |||
| 7ecda69703 | |||
| a3b76cd5c2 | |||
| 54df862998 | |||
| 301b7a4911 | |||
| e0a048abe6 | |||
| 671e3e19ff | |||
| 0c394c2e61 | |||
| 4ecbb5234c | |||
| 98f1700049 | |||
| 2f0b4a0187 | |||
| f66c6ed0c3 | |||
| 5d9785ac2d | |||
| bb97a8cccc | |||
| 571cf5b5b8 | |||
| 1974ed1c03 | |||
| 98275f7c87 | |||
| eca8726909 | |||
| baf125c450 | |||
| efcc710d91 | |||
| 5980ee4c86 | |||
| db9b7a22c2 | |||
| 5e24d4f322 | |||
| 2dd32cdce2 | |||
| 9cddd93dad | |||
| 4127898655 | |||
| 45d48483d0 | |||
| 852c25296a | |||
| aea631138e | |||
| 683fdbb02a | |||
| c3bbab35e2 | |||
| ba8941046e | |||
| d202f4e00d | |||
| 42da5d8d32 | |||
| 5af3533598 | |||
| 7843168fad | |||
| 8f51eb63b0 | |||
| 855f5f7af4 | |||
| c85dd2655c | |||
| fb0e4060cd | |||
| 707b4990a6 | |||
| 9c8b922069 | |||
| d4b421421d | |||
| 58e9646fa6 | |||
| 500f172561 | |||
| 68f6c90ea4 | |||
| 41e91f2922 | |||
| 999117cfeb | |||
| 6185df512f | |||
| 0cbf66c007 | |||
| cd378b721d | |||
| 547d38d1ef | |||
| dca56af5b9 | |||
| 224442772e | |||
| 003951fdf7 | |||
| d51b3da1b4 | |||
| 69f4af84db | |||
| 771759b252 | |||
| 20c7a71db6 | |||
| 8475ee0985 | |||
| f42811d3d4 | |||
| c3b1832cfb | |||
| eb6753afe1 | |||
| 5051cecb84 | |||
| cd03ede358 | |||
| 6563f8c738 | |||
| e5279b4827 | |||
| 79ff505963 | |||
| 8a67eba5fc | |||
| 6609a5f340 | |||
| d9972cb349 | |||
| 28d2539432 | |||
| f28386b71f | |||
| 53717076f5 | |||
| a9aa928629 | |||
| 8df121148d | |||
| 5e23c32ae8 | |||
| 9c0f6481c0 | |||
| 68ae45dd58 | |||
| 3091747438 | |||
| 2f266b8dd4 | |||
| ee20b87ee2 | |||
| 83e025d0bb | |||
| 5115c6e217 | |||
| 76f6a94de5 | |||
| 954830be18 | |||
| ea70299a45 | |||
| 88da071ed6 | |||
| 1dbf162a71 | |||
| 1c0964753b | |||
| daa1c7f577 | |||
| 854416ceb2 | |||
| 2230351e3e | |||
| 7da3244da2 | |||
| bfeb0c2988 | |||
| d4e75c1dec | |||
| 405bddcde0 | |||
| 8a27c45ab1 | |||
| 10b15896b3 | |||
| 0e97bbe37c | |||
| e0d7e90894 | |||
| 5d13f6aab6 | |||
| 1ebfbbe89e | |||
| 91ad43fdfc | |||
| 6fe6fc180d | |||
| d84d0bec38 | |||
| 7e7b1c6ee1 | |||
| effb354d1b | |||
| ba7d1ad35f | |||
| 3ca2b19502 | |||
| 8e0d91dcf5 | |||
| cd2c2587ae | |||
| 53044696ba | |||
| 6d6927213f | |||
| be1b5bce4f | |||
| 4b4fd0735b | |||
| c565b2a31f | |||
| 55f2261905 | |||
| 51912f2b83 | |||
| 7f4e2617ee | |||
| 960a385202 | |||
| 21f48d3485 | |||
| 7f9605e55f | |||
| cc409dc3f7 | |||
| af6091760c | |||
| e1d93c003c | |||
| ff9dd2dd03 | |||
| 7a306bb3d2 | |||
| 7ffc148358 | |||
| 50fef2edfa | |||
| aa40084010 | |||
| 740d788c7c | |||
| 4c2fa2c1b3 | |||
| 4350c7b7a9 | |||
| 595f14d98d | |||
| 2e95d6ea63 | |||
| 0da6abeb98 | |||
| e4e050e8e7 | |||
| 5bc082b75e | |||
| beedbd7646 | |||
| 507b069ffe | |||
| 71444b0427 | |||
| a08bba438e | |||
| df1e6711af | |||
| f6d4e934e3 | |||
| d5bd4c6735 | |||
| eb12ba6ed2 | |||
| 6e83c08535 | |||
| b6bfdec48d | |||
| f9ec796291 | |||
| 3beb1d0683 | |||
| 8836c7f0ca | |||
| ef5ce1d6e1 | |||
| 0ea1213139 | |||
| 51fe372f60 | |||
| eb8f9f8936 | |||
| afc1524874 | |||
| fbb975625c | |||
| 53e75d8209 | |||
| 5bdf970c10 | |||
| 50089f72c6 | |||
| 62e15e0208 | |||
| 3d8b02a7f3 | |||
| 20701d9cf1 | |||
| fa94442eb2 | |||
| 68ff77e172 | |||
| 102e9be3a8 | |||
| 92bf01a183 | |||
| 559504ae29 | |||
| 9b00b41a1e | |||
| b1f6ad17e1 | |||
| e7979fe9db | |||
| 7a276adbbc | |||
| db4997fdc4 | |||
| 44ebb841f0 | |||
| 09ae4e2096 | |||
| 0b46efe4ea | |||
| f1dda43e66 | |||
| ce483138d7 | |||
| 73cc39226d | |||
| 57257f63dd | |||
| 88b25790e8 | |||
| e01defc4aa | |||
| cb50c43e93 | |||
| 5908d15f91 | |||
| f66cfaec12 | |||
| 259f92c53b | |||
| a84f850e91 | |||
| 5a765e6f07 | |||
| 791889c659 | |||
| 5da63faf1f | |||
| 30d108fc35 | |||
| a09fefab5e | |||
| f74ca1c236 | |||
| 30e027092b | |||
| fd4ac7c9b9 | |||
| 4482049b94 | |||
| 5839380437 | |||
| 2152470fdc | |||
| 93b2a81495 | |||
| e139e952c0 | |||
| cf1c57ccb8 | |||
| f7a2138488 | |||
| 9614d03bef | |||
| 32a335c676 | |||
| 06e27fc1e0 | |||
| 1f40e8dcd9 | |||
| 77ff8cef1f | |||
| ef844fbccb | |||
| 070dc5a4c0 | |||
| 177ef1cdcc | |||
| 4b1ebf02e1 | |||
| 863e50203e | |||
| 01b8c209de | |||
| 30e92f2bc1 | |||
| 02accabb4a | |||
| fa00a41fe0 | |||
| 2e66666bdf | |||
| 4fe3c9a751 | |||
| 0a35e14590 | |||
| e979c176e3 | |||
| a0d9c3dc29 | |||
| efcb68351c | |||
| 94e8bf2e1c | |||
| 82d1a294a6 | |||
| de20274589 | |||
| 2f193e64c8 | |||
| 86751362cb | |||
| 4118323631 | |||
| 0d134f7f10 | |||
| 409724cfcd | |||
| 799a33be40 | |||
| 2fa9c66495 | |||
| ad818a8e7e | |||
| 581f72b3f8 | |||
| 1dd7e4347c | |||
| 36cc9398c7 | |||
| 68817feeec | |||
| 97661e2ca2 | |||
| 72def5ae6d | |||
| e638b155a1 | |||
| 32db18b0d6 | |||
| b653a5250d | |||
| 30329f7cad | |||
| 29a1478c86 | |||
| c882bf31ec | |||
| 17ccb8f083 | |||
| 0e7d2a8b0e | |||
| 3743543ef8 | |||
| 700dd7b45a | |||
| c2eb73fd8a | |||
| e1f4f7f95b | |||
| 37401409c6 | |||
| b282631cd5 | |||
| 9618d3b3f3 | 
| @@ -1,4 +1,3 @@ | ||||
| .svn | ||||
| db.sqlite | ||||
| out/**/*.o | ||||
| out/**/*.d | ||||
| .git | ||||
| db.sqlite* | ||||
| out/ | ||||
|   | ||||
| @@ -6,15 +6,51 @@ jobs: | ||||
|   Build-All: | ||||
|     runs-on: ubuntu-latest | ||||
|     container: | ||||
|       valid_volumes: ['/opt/keys'] | ||||
|       image: node:23-bookworm-slim | ||||
|       valid_volumes: | ||||
|         - '/opt/keys' | ||||
|         - '/opt/deps' | ||||
|       volumes: | ||||
|         - /opt/keys:/opt/keys | ||||
|         - /opt/deps:/opt/deps | ||||
|     steps: | ||||
|       - name: check out code | ||||
|       - name: Install build dependencies | ||||
|         run: > | ||||
|           apt update && apt install -y \ | ||||
|             build-essential \ | ||||
|             clang-19 \ | ||||
|             cmake \ | ||||
|             curl \ | ||||
|             docker.io \ | ||||
|             doxygen \ | ||||
|             file \ | ||||
|             gcc-aarch64-linux-gnu \ | ||||
|             git \ | ||||
|             graphviz \ | ||||
|             libgpgme11 \ | ||||
|             libssl-dev \ | ||||
|             mingw-w64 \ | ||||
|             rsync \ | ||||
|             unzip \ | ||||
|             zip \ | ||||
|             zlib1g-dev | ||||
|       - name: Get code | ||||
|         uses: actions/checkout@v4 | ||||
|         with: | ||||
|           submodules: true | ||||
|       - run: ln -s /opt/keys .keys | ||||
|       - name: Setup environment | ||||
|         run: | | ||||
|           update-alternatives --install /usr/bin/clang clang /usr/bin/clang-19 100 | ||||
|           update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-19 100 | ||||
|           ln -s /opt/keys .keys | ||||
|           ln -sf /opt/deps/ios_toolchain deps/ | ||||
|           ln -sf /opt/deps/macos_toolchain deps/ | ||||
|       - name: Build documentation | ||||
|         run: | | ||||
|           mkdir -p out/html/ ~/.ssh/ | ||||
|           make -j`nproc` docs | ||||
|           echo 'pildefriends ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKD3Kde5vDO0TrMBDK0IGGeNGe/XinWAZkSQ/rXxwUjt' >> ~/.ssh/known_hosts | ||||
|           rsync -avP --delete -e "ssh -i /opt/keys/ssh.ed25519" out/html/ tfdocs@pildefriends:docs/html/ | ||||
|       - name: Setup JDK | ||||
|         uses: actions/setup-java@v3 | ||||
|         with: | ||||
| @@ -23,16 +59,13 @@ jobs: | ||||
|       - name: Setup Android SDK | ||||
|         uses: android-actions/setup-android@v3 | ||||
|         with: | ||||
|           packages: 'tools platform-tools build-tools;34.0.0 platforms;android-34 ndk;26.3.11579264' | ||||
|       - run: sudo apt update && sudo apt install -y doxygen graphviz mingw-w64 libgpgme11 | ||||
|       - run: ANDROID_SDK=$HOME/.android/sdk make -j`nproc` all docs | ||||
|       - run: docker build . | ||||
|       - uses: actions/upload-artifact@v3 | ||||
|           packages: 'tools platform-tools build-tools;35.0.0 platforms;android-35 ndk;27.2.12479018' | ||||
|       - name: Docker build | ||||
|         run: DOCKER_BUILDKIT=1 docker build . | ||||
|       - name: Build | ||||
|         run: ANDROID_SDK=$HOME/.android/sdk make -j`nproc` all dist | ||||
|       - name: Upload artifacts | ||||
|         uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           path: out/TildeFriends-release.fdroid.apk | ||||
|       - uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           path: out/winrelease/tildefriends.exe | ||||
|       - uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           path: out/tildefriends-x86_64.AppImage | ||||
|           name: dist | ||||
|           path: dist/* | ||||
|   | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,10 +1,13 @@ | ||||
| build/ | ||||
| *.core | ||||
| db.* | ||||
| deps/ios_toolchain/ | ||||
| deps/ios_toolchain | ||||
| deps/macos_toolchain | ||||
| deps/openssl/ | ||||
| dist/ | ||||
| .flatpak-builder | ||||
| .keys | ||||
| **/.DS_Store | ||||
| logs/ | ||||
| **/node_modules | ||||
| out | ||||
|   | ||||
							
								
								
									
										7
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -19,10 +19,9 @@ | ||||
| [submodule "deps/picohttpparser"] | ||||
| 	path = deps/picohttpparser | ||||
| 	url = https://github.com/h2o/picohttpparser.git | ||||
| [submodule "deps/openssl_src"] | ||||
| 	path = deps/openssl_src | ||||
| 	url = https://github.com/openssl/openssl.git | ||||
| 	shallow = true | ||||
| [submodule "deps/c-ares"] | ||||
| 	path = deps/c-ares | ||||
| 	url = https://github.com/c-ares/c-ares.git | ||||
| [submodule "deps/zsign"] | ||||
| 	path = deps/zsign | ||||
| 	url = https://github.com/zhlynn/zsign.git | ||||
|   | ||||
| @@ -1,19 +1,15 @@ | ||||
| FROM bitnami/minideb:bullseye AS build | ||||
| FROM bitnami/minideb:bookworm AS build | ||||
|  | ||||
| RUN apt-get update && \ | ||||
| 	apt-get install -y --no-install-recommends \ | ||||
| 		gcc \ | ||||
| 		libc6-dev \ | ||||
| 		libssl-dev \ | ||||
| 		make | ||||
|  | ||||
| COPY . /app | ||||
| RUN make -C /app -j $(nproc) release | ||||
|  | ||||
| FROM bitnami/minideb:bullseye | ||||
| RUN apt-get update && \ | ||||
| 	apt-get install -y --no-install-recommends \ | ||||
| 		libssl1.1 | ||||
| FROM bitnami/minideb:bookworm | ||||
|  | ||||
| COPY --from=build /app/out/release/tildefriends /app/out/release/tildefriends | ||||
| COPY --from=build /app/apps /app/apps | ||||
|   | ||||
							
								
								
									
										374
									
								
								Doxyfile
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,4 @@ | ||||
| # Doxyfile 1.9.8 | ||||
| # Doxyfile 1.9.4 | ||||
|  | ||||
| # This file describes the settings to be used by the documentation system | ||||
| # doxygen (www.doxygen.org) for a project. | ||||
| @@ -19,8 +19,7 @@ | ||||
| # configuration file: | ||||
| # doxygen -x [configFile] | ||||
| # Use doxygen to compare the used configuration file with the template | ||||
| # configuration file without replacing the environment variables or CMake type | ||||
| # replacement variables: | ||||
| # configuration file without replacing the environment variables: | ||||
| # doxygen -x_noenv [configFile] | ||||
|  | ||||
| #--------------------------------------------------------------------------- | ||||
| @@ -86,7 +85,7 @@ CREATE_SUBDIRS         = NO | ||||
| # level increment doubles the number of directories, resulting in 4096 | ||||
| # directories at level 8 which is the default and also the maximum value. The | ||||
| # sub-directories are organized in 2 levels, the first level always has a fixed | ||||
| # number of 16 directories. | ||||
| # numer of 16 directories. | ||||
| # Minimum value: 0, maximum value: 8, default value: 8. | ||||
| # This tag requires that the tag CREATE_SUBDIRS is set to YES. | ||||
|  | ||||
| @@ -342,7 +341,7 @@ OPTIMIZE_OUTPUT_SLICE  = NO | ||||
| # | ||||
| # Note see also the list of default file extension mappings. | ||||
|  | ||||
| EXTENSION_MAPPING      = | ||||
| EXTENSION_MAPPING      = js=javascript | ||||
|  | ||||
| # If the MARKDOWN_SUPPORT tag is enabled then doxygen pre-processes all comments | ||||
| # according to the Markdown format, which allows for more readable | ||||
| @@ -363,17 +362,6 @@ MARKDOWN_SUPPORT       = YES | ||||
|  | ||||
| TOC_INCLUDE_HEADINGS   = 5 | ||||
|  | ||||
| # The MARKDOWN_ID_STYLE tag can be used to specify the algorithm used to | ||||
| # generate identifiers for the Markdown headings. Note: Every identifier is | ||||
| # unique. | ||||
| # Possible values are: DOXYGEN use a fixed 'autotoc_md' string followed by a | ||||
| # sequence number starting at 0 and GITHUB use the lower case version of title | ||||
| # with any whitespace replaced by '-' and punctuation characters removed. | ||||
| # The default value is: DOXYGEN. | ||||
| # This tag requires that the tag MARKDOWN_SUPPORT is set to YES. | ||||
|  | ||||
| MARKDOWN_ID_STYLE      = DOXYGEN | ||||
|  | ||||
| # When enabled doxygen tries to link words that correspond to documented | ||||
| # classes, or namespaces to their corresponding documentation. Such a link can | ||||
| # be prevented in individual cases by putting a % sign in front of the word or | ||||
| @@ -498,14 +486,6 @@ LOOKUP_CACHE_SIZE      = 0 | ||||
|  | ||||
| NUM_PROC_THREADS       = 1 | ||||
|  | ||||
| # If the TIMESTAMP tag is set different from NO then each generated page will | ||||
| # contain the date or date and time when the page was generated. Setting this to | ||||
| # NO can help when comparing the output of multiple runs. | ||||
| # Possible values are: YES, NO, DATETIME and DATE. | ||||
| # The default value is: NO. | ||||
|  | ||||
| TIMESTAMP              = NO | ||||
|  | ||||
| #--------------------------------------------------------------------------- | ||||
| # Build related configuration options | ||||
| #--------------------------------------------------------------------------- | ||||
| @@ -587,8 +567,7 @@ HIDE_UNDOC_MEMBERS     = NO | ||||
| # If the HIDE_UNDOC_CLASSES tag is set to YES, doxygen will hide all | ||||
| # undocumented classes that are normally visible in the class hierarchy. If set | ||||
| # to NO, these classes will be included in the various overviews. This option | ||||
| # will also hide undocumented C++ concepts if enabled. This option has no effect | ||||
| # if EXTRACT_ALL is enabled. | ||||
| # has no effect if EXTRACT_ALL is enabled. | ||||
| # The default value is: NO. | ||||
|  | ||||
| HIDE_UNDOC_CLASSES     = NO | ||||
| @@ -626,8 +605,7 @@ INTERNAL_DOCS          = NO | ||||
| # Windows (including Cygwin) and MacOS, users should typically set this option | ||||
| # to NO, whereas on Linux or other Unix flavors it should typically be set to | ||||
| # YES. | ||||
| # Possible values are: SYSTEM, NO and YES. | ||||
| # The default value is: SYSTEM. | ||||
| # The default value is: system dependent. | ||||
|  | ||||
| CASE_SENSE_NAMES       = YES | ||||
|  | ||||
| @@ -879,26 +857,11 @@ WARN_IF_INCOMPLETE_DOC = YES | ||||
|  | ||||
| WARN_NO_PARAMDOC       = NO | ||||
|  | ||||
| # If WARN_IF_UNDOC_ENUM_VAL option is set to YES, doxygen will warn about | ||||
| # undocumented enumeration values. If set to NO, doxygen will accept | ||||
| # undocumented enumeration values. If EXTRACT_ALL is set to YES then this flag | ||||
| # will automatically be disabled. | ||||
| # The default value is: NO. | ||||
|  | ||||
| WARN_IF_UNDOC_ENUM_VAL = NO | ||||
|  | ||||
| # If the WARN_AS_ERROR tag is set to YES then doxygen will immediately stop when | ||||
| # a warning is encountered. If the WARN_AS_ERROR tag is set to FAIL_ON_WARNINGS | ||||
| # then doxygen will continue running as if WARN_AS_ERROR tag is set to NO, but | ||||
| # at the end of the doxygen process doxygen will return with a non-zero status. | ||||
| # If the WARN_AS_ERROR tag is set to FAIL_ON_WARNINGS_PRINT then doxygen behaves | ||||
| # like FAIL_ON_WARNINGS but in case no WARN_LOGFILE is defined doxygen will not | ||||
| # write the warning messages in between other messages but write them at the end | ||||
| # of a run, in case a WARN_LOGFILE is defined the warning messages will be | ||||
| # besides being in the defined file also be shown at the end of a run, unless | ||||
| # the WARN_LOGFILE is defined as - i.e. standard output (stdout) in that case | ||||
| # the behavior will remain as with the setting FAIL_ON_WARNINGS. | ||||
| # Possible values are: NO, YES, FAIL_ON_WARNINGS and FAIL_ON_WARNINGS_PRINT. | ||||
| # Possible values are: NO, YES and FAIL_ON_WARNINGS. | ||||
| # The default value is: NO. | ||||
|  | ||||
| WARN_AS_ERROR          = NO | ||||
| @@ -943,28 +906,23 @@ WARN_LOGFILE           = | ||||
| # spaces. See also FILE_PATTERNS and EXTENSION_MAPPING | ||||
| # Note: If this tag is empty the current directory is searched. | ||||
|  | ||||
| INPUT                  = src/ | ||||
| INPUT                  = README.md \ | ||||
|                          core/app.js \ | ||||
|                          core/client.js \ | ||||
|                          core/core.js \ | ||||
|                          core/tfrpc.js \ | ||||
|                          docs/ \ | ||||
|                          src/ | ||||
|  | ||||
| # This tag can be used to specify the character encoding of the source files | ||||
| # that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses | ||||
| # libiconv (or the iconv built into libc) for the transcoding. See the libiconv | ||||
| # documentation (see: | ||||
| # https://www.gnu.org/software/libiconv/) for the list of possible encodings. | ||||
| # See also: INPUT_FILE_ENCODING | ||||
| # The default value is: UTF-8. | ||||
|  | ||||
| INPUT_ENCODING         = UTF-8 | ||||
|  | ||||
| # This tag can be used to specify the character encoding of the source files | ||||
| # that doxygen parses The INPUT_FILE_ENCODING tag can be used to specify | ||||
| # character encoding on a per file pattern basis. Doxygen will compare the file | ||||
| # name with each pattern and apply the encoding instead of the default | ||||
| # INPUT_ENCODING) if there is a match. The character encodings are a list of the | ||||
| # form: pattern=encoding (like *.php=ISO-8859-1). See cfg_input_encoding | ||||
| # "INPUT_ENCODING" for further information on supported encodings. | ||||
|  | ||||
| INPUT_FILE_ENCODING    = | ||||
|  | ||||
| # If the value of the INPUT tag contains directories, you can use the | ||||
| # FILE_PATTERNS tag to specify one or more wildcard patterns (like *.cpp and | ||||
| # *.h) to filter out the source-files in the directories. | ||||
| @@ -976,14 +934,15 @@ INPUT_FILE_ENCODING    = | ||||
| # Note the list of default checked file patterns might differ from the list of | ||||
| # default file extension mappings. | ||||
| # | ||||
| # If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cxxm, | ||||
| # *.cpp, *.cppm, *.c++, *.c++m, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, | ||||
| # *.ddl, *.odl, *.h, *.hh, *.hxx, *.hpp, *.h++, *.ixx, *.l, *.cs, *.d, *.php, | ||||
| # *.php4, *.php5, *.phtml, *.inc, *.m, *.markdown, *.md, *.mm, *.dox (to be | ||||
| # provided as doxygen C comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, | ||||
| # *.f18, *.f, *.for, *.vhd, *.vhdl, *.ucf, *.qsf and *.ice. | ||||
| # If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cpp, | ||||
| # *.c++, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, *.ddl, *.odl, *.h, | ||||
| # *.hh, *.hxx, *.hpp, *.h++, *.l, *.cs, *.d, *.php, *.php4, *.php5, *.phtml, | ||||
| # *.inc, *.m, *.markdown, *.md, *.mm, *.dox (to be provided as doxygen C | ||||
| # comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, *.f18, *.f, *.for, *.vhd, | ||||
| # *.vhdl, *.ucf, *.qsf and *.ice. | ||||
|  | ||||
| FILE_PATTERNS          = *.h \ | ||||
|                          *.js \ | ||||
|                          *.md | ||||
|  | ||||
| # The RECURSIVE tag can be used to specify whether or not subdirectories should | ||||
| @@ -1022,6 +981,9 @@ EXCLUDE_PATTERNS       = | ||||
| # output. The symbol name can be a fully qualified name, a word, or if the | ||||
| # wildcard * is used, a substring. Examples: ANamespace, AClass, | ||||
| # ANamespace::AClass, ANamespace::*Test | ||||
| # | ||||
| # Note that the wildcards are matched against the file with absolute path, so to | ||||
| # exclude all test directories use the pattern */test/* | ||||
|  | ||||
| EXCLUDE_SYMBOLS        = | ||||
|  | ||||
| @@ -1049,7 +1011,7 @@ EXAMPLE_RECURSIVE      = NO | ||||
| # that contain images that are to be included in the documentation (see the | ||||
| # \image command). | ||||
|  | ||||
| IMAGE_PATH             = | ||||
| IMAGE_PATH             = docs/images/ | ||||
|  | ||||
| # The INPUT_FILTER tag can be used to specify a program that doxygen should | ||||
| # invoke to filter for each input file. Doxygen will invoke the filter program | ||||
| @@ -1066,11 +1028,6 @@ IMAGE_PATH             = | ||||
| # code is scanned, but not when the output code is generated. If lines are added | ||||
| # or removed, the anchors will not be placed correctly. | ||||
| # | ||||
| # Note that doxygen will use the data processed and written to standard output | ||||
| # for further processing, therefore nothing else, like debug statements or used | ||||
| # commands (so in case of a Windows batch file always use @echo OFF), should be | ||||
| # written to standard output. | ||||
| # | ||||
| # Note that for custom extensions or not directly supported extensions you also | ||||
| # need to set EXTENSION_MAPPING for the extension otherwise the files are not | ||||
| # properly processed by doxygen. | ||||
| @@ -1110,16 +1067,7 @@ FILTER_SOURCE_PATTERNS = | ||||
| # (index.html). This can be useful if you have a project on for instance GitHub | ||||
| # and want to reuse the introduction page also for the doxygen output. | ||||
|  | ||||
| USE_MDFILE_AS_MAINPAGE = | ||||
|  | ||||
| # The Fortran standard specifies that for fixed formatted Fortran code all | ||||
| # characters from position 72 are to be considered as comment. A common | ||||
| # extension is to allow longer lines before the automatic comment starts. The | ||||
| # setting FORTRAN_COMMENT_AFTER will also make it possible that longer lines can | ||||
| # be processed before the automatic comment starts. | ||||
| # Minimum value: 7, maximum value: 10000, default value: 72. | ||||
|  | ||||
| FORTRAN_COMMENT_AFTER  = 72 | ||||
| USE_MDFILE_AS_MAINPAGE = README.md | ||||
|  | ||||
| #--------------------------------------------------------------------------- | ||||
| # Configuration options related to source browsing | ||||
| @@ -1258,11 +1206,10 @@ CLANG_DATABASE_PATH    = | ||||
|  | ||||
| ALPHABETICAL_INDEX     = YES | ||||
|  | ||||
| # The IGNORE_PREFIX tag can be used to specify a prefix (or a list of prefixes) | ||||
| # that should be ignored while generating the index headers. The IGNORE_PREFIX | ||||
| # tag works for classes, function and member names. The entity will be placed in | ||||
| # the alphabetical list under the first letter of the entity name that remains | ||||
| # after removing the prefix. | ||||
| # In case all classes in a project start with a common prefix, all classes will | ||||
| # be put under the same header in the alphabetical index. The IGNORE_PREFIX tag | ||||
| # can be used to specify a prefix (or a list of prefixes) that should be ignored | ||||
| # while generating the index headers. | ||||
| # This tag requires that the tag ALPHABETICAL_INDEX is set to YES. | ||||
|  | ||||
| IGNORE_PREFIX          = | ||||
| @@ -1341,12 +1288,7 @@ HTML_STYLESHEET        = | ||||
| # Doxygen will copy the style sheet files to the output directory. | ||||
| # Note: The order of the extra style sheet files is of importance (e.g. the last | ||||
| # style sheet in the list overrules the setting of the previous ones in the | ||||
| # list). | ||||
| # Note: Since the styling of scrollbars can currently not be overruled in | ||||
| # Webkit/Chromium, the styling will be left out of the default doxygen.css if | ||||
| # one or more extra stylesheets have been specified. So if scrollbar | ||||
| # customization is desired it has to be added explicitly. For an example see the | ||||
| # documentation. | ||||
| # list). For an example see the documentation. | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| HTML_EXTRA_STYLESHEET  = | ||||
| @@ -1361,19 +1303,6 @@ HTML_EXTRA_STYLESHEET  = | ||||
|  | ||||
| HTML_EXTRA_FILES       = | ||||
|  | ||||
| # The HTML_COLORSTYLE tag can be used to specify if the generated HTML output | ||||
| # should be rendered with a dark or light theme. | ||||
| # Possible values are: LIGHT always generate light mode output, DARK always | ||||
| # generate dark mode output, AUTO_LIGHT automatically set the mode according to | ||||
| # the user preference, use light mode if no preference is set (the default), | ||||
| # AUTO_DARK automatically set the mode according to the user preference, use | ||||
| # dark mode if no preference is set and TOGGLE allow to user to switch between | ||||
| # light and dark mode via a button. | ||||
| # The default value is: AUTO_LIGHT. | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| HTML_COLORSTYLE        = AUTO_LIGHT | ||||
|  | ||||
| # The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen | ||||
| # will adjust the colors in the style sheet and background images according to | ||||
| # this color. Hue is specified as an angle on a color-wheel, see | ||||
| @@ -1404,6 +1333,15 @@ HTML_COLORSTYLE_SAT    = 100 | ||||
|  | ||||
| HTML_COLORSTYLE_GAMMA  = 80 | ||||
|  | ||||
| # If the HTML_TIMESTAMP tag is set to YES then the footer of each generated HTML | ||||
| # page will contain the date and time when the page was generated. Setting this | ||||
| # to YES can help to show when doxygen was last run and thus if the | ||||
| # documentation is up to date. | ||||
| # The default value is: NO. | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| #HTML_TIMESTAMP         = NO | ||||
|  | ||||
| # If the HTML_DYNAMIC_MENUS tag is set to YES then the generated HTML | ||||
| # documentation will contain a main index with vertical navigation menus that | ||||
| # are dynamically created via JavaScript. If disabled, the navigation index will | ||||
| @@ -1423,13 +1361,6 @@ HTML_DYNAMIC_MENUS     = YES | ||||
|  | ||||
| HTML_DYNAMIC_SECTIONS  = NO | ||||
|  | ||||
| # If the HTML_CODE_FOLDING tag is set to YES then classes and functions can be | ||||
| # dynamically folded and expanded in the generated HTML source code. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| HTML_CODE_FOLDING      = YES | ||||
|  | ||||
| # With HTML_INDEX_NUM_ENTRIES one can control the preferred number of entries | ||||
| # shown in the various tree structured indices initially; the user can expand | ||||
| # and collapse entries dynamically later on. Doxygen will expand the tree to | ||||
| @@ -1560,16 +1491,6 @@ BINARY_TOC             = NO | ||||
|  | ||||
| TOC_EXPAND             = NO | ||||
|  | ||||
| # The SITEMAP_URL tag is used to specify the full URL of the place where the | ||||
| # generated documentation will be placed on the server by the user during the | ||||
| # deployment of the documentation. The generated sitemap is called sitemap.xml | ||||
| # and placed on the directory specified by HTML_OUTPUT. In case no SITEMAP_URL | ||||
| # is specified no sitemap is generated. For information about the sitemap | ||||
| # protocol see https://www.sitemaps.org | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| SITEMAP_URL            = | ||||
|  | ||||
| # If the GENERATE_QHP tag is set to YES and both QHP_NAMESPACE and | ||||
| # QHP_VIRTUAL_FOLDER are set, an additional index file will be generated that | ||||
| # can be used as input for Qt's qhelpgenerator to generate a Qt Compressed Help | ||||
| @@ -1680,7 +1601,7 @@ DISABLE_INDEX          = NO | ||||
| # The default value is: NO. | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| GENERATE_TREEVIEW      = NO | ||||
| GENERATE_TREEVIEW      = YES | ||||
|  | ||||
| # When both GENERATE_TREEVIEW and DISABLE_INDEX are set to YES, then the | ||||
| # FULL_SIDEBAR option determines if the side bar is limited to only the treeview | ||||
| @@ -1745,6 +1666,17 @@ HTML_FORMULA_FORMAT    = png | ||||
|  | ||||
| FORMULA_FONTSIZE       = 10 | ||||
|  | ||||
| # Use the FORMULA_TRANSPARENT tag to determine whether or not the images | ||||
| # generated for formulas are transparent PNGs. Transparent PNGs are not | ||||
| # supported properly for IE 6.0, but are supported on all modern browsers. | ||||
| # | ||||
| # Note that when changing this option you need to delete any form_*.png files in | ||||
| # the HTML output directory before the changes have effect. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag GENERATE_HTML is set to YES. | ||||
|  | ||||
| #FORMULA_TRANSPARENT    = YES | ||||
|  | ||||
| # The FORMULA_MACROFILE can contain LaTeX \newcommand and \renewcommand commands | ||||
| # to create new LaTeX commands to be used in formulas as building blocks. See | ||||
| # the section "Including formulas" for details. | ||||
| @@ -1806,8 +1738,8 @@ MATHJAX_RELPATH        = https://cdn.jsdelivr.net/npm/mathjax@2 | ||||
|  | ||||
| # The MATHJAX_EXTENSIONS tag can be used to specify one or more MathJax | ||||
| # extension names that should be enabled during MathJax rendering. For example | ||||
| # for MathJax version 2 (see | ||||
| # https://docs.mathjax.org/en/v2.7-latest/tex.html#tex-and-latex-extensions): | ||||
| # for MathJax version 2 (see https://docs.mathjax.org/en/v2.7-latest/tex.html | ||||
| # #tex-and-latex-extensions): | ||||
| # MATHJAX_EXTENSIONS = TeX/AMSmath TeX/AMSsymbols | ||||
| # For example for MathJax version 3 (see | ||||
| # http://docs.mathjax.org/en/latest/input/tex/extensions/index.html): | ||||
| @@ -2058,16 +1990,9 @@ PDF_HYPERLINKS         = YES | ||||
|  | ||||
| USE_PDFLATEX           = YES | ||||
|  | ||||
| # The LATEX_BATCHMODE tag signals the behavior of LaTeX in case of an error. | ||||
| # Possible values are: NO same as ERROR_STOP, YES same as BATCH, BATCH In batch | ||||
| # mode nothing is printed on the terminal, errors are scrolled as if <return> is | ||||
| # hit at every error; missing files that TeX tries to input or request from | ||||
| # keyboard input (\read on a not open input stream) cause the job to abort, | ||||
| # NON_STOP In nonstop mode the diagnostic message will appear on the terminal, | ||||
| # but there is no possibility of user interaction just like in batch mode, | ||||
| # SCROLL In scroll mode, TeX will stop only for missing files to input or if | ||||
| # keyboard input is necessary and ERROR_STOP In errorstop mode, TeX will stop at | ||||
| # each error, asking for user intervention. | ||||
| # If the LATEX_BATCHMODE tag is set to YES, doxygen will add the \batchmode | ||||
| # command to the generated LaTeX files. This will instruct LaTeX to keep running | ||||
| # if errors occur, instead of asking the user for help. | ||||
| # The default value is: NO. | ||||
| # This tag requires that the tag GENERATE_LATEX is set to YES. | ||||
|  | ||||
| @@ -2088,6 +2013,14 @@ LATEX_HIDE_INDICES     = NO | ||||
|  | ||||
| LATEX_BIB_STYLE        = plain | ||||
|  | ||||
| # If the LATEX_TIMESTAMP tag is set to YES then the footer of each generated | ||||
| # page will contain the date and time when the page was generated. Setting this | ||||
| # to NO can help when comparing the output of multiple runs. | ||||
| # The default value is: NO. | ||||
| # This tag requires that the tag GENERATE_LATEX is set to YES. | ||||
|  | ||||
| #LATEX_TIMESTAMP        = NO | ||||
|  | ||||
| # The LATEX_EMOJI_DIRECTORY tag is used to specify the (relative or absolute) | ||||
| # path from which the emoji images will be read. If a relative path is entered, | ||||
| # it will be relative to the LATEX_OUTPUT directory. If left blank the | ||||
| @@ -2253,39 +2186,13 @@ DOCBOOK_OUTPUT         = docbook | ||||
| #--------------------------------------------------------------------------- | ||||
|  | ||||
| # If the GENERATE_AUTOGEN_DEF tag is set to YES, doxygen will generate an | ||||
| # AutoGen Definitions (see https://autogen.sourceforge.net/) file that captures | ||||
| # AutoGen Definitions (see http://autogen.sourceforge.net/) file that captures | ||||
| # the structure of the code including all documentation. Note that this feature | ||||
| # is still experimental and incomplete at the moment. | ||||
| # The default value is: NO. | ||||
|  | ||||
| GENERATE_AUTOGEN_DEF   = NO | ||||
|  | ||||
| #--------------------------------------------------------------------------- | ||||
| # Configuration options related to Sqlite3 output | ||||
| #--------------------------------------------------------------------------- | ||||
|  | ||||
| # If the GENERATE_SQLITE3 tag is set to YES doxygen will generate a Sqlite3 | ||||
| # database with symbols found by doxygen stored in tables. | ||||
| # The default value is: NO. | ||||
|  | ||||
| GENERATE_SQLITE3       = NO | ||||
|  | ||||
| # The SQLITE3_OUTPUT tag is used to specify where the Sqlite3 database will be | ||||
| # put. If a relative path is entered the value of OUTPUT_DIRECTORY will be put | ||||
| # in front of it. | ||||
| # The default directory is: sqlite3. | ||||
| # This tag requires that the tag GENERATE_SQLITE3 is set to YES. | ||||
|  | ||||
| SQLITE3_OUTPUT         = sqlite3 | ||||
|  | ||||
| # The SQLITE3_OVERWRITE_DB tag is set to YES, the existing doxygen_sqlite3.db | ||||
| # database file will be recreated with each doxygen run. If set to NO, doxygen | ||||
| # will warn if an a database file is already found and not modify it. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag GENERATE_SQLITE3 is set to YES. | ||||
|  | ||||
| SQLITE3_RECREATE_DB    = YES | ||||
|  | ||||
| #--------------------------------------------------------------------------- | ||||
| # Configuration options related to the Perl module output | ||||
| #--------------------------------------------------------------------------- | ||||
| @@ -2428,15 +2335,15 @@ TAGFILES               = | ||||
|  | ||||
| GENERATE_TAGFILE       = | ||||
|  | ||||
| # If the ALLEXTERNALS tag is set to YES, all external classes and namespaces | ||||
| # will be listed in the class and namespace index. If set to NO, only the | ||||
| # inherited external classes will be listed. | ||||
| # If the ALLEXTERNALS tag is set to YES, all external class will be listed in | ||||
| # the class index. If set to NO, only the inherited external classes will be | ||||
| # listed. | ||||
| # The default value is: NO. | ||||
|  | ||||
| ALLEXTERNALS           = NO | ||||
|  | ||||
| # If the EXTERNAL_GROUPS tag is set to YES, all external groups will be listed | ||||
| # in the topic index. If set to NO, only the current project's groups will be | ||||
| # in the modules index. If set to NO, only the current project's groups will be | ||||
| # listed. | ||||
| # The default value is: YES. | ||||
|  | ||||
| @@ -2450,9 +2357,16 @@ EXTERNAL_GROUPS        = YES | ||||
| EXTERNAL_PAGES         = YES | ||||
|  | ||||
| #--------------------------------------------------------------------------- | ||||
| # Configuration options related to diagram generator tools | ||||
| # Configuration options related to the dot tool | ||||
| #--------------------------------------------------------------------------- | ||||
|  | ||||
| # You can include diagrams made with dia in doxygen documentation. Doxygen will | ||||
| # then run dia to produce the diagram and insert it in the documentation. The | ||||
| # DIA_PATH tag allows you to specify the directory where the dia binary resides. | ||||
| # If left empty dia is assumed to be found in the default search path. | ||||
|  | ||||
| DIA_PATH               = | ||||
|  | ||||
| # If set to YES the inheritance and collaboration graphs will hide inheritance | ||||
| # and usage relations if the target is undocumented or is not a class. | ||||
| # The default value is: YES. | ||||
| @@ -2461,10 +2375,10 @@ HIDE_UNDOC_RELATIONS   = YES | ||||
|  | ||||
| # If you set the HAVE_DOT tag to YES then doxygen will assume the dot tool is | ||||
| # available from the path. This tool is part of Graphviz (see: | ||||
| # https://www.graphviz.org/), a graph visualization toolkit from AT&T and Lucent | ||||
| # http://www.graphviz.org/), a graph visualization toolkit from AT&T and Lucent | ||||
| # Bell Labs. The other options in this section have no effect if this option is | ||||
| # set to NO | ||||
| # The default value is: YES. | ||||
| # The default value is: NO. | ||||
|  | ||||
| HAVE_DOT               = YES | ||||
|  | ||||
| @@ -2478,51 +2392,37 @@ HAVE_DOT               = YES | ||||
|  | ||||
| DOT_NUM_THREADS        = 0 | ||||
|  | ||||
| # DOT_COMMON_ATTR is common attributes for nodes, edges and labels of | ||||
| # subgraphs. When you want a differently looking font in the dot files that | ||||
| # doxygen generates you can specify fontname, fontcolor and fontsize attributes. | ||||
| # For details please see <a href=https://graphviz.org/doc/info/attrs.html>Node, | ||||
| # Edge and Graph Attributes specification</a> You need to make sure dot is able | ||||
| # to find the font, which can be done by putting it in a standard location or by | ||||
| # setting the DOTFONTPATH environment variable or by setting DOT_FONTPATH to the | ||||
| # directory containing the font. Default graphviz fontsize is 14. | ||||
| # The default value is: fontname=Helvetica,fontsize=10. | ||||
| # When you want a differently looking font in the dot files that doxygen | ||||
| # generates you can specify the font name using DOT_FONTNAME. You need to make | ||||
| # sure dot is able to find the font, which can be done by putting it in a | ||||
| # standard location or by setting the DOTFONTPATH environment variable or by | ||||
| # setting DOT_FONTPATH to the directory containing the font. | ||||
| # The default value is: Helvetica. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| DOT_COMMON_ATTR        = "fontname=Helvetica,fontsize=10" | ||||
| #DOT_FONTNAME           = Helvetica | ||||
|  | ||||
| # DOT_EDGE_ATTR is concatenated with DOT_COMMON_ATTR. For elegant style you can | ||||
| # add 'arrowhead=open, arrowtail=open, arrowsize=0.5'. <a | ||||
| # href=https://graphviz.org/doc/info/arrows.html>Complete documentation about | ||||
| # arrows shapes.</a> | ||||
| # The default value is: labelfontname=Helvetica,labelfontsize=10. | ||||
| # The DOT_FONTSIZE tag can be used to set the size (in points) of the font of | ||||
| # dot graphs. | ||||
| # Minimum value: 4, maximum value: 24, default value: 10. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| DOT_EDGE_ATTR          = "labelfontname=Helvetica,labelfontsize=10" | ||||
| #DOT_FONTSIZE           = 10 | ||||
|  | ||||
| # DOT_NODE_ATTR is concatenated with DOT_COMMON_ATTR. For view without boxes | ||||
| # around nodes set 'shape=plain' or 'shape=plaintext' <a | ||||
| # href=https://www.graphviz.org/doc/info/shapes.html>Shapes specification</a> | ||||
| # The default value is: shape=box,height=0.2,width=0.4. | ||||
| # By default doxygen will tell dot to use the default font as specified with | ||||
| # DOT_FONTNAME. If you specify a different font using DOT_FONTNAME you can set | ||||
| # the path where dot can find it using this tag. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| DOT_NODE_ATTR          = "shape=box,height=0.2,width=0.4" | ||||
| #DOT_FONTPATH           = | ||||
|  | ||||
| # You can set the path where dot can find font specified with fontname in | ||||
| # DOT_COMMON_ATTR and others dot attributes. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| DOT_FONTPATH           = | ||||
|  | ||||
| # If the CLASS_GRAPH tag is set to YES or GRAPH or BUILTIN then doxygen will | ||||
| # generate a graph for each documented class showing the direct and indirect | ||||
| # inheritance relations. In case the CLASS_GRAPH tag is set to YES or GRAPH and | ||||
| # HAVE_DOT is enabled as well, then dot will be used to draw the graph. In case | ||||
| # the CLASS_GRAPH tag is set to YES and HAVE_DOT is disabled or if the | ||||
| # CLASS_GRAPH tag is set to BUILTIN, then the built-in generator will be used. | ||||
| # If the CLASS_GRAPH tag is set to TEXT the direct and indirect inheritance | ||||
| # relations will be shown as texts / links. | ||||
| # Possible values are: NO, YES, TEXT, GRAPH and BUILTIN. | ||||
| # If the CLASS_GRAPH tag is set to YES (or GRAPH) then doxygen will generate a | ||||
| # graph for each documented class showing the direct and indirect inheritance | ||||
| # relations. In case HAVE_DOT is set as well dot will be used to draw the graph, | ||||
| # otherwise the built-in generator will be used. If the CLASS_GRAPH tag is set | ||||
| # to TEXT the direct and indirect inheritance relations will be shown as texts / | ||||
| # links. | ||||
| # Possible values are: NO, YES, TEXT and GRAPH. | ||||
| # The default value is: YES. | ||||
|  | ||||
| CLASS_GRAPH            = YES | ||||
| @@ -2530,21 +2430,15 @@ CLASS_GRAPH            = YES | ||||
| # If the COLLABORATION_GRAPH tag is set to YES then doxygen will generate a | ||||
| # graph for each documented class showing the direct and indirect implementation | ||||
| # dependencies (inheritance, containment, and class references variables) of the | ||||
| # class with other documented classes. Explicit enabling a collaboration graph, | ||||
| # when COLLABORATION_GRAPH is set to NO, can be accomplished by means of the | ||||
| # command \collaborationgraph. Disabling a collaboration graph can be | ||||
| # accomplished by means of the command \hidecollaborationgraph. | ||||
| # class with other documented classes. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| COLLABORATION_GRAPH    = YES | ||||
|  | ||||
| # If the GROUP_GRAPHS tag is set to YES then doxygen will generate a graph for | ||||
| # groups, showing the direct groups dependencies. Explicit enabling a group | ||||
| # dependency graph, when GROUP_GRAPHS is set to NO, can be accomplished by means | ||||
| # of the command \groupgraph. Disabling a directory graph can be accomplished by | ||||
| # means of the command \hidegroupgraph. See also the chapter Grouping in the | ||||
| # manual. | ||||
| # groups, showing the direct groups dependencies. See also the chapter Grouping | ||||
| # in the manual. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| @@ -2604,9 +2498,7 @@ TEMPLATE_RELATIONS     = NO | ||||
| # If the INCLUDE_GRAPH, ENABLE_PREPROCESSING and SEARCH_INCLUDES tags are set to | ||||
| # YES then doxygen will generate a graph for each documented file showing the | ||||
| # direct and indirect include dependencies of the file with other documented | ||||
| # files. Explicit enabling an include graph, when INCLUDE_GRAPH is is set to NO, | ||||
| # can be accomplished by means of the command \includegraph. Disabling an | ||||
| # include graph can be accomplished by means of the command \hideincludegraph. | ||||
| # files. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| @@ -2615,10 +2507,7 @@ INCLUDE_GRAPH          = YES | ||||
| # If the INCLUDED_BY_GRAPH, ENABLE_PREPROCESSING and SEARCH_INCLUDES tags are | ||||
| # set to YES then doxygen will generate a graph for each documented file showing | ||||
| # the direct and indirect include dependencies of the file with other documented | ||||
| # files. Explicit enabling an included by graph, when INCLUDED_BY_GRAPH is set | ||||
| # to NO, can be accomplished by means of the command \includedbygraph. Disabling | ||||
| # an included by graph can be accomplished by means of the command | ||||
| # \hideincludedbygraph. | ||||
| # files. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| @@ -2658,10 +2547,7 @@ GRAPHICAL_HIERARCHY    = YES | ||||
| # If the DIRECTORY_GRAPH tag is set to YES then doxygen will show the | ||||
| # dependencies a directory has on other directories in a graphical way. The | ||||
| # dependency relations are determined by the #include relations between the | ||||
| # files in the directories. Explicit enabling a directory graph, when | ||||
| # DIRECTORY_GRAPH is set to NO, can be accomplished by means of the command | ||||
| # \directorygraph. Disabling a directory graph can be accomplished by means of | ||||
| # the command \hidedirectorygraph. | ||||
| # files in the directories. | ||||
| # The default value is: YES. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| @@ -2677,13 +2563,12 @@ DIR_GRAPH_MAX_DEPTH    = 1 | ||||
| # The DOT_IMAGE_FORMAT tag can be used to set the image format of the images | ||||
| # generated by dot. For an explanation of the image formats see the section | ||||
| # output formats in the documentation of the dot tool (Graphviz (see: | ||||
| # https://www.graphviz.org/)). | ||||
| # http://www.graphviz.org/)). | ||||
| # Note: If you choose svg you need to set HTML_FILE_EXTENSION to xhtml in order | ||||
| # to make the SVG files visible in IE 9+ (other browsers do not have this | ||||
| # requirement). | ||||
| # Possible values are: png, jpg, jpg:cairo, jpg:cairo:gd, jpg:gd, jpg:gd:gd, | ||||
| # gif, gif:cairo, gif:cairo:gd, gif:gd, gif:gd:gd, svg, png:gd, png:gd:gd, | ||||
| # png:cairo, png:cairo:gd, png:cairo:cairo, png:cairo:gdiplus, png:gdiplus and | ||||
| # Possible values are: png, jpg, gif, svg, png:gd, png:gd:gd, png:cairo, | ||||
| # png:cairo:gd, png:cairo:cairo, png:cairo:gdiplus, png:gdiplus and | ||||
| # png:gdiplus:gdiplus. | ||||
| # The default value is: png. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
| @@ -2715,12 +2600,11 @@ DOT_PATH               = | ||||
|  | ||||
| DOTFILE_DIRS           = | ||||
|  | ||||
| # You can include diagrams made with dia in doxygen documentation. Doxygen will | ||||
| # then run dia to produce the diagram and insert it in the documentation. The | ||||
| # DIA_PATH tag allows you to specify the directory where the dia binary resides. | ||||
| # If left empty dia is assumed to be found in the default search path. | ||||
| # The MSCFILE_DIRS tag can be used to specify one or more directories that | ||||
| # contain msc files that are included in the documentation (see the \mscfile | ||||
| # command). | ||||
|  | ||||
| DIA_PATH               = | ||||
| MSCFILE_DIRS           = | ||||
|  | ||||
| # The DIAFILE_DIRS tag can be used to specify one or more directories that | ||||
| # contain dia files that are included in the documentation (see the \diafile | ||||
| @@ -2770,6 +2654,18 @@ DOT_GRAPH_MAX_NODES    = 50 | ||||
|  | ||||
| MAX_DOT_GRAPH_DEPTH    = 0 | ||||
|  | ||||
| # Set the DOT_TRANSPARENT tag to YES to generate images with a transparent | ||||
| # background. This is disabled by default, because dot on Windows does not seem | ||||
| # to support this out of the box. | ||||
| # | ||||
| # Warning: Depending on the platform used, enabling this option may lead to | ||||
| # badly anti-aliased labels on the edges of a graph (i.e. they become hard to | ||||
| # read). | ||||
| # The default value is: NO. | ||||
| # This tag requires that the tag HAVE_DOT is set to YES. | ||||
|  | ||||
| #DOT_TRANSPARENT        = NO | ||||
|  | ||||
| # Set the DOT_MULTI_TARGETS tag to YES to allow dot to generate multiple output | ||||
| # files in one run (i.e. multiple -o and -T options on the command line). This | ||||
| # makes dot run faster, but since only newer versions of dot (>1.8.10) support | ||||
| @@ -2797,19 +2693,3 @@ GENERATE_LEGEND        = YES | ||||
| # The default value is: YES. | ||||
|  | ||||
| DOT_CLEANUP            = YES | ||||
|  | ||||
| # You can define message sequence charts within doxygen comments using the \msc | ||||
| # command. If the MSCGEN_TOOL tag is left empty (the default), then doxygen will | ||||
| # use a built-in version of mscgen tool to produce the charts. Alternatively, | ||||
| # the MSCGEN_TOOL tag can also specify the name an external tool. For instance, | ||||
| # specifying prog as the value, doxygen will call the tool as prog -T | ||||
| # <outfile_format> -o <outputfile> <inputfile>. The external tool should support | ||||
| # output file formats "png", "eps", "svg", and "ismap". | ||||
|  | ||||
| MSCGEN_TOOL            = | ||||
|  | ||||
| # The MSCFILE_DIRS tag can be used to specify one or more directories that | ||||
| # contain msc files that are included in the documentation (see the \mscfile | ||||
| # command). | ||||
|  | ||||
| MSCFILE_DIRS           = | ||||
|   | ||||
							
								
								
									
										693
									
								
								GNUmakefile
									
									
									
									
									
								
							
							
						
						
							
								
								
									
										57
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,23 +1,18 @@ | ||||
| # Tilde Friends | ||||
|  | ||||
| Tilde Friends is a tool for making and sharing. | ||||
| Tilde Friends participates in the Secure Scuttlebutt decentralized social | ||||
| network while also functioning as a platform for making, sharing, and running | ||||
| web applications. | ||||
|  | ||||
| A public instance lives at https://www.tildefriends.net/. | ||||
|  | ||||
| It is both a peer-to-peer social network client, participating in Secure | ||||
| Scuttlebutt, as well as a platform for writing and running web applications. | ||||
|  | ||||
| ## Goals | ||||
|  | ||||
| 1. Make it easy and fun to run all sorts of web applications. | ||||
| 2. Provide security that is easy to understand and protects your data. | ||||
| 3. Make creating and sharing web applications accessible to anyone with a | ||||
|    browser. | ||||
| 1. Be the fanciest, best-maintained Secure Scuttlebutt client in town. | ||||
| 1. Make it easy to make, share, and run all sorts of applications while | ||||
|    respecting the privacy and safety of your data. | ||||
|  | ||||
| ## Building | ||||
|  | ||||
| Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. Builds for | ||||
| all of those host platforms plus mingw64, iOS, and android. | ||||
| ## Getting the Source | ||||
|  | ||||
| Tilde Friends uses git submodules, so either: | ||||
|  | ||||
| @@ -35,29 +30,39 @@ git submodule update --init --recursive | ||||
|  | ||||
| The `.tar.xz` source releases are all-inclusive. | ||||
|  | ||||
| 1. On Linux only, system OpenSSL libraries (`libssl-dev`, in debian-speak) is | ||||
|    assumed to be available. | ||||
| 2. To build, run `make debug` or `make release`. An executable will be | ||||
|    generated in a subdirectory of `out/`. | ||||
| 3. It's possible to build for Android, iOS, and Windows on Linux, if you have | ||||
|    the right dependencies in the right places. `make windebug winrelease | ||||
| iosdebug-ipa iosrelease-ipa release-apk`. | ||||
| 4. To build in docker, `docker build .`. | ||||
| 5. `make format` will normalize formatting to the coding standard. | ||||
| ## Building | ||||
|  | ||||
| Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. It's possible | ||||
| to build for Android, iOS, and Windows on Linux, if you have the right | ||||
| dependencies in the right places. | ||||
|  | ||||
| ### Requirements | ||||
|  | ||||
| On MacOS, Xcode's command-line tools are expected to be available. | ||||
|  | ||||
| ### Build Commands | ||||
|  | ||||
| Run `make` with no arguments to see available build targets and options. `make | ||||
| debug` is a good place to start. | ||||
|  | ||||
| To build in docker, `docker build .`. | ||||
|  | ||||
| `make format` and `make prettier` will normalize formatting to the coding | ||||
| standard. | ||||
|  | ||||
| ## Running | ||||
|  | ||||
| By default, running the built `tildefriends` executable will start a web server | ||||
| at <http://localhost:12345/>. `tildefriends -h` lists further options. | ||||
| By default, running the built `out/debug/tildefriends` executable will start a | ||||
| web server at <http://localhost:12345/>. `tildefriends -h` lists further | ||||
| options. | ||||
|  | ||||
| The first user to create an account and log in will be granted administrative | ||||
| privileges. Further administration can be done at | ||||
| privileges. Further administration can be done in the `admin` app at | ||||
| <http://localhost:12345/~core/admin/>. | ||||
|  | ||||
| ## Documentation | ||||
|  | ||||
| Docs are a work in progress: | ||||
| <https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>. | ||||
| Docs live here: <https://docs.tildefriends.net/>. | ||||
|  | ||||
| ## License | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🎛", | ||||
| 	"previous": "&R49FywYF8CXPhoSEydLbSCgvCddeyTiBwGuDU/gqY+M=.sha256" | ||||
| 	"previous": "&kmKNyb/uaXNb24gCinJtfS8iWx4cLUWdtl0y2DwEUas=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -72,7 +72,7 @@ ${description.value}</textarea | ||||
| 					</button> | ||||
| 				</li> | ||||
| 			`; | ||||
| 		} else { | ||||
| 		} else if (description.type != 'hidden') { | ||||
| 			return html` | ||||
| 				<li class="w3-row"> | ||||
| 					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${title_case(key)}</label> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ | ||||
| /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */ | ||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||
| /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ | ||||
| html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} | ||||
| @@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} | ||||
| .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} | ||||
| .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} | ||||
| .w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex} | ||||
| .w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic} | ||||
| .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} | ||||
| .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} | ||||
| .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} | ||||
| @@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-button:hover{color:#000!important;background-color:#ccc!important} | ||||
| .w3-transparent,.w3-hover-none:hover{background-color:transparent!important} | ||||
| .w3-hover-none:hover{box-shadow:none!important} | ||||
| .w3-rtl{direction:rtl}.w3-ltr{direction:ltr} | ||||
| /* Colors */ | ||||
| .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} | ||||
| @@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} | ||||
| .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} | ||||
| .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} | ||||
| .w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important} | ||||
| .w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important} | ||||
| .w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important} | ||||
| .w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important} | ||||
| .w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important} | ||||
| .w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important} | ||||
| .w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important} | ||||
| .w3-danger{color:#fff!important;background-color:#dd0000!important} | ||||
| .w3-note{color:#000!important;background-color:#fff599!important} | ||||
| .w3-info{color:#fff!important;background-color:#0a6fc2!important} | ||||
| .w3-warning{color:#000!important;background-color:#ffb305!important} | ||||
| .w3-success{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} | ||||
| .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} | ||||
| .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} | ||||
| @@ -232,4 +248,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important} | ||||
| .w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important} | ||||
| .w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "📜", | ||||
| 	"previous": "&miGORZ8BwjHg2YO0t4bms6SI28XWPYqnqOZ8u9zsbZc=.sha256" | ||||
| 	"previous": "&sJqeyYjHys6Z8IqqtZ2ij2ZC1E2xieu/FU/u2hE+O1U=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -21,7 +21,7 @@ function* treeify(prefix, o) { | ||||
|  | ||||
| function markdown(md) { | ||||
| 	let parsed = new commonmark.Parser().parse(md ?? '*undocumented*'); | ||||
| 	return new commonmark.HtmlRenderer().render(parsed); | ||||
| 	return new commonmark.HtmlRenderer({safe: true}).render(parsed); | ||||
| } | ||||
|  | ||||
| function document(api) { | ||||
| @@ -55,6 +55,9 @@ app.setDocument(`<head> | ||||
| </head> | ||||
| <body style="color:#fff"> | ||||
| 	${markdown(docs.docs.global)} | ||||
| 	<!-- | ||||
| 	${Object.keys(docs.docs).filter(x => [...treeify('', globalThis)].indexOf(x) == -1).map(x => `<p>STALE: ${x}</p>`).join('')} | ||||
| 	--> | ||||
| 	${[...treeify('', globalThis)].map(x => document(x)).join('\n')} | ||||
| 	<a id="Database"></a> | ||||
| 	${markdown(docs.docs.database)} | ||||
|   | ||||
| @@ -195,51 +195,6 @@ Call a function after some delay. | ||||
|  * *Number* **timeout** Number of milliseconds to wait before calling the callback function. | ||||
| `; | ||||
|  | ||||
| docs['parseHttpRequest()'] = ` | ||||
| Parses an HTTP request. | ||||
| ### Parameters | ||||
|  * *Uint8Array* **request** The request data.  Maybe be partial or contain extra data.  The return value will | ||||
|     indicate when and where it is complete. | ||||
|  * *Number* **last_length** The length of the data passed on a previous attempt for the same request, or 0 initially. | ||||
| ### Returns | ||||
|  * *Integer* **-2** if the request is incomplete. | ||||
|  * *Integer* **-1** if the request could not be parsed. | ||||
|  * *Object* An object with **bytes_parsed**, **minor_version**, **path**, and **headers** fields on successful parse. | ||||
| `; | ||||
|  | ||||
| docs['parseHttpResponse()'] = ` | ||||
| Parses an HTTP response. | ||||
| ### Parameters | ||||
|  * *Uint8Array* **response** The response data.  Maybe be partial or contain extra data.  The return value will | ||||
|     indicate when and where it is complete. | ||||
|  * *Number* **last_length** The length of the data passed on a previous attempt for the same response, or 0 initially. | ||||
| ### Returns | ||||
|  * *Integer* **-2** if the response is incomplete. | ||||
|  * *Integer* **-1** if the response could not be parsed. | ||||
|  * *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse. | ||||
| `; | ||||
|  | ||||
| docs['sha1Digest()'] = ` | ||||
| Calculates a SHA1 digest. | ||||
|  | ||||
| Completes synchronously. | ||||
| ### Parameters | ||||
|  * *String* **value** The value for which to calculate the digest. | ||||
| ### Returns | ||||
| *String* The SHA1 digest of UTF-8 encoded \`value\`. | ||||
| `; | ||||
|  | ||||
| docs['maskBytes()'] = ` | ||||
| Masks bytes for WebSocket communication. | ||||
|  | ||||
| Completes synchronously. | ||||
| ### Parameters | ||||
|  * *Uint8Array* **bytes** The byte array of data to mask. | ||||
|  * *Uint32* **mask** The mask to apply. | ||||
| ### Returns | ||||
| *Uint32Array* The masked bytes. | ||||
| `; | ||||
|  | ||||
| docs['exit()'] = ` | ||||
| Exits the app.  But why would you want to do that? | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "💻", | ||||
| 	"previous": "&icsPplXHgmpkbNWyo/WTvRdT/A8BXxW4lJixOtP4ueQ=.sha256" | ||||
| 	"previous": "&sFRTDn/RpxP1NJeECXHrXKwCRUJsEOEDVaCMPl50zpM=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -19,10 +19,6 @@ async function fetch_info(apps) { | ||||
| 	return result; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * | ||||
|  * | ||||
|  */ | ||||
| async function fetch_shared_apps() { | ||||
| 	let messages = {}; | ||||
|  | ||||
| @@ -69,17 +65,17 @@ async function main() { | ||||
| 	const stylesheet = ` | ||||
| 		body { | ||||
| 			color: whitesmoke; | ||||
| 			font-family: sans-serif; | ||||
| 			margin: 16px; | ||||
| 			margin: 8px; | ||||
| 		} | ||||
| 		.container { | ||||
|  | ||||
| 		.iconbox { | ||||
| 			display: grid; | ||||
| 			grid-template-columns: repeat(auto-fill, 64px); | ||||
| 			gap: 1em; | ||||
| 			justify-content: space-around; | ||||
| 			background-color: #ffffff10; | ||||
| 			border: 2px solid #073642; | ||||
| 			border-radius: 8px; | ||||
| 			grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); | ||||
| 		} | ||||
|  | ||||
| 		.iconbox::after { | ||||
| 			content: ""; | ||||
| 			flex: auto; | ||||
| 		} | ||||
|  | ||||
| 		.app { | ||||
| @@ -101,16 +97,28 @@ async function main() { | ||||
| 	`; | ||||
|  | ||||
| 	const body = ` | ||||
| 		<h1 style="text-shadow: #808080 0 0 10px;">Welcome to Tilde Friends.</h1> | ||||
| 		<h1>Welcome to Tilde Friends</h1> | ||||
|  | ||||
| 		<h2>your apps</h2> | ||||
| 		<div id="apps" class="container"></div> | ||||
| 		<div class="w3-card-4 w3-margin-top"> | ||||
| 			<header class="w3-container w3-light-blue"> | ||||
| 				<h2>Your Apps</h2> | ||||
| 			</header> | ||||
| 			<div id="apps" class="w3-indigo iconbox"></div> | ||||
| 		</div> | ||||
|  | ||||
| 		<h2>shared apps</h2> | ||||
| 		<div id="shared_apps" class="container"></div> | ||||
| 		<div class="w3-card-4 w3-margin-top"> | ||||
| 			<header class="w3-container w3-light-blue"> | ||||
| 				<h2>Shared Apps</h2> | ||||
| 			</header> | ||||
| 			<div id="shared_apps" class="w3-indigo iconbox"></div> | ||||
| 		</div> | ||||
|  | ||||
| 		<h2>core apps</h2> | ||||
| 		<div id="core_apps" class="container"></div> | ||||
| 		<div class="w3-card-4 w3-margin-top"> | ||||
| 			<header class="w3-container w3-light-blue"> | ||||
| 				<h2>Core Apps</h2> | ||||
| 			</header> | ||||
| 			<div id="core_apps" class="w3-indigo iconbox"></div> | ||||
| 		</div> | ||||
| 	`; | ||||
|  | ||||
| 	const script = ` | ||||
| @@ -126,9 +134,13 @@ async function main() { | ||||
|  | ||||
| 			// For each app in the provided list | ||||
| 			for (let app of Object.keys(apps).sort()) { | ||||
|  | ||||
| 				// Create the item | ||||
| 				let div = list.appendChild(document.createElement('div')); | ||||
| 				let inline = document.createElement('div'); | ||||
| 				inline.style.display = 'inline-block'; | ||||
| 				inline.classList.add('w3-button'); | ||||
| 				list.appendChild(inline); | ||||
| 				let div = document.createElement('div'); | ||||
| 				inline.appendChild(div); | ||||
| 				div.classList.add('app'); | ||||
|  | ||||
| 				// The app's icon | ||||
| @@ -161,12 +173,13 @@ async function main() { | ||||
| 	<!DOCTYPE html> | ||||
| 	<html> | ||||
| 		<head> | ||||
| 			<link type="text/css" rel="stylesheet" href="w3.css"></link> | ||||
| 			<style> | ||||
| 				${stylesheet} | ||||
| 			</style> | ||||
| 		</head> | ||||
|  | ||||
| 		<body> | ||||
| 		<body class="w3-darkgray"> | ||||
| 			${body} | ||||
| 		</body> | ||||
|  | ||||
|   | ||||
							
								
								
									
										251
									
								
								apps/apps/w3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,251 @@ | ||||
| /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */ | ||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||
| /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ | ||||
| html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} | ||||
| article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item} | ||||
| audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline} | ||||
| audio:not([controls]){display:none;height:0}[hidden],template{display:none} | ||||
| a{background-color:transparent}a:active,a:hover{outline-width:0} | ||||
| abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted} | ||||
| b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000} | ||||
| small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline} | ||||
| sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none} | ||||
| code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible} | ||||
| button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold} | ||||
| button,input{overflow:visible}button,select{text-transform:none} | ||||
| button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button} | ||||
| button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0} | ||||
| button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText} | ||||
| fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em} | ||||
| legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto} | ||||
| [type=checkbox],[type=radio]{padding:0} | ||||
| [type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto} | ||||
| [type=search]{-webkit-appearance:textfield;outline-offset:-2px} | ||||
| [type=search]::-webkit-search-decoration{-webkit-appearance:none} | ||||
| ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit} | ||||
| /* End extract */ | ||||
| html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden} | ||||
| h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px} | ||||
| .w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace} | ||||
| h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px} | ||||
| hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit} | ||||
| .w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc} | ||||
| .w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1} | ||||
| .w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1} | ||||
| .w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center} | ||||
| .w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top} | ||||
| .w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px} | ||||
| .w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap} | ||||
| .w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)} | ||||
| .w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}    | ||||
| .w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none} | ||||
| .w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none} | ||||
| .w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%} | ||||
| .w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none} | ||||
| .w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block} | ||||
| .w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s} | ||||
| .w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%} | ||||
| .w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc} | ||||
| .w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer} | ||||
| .w3-dropdown-hover:hover .w3-dropdown-content{display:block} | ||||
| .w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000} | ||||
| .w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000} | ||||
| .w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1} | ||||
| .w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px} | ||||
| .w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto} | ||||
| .w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%} | ||||
| .w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%} | ||||
| .w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px} | ||||
| .w3-main,#main{transition:margin-left .4s} | ||||
| .w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)} | ||||
| .w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px} | ||||
| .w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto} | ||||
| .w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0} | ||||
| .w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left} | ||||
| .w3-bar .w3-button{white-space:normal} | ||||
| .w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0} | ||||
| .w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%} | ||||
| .w3-responsive{display:block;overflow-x:auto} | ||||
| .w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before, | ||||
| .w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both} | ||||
| .w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%} | ||||
| .w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%} | ||||
| .w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%} | ||||
| .w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%} | ||||
| @media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%} | ||||
| .w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%} | ||||
| .w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}} | ||||
| @media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%} | ||||
| .w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%} | ||||
| .w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}} | ||||
| .w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px} | ||||
| .w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px} | ||||
| .w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell} | ||||
| .w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom} | ||||
| .w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important} | ||||
| @media (max-width:1205px){.w3-auto{max-width:95%}} | ||||
| @media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px} | ||||
| .w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	 | ||||
| .w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center} | ||||
| .w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}} | ||||
| @media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}} | ||||
| @media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}} | ||||
| @media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}} | ||||
| @media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}} | ||||
| .w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0} | ||||
| .w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2} | ||||
| .w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0} | ||||
| .w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0} | ||||
| .w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)} | ||||
| .w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)} | ||||
| .w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)} | ||||
| .w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)} | ||||
| .w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)} | ||||
| .w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none} | ||||
| .w3-display-position{position:absolute} | ||||
| .w3-circle{border-radius:50%} | ||||
| .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} | ||||
| .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} | ||||
| .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} | ||||
| .w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex} | ||||
| .w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic} | ||||
| .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} | ||||
| .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} | ||||
| .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} | ||||
| .w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)} | ||||
| .w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)} | ||||
| .w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}} | ||||
| .w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}} | ||||
| .w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}} | ||||
| .w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}} | ||||
| .w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}} | ||||
| .w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}} | ||||
| .w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}} | ||||
| .w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}} | ||||
| .w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important} | ||||
| .w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1} | ||||
| .w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75} | ||||
| .w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)} | ||||
| .w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)} | ||||
| .w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)} | ||||
| .w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important} | ||||
| .w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important} | ||||
| .w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important} | ||||
| .w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important} | ||||
| .w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important} | ||||
| .w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important} | ||||
| .w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important} | ||||
| .w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important} | ||||
| .w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important} | ||||
| .w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important} | ||||
| .w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important} | ||||
| .w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important} | ||||
| .w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important} | ||||
| .w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important} | ||||
| .w3-padding-64{padding-top:64px!important;padding-bottom:64px!important} | ||||
| .w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important} | ||||
| .w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important} | ||||
| .w3-left{float:left!important}.w3-right{float:right!important} | ||||
| .w3-button:hover{color:#000!important;background-color:#ccc!important} | ||||
| .w3-transparent,.w3-hover-none:hover{background-color:transparent!important} | ||||
| .w3-hover-none:hover{box-shadow:none!important} | ||||
| .w3-rtl{direction:rtl}.w3-ltr{direction:ltr} | ||||
| /* Colors */ | ||||
| .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} | ||||
| .w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important} | ||||
| .w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important} | ||||
| .w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important} | ||||
| .w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important} | ||||
| .w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important} | ||||
| .w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important} | ||||
| .w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important} | ||||
| .w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important} | ||||
| .w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important} | ||||
| .w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important} | ||||
| .w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important} | ||||
| .w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important} | ||||
| .w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important} | ||||
| .w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important} | ||||
| .w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important} | ||||
| .w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important} | ||||
| .w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important} | ||||
| .w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important} | ||||
| .w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important} | ||||
| .w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important} | ||||
| .w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important} | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} | ||||
| .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} | ||||
| .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} | ||||
| .w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important} | ||||
| .w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important} | ||||
| .w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important} | ||||
| .w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important} | ||||
| .w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important} | ||||
| .w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important} | ||||
| .w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important} | ||||
| .w3-danger{color:#fff!important;background-color:#dd0000!important} | ||||
| .w3-note{color:#000!important;background-color:#fff599!important} | ||||
| .w3-info{color:#fff!important;background-color:#0a6fc2!important} | ||||
| .w3-warning{color:#000!important;background-color:#ffb305!important} | ||||
| .w3-success{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} | ||||
| .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} | ||||
| .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} | ||||
| .w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important} | ||||
| .w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important} | ||||
| .w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important} | ||||
| .w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important} | ||||
| .w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important} | ||||
| .w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important} | ||||
| .w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important} | ||||
| .w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important} | ||||
| .w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important} | ||||
| .w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important} | ||||
| .w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important} | ||||
| .w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important} | ||||
| .w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important} | ||||
| .w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important} | ||||
| .w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important} | ||||
| .w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important} | ||||
| .w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important} | ||||
| .w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important} | ||||
| .w3-text-red,.w3-hover-text-red:hover{color:#f44336!important} | ||||
| .w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important} | ||||
| .w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important} | ||||
| .w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important} | ||||
| .w3-text-white,.w3-hover-text-white:hover{color:#fff!important} | ||||
| .w3-text-black,.w3-hover-text-black:hover{color:#000!important} | ||||
| .w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important} | ||||
| .w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important} | ||||
| .w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important} | ||||
| .w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important} | ||||
| .w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important} | ||||
| .w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important} | ||||
| .w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important} | ||||
| .w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important} | ||||
| .w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important} | ||||
| .w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important} | ||||
| .w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important} | ||||
| .w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important} | ||||
| .w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important} | ||||
| .w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important} | ||||
| .w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important} | ||||
| .w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important} | ||||
| .w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important} | ||||
| .w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important} | ||||
| .w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important} | ||||
| .w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important} | ||||
| .w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important} | ||||
| .w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important} | ||||
| .w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important} | ||||
| .w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important} | ||||
| .w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important} | ||||
| .w3-border-black,.w3-hover-border-black:hover{border-color:#000!important} | ||||
| .w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important} | ||||
| .w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important} | ||||
| .w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important} | ||||
| .w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🪵", | ||||
| 	"previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256" | ||||
| 	"previous": "&3jabNEk6W2uolzTvfXX6fcWF50N3501vtgZ6ZxFVJ1s=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -52,8 +52,8 @@ export async function get_blog_message(id) { | ||||
| } | ||||
|  | ||||
| export function markdown(md) { | ||||
| 	let reader = new commonmark.Parser({safe: true}); | ||||
| 	let writer = new commonmark.HtmlRenderer(); | ||||
| 	let reader = new commonmark.Parser(); | ||||
| 	let writer = new commonmark.HtmlRenderer({safe: true}); | ||||
| 	let parsed = reader.parse(md || ''); | ||||
| 	let walker = parsed.walker(); | ||||
| 	let event, node; | ||||
|   | ||||
							
								
								
									
										42
									
								
								apps/blog/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,4 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "💽" | ||||
| 	"emoji": "💽", | ||||
| 	"previous": "&uQzkIe/Aj8yNhLKe3hEq+5fEJsGwIUx8NVBjJKwoV2U=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -51,6 +51,19 @@ async function key_list(db) { | ||||
| 	app.setDocument(doc); | ||||
| } | ||||
|  | ||||
| function load() { | ||||
| 	if (core.user?.credentials?.session) { | ||||
| 		database_list(); | ||||
| 	} else { | ||||
| 		app.setDocument(`<!DOCTYPE html> | ||||
| <html> | ||||
| <body style="background: #888"> | ||||
| 	<h1>Must be signed in to examine databases.</h1> | ||||
| </body> | ||||
| </html>`); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| core.register('message', async function (message) { | ||||
| 	if (message.event == 'hashChange') { | ||||
| 		let hash = message.hash.substring(1); | ||||
| @@ -62,9 +75,9 @@ core.register('message', async function (message) { | ||||
| 		} else if (hash.length) { | ||||
| 			key_list(await database(hash.split(':').slice(1).join(':'))); | ||||
| 		} else { | ||||
| 			database_list(); | ||||
| 			load(); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| database_list(); | ||||
| load(); | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "➡️" | ||||
| 	"emoji": "➡️", | ||||
| 	"previous": "&YDDSzbRD8NFAykYlZnk4r4hAK5qXjT5LmKE6rhS1s+A=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -14,7 +14,7 @@ async function contacts_internal(id, last_row_id, following, max_row_id) { | ||||
| 	result.blocking = result.blocking || {}; | ||||
| 	let contacts = await query( | ||||
| 		` | ||||
| 				SELECT content FROM messages | ||||
| 				SELECT json(content) AS content FROM messages | ||||
| 				WHERE author = ? AND | ||||
| 				rowid > ? AND | ||||
| 				rowid <= ? AND | ||||
| @@ -189,50 +189,6 @@ async function fetch_about(db, ids, users) { | ||||
| 	return Object.assign({}, users); | ||||
| } | ||||
|  | ||||
| async function getAbout(db, id) { | ||||
| 	if (g_about_cache[id]) { | ||||
| 		return g_about_cache[id]; | ||||
| 	} | ||||
| 	let o = await db.get(id + ':about'); | ||||
| 	const k_version = 4; | ||||
| 	let f = o ? JSON.parse(o) : o; | ||||
| 	if (!f || f.version != k_version) { | ||||
| 		f = {about: {}, sequence: 0, version: k_version}; | ||||
| 	} | ||||
| 	await ssb.sqlAsync( | ||||
| 		'SELECT ' + | ||||
| 			'  sequence, ' + | ||||
| 			'  content ' + | ||||
| 			'FROM messages ' + | ||||
| 			'WHERE ' + | ||||
| 			'  author = ?1 AND ' + | ||||
| 			'  sequence > ?2 AND ' + | ||||
| 			"  json_extract(content, '$.type') = 'about' AND " + | ||||
| 			"  json_extract(content, '$.about') = ?1 " + | ||||
| 			'UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 ' + | ||||
| 			'ORDER BY sequence', | ||||
| 		[id, f.sequence], | ||||
| 		function (row) { | ||||
| 			f.sequence = row.sequence; | ||||
| 			if (row.content) { | ||||
| 				let about = {}; | ||||
| 				try { | ||||
| 					about = JSON.parse(row.content); | ||||
| 				} catch {} | ||||
| 				delete about.about; | ||||
| 				delete about.type; | ||||
| 				f.about = Object.assign(f.about, about); | ||||
| 			} | ||||
| 		} | ||||
| 	); | ||||
| 	let j = JSON.stringify(f); | ||||
| 	if (o != j) { | ||||
| 		await db.set(id + ':about', j); | ||||
| 	} | ||||
| 	g_about_cache[id] = f.about; | ||||
| 	return f.about; | ||||
| } | ||||
|  | ||||
| async function getSize(db, id) { | ||||
| 	let size = 0; | ||||
| 	await ssb.sqlAsync( | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🪪", | ||||
| 	"previous": "&zxsmzdLKsiG/WZt/Gw7JOxepgypoktNNbIyWiyFiJVc=.sha256" | ||||
| 	"previous": "&5kw/2PgcySwOYCmAkjHTR2xTkIx3i7UjQmtQ8MfgWw8=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import * as tfrpc from '/tfrpc.js'; | ||||
|  | ||||
| const is_admin = core.user?.credentials?.permissions?.administration; | ||||
|  | ||||
| tfrpc.register(async function get_private_key(id) { | ||||
| 	return bip39Words(await ssb.getPrivateKey(id)); | ||||
| }); | ||||
| @@ -15,6 +17,9 @@ tfrpc.register(async function delete_id(id) { | ||||
| tfrpc.register(async function reload() { | ||||
| 	await main(); | ||||
| }); | ||||
| tfrpc.register(async function make_server(id) { | ||||
| 	return await ssb.swapWithServerIdentity(id); | ||||
| }); | ||||
|  | ||||
| async function main() { | ||||
| 	let ids = await ssb.getIdentities(); | ||||
| @@ -99,6 +104,16 @@ async function main() { | ||||
| 					alert('Error deleting ID: ' + e); | ||||
| 				} | ||||
| 			} | ||||
| 			handler.make_server = async function make_server(event) { | ||||
| 				let id = event.srcElement.dataset.id; | ||||
| 				try { | ||||
| 					if (confirm('Are you sure you want to make "' + id + '" the server identity?\\n\\nFor it to take effect, you will need to both sign in again and restart Tilde Friends.')) { | ||||
| 						await tfrpc.rpc.make_server(id); | ||||
| 					} | ||||
| 				} catch (e) { | ||||
| 					alert('Error making server ID: ' + e); | ||||
| 				} | ||||
| 			} | ||||
| 		</script> | ||||
| 		<header class="w3-theme w3-padding"><h1>SSB Identity Management</h1></header> | ||||
| 		<div class="w3-card-4 w3-margin"> | ||||
| @@ -117,13 +132,14 @@ async function main() { | ||||
| 		<div class="w3-card-4 w3-margin"> | ||||
| 			<header class="w3-container w3-theme-l2"><h2>Identities</h2></header> | ||||
| 			<ul class="w3-ul">` + | ||||
| 			ids | ||||
| 			(ids ?? []) | ||||
| 				.map( | ||||
| 					( | ||||
| 						id | ||||
| 					) => `<li style="overflow: hidden; text-wrap: nowrap; text-overflow: ellipsis"> | ||||
| 				<button onclick="handler.export_id(event)" data-id="${id}" class="w3-button w3-theme">Export Identity</button> | ||||
| 				<button onclick="handler.delete_id(event)" data-id="${id}" class="w3-button w3-theme">Delete Identity</button> | ||||
| 				${is_admin && id != server_id ? `<button onclick="handler.make_server(event)" data-id="${id}" class="w3-button w3-theme">Make Server Identity</button>` : ''} | ||||
| 				${id}${id == server_id ? ' <div class="w3-tag w3-theme-l4 w3-round">🖥 local server</div>' : ''} | ||||
| 			</li>` | ||||
| 				) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ | ||||
| /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */ | ||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||
| /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ | ||||
| html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} | ||||
| @@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} | ||||
| .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} | ||||
| .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} | ||||
| .w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex} | ||||
| .w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic} | ||||
| .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} | ||||
| .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} | ||||
| .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} | ||||
| @@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-button:hover{color:#000!important;background-color:#ccc!important} | ||||
| .w3-transparent,.w3-hover-none:hover{background-color:transparent!important} | ||||
| .w3-hover-none:hover{box-shadow:none!important} | ||||
| .w3-rtl{direction:rtl}.w3-ltr{direction:ltr} | ||||
| /* Colors */ | ||||
| .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} | ||||
| @@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} | ||||
| .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} | ||||
| .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} | ||||
| .w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important} | ||||
| .w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important} | ||||
| .w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important} | ||||
| .w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important} | ||||
| .w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important} | ||||
| .w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important} | ||||
| .w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important} | ||||
| .w3-danger{color:#fff!important;background-color:#dd0000!important} | ||||
| .w3-note{color:#000!important;background-color:#fff599!important} | ||||
| .w3-info{color:#fff!important;background-color:#0a6fc2!important} | ||||
| .w3-warning{color:#000!important;background-color:#ffb305!important} | ||||
| .w3-success{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} | ||||
| .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} | ||||
| .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} | ||||
| @@ -232,4 +248,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important} | ||||
| .w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important} | ||||
| .w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
|   | ||||
							
								
								
									
										5
									
								
								apps/intro.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "💡", | ||||
| 	"previous": "&eN6DNPpQUNhGvxneLuLPgsOXR6qyFZ7u+MAz0b4fa7k=.sha256" | ||||
| } | ||||
							
								
								
									
										16
									
								
								apps/intro/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | ||||
| import * as tfrpc from '/tfrpc.js'; | ||||
|  | ||||
| async function main() { | ||||
| 	await app.setDocument(utf8Decode(getFile('index.html'))); | ||||
| } | ||||
|  | ||||
| tfrpc.register(async function complete() { | ||||
| 	if ( | ||||
| 		core.user?.credentials?.permissions?.administration && | ||||
| 		(await core.globalSettingsGet('index')) == '/~core/intro/' | ||||
| 	) { | ||||
| 		return await core.globalSettingsSet('index', '/~core/ssb/'); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| main(); | ||||
							
								
								
									
										286
									
								
								apps/intro/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,286 @@ | ||||
| <!doctype html> | ||||
| <html style="height: 100%; margin: 0; padding: 0; box-sizing: border-box"> | ||||
| 	<head> | ||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
| 		<link rel="stylesheet" type="text/css" href="w3.css" /> | ||||
| 		<style> | ||||
| 			.slide { | ||||
| 				display: none; | ||||
| 				margin-left: auto; | ||||
| 				margin-right: auto; | ||||
| 			} | ||||
| 			.dot { | ||||
| 				width: 1em; | ||||
| 				height: 1em; | ||||
| 				cursor: pointer; | ||||
| 			} | ||||
| 			.w3-left, | ||||
| 			.w3-right { | ||||
| 				cursor: pointer; | ||||
| 			} | ||||
| 		</style> | ||||
| 	</head> | ||||
| 	<body | ||||
| 		style=" | ||||
| 			width: 100%; | ||||
| 			height: 100%; | ||||
| 			max-width: 100%; | ||||
| 			max-height: 100%; | ||||
| 			margin: 0; | ||||
| 			padding: 0; | ||||
| 			flex-direction: column; | ||||
| 			align-items: center; | ||||
| 		" | ||||
| 		class="w3-flex w3-dark-gray w3-center" | ||||
| 	> | ||||
| 		<div | ||||
| 			style=" | ||||
| 				flex: 1 1 auto; | ||||
| 				overflow: auto; | ||||
| 				contain: content; | ||||
| 				padding-top: 16px; | ||||
| 				padding-bottom: 16px; | ||||
| 			" | ||||
| 		> | ||||
| 			<div class="slide"> | ||||
| 				<div | ||||
| 					class="w3-content w3-xlarge w3-card-4 w3-blue w3-panel w3-padding-32 w3-round-xlarge" | ||||
| 					style="margin: 32px" | ||||
| 				> | ||||
| 					<div> | ||||
| 						<div>Welcome to</div> | ||||
| 						<div>~😎 Tilde Friends.</div> | ||||
| 					</div> | ||||
| 					<footer> | ||||
| 						<button class="w3-button w3-yellow proceed">Next</button> | ||||
| 					</footer> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="slide w3-card-4 w3-gray" style="width: 90%"> | ||||
| 				<header class="w3-container w3-blue w3-xlarge"> | ||||
| 					<h1>This brief tutorial will introduce:</h1> | ||||
| 				</header> | ||||
| 				<ul class="w3-large w3-left-align"> | ||||
| 					<li><b>Secure Scuttlebutt</b>, a decentralized social network.</li> | ||||
| 					<li> | ||||
| 						<b>Tilde Friends</b>, the application platform that you are using | ||||
| 						right now. | ||||
| 					</li> | ||||
| 					<li> | ||||
| 						<b>How to get started</b> if you want to get gossiping right away. | ||||
| 					</li> | ||||
| 				</ul> | ||||
| 				<footer class="w3-center w3-xlarge w3-padding"> | ||||
| 					<button class="w3-button w3-yellow proceed">Onward</button> | ||||
| 				</footer> | ||||
| 			</div> | ||||
| 			<div class="slide w3-gray" style="width: 90%"> | ||||
| 				<div class="w3-card-4 w3-xlarge"> | ||||
| 					<header class="w3-container w3-blue"> | ||||
| 						<h1>💻Secure Scuttlebutt in a Nutshell🦀</h1> | ||||
| 					</header> | ||||
| 					<div class="w3-container w3-large w3-left-align"> | ||||
| 						<p> | ||||
| 							Secure Scuttlebutt is a social network whose technical operation | ||||
| 							attempts to mirror human social interaction. | ||||
| 						</p> | ||||
| 						<ul> | ||||
| 							<li> | ||||
| 								You can create your own account and post to your own feed on | ||||
| 								your own device. This is all <b>local</b> with no external | ||||
| 								communication. This puts you fully in control of your own words | ||||
| 								and actions. | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								Before you can interact with others, you need to | ||||
| 								<b>connect over the network</b>, either directly to your friends | ||||
| 								(i.e., peer-to-peer between your phones on coffee shop Wi-Fi) or | ||||
| 								to 🚪<i>rooms</i> and 🍻<i>pubs</i> (hint: search the web for | ||||
| 								<i>#ssbroom</i>). | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								Who you choose to <b>follow</b> determines what you see, with | ||||
| 								most people choosing to see messages from friends and friends of | ||||
| 								those friends. If you encounter content you'd rather not see, | ||||
| 								<b>block</b> the offending account to improve the experience for | ||||
| 								you and your followers. | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								Your feed is an <b>immutable</b> log of your activity. Post with | ||||
| 								care, because like your words in real life, posts can't be taken | ||||
| 								back. | ||||
| 							</li> | ||||
| 						</ul> | ||||
| 					</div> | ||||
| 					<footer class="w3-center w3-xlarge w3-padding"> | ||||
| 						<a | ||||
| 							class="w3-button w3-light-gray" | ||||
| 							href="https://scuttlebutt.nz/" | ||||
| 							target="_blank" | ||||
| 							>See scuttlebutt.nz</a | ||||
| 						> | ||||
| 						<button class="w3-button w3-yellow proceed">Got It</button> | ||||
| 					</footer> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="slide w3-gray" style="width: 90%"> | ||||
| 				<div class="w3-card-4 w3-xlarge"> | ||||
| 					<header class="w3-container w3-blue w3-center"> | ||||
| 						<h1>~😎 Let's Talk Tilde Friends ~😎</h1> | ||||
| 					</header> | ||||
| 					<div class="w3-container w3-large w3-left-align"> | ||||
| 						<p> | ||||
| 							Tilde Friends is an application platform that is an application of | ||||
| 							its own. | ||||
| 						</p> | ||||
| 						<ul> | ||||
| 							<li> | ||||
| 								This intro is a Tilde Friends app. You can click <b>edit</b> at | ||||
| 								the top to look under the hood and make changes. | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								It is already possible to make and share new applications using | ||||
| 								only Tilde Friends and Secure Scuttlebutt without having to set | ||||
| 								up development environments, configure web servers, register | ||||
| 								domain names, or pay for hosting services. | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								But it's also set up so that you can't just break an app that | ||||
| 								everybody is using or do malicious things with personal content. | ||||
| 								There are <b>protections</b> in place like an operating system. | ||||
| 								The intent is also for it to be <b>safe</b> to run strange apps | ||||
| 								without worrying about adverse effects. | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								But this is all a big 🚧work in progress🚧 and | ||||
| 								<b>experiment</b>. Let's see where it takes us. | ||||
| 							</li> | ||||
| 						</ul> | ||||
| 					</div> | ||||
| 					<footer class="w3-center w3-xlarge w3-padding"> | ||||
| 						<button class="w3-button w3-yellow proceed">Okay</button> | ||||
| 					</footer> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="slide w3-gray" style="width: 90%"> | ||||
| 				<div class="w3-card-4 w3-xlarge"> | ||||
| 					<header class="w3-container w3-blue w3-center"> | ||||
| 						<h1>🦀Let's Get this Tilde Friends Party Started🎉</h1> | ||||
| 					</header> | ||||
| 					<div class="w3-container w3-large w3-left-align"> | ||||
| 						<p>The button below will take you to the Secure Scuttlebutt app.</p> | ||||
| 						<ul> | ||||
| 							<li> | ||||
| 								Remember: | ||||
| 								<ol> | ||||
| 									<li>You are in charge. This is all on your device.</li> | ||||
| 									<li> | ||||
| 										Make network connections to exchange messages with others. | ||||
| 									</li> | ||||
| 									<li> | ||||
| 										Follow more accounts to see more content, and block those | ||||
| 										posting content you'd rather not see. | ||||
| 									</li> | ||||
| 									<li> | ||||
| 										Be respectful, and consider the consequences of what you | ||||
| 										post. | ||||
| 									</li> | ||||
| 									<li> | ||||
| 										This is all under active development. Exercise patience, and | ||||
| 										report issues encountered. | ||||
| 									</li> | ||||
| 								</ol> | ||||
| 							</li> | ||||
| 							<li> | ||||
| 								To see this tutorial again later, select <b>apps</b> -> | ||||
| 								<b>Core Apps</b> -> <b>intro</b>. | ||||
| 							</li> | ||||
| 						</ul> | ||||
| 					</div> | ||||
| 					<footer class="w3-center w3-xlarge w3-padding"> | ||||
| 						<button class="w3-button w3-yellow" id="complete">Let's Go!</button> | ||||
| 					</footer> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<div | ||||
| 			class="w3-text-white w3-xlarge w3-center w3-flex" | ||||
| 			style=" | ||||
| 				width: 100%; | ||||
| 				flex: 0 1; | ||||
| 				flex-direction: row; | ||||
| 				align-items: center; | ||||
| 				gap: 8px; | ||||
| 			" | ||||
| 		> | ||||
| 			<div class="w3-jumbo" id="left" style="flex: 1 0; cursor: pointer"> | ||||
| 				❮ | ||||
| 			</div> | ||||
| 			<span class="w3-badge dot w3-border w3-hover-yellow"></span> | ||||
| 			<span class="w3-badge dot w3-border w3-hover-yellow"></span> | ||||
| 			<span class="w3-badge dot w3-border w3-hover-yellow"></span> | ||||
| 			<span class="w3-badge dot w3-border w3-hover-yellow"></span> | ||||
| 			<span class="w3-badge dot w3-border w3-hover-yellow"></span> | ||||
| 			<div class="w3-jumbo" style="flex: 1 0; cursor: pointer" id="right"> | ||||
| 				❯ | ||||
| 			</div> | ||||
| 		</div> | ||||
| 		<script type="module"> | ||||
| 			import * as tfrpc from '/static/tfrpc.js'; | ||||
|  | ||||
| 			let index = 0; | ||||
| 			function set(i) { | ||||
| 				show(i - index); | ||||
| 			} | ||||
| 			function show(delta) { | ||||
| 				let slides = [...document.getElementsByClassName('slide')]; | ||||
| 				let dots = [...document.getElementsByClassName('dot')]; | ||||
| 				index = (index + delta + slides.length) % slides.length; | ||||
| 				for (let slide of slides) { | ||||
| 					slide.style.display = | ||||
| 						slides.indexOf(slide) == index ? 'block' : 'none'; | ||||
| 				} | ||||
| 				for (let dot of dots) { | ||||
| 					if (dots.indexOf(dot) == index) { | ||||
| 						dot.classList.add('w3-white'); | ||||
| 					} else { | ||||
| 						dot.classList.remove('w3-white'); | ||||
| 					} | ||||
| 				} | ||||
| 				document.getElementById('left').style.visibility = | ||||
| 					index == 0 ? 'hidden' : 'visible'; | ||||
| 				document.getElementById('right').style.visibility = | ||||
| 					index == slides.length - 1 ? 'hidden' : 'visible'; | ||||
| 			} | ||||
|  | ||||
| 			let dots = [...document.getElementsByClassName('dot')]; | ||||
| 			for (let dot of dots) { | ||||
| 				dot.onclick = () => set(dots.indexOf(dot)); | ||||
| 			} | ||||
| 			for (let button of document.getElementsByClassName('proceed')) { | ||||
| 				button.onclick = () => show(1); | ||||
| 			} | ||||
| 			document.getElementById('left').onclick = () => show(-1); | ||||
| 			document.getElementById('right').onclick = () => show(1); | ||||
| 			document.getElementById('complete').onclick = function () { | ||||
| 				console.log('completing'); | ||||
| 				tfrpc.rpc.complete().finally(function () { | ||||
| 					console.log('completed'); | ||||
| 					let a = document.createElement('a'); | ||||
| 					a.href = '/~core/ssb/'; | ||||
| 					a.target = '_top'; | ||||
| 					document.body.appendChild(a); | ||||
| 					a.click(); | ||||
| 				}); | ||||
| 			}; | ||||
| 			window.addEventListener('keyup', function (event) { | ||||
| 				if (event.key == 'ArrowLeft') { | ||||
| 					show(-1); | ||||
| 				} else if (event.key == 'ArrowRight') { | ||||
| 					show(1); | ||||
| 				} | ||||
| 			}); | ||||
| 			show(0); | ||||
| 		</script> | ||||
| 	</body> | ||||
| </html> | ||||
							
								
								
									
										251
									
								
								apps/intro/w3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,251 @@ | ||||
| /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */ | ||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||
| /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ | ||||
| html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} | ||||
| article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item} | ||||
| audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline} | ||||
| audio:not([controls]){display:none;height:0}[hidden],template{display:none} | ||||
| a{background-color:transparent}a:active,a:hover{outline-width:0} | ||||
| abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted} | ||||
| b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000} | ||||
| small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline} | ||||
| sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none} | ||||
| code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible} | ||||
| button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold} | ||||
| button,input{overflow:visible}button,select{text-transform:none} | ||||
| button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button} | ||||
| button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0} | ||||
| button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText} | ||||
| fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em} | ||||
| legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto} | ||||
| [type=checkbox],[type=radio]{padding:0} | ||||
| [type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto} | ||||
| [type=search]{-webkit-appearance:textfield;outline-offset:-2px} | ||||
| [type=search]::-webkit-search-decoration{-webkit-appearance:none} | ||||
| ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit} | ||||
| /* End extract */ | ||||
| html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden} | ||||
| h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px} | ||||
| .w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace} | ||||
| h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px} | ||||
| hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit} | ||||
| .w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc} | ||||
| .w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1} | ||||
| .w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1} | ||||
| .w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center} | ||||
| .w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top} | ||||
| .w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px} | ||||
| .w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap} | ||||
| .w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)} | ||||
| .w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}    | ||||
| .w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none} | ||||
| .w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none} | ||||
| .w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%} | ||||
| .w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none} | ||||
| .w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block} | ||||
| .w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s} | ||||
| .w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%} | ||||
| .w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc} | ||||
| .w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer} | ||||
| .w3-dropdown-hover:hover .w3-dropdown-content{display:block} | ||||
| .w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000} | ||||
| .w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000} | ||||
| .w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1} | ||||
| .w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px} | ||||
| .w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto} | ||||
| .w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%} | ||||
| .w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%} | ||||
| .w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px} | ||||
| .w3-main,#main{transition:margin-left .4s} | ||||
| .w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)} | ||||
| .w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px} | ||||
| .w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto} | ||||
| .w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0} | ||||
| .w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left} | ||||
| .w3-bar .w3-button{white-space:normal} | ||||
| .w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0} | ||||
| .w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%} | ||||
| .w3-responsive{display:block;overflow-x:auto} | ||||
| .w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before, | ||||
| .w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both} | ||||
| .w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%} | ||||
| .w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%} | ||||
| .w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%} | ||||
| .w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%} | ||||
| @media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%} | ||||
| .w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%} | ||||
| .w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}} | ||||
| @media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%} | ||||
| .w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%} | ||||
| .w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}} | ||||
| .w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px} | ||||
| .w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px} | ||||
| .w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell} | ||||
| .w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom} | ||||
| .w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important} | ||||
| @media (max-width:1205px){.w3-auto{max-width:95%}} | ||||
| @media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px} | ||||
| .w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	 | ||||
| .w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center} | ||||
| .w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}} | ||||
| @media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}} | ||||
| @media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}} | ||||
| @media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}} | ||||
| @media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}} | ||||
| .w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0} | ||||
| .w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2} | ||||
| .w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0} | ||||
| .w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0} | ||||
| .w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)} | ||||
| .w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)} | ||||
| .w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)} | ||||
| .w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)} | ||||
| .w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)} | ||||
| .w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none} | ||||
| .w3-display-position{position:absolute} | ||||
| .w3-circle{border-radius:50%} | ||||
| .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} | ||||
| .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} | ||||
| .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} | ||||
| .w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex} | ||||
| .w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic} | ||||
| .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} | ||||
| .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} | ||||
| .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} | ||||
| .w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)} | ||||
| .w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)} | ||||
| .w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}} | ||||
| .w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}} | ||||
| .w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}} | ||||
| .w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}} | ||||
| .w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}} | ||||
| .w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}} | ||||
| .w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}} | ||||
| .w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}} | ||||
| .w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important} | ||||
| .w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1} | ||||
| .w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75} | ||||
| .w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)} | ||||
| .w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)} | ||||
| .w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)} | ||||
| .w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important} | ||||
| .w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important} | ||||
| .w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important} | ||||
| .w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important} | ||||
| .w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important} | ||||
| .w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important} | ||||
| .w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important} | ||||
| .w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important} | ||||
| .w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important} | ||||
| .w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important} | ||||
| .w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important} | ||||
| .w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important} | ||||
| .w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important} | ||||
| .w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important} | ||||
| .w3-padding-64{padding-top:64px!important;padding-bottom:64px!important} | ||||
| .w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important} | ||||
| .w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important} | ||||
| .w3-left{float:left!important}.w3-right{float:right!important} | ||||
| .w3-button:hover{color:#000!important;background-color:#ccc!important} | ||||
| .w3-transparent,.w3-hover-none:hover{background-color:transparent!important} | ||||
| .w3-hover-none:hover{box-shadow:none!important} | ||||
| .w3-rtl{direction:rtl}.w3-ltr{direction:ltr} | ||||
| /* Colors */ | ||||
| .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} | ||||
| .w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important} | ||||
| .w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important} | ||||
| .w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important} | ||||
| .w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important} | ||||
| .w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important} | ||||
| .w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important} | ||||
| .w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important} | ||||
| .w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important} | ||||
| .w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important} | ||||
| .w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important} | ||||
| .w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important} | ||||
| .w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important} | ||||
| .w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important} | ||||
| .w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important} | ||||
| .w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important} | ||||
| .w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important} | ||||
| .w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important} | ||||
| .w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important} | ||||
| .w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important} | ||||
| .w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important} | ||||
| .w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important} | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} | ||||
| .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} | ||||
| .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} | ||||
| .w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important} | ||||
| .w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important} | ||||
| .w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important} | ||||
| .w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important} | ||||
| .w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important} | ||||
| .w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important} | ||||
| .w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important} | ||||
| .w3-danger{color:#fff!important;background-color:#dd0000!important} | ||||
| .w3-note{color:#000!important;background-color:#fff599!important} | ||||
| .w3-info{color:#fff!important;background-color:#0a6fc2!important} | ||||
| .w3-warning{color:#000!important;background-color:#ffb305!important} | ||||
| .w3-success{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} | ||||
| .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} | ||||
| .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} | ||||
| .w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important} | ||||
| .w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important} | ||||
| .w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important} | ||||
| .w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important} | ||||
| .w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important} | ||||
| .w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important} | ||||
| .w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important} | ||||
| .w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important} | ||||
| .w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important} | ||||
| .w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important} | ||||
| .w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important} | ||||
| .w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important} | ||||
| .w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important} | ||||
| .w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important} | ||||
| .w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important} | ||||
| .w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important} | ||||
| .w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important} | ||||
| .w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important} | ||||
| .w3-text-red,.w3-hover-text-red:hover{color:#f44336!important} | ||||
| .w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important} | ||||
| .w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important} | ||||
| .w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important} | ||||
| .w3-text-white,.w3-hover-text-white:hover{color:#fff!important} | ||||
| .w3-text-black,.w3-hover-text-black:hover{color:#000!important} | ||||
| .w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important} | ||||
| .w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important} | ||||
| .w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important} | ||||
| .w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important} | ||||
| .w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important} | ||||
| .w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important} | ||||
| .w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important} | ||||
| .w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important} | ||||
| .w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important} | ||||
| .w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important} | ||||
| .w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important} | ||||
| .w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important} | ||||
| .w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important} | ||||
| .w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important} | ||||
| .w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important} | ||||
| .w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important} | ||||
| .w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important} | ||||
| .w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important} | ||||
| .w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important} | ||||
| .w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important} | ||||
| .w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important} | ||||
| .w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important} | ||||
| .w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important} | ||||
| .w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important} | ||||
| .w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important} | ||||
| .w3-border-black,.w3-hover-border-black:hover{border-color:#000!important} | ||||
| .w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important} | ||||
| .w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important} | ||||
| .w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important} | ||||
| .w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🦟", | ||||
| 	"previous": "&cUqvSDUls3jn0haD85LPFAGdkc8wFuy347TtATNcJgg=.sha256" | ||||
| 	"previous": "&O0huuEgL/UQC9bkUfhPOyZFo9eRiz+koOkba6QwCGNA=.sha256" | ||||
| } | ||||
|   | ||||
							
								
								
									
										42
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,5 +1,11 @@ | ||||
| import * as linkify from './commonmark-linkify.js'; | ||||
|  | ||||
| var reUnsafeProtocol = /^javascript:|vbscript:|file:|data:/i; | ||||
| var reSafeDataProtocol = /^data:image\/(?:png|gif|jpeg|webp)/i; | ||||
| var potentiallyUnsafe = function (url) { | ||||
| 	return reUnsafeProtocol.test(url) && !reSafeDataProtocol.test(url); | ||||
| }; | ||||
|  | ||||
| function image(node, entering) { | ||||
| 	if ( | ||||
| 		node.firstChild?.type === 'text' && | ||||
| @@ -61,8 +67,8 @@ function image(node, entering) { | ||||
| } | ||||
|  | ||||
| export function markdown(md) { | ||||
| 	var reader = new commonmark.Parser({safe: true}); | ||||
| 	var writer = new commonmark.HtmlRenderer(); | ||||
| 	var reader = new commonmark.Parser(); | ||||
| 	var writer = new commonmark.HtmlRenderer({safe: true}); | ||||
| 	writer.image = image; | ||||
| 	var parsed = reader.parse(md || ''); | ||||
| 	parsed = linkify.transform(parsed); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "📝", | ||||
| 	"previous": "&b//KqE4Vx6kOSBRODK1p/8wjOLKZJ+CBB5IkaBt5YsM=.sha256" | ||||
| 	"previous": "&5LpOTEnor/rYFk3axyfmmehAoq9aEwNQRH4jwNhRQ7o=.sha256" | ||||
| } | ||||
|   | ||||
							
								
								
									
										42
									
								
								apps/journal/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -18,8 +18,8 @@ class TfJournalEntryElement extends LitElement { | ||||
| 	} | ||||
|  | ||||
| 	markdown(md) { | ||||
| 		var reader = new commonmark.Parser({safe: true}); | ||||
| 		var writer = new commonmark.HtmlRenderer(); | ||||
| 		var reader = new commonmark.Parser(); | ||||
| 		var writer = new commonmark.HtmlRenderer({safe: true}); | ||||
| 		var parsed = reader.parse(md || ''); | ||||
| 		return writer.render(parsed); | ||||
| 	} | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🚪", | ||||
| 	"previous": "&HXCdDG8gGYXElTyEFbg85jqa6lDXNL2ENPIA9UoJNbI=.sha256" | ||||
| 	"previous": "&DJwkqNfYWtW9yBtJQMseEXm7l04Enpi+yAxZulLq9Vk=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| async function main() { | ||||
| 	let host = core.url.match(/.*\/\/(.*?)\//)[1]; | ||||
| 	let id = (await ssb.getServerIdentity()).substring(1); | ||||
| 	let room = `net:${host}:${ssb.port}~shs:${id}:SSB+Room+SK3TLYC2T86EHQCUHBUHASCASE18JBV24=`; | ||||
| 	print(core.url); | ||||
| 	let host = core.url.match(/.*?\/\/([^:/]*)/)[1]; | ||||
| 	let port = await ssb.port(); | ||||
| 	let id = (await ssb.getServerIdentity()).substring(1).split('.')[0]; | ||||
| 	let room = `net:${host}:${port}~shs:${id}:SSB+Room+PSK3TLYC2T86EHQCUHBUHASCASE18JBV24=`; | ||||
| 	await app.setDocument(` | ||||
| 		<body style="color: #fff"> | ||||
| 			<h1>Server</h1> | ||||
|   | ||||
							
								
								
									
										42
									
								
								apps/sneaker/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🐌", | ||||
| 	"previous": "&O7Rf3apWJhDC4Zfjo1a5ZRk6AMNeCmFuEIiczXwmSYE=.sha256" | ||||
| 	"emoji": "🦀", | ||||
| 	"previous": "&Gic1e3jOZ7z5131jSCclbFXRpjyu8JlWJrjE7Fvn5dc=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -21,9 +21,6 @@ tfrpc.register(async function createIdentity() { | ||||
| tfrpc.register(async function getServerIdentity() { | ||||
| 	return ssb.getServerIdentity(); | ||||
| }); | ||||
| tfrpc.register(async function setServerFollowingMe(id, following) { | ||||
| 	return ssb.setServerFollowingMe(id, following); | ||||
| }); | ||||
| tfrpc.register(async function getIdentities() { | ||||
| 	return ssb.getIdentities(); | ||||
| }); | ||||
| @@ -79,6 +76,9 @@ tfrpc.register(function setHash(hash) { | ||||
| core.register('onMessage', async function (id) { | ||||
| 	await tfrpc.rpc.notifyNewMessage(id); | ||||
| }); | ||||
| core.register('onBlob', async function (id) { | ||||
| 	await tfrpc.rpc.notifyNewBlob(id); | ||||
| }); | ||||
| tfrpc.register(async function store_blob(blob) { | ||||
| 	if (Array.isArray(blob)) { | ||||
| 		blob = Uint8Array.from(blob); | ||||
| @@ -106,6 +106,19 @@ tfrpc.register(async function getActiveIdentity() { | ||||
| tfrpc.register(async function sync() { | ||||
| 	return await ssb.sync(); | ||||
| }); | ||||
| tfrpc.register(async function url() { | ||||
| 	return core.url; | ||||
| }); | ||||
| tfrpc.register(async function globalSettingsGet(key) { | ||||
| 	return core.globalSettingsGet(key); | ||||
| }); | ||||
| tfrpc.register(async function globalSettingsSet(key, value) { | ||||
| 	return core.globalSettingsSet(key, value); | ||||
| }); | ||||
| tfrpc.register(function isAdministrator() { | ||||
| 	return core.user?.credentials?.permissions?.administration; | ||||
| }); | ||||
|  | ||||
| core.register('onBroadcastsChanged', async function () { | ||||
| 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | ||||
| }); | ||||
|   | ||||
| @@ -7,7 +7,7 @@ function textNode(text) { | ||||
| function linkNode(text, link) { | ||||
| 	const linkNode = new commonmark.Node('link', undefined); | ||||
| 	if (link.startsWith('#')) { | ||||
| 		linkNode.destination = `#q=${encodeURIComponent(link)}`; | ||||
| 		linkNode.destination = `#${encodeURIComponent(link)}`; | ||||
| 	} else { | ||||
| 		linkNode.destination = link; | ||||
| 	} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {html, render} from './lit-all.min.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| let g_emojis; | ||||
|  | ||||
| @@ -14,33 +14,20 @@ function get_emojis() { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| async function get_recent(author) { | ||||
| 	let recent = await tfrpc.rpc.query( | ||||
| 		` | ||||
| 		SELECT DISTINCT content ->> '$.vote.expression' AS value | ||||
| 		FROM messages | ||||
| 		WHERE author = ? AND | ||||
| 		content ->> '$.type' = 'vote' | ||||
| 		ORDER BY timestamp DESC LIMIT 10 | ||||
| 	`, | ||||
| 		[author] | ||||
| 	); | ||||
| 	return recent.map((x) => x.value); | ||||
| } | ||||
|  | ||||
| export async function picker(callback, anchor, author) { | ||||
| export async function picker(callback, anchor, author, recent) { | ||||
| 	let json = await get_emojis(); | ||||
| 	let recent = await get_recent(author); | ||||
|  | ||||
| 	let div = document.createElement('div'); | ||||
| 	div.id = 'emoji_picker'; | ||||
| 	div.style.color = '#000'; | ||||
| 	div.style.background = '#fff'; | ||||
| 	div.style.border = '1px solid #000'; | ||||
| 	div.style.display = 'block'; | ||||
| 	div.style.display = 'flex'; | ||||
| 	div.style.overflow = 'scroll'; | ||||
| 	div.style.fontWeight = 'bold'; | ||||
| 	div.style.fontSize = 'xx-large'; | ||||
| 	div.style.flex = '1 1'; | ||||
| 	div.style.flexDirection = 'column'; | ||||
| 	let input = document.createElement('input'); | ||||
| 	input.type = 'text'; | ||||
| 	input.style.display = 'block'; | ||||
| @@ -50,6 +37,7 @@ export async function picker(callback, anchor, author) { | ||||
| 	input.style.position = 'relative'; | ||||
| 	div.appendChild(input); | ||||
| 	let list = document.createElement('div'); | ||||
| 	list.style.overflow = 'scroll'; | ||||
| 	div.appendChild(list); | ||||
| 	div.addEventListener('mousedown', function (event) { | ||||
| 		event.stopPropagation(); | ||||
| @@ -142,21 +130,43 @@ export async function picker(callback, anchor, author) { | ||||
| 	} | ||||
| 	refresh(); | ||||
| 	input.oninput = refresh; | ||||
| 	let modal = html` | ||||
| 		<style> | ||||
| 			${styles} | ||||
| 		</style> | ||||
| 		<div class="w3-modal" style="display: block"> | ||||
| 			<div class="w3-modal-content w3-card-4">${div}</div> | ||||
| 		</div> | ||||
| 	`; | ||||
| 	let parent = document.createElement('div'); | ||||
| 	document.body.appendChild(parent); | ||||
| 	function cleanup() { | ||||
| 		parent.parentElement.removeChild(parent); | ||||
| 		window.removeEventListener('keydown', key_down); | ||||
| 		document.body.removeEventListener('mousedown', cleanup); | ||||
| 	} | ||||
| 	let modal = html` | ||||
| 		<style> | ||||
| 			${styles} | ||||
| 		</style> | ||||
| 		<style> | ||||
| 			${generate_theme()} | ||||
| 		</style> | ||||
| 		<div | ||||
| 			class="w3-modal" | ||||
| 			style="display: block; box-sizing: border-box; z-index: 10" | ||||
| 		> | ||||
| 			<div class="w3-modal-content w3-card-4"> | ||||
| 				<div | ||||
| 					class="w3-content w3-theme-d1" | ||||
| 					style="display: flex; flex-direction: column; max-height: 80vh" | ||||
| 				> | ||||
| 					<header class="w3-container" style="flex: 0 0"> | ||||
| 						<h1>Choose a Reaction</h1> | ||||
| 						<span class="w3-button w3-display-topright" @click=${cleanup} | ||||
| 							>×</span | ||||
| 						> | ||||
| 					</header> | ||||
| 					${div} | ||||
| 					<footer class="w3-container w3-padding" style="flex: 0 0"> | ||||
| 						<button class="w3-button" @click=${cleanup}>Close</button> | ||||
| 					</footer> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	`; | ||||
| 	document.body.appendChild(parent); | ||||
| 	render(modal, parent); | ||||
| 	input.focus(); | ||||
| 	document.body.addEventListener('mousedown', cleanup); | ||||
|   | ||||
							
								
								
									
										42
									
								
								apps/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -8,10 +8,18 @@ import * as tf_compose from './tf-compose.js'; | ||||
| import * as tf_news from './tf-news.js'; | ||||
| import * as tf_profile from './tf-profile.js'; | ||||
| import * as tf_reactions_modal from './tf-reactions-modal.js'; | ||||
| import * as tf_tab_mentions from './tf-tab-mentions.js'; | ||||
| import * as tf_tab_news from './tf-tab-news.js'; | ||||
| import * as tf_tab_news_feed from './tf-tab-news-feed.js'; | ||||
| import * as tf_tab_search from './tf-tab-search.js'; | ||||
| import * as tf_tab_connections from './tf-tab-connections.js'; | ||||
| import * as tf_tab_query from './tf-tab-query.js'; | ||||
| import * as tf_tag from './tf-tag.js'; | ||||
| import * as tf_styles from './tf-styles.js'; | ||||
|  | ||||
| window.addEventListener('load', function () { | ||||
| 	let style = document.createElement('style'); | ||||
| 	style.innerText = tf_styles.styles; | ||||
| 	Promise.resolve(tf_styles.generate_theme()).then(function (x) { | ||||
| 		style.innerText += x; | ||||
| 	}); | ||||
| 	document.body.appendChild(style); | ||||
| }); | ||||
|   | ||||
| @@ -1,22 +1,33 @@ | ||||
| import {LitElement, html, css, guard, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			hash: {type: String}, | ||||
| 			unread: {type: Array}, | ||||
| 			tab: {type: String}, | ||||
| 			broadcasts: {type: Array}, | ||||
| 			connections: {type: Array}, | ||||
| 			loading: {type: Boolean}, | ||||
| 			loading_about: {type: Number}, | ||||
| 			loaded: {type: Boolean}, | ||||
| 			following: {type: Array}, | ||||
| 			users: {type: Object}, | ||||
| 			ids: {type: Array}, | ||||
| 			tags: {type: Array}, | ||||
| 			channels: {type: Array}, | ||||
| 			channels_unread: {type: Object}, | ||||
| 			channels_latest: {type: Object}, | ||||
| 			guest: {type: Boolean}, | ||||
| 			url: {type: String}, | ||||
| 			private_closed: {type: Object}, | ||||
| 			private_messages: {type: Array}, | ||||
| 			grouped_private_messages: {type: Object}, | ||||
| 			recent_reactions: {type: Array}, | ||||
| 			is_administrator: {type: Boolean}, | ||||
| 			stay_connected: {type: Boolean}, | ||||
| 			progress: {type: Number}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -26,14 +37,20 @@ class TfElement extends LitElement { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.hash = '#'; | ||||
| 		this.unread = []; | ||||
| 		this.tab = 'news'; | ||||
| 		this.broadcasts = []; | ||||
| 		this.connections = []; | ||||
| 		this.following = []; | ||||
| 		this.users = {}; | ||||
| 		this.loaded = false; | ||||
| 		this.tags = []; | ||||
| 		this.loading_about = 0; | ||||
| 		this.channels = []; | ||||
| 		this.channels_unread = {}; | ||||
| 		this.channels_latest = {}; | ||||
| 		this.loading_latest = 0; | ||||
| 		this.loading_latest_scheduled = 0; | ||||
| 		this.recent_reactions = []; | ||||
| 		this.private_closed = {}; | ||||
| 		tfrpc.rpc.getBroadcasts().then((b) => { | ||||
| 			self.broadcasts = b || []; | ||||
| 		}); | ||||
| @@ -43,10 +60,22 @@ class TfElement extends LitElement { | ||||
| 		tfrpc.rpc.getHash().then((hash) => self.set_hash(hash)); | ||||
| 		tfrpc.register(function hashChanged(hash) { | ||||
| 			self.set_hash(hash); | ||||
| 			self.reset_progress(); | ||||
| 		}); | ||||
| 		tfrpc.register(async function notifyNewMessage(id) { | ||||
| 			await self.fetch_new_message(id); | ||||
| 		}); | ||||
| 		tfrpc.register(async function notifyNewBlob(id) { | ||||
| 			window.dispatchEvent( | ||||
| 				new CustomEvent('blob-stored', { | ||||
| 					bubbles: true, | ||||
| 					composed: true, | ||||
| 					detail: { | ||||
| 						id: id, | ||||
| 					}, | ||||
| 				}) | ||||
| 			); | ||||
| 		}); | ||||
| 		tfrpc.register(function set(name, value) { | ||||
| 			if (name === 'broadcasts') { | ||||
| 				self.broadcasts = value; | ||||
| @@ -62,98 +91,217 @@ class TfElement extends LitElement { | ||||
| 	async initial_load() { | ||||
| 		let whoami = await tfrpc.rpc.getActiveIdentity(); | ||||
| 		let ids = (await tfrpc.rpc.getIdentities()) || []; | ||||
| 		this.is_administrator = await tfrpc.rpc.isAdministrator(); | ||||
| 		this.stay_connected = | ||||
| 			this.is_administrator && | ||||
| 			(await tfrpc.rpc.globalSettingsGet('stay_connected')); | ||||
| 		this.url = await tfrpc.rpc.url(); | ||||
| 		this.whoami = whoami ?? (ids.length ? ids[0] : undefined); | ||||
| 		this.guest = !this.whoami?.length; | ||||
| 		this.ids = ids; | ||||
| 		let private_closed = | ||||
| 			(await tfrpc.rpc.databaseGet('private_closed')) ?? '{}'; | ||||
| 		this.private_closed = JSON.parse(private_closed); | ||||
| 		await this.load_channels(); | ||||
| 	} | ||||
|  | ||||
| 	async close_private_chat(event) { | ||||
| 		let update = {}; | ||||
| 		update[event.detail.key] = true; | ||||
| 		this.private_closed = Object.assign(update, this.private_closed); | ||||
| 		await tfrpc.rpc.databaseSet( | ||||
| 			'private_closed', | ||||
| 			JSON.stringify(this.private_closed) | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	async load_channels() { | ||||
| 		let channels = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 			SELECT | ||||
| 				content ->> 'channel' AS channel, | ||||
| 				content ->> 'subscribed' AS subscribed | ||||
| 			FROM | ||||
| 				messages | ||||
| 			WHERE | ||||
| 				author = ? AND | ||||
| 				content ->> 'type' = 'channel' | ||||
| 			ORDER BY sequence | ||||
| 		`, | ||||
| 			[this.whoami] | ||||
| 		); | ||||
| 		let channel_map = {}; | ||||
| 		for (let row of channels) { | ||||
| 			if (row.subscribed) { | ||||
| 				channel_map[row.channel] = true; | ||||
| 			} else { | ||||
| 				delete channel_map[row.channel]; | ||||
| 			} | ||||
| 		} | ||||
| 		this.channels = Object.keys(channel_map).sort(); | ||||
| 	} | ||||
|  | ||||
| 	connectedCallback() { | ||||
| 		super.connectedCallback(); | ||||
| 		this._keydown = this.keydown.bind(this); | ||||
| 		window.addEventListener('keydown', this._keydown); | ||||
| 	} | ||||
|  | ||||
| 	disconnectedCallback() { | ||||
| 		super.disconnectedCallback(); | ||||
| 		window.removeEventListener('keydown', this._keydown); | ||||
| 	} | ||||
|  | ||||
| 	keydown(event) { | ||||
| 		if (event.altKey && event.key == 'ArrowUp') { | ||||
| 			this.next_channel(-1); | ||||
| 			event.preventDefault(); | ||||
| 		} else if (event.altKey && event.key == 'ArrowDown') { | ||||
| 			this.next_channel(1); | ||||
| 			event.preventDefault(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	visible_private() { | ||||
| 		if (!this.grouped_private_messages || !this.private_closed) { | ||||
| 			return []; | ||||
| 		} | ||||
| 		let self = this; | ||||
| 		return Object.fromEntries( | ||||
| 			Object.entries(this.grouped_private_messages).filter(([key, value]) => { | ||||
| 				let channel = '🔐' + [...new Set(JSON.parse(key))].sort().join(','); | ||||
| 				let grouped_latest = Math.max(...value.map((x) => x.rowid)); | ||||
| 				return ( | ||||
| 					!self.private_closed[key] || | ||||
| 					self.channels_unread[channel] === undefined || | ||||
| 					grouped_latest > self.channels_unread[channel] | ||||
| 				); | ||||
| 			}) | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	next_channel(delta) { | ||||
| 		let channel_names = [ | ||||
| 			'', | ||||
| 			'@', | ||||
| 			'👍', | ||||
| 			...Object.keys(this.visible_private()) | ||||
| 				.sort() | ||||
| 				.map((x) => '🔐' + JSON.parse(x).join(',')), | ||||
| 			...this.channels.map((x) => '#' + x), | ||||
| 		]; | ||||
| 		let index = channel_names.indexOf(this.hash.substring(1)); | ||||
| 		index = index != -1 ? index + delta : 0; | ||||
| 		tfrpc.rpc.setHash( | ||||
| 			'#' + | ||||
| 				encodeURIComponent( | ||||
| 					channel_names[(index + channel_names.length) % channel_names.length] | ||||
| 				) | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	set_hash(hash) { | ||||
| 		this.hash = hash || '#'; | ||||
| 		this.hash = decodeURIComponent(hash || '#'); | ||||
| 		if (this.hash.startsWith('#q=')) { | ||||
| 			this.tab = 'search'; | ||||
| 		} else if (this.hash === '#connections') { | ||||
| 			this.tab = 'connections'; | ||||
| 		} else if (this.hash === '#mentions') { | ||||
| 			this.tab = 'mentions'; | ||||
| 		} else if (this.hash.startsWith('#sql=')) { | ||||
| 			this.tab = 'query'; | ||||
| 		} else { | ||||
| 			this.tab = 'news'; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async fetch_about(ids, users) { | ||||
| 		const k_cache_version = 1; | ||||
| 	async fetch_about(following, users) { | ||||
| 		this.loading_about++; | ||||
| 		let ids = Object.keys(following).sort(); | ||||
| 		const k_cache_version = 3; | ||||
| 		let cache = await tfrpc.rpc.databaseGet('about'); | ||||
| 		let original_cache = cache; | ||||
| 		cache = cache ? JSON.parse(cache) : {}; | ||||
| 		if (cache.version !== k_cache_version) { | ||||
| 			cache = { | ||||
| 				version: k_cache_version, | ||||
| 				about: {}, | ||||
| 				last_row_id: 0, | ||||
| 			}; | ||||
| 		} | ||||
| 		let max_row_id = ( | ||||
| 			await tfrpc.rpc.query( | ||||
| 				` | ||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||
| 		`, | ||||
| 				[] | ||||
| 			) | ||||
| 		)[0].max_row_id; | ||||
|  | ||||
| 		let ids_out_of_date = ids.filter( | ||||
| 			(x) => | ||||
| 				(users[x]?.seq && !cache.about[x]?.seq) || | ||||
| 				(users[x]?.seq && users[x]?.seq > cache.about[x].seq) | ||||
| 		); | ||||
|  | ||||
| 		for (let id of Object.keys(cache.about)) { | ||||
| 			if (ids.indexOf(id) == -1) { | ||||
| 				delete cache.about[id]; | ||||
| 			} else { | ||||
| 				users[id] = Object.assign(cache.about[id], users[id] || {}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		let abouts = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT | ||||
| 					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM | ||||
| 					messages, | ||||
| 					json_each(?1) AS following | ||||
| 				WHERE | ||||
| 					messages.author = following.value AND | ||||
| 					messages.rowid > ?3 AND | ||||
| 					messages.rowid <= ?4 AND | ||||
| 					json_extract(messages.content, '$.type') = 'about' | ||||
| 				UNION | ||||
| 				SELECT | ||||
| 					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM | ||||
| 					messages, | ||||
| 					json_each(?2) AS following | ||||
| 				WHERE | ||||
| 					messages.author = following.value AND | ||||
| 					messages.rowid <= ?4 AND | ||||
| 					json_extract(messages.content, '$.type') = 'about' | ||||
| 				ORDER BY messages.author, messages.sequence | ||||
| 			`, | ||||
| 			[ | ||||
| 				JSON.stringify(ids.filter((id) => cache.about[id])), | ||||
| 				JSON.stringify(ids.filter((id) => !cache.about[id])), | ||||
| 				cache.last_row_id, | ||||
| 				max_row_id, | ||||
| 			] | ||||
| 		console.log( | ||||
| 			'loading about for', | ||||
| 			ids.length, | ||||
| 			'accounts', | ||||
| 			ids_out_of_date.length, | ||||
| 			'out of date' | ||||
| 		); | ||||
| 		for (let about of abouts) { | ||||
| 			let content = JSON.parse(about.content); | ||||
| 			if (content.about === about.author) { | ||||
| 				delete content.type; | ||||
| 				delete content.about; | ||||
| 				cache.about[about.author] = Object.assign( | ||||
| 					cache.about[about.author] || {}, | ||||
| 					content | ||||
| 		if (ids_out_of_date.length) { | ||||
| 			try { | ||||
| 				let rows = await tfrpc.rpc.query( | ||||
| 					` | ||||
| 						SELECT all_abouts.author, json(json_group_object(all_abouts.key, all_abouts.value)) AS about | ||||
| 						FROM ( | ||||
| 							SELECT | ||||
| 								messages.author, | ||||
| 								fields.key, | ||||
| 								RANK() OVER (PARTITION BY messages.author, fields.key ORDER BY messages.sequence DESC) AS rank, | ||||
| 								fields.value | ||||
| 							FROM messages JOIN json_each(messages.content) AS fields | ||||
| 							WHERE | ||||
| 								messages.content ->> '$.type' = 'about' AND | ||||
| 								messages.content ->> '$.about' = messages.author AND | ||||
| 								NOT fields.key IN ('about', 'type')) all_abouts | ||||
| 						JOIN json_each(?) AS following ON all_abouts.author = following.value | ||||
| 						WHERE rank = 1 | ||||
| 						GROUP BY all_abouts.author | ||||
| 					`, | ||||
| 					[JSON.stringify(ids_out_of_date)] | ||||
| 				); | ||||
| 				users = users || {}; | ||||
| 				for (let row of rows) { | ||||
| 					users[row.author] = Object.assign( | ||||
| 						users[row.author] || {}, | ||||
| 						JSON.parse(row.about) | ||||
| 					); | ||||
| 					cache.about[row.author] = Object.assign( | ||||
| 						{seq: users[row.author].seq}, | ||||
| 						JSON.parse(row.about) | ||||
| 					); | ||||
| 				} | ||||
| 			} catch (e) { | ||||
| 				console.log(e); | ||||
| 			} | ||||
| 		} | ||||
| 		cache.last_row_id = max_row_id; | ||||
| 		await tfrpc.rpc.databaseSet('about', JSON.stringify(cache)); | ||||
| 		users = users || {}; | ||||
| 		for (let id of Object.keys(cache.about)) { | ||||
| 			users[id] = Object.assign(users[id] || {}, cache.about[id]); | ||||
|  | ||||
| 		for (let id of ids_out_of_date) { | ||||
| 			if (!cache.about[id]?.seq) { | ||||
| 				cache.about[id] = Object.assign(cache.about[id] ?? {}, { | ||||
| 					seq: users[id]?.seq ?? 0, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		this.loading_about--; | ||||
|  | ||||
| 		let new_cache = JSON.stringify(cache); | ||||
| 		if (new_cache != original_cache) { | ||||
| 			let start_time = new Date(); | ||||
| 			tfrpc.rpc.databaseSet('about', new_cache).then(function () { | ||||
| 				console.log('saving about took', (new Date() - start_time) / 1000); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		return Object.assign({}, users); | ||||
| 	} | ||||
|  | ||||
| @@ -167,10 +315,15 @@ class TfElement extends LitElement { | ||||
| 			`, | ||||
| 			[JSON.stringify(this.following), id] | ||||
| 		); | ||||
| 		if (messages && messages.length) { | ||||
| 			this.unread = [...this.unread, ...messages]; | ||||
| 			this.unread = this.unread.slice(this.unread.length - 1024); | ||||
| 		for (let message of messages) { | ||||
| 			if ( | ||||
| 				message.author == this.whoami && | ||||
| 				JSON.parse(message.content)?.type == 'channel' | ||||
| 			) { | ||||
| 				this.load_channels(); | ||||
| 			} | ||||
| 		} | ||||
| 		this.schedule_load_latest(); | ||||
| 	} | ||||
|  | ||||
| 	async _handle_whoami_changed(event) { | ||||
| @@ -185,70 +338,353 @@ class TfElement extends LitElement { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async create_identity() { | ||||
| 		if (confirm('Are you sure you want to create a new identity?')) { | ||||
| 			await tfrpc.rpc.createIdentity(); | ||||
| 			this.ids = (await tfrpc.rpc.getIdentities()) || []; | ||||
| 			if (this.ids && !this.whoami) { | ||||
| 				this.whoami = this.ids[0]; | ||||
| 	async get_latest_private(following) { | ||||
| 		const k_version = 1; | ||||
| 		// { "version": 1, "range": [1234, 5678], messages: [ "%1.sha256", "%2.sha256", ... ], latest: rowid } | ||||
| 		let cache = JSON.parse( | ||||
| 			(await tfrpc.rpc.databaseGet(`private:${this.whoami}`)) ?? '{}' | ||||
| 		); | ||||
| 		if (cache.version !== k_version) { | ||||
| 			cache = { | ||||
| 				version: k_version, | ||||
| 				messages: [], | ||||
| 				range: [], | ||||
| 			}; | ||||
| 		} | ||||
| 		let latest = ( | ||||
| 			await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages') | ||||
| 		)[0].latest; | ||||
| 		let ranges = []; | ||||
| 		const k_chunk_size = 512; | ||||
| 		if (cache.range.length) { | ||||
| 			for (let i = cache.range[1]; i < latest; i += k_chunk_size) { | ||||
| 				ranges.push([i, Math.min(i + k_chunk_size, latest), true]); | ||||
| 			} | ||||
| 			for (let i = cache.range[0]; i >= 0; i -= k_chunk_size) { | ||||
| 				ranges.push([Math.max(i - k_chunk_size, 0), i, false]); | ||||
| 			} | ||||
| 		} else { | ||||
| 			for (let i = 0; i < latest; i += k_chunk_size) { | ||||
| 				ranges.push([i, Math.min(i + k_chunk_size, latest), true]); | ||||
| 			} | ||||
| 		} | ||||
| 		for (let range of ranges) { | ||||
| 			let messages = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					SELECT messages.rowid, messages.id, json(content) AS content | ||||
| 						FROM messages | ||||
| 						WHERE | ||||
| 							messages.rowid > ?1 AND | ||||
| 							messages.rowid <= ?2 AND | ||||
| 							json(messages.content) LIKE '"%' | ||||
| 						ORDER BY messages.rowid DESC | ||||
| 					`, | ||||
| 				[range[0], range[1]] | ||||
| 			); | ||||
| 			messages = (await this.decrypt(messages)).filter((x) => x.decrypted); | ||||
| 			if (messages.length) { | ||||
| 				cache.latest = Math.max( | ||||
| 					cache.latest ?? 0, | ||||
| 					...messages.map((x) => x.rowid) | ||||
| 				); | ||||
| 				if (range[2]) { | ||||
| 					cache.messages = [...cache.messages, ...messages.map((x) => x.id)]; | ||||
| 				} else { | ||||
| 					cache.messages = [...messages.map((x) => x.id), ...cache.messages]; | ||||
| 				} | ||||
| 			} | ||||
| 			cache.range[0] = Math.min(cache.range[0] ?? range[0], range[0]); | ||||
| 			cache.range[1] = Math.max(cache.range[1] ?? range[1], range[1]); | ||||
| 			await tfrpc.rpc.databaseSet( | ||||
| 				`private:${this.whoami}`, | ||||
| 				JSON.stringify(cache) | ||||
| 			); | ||||
| 		} | ||||
| 		return [cache.latest, cache.messages]; | ||||
| 	} | ||||
|  | ||||
| 	async query_timed(sql, args) { | ||||
| 		let start = new Date(); | ||||
| 		let result = await tfrpc.rpc.query(sql, args); | ||||
| 		let end = new Date(); | ||||
| 		console.log((end - start) / 1000, sql.replaceAll(/\s+/g, ' ').trim()); | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	async group_private_messages(messages) { | ||||
| 		let groups = {}; | ||||
| 		let result = await this.decrypt( | ||||
| 			await tfrpc.rpc.query( | ||||
| 				` | ||||
| 				SELECT messages.rowid, messages.id, author, timestamp, json(content) AS content | ||||
| 				FROM messages | ||||
| 				JOIN json_each(?) AS ids | ||||
| 				WHERE messages.id = ids.value | ||||
| 				ORDER BY timestamp DESC | ||||
| 			`, | ||||
| 				[JSON.stringify(messages)] | ||||
| 			) | ||||
| 		); | ||||
| 		for (let message of result) { | ||||
| 			let key = JSON.stringify( | ||||
| 				[ | ||||
| 					...new Set( | ||||
| 						message?.decrypted?.recps?.filter((x) => x != this.whoami) | ||||
| 					), | ||||
| 				].sort() ?? [] | ||||
| 			); | ||||
| 			if (!groups[key]) { | ||||
| 				groups[key] = []; | ||||
| 			} | ||||
| 			groups[key].push(message); | ||||
| 		} | ||||
| 		return groups; | ||||
| 	} | ||||
|  | ||||
| 	async load_channels_latest(following) { | ||||
| 		let start_time = new Date(); | ||||
| 		let latest_private = this.get_latest_private(following); | ||||
| 		const k_args = [ | ||||
| 			JSON.stringify(this.channels), | ||||
| 			JSON.stringify(following), | ||||
| 			'"' + this.whoami.replace('"', '""') + '"', | ||||
| 			this.whoami, | ||||
| 		]; | ||||
| 		let channels = ( | ||||
| 			await Promise.all([ | ||||
| 				this.query_timed( | ||||
| 					` | ||||
| 					SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages | ||||
| 					JOIN json_each(?1) AS channels ON messages.content ->> 'channel' = channels.value | ||||
| 					JOIN json_each(?2) AS following ON messages.author = following.value | ||||
| 					WHERE | ||||
| 						messages.content ->> 'type' = 'post' AND | ||||
| 						messages.content ->> 'root' IS NULL AND | ||||
| 						messages.author != ?4 | ||||
| 					GROUP by channel | ||||
| 				`, | ||||
| 					k_args | ||||
| 				), | ||||
| 				this.query_timed( | ||||
| 					` | ||||
| 					SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages | ||||
| 					JOIN messages_refs ON messages.id = messages_refs.message | ||||
| 					JOIN json_each(?1) AS channels ON messages_refs.ref = '#' || channels.value | ||||
| 					JOIN json_each(?2) AS following ON messages.author = following.value | ||||
| 					WHERE | ||||
| 						messages.content ->> 'type' = 'post' AND | ||||
| 						messages.content ->> 'root' IS NULL AND | ||||
| 						messages.author != ?4 | ||||
| 					GROUP by channel | ||||
| 				`, | ||||
| 					k_args | ||||
| 				), | ||||
| 				this.query_timed( | ||||
| 					` | ||||
| 					SELECT '' AS channel, MAX(messages.rowid) AS rowid FROM messages | ||||
| 					JOIN json_each(?2) AS following ON messages.author = following.value | ||||
| 					WHERE | ||||
| 						messages.content ->> 'type' = 'post' AND | ||||
| 						messages.content ->> 'root' IS NULL AND | ||||
| 						messages.author != ?4 | ||||
| 				`, | ||||
| 					k_args | ||||
| 				), | ||||
| 				this.query_timed( | ||||
| 					` | ||||
| 					SELECT '@' AS channel, MAX(messages.rowid) AS rowid FROM messages_fts(?3) | ||||
| 					JOIN messages ON messages.rowid = messages_fts.rowid | ||||
| 					JOIN json_each(?2) AS following ON messages.author = following.value | ||||
| 					WHERE messages.author != ?4 | ||||
| 				`, | ||||
| 					k_args | ||||
| 				), | ||||
| 			]) | ||||
| 		).flat(); | ||||
| 		let latest = {}; | ||||
| 		for (let row of channels) { | ||||
| 			if (!latest[row.channel]) { | ||||
| 				latest[row.channel] = row.rowid; | ||||
| 			} else { | ||||
| 				latest[row.channel] = Math.max(row.rowid, latest[row.channel]); | ||||
| 			} | ||||
| 		} | ||||
| 		this.channels_latest = latest; | ||||
| 		console.log('channels took', (new Date() - start_time) / 1000.0); | ||||
| 		let self = this; | ||||
| 		start_time = new Date(); | ||||
| 		latest_private.then(async function (latest) { | ||||
| 			self.channels_latest = Object.assign({}, self.channels_latest, { | ||||
| 				'🔐': latest[0], | ||||
| 			}); | ||||
| 			console.log('private took', (new Date() - start_time) / 1000.0); | ||||
| 			self.private_messages = latest[1]; | ||||
| 			self.grouped_private_messages = await self.group_private_messages( | ||||
| 				latest[1] | ||||
| 			); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	_schedule_load_latest_timer() { | ||||
| 		--this.loading_latest_scheduled; | ||||
| 		this.schedule_load_latest(); | ||||
| 	} | ||||
|  | ||||
| 	reset_progress() { | ||||
| 		if (this.progress === undefined) { | ||||
| 			this._progress_start = new Date(); | ||||
| 			requestAnimationFrame(this.update_progress.bind(this)); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async load_recent_tags() { | ||||
| 		let start = new Date(); | ||||
| 		this.tags = await tfrpc.rpc.query( | ||||
| 	update_progress() { | ||||
| 		if ( | ||||
| 			!this.loading_latest && | ||||
| 			!this.loading_latest_scheduled && | ||||
| 			!this.shadowRoot.getElementById('tf-tab-news')?.is_loading() | ||||
| 		) { | ||||
| 			this.progress = undefined; | ||||
| 			return; | ||||
| 		} | ||||
| 		this.progress = (new Date() - this._progress_start).valueOf(); | ||||
| 		requestAnimationFrame(this.update_progress.bind(this)); | ||||
| 	} | ||||
|  | ||||
| 	schedule_load_latest() { | ||||
| 		this.reset_progress(); | ||||
| 		if (!this.loading_latest) { | ||||
| 			this.shadowRoot.getElementById('tf-tab-news')?.load_latest(); | ||||
| 			this.load(); | ||||
| 		} else if (!this.loading_latest_scheduled) { | ||||
| 			this.loading_latest_scheduled++; | ||||
| 			setTimeout(this._schedule_load_latest_timer.bind(this), 5000); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async fetch_user_info(users) { | ||||
| 		let info = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 			WITH | ||||
| 				recent AS (SELECT id, json(content) AS content FROM messages | ||||
| 					WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post' | ||||
| 					ORDER BY timestamp DESC LIMIT 1024), | ||||
| 				recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag | ||||
| 					FROM recent | ||||
| 					WHERE json_extract(content, '$.channel') IS NOT NULL), | ||||
| 				recent_mentions AS (SELECT recent.id, json_extract(mention.value, '$.link') AS tag | ||||
| 					FROM recent, json_each(recent.content, '$.mentions') AS mention | ||||
| 					WHERE json_valid(mention.value) AND tag LIKE '#%'), | ||||
| 				combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions), | ||||
| 				by_message AS (SELECT DISTINCT id, tag FROM combined) | ||||
| 			SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10 | ||||
| 		`, | ||||
| 			[new Date() - 7 * 24 * 60 * 60 * 1000] | ||||
| 				SELECT messages_stats.author, messages_stats.max_sequence, messages_stats.max_timestamp AS max_ts FROM messages_stats | ||||
| 				JOIN json_each(?) AS following | ||||
| 				ON messages_stats.author = following.value | ||||
| 			`, | ||||
| 			[JSON.stringify(Object.keys(users))] | ||||
| 		); | ||||
| 		console.log('tags took', (new Date() - start) / 1000.0, 'seconds'); | ||||
| 		for (let row of info) { | ||||
| 			users[row.author] = Object.assign(users[row.author], { | ||||
| 				seq: row.max_sequence, | ||||
| 				ts: row.max_ts, | ||||
| 			}); | ||||
| 		} | ||||
| 		return users; | ||||
| 	} | ||||
|  | ||||
| 	async load_recent_reactions() { | ||||
| 		this.recent_reactions = ( | ||||
| 			await tfrpc.rpc.query( | ||||
| 				` | ||||
| 			SELECT DISTINCT content ->> '$.vote.expression' AS value | ||||
| 			FROM messages | ||||
| 			WHERE author = ? AND | ||||
| 			content ->> '$.type' = 'vote' | ||||
| 			ORDER BY timestamp DESC LIMIT 10 | ||||
| 		`, | ||||
| 				[this.whoami] | ||||
| 			) | ||||
| 		).map((x) => x.value); | ||||
| 	} | ||||
|  | ||||
| 	async load() { | ||||
| 		let whoami = this.whoami; | ||||
| 		let tags = this.load_recent_tags(); | ||||
| 		let following = await tfrpc.rpc.following([whoami], 2); | ||||
| 		let users = {}; | ||||
| 		let by_count = []; | ||||
| 		for (let [id, v] of Object.entries(following)) { | ||||
| 			users[id] = { | ||||
| 				following: v.of, | ||||
| 				blocking: v.ob, | ||||
| 				followed: v.if, | ||||
| 				blocked: v.ib, | ||||
| 			}; | ||||
| 			by_count.push({count: v.of, id: id}); | ||||
| 		this.loading_latest = true; | ||||
| 		this.reset_progress(); | ||||
| 		try { | ||||
| 			let start_time = new Date(); | ||||
| 			let whoami = this.whoami; | ||||
| 			let following = await tfrpc.rpc.following([whoami], 2); | ||||
| 			let old_users = this.users ?? {}; | ||||
| 			let users = {}; | ||||
| 			let by_count = []; | ||||
| 			for (let [id, v] of Object.entries(following)) { | ||||
| 				users[id] = Object.assign( | ||||
| 					{ | ||||
| 						following: v.of, | ||||
| 						blocking: v.ob, | ||||
| 						followed: v.if, | ||||
| 						blocked: v.ib, | ||||
| 						follow_depth: following[id]?.d, | ||||
| 					}, | ||||
| 					old_users[id] | ||||
| 				); | ||||
| 				by_count.push({count: v.of, id: id}); | ||||
| 			} | ||||
| 			let reactions = this.load_recent_reactions(); | ||||
| 			this.load_channels_latest(Object.keys(following)); | ||||
| 			this.channels_unread = JSON.parse( | ||||
| 				(await tfrpc.rpc.databaseGet('unread')) ?? '{}' | ||||
| 			); | ||||
| 			this.following = Object.keys(following); | ||||
| 			let about_start_time = new Date(); | ||||
| 			start_time = new Date(); | ||||
| 			users = await this.fetch_user_info(users); | ||||
| 			console.log( | ||||
| 				'user info took', | ||||
| 				(new Date() - start_time) / 1000.0, | ||||
| 				'seconds' | ||||
| 			); | ||||
| 			this.users = users; | ||||
|  | ||||
| 			let self = this; | ||||
| 			this.fetch_about(following, users).then(function (result) { | ||||
| 				self.users = result; | ||||
| 				console.log( | ||||
| 					'about took', | ||||
| 					(new Date() - about_start_time) / 1000.0, | ||||
| 					'seconds for', | ||||
| 					Object.keys(users).length, | ||||
| 					'users' | ||||
| 				); | ||||
| 			}); | ||||
| 			console.log( | ||||
| 				`load finished ${whoami} => ${this.whoami} in ${(new Date() - start_time) / 1000}` | ||||
| 			); | ||||
| 			await reactions; | ||||
| 			this.whoami = whoami; | ||||
| 			this.loaded = whoami; | ||||
| 		} finally { | ||||
| 			this.loading_latest = false; | ||||
| 		} | ||||
| 		console.log(by_count.sort((x, y) => y.count - x.count).slice(0, 20)); | ||||
| 		let start_time = new Date(); | ||||
| 		users = await this.fetch_about(Object.keys(following).sort(), users); | ||||
| 		console.log( | ||||
| 			'about took', | ||||
| 			(new Date() - start_time) / 1000.0, | ||||
| 			'seconds for', | ||||
| 			Object.keys(users).length, | ||||
| 			'users' | ||||
| 	} | ||||
|  | ||||
| 	channel_set_unread(event) { | ||||
| 		this.channels_unread[event.detail.channel ?? ''] = event.detail.unread; | ||||
| 		this.channels_unread = Object.assign({}, this.channels_unread); | ||||
| 		tfrpc.rpc.databaseSet('unread', JSON.stringify(this.channels_unread)); | ||||
| 	} | ||||
|  | ||||
| 	async decrypt(messages) { | ||||
| 		let whoami = this.whoami; | ||||
| 		return Promise.all( | ||||
| 			messages.map(async function (message) { | ||||
| 				let content; | ||||
| 				try { | ||||
| 					content = JSON.parse(message?.content); | ||||
| 				} catch {} | ||||
| 				if (typeof content === 'string') { | ||||
| 					let decrypted; | ||||
| 					try { | ||||
| 						decrypted = await tfrpc.rpc.try_decrypt(whoami, content); | ||||
| 					} catch {} | ||||
| 					if (decrypted) { | ||||
| 						try { | ||||
| 							message.decrypted = JSON.parse(decrypted); | ||||
| 						} catch { | ||||
| 							message.decrypted = decrypted; | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 				return message; | ||||
| 			}) | ||||
| 		); | ||||
| 		this.following = Object.keys(following); | ||||
| 		this.users = users; | ||||
| 		await tags; | ||||
| 		console.log(`load finished ${whoami} => ${this.whoami}`); | ||||
| 		this.whoami = whoami; | ||||
| 		this.loaded = whoami; | ||||
| 	} | ||||
|  | ||||
| 	render_tab() { | ||||
| @@ -262,9 +698,22 @@ class TfElement extends LitElement { | ||||
| 					whoami=${this.whoami} | ||||
| 					.users=${this.users} | ||||
| 					hash=${this.hash} | ||||
| 					.unread=${this.unread} | ||||
| 					@refresh=${() => (this.unread = [])} | ||||
| 					?loading=${this.loading} | ||||
| 					?loading=${this.loading || this.loading_about != 0} | ||||
| 					.channels=${this.channels} | ||||
| 					.channels_latest=${this.channels_latest} | ||||
| 					.channels_unread=${this.channels_unread} | ||||
| 					@channelsetunread=${this.channel_set_unread} | ||||
| 					@refresh=${this.refresh} | ||||
| 					@toggle_stay_connected=${this.toggle_stay_connected} | ||||
| 					@loadmessages=${this.reset_progress} | ||||
| 					@closeprivatechat=${this.close_private_chat} | ||||
| 					.connections=${this.connections} | ||||
| 					.private_messages=${this.private_messages} | ||||
| 					.visible_private_messages=${this.visible_private()} | ||||
| 					.grouped_private_messages=${this.grouped_private_messages} | ||||
| 					.recent_reactions=${this.recent_reactions} | ||||
| 					?is_administrator=${this.is_administrator} | ||||
| 					?stay_connected=${this.stay_connected} | ||||
| 				></tf-tab-news> | ||||
| 			`; | ||||
| 		} else if (this.tab === 'connections') { | ||||
| @@ -275,14 +724,6 @@ class TfElement extends LitElement { | ||||
| 					.broadcasts=${this.broadcasts} | ||||
| 				></tf-tab-connections> | ||||
| 			`; | ||||
| 		} else if (this.tab === 'mentions') { | ||||
| 			return html` | ||||
| 				<tf-tab-mentions | ||||
| 					.following=${this.following} | ||||
| 					whoami=${this.whoami} | ||||
| 					.users="${this.users}}" | ||||
| 				></tf-tab-mentions> | ||||
| 			`; | ||||
| 		} else if (this.tab === 'search') { | ||||
| 			return html` | ||||
| 				<tf-tab-search | ||||
| @@ -294,33 +735,46 @@ class TfElement extends LitElement { | ||||
| 						: null} | ||||
| 				></tf-tab-search> | ||||
| 			`; | ||||
| 		} else if (this.tab === 'query') { | ||||
| 			return html` | ||||
| 				<tf-tab-query | ||||
| 					.following=${this.following} | ||||
| 					whoami=${this.whoami} | ||||
| 					.users=${this.users} | ||||
| 					query=${this.hash?.startsWith('#sql=') | ||||
| 						? decodeURIComponent(this.hash.substring(5)) | ||||
| 						: null} | ||||
| 				></tf-tab-query> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async set_tab(tab) { | ||||
| 		this.tab = tab; | ||||
| 		if (tab === 'news') { | ||||
| 			this.schedule_load_latest(); | ||||
| 			await tfrpc.rpc.setHash('#'); | ||||
| 		} else if (tab === 'connections') { | ||||
| 			await tfrpc.rpc.setHash('#connections'); | ||||
| 		} else if (tab === 'mentions') { | ||||
| 			await tfrpc.rpc.setHash('#mentions'); | ||||
| 		} else if (tab === 'query') { | ||||
| 			await tfrpc.rpc.setHash('#sql='); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	refresh() { | ||||
| 		tfrpc.rpc.sync(); | ||||
| 	} | ||||
|  | ||||
| 	async toggle_stay_connected() { | ||||
| 		let stay_connected = await tfrpc.rpc.globalSettingsGet('stay_connected'); | ||||
| 		let new_stay_connected = !this.stay_connected; | ||||
| 		try { | ||||
| 			if (new_stay_connected != stay_connected) { | ||||
| 				await tfrpc.rpc.globalSettingsSet('stay_connected', new_stay_connected); | ||||
| 			} | ||||
| 		} finally { | ||||
| 			this.stay_connected = await tfrpc.rpc.globalSettingsGet('stay_connected'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async pick_color() { | ||||
| 		let input = document.createElement('input'); | ||||
| 		input.type = 'color'; | ||||
| 		input.value = (await tfrpc.rpc.localStorageGet('color')) ?? '#ff0000'; | ||||
| 		input.addEventListener('change', async function () { | ||||
| 			await tfrpc.rpc.localStorageSet('color', input.value); | ||||
| 			window.location.reload(); | ||||
| 		}); | ||||
| 		input.click(); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
|  | ||||
| @@ -334,13 +788,33 @@ class TfElement extends LitElement { | ||||
| 		const k_tabs = { | ||||
| 			'📰': 'news', | ||||
| 			'📡': 'connections', | ||||
| 			'@': 'mentions', | ||||
| 			'🔍': 'search', | ||||
| 			'👩💻': 'query', | ||||
| 		}; | ||||
|  | ||||
| 		let tabs = html` | ||||
| 			<div class="w3-bar w3-theme-l1"> | ||||
| 			<div | ||||
| 				class="w3-bar w3-theme-l1" | ||||
| 				style="position: static; top: 0; z-index: 10" | ||||
| 			> | ||||
| 				${this.is_administrator | ||||
| 					? html` | ||||
| 							<button | ||||
| 								class=${'w3-bar-item w3-button w3-circle w3-ripple' + | ||||
| 								(this.connections?.some((x) => x.flags.one_shot) | ||||
| 									? ' w3-spin' | ||||
| 									: '')} | ||||
| 								@click=${this.refresh} | ||||
| 							> | ||||
| 								↻ | ||||
| 							</button> | ||||
| 							<button | ||||
| 								class="w3-bar-item w3-button w3-ripple" | ||||
| 								@click=${this.toggle_stay_connected} | ||||
| 							> | ||||
| 								${this.stay_connected ? '🔗' : '⛓️💥'} | ||||
| 							</button> | ||||
| 						` | ||||
| 					: undefined} | ||||
| 				${Object.entries(k_tabs).map( | ||||
| 					([k, v]) => html` | ||||
| 						<button | ||||
| @@ -357,28 +831,57 @@ class TfElement extends LitElement { | ||||
| 						</button> | ||||
| 					` | ||||
| 				)} | ||||
| 				<button | ||||
| 					class="w3-bar-item w3-button w3-right" | ||||
| 					@click=${this.pick_color} | ||||
| 				> | ||||
| 					🎨<span class="w3-hide-small">Color</span> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 		`; | ||||
| 		let contents = !this.loaded | ||||
| 			? this.loading | ||||
| 				? html`<div | ||||
| 							class="w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge" | ||||
| 		let contents = this.guest | ||||
| 			? html`<div | ||||
| 					class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge w3-container" | ||||
| 				> | ||||
| 					<p>⚠️🦀 Must be logged in to Tilde Friends to scuttle here. 🦀⚠️</p> | ||||
| 					<footer class="w3-center"> | ||||
| 						<a | ||||
| 							class="w3-button w3-theme-d1" | ||||
| 							href=${`/login?return=${encodeURIComponent(this.url)}`} | ||||
| 							>Login</a | ||||
| 						> | ||||
| 							Loading... | ||||
| 					</footer> | ||||
| 				</div>` | ||||
| 			: !this.loaded || this.loading | ||||
| 				? html`<div | ||||
| 						class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge" | ||||
| 					> | ||||
| 						<span class="w3-spin" style="display: inline-block">🦀</span> | ||||
| 						Loading... | ||||
| 					</div>` | ||||
| 				: this.render_tab(); | ||||
| 		let progress = | ||||
| 			this.progress !== undefined | ||||
| 				? html` | ||||
| 						<div style="position: absolute; width: 100%" id="progress"> | ||||
| 							<div | ||||
| 								class="w3-theme-l3" | ||||
| 								style=${`height: 4px; position: absolute; right: ${Math.cos(this.progress / 250) > 0 ? 'auto' : '0'}; width: ${50 * Math.sin(this.progress / 250) + 50}%`} | ||||
| 							></div> | ||||
| 						</div> | ||||
| 						${this.render_tab()}` | ||||
| 				: html`<div>Select or create an identity.</div>` | ||||
| 			: this.render_tab(); | ||||
| 					` | ||||
| 				: undefined; | ||||
| 		return html` | ||||
| 			<style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			<div | ||||
| 				style="width: 100vw; min-height: 100vh; height: 100%" | ||||
| 				style="width: 100vw; min-height: 100vh; height: 100vh; display: flex; flex-direction: column" | ||||
| 				class="w3-theme-dark" | ||||
| 			> | ||||
| 				${tabs} | ||||
| 				<div style="padding: 8px"> | ||||
| 					${this.tags.map( | ||||
| 						(x) => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>` | ||||
| 					)} | ||||
| 				${progress} | ||||
| 				<div style="flex: 0 0">${tabs}</div> | ||||
| 				<div style="flex: 1 1; overflow: auto; contain: layout"> | ||||
| 					${contents} | ||||
| 				</div> | ||||
| 			</div> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import {LitElement, html, unsafeHTML, live} from './lit-all.min.js'; | ||||
| import * as tfutils from './tf-utils.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
| import Tribute from './tribute.esm.js'; | ||||
|  | ||||
| class TfComposeElement extends LitElement { | ||||
| @@ -14,6 +14,9 @@ class TfComposeElement extends LitElement { | ||||
| 			apps: {type: Object}, | ||||
| 			drafts: {type: Object}, | ||||
| 			author: {type: String}, | ||||
| 			channel: {type: String}, | ||||
| 			new_thread: {type: Boolean}, | ||||
| 			recipients: {type: Array}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -27,6 +30,7 @@ class TfComposeElement extends LitElement { | ||||
| 		this.apps = undefined; | ||||
| 		this.drafts = {}; | ||||
| 		this.author = undefined; | ||||
| 		this.new_thread = false; | ||||
| 	} | ||||
|  | ||||
| 	process_text(text) { | ||||
| @@ -88,7 +92,9 @@ class TfComposeElement extends LitElement { | ||||
| 				bubbles: true, | ||||
| 				composed: true, | ||||
| 				detail: { | ||||
| 					id: this.branch, | ||||
| 					id: | ||||
| 						this.branch ?? | ||||
| 						(this.recipients ? this.recipients.join(',') : undefined), | ||||
| 					draft: draft, | ||||
| 				}, | ||||
| 			}) | ||||
| @@ -180,6 +186,13 @@ class TfComposeElement extends LitElement { | ||||
| 				break; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		event.preventDefault(); | ||||
| 		document.execCommand( | ||||
| 			'insertText', | ||||
| 			false, | ||||
| 			event.clipboardData.getData('text/plain') | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	async submit() { | ||||
| @@ -189,11 +202,26 @@ class TfComposeElement extends LitElement { | ||||
| 		let message = { | ||||
| 			type: 'post', | ||||
| 			text: edit.innerText, | ||||
| 			channel: this.channel, | ||||
| 		}; | ||||
| 		if (this.root || this.branch) { | ||||
| 			message.root = this.root; | ||||
| 			message.root = this.new_thread ? (this.branch ?? this.root) : this.root; | ||||
| 			message.branch = this.branch; | ||||
| 		} | ||||
| 		let reply = Object.fromEntries( | ||||
| 			( | ||||
| 				await tfrpc.rpc.query( | ||||
| 					` | ||||
| 				SELECT messages.id, messages.author FROM messages | ||||
| 				JOIN json_each(?) AS refs ON messages.id = refs.value | ||||
| 			`, | ||||
| 					[JSON.stringify([this.root, this.branch])] | ||||
| 				) | ||||
| 			).map((row) => [row.id, row.author]) | ||||
| 		); | ||||
| 		if (Object.keys(reply).length) { | ||||
| 			message.reply = reply; | ||||
| 		} | ||||
| 		if (Object.values(draft.mentions || {}).length) { | ||||
| 			message.mentions = Object.values(draft.mentions); | ||||
| 		} | ||||
| @@ -230,10 +258,12 @@ class TfComposeElement extends LitElement { | ||||
| 		let self = this; | ||||
| 		let input = document.createElement('input'); | ||||
| 		input.type = 'file'; | ||||
| 		input.onchange = function (event) { | ||||
| 		input.addEventListener('change', function (event) { | ||||
| 			input.parentNode.removeChild(input); | ||||
| 			let file = event.target.files[0]; | ||||
| 			self.add_file(file); | ||||
| 		}; | ||||
| 		}); | ||||
| 		document.body.appendChild(input); | ||||
| 		input.click(); | ||||
| 	} | ||||
|  | ||||
| @@ -264,7 +294,7 @@ class TfComposeElement extends LitElement { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	firstUpdated() { | ||||
| 	get_values() { | ||||
| 		let values = Object.entries(this.users).map((x) => ({ | ||||
| 			key: x[1].name ?? x[0], | ||||
| 			value: x[0], | ||||
| @@ -280,11 +310,15 @@ class TfComposeElement extends LitElement { | ||||
| 				values | ||||
| 			); | ||||
| 		} | ||||
| 		return values; | ||||
| 	} | ||||
|  | ||||
| 	firstUpdated() { | ||||
| 		let tribute = new Tribute({ | ||||
| 			iframe: this.shadowRoot, | ||||
| 			collection: [ | ||||
| 				{ | ||||
| 					values: values, | ||||
| 					values: this.get_values(), | ||||
| 					selectTemplate: function (item) { | ||||
| 						return item | ||||
| 							? `[@${item.original.key}](${item.original.value})` | ||||
| @@ -303,6 +337,7 @@ class TfComposeElement extends LitElement { | ||||
| 			], | ||||
| 		}); | ||||
| 		tribute.attach(this.renderRoot.getElementById('edit')); | ||||
| 		this._tribute = tribute; | ||||
| 	} | ||||
|  | ||||
| 	updated() { | ||||
| @@ -313,6 +348,7 @@ class TfComposeElement extends LitElement { | ||||
| 			preview.innerHTML = this.process_text(edit.innerText); | ||||
| 			this.last_updated_text = edit.innerText; | ||||
| 		} | ||||
| 		this._tribute.collection[0].values = this.get_values(); | ||||
| 		let encrypt = this.renderRoot.getElementById('encrypt_to'); | ||||
| 		if (encrypt) { | ||||
| 			let tribute = new Tribute({ | ||||
| @@ -332,7 +368,7 @@ class TfComposeElement extends LitElement { | ||||
| 	remove_mention(id) { | ||||
| 		let draft = this.get_draft(); | ||||
| 		delete draft.mentions[id]; | ||||
| 		setTimeout(() => this.notify(), 0); | ||||
| 		setTimeout(() => this.notify(draft), 0); | ||||
| 	} | ||||
|  | ||||
| 	render_mention(mention) { | ||||
| @@ -419,12 +455,15 @@ class TfComposeElement extends LitElement { | ||||
| 			self.apps = await tfrpc.rpc.apps(); | ||||
| 		} | ||||
| 		if (!this.apps) { | ||||
| 			return html`<button class="w3-button w3-theme-d1" @click=${attach_app}> | ||||
| 			return html`<button | ||||
| 				class="w3-button w3-bar-item w3-theme-d1" | ||||
| 				@click=${attach_app} | ||||
| 			> | ||||
| 				Attach App | ||||
| 			</button>`; | ||||
| 		} else { | ||||
| 			return html`<button | ||||
| 				class="w3-button w3-theme-d1" | ||||
| 				class="w3-button w3-bar-item w3-theme-d1" | ||||
| 				@click=${() => (this.apps = null)} | ||||
| 			> | ||||
| 				Discard App | ||||
| @@ -445,23 +484,38 @@ class TfComposeElement extends LitElement { | ||||
| 		if (draft.content_warning !== undefined) { | ||||
| 			return html` | ||||
| 				<div class="w3-container w3-padding"> | ||||
| 					<p> | ||||
| 						<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input> | ||||
| 						<label for="cw">CW</label> | ||||
| 					</p> | ||||
| 					<input type="text" class="w3-input w3-border w3-theme-d1" id="content_warning" placeholder="Enter a content warning here." @input=${self.input} value=${draft.content_warning}></input> | ||||
| 				</div> | ||||
| 			`; | ||||
| 		} else { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_new_thread() { | ||||
| 		let self = this; | ||||
| 		if ( | ||||
| 			this.root !== undefined && | ||||
| 			this.branch !== undefined && | ||||
| 			this.root != this.branch | ||||
| 		) { | ||||
| 			return html` | ||||
| 				<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning('')}></input> | ||||
| 				<label for="cw">CW</label> | ||||
| 				<input type="checkbox" class="w3-check w3-theme-d1" id="new_thread" @change=${() => (self.new_thread = !self.new_thread)} ?checked=${self.new_thread}></input> | ||||
| 				<label for="new_thread">New Thread</label> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	get_draft() { | ||||
| 		return this.drafts[this.branch || ''] || {}; | ||||
| 		let key = | ||||
| 			this.branch || | ||||
| 			(this.recipients ? this.recipients.join(',') : undefined) || | ||||
| 			''; | ||||
| 		let draft = this.drafts[key] || {}; | ||||
| 		if (this.recipients && !draft.encrypt_to?.length) { | ||||
| 			draft.encrypt_to = [ | ||||
| 				...new Set(this.recipients).union(new Set(draft.encrypt_to ?? [])), | ||||
| 			]; | ||||
| 		} | ||||
| 		return draft; | ||||
| 	} | ||||
|  | ||||
| 	update_encrypt(event) { | ||||
| @@ -483,7 +537,7 @@ class TfComposeElement extends LitElement { | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: row; width: 100%"> | ||||
| 				<label for="encrypt_to">🔐 To:</label> | ||||
| 				<input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input> | ||||
| 				<input type="text" id="encrypt_to" class="w3-input w3-theme-d1 w3-border" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input> | ||||
| 				<button class="w3-button w3-theme-d1" @click=${() => this.set_encrypt(undefined)}>🚮</button> | ||||
| 			</div> | ||||
| 			<ul> | ||||
| @@ -505,6 +559,31 @@ class TfComposeElement extends LitElement { | ||||
| 		this.requestUpdate(); | ||||
| 	} | ||||
|  | ||||
| 	toggle_menu(event) { | ||||
| 		event.srcElement.parentNode | ||||
| 			.querySelector('.w3-dropdown-content') | ||||
| 			.classList.toggle('w3-show'); | ||||
| 	} | ||||
|  | ||||
| 	connectedCallback() { | ||||
| 		super.connectedCallback(); | ||||
| 		this._click_callback = this.document_click.bind(this); | ||||
| 		document.body.addEventListener('mouseup', this._click_callback); | ||||
| 	} | ||||
|  | ||||
| 	disconnectedCallback() { | ||||
| 		super.disconnectedCallback(); | ||||
| 		document.body.removeEventListener('mouseup', this._click_callback); | ||||
| 	} | ||||
|  | ||||
| 	document_click(event) { | ||||
| 		let content = this.renderRoot.querySelector('.w3-dropdown-content'); | ||||
| 		let target = event.target; | ||||
| 		if (content && !content.contains(target)) { | ||||
| 			content.classList.remove('w3-show'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		let draft = self.get_draft(); | ||||
| @@ -518,22 +597,38 @@ class TfComposeElement extends LitElement { | ||||
| 			draft.encrypt_to !== undefined | ||||
| 				? undefined | ||||
| 				: html`<button | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						class="w3-button w3-bar-item w3-theme-d1" | ||||
| 						@click=${() => this.set_encrypt([])} | ||||
| 					> | ||||
| 						🔐 | ||||
| 						🔐 Encrypt | ||||
| 					</button>`; | ||||
| 		let result = html` | ||||
| 			<style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			<style> | ||||
| 				.w3-input:empty::before { | ||||
| 					content: attr(placeholder); | ||||
| 				} | ||||
| 				.w3-input:empty:focus::before { | ||||
| 					content: ''; | ||||
| 				} | ||||
| 			</style> | ||||
| 			<div | ||||
| 				class="w3-card-4 w3-theme-d4 w3-padding-small" | ||||
| 				class="w3-card-4 w3-theme-d4 w3-padding w3-margin-top w3-margin-bottom" | ||||
| 				style="box-sizing: border-box" | ||||
| 			> | ||||
| 				${this.render_encrypt()} | ||||
| 				<div class="w3-container w3-padding-small"> | ||||
| 				<header class="w3-container"> | ||||
| 					${this.channel !== undefined | ||||
| 						? html`<p>To #${this.channel}:</p>` | ||||
| 						: undefined} | ||||
| 					${this.render_encrypt()} | ||||
| 				</header> | ||||
| 				<div class="w3-container" style="padding: 0 0 16px 0"> | ||||
| 					<div class="w3-half"> | ||||
| 						<span | ||||
| 							class="w3-input w3-theme-d1 w3-border" | ||||
| 							style="resize: vertical; width: 100%; overflow: hidden; white-space: pre-wrap" | ||||
| 							style="resize: vertical; width: 100%; white-space: pre-wrap" | ||||
| 							placeholder="Write a post here." | ||||
| 							id="edit" | ||||
| 							@input=${this.input} | ||||
| @@ -542,25 +637,62 @@ class TfComposeElement extends LitElement { | ||||
| 							.innerText=${live(draft.text ?? '')} | ||||
| 						></span> | ||||
| 					</div> | ||||
| 					<div class="w3-half w3-padding"> | ||||
| 					<div class="w3-half w3-container"> | ||||
| 						${content_warning} | ||||
| 						<div id="preview"></div> | ||||
| 						<p id="preview"></p> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				${Object.values(draft.mentions || {}).map((x) => | ||||
| 					self.render_mention(x) | ||||
| 				)} | ||||
| 				${this.render_attach_app()} ${this.render_content_warning()} | ||||
| 				<button class="w3-button w3-theme-d1" id="submit" @click=${this.submit}> | ||||
| 					Submit | ||||
| 				</button> | ||||
| 				<button class="w3-button w3-theme-d1" @click=${this.attach}> | ||||
| 					Attach | ||||
| 				</button> | ||||
| 				${this.render_attach_app_button()} ${encrypt} | ||||
| 				<button class="w3-button w3-theme-d1" @click=${this.discard}> | ||||
| 					Discard | ||||
| 				</button> | ||||
| 				<footer> | ||||
| 					${this.render_attach_app()} ${this.render_content_warning()} | ||||
| 					${this.render_new_thread()} | ||||
| 					<button | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						id="submit" | ||||
| 						@click=${this.submit} | ||||
| 					> | ||||
| 						Submit | ||||
| 					</button> | ||||
| 					<div class="w3-dropdown-click"> | ||||
| 						<button class="w3-button w3-theme-d1" @click=${this.toggle_menu}> | ||||
| 							⚙️ | ||||
| 						</button> | ||||
| 						<div class="w3-dropdown-content w3-bar-block"> | ||||
| 							${this.get_draft().content_warning === undefined | ||||
| 								? html` | ||||
| 										<button | ||||
| 											class="w3-button w3-bar-item w3-theme-d1" | ||||
| 											@click=${() => self.set_content_warning('')} | ||||
| 										> | ||||
| 											Add Content Warning | ||||
| 										</button> | ||||
| 									` | ||||
| 								: html` | ||||
| 										<button | ||||
| 											class="w3-button w3-bar-item w3-theme-d1" | ||||
| 											@click=${() => self.set_content_warning(undefined)} | ||||
| 										> | ||||
| 											Remove Content Warning | ||||
| 										</button> | ||||
| 									`} | ||||
| 							<button | ||||
| 								class="w3-button w3-bar-item w3-theme-d1" | ||||
| 								@click=${this.attach} | ||||
| 							> | ||||
| 								Attach | ||||
| 							</button> | ||||
| 							${this.render_attach_app_button()} ${encrypt} | ||||
| 							<button | ||||
| 								class="w3-button w3-bar-item w3-theme-d1" | ||||
| 								@click=${this.discard} | ||||
| 							> | ||||
| 								Discard | ||||
| 							</button> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</footer> | ||||
| 			</div> | ||||
| 		`; | ||||
| 		return result; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import {LitElement, html, unsafeHTML, until} from './lit-all.min.js'; | ||||
| import {LitElement, html, unsafeHTML, repeat, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfNewsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -11,6 +11,10 @@ class TfNewsElement extends LitElement { | ||||
| 			following: {type: Array}, | ||||
| 			drafts: {type: Object}, | ||||
| 			expanded: {type: Object}, | ||||
| 			channel: {type: String}, | ||||
| 			channel_unread: {type: Number}, | ||||
| 			recent_reactions: {type: Array}, | ||||
| 			hash: {type: String}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -25,6 +29,8 @@ class TfNewsElement extends LitElement { | ||||
| 		this.following = []; | ||||
| 		this.drafts = {}; | ||||
| 		this.expanded = {}; | ||||
| 		this.channel_unread = -1; | ||||
| 		this.recent_reactions = []; | ||||
| 	} | ||||
|  | ||||
| 	process_messages(messages) { | ||||
| @@ -33,12 +39,13 @@ class TfNewsElement extends LitElement { | ||||
|  | ||||
| 		console.log('processing', messages.length, 'messages'); | ||||
|  | ||||
| 		function ensure_message(id) { | ||||
| 		function ensure_message(id, rowid) { | ||||
| 			let found = messages_by_id[id]; | ||||
| 			if (found) { | ||||
| 				return found; | ||||
| 			} else { | ||||
| 				let added = { | ||||
| 					rowid: rowid, | ||||
| 					id: id, | ||||
| 					placeholder: true, | ||||
| 					content: '"placeholder"', | ||||
| @@ -53,7 +60,7 @@ class TfNewsElement extends LitElement { | ||||
|  | ||||
| 		function link_message(message) { | ||||
| 			if (message.content.type === 'vote') { | ||||
| 				let parent = ensure_message(message.content.vote.link); | ||||
| 				let parent = ensure_message(message.content.vote.link, message.rowid); | ||||
| 				if (!parent.votes) { | ||||
| 					parent.votes = []; | ||||
| 				} | ||||
| @@ -62,14 +69,14 @@ class TfNewsElement extends LitElement { | ||||
| 			} else if (message.content.type == 'post') { | ||||
| 				if (message.content.root) { | ||||
| 					if (typeof message.content.root === 'string') { | ||||
| 						let m = ensure_message(message.content.root); | ||||
| 						let m = ensure_message(message.content.root, message.rowid); | ||||
| 						if (!m.child_messages) { | ||||
| 							m.child_messages = []; | ||||
| 						} | ||||
| 						m.child_messages.push(message); | ||||
| 						message.parent_message = message.content.root; | ||||
| 					} else { | ||||
| 						let m = ensure_message(message.content.root[0]); | ||||
| 						let m = ensure_message(message.content.root[0], message.rowid); | ||||
| 						if (!m.child_messages) { | ||||
| 							m.child_messages = []; | ||||
| 						} | ||||
| @@ -153,43 +160,120 @@ class TfNewsElement extends LitElement { | ||||
| 		return recursive_sort(roots, true); | ||||
| 	} | ||||
|  | ||||
| 	group_following(messages) { | ||||
| 	group_messages(messages) { | ||||
| 		let result = []; | ||||
| 		let group = []; | ||||
| 		let type = undefined; | ||||
| 		for (let message of messages) { | ||||
| 			if (message?.content?.type === 'contact') { | ||||
| 			if ( | ||||
| 				message?.content?.type === 'contact' || | ||||
| 				message?.content?.type === 'channel' | ||||
| 			) { | ||||
| 				if (type && message.content.type !== type) { | ||||
| 					if (group.length == 1) { | ||||
| 						result.push(group[0]); | ||||
| 						group = []; | ||||
| 					} else if (group.length > 1) { | ||||
| 						result.push({ | ||||
| 							rowid: Math.max(...group.map((x) => x.rowid)), | ||||
| 							type: `${type}_group`, | ||||
| 							messages: group, | ||||
| 						}); | ||||
| 						group = []; | ||||
| 					} | ||||
| 				} | ||||
| 				type = message.content.type; | ||||
| 				group.push(message); | ||||
| 			} else { | ||||
| 				if (group.length > 0) { | ||||
| 				if (group.length == 1) { | ||||
| 					result.push(group[0]); | ||||
| 					group = []; | ||||
| 				} else if (group.length > 1) { | ||||
| 					result.push({ | ||||
| 						type: 'contact_group', | ||||
| 						rowid: Math.max(...group.map((x) => x.rowid)), | ||||
| 						type: `${type}_group`, | ||||
| 						messages: group, | ||||
| 					}); | ||||
| 					group = []; | ||||
| 				} | ||||
| 				result.push(message); | ||||
| 				type = undefined; | ||||
| 			} | ||||
| 		} | ||||
| 		if (group.length == 1) { | ||||
| 			result.push(group[0]); | ||||
| 			group = []; | ||||
| 		} else if (group.length > 1) { | ||||
| 			result.push({ | ||||
| 				rowid: Math.max(...group.map((x) => x.rowid)), | ||||
| 				type: `${type}_group`, | ||||
| 				messages: group, | ||||
| 			}); | ||||
| 		} | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	unread_allowed() { | ||||
| 		return !this.hash?.startsWith('#%') && !this.hash?.startsWith('#@'); | ||||
| 	} | ||||
|  | ||||
| 	load_and_render(messages) { | ||||
| 		let messages_by_id = this.process_messages(messages); | ||||
| 		let final_messages = this.group_following( | ||||
| 		let final_messages = this.group_messages( | ||||
| 			this.finalize_messages(messages_by_id) | ||||
| 		); | ||||
| 		let unread_rowid = -1; | ||||
| 		if (this.unread_allowed()) { | ||||
| 			for (let message of final_messages) { | ||||
| 				if (message.rowid >= this.channel_unread) { | ||||
| 					unread_rowid = message.rowid; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: column"> | ||||
| 				${final_messages.map( | ||||
| 					(x) => | ||||
| 						html`<tf-message | ||||
| 			<style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			<div> | ||||
| 				${repeat( | ||||
| 					final_messages, | ||||
| 					(x) => x.id, | ||||
| 					(x) => html` | ||||
| 						<tf-message | ||||
| 							.message=${x} | ||||
| 							whoami=${this.whoami} | ||||
| 							.users=${this.users} | ||||
| 							.drafts=${this.drafts} | ||||
| 							.expanded=${this.expanded} | ||||
| 							collapsed="true" | ||||
| 						></tf-message>` | ||||
| 							channel=${this.channel} | ||||
| 							channel_unread=${this.channel_unread} | ||||
| 							.recent_reactions=${this.recent_reactions} | ||||
| 						></tf-message> | ||||
| 						${x.rowid == unread_rowid | ||||
| 							? html`<div style="display: flex; flex-direction: row"> | ||||
| 									<div | ||||
| 										style="border-bottom: 1px solid #f00; flex: 1; align-self: center; height: 1px" | ||||
| 									></div> | ||||
| 									<button | ||||
| 										style="color: #f00; padding: 8px" | ||||
| 										class="w3-button" | ||||
| 										@click=${() => | ||||
| 											this.dispatchEvent( | ||||
| 												new Event('mark_all_read', { | ||||
| 													bubbles: true, | ||||
| 													composed: true, | ||||
| 												}) | ||||
| 											)} | ||||
| 									> | ||||
| 										unread | ||||
| 									</button> | ||||
| 									<div | ||||
| 										style="border-bottom: 1px solid #f00; flex: 1; align-self: center; height: 1px" | ||||
| 									></div> | ||||
| 								</div>` | ||||
| 							: undefined} | ||||
| 					` | ||||
| 				)} | ||||
| 			</div> | ||||
| 		`; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import {LitElement, html, until, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import * as tfutils from './tf-utils.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfProfileElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -11,9 +11,10 @@ class TfProfileElement extends LitElement { | ||||
| 			id: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			size: {type: Number}, | ||||
| 			server_follows_me: {type: Boolean}, | ||||
| 			sequence: {type: Number}, | ||||
| 			following: {type: Boolean}, | ||||
| 			blocking: {type: Boolean}, | ||||
| 			show_followed: {type: Boolean}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -27,7 +28,7 @@ class TfProfileElement extends LitElement { | ||||
| 		this.id = null; | ||||
| 		this.users = {}; | ||||
| 		this.size = 0; | ||||
| 		this.server_follows_me = undefined; | ||||
| 		this.sequence = 0; | ||||
| 	} | ||||
|  | ||||
| 	async load() { | ||||
| @@ -63,27 +64,8 @@ class TfProfileElement extends LitElement { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async initial_load() { | ||||
| 		this.server_follows_me = undefined; | ||||
| 		let server_id = await tfrpc.rpc.getServerIdentity(); | ||||
| 		let followed = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 			SELECT json_extract(content, '$.following') AS following | ||||
| 			FROM messages | ||||
| 			WHERE author = ? AND | ||||
| 			json_extract(content, '$.type') = 'contact' AND | ||||
| 			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1 | ||||
| 		`, | ||||
| 			[server_id, this.whoami] | ||||
| 		); | ||||
| 		let is_followed = false; | ||||
| 		for (let row of followed) { | ||||
| 			is_followed = row.following != 0; | ||||
| 		} | ||||
| 		this.server_follows_me = is_followed; | ||||
| 	} | ||||
|  | ||||
| 	modify(change) { | ||||
| 		let self = this; | ||||
| 		tfrpc.rpc | ||||
| 			.appendMessage( | ||||
| 				this.whoami, | ||||
| @@ -95,6 +77,10 @@ class TfProfileElement extends LitElement { | ||||
| 					change | ||||
| 				) | ||||
| 			) | ||||
| 			.then(function () { | ||||
| 				self._follow_whoami = undefined; | ||||
| 				self.load(); | ||||
| 			}) | ||||
| 			.catch(function (error) { | ||||
| 				alert(error?.message); | ||||
| 			}); | ||||
| @@ -156,7 +142,8 @@ class TfProfileElement extends LitElement { | ||||
| 		let self = this; | ||||
| 		let input = document.createElement('input'); | ||||
| 		input.type = 'file'; | ||||
| 		input.onchange = function (event) { | ||||
| 		input.addEventListener('change', function (event) { | ||||
| 			input.parentNode.removeChild(input); | ||||
| 			let file = event.target.files[0]; | ||||
| 			file | ||||
| 				.arrayBuffer() | ||||
| @@ -171,78 +158,116 @@ class TfProfileElement extends LitElement { | ||||
| 				.catch(function (e) { | ||||
| 					alert(e.message); | ||||
| 				}); | ||||
| 		}; | ||||
| 		}); | ||||
| 		document.body.appendChild(input); | ||||
| 		input.click(); | ||||
| 	} | ||||
|  | ||||
| 	async server_follow_me(follow) { | ||||
| 		try { | ||||
| 			await tfrpc.rpc.setServerFollowingMe(this.whoami, follow); | ||||
| 		} catch (e) { | ||||
| 			console.log(e); | ||||
| 		} | ||||
| 		try { | ||||
| 			await this.initial_load(); | ||||
| 		} catch (e) { | ||||
| 			console.log(e); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	copy_id() { | ||||
| 		navigator.clipboard.writeText(this.id); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if ( | ||||
| 			this.id == this.whoami && | ||||
| 			this.editing && | ||||
| 			this.server_follows_me === undefined | ||||
| 		) { | ||||
| 			this.initial_load(); | ||||
| 	show_image(link) { | ||||
| 		let div = document.createElement('div'); | ||||
| 		div.style.left = 0; | ||||
| 		div.style.top = 0; | ||||
| 		div.style.width = '100%'; | ||||
| 		div.style.height = '100%'; | ||||
| 		div.style.position = 'fixed'; | ||||
| 		div.style.background = '#000'; | ||||
| 		div.style.zIndex = 100; | ||||
| 		div.style.display = 'grid'; | ||||
| 		let img = document.createElement('img'); | ||||
| 		img.src = link; | ||||
| 		img.style.maxWidth = '100vw'; | ||||
| 		img.style.maxHeight = '100vh'; | ||||
| 		img.style.display = 'block'; | ||||
| 		img.style.margin = 'auto'; | ||||
| 		img.style.objectFit = 'contain'; | ||||
| 		img.style.width = '100vw'; | ||||
| 		div.appendChild(img); | ||||
| 		function image_close(event) { | ||||
| 			document.body.removeChild(div); | ||||
| 			window.removeEventListener('keydown', image_close); | ||||
| 		} | ||||
| 		div.onclick = image_close; | ||||
| 		window.addEventListener('keydown', image_close); | ||||
| 		document.body.appendChild(div); | ||||
| 	} | ||||
|  | ||||
| 	body_click(event) { | ||||
| 		if (event.srcElement.tagName == 'IMG') { | ||||
| 			this.show_image(event.srcElement.src); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	toggle_account_list(event) { | ||||
| 		let content = event.srcElement.nextElementSibling; | ||||
| 		this.show_followed = !this.show_followed; | ||||
| 	} | ||||
|  | ||||
| 	async load_follows() { | ||||
| 		let accounts = await tfrpc.rpc.following([this.id], 1); | ||||
| 		return html` | ||||
| 			<div class="w3-container"> | ||||
| 				<button | ||||
| 					class="w3-button w3-block w3-theme-d1 followed_accounts" | ||||
| 					@click=${this.toggle_account_list} | ||||
| 				> | ||||
| 					${this.show_followed ? 'Hide' : 'Show'} Followed Accounts | ||||
| 					(${Object.keys(accounts).length}) | ||||
| 				</button> | ||||
| 				<div class=${'w3-card' + (this.show_followed ? '' : ' w3-hide')}> | ||||
| 					<ul class="w3-ul w3-theme-d4 w3-border-theme"> | ||||
| 						${Object.keys(accounts).map( | ||||
| 							(x) => html` | ||||
| 								<li class="w3-border-theme"> | ||||
| 									<tf-user id=${x} .users=${this.users}></tf-user> | ||||
| 								</li> | ||||
| 							` | ||||
| 						)} | ||||
| 					</ul> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		this.load(); | ||||
| 		let self = this; | ||||
| 		let profile = this.users[this.id] || {}; | ||||
| 		tfrpc.rpc | ||||
| 			.query( | ||||
| 				`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, | ||||
| 				`SELECT SUM(LENGTH(content)) AS size, MAX(sequence) AS sequence FROM messages WHERE author = ?`, | ||||
| 				[this.id] | ||||
| 			) | ||||
| 			.then(function (result) { | ||||
| 				self.size = result[0].size; | ||||
| 				self.sequence = result[0].sequence; | ||||
| 			}); | ||||
| 		let edit; | ||||
| 		let follow; | ||||
| 		let block; | ||||
| 		if (this.id === this.whoami) { | ||||
| 			if (this.editing) { | ||||
| 				let server_follow; | ||||
| 				if (this.server_follows_me === true) { | ||||
| 					server_follow = html`<button | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						@click=${() => this.server_follow_me(false)} | ||||
| 					> | ||||
| 						Server, Stop Following Me | ||||
| 					</button>`; | ||||
| 				} else if (this.server_follows_me === false) { | ||||
| 					server_follow = html`<button | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						@click=${() => this.server_follow_me(true)} | ||||
| 					> | ||||
| 						Server, Follow Me | ||||
| 					</button>`; | ||||
| 				} | ||||
| 				edit = html` | ||||
| 					<button class="w3-button w3-theme-d1" @click=${this.save_edits}> | ||||
| 					<button | ||||
| 						id="save_profile" | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						@click=${this.save_edits} | ||||
| 					> | ||||
| 						Save Profile | ||||
| 					</button> | ||||
| 					<button class="w3-button w3-theme-d1" @click=${this.discard_edits}> | ||||
| 						Discard | ||||
| 					</button> | ||||
| 					${server_follow} | ||||
| 				`; | ||||
| 			} else { | ||||
| 				edit = html`<button class="w3-button w3-theme-d1" @click=${this.edit}> | ||||
| 				edit = html`<button | ||||
| 					id="edit_profile" | ||||
| 					class="w3-button w3-theme-d1" | ||||
| 					@click=${this.edit} | ||||
| 				> | ||||
| 					Edit Profile | ||||
| 				</button>`; | ||||
| 			} | ||||
| @@ -268,55 +293,72 @@ class TfProfileElement extends LitElement { | ||||
| 		let edit_profile = this.editing | ||||
| 			? html` | ||||
| 			<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px"> | ||||
| 				<div class="w3-container"> | ||||
| 					<div> | ||||
| 						<label for="name">Name:</label> | ||||
| 						<input class="w3-input w3-theme-d1" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))}></input> | ||||
| 					</div> | ||||
| 					<div><label for="description">Description:</label></div> | ||||
| 					<textarea class="w3-input w3-theme-d1" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))}>${this.editing.description}</textarea> | ||||
| 					<div> | ||||
| 						<label for="public_web_hosting">Public Web Hosting:</label> | ||||
| 						<input class="w3-check w3-theme-d1" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input> | ||||
| 					</div> | ||||
| 					<div> | ||||
| 						<button class="w3-button w3-theme-d1" @click=${this.attach_image}>Attach Image</button> | ||||
| 					</div> | ||||
| 				<div> | ||||
| 					<label for="name">Name:</label> | ||||
| 					<input class="w3-input w3-theme-d1" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))} placeholder="Choose a name"></input> | ||||
| 				</div> | ||||
| 				<div><label for="description">Description:</label></div> | ||||
| 				<textarea class="w3-input w3-theme-d1" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))} placeholder="Tell people a little bit about yourself here, if you like.">${this.editing.description}</textarea> | ||||
| 				<div> | ||||
| 					<label for="public_web_hosting">Public Web Hosting:</label> | ||||
| 					<input class="w3-check w3-theme-d1" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input> | ||||
| 				</div> | ||||
| 				<div> | ||||
| 					<button class="w3-button w3-theme-d1" @click=${this.attach_image}>Attach Image</button> | ||||
| 				</div> | ||||
| 			</div>` | ||||
| 			: null; | ||||
| 		let image = | ||||
| 			typeof profile.image == 'string' ? profile.image : profile.image?.link; | ||||
| 		let image = profile.image; | ||||
| 		if (typeof image == 'string' && !image.startsWith('&')) { | ||||
| 			try { | ||||
| 				image = JSON.parse(image)?.link; | ||||
| 			} catch {} | ||||
| 		} | ||||
| 		image = this.editing?.image ?? image; | ||||
| 		let description = this.editing?.description ?? profile.description; | ||||
| 		return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px"> | ||||
| 			<tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)}) | ||||
| 			<div class="w3-row"> | ||||
| 				<div class="w3-col s1 w3-container w3-right"> | ||||
| 					<button class="w3-button w3-theme-d1 w3-ripple" @click=${this.copy_id}>Copy</button> | ||||
| 		return html` | ||||
| 			<style>${generate_theme()}</style> | ||||
| 			<div class="w3-card-4 w3-container w3-theme-d3" style="box-sizing: border-box"> | ||||
| 			<header class="w3-container"> | ||||
| 				<p><tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)} in ${this.sequence} messages)</p> | ||||
| 			</header> | ||||
| 			<div class="w3-container" @click=${this.body_click}> | ||||
| 				<div class="w3-margin-bottom" style="display: flex; flex-direction: row"> | ||||
| 					<input type="text" class="w3-input w3-border w3-theme-d1" style="display: flex 1 1" readonly value=${this.id}></input> | ||||
| 					<button class="w3-button w3-theme-d1 w3-ripple" style="flex: 0 0 auto" @click=${this.copy_id}>Copy</button> | ||||
| 				</div> | ||||
| 				<div class="w3-rest w3-container"> | ||||
| 					<input type="text" class="w3-theme-d1" style="width: 100%; vertical-align: middle" readonly value=${this.id}></input> | ||||
| 				<div style="display: flex; flex-direction: row; gap: 1em"> | ||||
| 					${edit_profile} | ||||
| 					<div style="flex: 1 0 50%; contain: layout; overflow: auto; word-wrap: normal; word-break: normal"> | ||||
| 						${ | ||||
| 							image | ||||
| 								? html`<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>` | ||||
| 								: html`<div> | ||||
| 										<div class="w3-jumbo">😎</div> | ||||
| 										<div><i>Profile image not set.</i></div> | ||||
| 									</div>` | ||||
| 						} | ||||
| 						<div>${unsafeHTML(tfutils.markdown(description))}</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div> | ||||
| 					Following ${profile.following} identities. | ||||
| 					Followed by ${profile.followed} identities. | ||||
| 					Blocking ${profile.blocking} identities. | ||||
| 					Blocked by ${profile.blocked} identities. | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div style="display: flex; flex-direction: row; gap: 1em"> | ||||
| 				${edit_profile} | ||||
| 				<div style="flex: 1 0 50%"> | ||||
| 					<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div> | ||||
| 					<div>${unsafeHTML(tfutils.markdown(description))}</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				Following ${profile.following} identities. | ||||
| 				Followed by ${profile.followed} identities. | ||||
| 				Blocking ${profile.blocking} identities. | ||||
| 				Blocked by ${profile.blocked} identities. | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				${edit} | ||||
| 				${follow} | ||||
| 				${block} | ||||
| 			</div> | ||||
| 			${until(this.load_follows(), html`<p>Loading accounts followed...</p>`)} | ||||
| 			<footer class="w3-container"> | ||||
| 				<p> | ||||
| 					<a class="w3-button w3-theme-d1" href=${'#🔐' + (this.id != this.whoami ? this.id : '')}> | ||||
| 						Open Private Chat | ||||
| 					</a> | ||||
| 					${edit} | ||||
| 					${follow} | ||||
| 					${block} | ||||
| 				</p> | ||||
| 			</footer> | ||||
| 		</div>`; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfReactionsModalElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -24,43 +24,57 @@ class TfReactionsModalElement extends LitElement { | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		return this.votes?.length | ||||
| 			? html` <div | ||||
| 					class="w3-modal w3-animate-opacity" | ||||
| 					style="display: block; box-sizing: border-box" | ||||
| 				> | ||||
| 					<div class="w3-modal-content w3-card-4 w3-theme-d1"> | ||||
| 						<div class="w3-container w3-padding"> | ||||
| 							<header class="w3-container"> | ||||
| 								<h2>Reactions</h2> | ||||
| 								<span class="w3-button w3-display-topright" @click=${this.clear} | ||||
| 									>×</span | ||||
| 								> | ||||
| 							</header> | ||||
| 							<ul class="w3-theme-dark w3-container w3-ul"> | ||||
| 								${this.votes.map( | ||||
| 									(x) => html` | ||||
| 										<li class="w3-bar"> | ||||
| 											<span class="w3-bar-item" | ||||
| 												>${x?.content?.vote?.expression}</span | ||||
| 											> | ||||
| 											<tf-user | ||||
| 												class="w3-bar-item" | ||||
| 												id=${x.author} | ||||
| 												.users=${this.users} | ||||
| 											></tf-user> | ||||
| 											<span class="w3-bar-item w3-right" | ||||
| 												>${new Date(x?.timestamp).toLocaleString()}</span | ||||
| 											> | ||||
| 										</li> | ||||
| 									` | ||||
| 								)} | ||||
| 							</ul> | ||||
| 							<footer class="w3-container w3-padding"> | ||||
| 								<button class="w3-button" @click=${this.clear}>Close</button> | ||||
| 							</footer> | ||||
| 			? html` <style> | ||||
| 						${generate_theme()} | ||||
| 					</style> | ||||
| 					<div | ||||
| 						class="w3-modal w3-animate-opacity" | ||||
| 						style="display: block; box-sizing: border-box; z-index: 10" | ||||
| 						@click=${this.clear} | ||||
| 					> | ||||
| 						<div | ||||
| 							class="w3-modal-content w3-card-4 w3-theme-d1" | ||||
| 							onclick="event.stopPropagation()" | ||||
| 						> | ||||
| 							<div class="w3-container w3-padding"> | ||||
| 								<header class="w3-container"> | ||||
| 									<h2>Reactions</h2> | ||||
| 									<span | ||||
| 										class="w3-button w3-display-topright" | ||||
| 										@click=${this.clear} | ||||
| 										>×</span | ||||
| 									> | ||||
| 								</header> | ||||
| 								<ul class="w3-theme-dark w3-container w3-ul"> | ||||
| 									${this.votes | ||||
| 										.sort((x, y) => y.timestamp - x.timestamp) | ||||
| 										.map( | ||||
| 											(x) => html` | ||||
| 												<li | ||||
| 													style="display: flex; flex-direction: row; gap: 4px" | ||||
| 												> | ||||
| 													<span style="flex-basis: 3em" | ||||
| 														>${x?.content?.vote?.expression}</span | ||||
| 													> | ||||
| 													<tf-user | ||||
| 														style="flex: 1 1; overflow: hidden" | ||||
| 														id=${x.author} | ||||
| 														.users=${this.users} | ||||
| 													></tf-user> | ||||
| 													<span | ||||
| 														style="flex-shrink: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis" | ||||
| 														>${new Date(x?.timestamp).toLocaleString()}</span | ||||
| 													> | ||||
| 												</li> | ||||
| 											` | ||||
| 										)} | ||||
| 								</ul> | ||||
| 								<footer class="w3-container w3-padding"> | ||||
| 									<button class="w3-button" @click=${this.clear}>Close</button> | ||||
| 								</footer> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div>` | ||||
| 					</div>` | ||||
| 			: undefined; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import {css} from './lit-all.min.js'; | ||||
| import {css, unsafeCSS, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
|  | ||||
| const tf = css` | ||||
| 	img { | ||||
| @@ -43,12 +44,14 @@ const tf = css` | ||||
| 		border-left: 4px solid #fff; | ||||
| 		padding: 8px; | ||||
| 		padding-left: 12px; | ||||
| 		margin-left: 0; | ||||
| 		margin-right: 0; | ||||
| 	} | ||||
| `; | ||||
|  | ||||
| // prettier-ignore | ||||
| const w3 = css` | ||||
| /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ | ||||
| /* W3.CSS 5.01 March 14 2025 by Jan Egil and Borge Refsnes */ | ||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||
| /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ | ||||
| html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} | ||||
| @@ -88,7 +91,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px} | ||||
| .w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap} | ||||
| .w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)} | ||||
| .w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none} | ||||
| .w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}    | ||||
| .w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none} | ||||
| .w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none} | ||||
| .w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%} | ||||
| @@ -136,7 +139,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important} | ||||
| @media (max-width:1205px){.w3-auto{max-width:95%}} | ||||
| @media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px} | ||||
| .w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative} | ||||
| .w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	 | ||||
| .w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center} | ||||
| .w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}} | ||||
| @media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}} | ||||
| @@ -158,6 +161,10 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} | ||||
| .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} | ||||
| .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} | ||||
|  | ||||
| .w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex} | ||||
| .w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic} | ||||
|  | ||||
| .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} | ||||
| .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} | ||||
| .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} | ||||
| @@ -199,9 +206,9 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-transparent,.w3-hover-none:hover{background-color:transparent!important} | ||||
| .w3-hover-none:hover{box-shadow:none!important} | ||||
| /* Colors */ | ||||
| .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-amber,.w3-hover-amber:hover,.w3-warning{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} | ||||
| .w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important} | ||||
| .w3-blue,.w3-hover-blue:hover,.w3-info,.w3-primary{color:#fff!important;background-color:#2196F3!important} | ||||
| .w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important} | ||||
| .w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important} | ||||
| .w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important} | ||||
| @@ -216,15 +223,24 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important} | ||||
| .w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important} | ||||
| .w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important} | ||||
| .w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important} | ||||
| .w3-red,.w3-hover-red:hover,.w3-danger{color:#fff!important;background-color:#f44336!important} | ||||
| .w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important} | ||||
| .w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important} | ||||
| .w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important} | ||||
| .w3-yellow,.w3-hover-yellow:hover,.w3-note{color:#000!important;background-color:#ffeb3b!important} | ||||
| .w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important} | ||||
| .w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important} | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} | ||||
|  | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover,.w3-secondary{color:#000!important;background-color:#9e9e9e!important} | ||||
| .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} | ||||
| .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} | ||||
|  | ||||
| .w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important} | ||||
| .w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important} | ||||
| .w3-emerald,.w3-hover-emerald:hover,.w3-success{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important} | ||||
| .w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important} | ||||
| .w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important} | ||||
|  | ||||
| .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} | ||||
| .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} | ||||
| .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} | ||||
| @@ -285,30 +301,181 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
| `; | ||||
|  | ||||
| // prettier-ignore | ||||
| const w3_2016_riverside = css` | ||||
| .w3-theme-l5 {color:#000 !important; background-color:#f4f6f9 !important} | ||||
| .w3-theme-l4 {color:#000 !important; background-color:#d9e1ec !important} | ||||
| .w3-theme-l3 {color:#000 !important; background-color:#b4c3d8 !important} | ||||
| .w3-theme-l2 {color:#fff !important; background-color:#8ea6c5 !important} | ||||
| .w3-theme-l1 {color:#fff !important; background-color:#6888b1 !important} | ||||
| .w3-theme-d1 {color:#fff !important; background-color:#456185 !important} | ||||
| .w3-theme-d2 {color:#fff !important; background-color:#3d5676 !important} | ||||
| .w3-theme-d3 {color:#fff !important; background-color:#354b68 !important} | ||||
| .w3-theme-d4 {color:#fff !important; background-color:#2e4059 !important} | ||||
| .w3-theme-d5 {color:#fff !important; background-color:#26364a !important} | ||||
| function rgb_to_hsl(r, g, b) { | ||||
| 	let min, | ||||
| 		max, | ||||
| 		i, | ||||
| 		l, | ||||
| 		s, | ||||
| 		maxcolor, | ||||
| 		h, | ||||
| 		rgb = []; | ||||
| 	rgb[0] = r / 255; | ||||
| 	rgb[1] = g / 255; | ||||
| 	rgb[2] = b / 255; | ||||
| 	min = rgb[0]; | ||||
| 	max = rgb[0]; | ||||
| 	maxcolor = 0; | ||||
| 	for (i = 0; i < rgb.length - 1; i++) { | ||||
| 		if (rgb[i + 1] <= min) { | ||||
| 			min = rgb[i + 1]; | ||||
| 		} | ||||
| 		if (rgb[i + 1] >= max) { | ||||
| 			max = rgb[i + 1]; | ||||
| 			maxcolor = i + 1; | ||||
| 		} | ||||
| 	} | ||||
| 	if (maxcolor == 0) { | ||||
| 		h = (rgb[1] - rgb[2]) / (max - min); | ||||
| 	} | ||||
| 	if (maxcolor == 1) { | ||||
| 		h = 2 + (rgb[2] - rgb[0]) / (max - min); | ||||
| 	} | ||||
| 	if (maxcolor == 2) { | ||||
| 		h = 4 + (rgb[0] - rgb[1]) / (max - min); | ||||
| 	} | ||||
| 	if (isNaN(h)) { | ||||
| 		h = 0; | ||||
| 	} | ||||
| 	h = h * 60; | ||||
| 	if (h < 0) { | ||||
| 		h = h + 360; | ||||
| 	} | ||||
| 	l = (min + max) / 2; | ||||
| 	if (min == max) { | ||||
| 		s = 0; | ||||
| 	} else { | ||||
| 		if (l < 0.5) { | ||||
| 			s = (max - min) / (max + min); | ||||
| 		} else { | ||||
| 			s = (max - min) / (2 - max - min); | ||||
| 		} | ||||
| 	} | ||||
| 	s = s; | ||||
| 	return [h, s, l]; | ||||
| } | ||||
|  | ||||
| .w3-theme-light {color:#000 !important; background-color:#f4f6f9 !important} | ||||
| .w3-theme-dark {color:#fff !important; background-color:#26364a !important} | ||||
| .w3-theme-action {color:#fff !important; background-color:#26364a !important} | ||||
| function hex_to_rgb(hex) { | ||||
| 	if (hex.charAt(0) == '#') { | ||||
| 		hex = hex.substring(1); | ||||
| 	} | ||||
| 	return [ | ||||
| 		parseInt(hex.substring(0, 2), 16), | ||||
| 		parseInt(hex.substring(2, 4), 16), | ||||
| 		parseInt(hex.substring(4, 6), 16), | ||||
| 	]; | ||||
| } | ||||
|  | ||||
| .w3-theme {color:#fff !important; background-color:#4c6a92 !important} | ||||
| .w3-text-theme {color:#4c6a92 !important} | ||||
| .w3-border-theme {border-color:#4c6a92 !important} | ||||
| function hsl_to_rgb(hue, sat, light) { | ||||
| 	let t2; | ||||
| 	hue /= 60; | ||||
| 	if (light <= 0.5) { | ||||
| 		t2 = light * (sat + 1); | ||||
| 	} else { | ||||
| 		t2 = light + sat - light * sat; | ||||
| 	} | ||||
| 	let t1 = light * 2 - t2; | ||||
| 	return [ | ||||
| 		hue_to_rgb(t1, t2, hue + 2) * 255, | ||||
| 		hue_to_rgb(t1, t2, hue) * 255, | ||||
| 		hue_to_rgb(t1, t2, hue - 2) * 255, | ||||
| 	]; | ||||
| } | ||||
| function hue_to_rgb(t1, t2, hue) { | ||||
| 	if (hue < 0) { | ||||
| 		hue += 6; | ||||
| 	} | ||||
| 	if (hue >= 6) { | ||||
| 		hue -= 6; | ||||
| 	} | ||||
| 	if (hue < 1) { | ||||
| 		return (t2 - t1) * hue + t1; | ||||
| 	} else if (hue < 3) { | ||||
| 		return t2; | ||||
| 	} else if (hue < 4) { | ||||
| 		return (t2 - t1) * (4 - hue) + t1; | ||||
| 	} else { | ||||
| 		return t1; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .w3-hover-theme:hover {color:#fff !important; background-color:#4c6a92 !important} | ||||
| .w3-hover-text-theme:hover {color:#4c6a92 !important} | ||||
| .w3-hover-border-theme:hover {border-color:#4c6a92 !important} | ||||
| `; | ||||
| function rgb_to_hex(rgb) { | ||||
| 	const hex_pair = (x) => Math.floor(x).toString(16).padStart(2, '0'); | ||||
| 	return `#${hex_pair(rgb[0])}${hex_pair(rgb[1])}${hex_pair(rgb[2])}`; | ||||
| } | ||||
|  | ||||
| export let styles = [tf, w3, w3_2016_riverside]; | ||||
| function is_dark(hex, value) { | ||||
| 	let [r, g, b] = hex_to_rgb(hex); | ||||
| 	return (r * 299 + g * 587 + b * 114) / 1000 < value; | ||||
| } | ||||
|  | ||||
| export function generate(color) { | ||||
| 	let [r, g, b] = hex_to_rgb(color); | ||||
| 	let [h, s, l] = rgb_to_hsl(r, g, b); | ||||
|  | ||||
| 	let theme1 = { | ||||
| 		l5: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 4.7)), | ||||
| 		l4: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 4)), | ||||
| 		l3: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 3)), | ||||
| 		l2: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 2)), | ||||
| 		l1: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 1)), | ||||
| 		d0: rgb_to_hex(hsl_to_rgb(h, s, l)), | ||||
| 		d1: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 0.5)), | ||||
| 		d2: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 1)), | ||||
| 		d3: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 1.5)), | ||||
| 		d4: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 2)), | ||||
| 		d5: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 2.5)), | ||||
| 	}; | ||||
| 	for (let [k, v] of Object.entries(theme1)) { | ||||
| 		theme1['t' + k] = is_dark(v, 165) ? '#fff' : '#000'; | ||||
| 	} | ||||
|  | ||||
| 	let result = ` | ||||
| 		.w3-theme-l5 {color: ${theme1.tl5} !important; background-color: ${theme1.l5} !important} | ||||
| 		.w3-theme-l4 {color: ${theme1.tl4} !important; background-color: ${theme1.l4} !important} | ||||
| 		.w3-theme-l3 {color: ${theme1.tl3} !important; background-color: ${theme1.l3} !important} | ||||
| 		.w3-theme-l2 {color: ${theme1.tl2} !important; background-color: ${theme1.l2} !important} | ||||
| 		.w3-theme-l1 {color: ${theme1.tl1} !important; background-color: ${theme1.l1} !important} | ||||
| 		.w3-theme-d1 {color: ${theme1.td1} !important; background-color: ${theme1.d1} !important} | ||||
| 		.w3-theme-d2 {color: ${theme1.td2} !important; background-color: ${theme1.d2} !important} | ||||
| 		.w3-theme-d3 {color: ${theme1.td3} !important; background-color: ${theme1.d3} !important} | ||||
| 		.w3-theme-d4 {color: ${theme1.td4} !important; background-color: ${theme1.d4} !important} | ||||
| 		.w3-theme-d5 {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important} | ||||
| 		.w3-theme-light {color: ${theme1.tl5} !important; background-color: ${theme1.l5} !important} | ||||
| 		.w3-theme-dark {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important} | ||||
| 		.w3-theme-action {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important} | ||||
| 		.w3-theme {color: ${theme1.td0} !important; background-color: ${theme1.d0} !important} | ||||
| 		.w3-text-theme {color: ${theme1.d0} !important} | ||||
| 		.w3-border-theme {border-color: ${theme1.d0} !important} | ||||
| 		.w3-hover-theme:hover {color: ${theme1.td0} !important; background-color: ${theme1.d0} !important} | ||||
| 		.w3-hover-text-theme:hover {color: ${theme1.d0} !important} | ||||
| 		.w3-hover-border-theme:hover {border-color: ${theme1.d0} !important} | ||||
| 	`; | ||||
| 	return unsafeCSS(result); | ||||
| } | ||||
|  | ||||
| let g_theme; | ||||
| export function generate_theme() { | ||||
| 	return g_theme | ||||
| 		? g_theme | ||||
| 		: until( | ||||
| 				tfrpc.rpc.localStorageGet('color').then(function (value) { | ||||
| 					g_theme = generate(value ?? '#034f84'); | ||||
| 					return g_theme; | ||||
| 				}), | ||||
| 				generated_now() | ||||
| 			); | ||||
| } | ||||
|  | ||||
| function generated_now() { | ||||
| 	let now = new Date(); | ||||
| 	return generate( | ||||
| 		rgb_to_hex([ | ||||
| 			(now.getDay() * 128) / 6, | ||||
| 			(now.getHours() * 128) / 23, | ||||
| 			(now.getSeconds() * 128) / 59, | ||||
| 		]) | ||||
| 	); | ||||
| } | ||||
|  | ||||
| export let styles = [tf, w3]; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabConnectionsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -12,6 +12,10 @@ class TfTabConnectionsElement extends LitElement { | ||||
| 			stored_connections: {type: Array}, | ||||
| 			users: {type: Object}, | ||||
| 			server_identity: {type: String}, | ||||
| 			connect_attempt: {type: Object}, | ||||
| 			connect_message: {type: String}, | ||||
| 			connect_success: {type: Boolean}, | ||||
| 			peer_exchange: {type: Boolean}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -44,6 +48,20 @@ class TfTabConnectionsElement extends LitElement { | ||||
| 		tfrpc.rpc.getServerIdentity().then(function (identity) { | ||||
| 			self.server_identity = identity; | ||||
| 		}); | ||||
| 		this.check_peer_exchange(); | ||||
| 	} | ||||
|  | ||||
| 	async check_peer_exchange() { | ||||
| 		if (await tfrpc.rpc.isAdministrator()) { | ||||
| 			this.peer_exchange = await tfrpc.rpc.globalSettingsGet('peer_exchange'); | ||||
| 		} else { | ||||
| 			this.peer_exchange = undefined; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async enable_peer_exchange() { | ||||
| 		await tfrpc.rpc.globalSettingsSet('peer_exchange', true); | ||||
| 		await this.check_peer_exchange(); | ||||
| 	} | ||||
|  | ||||
| 	render_connection_summary(connection) { | ||||
| @@ -88,20 +106,53 @@ class TfTabConnectionsElement extends LitElement { | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	render_broadcast(connection) { | ||||
| 		return html` | ||||
| 			<li class="w3-bar" style="overflow: hidden; overflow-wrap: nowrap"> | ||||
| 				<button | ||||
| 					class="w3-bar-item w3-button w3-theme-d1" | ||||
| 					@click=${() => tfrpc.rpc.connect(connection)} | ||||
| 				> | ||||
| 					Connect | ||||
| 				</button> | ||||
| 				<div class="w3-bar-item"> | ||||
| 					${TfTabConnectionsElement.k_broadcast_emojis[connection.origin]} | ||||
| 					<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | ||||
| 					${this.render_connection_summary(connection)} | ||||
| 	render_message(connection) { | ||||
| 		return html`<div | ||||
| 			?hidden=${this.connect_message === undefined || | ||||
| 			this.connect_attempt != connection} | ||||
| 			style="cursor: pointer" | ||||
| 			class=${'w3-panel ' + (this.connect_success ? 'w3-green' : 'w3-red')} | ||||
| 			@click=${() => (this.connect_attempt = undefined)} | ||||
| 		> | ||||
| 			<p>${this.connect_message}</p> | ||||
| 		</div>`; | ||||
| 	} | ||||
|  | ||||
| 	render_progress(name, value, max) { | ||||
| 		if (max && value != max) { | ||||
| 			return html` | ||||
| 				<div class="w3-theme-d1 w3-small"> | ||||
| 					<div | ||||
| 						class="w3-container w3-theme-l1" | ||||
| 						style="width: ${Math.floor( | ||||
| 							(100.0 * value) / max | ||||
| 						)}%; text-wrap: nowrap" | ||||
| 					> | ||||
| 						${name} ${value} / ${max} (${Math.round((100.0 * value) / max)}%) | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_broadcast(connection) { | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<li> | ||||
| 				<div class="w3-bar" style="overflow: hidden; overflow-wrap: nowrap"> | ||||
| 					<button | ||||
| 						class="w3-bar-item w3-button w3-theme-d1" | ||||
| 						@click=${() => self.connect(connection)} | ||||
| 					> | ||||
| 						Connect | ||||
| 					</button> | ||||
| 					<div class="w3-bar-item"> | ||||
| 						${TfTabConnectionsElement.k_broadcast_emojis[connection.origin]} | ||||
| 						<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | ||||
| 						${this.render_connection_summary(connection)} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				${this.render_message(connection)} | ||||
| 			</li> | ||||
| 		`; | ||||
| 	} | ||||
| @@ -123,13 +174,28 @@ class TfTabConnectionsElement extends LitElement { | ||||
| 			}, {}) | ||||
| 		); | ||||
| 		return html` | ||||
| 			<button | ||||
| 				class="w3-button w3-theme-d1" | ||||
| 				@click=${() => tfrpc.rpc.closeConnection(connection.id)} | ||||
| 			> | ||||
| 				Close | ||||
| 			</button> | ||||
| 			${connection.connected | ||||
| 				? html` | ||||
| 						<button | ||||
| 							class="w3-button w3-theme-d1" | ||||
| 							@click=${() => tfrpc.rpc.closeConnection(connection.id)} | ||||
| 						> | ||||
| 							Close | ||||
| 						</button> | ||||
| 					` | ||||
| 				: undefined} | ||||
| 			${connection.flags.one_shot ? '🔃' : undefined} | ||||
| 			<tf-user id=${connection.id} .users=${this.users}></tf-user> | ||||
| 			${this.render_progress( | ||||
| 				'recv', | ||||
| 				connection.progress.in.total - connection.progress.in.current, | ||||
| 				connection.progress.in.total | ||||
| 			)} | ||||
| 			${this.render_progress( | ||||
| 				'send', | ||||
| 				connection.progress.out.total - connection.progress.out.current, | ||||
| 				connection.progress.out.total | ||||
| 			)} | ||||
| 			${connection.tunnel !== undefined | ||||
| 				? '🚇' | ||||
| 				: html`(${connection.host}:${connection.port})`} | ||||
| @@ -155,92 +221,167 @@ class TfTabConnectionsElement extends LitElement { | ||||
| 					.map((x) => html`<li>${this.render_connection(x)}</li>`)} | ||||
| 				${this.render_room_peers(connection.id)} | ||||
| 			</ul> | ||||
| 			<div ?hidden=${!connection.destroy_reason} class="w3-panel w3-red"> | ||||
| 				<p>${connection.destroy_reason}</p> | ||||
| 			</div> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	refresh() { | ||||
| 		tfrpc.rpc.sync(); | ||||
| 	connect(address) { | ||||
| 		let self = this; | ||||
| 		self.connect_attempt = address; | ||||
| 		self.connect_message = undefined; | ||||
| 		self.connect_success = false; | ||||
| 		tfrpc.rpc | ||||
| 			.connect(address) | ||||
| 			.then(function () { | ||||
| 				if (self.connect_attempt == address) { | ||||
| 					self.connect_message = 'Connected.'; | ||||
| 					self.connect_success = true; | ||||
| 				} | ||||
| 			}) | ||||
| 			.catch(function (error) { | ||||
| 				if (self.connect_attempt == address) { | ||||
| 					self.connect_message = 'Error: ' + error; | ||||
| 					self.connect_success = false; | ||||
| 				} | ||||
| 			}); | ||||
| 	} | ||||
|  | ||||
| 	toggle_accordian(id) { | ||||
| 		let element = this.renderRoot.getElementById(id); | ||||
| 		element.classList.toggle('w3-hide'); | ||||
| 	} | ||||
|  | ||||
| 	valid_connections() { | ||||
| 		return this.connections.filter((x) => x.tunnel === undefined); | ||||
| 	} | ||||
|  | ||||
| 	valid_broadcasts() { | ||||
| 		return this.broadcasts | ||||
| 			.filter((x) => x.address) | ||||
| 			.filter((x) => this.connections.map((c) => c.id).indexOf(x.pubkey) == -1); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			<div class="w3-container" style="box-sizing: border-box"> | ||||
| 				<button | ||||
| 					class="w3-button w3-theme-l3 w3-circle w3-ripple w3-large" | ||||
| 					@click=${this.refresh} | ||||
| 				<div | ||||
| 					class=${'w3-panel w3-padding w3-theme-l3' + | ||||
| 					(this.peer_exchange !== false ? ' w3-hide' : '')} | ||||
| 				> | ||||
| 					🔃 | ||||
| 				</button> | ||||
| 					<p> | ||||
| 						Looking for connections? Enabling this option will include publicly | ||||
| 						advertised rooms and pubs among the list of discovered connections | ||||
| 						to help you replicate. | ||||
| 					</p> | ||||
| 					<button | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						@click=${this.enable_peer_exchange} | ||||
| 					> | ||||
| 						🔍🌐 Use publicly advertised peers | ||||
| 					</button> | ||||
| 				</div> | ||||
| 				<h2>New Connection</h2> | ||||
| 				<textarea class="w3-input w3-theme-d1" id="code"></textarea> | ||||
| 				${this.render_message(this.renderRoot.getElementById('code')?.value)} | ||||
| 				<button | ||||
| 					class="w3-button w3-theme-d1" | ||||
| 					@click=${() => | ||||
| 						tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)} | ||||
| 						self.connect(self.renderRoot.getElementById('code')?.value)} | ||||
| 				> | ||||
| 					Connect | ||||
| 				</button> | ||||
| 				<h2>Broadcasts</h2> | ||||
| 				<ul class="w3-ul w3-border"> | ||||
| 					${this.broadcasts | ||||
| 						.filter((x) => x.address) | ||||
| 						.map((x) => self.render_broadcast(x))} | ||||
| 				<h2 | ||||
| 					class="w3-button w3-block w3-theme-d1" | ||||
| 					@click=${() => self.toggle_accordian('connections')} | ||||
| 				> | ||||
| 					Connections (${this.valid_connections().length}) | ||||
| 				</h2> | ||||
| 				<ul class="w3-ul w3-border" id="connections"> | ||||
| 					${this.valid_connections().map( | ||||
| 						(x) => html` <li class="w3-bar">${this.render_connection(x)}</li> ` | ||||
| 					)} | ||||
| 				</ul> | ||||
| 				<h2>Connections</h2> | ||||
| 				<ul class="w3-ul w3-border"> | ||||
| 					${this.connections | ||||
| 						.filter((x) => x.tunnel === undefined) | ||||
| 						.map( | ||||
| 							(x) => html` | ||||
| 								<li class="w3-bar">${this.render_connection(x)}</li> | ||||
| 							` | ||||
| 						)} | ||||
| 				<h2 | ||||
| 					class="w3-button w3-block w3-theme-d1" | ||||
| 					@click=${() => self.toggle_accordian('broadcasts')} | ||||
| 				> | ||||
| 					Discovery (${this.valid_broadcasts().length}) | ||||
| 				</h2> | ||||
| 				<ul class="w3-ul w3-border w3-hide" id="broadcasts"> | ||||
| 					${this.valid_broadcasts().map((x) => self.render_broadcast(x))} | ||||
| 				</ul> | ||||
| 				<h2>Stored Connections</h2> | ||||
| 				<ul class="w3-ul w3-border"> | ||||
| 				<h2 | ||||
| 					class="w3-button w3-block w3-theme-d1" | ||||
| 					@click=${() => self.toggle_accordian('stored_connections')} | ||||
| 				> | ||||
| 					Stored Connections (${this.stored_connections.length}) | ||||
| 				</h2> | ||||
| 				<ul class="w3-ul w3-border w3-hide" id="stored_connections"> | ||||
| 					${this.stored_connections.map( | ||||
| 						(x) => html` | ||||
| 							<li class="w3-bar"> | ||||
| 								<button | ||||
| 									class="w3-bar-item w3-button w3-theme-d1" | ||||
| 									@click=${() => self.forget_stored_connection(x)} | ||||
| 								> | ||||
| 									Forget | ||||
| 								</button> | ||||
| 								<button | ||||
| 									class="w3-bar-item w3-button w3-theme-d1" | ||||
| 									@click=${() => tfrpc.rpc.connect(x)} | ||||
| 								> | ||||
| 									Connect | ||||
| 								</button> | ||||
| 								<div class="w3-bar-item"> | ||||
| 									<tf-user id=${x.pubkey} .users=${self.users}></tf-user> | ||||
| 									<div><small>${x.address}:${x.port}</small></div> | ||||
| 							<li> | ||||
| 								<div class="w3-bar"> | ||||
| 									<button | ||||
| 										class="w3-bar-item w3-button w3-theme-d1" | ||||
| 										@click=${() => self.forget_stored_connection(x)} | ||||
| 									> | ||||
| 										Forget | ||||
| 									</button> | ||||
| 									<button | ||||
| 										class="w3-bar-item w3-button w3-theme-d1" | ||||
| 										@click=${() => this.connect(x)} | ||||
| 									> | ||||
| 										Connect | ||||
| 									</button> | ||||
| 									<div class="w3-bar-item"> | ||||
| 										<tf-user id=${x.pubkey} .users=${self.users}></tf-user> | ||||
| 										<div><small>${x.address}:${x.port}</small></div> | ||||
| 										<div> | ||||
| 											<small | ||||
| 												>Last connection: | ||||
| 												${new Date(x.last_success * 1000)}</small | ||||
| 											> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 								${this.render_message(x)} | ||||
| 							</li> | ||||
| 						` | ||||
| 					)} | ||||
| 				</ul> | ||||
| 				<h2>Local Accounts</h2> | ||||
| 				<ul class="w3-ul w3-border"> | ||||
| 				<h2 | ||||
| 					class="w3-button w3-block w3-theme-d1" | ||||
| 					@click=${() => self.toggle_accordian('local_accounts')} | ||||
| 				> | ||||
| 					Local Accounts (${this.identities.length}) | ||||
| 				</h2> | ||||
| 				<div class="w3-container w3-hide" id="local_accounts"> | ||||
| 					${this.identities.map( | ||||
| 						(x) => | ||||
| 							html`<li class="w3-bar"> | ||||
| 							html`<div | ||||
| 								class="w3-tag w3-round w3-theme-l3" | ||||
| 								style="padding: 4px; margin: 2px; max-width: 100%; text-wrap: nowrap; overflow: hidden" | ||||
| 							> | ||||
| 								${x == this.server_identity | ||||
| 									? html`<span class="w3-tag w3-medium w3-round w3-theme-l1" | ||||
| 											>🖥 local server</span | ||||
| 										>` | ||||
| 									? html`<div class="w3-tag w3-medium w3-round w3-theme-l1"> | ||||
| 											🖥 local server | ||||
| 										</div>` | ||||
| 									: undefined} | ||||
| 								${this.my_identities.indexOf(x) != -1 | ||||
| 									? html`<span class="w3-tag w3-medium w3-round w3-theme-d1" | ||||
| 											>😎 you</span | ||||
| 										>` | ||||
| 									? html`<div class="w3-tag w3-medium w3-round w3-theme-d1"> | ||||
| 											😎 you | ||||
| 										</div>` | ||||
| 									: undefined} | ||||
| 								<tf-user id=${x} .users=${this.users}></tf-user> | ||||
| 							</li>` | ||||
| 							</div>` | ||||
| 					)} | ||||
| 				</ul> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		`; | ||||
| 	} | ||||
|   | ||||
| @@ -1,78 +0,0 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabMentionsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			following: {type: Array}, | ||||
| 			expanded: {type: Object}, | ||||
| 			messages: {type: Array}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.following = []; | ||||
| 		this.expanded = {}; | ||||
| 		this.messages = []; | ||||
| 	} | ||||
|  | ||||
| 	async load() { | ||||
| 		console.log('Loading...', this.whoami); | ||||
| 		let results = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM messages_fts(?) | ||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 				WHERE messages.author != ? | ||||
| 				ORDER BY timestamp DESC limit 20 | ||||
| 			`, | ||||
| 			[ | ||||
| 				'"' + this.whoami.replace('"', '""') + '"', | ||||
| 				JSON.stringify(this.following), | ||||
| 				this.whoami, | ||||
| 			] | ||||
| 		); | ||||
| 		console.log('Done.'); | ||||
| 		this.messages = results; | ||||
| 	} | ||||
|  | ||||
| 	on_expand(event) { | ||||
| 		if (event.detail.expanded) { | ||||
| 			let expand = {}; | ||||
| 			expand[event.detail.id] = true; | ||||
| 			this.expanded = Object.assign({}, this.expanded, expand); | ||||
| 		} else { | ||||
| 			delete this.expanded[event.detail.id]; | ||||
| 			this.expanded = Object.assign({}, this.expanded); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		if (!this.loading) { | ||||
| 			this.loading = true; | ||||
| 			this.load(); | ||||
| 		} | ||||
| 		return html` | ||||
| 			<tf-news | ||||
| 				id="news" | ||||
| 				whoami=${this.whoami} | ||||
| 				.messages=${this.messages} | ||||
| 				.users=${this.users} | ||||
| 				.expanded=${this.expanded} | ||||
| 				@tf-expand=${this.on_expand} | ||||
| 			></tf-news> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
| customElements.define('tf-tab-mentions', TfTabMentionsElement); | ||||
| @@ -1,6 +1,6 @@ | ||||
| import {LitElement, html, unsafeHTML, until} from './lit-all.min.js'; | ||||
| import {LitElement, cache, html, unsafeHTML, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabNewsFeedElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -12,6 +12,14 @@ class TfTabNewsFeedElement extends LitElement { | ||||
| 			messages: {type: Array}, | ||||
| 			drafts: {type: Object}, | ||||
| 			expanded: {type: Object}, | ||||
| 			channels_unread: {type: Object}, | ||||
| 			channels_latest: {type: Object}, | ||||
| 			loading: {type: Number}, | ||||
| 			time_range: {type: Array}, | ||||
| 			time_loading: {type: Array}, | ||||
| 			private_messages: {type: Array}, | ||||
| 			grouped_private_messages: {type: Object}, | ||||
| 			recent_reactions: {type: Array}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -26,112 +34,308 @@ class TfTabNewsFeedElement extends LitElement { | ||||
| 		this.following = []; | ||||
| 		this.drafts = {}; | ||||
| 		this.expanded = {}; | ||||
| 		this.start_time = new Date().valueOf() - 24 * 60 * 60 * 1000; | ||||
| 		this.channels_unread = {}; | ||||
| 		this.channels_latest = {}; | ||||
| 		this.start_time = new Date().valueOf(); | ||||
| 		this.time_range = [0, 0]; | ||||
| 		this.time_loading = undefined; | ||||
| 		this.recent_reactions = []; | ||||
| 		this.loading = 0; | ||||
| 	} | ||||
|  | ||||
| 	async fetch_messages() { | ||||
| 		if (this.hash.startsWith('#@')) { | ||||
| 			let r = await tfrpc.rpc.query( | ||||
| 	channel() { | ||||
| 		return this.hash.startsWith('##') | ||||
| 			? this.hash.substring(2) | ||||
| 			: this.hash.substring(1); | ||||
| 	} | ||||
|  | ||||
| 	async _fetch_related_messages(messages) { | ||||
| 		let refs = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				WITH | ||||
| 					news AS ( | ||||
| 						SELECT value AS id FROM json_each(?) | ||||
| 					) | ||||
| 				SELECT refs_out.ref AS ref FROM messages_refs refs_out JOIN news ON refs_out.message = news.id | ||||
| 				UNION | ||||
| 				SELECT refs_in.message AS ref FROM messages_refs refs_in JOIN news ON refs_in.ref = news.id | ||||
| 			`, | ||||
| 			[JSON.stringify(messages.map((x) => x.id))] | ||||
| 		); | ||||
| 		let related_messages = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM messages | ||||
| 				JOIN json_each(?2) refs ON messages.id = refs.value | ||||
| 				JOIN json_each(?1) AS following ON messages.author = following.value | ||||
| 			`, | ||||
| 			[JSON.stringify(this.following), JSON.stringify(refs.map((x) => x.ref))] | ||||
| 		); | ||||
| 		let combined = [].concat(messages, related_messages); | ||||
| 		let refs2 = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				WITH | ||||
| 					news AS ( | ||||
| 						SELECT value AS id FROM json_each(?) | ||||
| 					) | ||||
| 				SELECT refs_out.ref AS ref FROM messages_refs refs_out JOIN news ON refs_out.message = news.id | ||||
| 				UNION | ||||
| 				SELECT refs_in.message AS ref FROM messages_refs refs_in JOIN news ON refs_in.ref = news.id | ||||
| 			`, | ||||
| 			[JSON.stringify(combined.map((x) => x.id))] | ||||
| 		); | ||||
| 		let t0 = new Date(); | ||||
| 		let result = [].concat( | ||||
| 			combined, | ||||
| 			await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH mine AS (SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 						FROM messages | ||||
| 						WHERE messages.author = ? | ||||
| 						ORDER BY sequence DESC | ||||
| 						LIMIT 20) | ||||
| 					SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 						FROM mine | ||||
| 						JOIN messages_refs ON mine.id = messages_refs.ref | ||||
| 				SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM json_each(?2) refs | ||||
| 				JOIN messages ON messages.id = refs.value | ||||
| 				JOIN json_each(?1) following ON messages.author = following.value | ||||
| 				WHERE messages.content ->> 'type' != 'post' | ||||
| 			`, | ||||
| 				[ | ||||
| 					JSON.stringify(this.following), | ||||
| 					JSON.stringify(refs2.map((x) => x.ref)), | ||||
| 				] | ||||
| 			) | ||||
| 		); | ||||
| 		let t1 = new Date(); | ||||
| 		console.log((t1 - t0) / 1000); | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	async fetch_messages(start_time, end_time) { | ||||
| 		this.dispatchEvent( | ||||
| 			new CustomEvent('loadmessages', { | ||||
| 				bubbles: true, | ||||
| 				composed: true, | ||||
| 			}) | ||||
| 		); | ||||
| 		this.time_loading = [start_time, end_time]; | ||||
| 		let result; | ||||
| 		const k_max_results = 64; | ||||
| 		if (this.hash == '#@') { | ||||
| 			result = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH mentions AS (SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 						FROM messages_fts(?1) | ||||
| 						JOIN messages ON messages.rowid = messages_fts.rowid | ||||
| 						JOIN json_each(?2) AS following ON messages.author = following.value | ||||
| 						WHERE | ||||
| 							messages.author != ?1 AND | ||||
| 							(?3 IS NULL OR messages.timestamp >= ?3) AND messages.timestamp < ?4 | ||||
| 						ORDER BY timestamp DESC limit ?5) | ||||
| 					SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 						FROM mentions | ||||
| 						JOIN messages_refs ON mentions.id = messages_refs.ref | ||||
| 						JOIN messages ON messages_refs.message = messages.id | ||||
| 					UNION | ||||
| 					SELECT * FROM mine | ||||
| 					SELECT TRUE AS is_primary, * FROM mentions | ||||
| 				`, | ||||
| 				[this.hash.substring(1)] | ||||
| 				[ | ||||
| 					'"' + this.whoami.replace('"', '""') + '"', | ||||
| 					JSON.stringify(this.following), | ||||
| 					start_time, | ||||
| 					end_time, | ||||
| 					k_max_results, | ||||
| 				] | ||||
| 			); | ||||
| 			return r; | ||||
| 		} else if (this.hash.startsWith('#%')) { | ||||
| 			return await tfrpc.rpc.query( | ||||
| 		} else if (this.hash.startsWith('#@')) { | ||||
| 			result = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 					FROM messages | ||||
| 					WHERE id = ?1 | ||||
| 					WITH | ||||
| 						selected AS (SELECT rowid, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 							FROM messages | ||||
| 							WHERE messages.author = ?1 AND (?2 IS NULL OR messages.timestamp >= 2) AND messages.timestamp < ?3 | ||||
| 							ORDER BY sequence DESC LIMIT ?4 | ||||
| 						) | ||||
| 					SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 						FROM selected | ||||
| 						JOIN messages_refs ON selected.id = messages_refs.ref | ||||
| 						JOIN messages ON messages_refs.message = messages.id | ||||
| 					UNION | ||||
| 					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 					SELECT TRUE AS is_primary, * FROM selected | ||||
| 				`, | ||||
| 				[this.hash.substring(1), start_time, end_time, k_max_results] | ||||
| 			); | ||||
| 		} else if (this.hash.startsWith('#%')) { | ||||
| 			result = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					SELECT TRUE AS is_primary, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 					FROM messages | ||||
| 					WHERE messages.id = ?1 | ||||
| 					UNION | ||||
| 					SELECT FALSE AS is_primary, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 					FROM messages JOIN messages_refs | ||||
| 					ON messages.id = messages_refs.message | ||||
| 					WHERE messages_refs.ref = ?1 | ||||
| 				`, | ||||
| 				[this.hash.substring(1)] | ||||
| 			); | ||||
| 		} else { | ||||
| 			let promises = []; | ||||
| 			const k_following_limit = 256; | ||||
| 			for (let i = 0; i < this.following.length; i += k_following_limit) { | ||||
| 				promises.push( | ||||
| 					tfrpc.rpc.query( | ||||
| 						` | ||||
| 						WITH news AS (SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 		} else if (this.hash.startsWith('##')) { | ||||
| 			let t0 = new Date(); | ||||
| 			let initial_messages = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH | ||||
| 						all_news AS ( | ||||
| 							SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 								FROM messages | ||||
| 								JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 								WHERE messages.content ->> 'channel' = ?4 AND messages.content ->> 'type' != 'vote' | ||||
| 							UNION | ||||
| 							SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 								FROM messages_refs | ||||
| 								JOIN messages ON messages.id = messages_refs.message | ||||
| 								JOIN json_each(?1) AS following ON messages.author = following.value | ||||
| 								WHERE messages_refs.ref = '#' || ?4 AND messages.content ->> 'type' != 'vote' | ||||
| 							) | ||||
| 					SELECT TRUE AS is_primary, all_news.* FROM all_news | ||||
| 						WHERE (?2 IS NULL OR all_news.timestamp >= ?2) AND all_news.timestamp < ?3 | ||||
| 						ORDER BY all_news.timestamp DESC LIMIT ?5 | ||||
| 				`, | ||||
| 				[ | ||||
| 					JSON.stringify(this.following), | ||||
| 					start_time, | ||||
| 					end_time, | ||||
| 					this.hash.substring(2), | ||||
| 					k_max_results, | ||||
| 				] | ||||
| 			); | ||||
| 			let t1 = new Date(); | ||||
| 			result = await this._fetch_related_messages(initial_messages); | ||||
| 			let t2 = new Date(); | ||||
| 			console.log( | ||||
| 				`load of ${result.length} rows took ${(t2 - t0) / 1000} (${(t1 - t0) / 1000} to find ${initial_messages.length} initial messages, ${(t2 - t1) / 1000} to find ${result.length} total messages) following=${this.following.length} st=${start_time} et=${end_time}` | ||||
| 			); | ||||
| 		} else if (this.hash.startsWith('#🔐')) { | ||||
| 			let ids = | ||||
| 				this.hash == '#🔐' ? [] : this.hash.substring('#🔐'.length).split(','); | ||||
| 			result = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					SELECT TRUE AS is_primary, messages.rowid, messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||
| 					FROM messages | ||||
| 					JOIN json_each(?1) AS private_messages ON messages.id = private_messages.value | ||||
| 					WHERE | ||||
| 						(?2 IS NULL OR (messages.timestamp >= ?2)) AND messages.timestamp < ?3 AND | ||||
| 						json(messages.content) LIKE '"%' | ||||
| 					ORDER BY messages.rowid DESC LIMIT ?4 | ||||
| 				`, | ||||
| 				[ | ||||
| 					JSON.stringify( | ||||
| 						this.grouped_private_messages?.[JSON.stringify(ids)]?.map( | ||||
| 							(x) => x.id | ||||
| 						) ?? [] | ||||
| 					), | ||||
| 					start_time, | ||||
| 					end_time, | ||||
| 					k_max_results, | ||||
| 				] | ||||
| 			); | ||||
| 			result = (await this.decrypt(result)).filter((x) => x.decrypted); | ||||
| 		} else if (this.hash == '#👍') { | ||||
| 			result = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH votes AS (SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 						FROM messages | ||||
| 						JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 						WHERE messages.timestamp > ? AND messages.timestamp < ? | ||||
| 						ORDER BY messages.timestamp DESC) | ||||
| 						SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 							FROM news | ||||
| 							JOIN messages_refs ON news.id = messages_refs.ref | ||||
| 							JOIN messages ON messages_refs.message = messages.id | ||||
| 						UNION | ||||
| 						SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 							FROM news | ||||
| 							JOIN messages_refs ON news.id = messages_refs.message | ||||
| 							JOIN messages ON messages_refs.ref = messages.id | ||||
| 						UNION | ||||
| 						SELECT news.* FROM news | ||||
| 					`, | ||||
| 						[ | ||||
| 							JSON.stringify(this.following.slice(i, i + k_following_limit)), | ||||
| 							this.start_time, | ||||
| 							/* | ||||
| 							 ** Don't show messages more than a day into the future to prevent | ||||
| 							 ** messages with far-future timestamps from staying at the top forever. | ||||
| 							 */ | ||||
| 							new Date().valueOf() + 24 * 60 * 60 * 1000, | ||||
| 						] | ||||
| 					) | ||||
| 				); | ||||
| 			} | ||||
| 			return [].concat(...(await Promise.all(promises))); | ||||
| 						JOIN json_each(?1) AS following ON messages.author = following.value | ||||
| 						WHERE | ||||
| 							messages.content ->> 'type' = 'vote' AND | ||||
| 							(?2 IS NULL OR messages.timestamp >= ?2) AND messages.timestamp < ?3 | ||||
| 						ORDER BY timestamp DESC limit ?4) | ||||
| 					SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 						FROM votes | ||||
| 						JOIN messages ON messages.id = votes.content ->> '$.vote.link' | ||||
| 					UNION | ||||
| 					SELECT TRUE AS is_primary, * FROM votes | ||||
| 				`, | ||||
| 				[JSON.stringify(this.following), start_time, end_time, k_max_results] | ||||
| 			); | ||||
| 		} else { | ||||
| 			let t0 = new Date(); | ||||
| 			let initial_messages = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH | ||||
| 						channels AS (SELECT '#' || value AS value FROM json_each(?5)) | ||||
| 					SELECT TRUE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 							FROM messages | ||||
| 							JOIN json_each(?1) AS following ON messages.author = following.value | ||||
| 							WHERE messages.timestamp < ?3 AND (?2 IS NULL OR messages.timestamp >= ?2) AND | ||||
| 								messages.content ->> 'type' != 'vote' AND | ||||
| 								(messages.content ->> 'root' IS NULL OR ( | ||||
| 									NOT EXISTS (SELECT * FROM messages root JOIN channels ON ('#' || (root.content ->> 'channel')) = channels.value WHERE root.id = messages.content ->> 'root') AND | ||||
| 									NOT EXISTS (SELECT * FROM messages root JOIN messages_refs ON root.id = messages.content ->> 'root' JOIN channels ON messages_refs.message = root.id AND messages_refs.ref = channels.value) | ||||
| 								)) AND | ||||
| 								(messages.content ->> 'channel' IS NULL OR ('#' || (messages.content ->> 'channel')) NOT IN (SELECT * FROM channels)) AND | ||||
| 								NOT EXISTS (SELECT * FROM messages_refs JOIN channels ON messages_refs.message = messages.id AND messages_refs.ref = channels.value) | ||||
| 					ORDER BY timestamp DESC LIMIT ?4 | ||||
| 				`, | ||||
| 				[ | ||||
| 					JSON.stringify(this.following), | ||||
| 					start_time, | ||||
| 					end_time, | ||||
| 					k_max_results, | ||||
| 					JSON.stringify(Object.keys(this.channels_latest)), | ||||
| 				] | ||||
| 			); | ||||
| 			let t1 = new Date(); | ||||
| 			result = await this._fetch_related_messages(initial_messages); | ||||
| 			let t2 = new Date(); | ||||
| 			console.log( | ||||
| 				`load of ${result.length} rows took ${(t2 - t0) / 1000} (${(t1 - t0) / 1000} to find ${initial_messages.length} initial messages, ${(t2 - t1) / 1000} to find ${result.length} total messages) following=${this.following.length} st=${start_time} et=${end_time}` | ||||
| 			); | ||||
| 		} | ||||
| 		this.time_loading = undefined; | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	update_time_range_from_messages(messages) { | ||||
| 		let only_primary = messages.filter((x) => x.is_primary); | ||||
| 		this.time_range = [ | ||||
| 			only_primary.reduce( | ||||
| 				(accumulator, current) => Math.min(accumulator, current.timestamp), | ||||
| 				this.time_range[0] | ||||
| 			), | ||||
| 			only_primary.reduce( | ||||
| 				(accumulator, current) => Math.max(accumulator, current.timestamp), | ||||
| 				this.time_range[1] | ||||
| 			), | ||||
| 		]; | ||||
| 	} | ||||
|  | ||||
| 	unread_allowed() { | ||||
| 		return ( | ||||
| 			this.hash == '#@' || | ||||
| 			(!this.hash.startsWith('#%') && !this.hash.startsWith('#@')) | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	async load_more() { | ||||
| 		let last_start_time = this.start_time; | ||||
| 		this.start_time = last_start_time - 24 * 60 * 60 * 1000; | ||||
| 		let more = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				WITH news AS (SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM messages | ||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 				WHERE messages.timestamp > ? | ||||
| 				AND messages.timestamp <= ? | ||||
| 				ORDER BY messages.timestamp DESC) | ||||
| 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 					FROM news | ||||
| 					JOIN messages_refs ON news.id = messages_refs.ref | ||||
| 					JOIN messages ON messages_refs.message = messages.id | ||||
| 				UNION | ||||
| 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 					FROM news | ||||
| 					JOIN messages_refs ON news.id = messages_refs.message | ||||
| 					JOIN messages ON messages_refs.ref = messages.id | ||||
| 				UNION | ||||
| 				SELECT news.* FROM news | ||||
| 			`, | ||||
| 			[JSON.stringify(this.following), this.start_time, last_start_time] | ||||
| 		); | ||||
| 		this.messages = await this.decrypt([...more, ...this.messages]); | ||||
| 		this.loading++; | ||||
| 		this.loading_canceled = false; | ||||
| 		try { | ||||
| 			let more = []; | ||||
| 			let last_start_time = this.time_range[0]; | ||||
| 			try { | ||||
| 				more = await this.fetch_messages(null, last_start_time); | ||||
| 			} catch (e) { | ||||
| 				console.log(e); | ||||
| 			} | ||||
| 			this.update_time_range_from_messages( | ||||
| 				more.filter((x) => x.timestamp < last_start_time) | ||||
| 			); | ||||
| 			this.messages = await this.decrypt([...more, ...this.messages]); | ||||
| 		} finally { | ||||
| 			this.loading--; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	cancel_load() { | ||||
| 		this.loading_canceled = true; | ||||
| 	} | ||||
|  | ||||
| 	async decrypt(messages) { | ||||
| 		console.log('decrypt'); | ||||
| 		let result = []; | ||||
| 		for (let message of messages) { | ||||
| 			let content; | ||||
| @@ -156,44 +360,200 @@ class TfTabNewsFeedElement extends LitElement { | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	async add_messages(messages) { | ||||
| 		this.messages = await this.decrypt([...messages, ...this.messages]); | ||||
| 	merge_messages(old_messages, new_messages) { | ||||
| 		let old_by_id = Object.fromEntries(old_messages.map((x) => [x.id, x])); | ||||
| 		return new_messages.map((x) => (old_by_id[x.id] ? old_by_id[x.id] : x)); | ||||
| 	} | ||||
|  | ||||
| 	async load_latest() { | ||||
| 		this.loading++; | ||||
| 		let now = new Date().valueOf(); | ||||
| 		let end_time = now + 24 * 60 * 60 * 1000; | ||||
| 		let messages = []; | ||||
| 		try { | ||||
| 			messages = await this.fetch_messages(this.time_range[0], end_time); | ||||
| 			messages = await this.decrypt(messages); | ||||
| 			this.update_time_range_from_messages( | ||||
| 				messages.filter( | ||||
| 					(x) => x.timestamp >= this.time_range[0] && x.timestamp < end_time | ||||
| 				) | ||||
| 			); | ||||
| 		} finally { | ||||
| 			this.loading--; | ||||
| 		} | ||||
| 		this.messages = this.merge_messages( | ||||
| 			this.messages, | ||||
| 			Object.values( | ||||
| 				Object.fromEntries( | ||||
| 					[...this.messages, ...messages] | ||||
| 						.sort((x, y) => x.timestamp - y.timestamp) | ||||
| 						.slice(-1024) | ||||
| 						.map((x) => [x.id, x]) | ||||
| 				) | ||||
| 			) | ||||
| 		); | ||||
| 		console.log('done loading latest messages.'); | ||||
| 	} | ||||
|  | ||||
| 	async load_messages() { | ||||
| 		let start_time = new Date(); | ||||
| 		let self = this; | ||||
| 		this.loading++; | ||||
| 		let messages = []; | ||||
| 		let original_hash = this.hash; | ||||
| 		try { | ||||
| 			if (this._messages_hash !== this.hash) { | ||||
| 				this.messages = []; | ||||
| 				this._messages_hash = this.hash; | ||||
| 			} | ||||
| 			this._messages_following = JSON.stringify(this.following); | ||||
| 			this._private_messages = | ||||
| 				JSON.stringify(this.private_messages) + | ||||
| 				JSON.stringify(this.grouped_private_messages); | ||||
| 			this._channels_latest = JSON.stringify( | ||||
| 				Object.keys(this.channels_latest ?? {}) | ||||
| 			); | ||||
| 			let now = new Date().valueOf(); | ||||
| 			let start_time = now - 24 * 60 * 60 * 1000; | ||||
| 			this.start_time = start_time; | ||||
| 			this.time_range = [now + 24 * 60 * 60 * 1000, now + 24 * 60 * 60 * 1000]; | ||||
| 			messages = await this.fetch_messages(null, this.time_range[1]); | ||||
| 			this.update_time_range_from_messages( | ||||
| 				messages.filter((x) => x.timestamp < this.time_range[1]) | ||||
| 			); | ||||
| 			messages = await this.decrypt(messages); | ||||
| 		} finally { | ||||
| 			this.loading--; | ||||
| 		} | ||||
| 		if (this.hash == original_hash) { | ||||
| 			this.messages = this.merge_messages(this.messages, messages); | ||||
| 		} | ||||
| 		this.time_loading = undefined; | ||||
| 		console.log( | ||||
| 			`loading ${messages.length} messages done for ${self.whoami} in ${(new Date() - start_time) / 1000}s` | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	mark_all_read() { | ||||
| 		let newest = this.messages.reduce( | ||||
| 			(accumulator, current) => Math.max(accumulator, current.rowid), | ||||
| 			this.channels_latest[this.channel()] ?? -1 | ||||
| 		); | ||||
| 		if (newest >= 0) { | ||||
| 			this.dispatchEvent( | ||||
| 				new CustomEvent('channelsetunread', { | ||||
| 					bubbles: true, | ||||
| 					composed: true, | ||||
| 					detail: { | ||||
| 						channel: this.channel(), | ||||
| 						unread: newest + 1, | ||||
| 					}, | ||||
| 				}) | ||||
| 			); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	close_private_chat() { | ||||
| 		this.mark_all_read(); | ||||
| 		this.dispatchEvent( | ||||
| 			new CustomEvent('closeprivatechat', { | ||||
| 				bubbles: true, | ||||
| 				composed: true, | ||||
| 				detail: { | ||||
| 					key: JSON.stringify( | ||||
| 						this.hash == '#🔐' | ||||
| 							? [] | ||||
| 							: this.hash.substring('#🔐'.length).split(',') | ||||
| 					), | ||||
| 				}, | ||||
| 			}) | ||||
| 		); | ||||
| 		tfrpc.rpc.setHash('#'); | ||||
| 	} | ||||
|  | ||||
| 	render_close_chat_button() { | ||||
| 		if (this.hash.startsWith('#🔐')) { | ||||
| 			return html` | ||||
| 				<button class="w3-button w3-theme-d1" @click=${this.close_private_chat}> | ||||
| 					Close Chat | ||||
| 				</button> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if ( | ||||
| 			!this.messages || | ||||
| 			this._messages_hash !== this.hash || | ||||
| 			this._messages_following !== this.following | ||||
| 			this._messages_following !== JSON.stringify(this.following) || | ||||
| 			this._private_messages !== | ||||
| 				JSON.stringify(this.private_messages) + | ||||
| 					JSON.stringify(this.grouped_private_messages) || | ||||
| 			this._channels_latest !== | ||||
| 				JSON.stringify(Object.keys(this.channels_latest)) | ||||
| 		) { | ||||
| 			console.log( | ||||
| 				`loading messages for ${this.whoami} (following ${this.following.length})` | ||||
| 			); | ||||
| 			let self = this; | ||||
| 			this.messages = []; | ||||
| 			this._messages_hash = this.hash; | ||||
| 			this._messages_following = this.following; | ||||
| 			this.fetch_messages() | ||||
| 				.then(this.decrypt.bind(this)) | ||||
| 				.then(function (messages) { | ||||
| 					self.messages = messages; | ||||
| 					console.log(`loading mesages done for ${self.whoami}`); | ||||
| 				}) | ||||
| 				.catch(function (error) { | ||||
| 					alert(JSON.stringify(error, null, 2)); | ||||
| 				}); | ||||
| 			this.load_messages(); | ||||
| 		} | ||||
| 		let more; | ||||
| 		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) { | ||||
| 		if (!this.hash.startsWith('#%')) { | ||||
| 			more = html` | ||||
| 				<p> | ||||
| 					<button class="w3-button w3-theme-d1" @click=${this.load_more}> | ||||
| 					${this.unread_allowed() | ||||
| 						? html` | ||||
| 								<button | ||||
| 									class="w3-button w3-theme-d1" | ||||
| 									@click=${this.mark_all_read} | ||||
| 								> | ||||
| 									Mark All Read | ||||
| 								</button> | ||||
| 							` | ||||
| 						: undefined} | ||||
| 					<button | ||||
| 						?disabled=${this.loading} | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						@click=${this.load_more} | ||||
| 					> | ||||
| 						Load More | ||||
| 					</button> | ||||
| 					<button | ||||
| 						class=${'w3-button w3-theme-d1' + (this.loading ? '' : ' w3-hide')} | ||||
| 						@click=${this.cancel_load} | ||||
| 					> | ||||
| 						Cancel | ||||
| 					</button> | ||||
| 					<span | ||||
| 						>Showing | ||||
| 						${new Date( | ||||
| 							this.time_loading | ||||
| 								? Math.min(this.time_loading[0], this.time_range[0]) | ||||
| 								: this.time_range[0] | ||||
| 						).toLocaleDateString()} | ||||
| 						- | ||||
| 						${new Date( | ||||
| 							this.time_loading | ||||
| 								? Math.max(this.time_loading[1], this.time_range[1]) | ||||
| 								: this.time_range[1] | ||||
| 						).toLocaleDateString()}.</span | ||||
| 					> | ||||
| 				</p> | ||||
| 			`; | ||||
| 		} | ||||
| 		return html` | ||||
| 		return cache(html` | ||||
| 			<style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			${this.unread_allowed() | ||||
| 				? html`<button | ||||
| 						class="w3-button w3-theme-d1" | ||||
| 						@click=${this.mark_all_read} | ||||
| 					> | ||||
| 						Mark All Read | ||||
| 					</button>` | ||||
| 				: undefined} | ||||
| 			${this.render_close_chat_button()} | ||||
| 			<tf-news | ||||
| 				id="news" | ||||
| 				whoami=${this.whoami} | ||||
| @@ -202,9 +562,14 @@ class TfTabNewsFeedElement extends LitElement { | ||||
| 				.following=${this.following} | ||||
| 				.drafts=${this.drafts} | ||||
| 				.expanded=${this.expanded} | ||||
| 				hash=${this.hash} | ||||
| 				channel=${this.channel()} | ||||
| 				channel_unread=${this.channels_unread?.[this.channel()]} | ||||
| 				.recent_reactions=${this.recent_reactions} | ||||
| 				@mark_all_read=${this.mark_all_read} | ||||
| 			></tf-news> | ||||
| 			${more} | ||||
| 		`; | ||||
| 		`); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,13 @@ | ||||
| import {LitElement, html, unsafeHTML, until} from './lit-all.min.js'; | ||||
| import { | ||||
| 	LitElement, | ||||
| 	cache, | ||||
| 	keyed, | ||||
| 	html, | ||||
| 	unsafeHTML, | ||||
| 	until, | ||||
| } from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabNewsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -8,11 +15,21 @@ class TfTabNewsElement extends LitElement { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			hash: {type: String}, | ||||
| 			unread: {type: Array}, | ||||
| 			following: {type: Array}, | ||||
| 			drafts: {type: Object}, | ||||
| 			expanded: {type: Object}, | ||||
| 			loading: {type: Boolean}, | ||||
| 			channels: {type: Array}, | ||||
| 			channels_unread: {type: Object}, | ||||
| 			channels_latest: {type: Object}, | ||||
| 			connections: {type: Array}, | ||||
| 			private_messages: {type: Array}, | ||||
| 			grouped_private_messages: {type: Object}, | ||||
| 			visible_private_messages: {type: Object}, | ||||
| 			recent_reactions: {type: Array}, | ||||
| 			peer_exchange: {type: Boolean}, | ||||
| 			is_administrator: {type: Boolean}, | ||||
| 			stay_connected: {type: Boolean}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -24,14 +41,19 @@ class TfTabNewsElement extends LitElement { | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.hash = '#'; | ||||
| 		this.unread = []; | ||||
| 		this.following = []; | ||||
| 		this.cache = {}; | ||||
| 		this.drafts = {}; | ||||
| 		this.expanded = {}; | ||||
| 		this.channels_unread = {}; | ||||
| 		this.channels_latest = {}; | ||||
| 		this.channels = []; | ||||
| 		this.connections = []; | ||||
| 		this.recent_reactions = []; | ||||
| 		tfrpc.rpc.localStorageGet('drafts').then(function (d) { | ||||
| 			self.drafts = JSON.parse(d || '{}'); | ||||
| 		}); | ||||
| 		this.check_peer_exchange(); | ||||
| 	} | ||||
|  | ||||
| 	connectedCallback() { | ||||
| @@ -44,37 +66,19 @@ class TfTabNewsElement extends LitElement { | ||||
| 		document.body.removeEventListener('keypress', this.on_keypress.bind(this)); | ||||
| 	} | ||||
|  | ||||
| 	show_more() { | ||||
| 		let unread = this.unread; | ||||
| 		let news = this.shadowRoot?.getElementById('news'); | ||||
| 		if (news) { | ||||
| 			console.log('injecting messages', news.messages); | ||||
| 			news.add_messages( | ||||
| 				Object.values(Object.fromEntries(this.unread.map((x) => [x.id, x]))) | ||||
| 			); | ||||
| 			this.dispatchEvent(new CustomEvent('refresh')); | ||||
| 	async check_peer_exchange() { | ||||
| 		if (await tfrpc.rpc.isAdministrator()) { | ||||
| 			this.peer_exchange = await tfrpc.rpc.globalSettingsGet('peer_exchange'); | ||||
| 		} else { | ||||
| 			this.peer_exchange = undefined; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	new_messages_text() { | ||||
| 		if (!this.unread?.length) { | ||||
| 			return 'No new messages.'; | ||||
| 	load_latest() { | ||||
| 		let news = this.shadowRoot?.getElementById('news'); | ||||
| 		if (news) { | ||||
| 			news.load_latest(); | ||||
| 		} | ||||
| 		let counts = {}; | ||||
| 		for (let message of this.unread) { | ||||
| 			let type = 'private'; | ||||
| 			try { | ||||
| 				type = JSON.parse(message.content).type || type; | ||||
| 			} catch {} | ||||
| 			counts[type] = (counts[type] || 0) + 1; | ||||
| 		} | ||||
| 		return ( | ||||
| 			'↻ Show New: ' + | ||||
| 			Object.keys(counts) | ||||
| 				.sort() | ||||
| 				.map((x) => counts[x].toString() + ' ' + x + 's') | ||||
| 				.join(', ') | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	draft(event) { | ||||
| @@ -106,14 +110,278 @@ class TfTabNewsElement extends LitElement { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	unread_status(channel) { | ||||
| 		if (channel === undefined) { | ||||
| 			if ( | ||||
| 				Object.keys(this.channels_unread).some((x) => this.unread_status(x)) | ||||
| 			) { | ||||
| 				return '✉️ '; | ||||
| 			} | ||||
| 		} else if (channel?.startsWith('🔐')) { | ||||
| 			let key = JSON.stringify(channel.substring('🔐'.length).split(',')); | ||||
| 			if (this.grouped_private_messages?.[key]) { | ||||
| 				let grouped_latest = Math.max( | ||||
| 					...this.grouped_private_messages?.[key]?.map((x) => x.rowid) | ||||
| 				); | ||||
| 				if ( | ||||
| 					this.channels_unread[channel] === undefined || | ||||
| 					grouped_latest > this.channels_unread[channel] | ||||
| 				) { | ||||
| 					return '✉️ '; | ||||
| 				} | ||||
| 			} | ||||
| 		} else if ( | ||||
| 			this.channels_latest[channel] && | ||||
| 			this.channels_latest[channel] > 0 && | ||||
| 			(this.channels_unread[channel] === undefined || | ||||
| 				this.channels_unread[channel] <= this.channels_latest[channel]) | ||||
| 		) { | ||||
| 			return '✉️ '; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	show_sidebar() { | ||||
| 		this.renderRoot.getElementById('sidebar').style.display = 'block'; | ||||
| 		this.renderRoot.getElementById('sidebar_overlay').style.display = 'block'; | ||||
| 	} | ||||
|  | ||||
| 	hide_sidebar() { | ||||
| 		this.renderRoot.getElementById('sidebar').style.display = 'none'; | ||||
| 		this.renderRoot.getElementById('sidebar_overlay').style.display = 'none'; | ||||
| 	} | ||||
|  | ||||
| 	async channel_toggle_subscribed() { | ||||
| 		let channel = this.hash.substring(2); | ||||
| 		let subscribed = this.channels.indexOf(channel) != -1; | ||||
| 		subscribed = !subscribed; | ||||
|  | ||||
| 		await tfrpc.rpc.appendMessage(this.whoami, { | ||||
| 			type: 'channel', | ||||
| 			channel: channel, | ||||
| 			subscribed: subscribed, | ||||
| 		}); | ||||
| 		if (subscribed) { | ||||
| 			this.channels = [].concat([channel], this.channels).sort(); | ||||
| 		} else { | ||||
| 			this.channels = this.channels.filter((x) => x != channel); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	channel() { | ||||
| 		return this.hash.startsWith('##') ? this.hash.substring(2) : undefined; | ||||
| 	} | ||||
|  | ||||
| 	compare_follows(a, b) { | ||||
| 		return b[1].ts > a[1].ts ? 1 : b[1].ts < a[1].ts ? -1 : 0; | ||||
| 	} | ||||
|  | ||||
| 	suggested_follows() { | ||||
| 		/* | ||||
| 		 ** Filter out people who have used future timestamps so that they aren't | ||||
| 		 ** pinned at the top. | ||||
| 		 */ | ||||
| 		let self = this; | ||||
| 		let now = new Date().valueOf(); | ||||
| 		return Object.entries(this.users) | ||||
| 			.filter((x) => x[1].ts < now) | ||||
| 			.filter((x) => x[1].follow_depth > 1) | ||||
| 			.sort(self.compare_follows) | ||||
| 			.slice(0, 8) | ||||
| 			.map((x) => x[0]); | ||||
| 	} | ||||
|  | ||||
| 	async enable_peer_exchange() { | ||||
| 		await tfrpc.rpc.globalSettingsSet('peer_exchange', true); | ||||
| 		await this.check_peer_exchange(); | ||||
| 	} | ||||
|  | ||||
| 	is_loading() { | ||||
| 		return this.shadowRoot?.getElementById('news')?.loading; | ||||
| 	} | ||||
|  | ||||
| 	render_sidebar() { | ||||
| 		return html` | ||||
| 			<div | ||||
| 				class="w3-sidebar w3-bar-block w3-theme-d1 w3-collapse w3-animate-left" | ||||
| 				style="width: 2in; left: 0; z-index: 5; box-sizing: border-box; top: 0" | ||||
| 				id="sidebar" | ||||
| 			> | ||||
| 				<div | ||||
| 					class="w3-right w3-button w3-hide-large" | ||||
| 					@click=${this.hide_sidebar} | ||||
| 				> | ||||
| 					× | ||||
| 				</div> | ||||
| 				${this.hash.startsWith('##') && | ||||
| 				this.channels.indexOf(this.hash.substring(2)) == -1 | ||||
| 					? html` | ||||
| 							<div class="w3-bar-item w3-theme-d2">Viewing</div> | ||||
| 							<a | ||||
| 								href="#" | ||||
| 								class="w3-bar-item w3-button" | ||||
| 								style="font-weight: bold" | ||||
| 								>${this.hash.substring(1)}</a | ||||
| 							> | ||||
| 						` | ||||
| 					: undefined} | ||||
| 				<h4 class="w3-bar-item w3-theme-d2">Channels</h4> | ||||
| 				<a | ||||
| 					href="#" | ||||
| 					class="w3-bar-item w3-button" | ||||
| 					style=${this.hash == '#' ? 'font-weight: bold' : undefined} | ||||
| 					>${this.unread_status('')}general</a | ||||
| 				> | ||||
| 				<a | ||||
| 					href="#@" | ||||
| 					class="w3-bar-item w3-button" | ||||
| 					style=${this.hash == '#@' ? 'font-weight: bold' : undefined} | ||||
| 					>${this.unread_status('@')}@mentions</a | ||||
| 				> | ||||
| 				<a | ||||
| 					href="#👍" | ||||
| 					class="w3-bar-item w3-button" | ||||
| 					style=${this.hash == '#👍' ? 'font-weight: bold' : undefined} | ||||
| 					>${this.unread_status('👍')}👍votes</a | ||||
| 				> | ||||
| 				${Object.keys(this?.visible_private_messages ?? []) | ||||
| 					?.sort() | ||||
| 					?.map( | ||||
| 						(key) => html` | ||||
| 							<a | ||||
| 								href=${'#🔐' + JSON.parse(key).join(',')} | ||||
| 								class="w3-bar-item w3-button" | ||||
| 								style=${this.hash == '#🔐' + JSON.parse(key).join(',') | ||||
| 									? 'font-weight: bold' | ||||
| 									: undefined} | ||||
| 								>${this.unread_status('🔐' + JSON.parse(key).join(','))} | ||||
| 								${(key != '[]' ? JSON.parse(key) : [this.whoami]).map( | ||||
| 									(id) => html` | ||||
| 										<tf-user | ||||
| 											id=${id} | ||||
| 											nolink="true" | ||||
| 											.users=${this.users} | ||||
| 										></tf-user> | ||||
| 									` | ||||
| 								)}</a | ||||
| 							> | ||||
| 						` | ||||
| 					)} | ||||
| 				${Object.keys(this.drafts) | ||||
| 					.sort() | ||||
| 					.map( | ||||
| 						(x) => html` | ||||
| 							<a | ||||
| 								href=${'#' + encodeURIComponent(x)} | ||||
| 								class="w3-bar-item w3-button" | ||||
| 								style="text-wrap: nowrap; text-overflow: ellipsis" | ||||
| 								>📝 ${this.drafts[x]?.text ?? x}</a | ||||
| 							> | ||||
| 						` | ||||
| 					)} | ||||
| 				${this.channels.map( | ||||
| 					(x) => html` | ||||
| 						<a | ||||
| 							href=${'#' + encodeURIComponent('#' + x)} | ||||
| 							class="w3-bar-item w3-button" | ||||
| 							style=${this.hash == '##' + x ? 'font-weight: bold' : undefined} | ||||
| 							>${this.unread_status(x)}#${x}</a | ||||
| 						> | ||||
| 					` | ||||
| 				)} | ||||
|  | ||||
| 				<a class="w3-bar-item w3-theme-d2 w3-button" href="#connections"> | ||||
| 					<h4 style="margin: 0">Connections</h4> | ||||
| 				</a> | ||||
| 				${this.connections?.filter((x) => x.id)?.length == 0 | ||||
| 					? html` | ||||
| 							<button | ||||
| 								class=${'w3-bar-item w3-button' + | ||||
| 								(this.connections?.some((x) => x.flags.one_shot) | ||||
| 									? ' w3-spin' | ||||
| 									: '')} | ||||
| 								@click=${() => | ||||
| 									this.dispatchEvent( | ||||
| 										new Event('refresh', {bubbles: true, composed: true}) | ||||
| 									)} | ||||
| 							> | ||||
| 								↻ Sync now | ||||
| 							</button> | ||||
| 							<button | ||||
| 								class="w3-bar-item w3-button w3-ripple" | ||||
| 								@click=${() => | ||||
| 									this.dispatchEvent( | ||||
| 										new Event('toggle_stay_connected', { | ||||
| 											bubbles: true, | ||||
| 											composed: true, | ||||
| 										}) | ||||
| 									)} | ||||
| 							> | ||||
| 								<span style="display: inline-block; width: 1.8em" | ||||
| 									>${this.stay_connected ? '🔗' : '⛓️💥'}</span | ||||
| 								> | ||||
| 								${this.stay_connected ? 'Online mode' : 'Passive mode'} | ||||
| 							</button> | ||||
| 							<button | ||||
| 								class=${'w3-bar-item w3-button w3-border w3-leftbar w3-rightbar' + | ||||
| 								(this.peer_exchange !== false ? ' w3-hide' : '')} | ||||
| 								@click=${this.enable_peer_exchange} | ||||
| 							> | ||||
| 								🔍🌐 Use publicly advertised peers | ||||
| 							</button> | ||||
| 						` | ||||
| 					: undefined} | ||||
| 				${this.connections | ||||
| 					.filter((x) => x.id) | ||||
| 					.map( | ||||
| 						(x) => html` | ||||
| 							<tf-user | ||||
| 								class="w3-bar-item" | ||||
| 								style=${x.destroy_reason | ||||
| 									? 'border-left: 4px solid red; border-right: 4px solid red' | ||||
| 									: x.connected | ||||
| 										? x.flags?.one_shot | ||||
| 											? 'border-left: 4px solid blue; border-right: 4px solid blue' | ||||
| 											: 'border-left: 4px solid green; border-right: 4px solid green' | ||||
| 										: ''} | ||||
| 								id=${x.id} | ||||
| 								fallback_name=${x.host} | ||||
| 								.users=${this.users} | ||||
| 							></tf-user> | ||||
| 						` | ||||
| 					)} | ||||
| 				<h4 class="w3-bar-item w3-theme-d2">Suggested Follows</h4> | ||||
| 				${this.suggested_follows().map( | ||||
| 					(x) => html` | ||||
| 						<tf-user | ||||
| 							class="w3-bar-item" | ||||
| 							style="max-width: 100%" | ||||
| 							id=${x} | ||||
| 							.users=${this.users} | ||||
| 						></tf-user> | ||||
| 					` | ||||
| 				)} | ||||
| 			</div> | ||||
| 			<div | ||||
| 				class="w3-overlay" | ||||
| 				id="sidebar_overlay" | ||||
| 				@click=${this.hide_sidebar} | ||||
| 			></div> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let profile = this.hash.startsWith('#@') | ||||
| 			? html`<tf-profile | ||||
| 					id=${this.hash.substring(1)} | ||||
| 					whoami=${this.whoami} | ||||
| 					.users=${this.users} | ||||
| 				></tf-profile>` | ||||
| 			: undefined; | ||||
| 		let profile = | ||||
| 			this.hash.startsWith('#@') && this.hash != '#@' | ||||
| 				? keyed( | ||||
| 						this.hash.substring(1), | ||||
| 						html`<tf-profile | ||||
| 							class="tf-profile" | ||||
| 							id=${this.hash.substring(1)} | ||||
| 							whoami=${this.whoami} | ||||
| 							.users=${this.users} | ||||
| 						></tf-profile>` | ||||
| 					) | ||||
| 				: undefined; | ||||
| 		let edit_profile; | ||||
| 		if ( | ||||
| 			!this.loading && | ||||
| @@ -127,41 +395,75 @@ class TfTabNewsElement extends LitElement { | ||||
| 				name. | ||||
| 			</div>`; | ||||
| 		} | ||||
| 		return html` | ||||
| 			<p class="w3-bar"> | ||||
| 				<button | ||||
| 					class="w3-bar-item w3-button w3-theme-d1" | ||||
| 					@click=${this.show_more} | ||||
| 				> | ||||
| 					${this.new_messages_text()} | ||||
| 				</button> | ||||
| 			</p> | ||||
| 			<div class="w3-bar"> | ||||
| 				Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>! | ||||
| 				${edit_profile} | ||||
| 		return cache(html` | ||||
| 			<style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			${this.render_sidebar()} | ||||
| 			<div | ||||
| 				style="margin-left: 2in; padding: 0px; top: 0; height: 100vh; max-height: 100%; overflow: auto; contain: layout" | ||||
| 				id="main" | ||||
| 				class="w3-main" | ||||
| 			> | ||||
| 				<div style="padding: 8px"> | ||||
| 					<p> | ||||
| 						${this.hash.startsWith('##') | ||||
| 							? html` | ||||
| 									<button | ||||
| 										class="w3-button w3-theme-d1" | ||||
| 										@click=${this.channel_toggle_subscribed} | ||||
| 									> | ||||
| 										${this.channels.indexOf(this.hash.substring(2)) != -1 | ||||
| 											? 'Unsubscribe from #' | ||||
| 											: 'Subscribe to #'}${this.hash.substring(2)} | ||||
| 									</button> | ||||
| 								` | ||||
| 							: undefined} | ||||
| 					</p> | ||||
| 					<div> | ||||
| 						<div | ||||
| 							id="show_sidebar" | ||||
| 							class="w3-button w3-hide-large" | ||||
| 							@click=${this.show_sidebar} | ||||
| 						> | ||||
| 							${this.unread_status()}☰ | ||||
| 						</div> | ||||
| 						Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>! | ||||
| 						${edit_profile} | ||||
| 					</div> | ||||
| 					<div> | ||||
| 						<tf-compose | ||||
| 							id="tf-compose" | ||||
| 							whoami=${this.whoami} | ||||
| 							.users=${this.users} | ||||
| 							.drafts=${this.drafts} | ||||
| 							@tf-draft=${this.draft} | ||||
| 							.channel=${this.channel()} | ||||
| 							.recipients=${this.hash.startsWith('#🔐') | ||||
| 								? this.hash.substring('#🔐'.length).split(',') | ||||
| 								: undefined} | ||||
| 						></tf-compose> | ||||
| 					</div> | ||||
| 					${profile} | ||||
| 					<tf-tab-news-feed | ||||
| 						id="news" | ||||
| 						whoami=${this.whoami} | ||||
| 						.users=${this.users} | ||||
| 						.following=${this.following} | ||||
| 						hash=${this.hash} | ||||
| 						.drafts=${this.drafts} | ||||
| 						.expanded=${this.expanded} | ||||
| 						@tf-draft=${this.draft} | ||||
| 						@tf-expand=${this.on_expand} | ||||
| 						.channels_unread=${this.channels_unread} | ||||
| 						.channels_latest=${this.channels_latest} | ||||
| 						.private_messages=${this.private_messages} | ||||
| 						.grouped_private_messages=${this.grouped_private_messages} | ||||
| 						.recent_reactions=${this.recent_reactions} | ||||
| 					></tf-tab-news-feed> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				<tf-compose | ||||
| 					id="tf-compose" | ||||
| 					whoami=${this.whoami} | ||||
| 					.users=${this.users} | ||||
| 					.drafts=${this.drafts} | ||||
| 					@tf-draft=${this.draft} | ||||
| 				></tf-compose> | ||||
| 			</div> | ||||
| 			${profile} | ||||
| 			<tf-tab-news-feed | ||||
| 				id="news" | ||||
| 				whoami=${this.whoami} | ||||
| 				.users=${this.users} | ||||
| 				.following=${this.following} | ||||
| 				hash=${this.hash} | ||||
| 				.drafts=${this.drafts} | ||||
| 				.expanded=${this.expanded} | ||||
| 				@tf-draft=${this.draft} | ||||
| 				@tf-expand=${this.on_expand} | ||||
| 			></tf-tab-news-feed> | ||||
| 		`; | ||||
| 		`); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,136 +0,0 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabQueryElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			following: {type: Array}, | ||||
| 			query: {type: String}, | ||||
| 			expanded: {type: Object}, | ||||
| 			results: {type: Array}, | ||||
| 			error: {type: Object}, | ||||
| 			duration: {type: Number}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.following = []; | ||||
| 		this.expanded = {}; | ||||
| 		this.duration = undefined; | ||||
| 	} | ||||
|  | ||||
| 	async search(query) { | ||||
| 		console.log('Searching...', this.whoami, query); | ||||
| 		this.results = []; | ||||
| 		this.error = undefined; | ||||
| 		this.duration = undefined; | ||||
| 		let search = this.renderRoot.getElementById('search'); | ||||
| 		if (search) { | ||||
| 			search.value = query; | ||||
| 			search.focus(); | ||||
| 		} | ||||
| 		await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query)); | ||||
| 		let start_time = new Date(); | ||||
| 		try { | ||||
| 			this.results = await tfrpc.rpc.query(query, []); | ||||
| 		} catch (error) { | ||||
| 			this.error = error; | ||||
| 		} | ||||
| 		let end_time = new Date(); | ||||
| 		this.duration = (end_time - start_time).valueOf(); | ||||
| 		console.log('Done.'); | ||||
| 		search = this.renderRoot.getElementById('search'); | ||||
| 		if (search) { | ||||
| 			search.value = query; | ||||
| 			search.focus(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	search_keydown(event) { | ||||
| 		if (event.keyCode == 13 && event.ctrlKey) { | ||||
| 			this.query = this.renderRoot.getElementById('search').value; | ||||
| 			event.preventDefault(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	on_expand(event) { | ||||
| 		if (event.detail.expanded) { | ||||
| 			let expand = {}; | ||||
| 			expand[event.detail.id] = true; | ||||
| 			this.expanded = Object.assign({}, this.expanded, expand); | ||||
| 		} else { | ||||
| 			delete this.expanded[event.detail.id]; | ||||
| 			this.expanded = Object.assign({}, this.expanded); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_results() { | ||||
| 		if (!this.results?.length) { | ||||
| 			return html`<div>No results.</div>`; | ||||
| 		} else { | ||||
| 			let keys = Object.keys(this.results[0]).sort(); | ||||
| 			return html`<table style="width: 100%; max-width: 100%"> | ||||
| 				<tr> | ||||
| 					${keys.map((key) => html`<th>${key}</th>`)} | ||||
| 				</tr> | ||||
| 				${this.results.map( | ||||
| 					(row) => | ||||
| 						html`<tr> | ||||
| 							${keys.map((key) => html`<td>${row[key]}</td>`)} | ||||
| 						</tr>` | ||||
| 				)} | ||||
| 			</table>`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_error() { | ||||
| 		if (this.error) { | ||||
| 			return html`<h2 style="color: red">${this.error.message}</h2> | ||||
| 				<pre style="color: red">${this.error.stack}</pre>`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if (this.query !== this.last_query) { | ||||
| 			this.last_query = this.query; | ||||
| 			this.search(this.query); | ||||
| 		} | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: row; gap: 4px"> | ||||
| 				<textarea | ||||
| 					id="search" | ||||
| 					rows="8" | ||||
| 					class="w3-input w3-theme-d1" | ||||
| 					style="flex: 1; resize: vertical" | ||||
| 					@keydown=${this.search_keydown} | ||||
| 				> | ||||
| ${this.query}</textarea | ||||
| 				> | ||||
| 				<button | ||||
| 					class="w3-button w3-theme-d1" | ||||
| 					@click=${(event) => | ||||
| 						self.search(self.renderRoot.getElementById('search').value)} | ||||
| 				> | ||||
| 					Execute | ||||
| 				</button> | ||||
| 			</div> | ||||
| 			<div ?hidden=${this.duration === undefined}> | ||||
| 				Took ${this.duration / 1000.0} seconds. | ||||
| 			</div> | ||||
| 			<div ?hidden=${this.duration !== undefined}>Executing...</div> | ||||
| 			${this.render_error()} ${this.render_results()} | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-tab-query', TfTabQueryElement); | ||||
| @@ -1,6 +1,6 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabSearchElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -11,6 +11,9 @@ class TfTabSearchElement extends LitElement { | ||||
| 			following: {type: Array}, | ||||
| 			query: {type: String}, | ||||
| 			expanded: {type: Object}, | ||||
| 			messages: {type: Array}, | ||||
| 			results: {type: Array}, | ||||
| 			error: {type: Object}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -38,24 +41,40 @@ class TfTabSearchElement extends LitElement { | ||||
| 			search.select(); | ||||
| 		} | ||||
| 		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); | ||||
| 		let results = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 				FROM messages_fts(?) | ||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 				ORDER BY timestamp DESC limit 100 | ||||
| 			`, | ||||
| 			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)] | ||||
| 		); | ||||
| 		console.log('Done.'); | ||||
| 		search = this.renderRoot.getElementById('search'); | ||||
| 		if (search) { | ||||
| 			search.value = query; | ||||
| 			search.focus(); | ||||
| 			search.select(); | ||||
| 		this.error = undefined; | ||||
| 		this.results = []; | ||||
| 		this.messages = []; | ||||
| 		if (query.startsWith('sql:')) { | ||||
| 			this.messages = []; | ||||
| 			try { | ||||
| 				this.results = await tfrpc.rpc.query( | ||||
| 					query.substring('sql:'.length), | ||||
| 					[] | ||||
| 				); | ||||
| 			} catch (e) { | ||||
| 				this.results = []; | ||||
| 				this.error = e; | ||||
| 			} | ||||
| 		} else { | ||||
| 			let results = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||
| 					FROM messages_fts(?) | ||||
| 					JOIN messages ON messages.rowid = messages_fts.rowid | ||||
| 					JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 					ORDER BY timestamp DESC limit 100 | ||||
| 				`, | ||||
| 				['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)] | ||||
| 			); | ||||
| 			console.log('Done.'); | ||||
| 			search = this.renderRoot.getElementById('search'); | ||||
| 			if (search) { | ||||
| 				search.value = query; | ||||
| 				search.focus(); | ||||
| 				search.select(); | ||||
| 			} | ||||
| 			this.messages = results; | ||||
| 		} | ||||
| 		this.renderRoot.getElementById('news').messages = results; | ||||
| 	} | ||||
|  | ||||
| 	search_keydown(event) { | ||||
| @@ -87,6 +106,39 @@ class TfTabSearchElement extends LitElement { | ||||
| 		tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts)); | ||||
| 	} | ||||
|  | ||||
| 	render_results() { | ||||
| 		if (this.error) { | ||||
| 			return html`<h2 style="color: red">${this.error.message}</h2> | ||||
| 				<pre style="color: red">${this.error.stack}</pre>`; | ||||
| 		} else if (this.messages?.length) { | ||||
| 			return html`<tf-news | ||||
| 				id="news" | ||||
| 				whoami=${this.whoami} | ||||
| 				.messages=${this.messages} | ||||
| 				.users=${this.users} | ||||
| 				.expanded=${this.expanded} | ||||
| 				.drafts=${this.drafts} | ||||
| 				@tf-expand=${this.on_expand} | ||||
| 				@tf-draft=${this.draft} | ||||
| 			></tf-news>`; | ||||
| 		} else if (this.results?.length) { | ||||
| 			let keys = Object.keys(this.results[0]).sort(); | ||||
| 			return html`<table style="width: 100%; max-width: 100%"> | ||||
| 				<tr> | ||||
| 					${keys.map((key) => html`<th>${key}</th>`)} | ||||
| 				</tr> | ||||
| 				${this.results.map( | ||||
| 					(row) => | ||||
| 						html`<tr> | ||||
| 							${keys.map((key) => html`<td>${row[key]}</td>`)} | ||||
| 						</tr>` | ||||
| 				)} | ||||
| 			</table>`; | ||||
| 		} else { | ||||
| 			return html`<div>No results.</div>`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if (this.query !== this.last_query) { | ||||
| 			this.last_query = this.query; | ||||
| @@ -94,11 +146,14 @@ class TfTabSearchElement extends LitElement { | ||||
| 		} | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: row; gap: 4px"> | ||||
| 				<input type="text" class="w3-input w3-theme-d1" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input> | ||||
| 				<button class="w3-button w3-theme-d1" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button> | ||||
| 			<style>${generate_theme()}</style> | ||||
| 			<div class="w3-padding"> | ||||
| 				<div style="display: flex; flex-direction: row; gap: 4px"> | ||||
| 					<input type="text" class="w3-input w3-theme-d1" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input> | ||||
| 					<button class="w3-button w3-theme-d1" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button> | ||||
| 				</div> | ||||
| 				${this.render_results()} | ||||
| 			</div> | ||||
| 			<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} .drafts=${this.drafts} @tf-expand=${this.on_expand} @tf-draft=${this.draft}></tf-news> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfTagElement extends LitElement { | ||||
| 	static get properties() { | ||||
| @@ -17,11 +17,15 @@ class TfTagElement extends LitElement { | ||||
|  | ||||
| 	render() { | ||||
| 		let number = this.count ? html` (${this.count})` : undefined; | ||||
| 		return html`<a | ||||
| 			href="#q=${this.tag}" | ||||
| 			style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px" | ||||
| 			>${this.tag}${number}</a | ||||
| 		>`; | ||||
| 		return html` | ||||
| 			<style> | ||||
| 				${generate_theme()}</style | ||||
| 			><a | ||||
| 				href=${'#' + encodeURIComponent(this.tag)} | ||||
| 				class="w3-tag w3-theme-d1 w3-round-4 w3-button" | ||||
| 				>${this.tag}${number}</a | ||||
| 			> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import {styles, generate_theme} from './tf-styles.js'; | ||||
|  | ||||
| class TfUserElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			id: {type: String}, | ||||
| 			fallback_name: {type: String}, | ||||
| 			icon_only: {type: Boolean}, | ||||
| 			users: {type: Object}, | ||||
| 			nolink: {type: Boolean}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| @@ -15,36 +18,55 @@ class TfUserElement extends LitElement { | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		this.id = null; | ||||
| 		this.fallback_name = null; | ||||
| 		this.icon_only = false; | ||||
| 		this.users = {}; | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let user = this.users[this.id]; | ||||
| 		let shape = | ||||
| 			user?.follow_depth === undefined || user.follow_depth >= 2 | ||||
| 				? 'w3-circle' | ||||
| 				: 'w3-round'; | ||||
| 		let image = html`<span | ||||
| 			class="w3-theme-light w3-circle" | ||||
| 			class=${'w3-theme-l4 ' + shape} | ||||
| 			style="display: inline-block; width: 2em; height: 2em; text-align: center; line-height: 2em" | ||||
| 			>?</span | ||||
| 			>😎</span | ||||
| 		>`; | ||||
| 		let name = this.users?.[this.id]?.name; | ||||
| 		name = | ||||
| 			name !== undefined | ||||
| 				? html`<a target="_top" href=${'#' + this.id}>${name}</a>` | ||||
| 				: html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`; | ||||
| 		let name_string = name ?? this.fallback_name ?? this.id; | ||||
| 		name = this.icon_only | ||||
| 			? undefined | ||||
| 			: !this.nolink | ||||
| 				? html`<a target="_top" href=${'#' + this.id}>${name_string}</a>` | ||||
| 				: html`<span>${name_string}</span>`; | ||||
|  | ||||
| 		if (this.users[this.id]) { | ||||
| 			let image_link = this.users[this.id].image; | ||||
| 			image_link = | ||||
| 				typeof image_link == 'string' ? image_link : image_link?.link; | ||||
| 		if (user) { | ||||
| 			let image_link = user.image; | ||||
| 			if (typeof image_link == 'string' && !image_link.startsWith('&')) { | ||||
| 				try { | ||||
| 					image_link = JSON.parse(image_link)?.link; | ||||
| 				} catch {} | ||||
| 			} | ||||
| 			if (image_link !== undefined) { | ||||
| 				image = html`<img | ||||
| 					class="w3-circle" | ||||
| 					style="width: 2em; height: 2em; vertical-align: middle" | ||||
| 					class=${'w3-theme-l4 ' + shape} | ||||
| 					style="width: 2em; height: 2em; vertical-align: middle; object-fit: cover" | ||||
| 					src="/${image_link}/view" | ||||
| 					title=${name_string + ' (' + this.id + ')'} | ||||
| 				/>`; | ||||
| 			} | ||||
| 		} | ||||
| 		return html` <div style="display: inline-block; font-weight: bold"> | ||||
| 			${image} ${name} | ||||
| 		</div>`; | ||||
| 		return html` <style> | ||||
| 				${generate_theme()} | ||||
| 			</style> | ||||
| 			<div | ||||
| 				style=${'display: inline-block; vertical-align: middle; text-wrap: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis' + | ||||
| 				(this.nolink ? '' : '; font-weight: bold')} | ||||
| 			> | ||||
| 				${image} ${name} | ||||
| 			</div>`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,12 @@ import * as hashtagify from './commonmark-hashtag.js'; | ||||
|  | ||||
| const k_code_classes = 'w3-theme-l4 w3-theme-border w3-round'; | ||||
|  | ||||
| var reUnsafeProtocol = /^javascript:|vbscript:|file:|data:/i; | ||||
| var reSafeDataProtocol = /^data:image\/(?:png|gif|jpeg|webp)/i; | ||||
| var potentiallyUnsafe = function (url) { | ||||
| 	return reUnsafeProtocol.test(url) && !reSafeDataProtocol.test(url); | ||||
| }; | ||||
|  | ||||
| function image(node, entering) { | ||||
| 	if ( | ||||
| 		node.firstChild?.type === 'text' && | ||||
| @@ -44,9 +50,9 @@ function image(node, entering) { | ||||
| 						'</div>' | ||||
| 				); | ||||
| 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | ||||
| 					this.lit('<img src="" alt="'); | ||||
| 					this.lit('<img src="" title="'); | ||||
| 				} else { | ||||
| 					this.lit('<img src="' + this.esc(node.destination) + '" alt="'); | ||||
| 					this.lit('<img src="' + this.esc(node.destination) + '" title="'); | ||||
| 				} | ||||
| 			} | ||||
| 			this.disableTags += 1; | ||||
| @@ -81,8 +87,8 @@ function attrs(node) { | ||||
| } | ||||
|  | ||||
| export function markdown(md) { | ||||
| 	let reader = new commonmark.Parser({safe: true}); | ||||
| 	let writer = new commonmark.HtmlRenderer(); | ||||
| 	let reader = new commonmark.Parser(); | ||||
| 	let writer = new commonmark.HtmlRenderer({safe: true}); | ||||
| 	writer.image = image; | ||||
| 	writer.code = code; | ||||
| 	writer.attrs = attrs; | ||||
| @@ -98,12 +104,12 @@ export function markdown(md) { | ||||
| 					node.destination.startsWith('@') && | ||||
| 					node.destination.endsWith('.ed25519') | ||||
| 				) { | ||||
| 					node.destination = '#' + node.destination; | ||||
| 					node.destination = '#' + encodeURIComponent(node.destination); | ||||
| 				} else if ( | ||||
| 					node.destination.startsWith('%') && | ||||
| 					node.destination.endsWith('.sha256') | ||||
| 				) { | ||||
| 					node.destination = '#' + node.destination; | ||||
| 					node.destination = '#' + encodeURIComponent(node.destination); | ||||
| 				} else if ( | ||||
| 					node.destination.startsWith('&') && | ||||
| 					node.destination.endsWith('.sha256') | ||||
|   | ||||
							
								
								
									
										5
									
								
								apps/storage.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "💾", | ||||
| 	"previous": "&tzZFIe7Y54O4sx1QtAPdemkXh+p5qHXSG/dlS7NP6OQ=.sha256" | ||||
| } | ||||
							
								
								
									
										126
									
								
								apps/storage/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,126 @@ | ||||
| async function query(sql, args) { | ||||
| 	let rows = []; | ||||
| 	await ssb.sqlAsync(sql, args ?? [], function (row) { | ||||
| 		rows.push(row); | ||||
| 	}); | ||||
| 	return rows; | ||||
| } | ||||
|  | ||||
| async function get_biggest() { | ||||
| 	return query(` | ||||
| 		select author, size from messages_stats group by author order by size desc limit 10; | ||||
| 	`); | ||||
| } | ||||
|  | ||||
| async function get_total() { | ||||
| 	return ( | ||||
| 		await query(` | ||||
| 		select sum(length(content)) as size, count(distinct author) as count from messages; | ||||
| 	`) | ||||
| 	)[0]; | ||||
| } | ||||
|  | ||||
| async function get_names(identities) { | ||||
| 	return query( | ||||
| 		` | ||||
| 		SELECT author, name FROM ( | ||||
| 			SELECT | ||||
| 				messages.author, | ||||
| 				RANK() OVER (PARTITION BY messages.author ORDER BY messages.sequence DESC) AS author_rank, | ||||
| 				messages.content ->> 'name' AS name | ||||
| 			FROM messages | ||||
| 			JOIN json_each(?) AS identities ON identities.value = messages.author | ||||
| 			WHERE | ||||
| 				json_extract(messages.content, '$.type') = 'about' AND | ||||
| 				content ->> 'about' = messages.author AND name IS NOT NULL) | ||||
| 		WHERE author_rank = 1 | ||||
| 	`, | ||||
| 		[JSON.stringify(identities)] | ||||
| 	); | ||||
| } | ||||
|  | ||||
| async function get_most_follows() { | ||||
| 	return query(` | ||||
| 		select author, count(*) as count | ||||
| 		from messages | ||||
| 		where content ->> 'type' = 'contact' and content ->> 'following' = true | ||||
| 		group by author | ||||
| 		order by count desc | ||||
| 		limit 10; | ||||
| 	`); | ||||
| } | ||||
|  | ||||
| function nice_size(bytes) { | ||||
| 	let value = bytes; | ||||
| 	let index = 0; | ||||
| 	let units = ['B', 'kB', 'MB', 'GB']; | ||||
| 	while (value > 1024 && index < units.length - 1) { | ||||
| 		value /= 1024; | ||||
| 		index++; | ||||
| 	} | ||||
| 	return `${Math.round(value * 10) / 10} ${units[index]}`; | ||||
| } | ||||
|  | ||||
| async function main() { | ||||
| 	await app.setDocument('<p style="color: #fff">Analyzing feeds...</p>'); | ||||
| 	let most_follows = get_most_follows(); | ||||
| 	let total = await get_total(); | ||||
| 	let identities = await ssb.getAllIdentities(); | ||||
| 	let following1 = await ssb.following(identities, 1); | ||||
| 	let following2 = await ssb.following(identities, 2); | ||||
| 	let biggest = await get_biggest(); | ||||
| 	most_follows = await most_follows; | ||||
| 	let names = await get_names( | ||||
| 		[].concat( | ||||
| 			biggest.map((x) => x.author), | ||||
| 			most_follows.map((x) => x.author) | ||||
| 		) | ||||
| 	); | ||||
| 	names = Object.fromEntries(names.map((x) => [x.author, x.name])); | ||||
| 	for (let item of biggest) { | ||||
| 		item.name = names[item.author]; | ||||
| 		item.following = | ||||
| 			identities.indexOf(item.author) != -1 | ||||
| 				? 0 | ||||
| 				: following1[item.author] !== undefined | ||||
| 					? 1 | ||||
| 					: following2[item.author] !== undefined | ||||
| 						? 2 | ||||
| 						: undefined; | ||||
| 	} | ||||
| 	for (let item of most_follows) { | ||||
| 		item.name = names[item.author]; | ||||
| 	} | ||||
| 	let html = `<body style="color: #000; background-color: #ddd">\n | ||||
| 		<h1>Storage Summary</h1> | ||||
| 		<h2>Top Accounts by Size</h2> | ||||
| 		<ol>`; | ||||
| 	for (let item of biggest) { | ||||
| 		html += `<li> | ||||
| 			<span style="color: #888">${nice_size(item.size)}</span> | ||||
| 			<a target="_top" href="/~core/ssb/#${encodeURI(item.author)}">${item.name ?? item.author}</a> | ||||
| 		</li> | ||||
| 		\n`; | ||||
| 	} | ||||
| 	html += ` | ||||
| 		</ol> | ||||
| 		<h2>Top Accounts by Follows</h2> | ||||
| 		<ol>`; | ||||
| 	for (let item of most_follows) { | ||||
| 		html += `<li> | ||||
| 			<span style="color: #888">${item.count}</span> | ||||
| 			${following2[item.author] ? '✅' : '🚫'} | ||||
| 			<a target="_top" href="/~core/ssb/#${encodeURI(item.author)}">${item.name ?? item.author}</a> | ||||
| 		</li> | ||||
| 		\n`; | ||||
| 	} | ||||
| 	html += ` | ||||
| 		</ol> | ||||
| 		<p>Total <span style="color: #888">${nice_size(total.size)}</span> in ${total.count} accounts.</p> | ||||
| 	`; | ||||
| 	await app.setDocument(html); | ||||
| } | ||||
|  | ||||
| main().catch(function (e) { | ||||
| 	print(e); | ||||
| }); | ||||
							
								
								
									
										4
									
								
								apps/test.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "📦" | ||||
| } | ||||
							
								
								
									
										3
									
								
								apps/test/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| app.setDocument( | ||||
| 	'<p style="color: #fff">Maybe one day this app will run tests, but for now there is nothing to see here.</p>' | ||||
| ); | ||||
							
								
								
									
										1
									
								
								apps/test/hello.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| Hello, world! | ||||
							
								
								
									
										5
									
								
								apps/web.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "🕸", | ||||
| 	"previous": "&n7hu5b8/TsfiG6FDlCRG5nPCrIdCr96+xpIJ/aQT/uM=.sha256" | ||||
| } | ||||
							
								
								
									
										100
									
								
								apps/web/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,100 @@ | ||||
| let g_hash; | ||||
|  | ||||
| async function query(sql, params) { | ||||
| 	let results = []; | ||||
| 	await ssb.sqlAsync(sql, params, function (row) { | ||||
| 		results.push(row); | ||||
| 	}); | ||||
| 	return results; | ||||
| } | ||||
|  | ||||
| async function resolve(id) { | ||||
| 	try { | ||||
| 		let blob = await ssb.blobGet(id); | ||||
| 		if (blob) { | ||||
| 			let json; | ||||
| 			try { | ||||
| 				json = JSON.parse(utf8Decode(blob)); | ||||
| 			} catch { | ||||
| 				return {id: utf8Decode(blob)}; | ||||
| 			} | ||||
| 			if (json?.links) { | ||||
| 				for (let [key, value] of Object.entries(json.links)) { | ||||
| 					json.links[key] = await resolve(value); | ||||
| 				} | ||||
| 				return json; | ||||
| 			} else { | ||||
| 				return 'huh?' + json; | ||||
| 			} | ||||
| 		} else { | ||||
| 			return `missing<${id}>`; | ||||
| 		} | ||||
| 	} catch (e) { | ||||
| 		return id + ': ' + e.message; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function get_names(identities) { | ||||
| 	return Object.fromEntries( | ||||
| 		( | ||||
| 			await query( | ||||
| 				` | ||||
| 		SELECT author, name FROM ( | ||||
| 			SELECT | ||||
| 				messages.author, | ||||
| 				RANK() OVER (PARTITION BY messages.author ORDER BY messages.sequence DESC) AS author_rank, | ||||
| 				messages.content ->> 'name' AS name | ||||
| 			FROM messages | ||||
| 			JOIN json_each(?) AS identities ON identities.value = messages.author | ||||
| 			WHERE | ||||
| 				json_extract(messages.content, '$.type') = 'about' AND | ||||
| 				content ->> 'about' = messages.author AND name IS NOT NULL) | ||||
| 		WHERE author_rank = 1 | ||||
| 	`, | ||||
| 				[JSON.stringify(identities)] | ||||
| 			) | ||||
| 		).map((x) => [x.author, x.name]) | ||||
| 	); | ||||
| } | ||||
|  | ||||
| async function render(hash) { | ||||
| 	g_hash = hash; | ||||
| 	if (!hash) { | ||||
| 		let sites = await query( | ||||
| 			` | ||||
| 			SELECT site.author, site.id | ||||
| 			FROM messages site | ||||
| 			WHERE site.content ->> 'type' = 'web-init' | ||||
| 		`, | ||||
| 			[] | ||||
| 		); | ||||
| 		let names = await get_names(sites.map((x) => x.author)); | ||||
| 		if (hash === g_hash) { | ||||
| 			await app.setDocument( | ||||
| 				`<ul style="background-color: #ddd">${sites.map((x) => `<li><a target="_top" href="#${encodeURIComponent(x.id)}">${names[x.author] ?? x.author} - ${x.id}</a></li>`).join('\n')}</ul>` | ||||
| 			); | ||||
| 		} | ||||
| 	} else { | ||||
| 		let site_id = | ||||
| 			hash.charAt(0) == '#' | ||||
| 				? decodeURIComponent(hash.substring(1)) | ||||
| 				: decodeURIComponent(hash); | ||||
| 		await app.setDocument(`<html style="margin: 0; padding: 0; width: 100vw; height: 100vh; margin: 0; padding: 0"> | ||||
| 			<body style="display: flex; flex-direction: column; width: 100vw; height: 100vh"> | ||||
| 				<iframe src="${encodeURIComponent(site_id)}/index.html" style="flex: 1 1; border: 0; background-color: #fff"></iframe> | ||||
| 			</body> | ||||
| 		</html>`); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| core.register('message', async function message_handler(message) { | ||||
| 	if (message.event == 'hashChange') { | ||||
| 		await render(message.hash); | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| async function main() { | ||||
| 	render(null); | ||||
| } | ||||
|  | ||||
| main(); | ||||
							
								
								
									
										63
									
								
								apps/web/handler.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,63 @@ | ||||
| async function query(sql, params) { | ||||
| 	let results = []; | ||||
| 	await ssb.sqlAsync(sql, params, function (row) { | ||||
| 		results.push(row); | ||||
| 	}); | ||||
| 	return results; | ||||
| } | ||||
|  | ||||
| function guess_content_type(name) { | ||||
| 	if (name.endsWith('.html')) { | ||||
| 		return 'text/html; charset=UTF-8'; | ||||
| 	} else if (name.endsWith('.js') || name.endsWith('.mjs')) { | ||||
| 		return 'text/javascript; charset=UTF-8'; | ||||
| 	} else if (name.endsWith('.css')) { | ||||
| 		return 'text/stylesheet; charset=UTF-8'; | ||||
| 	} else { | ||||
| 		return 'application/binary'; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function main() { | ||||
| 	let path = request.path.replaceAll(/(%[0-9a-fA-F]{2})/g, (x) => | ||||
| 		String.fromCharCode(parseInt(x.substring(1), 16)) | ||||
| 	); | ||||
| 	let match = path.match(/^(%.{44}\.sha256)(?:\/)?(.*)$/); | ||||
|  | ||||
| 	let content_type = guess_content_type(request.path); | ||||
| 	let root = await query( | ||||
| 		` | ||||
| 		SELECT root.content ->> 'root' AS root | ||||
| 		FROM messages site | ||||
| 		JOIN messages root | ||||
| 		ON site.id = ? AND root.author = site.author AND root.content ->> 'site' = site.id | ||||
| 		ORDER BY root.sequence DESC LIMIT 1 | ||||
| 	`, | ||||
| 		[match[1]] | ||||
| 	); | ||||
| 	let root_id = root[0]['root']; | ||||
| 	let last_id = root_id; | ||||
| 	let blob = await ssb.blobGet(root_id); | ||||
| 	try { | ||||
| 		for (let part of match[2]?.split('/')) { | ||||
| 			let dir = JSON.parse(utf8Decode(blob)); | ||||
| 			last_id = dir?.links[part]; | ||||
| 			blob = await ssb.blobGet(dir?.links[part]); | ||||
| 			content_type = guess_content_type(part); | ||||
| 		} | ||||
| 	} catch {} | ||||
|  | ||||
| 	respond({ | ||||
| 		status_code: 200, | ||||
| 		data: blob ? utf8Decode(blob) : `${last_id} not found`, | ||||
| 		content_type: content_type, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| main().catch(function (e) { | ||||
| 	respond({ | ||||
| 		status_code: 200, | ||||
| 		data: `${e.message}\n${e.stack}`, | ||||
| 		content_type: 'text/plain', | ||||
| 	}); | ||||
| }); | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "👋", | ||||
| 	"previous": "&g88EmT9WIJ93YGDyxUB5u3AdzdKh8q+KiaqiUEu99ec=.sha256" | ||||
| 	"previous": "&n1QkPkB5JoduFSx8UKOY3IlZqS2GwLiTUZv4ZrEOthQ=.sha256" | ||||
| } | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| async function main() { | ||||
| 	await app.setDocument(utf8Decode(getFile('index.html'))); | ||||
| } | ||||
|  | ||||
| main(); | ||||
							
								
								
									
										1
									
								
								apps/welcome/gitea.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg> | ||||
| After Width: | Height: | Size: 2.1 KiB | 
							
								
								
									
										23
									
								
								apps/welcome/googleplay.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="iso-8859-1"?> | ||||
| <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> | ||||
| <svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"  | ||||
| 	 viewBox="0 0 511.999 511.999" xml:space="preserve"> | ||||
| <g> | ||||
| 	<path style="fill:#32BBFF;" d="M382.369,175.623C322.891,142.356,227.427,88.937,79.355,6.028 | ||||
| 		C69.372-0.565,57.886-1.429,47.962,1.93l254.05,254.05L382.369,175.623z"/> | ||||
| 	<path style="fill:#32BBFF;" d="M47.962,1.93c-1.86,0.63-3.67,1.39-5.401,2.308C31.602,10.166,23.549,21.573,23.549,36v439.96 | ||||
| 		c0,14.427,8.052,25.834,19.012,31.761c1.728,0.917,3.537,1.68,5.395,2.314L302.012,255.98L47.962,1.93z"/> | ||||
| 	<path style="fill:#32BBFF;" d="M302.012,255.98L47.956,510.035c9.927,3.384,21.413,2.586,31.399-4.103 | ||||
| 		c143.598-80.41,237.986-133.196,298.152-166.746c1.675-0.941,3.316-1.861,4.938-2.772L302.012,255.98z"/> | ||||
| </g> | ||||
| <path style="fill:#2C9FD9;" d="M23.549,255.98v219.98c0,14.427,8.052,25.834,19.012,31.761c1.728,0.917,3.537,1.68,5.395,2.314 | ||||
| 	L302.012,255.98H23.549z"/> | ||||
| <path style="fill:#29CC5E;" d="M79.355,6.028C67.5-1.8,53.52-1.577,42.561,4.239l255.595,255.596l84.212-84.212 | ||||
| 	C322.891,142.356,227.427,88.937,79.355,6.028z"/> | ||||
| <path style="fill:#D93F21;" d="M298.158,252.126L42.561,507.721c10.96,5.815,24.939,6.151,36.794-1.789 | ||||
| 	c143.598-80.41,237.986-133.196,298.152-166.746c1.675-0.941,3.316-1.861,4.938-2.772L298.158,252.126z"/> | ||||
| <path style="fill:#FFD500;" d="M488.45,255.98c0-12.19-6.151-24.492-18.342-31.314c0,0-22.799-12.721-92.682-51.809l-83.123,83.123 | ||||
| 	l83.204,83.205c69.116-38.807,92.6-51.892,92.6-51.892C482.299,280.472,488.45,268.17,488.45,255.98z"/> | ||||
| <path style="fill:#FFAA00;" d="M470.108,287.294c12.191-6.822,18.342-19.124,18.342-31.314H294.303l83.204,83.205 | ||||
| 	C446.624,300.379,470.108,287.294,470.108,287.294z"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1521
									
								
								apps/welcome/hermietildefriends.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 86 KiB | 
| @@ -10,17 +10,6 @@ | ||||
| 		<link rel="stylesheet" href="brands.min.css" /> | ||||
|  | ||||
| 		<style> | ||||
| 			body, | ||||
| 			h1, | ||||
| 			h2, | ||||
| 			h3, | ||||
| 			h4, | ||||
| 			h5 { | ||||
| 				font-family: 'Poppins', sans-serif; | ||||
| 			} | ||||
| 			body { | ||||
| 				font-size: 16px; | ||||
| 			} | ||||
| 			img { | ||||
| 				margin-bottom: -8px; | ||||
| 			} | ||||
| @@ -39,50 +28,39 @@ | ||||
| 						<b>😎 Tilde Friends</b> | ||||
| 					</h1> | ||||
| 					<h1 class="w3-xxlarge w3-text-green"> | ||||
| 						<b>Make apps and friends from the comfort of your web browser.</b> | ||||
| 						<b>a Secure Scuttlebutt decentralized social network client</b> | ||||
| 					</h1> | ||||
| 					<p> | ||||
| 						Tilde Friends is a platform for building, running, and sharing web | ||||
| 						applications. | ||||
| 					</p> | ||||
| 					<p> | ||||
| 						Available for lots of devices: | ||||
| 						<i class="fa-brands fa-linux w3-xlarge"></i> | ||||
| 						<i class="fa-brands fa-android w3-xlarge"></i> | ||||
| 						<i class="fa-brands fa-apple w3-xlarge"></i> | ||||
| 						<i class="fa fa-mobile-screen w3-xlarge"></i> | ||||
| 						<i class="fa-brands fa-windows w3-xlarge"></i> | ||||
| 						In addition to participating in Secure Scuttlebutt, Tilde Friends is | ||||
| 						a platform for building, running, and sharing applications. | ||||
| 					</p> | ||||
| 					<a | ||||
| 						class="w3-button w3-blue w3-padding-large" | ||||
| 						href="https://www.tildefriends.net/~core/ssb/" | ||||
| 						>🦀 Try It</a | ||||
| 					> | ||||
| 					<a | ||||
| 						class="w3-button w3-black w3-padding-large" | ||||
| 						href="https://dev.tildefriends.net/cory/tildefriends/releases" | ||||
| 						href="https://dev.tildefriends.net/cory/tildefriends/releases/latest" | ||||
| 						><i class="fa fa-download"></i> Download</a | ||||
| 					> | ||||
| 					<a | ||||
| 						class="w3-button w3-black w3-padding-large" | ||||
| 						href="https://www.tildefriends.net/~cory/apps/" | ||||
| 						><i class="fa fa-link"></i> Try It</a | ||||
| 						href="https://dev.tildefriends.net/cory/tildefriends" | ||||
| 					> | ||||
| 						<img src="gitea.svg" style="height: 1em; margin: 0" /> | ||||
| 						Development | ||||
| 					</a> | ||||
| 					<a | ||||
| 						class="w3-button w3-black w3-padding-large" | ||||
| 						href="https://docs.tildefriends.net/" | ||||
| 						><i class="fa fa-book"></i> Documentation</a | ||||
| 					> | ||||
| 					<a | ||||
| 						class="w3-button w3-black w3-padding-large" | ||||
| 						href="https://dev.tildefriends.net/" | ||||
| 						><i class="fa fa-mug-hot"></i> Development</a | ||||
| 						href="https://www.tildefriends.net/~cory/tildeblog/" | ||||
| 						><i class="fa fa-solid fa-square-rss"></i> Blog</a | ||||
| 					> | ||||
| 					<p> | ||||
| 						<a | ||||
| 							class="w3-button w3-round-large w3-padding w3-blue-gray" | ||||
| 							href="https://f-droid.org/en/packages/com.unprompted.tildefriends.fdroid/" | ||||
| 							><img src="f-droid.svg" style="height: 2em; margin: 0" /> Get it | ||||
| 							on F-Droid</a | ||||
| 						> | ||||
| 						<a | ||||
| 							class="w3-button w3-round-large w3-padding w3-blue-gray" | ||||
| 							href="https://dev.tildefriends.net/releases/tildefriends-x86_64.AppImage" | ||||
| 						> | ||||
| 							<img src="appimage.svg" style="height: 2em; margin: 0" /> | ||||
| 							Get Linux 64-bit AppImage | ||||
| 						</a> | ||||
| 					</p> | ||||
| 				</div> | ||||
| 				<div class="w3-col l4 m6"> | ||||
| 					<img src="tildefriends.png" class="w3-image w3-right w3-hide-small" /> | ||||
| @@ -100,15 +78,119 @@ | ||||
| 					<h2>First-time user checklist:</h2> | ||||
| 					<ol type="1" style="text-align: left"> | ||||
| 						<li> | ||||
| 							<a href="https://dev.tildefriends.net/cory/tildefriends/releases" | ||||
| 							<a | ||||
| 								href="https://dev.tildefriends.net/cory/tildefriends/releases/latest" | ||||
| 								>Download</a | ||||
| 							> | ||||
| 							Tilde Friends or use | ||||
| 							<a href="https://www.tildefriends.net/" | ||||
| 								>https://www.tildefriends.net/</a | ||||
| 							>. | ||||
| 							<div class="w3-cell-row"> | ||||
| 								<div class="w3-container w3-cell w3-mobile"> | ||||
| 									<h3>Mobile</h3> | ||||
| 									<p> | ||||
| 										<a | ||||
| 											class="w3-button w3-round-large w3-padding w3-blue-gray w3-margin-top" | ||||
| 											href="https://f-droid.org/en/packages/com.unprompted.tildefriends.fdroid/" | ||||
| 											><img src="f-droid.svg" style="height: 2em; margin: 0" /> | ||||
| 											Get it on F-Droid</a | ||||
| 										> | ||||
| 										<a | ||||
| 											class="w3-button w3-round-large w3-padding w3-blue-gray w3-margin-top" | ||||
| 											href="https://play.google.com/store/apps/details?id=com.unprompted.tildefriends" | ||||
| 										> | ||||
| 											<img | ||||
| 												src="googleplay.svg" | ||||
| 												style="height: 2em; margin: 0" | ||||
| 											/> | ||||
| 											Get it on Google Play | ||||
| 										</a> | ||||
| 										<a | ||||
| 											class="w3-button w3-round-large w3-padding w3-blue-gray w3-margin-top" | ||||
| 											href="https://testflight.apple.com/join/tXxgtSpE" | ||||
| 										> | ||||
| 											<img src="ios.svg" style="height: 2em; margin: 0" /> | ||||
| 											Get it on iOS (TestFlight) | ||||
| 										</a> | ||||
| 									</p> | ||||
| 									<p>Just launch the app.</p> | ||||
| 								</div> | ||||
| 								<div class="w3-container w3-cell w3-mobile"> | ||||
| 									<h3>Web</h3> | ||||
| 									<p> | ||||
| 										<a | ||||
| 											class="w3-button w3-round-large w3-blue w3-padding-large" | ||||
| 											href="https://www.tildefriends.net/~core/ssb/" | ||||
| 											>🦀 Try It</a | ||||
| 										> | ||||
| 									</p> | ||||
| 									<p> | ||||
| 										<a href="/login?return=/~core/intro" | ||||
| 											>Register an account with tildefriends.net</a | ||||
| 										> | ||||
| 										to take it for a spin right away. | ||||
| 									</p> | ||||
| 									<h3>PeachCloud</h3> | ||||
| 									<p> | ||||
| 										Tilde Friends is also a part of 🍑☁️<a | ||||
| 											href="https://peach-docs.commoninternet.net/" | ||||
| 											>PeachCloud</a | ||||
| 										>, which is available on | ||||
| 										<a href="https://apps.yunohost.org/app/peachpub" | ||||
| 											>YunoHost</a | ||||
| 										> | ||||
| 										for accessible self-hosting. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 								<div class="w3-container w3-cell w3-mobile"> | ||||
| 									<h3>Desktop</h3> | ||||
| 									<p> | ||||
| 										<a | ||||
| 											class="w3-button w3-round-large w3-black w3-padding-large" | ||||
| 											href="https://dev.tildefriends.net/cory/tildefriends/releases" | ||||
| 											><i class="fa fa-download"></i> Download</a | ||||
| 										> | ||||
| 										<a | ||||
| 											class="w3-button w3-round-large w3-padding w3-blue-gray" | ||||
| 											href="https://dev.tildefriends.net/releases/tildefriends-x86_64.AppImage" | ||||
| 										> | ||||
| 											<img src="appimage.svg" style="height: 2em; margin: 0" /> | ||||
| 											Get Linux 64-bit AppImage | ||||
| 										</a> | ||||
| 									</p> | ||||
| 									<p> | ||||
| 										Tilde Friends is distributed as a single executable file (or | ||||
| 										source that you can | ||||
| 										<a href="http://dev.tildefriends.net">build yourself</a>) | ||||
| 										and stores all of its data in a single | ||||
| 										file(<code>db.sqlite</code>). You can generally download the | ||||
| 										latest executable from | ||||
| 										<a | ||||
| 											href="https://dev.tildefriends.net/cory/tildefriends/releases" | ||||
| 											>releases</a | ||||
| 										> | ||||
| 										for your platform, mark it as executable (<code | ||||
| 											>chmod +x tildefriends*</code | ||||
| 										> | ||||
| 										on macOS and Linux), and run. Run with <code>-h</code> to | ||||
| 										learn more. | ||||
| 									</p> | ||||
| 									<p> | ||||
| 										Tilde Friends will run in the console and provide a web | ||||
| 										interface at | ||||
| 										<a href="http://localhost:12345/">http://localhost:12345/</a | ||||
| 										>. You will have to register a username and password to sign | ||||
| 										into your instance. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							<p> | ||||
| 								After a <a href="/~core/intro">brief introduction</a>, Tilde | ||||
| 								Friends will take you to the Secure Scuttlebutt social network | ||||
| 								app. | ||||
| 							</p> | ||||
| 						</li> | ||||
| 						<li>Create an account to identify yourself with that instance.</li> | ||||
| 						<li> | ||||
| 							Describe yourself in your profile in the <b>ssb</b> app. Give | ||||
| 							yourself a name and an avatar if you like. | ||||
| @@ -142,11 +224,11 @@ | ||||
| 		<!-- SSB Section --> | ||||
| 		<div class="w3-light-grey"> | ||||
| 			<div class="w3-row-padding w3-padding-64"> | ||||
| 				<div class="w3-col l4 m6 s4"> | ||||
| 				<div class="w3-col l4 m6 s4 w3-center"> | ||||
| 					<a href="https://scuttlebutt.nz/" | ||||
| 						><img | ||||
| 							class="w3-image w3-round-large" | ||||
| 							src="ssb.png" | ||||
| 							class="w3-image" | ||||
| 							src="hermietildefriends.svg" | ||||
| 							alt="Secure Scuttlebutt" | ||||
| 					/></a> | ||||
| 				</div> | ||||
| @@ -216,16 +298,15 @@ | ||||
|  | ||||
| 		<!-- Technlology Section --> | ||||
| 		<div class="w3-container w3-padding-64 w3-light-grey w3-center"> | ||||
| 			<h1 class="w3-jumbo"><b>Boring Technology</b></h1> | ||||
| 			<h1 class="w3-jumbo"><b>Built to Last</b></h1> | ||||
| 			<p> | ||||
| 				Tilde Friends is built using boring, trusted tech. Unless a better | ||||
| 				reason presents itself, it strives to use only simple and widely adopted | ||||
| 				dependencies in order to keep it easy to build for all sorts of | ||||
| 				platforms and maintainable for a very long time. | ||||
| 				Tilde Friends strives to use only simple and widely adopted dependencies | ||||
| 				in order to keep it easy to build for all sorts of platforms and | ||||
| 				maintainable for a very long time. | ||||
| 			</p> | ||||
| 			<p> | ||||
| 				Though of course for building Tilde Friends apps, you are free to use | ||||
| 				whatever fits. | ||||
| 				whatever fits on top. | ||||
| 			</p> | ||||
|  | ||||
| 			<div class="w3-row" style="margin-top: 64px"> | ||||
| @@ -259,10 +340,6 @@ | ||||
| 					<i class="fa fa-lock w3-text-purple w3-jumbo"></i> | ||||
| 					<p>libsodium</p> | ||||
| 				</a> | ||||
| 				<a href="https://github.com/openssl/openssl/releases" class="w3-col s3"> | ||||
| 					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i> | ||||
| 					<p>OpenSSL</p> | ||||
| 				</a> | ||||
| 				<a | ||||
| 					href="https://github.com/ianlancetaylor/libbacktrace" | ||||
| 					class="w3-col s3" | ||||
| @@ -270,13 +347,13 @@ | ||||
| 					<i class="fa fa-burst w3-text-pink w3-jumbo"></i> | ||||
| 					<p>libbacktrace</p> | ||||
| 				</a> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="w3-row" style="margin-top: 64px"> | ||||
| 				<a href="https://codemirror.net/docs/changelog/" class="w3-col s3"> | ||||
| 					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i> | ||||
| 					<p>CodeMirror</p> | ||||
| 				</a> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="w3-row" style="margin-top: 64px"> | ||||
| 				<a href="https://github.com/jlfwong/speedscope/" class="w3-col s3"> | ||||
| 					<i class="fa fa-microscope w3-text-orange w3-jumbo"></i> | ||||
| 					<p>Speedscope</p> | ||||
| @@ -289,9 +366,6 @@ | ||||
| 					<i class="fa fa-book-atlas w3-text-purple w3-jumbo"></i> | ||||
| 					<p>c-ares</p> | ||||
| 				</a> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="w3-row" style="margin-top: 64px"> | ||||
| 				<a href="https://www.gnu.org/software/make/" class="w3-col s3"> | ||||
| 					<i class="fa fa-hammer w3-text-teal w3-jumbo"></i> | ||||
| 					<p>GNU Make</p> | ||||
|   | ||||
							
								
								
									
										3
									
								
								apps/welcome/ios.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000"> | ||||
|   <path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 660 B | 
| Before Width: | Height: | Size: 50 KiB | 
| Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB | 
| @@ -1,4 +1,4 @@ | ||||
| /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ | ||||
| /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */ | ||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||
| /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ | ||||
| html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} | ||||
| @@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} | ||||
| .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} | ||||
| .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} | ||||
| .w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex} | ||||
| .w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic} | ||||
| .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} | ||||
| .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} | ||||
| .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} | ||||
| @@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-button:hover{color:#000!important;background-color:#ccc!important} | ||||
| .w3-transparent,.w3-hover-none:hover{background-color:transparent!important} | ||||
| .w3-hover-none:hover{box-shadow:none!important} | ||||
| .w3-rtl{direction:rtl}.w3-ltr{direction:ltr} | ||||
| /* Colors */ | ||||
| .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} | ||||
| .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} | ||||
| @@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} | ||||
| .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} | ||||
| .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} | ||||
| .w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important} | ||||
| .w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important} | ||||
| .w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important} | ||||
| .w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important} | ||||
| .w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important} | ||||
| .w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important} | ||||
| .w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important} | ||||
| .w3-danger{color:#fff!important;background-color:#dd0000!important} | ||||
| .w3-note{color:#000!important;background-color:#fff599!important} | ||||
| .w3-info{color:#fff!important;background-color:#0a6fc2!important} | ||||
| .w3-warning{color:#000!important;background-color:#ffb305!important} | ||||
| .w3-success{color:#fff!important;background-color:#008a00!important} | ||||
| .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} | ||||
| .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} | ||||
| .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} | ||||
| @@ -232,4 +248,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | ||||
| .w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important} | ||||
| .w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important} | ||||
| .w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
| .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| { | ||||
| 	"type": "tildefriends-app", | ||||
| 	"emoji": "📝", | ||||
| 	"previous": "&DaYqKHRBKhjFGaOzbKZ1+/pLspJeEkDJYTF2B50tH6k=.sha256" | ||||
| 	"previous": "&4UHlsfQJvSh7L3D86uFtr7KUKCMRVBBTFxRIMqIc5as=.sha256" | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								apps/wiki/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -2,8 +2,8 @@ import * as utils from './utils.js'; | ||||
| import * as commonmark from './commonmark.min.js'; | ||||
|  | ||||
| function markdown(md) { | ||||
| 	let reader = new commonmark.Parser({safe: true}); | ||||
| 	let writer = new commonmark.HtmlRenderer(); | ||||
| 	let reader = new commonmark.Parser(); | ||||
| 	let writer = new commonmark.HtmlRenderer({safe: true}); | ||||
| 	let parsed = reader.parse(md || ''); | ||||
| 	let walker = parsed.walker(); | ||||
| 	let event; | ||||
|   | ||||
							
								
								
									
										42
									
								
								apps/wiki/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -20,8 +20,8 @@ class TfWikiDocElement extends LitElement { | ||||
| 	} | ||||
|  | ||||
| 	markdown(md) { | ||||
| 		let reader = new commonmark.Parser({safe: true}); | ||||
| 		let writer = new commonmark.HtmlRenderer(); | ||||
| 		let reader = new commonmark.Parser(); | ||||
| 		let writer = new commonmark.HtmlRenderer({safe: true}); | ||||
| 		let parsed = reader.parse(md || ''); | ||||
| 		let walker = parsed.walker(); | ||||
| 		let event; | ||||
|   | ||||
							
								
								
									
										138
									
								
								core/app.js
									
									
									
									
									
								
							
							
						
						| @@ -1,53 +1,48 @@ | ||||
| /** | ||||
|  * \file | ||||
|  * \defgroup tfapp Tilde Friends App JS | ||||
|  * Tilde Friends server-side app wrapper. | ||||
|  * @{ | ||||
|  */ | ||||
|  | ||||
| /** \cond */ | ||||
| import * as core from './core.js'; | ||||
|  | ||||
| let g_next_id = 1; | ||||
| let g_calls = {}; | ||||
| export {App}; | ||||
| /** \endcond */ | ||||
|  | ||||
| let gSessionIndex = 0; | ||||
| /** A sequence number of apps. */ | ||||
| let g_session_index = 0; | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @returns | ||||
|  */ | ||||
| function makeSessionId() { | ||||
| 	return (gSessionIndex++).toString(); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @returns | ||||
|  ** App constructor. | ||||
|  ** @return An app instance. | ||||
|  */ | ||||
| function App() { | ||||
| 	this._on_output = null; | ||||
| 	this._send_queue = []; | ||||
| 	this.calls = {}; | ||||
| 	this._next_call_id = 1; | ||||
| 	return this; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} callback | ||||
|  */ | ||||
| App.prototype.readOutput = function (callback) { | ||||
| 	this._on_output = callback; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} api | ||||
|  * @returns | ||||
|  ** Create a function wrapper that when called invokes a function on the app | ||||
|  ** itself. | ||||
|  ** @param api The function and argument names. | ||||
|  ** @return A function. | ||||
|  */ | ||||
| App.prototype.makeFunction = function (api) { | ||||
| 	let self = this; | ||||
| 	let result = function () { | ||||
| 		let id = g_next_id++; | ||||
| 		while (!id || g_calls[id]) { | ||||
| 			id = g_next_id++; | ||||
| 		let id = self._next_call_id++; | ||||
| 		while (!id || self.calls[id]) { | ||||
| 			id = self._next_call_id++; | ||||
| 		} | ||||
| 		let promise = new Promise(function (resolve, reject) { | ||||
| 			g_calls[id] = {resolve: resolve, reject: reject}; | ||||
| 			self.calls[id] = {resolve: resolve, reject: reject}; | ||||
| 		}); | ||||
| 		let message = { | ||||
| 			message: 'tfrpc', | ||||
| 			action: 'tfrpc', | ||||
| 			method: api[0], | ||||
| 			params: [...arguments], | ||||
| 			id: id, | ||||
| @@ -60,8 +55,8 @@ App.prototype.makeFunction = function (api) { | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} message | ||||
|  ** Send a message to the app. | ||||
|  ** @param message The message to send. | ||||
|  */ | ||||
| App.prototype.send = function (message) { | ||||
| 	if (this._send_queue) { | ||||
| @@ -78,12 +73,11 @@ App.prototype.send = function (message) { | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} request | ||||
|  * @param {*} response | ||||
|  * @param {*} client | ||||
|  ** App socket handler. | ||||
|  ** @param request The HTTP request of the WebSocket connection. | ||||
|  ** @param response The HTTP response. | ||||
|  */ | ||||
| async function socket(request, response, client) { | ||||
| exports.app_socket = async function socket(request, response) { | ||||
| 	let process; | ||||
| 	let options = {}; | ||||
| 	let credentials = await httpd.auth_query(request.headers); | ||||
| @@ -103,10 +97,16 @@ async function socket(request, response, client) { | ||||
| 			try { | ||||
| 				message = JSON.parse(event.data); | ||||
| 			} catch (error) { | ||||
| 				print('ERROR', error, event.data, event.data.length, event.opCode); | ||||
| 				print( | ||||
| 					'WebSocket error:', | ||||
| 					error, | ||||
| 					event.data, | ||||
| 					event.data.length, | ||||
| 					event.opCode | ||||
| 				); | ||||
| 				return; | ||||
| 			} | ||||
| 			if (message.action == 'hello') { | ||||
| 			if (!process && message.action == 'hello') { | ||||
| 				let packageOwner; | ||||
| 				let packageName; | ||||
| 				let blobId; | ||||
| @@ -123,7 +123,7 @@ async function socket(request, response, client) { | ||||
| 					if (!blobId) { | ||||
| 						response.send( | ||||
| 							JSON.stringify({ | ||||
| 								message: 'tfrpc', | ||||
| 								action: 'tfrpc', | ||||
| 								method: 'error', | ||||
| 								params: [message.path + ' not found'], | ||||
| 								id: -1, | ||||
| @@ -149,7 +149,7 @@ async function socket(request, response, client) { | ||||
| 								parentApp: parentApp, | ||||
| 								id: blobId, | ||||
| 							}, | ||||
| 							await ssb.getIdentityInfo( | ||||
| 							await ssb_internal.getIdentityInfo( | ||||
| 								credentials?.session?.name, | ||||
| 								packageOwner, | ||||
| 								packageName | ||||
| @@ -164,7 +164,7 @@ async function socket(request, response, client) { | ||||
| 				options.packageOwner = packageOwner; | ||||
| 				options.packageName = packageName; | ||||
| 				options.url = message.url; | ||||
| 				let sessionId = makeSessionId(); | ||||
| 				let sessionId = 'session_' + (g_session_index++).toString(); | ||||
| 				if (blobId) { | ||||
| 					if (message.edit_only) { | ||||
| 						response.send( | ||||
| @@ -172,17 +172,28 @@ async function socket(request, response, client) { | ||||
| 							0x1 | ||||
| 						); | ||||
| 					} else { | ||||
| 						process = await core.getSessionProcessBlob( | ||||
| 							blobId, | ||||
| 							sessionId, | ||||
| 							options | ||||
| 						); | ||||
| 						process = await core.getProcessBlob(blobId, sessionId, options); | ||||
| 					} | ||||
| 				} | ||||
| 				if (process) { | ||||
| 					process.app.readOutput(function (message) { | ||||
| 					process.client_api.tfrpc = function (message) { | ||||
| 						if (message.id) { | ||||
| 							let calls = process?.app?.calls; | ||||
| 							if (calls) { | ||||
| 								let call = calls[message.id]; | ||||
| 								if (call) { | ||||
| 									if (message.error !== undefined) { | ||||
| 										call.reject(message.error); | ||||
| 									} else { | ||||
| 										call.resolve(message.result); | ||||
| 									} | ||||
| 									delete calls[message.id]; | ||||
| 								} | ||||
| 							} | ||||
| 						} | ||||
| 					}; | ||||
| 					process.app._on_output = (message) => | ||||
| 						response.send(JSON.stringify(message), 0x1); | ||||
| 					}); | ||||
| 					process.app.send(); | ||||
| 				} | ||||
|  | ||||
| @@ -211,26 +222,13 @@ async function socket(request, response, client) { | ||||
| 				if (process && process.timeout > 0) { | ||||
| 					setTimeout(ping, process.timeout); | ||||
| 				} | ||||
| 			} else if (message.action == 'resetPermission') { | ||||
| 				if (process) { | ||||
| 					process.resetPermission(message.permission); | ||||
| 				} | ||||
| 			} else if (message.action == 'setActiveIdentity') { | ||||
| 				process.setActiveIdentity(message.identity); | ||||
| 			} else if (message.action == 'createIdentity') { | ||||
| 				await process.createIdentity(); | ||||
| 			} else if (message.message == 'tfrpc') { | ||||
| 				if (message.id && g_calls[message.id]) { | ||||
| 					if (message.error !== undefined) { | ||||
| 						g_calls[message.id].reject(message.error); | ||||
| 					} else { | ||||
| 						g_calls[message.id].resolve(message.result); | ||||
| 					} | ||||
| 					delete g_calls[message.id]; | ||||
| 				} | ||||
| 			} else { | ||||
| 				if (process && process.eventHandlers['message']) { | ||||
| 					await core.invoke(process.eventHandlers['message'], [message]); | ||||
| 				if (process) { | ||||
| 					if (process.client_api[message.action]) { | ||||
| 						process.client_api[message.action](message); | ||||
| 					} else if (process.eventHandlers['message']) { | ||||
| 						await core.invoke(process.eventHandlers['message'], [message]); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} else if (event.opCode == 0x8) { | ||||
| @@ -249,6 +247,6 @@ async function socket(request, response, client) { | ||||
| 	}; | ||||
|  | ||||
| 	response.upgrade(100, {}); | ||||
| } | ||||
| }; | ||||
|  | ||||
| export {socket, App}; | ||||
| /** @} */ | ||||
|   | ||||
							
								
								
									
										722
									
								
								core/client.js
									
									
									
									
									
								
							
							
						
						
							
								
								
									
										994
									
								
								core/core.js
									
									
									
									
									
								
							
							
						
						
							
								
								
									
										44
									
								
								core/form.js
									
									
									
									
									
								
							
							
						
						| @@ -1,44 +0,0 @@ | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} encoded | ||||
|  * @returns | ||||
|  */ | ||||
| function decode(encoded) { | ||||
| 	let result = ''; | ||||
| 	for (let i = 0; i < encoded.length; i++) { | ||||
| 		let c = encoded[i]; | ||||
| 		if (c == '+') { | ||||
| 			result += ' '; | ||||
| 		} else if (c == '%') { | ||||
| 			result += String.fromCharCode(parseInt(encoded.slice(i + 1, i + 3), 16)); | ||||
| 			i += 2; | ||||
| 		} else { | ||||
| 			result += c; | ||||
| 		} | ||||
| 	} | ||||
| 	return result; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} encoded | ||||
|  * @param {*} initial | ||||
|  * @returns | ||||
|  */ | ||||
| function decodeForm(encoded, initial) { | ||||
| 	let result = initial || {}; | ||||
| 	if (encoded) { | ||||
| 		encoded = encoded.trim(); | ||||
| 		let items = encoded.split('&'); | ||||
| 		for (let i = 0; i < items.length; i++) { | ||||
| 			let item = items[i]; | ||||
| 			let equals = item.indexOf('='); | ||||
| 			let key = decode(item.slice(0, equals)); | ||||
| 			let value = decode(item.slice(equals + 1)); | ||||
| 			result[key] = value; | ||||
| 		} | ||||
| 	} | ||||
| 	return result; | ||||
| } | ||||
|  | ||||
| export {decodeForm}; | ||||
							
								
								
									
										113
									
								
								core/http.js
									
									
									
									
									
								
							
							
						
						| @@ -1,113 +0,0 @@ | ||||
| /** | ||||
|  * TODOC | ||||
|  * TODO: document so we can improve this | ||||
|  * @param {*} url | ||||
|  * @returns | ||||
|  */ | ||||
| function parseUrl(url) { | ||||
| 	// XXX: Hack. | ||||
| 	let match = url.match(new RegExp('(\\w+)://([^/:]+)(?::(\\d+))?(.*)')); | ||||
| 	return { | ||||
| 		protocol: match[1], | ||||
| 		host: match[2], | ||||
| 		path: match[4], | ||||
| 		port: match[3] ? parseInt(match[3]) : match[1] == 'http' ? 80 : 443, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} data | ||||
|  * @returns | ||||
|  */ | ||||
| function parseResponse(data) { | ||||
| 	let firstLine; | ||||
| 	let headers = {}; | ||||
| 	while (true) { | ||||
| 		let endLine = data.indexOf('\r\n'); | ||||
| 		let line = data.substring(0, endLine); | ||||
| 		data = data.substring(endLine + 2); | ||||
| 		if (!line.length) { | ||||
| 			break; | ||||
| 		} else if (!firstLine) { | ||||
| 			firstLine = line; | ||||
| 		} else { | ||||
| 			let colon = line.indexOf(':'); | ||||
| 			headers[line.substring(colon)] = line.substring(colon + 1); | ||||
| 		} | ||||
| 	} | ||||
| 	return {body: data}; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} url | ||||
|  * @param {*} options | ||||
|  * @param {*} allowed_hosts | ||||
|  * @returns | ||||
|  */ | ||||
| export function fetch(url, options, allowed_hosts) { | ||||
| 	let parsed = parseUrl(url); | ||||
| 	return new Promise(function (resolve, reject) { | ||||
| 		if ((allowed_hosts ?? []).indexOf(parsed.host) == -1) { | ||||
| 			throw new Error(`fetch() request to host ${parsed.host} is not allowed.`); | ||||
| 		} | ||||
| 		let socket = new Socket(); | ||||
| 		let buffer = new Uint8Array(0); | ||||
|  | ||||
| 		return socket | ||||
| 			.connect(parsed.host, parsed.port) | ||||
| 			.then(function () { | ||||
| 				socket.read(function (data) { | ||||
| 					if (data && data.length) { | ||||
| 						let newBuffer = new Uint8Array(buffer.length + data.length); | ||||
| 						newBuffer.set(buffer, 0); | ||||
| 						newBuffer.set(data, buffer.length); | ||||
| 						buffer = newBuffer; | ||||
| 					} else { | ||||
| 						let result = parseHttpResponse(buffer); | ||||
| 						if (!result) { | ||||
| 							reject(new Exception('Parse failed.')); | ||||
| 						} | ||||
| 						if (typeof result == 'number') { | ||||
| 							if (result == -2) { | ||||
| 								reject('Incomplete request.'); | ||||
| 							} else { | ||||
| 								reject('Bad request.'); | ||||
| 							} | ||||
| 						} else if (typeof result == 'object') { | ||||
| 							resolve({ | ||||
| 								body: buffer.slice(result.bytes_parsed), | ||||
| 								status: result.status, | ||||
| 								message: result.message, | ||||
| 								headers: result.headers, | ||||
| 							}); | ||||
| 						} else { | ||||
| 							reject(new Exception('Unexpected parse result.')); | ||||
| 						} | ||||
| 						resolve(parseResponse(utf8Decode(buffer))); | ||||
| 					} | ||||
| 				}); | ||||
|  | ||||
| 				if (parsed.port == 443) { | ||||
| 					return socket.startTls(); | ||||
| 				} | ||||
| 			}) | ||||
| 			.then(function () { | ||||
| 				let body = | ||||
| 					typeof options?.body == 'string' | ||||
| 						? utf8Encode(options.body) | ||||
| 						: options.body || new Uint8Array(0); | ||||
| 				let headers = utf8Encode( | ||||
| 					`${options?.method ?? 'GET'} ${parsed.path} HTTP/1.0\r\nHost: ${parsed.host}\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n` | ||||
| 				); | ||||
| 				let fullRequest = new Uint8Array(headers.length + body.length); | ||||
| 				fullRequest.set(headers, 0); | ||||
| 				fullRequest.set(body, headers.length); | ||||
| 				socket.write(fullRequest); | ||||
| 			}) | ||||
| 			.catch(function (error) { | ||||
| 				reject(error); | ||||
| 			}); | ||||
| 	}); | ||||
| } | ||||
| @@ -6,6 +6,26 @@ | ||||
| 		<link type="text/css" rel="stylesheet" href="/static/w3.css" /> | ||||
| 		<link type="image/svg+xml" rel="icon" href="/static/tildefriends.svg" /> | ||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
| 		<meta | ||||
| 			name="title" | ||||
| 			content="Tilde Friends - Make friends and apps from your web browser." | ||||
| 		/> | ||||
| 		<meta | ||||
| 			name="description" | ||||
| 			content="Tilde Friends is a Secure Scuttlebutt client and a platform for building, running, and sharing web applications. " | ||||
| 		/> | ||||
| 		<meta property="og:type" content="website" /> | ||||
| 		<meta property="og:url" content="https://metatags.io/" /> | ||||
| 		<meta | ||||
| 			property="og:title" | ||||
| 			content="Tilde Friends - Make friends and apps from your web browser." | ||||
| 		/> | ||||
| 		<meta | ||||
| 			property="og:description" | ||||
| 			content="Tilde Friends is a Secure Scuttlebutt client and a platform for building, running, and sharing web applications. " | ||||
| 		/> | ||||
| 		<meta property="og:image" content="/static/tildefriends.svg" /> | ||||
|  | ||||
| 		<script> | ||||
| 			function set_access_key_title(event) { | ||||
| 				if (!event.srcElement.title) { | ||||
| @@ -18,8 +38,8 @@ | ||||
| 		style=" | ||||
| 			display: flex; | ||||
| 			flex-flow: column; | ||||
| 			width: 100vw; | ||||
| 			height: 100vh; | ||||
| 			width: 100%; | ||||
| 			height: 100%; | ||||
| 			position: absolute; | ||||
| 			max-width: 100%; | ||||
| 			max-height: 100%; | ||||
|   | ||||
| @@ -7,8 +7,8 @@ html { | ||||
|  | ||||
| body { | ||||
| 	font-family: monospace; | ||||
| 	background-color: #002b36; | ||||
| 	color: #eee8d5; | ||||
| 	background-color: #444; | ||||
| 	color: #fff; | ||||
| 	width: 100%; | ||||
| 	height: 100%; | ||||
| 	padding: 0; | ||||
|   | ||||
| @@ -1,11 +1,22 @@ | ||||
| /** | ||||
|  * \file | ||||
|  * \defgroup tfrpc Tilde Friends RPC. | ||||
|  * Tilde Friends RPC. | ||||
|  * @{ | ||||
|  */ | ||||
|  | ||||
| /** Whether this module is being run in a web browser. */ | ||||
| const k_is_browser = get_is_browser(); | ||||
| /** Registered methods. */ | ||||
| let g_api = {}; | ||||
| /** The next method identifier. */ | ||||
| let g_next_id = 1; | ||||
| /** Identifiers of pending calls. */ | ||||
| let g_calls = {}; | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @returns | ||||
|  * Check if being called from a browser vs. server-side. | ||||
|  * @return true if called from a browser. | ||||
|  */ | ||||
| function get_is_browser() { | ||||
| 	try { | ||||
| @@ -15,16 +26,30 @@ function get_is_browser() { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| /** \cond */ | ||||
| if (k_is_browser) { | ||||
| 	print = console.log; | ||||
| } | ||||
|  | ||||
| if (k_is_browser) { | ||||
| 	window.addEventListener('message', function (event) { | ||||
| 		call_rpc(event.data); | ||||
| 	}); | ||||
| } else { | ||||
| 	core.register('message', function (message) { | ||||
| 		call_rpc(message?.message); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export let rpc = new Proxy({}, {get: make_rpc}); | ||||
| /** \endcond */ | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} target | ||||
|  * @param {*} prop | ||||
|  * @param {*} receiver | ||||
|  * @returns | ||||
|  * Make a function to invoke a remote procedure. | ||||
|  * @param target The target. | ||||
|  * @param prop The name of the function. | ||||
|  * @param receiver The receiver. | ||||
|  * @return A function. | ||||
|  */ | ||||
| function make_rpc(target, prop, receiver) { | ||||
| 	return function () { | ||||
| @@ -55,8 +80,8 @@ function make_rpc(target, prop, receiver) { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} response | ||||
|  * Send a response. | ||||
|  * @param response The response. | ||||
|  */ | ||||
| function send(response) { | ||||
| 	if (k_is_browser) { | ||||
| @@ -67,8 +92,8 @@ function send(response) { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} message | ||||
|  * Invoke a remote procedure. | ||||
|  * @param message An object describing the call. | ||||
|  */ | ||||
| function call_rpc(message) { | ||||
| 	if (message && message.message === 'tfrpc') { | ||||
| @@ -112,22 +137,12 @@ function call_rpc(message) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| if (k_is_browser) { | ||||
| 	window.addEventListener('message', function (event) { | ||||
| 		call_rpc(event.data); | ||||
| 	}); | ||||
| } else { | ||||
| 	core.register('message', function (message) { | ||||
| 		call_rpc(message?.message); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export let rpc = new Proxy({}, {get: make_rpc}); | ||||
|  | ||||
| /** | ||||
|  * TODOC | ||||
|  * @param {*} method | ||||
|  * Register a function that to be called remotely. | ||||
|  * @param method The method. | ||||
|  */ | ||||
| export function register(method) { | ||||
| 	g_api[method.name] = method; | ||||
| } | ||||
|  | ||||
| /** @} */ | ||||
|   | ||||